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');
|