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.
196 lines
8.6 KiB
JavaScript
196 lines
8.6 KiB
JavaScript
// Regression tests for the SAS-bypass and the unauthenticated control plane.
|
|
//
|
|
// Both bugs shared a root cause: "the peer completed the handshake" was treated
|
|
// as "the peer is who the user thinks it is". It is not — a MITM who sits on the
|
|
// out-of-band invite channel completes the handshake too, and holds the session
|
|
// keys. Only the SAS comparison distinguishes them, so nothing that reshapes the
|
|
// session may happen before it.
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
globalThis.window = {
|
|
EnhancedSecureCryptoUtils: { secureLog: { log() {} } }
|
|
};
|
|
globalThis.CustomEvent = class CustomEvent {
|
|
constructor(type, init) { this.type = type; this.detail = init?.detail; }
|
|
};
|
|
globalThis.document = { dispatchEvent() {} };
|
|
|
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
|
const P = EnhancedSecureWebRTCManager.prototype;
|
|
const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
|
|
|
const FP = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
|
|
const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\n`;
|
|
|
|
// ── the fingerprint comparison must not grant verification ───────────────────
|
|
// sessionMode is 'ratchet' for every session, so this branch ran on every
|
|
// restart round-trip. Because matching fingerprints are the NORMAL case, it
|
|
// effectively meant "any peer that echoes back the identity we already know is
|
|
// verified" — reachable with a single frame, and it bypassed _setVerifiedStatus
|
|
// and therefore the local-SAS-confirmation check it exists to enforce.
|
|
{
|
|
const mgr = {
|
|
sessionMode: 'ratchet',
|
|
isVerified: false,
|
|
_secureLog() {}
|
|
};
|
|
|
|
const same = await P._validateDTLSFingerprint.call(mgr, FP, FP, 'ice_restart_offer');
|
|
assert.equal(same, true, 'identical fingerprints must still compare equal');
|
|
assert.equal(mgr.isVerified, false,
|
|
'comparing fingerprints must never mark the session verified');
|
|
|
|
// And a genuine mismatch must still be refused, loudly.
|
|
await assert.rejects(
|
|
() => P._validateDTLSFingerprint.call(mgr, FP, '00:' + FP.slice(3), 'ice_restart_offer'),
|
|
/mismatch/i,
|
|
'a changed DTLS identity must be refused'
|
|
);
|
|
assert.equal(mgr.isVerified, false);
|
|
}
|
|
|
|
// ── _setVerifiedStatus remains the only way in ───────────────────────────────
|
|
{
|
|
const mgr = {
|
|
isVerified: false,
|
|
encryptionKey: {}, macKey: {},
|
|
localVerificationConfirmed: false,
|
|
keyFingerprint: 'x',
|
|
_secureLog() {},
|
|
onStatusChange() {}
|
|
};
|
|
|
|
assert.throws(
|
|
() => P._setVerifiedStatus.call(mgr, true, 'MUTUAL_SAS_CONFIRMED'),
|
|
/local SAS confirmation/i,
|
|
'a SAS-based transition without local confirmation must be refused'
|
|
);
|
|
assert.equal(mgr.isVerified, false);
|
|
|
|
mgr.localVerificationConfirmed = true;
|
|
P._setVerifiedStatus.call(mgr, true, 'MUTUAL_SAS_CONFIRMED');
|
|
assert.equal(mgr.isVerified, true, 'the legitimate path must still work');
|
|
}
|
|
|
|
// ── control frames are refused before verification, honoured after ───────────
|
|
{
|
|
const makeChannelManager = (isVerified) => {
|
|
const seen = { deleted: [], delivered: [], call: [], ice: [] };
|
|
const mgr = {
|
|
isVerified,
|
|
_secureLog() {},
|
|
_noteInboundActivity() {},
|
|
_enforceVerificationGate: P._enforceVerificationGate,
|
|
establishConnection: async () => {},
|
|
initializeFileTransfer() {},
|
|
_notifyVerificationReadyIfPossible() {},
|
|
initiateVerification() {},
|
|
processMessageQueue() {},
|
|
onStatusChange() {},
|
|
_resetReconnectState() {},
|
|
_teardownRecoveryLifecycleListeners() {},
|
|
startHeartbeat() {},
|
|
// The verified branch of the open handler schedules these on a timer.
|
|
calculateAndReportSecurityLevel: async () => {},
|
|
autoEnableSecurityFeatures() {},
|
|
notifySecurityUpdate() {},
|
|
pendingSASCode: null,
|
|
onMessageDelete: (id) => seen.deleted.push(id),
|
|
onMessageDelivered: (id) => seen.delivered.push(id),
|
|
_handleCallSignal: async (type) => { seen.call.push(type); },
|
|
_handleIceRestartSignal: async (type) => { seen.ice.push(type); },
|
|
setupDataChannel: P.setupDataChannel
|
|
};
|
|
const channel = { readyState: 'open', send() {} };
|
|
mgr.setupDataChannel(channel);
|
|
return { mgr, channel, seen };
|
|
};
|
|
|
|
const frames = [
|
|
[T.ICE_RESTART_OFFER, { sdp: sdpWith(FP) }],
|
|
[T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP) }],
|
|
[T.ICE_RESTART_REQUEST, {}],
|
|
[T.CALL_OFFER, { sdp: sdpWith(FP), callId: 'c1' }],
|
|
[T.CALL_ANSWER, { sdp: sdpWith(FP) }],
|
|
[T.CALL_ICE, { candidate: {} }],
|
|
[T.CALL_DECLINE, {}],
|
|
[T.CALL_END, {}],
|
|
[T.MESSAGE_DELETE, { messageId: 'm1' }],
|
|
[T.MESSAGE_RECEIPT, { messageId: 'm1' }]
|
|
];
|
|
|
|
// Unverified: this is the MITM window. Nothing may take effect.
|
|
{
|
|
const { channel, seen } = makeChannelManager(false);
|
|
for (const [type, data] of frames) {
|
|
await channel.onmessage({ data: JSON.stringify({ type, data }) });
|
|
}
|
|
assert.deepEqual(seen.ice, [], 'no ICE restart may be driven before verification');
|
|
assert.deepEqual(seen.call, [], 'no call may be signalled before verification');
|
|
assert.deepEqual(seen.deleted, [], 'no message may be deleted before verification');
|
|
assert.deepEqual(seen.delivered, [], 'no receipt may be forged before verification');
|
|
}
|
|
|
|
// Verified: the features must still work — a gate that breaks the product
|
|
// gets removed by the next person who touches this file.
|
|
{
|
|
const { channel, seen } = makeChannelManager(true);
|
|
for (const [type, data] of frames) {
|
|
await channel.onmessage({ data: JSON.stringify({ type, data }) });
|
|
}
|
|
assert.deepEqual(seen.ice,
|
|
[T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]);
|
|
assert.deepEqual(seen.call,
|
|
[T.CALL_OFFER, T.CALL_ANSWER, T.CALL_ICE, T.CALL_DECLINE, T.CALL_END]);
|
|
assert.deepEqual(seen.deleted, ['m1']);
|
|
assert.deepEqual(seen.delivered, ['m1']);
|
|
}
|
|
|
|
// Every gated type must be in the allowlist, or it silently falls through to
|
|
// the chat channel's default-deny branch and the feature breaks instead.
|
|
for (const [type] of frames) {
|
|
assert.ok(EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(type),
|
|
`${type} must be declared as a post-verification control frame`);
|
|
}
|
|
|
|
// The verification handshake itself must NOT be gated: it has to run before
|
|
// verification exists, otherwise no session could ever be established.
|
|
for (const type of [T.HEARTBEAT, T.VERIFICATION, T.VERIFICATION_RESPONSE,
|
|
T.VERIFICATION_CONFIRMED, T.VERIFICATION_BOTH_CONFIRMED]) {
|
|
assert.equal(EnhancedSecureWebRTCManager.POST_VERIFICATION_CONTROL_TYPES.has(type), false,
|
|
`${type} must stay reachable before verification`);
|
|
}
|
|
}
|
|
|
|
// ── the legacy processMessage pipeline delivers nothing unauthenticated ──────
|
|
// It is unreachable from the live handler today, but it is a second inbound
|
|
// pipeline with weaker rules; if it ever gets called it must not undo the fix.
|
|
{
|
|
const delivered = [];
|
|
const mgr = {
|
|
isVerified: false,
|
|
_secureLog() {},
|
|
_noteInboundActivity() {},
|
|
_checkInboundRateLimit: () => true,
|
|
_enforceVerificationGate: P._enforceVerificationGate,
|
|
onMessage: () => {},
|
|
deliverMessageToUI: (m) => delivered.push(m),
|
|
onMessageDelete: (id) => delivered.push(`delete:${id}`),
|
|
onMessageDelivered: (id) => delivered.push(`receipt:${id}`),
|
|
_handleCallSignal: async () => { delivered.push('call'); },
|
|
_handleIceRestartSignal: async () => { delivered.push('ice'); },
|
|
processMessage: P.processMessage
|
|
};
|
|
|
|
await mgr.processMessage(JSON.stringify({ type: 'message', data: 'injected' }));
|
|
await mgr.processMessage('not json at all');
|
|
await mgr.processMessage(JSON.stringify({ type: T.ICE_RESTART_OFFER, data: { sdp: sdpWith(FP) } }));
|
|
await mgr.processMessage(JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm1' } }));
|
|
|
|
assert.deepEqual(delivered, [],
|
|
'the legacy pipeline must not deliver or act on unauthenticated frames');
|
|
}
|
|
|
|
console.log('control-frame-authorization.test.mjs: all assertions passed');
|