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,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');
|
||||
Reference in New Issue
Block a user