Files
securebit-chat/tests/control-frame-authorization.test.mjs
T

196 lines
8.6 KiB
JavaScript
Raw Normal View History

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