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