Files
securebit-chat/tests/double-ratchet.test.mjs
T
lockbitchat 27279ae7c6
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s
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.
2026-08-05 23:02:20 -04:00

294 lines
13 KiB
JavaScript

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