287 lines
14 KiB
JavaScript
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');
|