Files
securebit-chat/tests/sbq2-key-exchange.test.mjs
T
lockbitchat 7b703e4e65 feat(handshake): in-band key exchange; SBQ2 live in the connection path; release v5.9.0
The descriptor no longer carries key material. It carries what brings up DTLS
plus a 16-byte commitment; the ECDH and ECDSA public keys travel as the first
frame on the DataChannel and are checked against that commitment BEFORE they are
parsed or imported. Measured on the live site, the invitation went from 2274
characters across 4 animated QR frames to 151 characters in one frame.

What the split buys is that nothing is trusted on the strength of having
completed a handshake. The fingerprint arrives over the channel the user
authenticated by looking at it, so DTLS completes only with whoever showed the
code; the commitment makes substituted key material fail closed automatically
instead of relying on the human comparison; and the SAS is computed over a
transcript covering both descriptors verbatim and both key blobs, so anything an
attacker can change anywhere in the handshake changes the digits.

Consequences for the rest of the crypto:

- the HKDF salt is derived from the transcript rather than transmitted, binding
  every session key to both DTLS fingerprints and every candidate;
- authProof is replaced by one ECDSA signature over the transcript, proving the
  same possession without echoing a nonce back across seven fields;
- Double Ratchet support is implied by the format rather than advertised in it.
  SBQ2 postdates the ratchet, so the "peer is old, fall back to static keys"
  branch is unreachable here -- it used to fire silently, since SBQ2 has no `dr`
  field for _initializeRatchet to find.

Fail closed throughout. _handshakeMode is latched per connection, so a session
cannot be pushed back onto SB1 halfway through; commitment mismatch, wrong role,
duplicate blob, bad proof and timeout all tear the connection down with a
specific message rather than degrade. SBQ2 handshake frames are refused outright
on a non-SBQ2 session, where there would be no commitment to check them against.

Reception of both formats is unconditional and split by first byte (0x02 vs
ASCII 'S') and text prefix. SBQ2_SEND_ENABLED is the single value that governs
what we emit; flipping it to false and redeploying reverts new invitations to
SB1, and the animated multi-frame QR path stays for them.

Verified end to end in real browsers -- 12/12 across {Chrome, Firefox} squared
and three network profiles -- through to a confirmed SAS, an active ratchet, a
64-byte transcript-derived salt and a decrypted message on the far side.
2026-08-06 18:20:48 -04:00

287 lines
14 KiB
JavaScript

