Files
securebit-chat/tests/ice-gathering-patience.test.mjs
T
lockbitchat 27279ae7c6
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run
feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1
Adds the Double Ratchet (Signal's design) on top of the existing ECDH session
keys, so message protection no longer rests on one set of keys lasting the whole
conversation. Every message gets its own key, derived through a one-way function
and discarded after use, and each change of direction introduces a fresh ECDH
key pair that re-keys the session root.

The ratchet needed no handshake change: both peers already hold each other's
authenticated ECDH public key, and the safety code compared during verification
covers exactly those keys. Its root is derived from the existing shared secret
through its own branch of the key schedule.

Support is negotiated in the invitation and response and used only when both
sides have it; a peer on an earlier release falls back to per-session keys. The
security panel reports which of the two is actually in force.

Out-of-order delivery is supported within fixed bounds (512 skipped keys per
chain, 1024 retained, five-minute expiry), and inbound frames are authenticated
before any ratchet state is committed, so a malformed frame cannot desynchronise
a live session.

Also in this release:

- Verification is enforced as a gate, not a label: control frames (reconnection
  signalling, call setup, message deletion, delivery receipts) are acted on only
  after both peers have compared the safety code, and verified state is set in a
  single guarded place.
- Chat content reaches the interface through one authenticated path; an older,
  weaker inbound path was retired.
- The security panel measures what it displays — several checks previously
  returned a fixed result and now exercise the subsystem they describe.
- Invitation data is no longer kept in local storage, and entries left by earlier
  versions are cleared on first launch.
- View-once and disappearing messages no longer place their text in system
  notifications.
- Shared-secret buffers are overwritten once derivation completes; scanned QR
  codes are decompressed with a size limit; voice notes are validated against
  audio type and size budgets before skipping the consent prompt; the master
  password is collected by the app rather than a browser dialog.
- Connection setup no longer fails on networks where STUN/TURN are unreachable:
  it proceeds as soon as usable candidates exist and only waits while there are
  none.

Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise
re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds,
tamper resistance, negotiation fallback, and byte-level key-derivation
compatibility with 5.6.0.
2026-08-05 23:02:20 -04:00

151 lines
6.3 KiB
JavaScript

// ICE gathering only reaches 'complete' once every configured STUN/TURN server
// has answered or timed out. On a network that blocks them — a VPN, a captive
// portal, an interface the browser cannot route from — that never happens, even
// though host candidates are available immediately and are enough to connect on
// a LAN.
//
// The old code waited a flat 10 s and then failed the whole handshake if the SDP
// happened to be empty at that instant. That made success a coin flip: the same
// device failed one attempt and connected on the next with gathering still in
// progress (observed in the field, 11 candidates at 10001 ms).
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i?.detail; } };
globalThis.document = { dispatchEvent() {} };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const P = EnhancedSecureWebRTCManager.prototype;
const T = EnhancedSecureWebRTCManager.TIMEOUTS;
// Fake timers so the 10 s / 25 s deadlines can be driven by hand.
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
let timers = [];
globalThis.setTimeout = (fn, delay) => {
const t = { fn, delay, cleared: false };
timers.push(t);
return t;
};
globalThis.clearTimeout = (t) => { if (t) t.cleared = true; };
const fireDelay = (delay) => {
for (const t of timers.filter((x) => !x.cleared && x.delay === delay)) {
t.cleared = true;
t.fn();
}
};
const sdpWithCandidates = (n) =>
'v=0\r\n' + Array.from({ length: n }, (_, i) =>
`a=candidate:${i} 1 udp 2122260223 192.168.1.${i + 2} 5000${i} typ host\r\n`).join('');
function makeManager(candidateCount) {
const listeners = [];
const ui = [];
return {
ui,
listeners,
peerConnection: {
iceGatheringState: 'gathering',
localDescription: { sdp: sdpWithCandidates(candidateCount) },
addEventListener: (name, fn) => listeners.push({ name, fn }),
removeEventListener: () => {}
},
_activeTimers: new Set(),
_secureLog() {},
deliverMessageToUI: (m) => ui.push(m),
_trackActiveTimer: P._trackActiveTimer,
_untrackActiveTimer: P._untrackActiveTimer,
_summarizeIceCandidatesInSDP: P._summarizeIceCandidatesInSDP,
_countIceCandidatesInSDP: P._countIceCandidatesInSDP,
waitForIceGathering: P.waitForIceGathering
};
}
try {
// ── the budget must actually be longer than the soft deadline ────────────
assert.ok(T.ICE_GATHERING_HARD_TIMEOUT > T.ICE_GATHERING_TIMEOUT,
'the hard ceiling must leave room past the soft deadline');
// ── already complete: return immediately, no waiting ─────────────────────
{
timers = [];
const mgr = makeManager(3);
mgr.peerConnection.iceGatheringState = 'complete';
assert.equal(await mgr.waitForIceGathering(), true);
assert.deepEqual(timers.filter((t) => !t.cleared), [], 'no timers left behind');
}
// ── gathering finishes on its own: resolve true and drop the timers ──────
{
timers = [];
const mgr = makeManager(3);
const pending = mgr.waitForIceGathering();
mgr.peerConnection.iceGatheringState = 'complete';
mgr.listeners.forEach((l) => l.fn());
assert.equal(await pending, true);
assert.deepEqual(timers.filter((t) => !t.cleared), [],
'a completed gather must not leave the hard timer armed');
}
// ── soft deadline WITH candidates: stop waiting, report "not complete" ───
// This is the common case on a restricted network, and it must succeed: the
// caller only refuses to export when there is nothing at all.
{
timers = [];
const mgr = makeManager(11);
const pending = mgr.waitForIceGathering();
fireDelay(T.ICE_GATHERING_TIMEOUT);
assert.equal(await pending, false, 'gathering did not complete...');
// ...but the caller's guard is `!completed && count === 0`, so 11
// candidates mean the handshake proceeds.
assert.ok(mgr._summarizeIceCandidatesInSDP(mgr.peerConnection.localDescription.sdp).total > 0);
}
// ── soft deadline with NOTHING: keep waiting instead of failing ──────────
// The regression under test. Previously this resolved at 10 s with an empty
// SDP and the handshake threw.
{
timers = [];
const mgr = makeManager(0);
let settled = false;
const pending = mgr.waitForIceGathering().then((v) => { settled = true; return v; });
fireDelay(T.ICE_GATHERING_TIMEOUT);
await Promise.resolve();
assert.equal(settled, false, 'an empty SDP at the soft deadline must not end the wait');
// A candidate arriving late is exactly what the extra patience buys.
mgr.peerConnection.localDescription.sdp = sdpWithCandidates(4);
mgr.peerConnection.iceGatheringState = 'complete';
mgr.listeners.forEach((l) => l.fn());
assert.equal(await pending, true, 'a late completion must still be picked up');
}
// ── a genuinely dead network still fails, at the hard ceiling ────────────
{
timers = [];
const mgr = makeManager(0);
const pending = mgr.waitForIceGathering();
fireDelay(T.ICE_GATHERING_TIMEOUT);
fireDelay(T.ICE_GATHERING_HARD_TIMEOUT);
assert.equal(await pending, false, 'nothing gathered at all must eventually give up');
}
// ── a caller-supplied budget shorter than the hard default is honoured ───
// Session recovery passes 4 s and must not silently wait 25 s instead.
{
timers = [];
const mgr = makeManager(0);
const pending = mgr.waitForIceGathering(T.ICE_RESTART_GATHERING, T.ICE_RESTART_GATHERING);
fireDelay(T.ICE_RESTART_GATHERING);
assert.equal(await pending, false, 'the recovery path keeps its short budget');
}
console.log('ice-gathering-patience.test.mjs: all assertions passed');
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
}