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.
214 lines
10 KiB
JavaScript
214 lines
10 KiB
JavaScript
// 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');
|