// SBQ2 in-band key exchange: the commitment gate, transcript coverage, and the
// identity proof that replaced authProof.
//
// The property under test throughout is that the descriptor the user carried by
// hand is what pins the key material: substituting the blob must fail before the
// blob is parsed, and anything that changes anywhere in the handshake must move
// the SAS digits the two people read to each other.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { webcrypto as crypto } from 'node:crypto';
const {
parseSdp, pruneCandidates, encodeDescriptor, decodeDescriptor,
bindingTag, commitBlob, TYPE, LIMITS,
} = await import('../src/network/descriptor/sbq2.js');
const {
ROLE, KEY_BLOB_VERSION, BLOB_LIMITS,
encodeKeyBlob, decodeKeyBlob, buildTranscript, deriveTranscriptSalt,
proofPayload, computeTranscriptSas, verifyBlobCommitment, KeyExchangeError,
} = await import('../src/network/descriptor/keyexchange.js');
const subtle = crypto.subtle;
const chrome = JSON.parse(readFileSync(new URL('./fixtures/sdp-chrome.json', import.meta.url)));
const digest = async (b) => new Uint8Array(await subtle.digest('SHA-256', b));
const rejects = (fn, match, label) => assert.throws(fn, (e) => {
assert.ok(e instanceof KeyExchangeError, `${label}: wrong error type ${e.name}`);
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
return true;
}, label);
const rejectsAsync = async (fn, match, label) => {
await assert.rejects(fn, (e) => {
assert.ok(e instanceof KeyExchangeError, `${label}: wrong error type ${e.name}`);
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
return true;
}, label);
};
async function makePeer(role) {
const ecdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
const ecdsa = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
const blob = encodeKeyBlob({
role,
ecdhSpki: new Uint8Array(await subtle.exportKey('spki', ecdh.publicKey)),
ecdsaSpki: new Uint8Array(await subtle.exportKey('spki', ecdsa.publicKey)),
});
return { ecdh, ecdsa, blob, commitment: await commitBlob(digest, blob) };
}
async function makeDescriptor(sdp, type, commitment, tag) {
const raw = parseSdp(sdp);
return encodeDescriptor({
type, expiresAtMs: Date.now() + 600000,
sdpFields: { ...raw, candidates: pruneCandidates(raw.candidates) },
commitment,
...(type === TYPE.ANSWER ? { bindingTag: tag } : {}),
});
}
/** A full two-sided handshake, as the manager runs it. */
async function handshake() {
const A = await makePeer(ROLE.OFFER);
const B = await makePeer(ROLE.ANSWER);
const offerDescriptor = await makeDescriptor(chrome.turn_all.offer, TYPE.OFFER, A.commitment);
const answerDescriptor = await makeDescriptor(
chrome.turn_all.answer, TYPE.ANSWER, B.commitment, await bindingTag(digest, offerDescriptor));
const transcript = buildTranscript({
offerDescriptor, answerDescriptor, offerBlob: A.blob, answerBlob: B.blob,
});
return { A, B, offerDescriptor, answerDescriptor, transcript };
}
// ---------------------------------------------------------------------------
// key blob encoding
// ---------------------------------------------------------------------------
{
const A = await makePeer(ROLE.OFFER);
const decoded = decodeKeyBlob(A.blob);
assert.equal(decoded.version, KEY_BLOB_VERSION);
assert.equal(decoded.role, ROLE.OFFER);
// Both keys must survive to a usable CryptoKey — a blob that decodes but
// cannot be imported is no better than one that fails outright.
await subtle.importKey('spki', decoded.ecdhSpki, { name: 'ECDH', namedCurve: 'P-384' }, false, []);
await subtle.importKey('spki', decoded.ecdsaSpki, { name: 'ECDSA', namedCurve: 'P-384' }, false, ['verify']);
rejects(() => decodeKeyBlob(new Uint8Array(0)), /empty/, 'empty blob');
rejects(() => decodeKeyBlob(A.blob.subarray(0, A.blob.length - 1)), /truncated/, 'truncated blob');
for (const v of [0x00, 0x01, 0x03, 0xff]) {
const x = Uint8Array.from(A.blob); x[0] = v;
rejects(() => decodeKeyBlob(x), /unsupported key blob version/, `blob version 0x${v.toString(16)}`);
}
const badRole = Uint8Array.from(A.blob); badRole[1] = 2;
rejects(() => decodeKeyBlob(badRole), /reserved key blob role/, 'reserved role');
const trailing = new Uint8Array(A.blob.length + 2);
trailing.set(A.blob); trailing.set([9, 9], A.blob.length);
rejects(() => decodeKeyBlob(trailing), /trailing byte/, 'trailing bytes');
const huge = Uint8Array.from(A.blob); huge[2] = 0xff; huge[3] = 0xff;
rejects(() => decodeKeyBlob(huge), /SPKI length out of range/, 'absurd SPKI length');
rejects(() => decodeKeyBlob(new Uint8Array(BLOB_LIMITS.MAX_BLOB_BYTES + 1)), /size limit/, 'oversized blob');
rejects(() => encodeKeyBlob({ role: 7, ecdhSpki: new Uint8Array(60), ecdsaSpki: new Uint8Array(60) }),
/invalid role/, 'encode with a bad role');
rejects(() => encodeKeyBlob({ role: ROLE.OFFER, ecdhSpki: new Uint8Array(4), ecdsaSpki: new Uint8Array(60) }),
/ecdh SPKI length out of range/, 'encode with a stub key');
}
// ---------------------------------------------------------------------------
// the commitment gate
// ---------------------------------------------------------------------------
{
const { A, B, offerDescriptor, answerDescriptor } = await handshake();
// The honest case: each side's descriptor commits to its own blob.
await verifyBlobCommitment(subtle, A.blob, decodeDescriptor(offerDescriptor).commitment);
await verifyBlobCommitment(subtle, B.blob, decodeDescriptor(answerDescriptor).commitment);
// Substitution: an attacker who can rewrite the in-band blob but not the
// scanned descriptor is caught before the blob is ever parsed.
const M = await makePeer(ROLE.ANSWER);
await rejectsAsync(
() => verifyBlobCommitment(subtle, M.blob, decodeDescriptor(answerDescriptor).commitment),
/does not match the commitment/, 'substituted blob');
// A single flipped bit anywhere in the blob is enough.
for (const idx of [0, 1, 5, 40, A.blob.length - 1]) {
const tampered = Uint8Array.from(A.blob); tampered[idx] ^= 0x01;
await rejectsAsync(
() => verifyBlobCommitment(subtle, tampered, decodeDescriptor(offerDescriptor).commitment),
/does not match the commitment/, `blob byte ${idx} flipped`);
}
// Swapping the two peers' blobs is also a mismatch.
await rejectsAsync(
() => verifyBlobCommitment(subtle, B.blob, decodeDescriptor(offerDescriptor).commitment),
/does not match the commitment/, 'blobs swapped');
// A descriptor with no commitment cannot be used to admit a blob.
await rejectsAsync(() => verifyBlobCommitment(subtle, A.blob, null),
/no usable commitment/, 'missing commitment');
await rejectsAsync(() => verifyBlobCommitment(subtle, A.blob, new Uint8Array(8)),
/no usable commitment/, 'short commitment');
}
// ---------------------------------------------------------------------------
// transcript, salt, SAS
// ---------------------------------------------------------------------------
{
const h = await handshake();
// Both peers derive the same salt and the same digits from their own side.
const salt = await deriveTranscriptSalt(subtle, h.transcript);
assert.equal(salt.length, 64, 'deriveSharedKeys requires exactly 64 bytes');
assert.ok(salt.every((b) => Number.isInteger(b) && b >= 0 && b <= 255));
const sasA = await computeTranscriptSas(subtle, {
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: h.transcript,
});
const sasB = await computeTranscriptSas(subtle, {
ecdhPrivateKey: h.B.ecdh.privateKey, peerEcdhPublicKey: h.A.ecdh.publicKey, transcript: h.transcript,
});
assert.equal(sasA, sasB, 'both sides must read the same digits');
assert.match(sasA, /^\d{7}$/, 'SAS is 7 digits');
// Every component of the transcript must move the digits and the salt.
const variants = {
'offer descriptor': { offerDescriptor: (x) => { const y = Uint8Array.from(x); y[6] ^= 0xff; return y; } },
'answer descriptor': { answerDescriptor: (x) => { const y = Uint8Array.from(x); y[6] ^= 0xff; return y; } },
'offer blob': { offerBlob: (x) => { const y = Uint8Array.from(x); y[10] ^= 0x01; return y; } },
'answer blob': { answerBlob: (x) => { const y = Uint8Array.from(x); y[10] ^= 0x01; return y; } },
};
for (const [label, mut] of Object.entries(variants)) {
const parts = {
offerDescriptor: h.offerDescriptor, answerDescriptor: h.answerDescriptor,
offerBlob: h.A.blob, answerBlob: h.B.blob,
};
for (const [k, fn] of Object.entries(mut)) parts[k] = fn(parts[k]);
const t2 = buildTranscript(parts);
const sas2 = await computeTranscriptSas(subtle, {
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: t2,
});
assert.notEqual(sas2, sasA, `SAS must change when the ${label} changes`);
assert.notDeepEqual(await deriveTranscriptSalt(subtle, t2), salt,
`the HKDF salt must change when the ${label} changes`);
}
// Role order, not call order: a peer that assembled the transcript with the
// sides swapped must not land on the same digits.
const swapped = buildTranscript({
offerDescriptor: h.answerDescriptor, answerDescriptor: h.offerDescriptor,
offerBlob: h.B.blob, answerBlob: h.A.blob,
});
assert.notDeepEqual(swapped, h.transcript, 'transcript is role-ordered');
rejects(() => buildTranscript({
offerDescriptor: h.offerDescriptor, answerDescriptor: h.answerDescriptor, offerBlob: h.A.blob,
}), /transcript component answerBlob is missing/, 'incomplete transcript');
}
// ---------------------------------------------------------------------------
// identity proof (replaces authProof)
// ---------------------------------------------------------------------------
{
const h = await handshake();
const payload = proofPayload(h.transcript);
const sigA = new Uint8Array(await subtle.sign(
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.privateKey, payload));
assert.equal(await subtle.verify(
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, payload), true, 'honest proof verifies');
// Wrong identity key: a peer that did not commit to this ECDSA key cannot
// produce the proof.
const M = await makePeer(ROLE.OFFER);
const sigM = new Uint8Array(await subtle.sign(
{ name: 'ECDSA', hash: 'SHA-384' }, M.ecdsa.privateKey, payload));
assert.equal(await subtle.verify(
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigM, payload), false, 'foreign key is rejected');
// A proof over a different transcript does not transfer: this is what stops
// a signature captured from one session being replayed into another.
const other = await handshake();
assert.equal(await subtle.verify(
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, proofPayload(other.transcript)),
false, 'proof does not transfer across sessions');
// The domain-separation label is part of what is signed, so a raw-transcript
// signature is not a valid proof.
assert.equal(await subtle.verify(
{ name: 'ECDSA', hash: 'SHA-384' }, h.A.ecdsa.publicKey, sigA, h.transcript),
false, 'proof is domain-separated from the bare transcript');
}
// ---------------------------------------------------------------------------
// end-to-end: the sequence the manager performs, including a MITM attempt
// ---------------------------------------------------------------------------
{
const h = await handshake();
// Each side checks the peer's commitment, then derives.
await verifyBlobCommitment(subtle, h.B.blob, decodeDescriptor(h.answerDescriptor).commitment);
await verifyBlobCommitment(subtle, h.A.blob, decodeDescriptor(h.offerDescriptor).commitment);
const salt = await deriveTranscriptSalt(subtle, h.transcript);
const bits = async (priv, pub) => new Uint8Array(
await subtle.deriveBits({ name: 'ECDH', public: pub }, priv, 256));
assert.deepEqual(
await bits(h.A.ecdh.privateKey, h.B.ecdh.publicKey),
await bits(h.B.ecdh.privateKey, h.A.ecdh.publicKey),
'both sides reach the same ECDH secret');
assert.equal(salt.length, 64);
// MITM: an attacker who terminates DTLS to each side and forwards the
// descriptors unchanged still has to present key material matching a
// commitment it cannot recompute, because the commitment travelled inside
// the descriptor the user carried.
const M = await makePeer(ROLE.ANSWER);
await rejectsAsync(
() => verifyBlobCommitment(subtle, M.blob, decodeDescriptor(h.answerDescriptor).commitment),
/does not match the commitment/, 'MITM key substitution');
// If the attacker rewrites the descriptor too — which requires control of
// the out-of-band channel — the commitment check passes, and the SAS is
// what catches it.
const forged = await makeDescriptor(
chrome.turn_all.answer, TYPE.ANSWER, M.commitment, await bindingTag(digest, h.offerDescriptor));
await verifyBlobCommitment(subtle, M.blob, decodeDescriptor(forged).commitment);
const forgedTranscript = buildTranscript({
offerDescriptor: h.offerDescriptor, answerDescriptor: forged,
offerBlob: h.A.blob, answerBlob: M.blob,
});
const honest = await computeTranscriptSas(subtle, {
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: h.B.ecdh.publicKey, transcript: h.transcript,
});
const attacked = await computeTranscriptSas(subtle, {
ecdhPrivateKey: h.A.ecdh.privateKey, peerEcdhPublicKey: M.ecdh.publicKey, transcript: forgedTranscript,
});
assert.notEqual(attacked, honest, 'a fully rewritten handshake still changes the SAS digits');
}
console.log('sbq2-key-exchange: all assertions passed');