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.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 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');
|
||||
@@ -0,0 +1,293 @@
|
||||
// Double Ratchet correctness and its security properties.
|
||||
//
|
||||
// The point of the ratchet is that a key recovered at time T must not open
|
||||
// anything sent before T, and that one exchange in each direction must lock out
|
||||
// an attacker who captured the whole state. Both are asserted here directly,
|
||||
// not inferred from the code shape.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = { document: {} };
|
||||
|
||||
const { DoubleRatchet, RATCHET_LIMITS } = await import('../src/crypto/DoubleRatchet.js');
|
||||
|
||||
const subtle = crypto.subtle;
|
||||
|
||||
/**
|
||||
* The peer's key must arrive the way production delivers it: through
|
||||
* importSignedPublicKey, which imports SPKI as NON-EXTRACTABLE. A generated
|
||||
* public key is always extractable regardless of the flag, so a test that passes
|
||||
* `keyPair.publicKey` straight through exercises a key shape that never occurs
|
||||
* in the app — and misses anything that tries to export it. That is exactly how
|
||||
* a ratchet-setup failure on the initiator reached production.
|
||||
*/
|
||||
async function asReceivedFromPeer(publicKey) {
|
||||
const spki = await subtle.exportKey('spki', publicKey);
|
||||
const imported = await subtle.importKey('spki', spki, { name: 'ECDH', namedCurve: 'P-384' }, false, []);
|
||||
assert.equal(imported.extractable, false, 'the stand-in must be non-extractable, like the real one');
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function makePair() {
|
||||
const alice = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, false, ['deriveKey', 'deriveBits']);
|
||||
const bob = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, false, ['deriveKey', 'deriveBits']);
|
||||
|
||||
const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: bob.publicKey }, alice.privateKey, 256));
|
||||
const sessionSalt = crypto.getRandomValues(new Uint8Array(64));
|
||||
|
||||
const a = new DoubleRatchet();
|
||||
const b = new DoubleRatchet();
|
||||
await a.init({
|
||||
sharedSecret: shared.slice(), sessionSalt, selfPrivateKey: alice.privateKey,
|
||||
remotePublicKey: await asReceivedFromPeer(bob.publicKey), isInitiator: true
|
||||
});
|
||||
await b.init({
|
||||
sharedSecret: shared.slice(), sessionSalt, selfPrivateKey: bob.privateKey,
|
||||
remotePublicKey: await asReceivedFromPeer(alice.publicKey), isInitiator: false
|
||||
});
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
const send = async (from, to, text) => {
|
||||
const { header, ciphertext } = await from.encrypt(text);
|
||||
return { header, ciphertext, open: () => to.decrypt(header, ciphertext) };
|
||||
};
|
||||
|
||||
// ── the basic round trip, in both directions ─────────────────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
|
||||
// The responder cannot speak first: it has no sending chain until the
|
||||
// initiator's first message arrives. This is by design, not a bug.
|
||||
await assert.rejects(() => b.encrypt('too early'), /no sending chain/);
|
||||
|
||||
const m1 = await send(a, b, 'hello bob');
|
||||
assert.equal(await m1.open(), 'hello bob');
|
||||
|
||||
// Now Bob can reply, and doing so introduces his own ratchet key.
|
||||
const m2 = await send(b, a, 'hello alice');
|
||||
assert.equal(await m2.open(), 'hello alice');
|
||||
|
||||
const m3 = await send(a, b, 'how are you');
|
||||
assert.equal(await m3.open(), 'how are you');
|
||||
}
|
||||
|
||||
// ── every message uses a different key ───────────────────────────────────────
|
||||
// Identical plaintexts must not produce identical ciphertexts; if they did, the
|
||||
// chain would not be advancing at all.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { header, ciphertext } = await a.encrypt('same text every time');
|
||||
assert.equal(seen.has(ciphertext), false, `ciphertext repeated at message ${i}`);
|
||||
seen.add(ciphertext);
|
||||
assert.equal(await b.decrypt(header, ciphertext), 'same text every time');
|
||||
}
|
||||
}
|
||||
|
||||
// ── FORWARD SECRECY: the current state cannot open earlier messages ──────────
|
||||
// This is the property the audit found missing. Capture a ciphertext, let the
|
||||
// conversation move on, then hand the receiver's live state the old frame: it
|
||||
// must fail, because the key that opened it was destroyed on use.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
|
||||
const early = await a.encrypt('the secret from the start of the session');
|
||||
assert.equal(await b.decrypt(early.header, early.ciphertext), 'the secret from the start of the session');
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const m = await a.encrypt(`later message ${i}`);
|
||||
await b.decrypt(m.header, m.ciphertext);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => b.decrypt(early.header, early.ciphertext),
|
||||
/behind the current chain|authentication failed/,
|
||||
'a compromised current state must not reopen an earlier message'
|
||||
);
|
||||
}
|
||||
|
||||
// ── replay is refused ────────────────────────────────────────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const m = await a.encrypt('deliver once');
|
||||
assert.equal(await b.decrypt(m.header, m.ciphertext), 'deliver once');
|
||||
await assert.rejects(() => b.decrypt(m.header, m.ciphertext), /behind the current chain/);
|
||||
}
|
||||
|
||||
// ── POST-COMPROMISE SECURITY: the DH ratchet re-keys the root ────────────────
|
||||
// After a full exchange in each direction the sending chain must derive from a
|
||||
// DH secret the attacker never saw. Observable proxy: the ratchet public key in
|
||||
// the header changes when the direction turns.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
|
||||
const first = await a.encrypt('one');
|
||||
await b.decrypt(first.header, first.ciphertext);
|
||||
const aliceKey1 = JSON.parse(first.header).dh;
|
||||
|
||||
const reply = await b.encrypt('two');
|
||||
await a.decrypt(reply.header, reply.ciphertext);
|
||||
const bobKey1 = JSON.parse(reply.header).dh;
|
||||
assert.notEqual(bobKey1, aliceKey1, 'each side contributes its own ratchet key');
|
||||
|
||||
const third = await a.encrypt('three');
|
||||
await b.decrypt(third.header, third.ciphertext);
|
||||
const aliceKey2 = JSON.parse(third.header).dh;
|
||||
assert.notEqual(aliceKey2, aliceKey1,
|
||||
'replying must adopt a fresh ratchet key — this is what recovers from compromise');
|
||||
|
||||
// Message numbering restarts per chain, and the previous length is carried.
|
||||
assert.equal(JSON.parse(third.header).n, 0);
|
||||
assert.equal(JSON.parse(third.header).pn, 1);
|
||||
}
|
||||
|
||||
// ── out-of-order delivery inside a chain ─────────────────────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const frames = [];
|
||||
for (let i = 0; i < 5; i++) frames.push(await a.encrypt(`m${i}`));
|
||||
|
||||
// Arrive 4, 0, 2, 1, 3.
|
||||
assert.equal(await b.decrypt(frames[4].header, frames[4].ciphertext), 'm4');
|
||||
assert.equal(await b.decrypt(frames[0].header, frames[0].ciphertext), 'm0');
|
||||
assert.equal(await b.decrypt(frames[2].header, frames[2].ciphertext), 'm2');
|
||||
assert.equal(await b.decrypt(frames[1].header, frames[1].ciphertext), 'm1');
|
||||
assert.equal(await b.decrypt(frames[3].header, frames[3].ciphertext), 'm3');
|
||||
assert.equal(b.getState().skippedKeys, 0, 'every retained key must be consumed');
|
||||
}
|
||||
|
||||
// ── out-of-order ACROSS a ratchet step ───────────────────────────────────────
|
||||
// A message from the previous chain arriving after the direction changed is the
|
||||
// case that breaks naive implementations.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
|
||||
const straggler = await a.encrypt('sent before the turn');
|
||||
const delivered = await a.encrypt('delivered first');
|
||||
await b.decrypt(delivered.header, delivered.ciphertext);
|
||||
|
||||
const reply = await b.encrypt('bob replies');
|
||||
await a.decrypt(reply.header, reply.ciphertext);
|
||||
const after = await a.encrypt('new chain');
|
||||
await b.decrypt(after.header, after.ciphertext);
|
||||
|
||||
assert.equal(await b.decrypt(straggler.header, straggler.ciphertext), 'sent before the turn',
|
||||
'a message from the previous chain must still open after a ratchet step');
|
||||
}
|
||||
|
||||
// ── DoS: an attacker cannot make us retain unbounded keys ────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const m = await a.encrypt('probe');
|
||||
const header = JSON.parse(m.header);
|
||||
|
||||
// A single frame claiming a huge message number would otherwise force us to
|
||||
// derive and hold that many keys.
|
||||
const absurd = JSON.stringify({ ...header, n: 5_000_000 });
|
||||
await assert.rejects(
|
||||
() => b.decrypt(absurd, m.ciphertext),
|
||||
/refusing to skip/,
|
||||
'a large forward jump must be refused, not honoured'
|
||||
);
|
||||
|
||||
// Just past the limit is still refused; the limit itself is workable.
|
||||
const overLimit = JSON.stringify({ ...header, n: RATCHET_LIMITS.MAX_SKIP_PER_CHAIN + 1 });
|
||||
await assert.rejects(() => b.decrypt(overLimit, m.ciphertext), /refusing to skip/);
|
||||
|
||||
assert.equal(b.getState().skippedKeys, 0, 'a refused frame must leave no keys behind');
|
||||
}
|
||||
|
||||
// ── the retained-key cache is bounded ────────────────────────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const frames = [];
|
||||
const gap = 200;
|
||||
for (let round = 0; round < 8; round++) {
|
||||
for (let i = 0; i < gap; i++) frames.push(await a.encrypt(`x${round}-${i}`));
|
||||
const marker = await a.encrypt(`marker-${round}`);
|
||||
await b.decrypt(marker.header, marker.ciphertext);
|
||||
}
|
||||
assert.ok(b.getState().skippedKeys <= RATCHET_LIMITS.MAX_SKIPPED_KEYS,
|
||||
`retained keys (${b.getState().skippedKeys}) must stay within the cap`);
|
||||
}
|
||||
|
||||
// ── a tampered header is rejected AND leaves the ratchet intact ──────────────
|
||||
// The header is plaintext on the wire, so this is reachable. The session must
|
||||
// survive it: a bad frame that desynchronised the chains would be a remote
|
||||
// denial of service against an established chat.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const m = await a.encrypt('authentic');
|
||||
const forged = JSON.stringify({ ...JSON.parse(m.header), pn: 99 });
|
||||
|
||||
await assert.rejects(() => b.decrypt(forged, m.ciphertext), /authentication failed/);
|
||||
|
||||
// The genuine frame must still open afterwards.
|
||||
assert.equal(await b.decrypt(m.header, m.ciphertext), 'authentic');
|
||||
|
||||
// And the conversation continues normally.
|
||||
const next = await a.encrypt('still working');
|
||||
assert.equal(await b.decrypt(next.header, next.ciphertext), 'still working');
|
||||
}
|
||||
|
||||
// ── a tampered body is rejected, likewise without side effects ───────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const m = await a.encrypt('authentic body');
|
||||
const flipped = Buffer.from(m.ciphertext, 'base64');
|
||||
flipped[flipped.length - 1] ^= 0xff;
|
||||
|
||||
await assert.rejects(
|
||||
() => b.decrypt(m.header, flipped.toString('base64')),
|
||||
/authentication failed/
|
||||
);
|
||||
assert.equal(await b.decrypt(m.header, m.ciphertext), 'authentic body',
|
||||
'the genuine frame must still open after a forged one');
|
||||
}
|
||||
|
||||
// ── two independent sessions never share ratchet state ───────────────────────
|
||||
{
|
||||
const one = await makePair();
|
||||
const two = await makePair();
|
||||
const m = await one.a.encrypt('for session one');
|
||||
await assert.rejects(
|
||||
() => two.b.decrypt(m.header, m.ciphertext),
|
||||
/authentication failed|behind the current chain|no receiving chain/
|
||||
);
|
||||
}
|
||||
|
||||
// ── destroy() clears the state ───────────────────────────────────────────────
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
const m = await a.encrypt('before destroy');
|
||||
await b.decrypt(m.header, m.ciphertext);
|
||||
|
||||
b.destroy();
|
||||
assert.equal(b.getState().initialised, false);
|
||||
assert.equal(b.getState().skippedKeys, 0);
|
||||
const after = await a.encrypt('after destroy');
|
||||
await assert.rejects(() => b.decrypt(after.header, after.ciphertext), /not initialised/);
|
||||
}
|
||||
|
||||
// ── a long conversation stays in sync ────────────────────────────────────────
|
||||
// Ratchet bugs love to appear at chain boundaries rather than on message two.
|
||||
{
|
||||
const { a, b } = await makePair();
|
||||
let expected = 0;
|
||||
for (let turn = 0; turn < 30; turn++) {
|
||||
const from = turn % 2 === 0 ? a : b;
|
||||
const to = turn % 2 === 0 ? b : a;
|
||||
const burst = 1 + (turn % 4);
|
||||
for (let i = 0; i < burst; i++) {
|
||||
const text = `turn ${turn} message ${i}`;
|
||||
const { header, ciphertext } = await from.encrypt(text);
|
||||
assert.equal(await to.decrypt(header, ciphertext), text);
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(expected > 60, 'the exchange should have covered many chain switches');
|
||||
}
|
||||
|
||||
console.log('double-ratchet.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Each call returns the next queued plaintext, so a flood can be distinguished
|
||||
// message by message rather than all looking alike.
|
||||
let nextPlaintext = 'hello';
|
||||
globalThis.window = {
|
||||
EnhancedSecureCryptoUtils: {
|
||||
async decryptMessage() {
|
||||
return { message: JSON.stringify({ type: 'message', data: 'enhanced hello' }) };
|
||||
return {
|
||||
message: JSON.stringify({ type: 'message', data: nextPlaintext }),
|
||||
messageId: 'msg_1',
|
||||
sequenceNumber: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -18,6 +25,12 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
||||
rateLimitMessagesPerMinute: perMinute,
|
||||
rateLimitBurstSize: burst
|
||||
},
|
||||
encryptionKey: {},
|
||||
macKey: {},
|
||||
metadataKey: {},
|
||||
// Anti-replay is a separate mechanism with its own test; keep this one
|
||||
// focused on rate limiting.
|
||||
_validateIncomingSequenceNumber: () => true,
|
||||
_checkInboundRateLimit: EnhancedSecureWebRTCManager.prototype._checkInboundRateLimit,
|
||||
_secureLog(level, message, context) {
|
||||
this.logs.push({ level, message, context });
|
||||
@@ -29,21 +42,30 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// The rate limiter is exercised through `enhanced_message`, the only frame type
|
||||
// that carries chat content. It used to be driven here through a bare
|
||||
// `{type:'message'}` frame, but those are rejected now: they were
|
||||
// unauthenticated peer input rendered as a real message.
|
||||
const deliver = (manager, text) => {
|
||||
nextPlaintext = text;
|
||||
return EnhancedSecureWebRTCManager.prototype._processEnhancedMessageWithoutMutex.call(
|
||||
manager,
|
||||
{ type: 'enhanced_message', data: 'ciphertext' }
|
||||
);
|
||||
};
|
||||
|
||||
// Normal inbound messages are delivered.
|
||||
{
|
||||
const manager = fakeManager();
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(
|
||||
manager,
|
||||
JSON.stringify({ type: 'message', data: 'hello' })
|
||||
);
|
||||
await deliver(manager, 'hello');
|
||||
assert.deepEqual(manager.delivered, [{ message: 'hello', type: 'received' }]);
|
||||
}
|
||||
|
||||
// Burst floods are dropped safely and logged.
|
||||
{
|
||||
const manager = fakeManager({ burst: 1 });
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
||||
await deliver(manager, 'first');
|
||||
await deliver(manager, 'second');
|
||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||
assert.match(manager.logs.at(-1).message, /Inbound message burst limit exceeded/);
|
||||
}
|
||||
@@ -51,13 +73,24 @@ function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
||||
// Sustained-window floods are rejected independently of burst accounting.
|
||||
{
|
||||
const manager = fakeManager({ perMinute: 1, burst: 10 });
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
||||
await deliver(manager, 'first');
|
||||
manager._inboundRateLimiter.lastBurstReset = Date.now() - 1001;
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
||||
await deliver(manager, 'second');
|
||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||
assert.match(manager.logs.at(-1).message, /Inbound message rate limit exceeded/);
|
||||
}
|
||||
|
||||
// And an unauthenticated frame is refused outright, limiter or no limiter —
|
||||
// rate limiting is not what keeps injected chat text out.
|
||||
{
|
||||
const manager = fakeManager();
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(
|
||||
manager,
|
||||
JSON.stringify({ type: 'message', data: 'injected' })
|
||||
);
|
||||
assert.deepEqual(manager.delivered, [], 'a bare message frame must never be delivered');
|
||||
}
|
||||
|
||||
// Binary and enhanced helpers are guarded before expensive processing.
|
||||
{
|
||||
const binaryManager = {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// deriveSharedKeys() stopped routing the ECDH shared secret through an
|
||||
// extractable AES key + exportKey() (which left the secret sitting in the heap
|
||||
// unwiped) and now uses deriveBits() directly.
|
||||
//
|
||||
// That is only safe if the BYTES are identical: both peers must derive the same
|
||||
// session keys, and a 5.6.1 client has to interoperate with a 5.6.0 one. This is
|
||||
// the test that says so — WebCrypto's ECDH deriveBits(n) returns the leftmost n
|
||||
// bits of the shared X coordinate, which is exactly what deriveKey to
|
||||
// AES-GCM-256 consumed. If a future change bumps 256 to 384 "for strength", this
|
||||
// fails, and it should: that is a protocol break, not an improvement.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
|
||||
const { subtle } = webcrypto;
|
||||
|
||||
const toHex = (buf) => Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
for (const namedCurve of ['P-384', 'P-256']) {
|
||||
const alice = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
|
||||
const bob = await subtle.generateKey({ name: 'ECDH', namedCurve }, false, ['deriveKey', 'deriveBits']);
|
||||
|
||||
// ── the old path: extractable AES key, then exportKey ────────────────────
|
||||
const legacyKey = await subtle.deriveKey(
|
||||
{ name: 'ECDH', public: bob.publicKey },
|
||||
alice.privateKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const legacyBytes = await subtle.exportKey('raw', legacyKey);
|
||||
|
||||
// ── the new path: deriveBits, no extractable key, buffer wipeable ────────
|
||||
const newBytes = await subtle.deriveBits(
|
||||
{ name: 'ECDH', public: bob.publicKey },
|
||||
alice.privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
assert.equal(toHex(newBytes), toHex(legacyBytes),
|
||||
`${namedCurve}: deriveBits(256) must reproduce the previous shared secret exactly`);
|
||||
|
||||
// Sanity: the two peers still agree, which is the property the whole session
|
||||
// rests on and is worth asserting rather than assuming.
|
||||
const bobBytes = await subtle.deriveBits(
|
||||
{ name: 'ECDH', public: alice.publicKey },
|
||||
bob.privateKey,
|
||||
256
|
||||
);
|
||||
assert.equal(toHex(bobBytes), toHex(newBytes), `${namedCurve}: both peers must derive the same secret`);
|
||||
|
||||
// ── the fingerprint material took the same detour ────────────────────────
|
||||
const ikm = await subtle.importKey('raw', newBytes, { name: 'HKDF', hash: 'SHA-256' }, false,
|
||||
['deriveKey', 'deriveBits']);
|
||||
const salt = new Uint8Array(64).fill(7);
|
||||
const info = new TextEncoder().encode('fingerprint-generation-v4');
|
||||
|
||||
const legacyFpKey = await subtle.deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt, info },
|
||||
ikm,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const legacyFpBytes = await subtle.exportKey('raw', legacyFpKey);
|
||||
const newFpBytes = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, ikm, 256);
|
||||
|
||||
assert.equal(toHex(newFpBytes), toHex(legacyFpBytes),
|
||||
`${namedCurve}: HKDF deriveBits(256) must reproduce the previous fingerprint material`);
|
||||
}
|
||||
|
||||
// ── zeroizeBuffer actually overwrites ────────────────────────────────────────
|
||||
{
|
||||
globalThis.window = { document: {} };
|
||||
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||
|
||||
const secret = new Uint8Array(32).fill(0xAB);
|
||||
EnhancedSecureCryptoUtils.zeroizeBuffer(secret);
|
||||
assert.ok(secret.every((b) => b === 0), 'a Uint8Array must end up zeroed');
|
||||
|
||||
const buf = new ArrayBuffer(32);
|
||||
new Uint8Array(buf).fill(0xCD);
|
||||
EnhancedSecureCryptoUtils.zeroizeBuffer(buf);
|
||||
assert.ok(new Uint8Array(buf).every((b) => b === 0), 'an ArrayBuffer must end up zeroed');
|
||||
|
||||
// Must not throw on the shapes it will legitimately be handed.
|
||||
EnhancedSecureCryptoUtils.zeroizeBuffer(null);
|
||||
EnhancedSecureCryptoUtils.zeroizeBuffer(undefined);
|
||||
EnhancedSecureCryptoUtils.zeroizeBuffer(new ArrayBuffer(0));
|
||||
}
|
||||
|
||||
console.log('key-derivation-compat.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,110 @@
|
||||
// End-to-end key agreement, using the REAL key generator and the REAL
|
||||
// derivation — no hand-rolled CryptoKeys.
|
||||
//
|
||||
// This exists because key-derivation-compat.test.mjs did not catch a bug that
|
||||
// broke every connection: it generated its own key pairs with
|
||||
// ['deriveKey','deriveBits'] usages, while generateECDHKeyPair() produced keys
|
||||
// with only ['deriveKey']. deriveBits() then failed with an InvalidAccessError
|
||||
// on the real object, and no session could be established. A test that builds
|
||||
// its own inputs verifies the algorithm; only a test that uses the shipped
|
||||
// factory verifies the code.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = { document: {} };
|
||||
|
||||
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||
|
||||
// ── the generated key pair must carry the usages the derivation needs ────────
|
||||
{
|
||||
const pair = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
assert.ok(pair.privateKey.usages.includes('deriveBits'),
|
||||
'deriveSharedKeys() calls deriveBits — the private key must permit it');
|
||||
assert.equal(pair.privateKey.extractable, false, 'the private key must stay non-extractable');
|
||||
}
|
||||
|
||||
// ── two peers derive identical session material ──────────────────────────────
|
||||
{
|
||||
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||
assert.equal(salt.length, 64, 'the derivation requires a 64-byte salt');
|
||||
|
||||
const aliceKeys = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||
const bobKeys = await EnhancedSecureCryptoUtils.deriveSharedKeys(bob.privateKey, alice.publicKey, salt);
|
||||
|
||||
// The fingerprint is what the SAS is built from: if the two sides disagree
|
||||
// here, the safety codes differ and the users cannot complete verification.
|
||||
assert.equal(aliceKeys.fingerprint, bobKeys.fingerprint,
|
||||
'both peers must derive the same key fingerprint');
|
||||
assert.match(aliceKeys.fingerprint, /^([0-9a-f]{2}:){11}[0-9a-f]{2}$/,
|
||||
'fingerprint format must stay stable (it is displayed and fed to _computeSAS)');
|
||||
|
||||
for (const [name, keys] of [['alice', aliceKeys], ['bob', bobKeys]]) {
|
||||
for (const field of ['messageKey', 'macKey', 'pfsKey', 'metadataKey']) {
|
||||
assert.ok(keys[field] instanceof CryptoKey, `${name}.${field} must be a CryptoKey`);
|
||||
assert.equal(keys[field].extractable, false, `${name}.${field} must be non-extractable`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── and the derived keys actually interoperate ───────────────────────────
|
||||
// Matching fingerprints could in principle come from matching inputs to a
|
||||
// broken derivation; encrypting on one side and decrypting on the other is
|
||||
// the property the chat depends on.
|
||||
const encrypted = await EnhancedSecureCryptoUtils.encryptMessage(
|
||||
'hello from alice', aliceKeys.messageKey, aliceKeys.macKey, aliceKeys.metadataKey, 'msg_1', 0
|
||||
);
|
||||
const decrypted = await EnhancedSecureCryptoUtils.decryptMessage(
|
||||
encrypted, bobKeys.messageKey, bobKeys.macKey, bobKeys.metadataKey, 0
|
||||
);
|
||||
assert.equal(decrypted.message, 'hello from alice', 'bob must decrypt what alice encrypted');
|
||||
assert.equal(decrypted.messageId, 'msg_1');
|
||||
}
|
||||
|
||||
// ── a different salt must give different keys ────────────────────────────────
|
||||
// Guards against the salt being dropped from the HKDF inputs, which would make
|
||||
// every session with the same peer derive the same keys.
|
||||
{
|
||||
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
|
||||
const first = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||
const second = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||
|
||||
assert.notEqual(first.fingerprint, second.fingerprint,
|
||||
'a fresh salt must produce fresh session material');
|
||||
}
|
||||
|
||||
// ── a mismatched peer key must not yield a shared secret ─────────────────────
|
||||
{
|
||||
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const mallory = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||
|
||||
const aliceWithBob = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||
const aliceWithMallory = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, mallory.publicKey, salt);
|
||||
|
||||
assert.notEqual(aliceWithBob.fingerprint, aliceWithMallory.fingerprint,
|
||||
'a substituted public key must change the fingerprint — this is what the SAS surfaces');
|
||||
}
|
||||
|
||||
// ── the derivation rejects malformed inputs rather than degrading ────────────
|
||||
{
|
||||
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
|
||||
await assert.rejects(
|
||||
() => EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, new Array(32).fill(1)),
|
||||
/64 bytes/,
|
||||
'a short salt must be refused'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => EnhancedSecureCryptoUtils.deriveSharedKeys('not-a-key', bob.publicKey, EnhancedSecureCryptoUtils.generateSalt()),
|
||||
/private key/i
|
||||
);
|
||||
}
|
||||
|
||||
console.log('key-exchange-e2e.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,68 @@
|
||||
// The reference-QR flow used to persist whole session offers — SDP with every
|
||||
// ICE candidate, both public keys, the session salt and the SAS code — under
|
||||
// `qr_offer_<id>`, and nothing ever deleted them. Removing the writer stops the
|
||||
// bleeding; these assertions cover the other half, that an upgrade also clears
|
||||
// what earlier versions already wrote to disk.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const bootSource = readFileSync(new URL('../src/scripts/app-boot.js', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
|
||||
|
||||
// ── nothing writes offer payloads to localStorage any more ───────────────────
|
||||
assert.equal(
|
||||
/localStorage\.setItem\(\s*[`'"]qr_offer_/.test(appSource),
|
||||
false,
|
||||
'no code may persist a session offer to localStorage'
|
||||
);
|
||||
// Match a definition, not the identifier: the explanatory note left in its place
|
||||
// names the removed function on purpose, and that note is worth keeping.
|
||||
assert.equal(
|
||||
/(const|let|var|function)\s+createQRReference\b/.test(appSource),
|
||||
false,
|
||||
'the reference-QR writer must stay removed'
|
||||
);
|
||||
|
||||
// ── and the purge runs at startup ────────────────────────────────────────────
|
||||
assert.ok(bootSource.includes('purgeLegacyOfferRecords'), 'startup must purge legacy records');
|
||||
|
||||
// Exercise the real behaviour against a localStorage stand-in.
|
||||
const store = new Map([
|
||||
['qr_offer_offer_1700000000000_abc123', '{"sdp":"v=0...","salt":[1,2,3]}'],
|
||||
['qr_offer_offer_1700000000001_def456', '{"sdp":"v=0..."}'],
|
||||
['securebit_my_status', 'available'],
|
||||
['securebit_relay_only_mode', 'true'],
|
||||
['app_version', '5.6.1']
|
||||
]);
|
||||
|
||||
globalThis.localStorage = {
|
||||
get length() { return store.size; },
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); }
|
||||
};
|
||||
|
||||
// Extract and run the purge exactly as shipped, rather than reimplementing it —
|
||||
// a copy in the test would keep passing after the real one drifted.
|
||||
const fnSource = bootSource.slice(
|
||||
bootSource.indexOf('const purgeLegacyOfferRecords'),
|
||||
bootSource.indexOf('// Mount application once DOM and modules are ready')
|
||||
);
|
||||
const purge = new Function(`${fnSource}; return purgeLegacyOfferRecords;`)();
|
||||
|
||||
purge();
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(store.keys()).filter((k) => k.startsWith('qr_offer_')),
|
||||
[],
|
||||
'every legacy offer record must be removed'
|
||||
);
|
||||
// Deleting while iterating by index is easy to get wrong — it shifts the
|
||||
// remaining entries and silently skips every other key. Assert survivors too.
|
||||
assert.equal(store.get('securebit_my_status'), 'available', 'user settings must survive');
|
||||
assert.equal(store.get('securebit_relay_only_mode'), 'true', 'user settings must survive');
|
||||
assert.equal(store.get('app_version'), '5.6.1', 'version tracking must survive');
|
||||
|
||||
console.log('legacy-offer-purge.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,91 @@
|
||||
// A view-once or disappearing message must never have its text handed to the
|
||||
// operating system. Notifications only fire while the tab is backgrounded — so
|
||||
// in practice onto a lock screen — and once the OS holds the text it persists in
|
||||
// the notification centre, in device backups and on the user's other synced
|
||||
// devices, where the app can no longer delete it. The message the UI destroys
|
||||
// after 30 seconds would outlive itself indefinitely.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Notification = dom.window.Notification = class {
|
||||
static permission = 'granted';
|
||||
static requestPermission() { return Promise.resolve('granted'); }
|
||||
close() {}
|
||||
};
|
||||
|
||||
await import('../src/notifications/NotificationIntegration.js');
|
||||
const NotificationIntegration = window.NotificationIntegration;
|
||||
|
||||
const SECRET = 'the account password is hunter2';
|
||||
|
||||
const setup = async () => {
|
||||
const manager = {
|
||||
onMessage: () => {},
|
||||
onStatusChange: () => {},
|
||||
deliverMessageToUI: () => {}
|
||||
};
|
||||
const integration = new NotificationIntegration(manager);
|
||||
await integration.init();
|
||||
|
||||
const notified = [];
|
||||
integration.notificationManager.notify = (senderName, text, options) => {
|
||||
notified.push({ senderName, text, options });
|
||||
return true;
|
||||
};
|
||||
// The real manager suppresses notifications while the tab is focused; these
|
||||
// assertions are about what it is ASKED to show, so bypass that.
|
||||
integration.notificationManager.isTabActive = false;
|
||||
return { manager, integration, notified };
|
||||
};
|
||||
|
||||
// ── ephemeral messages: the OS learns that something arrived, not what ───────
|
||||
for (const [label, meta] of [
|
||||
['view-once', { mid: 'm1', once: true, onceTtl: 15 }],
|
||||
['disappearing', { mid: 'm2', ttl: 30 }],
|
||||
['both', { mid: 'm3', once: true, onceTtl: 15, ttl: 30 }]
|
||||
]) {
|
||||
const { manager, notified } = await setup();
|
||||
manager.onMessage(SECRET, 'received', meta);
|
||||
|
||||
assert.equal(notified.length, 1, `${label}: a notification must still be shown`);
|
||||
assert.equal(
|
||||
notified[0].text.includes('hunter2'), false,
|
||||
`${label}: the message text must not reach the OS notification`
|
||||
);
|
||||
assert.ok(notified[0].text.length > 0, `${label}: but the user must still be told something arrived`);
|
||||
}
|
||||
|
||||
// ── ordinary messages keep their preview ─────────────────────────────────────
|
||||
// Suppressing everything would be the easy fix and the wrong one: it would make
|
||||
// notifications useless and invite someone to revert this.
|
||||
{
|
||||
const { manager, notified } = await setup();
|
||||
manager.onMessage(SECRET, 'received', { mid: 'm4', ts: Date.now() });
|
||||
assert.equal(notified.length, 1);
|
||||
assert.ok(notified[0].text.includes('hunter2'), 'a normal message keeps its preview');
|
||||
}
|
||||
|
||||
// ── a message with no meta at all is treated as ordinary ─────────────────────
|
||||
{
|
||||
const { manager, notified } = await setup();
|
||||
manager.onMessage(SECRET, 'received');
|
||||
assert.equal(notified.length, 1);
|
||||
assert.ok(notified[0].text.includes('hunter2'));
|
||||
}
|
||||
|
||||
// ── the deliverMessageToUI wrapper must apply the same rule ──────────────────
|
||||
// It is a second, independent entry point into the same notification path; the
|
||||
// original bug existed on both.
|
||||
{
|
||||
const { manager, notified } = await setup();
|
||||
manager.deliverMessageToUI(SECRET, 'received', { mid: 'm5', once: true });
|
||||
assert.equal(notified.length, 1);
|
||||
assert.equal(notified[0].text.includes('hunter2'), false,
|
||||
'deliverMessageToUI must suppress ephemeral previews too');
|
||||
}
|
||||
|
||||
console.log('notification-ephemeral-privacy.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,76 @@
|
||||
// A scanned QR code is fully attacker-controlled input, and DEFLATE compresses
|
||||
// repetitive data ~1000:1 — so a QR small enough to print on a sticker can
|
||||
// expand to hundreds of megabytes and OOM-kill the tab, taking the live session
|
||||
// and its keys with it.
|
||||
//
|
||||
// The subtlety this file exists for: pako documents a `maxOutputLength` option,
|
||||
// and pako 2.1.0 SILENTLY IGNORES IT. Passing it looks like a fix, passes review
|
||||
// and does nothing. These assertions pin the behaviour we actually depend on.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import pako from 'pako';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('../src/crypto/cose-qr.js', import.meta.url), 'utf8');
|
||||
|
||||
// ── the option we cannot rely on ─────────────────────────────────────────────
|
||||
{
|
||||
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024)); // 8 MB of zeros
|
||||
assert.ok(bomb.length < 64 * 1024, 'sanity: the bomb really is small compressed');
|
||||
|
||||
const ignored = pako.inflate(bomb, { maxOutputLength: 256 * 1024 });
|
||||
// If this ever starts throwing (or truncating), pako has gained real support
|
||||
// and inflateBounded could be simplified — but only then, deliberately.
|
||||
assert.equal(ignored.length, 8 * 1024 * 1024,
|
||||
'pako still ignores maxOutputLength; the streaming guard is load-bearing');
|
||||
}
|
||||
|
||||
// ── the QR path must not use the one-shot helper ─────────────────────────────
|
||||
assert.equal(
|
||||
/pako\.inflate\(/.test(source), false,
|
||||
'the one-shot pako.inflate does not bound output and must not be used on QR input'
|
||||
);
|
||||
assert.ok(/new pako\.Inflate\(/.test(source), 'the streaming API is what enforces the bound');
|
||||
|
||||
// ── the real helper, exercised as shipped ────────────────────────────────────
|
||||
const helperSource = source.slice(
|
||||
source.indexOf('const MAX_INFLATED_QR_BYTES'),
|
||||
source.indexOf('// Generate UUID for chunking')
|
||||
);
|
||||
const inflateBounded = new Function('pako', `${helperSource}; return inflateBounded;`)(pako);
|
||||
|
||||
{
|
||||
const bomb = pako.deflate(new Uint8Array(8 * 1024 * 1024));
|
||||
assert.throws(
|
||||
() => inflateBounded(bomb, 'test'),
|
||||
/expands beyond/,
|
||||
'a zip bomb must be refused'
|
||||
);
|
||||
}
|
||||
|
||||
// A normal offer still round-trips — a bound that breaks decompression would be
|
||||
// removed by the next person who hits it.
|
||||
{
|
||||
const payload = JSON.stringify({ type: 'enhanced_secure_offer', sdp: 'v=0\r\n'.repeat(200) });
|
||||
const restored = inflateBounded(pako.deflate(new TextEncoder().encode(payload)), 'test');
|
||||
assert.equal(new TextDecoder().decode(restored), payload, 'a real offer must decompress intact');
|
||||
}
|
||||
|
||||
// Right below the ceiling is fine; just above it is not.
|
||||
{
|
||||
const under = new Uint8Array(200 * 1024).map((_, i) => i % 251);
|
||||
assert.equal(inflateBounded(pako.deflate(under), 'test').length, under.length);
|
||||
|
||||
const over = new Uint8Array(300 * 1024).map((_, i) => i % 251);
|
||||
assert.throws(() => inflateBounded(pako.deflate(over), 'test'), /expands beyond/);
|
||||
}
|
||||
|
||||
// Garbage input fails cleanly rather than hanging or returning junk.
|
||||
{
|
||||
assert.throws(
|
||||
() => inflateBounded(new Uint8Array([1, 2, 3, 4, 5]), 'test'),
|
||||
/could not be decompressed/
|
||||
);
|
||||
}
|
||||
|
||||
console.log('qr-zip-bomb.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,213 @@
|
||||
// The ratchet wired into the manager, not in isolation.
|
||||
//
|
||||
// double-ratchet.test.mjs proves the algorithm. This proves the wiring: that the
|
||||
// handshake actually starts a ratchet, that a frame produced by the send path is
|
||||
// readable by the receive path, and — the part most likely to be got wrong —
|
||||
// that a peer which does not support it degrades to the previous scheme instead
|
||||
// of failing to communicate at all.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = { document: {} };
|
||||
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
|
||||
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
|
||||
|
||||
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.MESSAGE_TYPES;
|
||||
|
||||
/**
|
||||
* Re-import a public key the way the handshake delivers it. importSignedPublicKey
|
||||
* imports SPKI as NON-EXTRACTABLE, while a locally generated public key is always
|
||||
* extractable — so handing `keyPair.publicKey` straight to the manager tests a
|
||||
* key shape the app never sees. A ratchet-setup failure that hit only the
|
||||
* initiator got through review precisely because the test used the easy shape.
|
||||
*/
|
||||
async function asReceivedFromPeer(publicKey) {
|
||||
const spki = await crypto.subtle.exportKey('spki', publicKey);
|
||||
const imported = await crypto.subtle.importKey(
|
||||
'spki', spki, { name: 'ECDH', namedCurve: 'P-384' }, false, []
|
||||
);
|
||||
assert.equal(imported.extractable, false);
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function handshake({ initiatorSupports = true, responderSupports = true } = {}) {
|
||||
const initiatorKeys = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const responderKeys = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||
|
||||
// What each side actually holds for the other, post-handshake.
|
||||
const initiatorPeerKey = await asReceivedFromPeer(responderKeys.publicKey);
|
||||
const responderPeerKey = await asReceivedFromPeer(initiatorKeys.publicKey);
|
||||
|
||||
const make = (own, peerPub, isInitiator, peerSupports) => {
|
||||
const delivered = [];
|
||||
const mgr = {
|
||||
delivered,
|
||||
ecdhKeyPair: own,
|
||||
peerPublicKey: peerPub,
|
||||
sessionSalt: salt,
|
||||
securityFeatures: {},
|
||||
_peerSupportsRatchet: peerSupports,
|
||||
_ratchet: null,
|
||||
_secureLog() {},
|
||||
_checkInboundRateLimit: () => true,
|
||||
deliverMessageToUI: (m, type, meta) => delivered.push({ m, type, meta }),
|
||||
_initializeRatchet: P._initializeRatchet,
|
||||
isRatchetActive: P.isRatchetActive,
|
||||
_processRatchetMessage: P._processRatchetMessage
|
||||
};
|
||||
return { mgr, isInitiator };
|
||||
};
|
||||
|
||||
// Both sides derive from the same ECDH, exactly as the handshake does.
|
||||
const initiatorDerived = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
initiatorKeys.privateKey, responderKeys.publicKey, salt);
|
||||
const responderDerived = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
responderKeys.privateKey, initiatorKeys.publicKey, salt);
|
||||
|
||||
assert.equal(initiatorDerived.fingerprint, responderDerived.fingerprint,
|
||||
'sanity: the handshake must agree before the ratchet is layered on');
|
||||
|
||||
const a = make(initiatorKeys, initiatorPeerKey, true, responderSupports);
|
||||
const b = make(responderKeys, responderPeerKey, false, initiatorSupports);
|
||||
|
||||
await a.mgr._initializeRatchet(initiatorDerived, true);
|
||||
await b.mgr._initializeRatchet(responderDerived, false);
|
||||
|
||||
return { a: a.mgr, b: b.mgr };
|
||||
}
|
||||
|
||||
// Build a frame the way sendSecureMessage does, and hand it to the real
|
||||
// receive path rather than calling the ratchet directly.
|
||||
const sendThrough = async (from, to, text) => {
|
||||
const envelope = JSON.stringify({ type: 'message', data: text });
|
||||
const { header, ciphertext } = await from._ratchet.encrypt(envelope);
|
||||
await to._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext, version: '5.0' });
|
||||
};
|
||||
|
||||
// ── both sides support it: a ratchet comes up and carries chat ───────────────
|
||||
{
|
||||
const { a, b } = await handshake();
|
||||
assert.equal(a.isRatchetActive(), true, 'the initiator must start a ratchet');
|
||||
assert.equal(b.isRatchetActive(), true, 'the responder must start a ratchet');
|
||||
assert.equal(a.securityFeatures.hasPFS, true);
|
||||
|
||||
await sendThrough(a, b, 'hello from the initiator');
|
||||
assert.deepEqual(b.delivered.at(-1).m, 'hello from the initiator');
|
||||
assert.equal(b.delivered.at(-1).type, 'received');
|
||||
|
||||
await sendThrough(b, a, 'hello back');
|
||||
assert.equal(a.delivered.at(-1).m, 'hello back');
|
||||
|
||||
// Several turns, so the DH ratchet steps more than once.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await sendThrough(a, b, `a${i}`);
|
||||
await sendThrough(b, a, `b${i}`);
|
||||
}
|
||||
assert.equal(b.delivered.at(-1).m, 'a5');
|
||||
assert.equal(a.delivered.at(-1).m, 'b5');
|
||||
}
|
||||
|
||||
// ── the responder can speak before the initiator does ───────────────────────
|
||||
// The Double Ratchet gives the responder no sending chain until it has seen the
|
||||
// initiator's ratchet key — but the app pushes a presence update from BOTH sides
|
||||
// the moment verification completes. If the send path assumed a usable ratchet,
|
||||
// the responder's first frame would throw and its presence would never go out.
|
||||
{
|
||||
const { a, b } = await handshake();
|
||||
|
||||
assert.equal(b.isRatchetActive(), true, 'the responder still HAS a ratchet...');
|
||||
assert.equal(b._ratchet.canEncrypt, false, '...it just cannot send on it yet');
|
||||
assert.equal(a._ratchet.canEncrypt, true, 'the initiator can send immediately');
|
||||
|
||||
// Once the initiator speaks, the responder gains its sending chain.
|
||||
await sendThrough(a, b, 'first');
|
||||
assert.equal(b._ratchet.canEncrypt, true, 'receiving must open the responder’s sending chain');
|
||||
await sendThrough(b, a, 'reply');
|
||||
assert.equal(a.delivered.at(-1).m, 'reply');
|
||||
}
|
||||
|
||||
// ── per-message metadata still reaches the UI ────────────────────────────────
|
||||
// view-once / disappearing ride inside the encrypted envelope; losing them here
|
||||
// would silently turn ephemeral messages into permanent ones.
|
||||
{
|
||||
const { a, b } = await handshake();
|
||||
const envelope = JSON.stringify({ type: 'message', data: 'burn after reading', meta: { mid: 'm1', once: true } });
|
||||
const { header, ciphertext } = await a._ratchet.encrypt(envelope);
|
||||
await b._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext });
|
||||
|
||||
assert.equal(b.delivered.at(-1).m, 'burn after reading');
|
||||
assert.deepEqual(b.delivered.at(-1).meta, { mid: 'm1', once: true });
|
||||
}
|
||||
|
||||
// ── NEGOTIATION: a peer on an older build must still be able to talk ─────────
|
||||
// This is the compatibility guarantee. If either side does not advertise the
|
||||
// ratchet, neither may start one — a one-sided ratchet decrypts nothing.
|
||||
{
|
||||
const { a, b } = await handshake({ responderSupports: false, initiatorSupports: false });
|
||||
assert.equal(a.isRatchetActive(), false, 'no ratchet when the peer did not advertise it');
|
||||
assert.equal(b.isRatchetActive(), false);
|
||||
assert.equal(a.securityFeatures.hasPFS, undefined,
|
||||
'and the PFS flag must not be raised for a session that does not have it');
|
||||
}
|
||||
|
||||
// ── a half-negotiated session must not half-enable ───────────────────────────
|
||||
{
|
||||
const { a, b } = await handshake({ responderSupports: false, initiatorSupports: true });
|
||||
assert.equal(a.isRatchetActive(), false, 'initiator saw no support in the answer');
|
||||
assert.equal(b.isRatchetActive(), true, 'responder saw support in the offer');
|
||||
|
||||
// The asymmetric case cannot happen in practice — both flags come from the
|
||||
// same pair of packages — but if it ever did, the ratcheted side must not be
|
||||
// able to push frames the other cannot read. The receiving side simply has
|
||||
// no ratchet and drops them rather than crashing.
|
||||
const envelope = JSON.stringify({ type: 'message', data: 'unreadable' });
|
||||
const { header, ciphertext } = await b._ratchet.encrypt(envelope).catch(() => ({}));
|
||||
if (header) {
|
||||
await a._processRatchetMessage({ type: T.RATCHET_MESSAGE, h: header, c: ciphertext });
|
||||
assert.deepEqual(a.delivered, [], 'a frame we cannot decrypt must be dropped, not rendered');
|
||||
}
|
||||
}
|
||||
|
||||
// ── malformed frames are dropped without throwing ────────────────────────────
|
||||
{
|
||||
const { a, b } = await handshake();
|
||||
for (const frame of [
|
||||
{ type: T.RATCHET_MESSAGE },
|
||||
{ type: T.RATCHET_MESSAGE, h: 'not json', c: 'AAAA' },
|
||||
{ type: T.RATCHET_MESSAGE, h: JSON.stringify({ dh: 'x', pn: 0, n: 0 }), c: '!!!not base64!!!' },
|
||||
{ type: T.RATCHET_MESSAGE, h: 123, c: 456 }
|
||||
]) {
|
||||
await b._processRatchetMessage(frame);
|
||||
}
|
||||
assert.deepEqual(b.delivered, [], 'nothing malformed may reach the UI');
|
||||
|
||||
// And the session still works afterwards.
|
||||
await sendThrough(a, b, 'still fine');
|
||||
assert.equal(b.delivered.at(-1).m, 'still fine');
|
||||
}
|
||||
|
||||
// ── the ratchet root is domain-separated from the session keys ───────────────
|
||||
// Learning a message key must tell an attacker nothing about the ratchet root.
|
||||
{
|
||||
const alice = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const bob = await EnhancedSecureCryptoUtils.generateECDHKeyPair();
|
||||
const salt = EnhancedSecureCryptoUtils.generateSalt();
|
||||
const derived = await EnhancedSecureCryptoUtils.deriveSharedKeys(alice.privateKey, bob.publicKey, salt);
|
||||
|
||||
assert.ok(derived.ratchetRoot instanceof Uint8Array, 'a ratchet root must be produced');
|
||||
assert.equal(derived.ratchetRoot.length, 32);
|
||||
assert.ok(derived.ratchetRoot.some((b) => b !== 0), 'and it must not be all zeros');
|
||||
|
||||
// A different salt gives a different root, so two sessions never share state.
|
||||
const other = await EnhancedSecureCryptoUtils.deriveSharedKeys(
|
||||
alice.privateKey, bob.publicKey, EnhancedSecureCryptoUtils.generateSalt());
|
||||
assert.notDeepEqual(Array.from(derived.ratchetRoot), Array.from(other.ratchetRoot));
|
||||
}
|
||||
|
||||
console.log('ratchet-integration.test.mjs: all assertions passed');
|
||||
@@ -231,16 +231,15 @@ function createVerificationReadinessManager({
|
||||
}
|
||||
};
|
||||
|
||||
const originalTimeout = EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT;
|
||||
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = 0;
|
||||
try {
|
||||
assert.equal(
|
||||
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager),
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = originalTimeout;
|
||||
}
|
||||
// Both budgets are passed explicitly: gathering now waits past the soft
|
||||
// deadline while there is nothing at all to export (see
|
||||
// ice-gathering-patience.test.mjs), so relying on the default hard ceiling
|
||||
// here would stall this assertion for 25 s. The property under test is
|
||||
// unchanged — a timeout must report false, never "complete".
|
||||
assert.equal(
|
||||
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager, 0, 0),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// A timed-out ICE gathering can still yield usable candidates for manual export.
|
||||
|
||||
@@ -48,18 +48,33 @@ const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES;
|
||||
assert.equal(calls[0].meta, undefined);
|
||||
}
|
||||
|
||||
// ── processMessage routes message_delete to onMessageDelete ──────────────────
|
||||
// ── message_delete routes to onMessageDelete, but only once verified ─────────
|
||||
// Unsend lets the peer remove a message from OUR transcript, so it is a control
|
||||
// frame: acting on it before the SAS has been compared would let anyone who
|
||||
// completed the handshake — a MITM included — edit what the user sees. It used
|
||||
// to be honoured unconditionally.
|
||||
{
|
||||
const deleted = [];
|
||||
const manager = {
|
||||
_secureLog() {},
|
||||
onMessageDelete: (id) => deleted.push(id)
|
||||
const makeManager = (isVerified) => {
|
||||
const deleted = [];
|
||||
return {
|
||||
deleted,
|
||||
manager: {
|
||||
isVerified,
|
||||
_secureLog() {},
|
||||
_enforceVerificationGate: P._enforceVerificationGate,
|
||||
onMessageDelete: (id) => deleted.push(id)
|
||||
}
|
||||
};
|
||||
};
|
||||
await P.processMessage.call(
|
||||
manager,
|
||||
JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm_42' } })
|
||||
);
|
||||
assert.deepEqual(deleted, ['m_42']);
|
||||
const frame = JSON.stringify({ type: T.MESSAGE_DELETE, data: { messageId: 'm_42' } });
|
||||
|
||||
const before = makeManager(false);
|
||||
await P.processMessage.call(before.manager, frame);
|
||||
assert.deepEqual(before.deleted, [], 'an unverified peer must not delete our messages');
|
||||
|
||||
const after = makeManager(true);
|
||||
await P.processMessage.call(after.manager, frame);
|
||||
assert.deepEqual(after.deleted, ['m_42'], 'unsend still works on a verified session');
|
||||
}
|
||||
|
||||
// ── live enhanced-message path delivers metadata to the UI ───────────────────
|
||||
|
||||
@@ -31,8 +31,13 @@ function createManager(overrides = {}) {
|
||||
encryptionKey: {},
|
||||
hmacKey: {},
|
||||
replayProtectionEnabled: true,
|
||||
// Our own fingerprint and the peer's are tracked separately; the SAS binds
|
||||
// both, so the dtlsFingerprint flag requires both to be present.
|
||||
expectedDTLSFingerprint: 'aa:bb',
|
||||
_peerDTLSFingerprint: 'cc:dd',
|
||||
verificationCode: '1234567',
|
||||
localVerificationConfirmed: true,
|
||||
isRatchetActive: () => true,
|
||||
connectionId: 'conn-1',
|
||||
keyFingerprint: 'ff:ee',
|
||||
_secureLog() {},
|
||||
@@ -63,6 +68,34 @@ function createManager(overrides = {}) {
|
||||
assert.equal(data.score + '%', '90%');
|
||||
}
|
||||
|
||||
// ── the flags report what was verified, not what was merely computed ─────────
|
||||
// A SAS code exists the moment the handshake completes — including for a MITM's
|
||||
// session. It only means anything once the USER has compared it out of band, so
|
||||
// the flag has to track the confirmation and not the code's existence. Likewise,
|
||||
// holding our own DTLS fingerprint proves nothing without the peer's: the SAS
|
||||
// binds the pair.
|
||||
{
|
||||
const unconfirmed = await createManager({ localVerificationConfirmed: false }).getRealSecurityLevel();
|
||||
assert.equal(unconfirmed.sasCode, false, 'an uncompared SAS code is not authentication');
|
||||
|
||||
const halfFingerprint = await createManager({ _peerDTLSFingerprint: null }).getRealSecurityLevel();
|
||||
assert.equal(halfFingerprint.dtlsFingerprint, false, 'our own fingerprint alone proves nothing');
|
||||
|
||||
// PFS tracks whether the Double Ratchet is actually running on THIS
|
||||
// connection. A peer on an older build negotiates it away, and the panel has
|
||||
// to show that rather than the capability we happen to ship.
|
||||
const withRatchet = await createManager().getRealSecurityLevel();
|
||||
assert.equal(withRatchet.perfectForwardSecrecy, true, 'an active ratchet must be reported');
|
||||
|
||||
const withoutRatchet = await createManager({ isRatchetActive: () => false }).getRealSecurityLevel();
|
||||
assert.equal(withoutRatchet.perfectForwardSecrecy, false,
|
||||
'a session that fell back to static keys must not claim forward secrecy');
|
||||
|
||||
// A status report must never throw, even on a partially built manager.
|
||||
const partial = await createManager({ isRatchetActive: undefined }).getRealSecurityLevel();
|
||||
assert.equal(partial.perfectForwardSecrecy, false, 'unknown must read as off, not crash');
|
||||
}
|
||||
|
||||
// ── not-ready path is flagged, not rendered as a real measurement ────────────
|
||||
{
|
||||
// calculateAndReportSecurityLevel returns null when the session is not yet
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Voice notes are the only transfer accepted without asking the user. The flag
|
||||
// that grants that exemption, `isVoice`, is set by the SENDER and sits outside
|
||||
// the signed fileHash on purpose — so it is the receiver's job to decide whether
|
||||
// a transfer has actually earned the exemption.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = {};
|
||||
|
||||
const { EnhancedSecureFileTransfer } = await import('../src/transfer/EnhancedSecureFileTransfer.js');
|
||||
|
||||
const makeTransfer = () => new EnhancedSecureFileTransfer({
|
||||
dataChannel: { readyState: 'open', send() {} },
|
||||
encryptionKey: null,
|
||||
macKey: null,
|
||||
onProgress() {},
|
||||
onFileReceived() {},
|
||||
onError() {}
|
||||
});
|
||||
|
||||
const metadataFor = (over = {}) => ({
|
||||
fileId: 'f1',
|
||||
fileName: 'voice-message.webm',
|
||||
fileSize: 48_000,
|
||||
fileType: 'audio/webm',
|
||||
totalChunks: 3,
|
||||
chunkSize: 16 * 1024,
|
||||
salt: Array.from({ length: 32 }, (_, i) => i),
|
||||
isVoice: true,
|
||||
...over
|
||||
});
|
||||
|
||||
const ft = makeTransfer();
|
||||
|
||||
// ── a genuine voice note still skips the consent card ────────────────────────
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor());
|
||||
assert.equal(v.isValid, true, v.errors.join('; '));
|
||||
assert.equal(v.isVoice, true, 'a real voice note must keep auto-accept');
|
||||
assert.equal(v.voiceRejection, null);
|
||||
}
|
||||
|
||||
// ── an arbitrary blob wearing an allowed extension must not ──────────────────
|
||||
// This was the actual hole: `.mp4` is in the voice extension list and
|
||||
// application/octet-stream counts as a "generic" MIME for ordinary uploads, so a
|
||||
// 20 MB blob claiming isVoice was downloaded and rendered with no prompt at all.
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({
|
||||
fileName: 'payload.mp4',
|
||||
fileType: 'application/octet-stream',
|
||||
fileSize: 20 * 1024 * 1024
|
||||
}));
|
||||
assert.equal(v.isVoice, false, 'a generic-MIME blob must not auto-accept');
|
||||
assert.match(v.voiceRejection, /not an audio MIME type/);
|
||||
}
|
||||
|
||||
// ── an absent MIME is not a free pass either ─────────────────────────────────
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({ fileType: '' }));
|
||||
assert.equal(v.isVoice, false);
|
||||
assert.match(v.voiceRejection, /not an audio MIME type/);
|
||||
}
|
||||
|
||||
// ── audio, but not an audio type we actually support ─────────────────────────
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({ fileType: 'audio/x-made-up' }));
|
||||
assert.equal(v.isVoice, false);
|
||||
assert.match(v.voiceRejection, /unsupported audio MIME type/);
|
||||
}
|
||||
|
||||
// ── size ceiling: a voice note is minutes of speech, not a payload channel ───
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({
|
||||
fileSize: ft.MAX_AUTO_ACCEPT_VOICE_SIZE + 1
|
||||
}));
|
||||
assert.equal(v.isVoice, false);
|
||||
assert.match(v.voiceRejection, /too large to auto-accept/);
|
||||
|
||||
// Right at the ceiling is still fine.
|
||||
const ok = ft.validateIncomingMetadata(metadataFor({
|
||||
fileSize: ft.MAX_AUTO_ACCEPT_VOICE_SIZE
|
||||
}));
|
||||
assert.equal(ok.isVoice, true, 'the limit itself must be accepted');
|
||||
}
|
||||
|
||||
// ── a per-session budget bounds the total, not just each one ─────────────────
|
||||
{
|
||||
const budgeted = makeTransfer();
|
||||
budgeted.autoAcceptedVoiceBytes = budgeted.MAX_AUTO_ACCEPT_VOICE_SESSION_BYTES - 1000;
|
||||
|
||||
const v = budgeted.validateIncomingMetadata(metadataFor({ fileSize: 48_000 }));
|
||||
assert.equal(v.isVoice, false, 'the session budget must eventually stop auto-accept');
|
||||
assert.match(v.voiceRejection, /budget/);
|
||||
}
|
||||
|
||||
// ── a rejected voice claim is a downgrade, not a drop ────────────────────────
|
||||
// The peer may be sending something perfectly legitimate that simply does not
|
||||
// qualify; it must still reach the user through the normal consent card.
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({
|
||||
fileName: 'report.pdf',
|
||||
fileType: 'application/pdf',
|
||||
fileSize: 100_000
|
||||
}));
|
||||
assert.equal(v.isValid, true, 'the transfer itself stays valid');
|
||||
assert.equal(v.isVoice, false, 'but it does not skip consent');
|
||||
}
|
||||
|
||||
// ── files that never claimed to be voice are unaffected ──────────────────────
|
||||
{
|
||||
const v = ft.validateIncomingMetadata(metadataFor({
|
||||
fileName: 'photo.png', fileType: 'image/png', isVoice: undefined
|
||||
}));
|
||||
assert.equal(v.isValid, true, v.errors.join('; '));
|
||||
assert.equal(v.isVoice, false);
|
||||
assert.equal(v.voiceRejection, null, 'no rejection reason for something that never asked');
|
||||
}
|
||||
|
||||
console.log('voice-auto-accept.test.mjs: all assertions passed');
|
||||
Reference in New Issue
Block a user