Files
securebit-chat/tests/descriptor-sbq2.test.mjs
T
lockbitchat 6e82cfcae2 feat(descriptor): SBQ2 connection descriptor format; release v5.8.0
The SB1 invitation runs 2000-2400 characters and needs QR version 38-40, past
the point where a single code is scannable, so the app falls back to an
animated multi-frame QR. SBQ2 is a fixed binary layout carrying only what
brings up DTLS -- ICE credentials, certificate fingerprint, candidates -- with
the SDP rebuilt from a template by a strict serializer. Measured on real Chrome
and Firefox SDP across four network profiles: 98-149 bytes, QR version 6-8.

Key material is meant to move to the DataChannel, bound by a commitment in the
descriptor. That half does not exist yet, so nothing calls this module: the
format is landed for review and freeze, not wired into the connection path.
doc/DESCRIPTOR-SBQ2.md records the gate on phase 3.

The decoder is a parser of hostile input: fixed offsets, explicit lengths,
deny-by-default on reserved values and unknown TLV extension types, trailing
bytes rejected, ICE credentials alphabet-checked so a CRLF cannot reach the
serializer. No compression -- DEFLATE adds bytes on this payload, and dropping
it removes the decompression-bomb surface with it.

Candidate pruning keeps coverage before count: one candidate per (family, type,
transport) survives before any surplus, so an IPv6-only or UDP-blocked path
cannot be pruned away by a v4-first sort.

Tests cover round-trip against captured Chrome and Firefox SDP, IPv6 and NAT64
addresses, ICE-TCP candidates, the TLV area, clock skew, one-shot binding and
SAS transcript coverage.
2026-08-06 11:54:05 -04:00

522 lines
28 KiB
JavaScript

// SBQ2 connection descriptor: round-trip on real browser SDP, and rejection of
// the whole class of malformed input a scanner can be handed.
//
// The Chrome fixtures in tests/fixtures/sdp-chrome.json were captured from a
// real Chrome over four network profiles (host-only, STUN, STUN+TURN, and
// relay-only) with an identical ICE configuration on both peers, each in its own
// renderer so neither side's gathering could truncate the other's. The Firefox
// fixtures in sdp-firefox.json came from a real Firefox 153 the same way.
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { webcrypto as crypto } from 'node:crypto';
const {
parseSdp, pruneCandidates, candidateSize, encodeDescriptor, decodeDescriptor, serializeSdp,
bindingTag, commitBlob, sasTranscript,
toBase64Url, fromBase64Url, encodeText, decodeText,
TYPE, LIMITS, EXT, SBQ2_VERSION, DescriptorError, KIND, KIND_FAMILY, KIND_TYPE,
} = await import('../src/network/descriptor/sbq2.js');
const load = (name) => {
const url = new URL(`./fixtures/${name}`, import.meta.url);
return existsSync(url) ? JSON.parse(readFileSync(url)) : null;
};
const chrome = load('sdp-chrome.json');
const firefox = load('sdp-firefox.json');
const digest = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
const rnd = (n) => crypto.getRandomValues(new Uint8Array(n));
const EXPIRY = () => Date.now() + 10 * 60 * 1000;
async function build(sdp, type, over = {}) {
const raw = parseSdp(sdp);
const isAnswer = type === TYPE.ANSWER;
return encodeDescriptor({
type,
expiresAtMs: EXPIRY(),
sdpFields: { ...raw, candidates: pruneCandidates(raw.candidates) },
commitment: rnd(LIMITS.COMMITMENT_BYTES),
...(isAnswer ? { bindingTag: rnd(LIMITS.BINDING_BYTES) } : {}),
...over,
});
}
const rejects = (fn, match, label) => {
assert.throws(fn, (e) => {
assert.ok(e instanceof DescriptorError, `${label}: wrong error type ${e.name}: ${e.message}`);
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
return true;
}, label);
};
// Offset of ufrag_len, which differs by type because only answers carry the tag.
const ufragLenOffset = (type) => 1 + 1 + 3 + (type === TYPE.ANSWER ? LIMITS.BINDING_BYTES : 0) + LIMITS.FINGERPRINT_BYTES;
// ---------------------------------------------------------------------------
// round-trip against real browser SDP
// ---------------------------------------------------------------------------
for (const [browser, fixtures] of [['chrome', chrome], ['firefox', firefox]]) {
if (!fixtures) continue;
for (const [profile, pair] of Object.entries(fixtures)) {
for (const kind of ['offer', 'answer']) {
if (!pair[kind]) continue;
const tag = `${browser}/${profile}/${kind}`;
const type = kind === 'offer' ? TYPE.OFFER : TYPE.ANSWER;
const original = parseSdp(pair[kind]);
const bytes = await build(pair[kind], type);
const desc = decodeDescriptor(bytes);
assert.equal(desc.version, SBQ2_VERSION, `${tag} version`);
assert.equal(desc.type, type, `${tag} type`);
assert.equal(desc.ufrag, original.ufrag, `${tag} ufrag`);
assert.equal(desc.pwd, original.pwd, `${tag} pwd`);
assert.deepEqual(desc.fingerprint, original.fingerprint, `${tag} fingerprint`);
assert.equal(desc.setup, original.setup, `${tag} setup`);
assert.equal(desc.maxMessageSize, original.maxMessageSize, `${tag} max-message-size`);
assert.equal(desc.bindingTag === null, type === TYPE.OFFER, `${tag} binding tag presence`);
// The rebuilt SDP must re-parse to exactly the fields we encoded —
// this is what guarantees the template is lossless for everything
// the descriptor claims to carry.
const rebuilt = serializeSdp(desc);
assert.ok(rebuilt.sdp.endsWith('\r\n'), `${tag} CRLF terminated`);
const reparsed = parseSdp(rebuilt.sdp);
assert.equal(reparsed.ufrag, original.ufrag);
assert.equal(reparsed.pwd, original.pwd);
assert.deepEqual(reparsed.fingerprint, original.fingerprint);
assert.equal(reparsed.setup, original.setup);
assert.equal(reparsed.maxMessageSize, original.maxMessageSize);
assert.deepEqual(
reparsed.candidates.map((c) => [c.kind, c.tcptype, [...c.addr], c.port]),
desc.candidates.map((c) => [c.kind, c.tcptype, [...c.addr], c.port]),
`${tag} candidates survive the template`,
);
// Acceptance criteria: offer <= QR v10, answer <= QR v8 at level M.
const cap = kind === 'offer' ? 213 : 152;
assert.ok(bytes.length <= cap, `${tag} is ${bytes.length} B, over the ${cap} B budget`);
}
}
}
// ---------------------------------------------------------------------------
// candidate pruning: coverage before count
// ---------------------------------------------------------------------------
{
const cand = (kind, addr, port, priority, tcptype = 0) => ({ kind, tcptype, addr: Uint8Array.from(addr), port, priority });
const v4 = (a, b, c, d) => [a, b, c, d];
const v6 = (last) => [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, last];
// A pure count limit sorted v4-first would evict every v6 candidate here.
const dual = [
cand(KIND.HOST_V4, v4(192, 168, 1, 5), 1001, 2113937151),
cand(KIND.SRFLX_V4, v4(203, 0, 113, 1), 1002, 1677729535),
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1003, 50340351),
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1004, 16785663),
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1005, 8191),
cand(KIND.HOST_MDNS, new Array(16).fill(7), 1006, 2113937150),
cand(KIND.HOST_V6, v6(1), 1007, 2113939199),
cand(KIND.SRFLX_V6, v6(2), 1008, 1677731583),
cand(KIND.RELAY_V6, v6(3), 1009, 50341375),
];
const kept = pruneCandidates(dual);
const groups = new Set(kept.map((c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}`));
for (const g of ['v4/host', 'v4/srflx', 'v4/relay', 'v6/host', 'v6/srflx', 'v6/relay', 'mdns/host']) {
assert.ok(groups.has(g), `dual-stack pruning must keep a ${g} candidate`);
}
assert.ok(kept.length <= LIMITS.MAX_CANDIDATES, 'count limit still respected');
// IPv6-only: nothing may be dropped for being v6.
const v6only = [
cand(KIND.HOST_V6, v6(1), 2001, 2113939199),
cand(KIND.SRFLX_V6, v6(2), 2002, 1677731583),
cand(KIND.RELAY_V6, v6(3), 2003, 50341375),
cand(KIND.RELAY_V6, v6(4), 2004, 16786687),
];
const keptV6 = pruneCandidates(v6only);
assert.equal(new Set(keptV6.map((c) => KIND_TYPE[c.kind])).size, 3, 'host/srflx/relay all survive on v6-only');
assert.ok(keptV6.every((c) => KIND_FAMILY[c.kind] === 'v6'));
// NAT64: the synthesised v6 prefix carries a dotted-quad tail.
const nat64 = parseSdp([
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 1677729535 64:ff9b::203.0.113.7 40000 typ srflx',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('AA').join(':'), 'a=setup:actpass',
].join('\r\n'));
assert.equal(nat64.candidates.length, 1, 'NAT64 address parses');
assert.deepEqual([...nat64.candidates[0].addr].slice(12), [203, 0, 113, 7], 'dotted-quad tail preserved');
assert.equal(KIND_FAMILY[nat64.candidates[0].kind], 'v6');
// Coverage wins over the byte budget: a connection that can be made is
// worth more than a smaller QR.
const tight = pruneCandidates(dual, { maxBytes: 10 });
assert.ok(new Set(tight.map((c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}`)).size === 7,
'coverage is not sacrificed to the byte budget');
assert.equal(tight.length, 7, 'but nothing beyond coverage is admitted under a tight budget');
// Relay cap applies only to the surplus, never to coverage.
const manyRelays = pruneCandidates(dual, { maxRelays: 1 });
assert.equal(manyRelays.filter((c) => KIND_TYPE[c.kind] === 'relay').length, 2,
'one v4 relay + one v6 relay: both are coverage, so the cap does not evict either');
// Exact duplicates collapse.
const dupes = [cand(KIND.SRFLX_V4, v4(1, 2, 3, 4), 5, 100), cand(KIND.SRFLX_V4, v4(1, 2, 3, 4), 5, 100)];
assert.equal(pruneCandidates(dupes).length, 1);
assert.equal(candidateSize({ kind: KIND.SRFLX_V4 }), 7);
assert.equal(candidateSize({ kind: KIND.HOST_MDNS }), 19);
assert.equal(candidateSize({ kind: KIND.RELAY_V6 }), 19);
}
// ---------------------------------------------------------------------------
// IPv6 round-trip through the wire format
// ---------------------------------------------------------------------------
{
const sdp = [
'v=0', 'o=- 1 2 IN IP4 127.0.0.1', 's=-', 't=0 0',
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0',
'a=candidate:1 1 udp 2113939199 2001:db8::1 40000 typ host',
'a=candidate:2 1 udp 1677731583 2001:0db8:0000:0000:0000:ff00:0042:8329 40001 typ srflx',
'a=candidate:3 1 udp 50341375 64:ff9b::198.51.100.9 40002 typ relay',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase()).join(':'),
'a=setup:active', 'a=mid:0', 'a=sctp-port:5000',
].join('\r\n') + '\r\n';
const desc = decodeDescriptor(await build(sdp, TYPE.ANSWER));
const re = parseSdp(serializeSdp(desc).sdp);
assert.equal(re.candidates.length, 3);
assert.deepEqual([...re.candidates[0].addr].slice(0, 4), [0x20, 0x01, 0x0d, 0xb8], 'IPv6 :: expansion round-trips');
assert.deepEqual([...re.candidates[2].addr].slice(12), [198, 51, 100, 9], 'NAT64 tail round-trips');
}
// ---------------------------------------------------------------------------
// TCP candidates
// ---------------------------------------------------------------------------
{
const sdp = [
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 tcp 1518280447 192.168.1.9 9 typ host tcptype active',
'a=candidate:2 1 tcp 1518149375 192.168.1.9 50000 typ host tcptype passive',
'a=candidate:3 1 tcp 1509957375 203.0.113.4 50001 typ srflx raddr 192.168.1.9 rport 50000',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('BB').join(':'), 'a=setup:actpass',
].join('\r\n') + '\r\n';
const parsed = parseSdp(sdp);
// The srflx line has no tcptype, which makes it unusable and it is dropped.
assert.equal(parsed.candidates.length, 2, 'TCP candidates without tcptype are dropped');
assert.deepEqual(parsed.candidates.map((c) => c.tcptype).sort(), [1, 2]);
const desc = decodeDescriptor(await build(sdp, TYPE.OFFER));
const out = serializeSdp(desc).sdp;
assert.match(out, /tcptype passive/, 'passive tcptype re-emitted');
assert.match(out, /tcptype active/, 'active tcptype re-emitted');
const re = parseSdp(out);
assert.deepEqual(re.candidates.map((c) => c.tcptype).sort(), [1, 2], 'tcptype survives the round trip');
}
// ---------------------------------------------------------------------------
// TLV extension area
// ---------------------------------------------------------------------------
{
const base = [
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('CC').join(':'), 'a=setup:actpass',
];
const withMms = (v) => (base.concat(v === null ? [] : [`a=max-message-size:${v}`]).join('\r\n') + '\r\n');
// The three well-known values stay in the flags: zero extension bytes.
const flagged = await build(withMms(262144), TYPE.OFFER);
assert.equal(decodeDescriptor(flagged).maxMessageSize, 262144);
assert.equal(decodeDescriptor(flagged).extensions.size, 0);
assert.equal((flagged[1] & 0x80), 0, 'no extension area when the enum suffices');
assert.equal(decodeDescriptor(await build(withMms(1073741823), TYPE.OFFER)).maxMessageSize, 1073741823);
assert.equal(decodeDescriptor(await build(withMms(null), TYPE.OFFER)).maxMessageSize, 65536,
'an absent attribute means 64 KiB per RFC 8841');
// A fifth value rides in a TLV record instead of being silently downgraded.
const odd = await build(withMms(131072), TYPE.OFFER);
const oddDesc = decodeDescriptor(odd);
assert.equal(oddDesc.maxMessageSize, 131072, 'explicit max-message-size survives');
assert.equal(oddDesc.extensions.get(EXT.MAX_MESSAGE_SIZE), 131072);
assert.equal((odd[1] & 0x80) !== 0, true, 'extension flag set');
assert.equal(odd.length - flagged.length, 7, 'the TLV costs exactly ext_len + type + len + 4');
// Unknown types are refused, not skipped. This is the downgrade defence.
const unknown = Uint8Array.from(odd);
unknown[unknown.length - 6] = 0x7f; // rewrite the record type
rejects(() => decodeDescriptor(unknown), /unknown extension type 0x7f/, 'unknown extension type');
// Length games inside the area.
const badLen = Uint8Array.from(odd); badLen[badLen.length - 5] = 3;
rejects(() => decodeDescriptor(badLen), /must be 4 bytes/, 'wrong TLV length');
const overrun = Uint8Array.from(odd); overrun[overrun.length - 5] = 200;
rejects(() => decodeDescriptor(overrun), /truncated/, 'TLV length overruns the area');
const emptyArea = Uint8Array.from(flagged.subarray(0, flagged.length));
const withEmpty = new Uint8Array(emptyArea.length + 1);
withEmpty.set(emptyArea); withEmpty[1] |= 0x80; withEmpty[withEmpty.length - 1] = 0;
rejects(() => decodeDescriptor(withEmpty), /flagged but empty/, 'empty extension area');
// Ordering and duplicates: exactly one valid spelling per descriptor.
const dup = new Uint8Array(odd.length + 6);
dup.set(odd.subarray(0, odd.length - 7));
dup[odd.length - 7] = 12; // ext_len = two records
dup.set(odd.subarray(odd.length - 6), odd.length - 6);
dup.set(odd.subarray(odd.length - 6), odd.length);
rejects(() => decodeDescriptor(dup), /ascending type order/, 'duplicate extension record');
// Flags and area must agree in both directions.
const promised = Uint8Array.from(flagged); promised[1] |= 0x30; // mmsIndex = 3, no area
rejects(() => decodeDescriptor(promised), /no extension carries it/, 'explicit promised but absent');
const unselected = Uint8Array.from(odd); unselected[1] &= ~0x30; // area present, enum selected
rejects(() => decodeDescriptor(unselected), /do not select it/, 'extension present but unselected');
// A TLV that merely restates an enum value is non-canonical.
const restate = Uint8Array.from(odd);
restate[restate.length - 4] = 0x00; restate[restate.length - 3] = 0x04;
restate[restate.length - 2] = 0x00; restate[restate.length - 1] = 0x00; // 262144
rejects(() => decodeDescriptor(restate), /duplicates a value the flags already encode/, 'non-canonical TLV');
}
// ---------------------------------------------------------------------------
// rejection: malformed / hostile descriptors
// ---------------------------------------------------------------------------
{
const good = await build(chrome.turn_all.offer, TYPE.OFFER);
const UF = ufragLenOffset(TYPE.OFFER);
rejects(() => decodeDescriptor(new Uint8Array(0)), /empty/, 'empty');
rejects(() => decodeDescriptor(good.subarray(0, good.length - 1)), /truncated/, 'truncated');
rejects(() => decodeDescriptor(good.subarray(0, 20)), /truncated/, 'heavily truncated');
// Version gate: a v1 byte is an error, never an attempt at the old parser.
for (const v of [0x00, 0x01, 0x03, 0xff]) {
const x = Uint8Array.from(good); x[0] = v;
rejects(() => decodeDescriptor(x), /unsupported descriptor version/, `version 0x${v.toString(16)}`);
}
// Trailing bytes: never silently ignored.
const trailing = new Uint8Array(good.length + 3);
trailing.set(good); trailing.set([1, 2, 3], good.length);
rejects(() => decodeDescriptor(trailing), /trailing byte/, 'trailing bytes');
// Reserved VALUES are refused. (Every flag bit is now allocated; growth goes
// through the TLV area, which is itself deny-by-default.)
const reservedType = Uint8Array.from(good); reservedType[1] = (reservedType[1] & ~0x03) | 0x02;
rejects(() => decodeDescriptor(reservedType), /reserved descriptor type/, 'reserved type');
const reservedSetup = Uint8Array.from(good); reservedSetup[1] = (reservedSetup[1] & ~0x0c) | 0x0c;
rejects(() => decodeDescriptor(reservedSetup), /reserved DTLS setup role/, 'reserved setup');
rejects(() => decodeDescriptor(new Uint8Array(LIMITS.MAX_PAYLOAD_BYTES + 1)), /payload limit/, 'oversized');
// Candidate count and kind.
const pwdOff = UF + 1 + good[UF];
const countOff = pwdOff + 1 + good[pwdOff];
const tooMany = Uint8Array.from(good); tooMany[countOff] = LIMITS.MAX_CANDIDATES + 1;
rejects(() => decodeDescriptor(tooMany), /too many candidates/, 'candidate count over limit');
const badKind = Uint8Array.from(good); badKind[countOff + 1] = 0xf0;
rejects(() => decodeDescriptor(badKind), /reserved candidate kind/, 'reserved candidate kind');
const badTcp = Uint8Array.from(good); badTcp[countOff + 1] = (badTcp[countOff + 1] & 0xf0) | 0x0f;
rejects(() => decodeDescriptor(badTcp), /reserved TCP candidate type/, 'reserved tcptype');
const firstKind = good[countOff + 1] >> 4;
const portOff = countOff + 2 + (firstKind === 1 || firstKind >= 4 ? 16 : 4);
const zeroPort = Uint8Array.from(good); zeroPort[portOff] = 0; zeroPort[portOff + 1] = 0;
rejects(() => decodeDescriptor(zeroPort), /port must be non-zero/, 'zero port');
// ICE credential alphabet and length.
const badUfragLen = Uint8Array.from(good); badUfragLen[UF] = 2;
rejects(() => decodeDescriptor(badUfragLen), /ice-ufrag length out of range/, 'short ufrag');
const badUfragChar = Uint8Array.from(good); badUfragChar[UF + 1] = '!'.charCodeAt(0);
rejects(() => decodeDescriptor(badUfragChar), /ICE alphabet/, 'ufrag alphabet');
// A CR or LF inside a credential is what an SDP-injection attempt looks
// like; it must die in the decoder, long before the serializer.
for (const evil of [0x0d, 0x0a, 0x00]) {
const inj = Uint8Array.from(good); inj[UF + 1] = evil;
rejects(() => decodeDescriptor(inj), /non-printable byte/, `injected byte 0x${evil.toString(16)}`);
}
}
// ---------------------------------------------------------------------------
// expiry, clock skew, one-shot
// ---------------------------------------------------------------------------
{
const bytes = await build(chrome.stun.offer, TYPE.OFFER, { expiresAtMs: Date.now() + 60_000 });
assert.ok(decodeDescriptor(bytes, { nowMs: Date.now() }), 'valid inside the window');
// Clock skew: a receiver running a minute fast still accepts.
assert.ok(decodeDescriptor(bytes, { nowMs: Date.now() + 60_000 + 60_000 }),
'one minute of receiver skew past expiry is tolerated');
rejects(() => decodeDescriptor(bytes, { nowMs: Date.now() + 60_000 + LIMITS.CLOCK_SKEW_MS + 60_000 }),
/expired/, 'beyond the skew allowance');
// The error must point at the clock, because that is the likely cause.
rejects(() => decodeDescriptor(bytes, { nowMs: Date.now() + 3600_000 }),
/clock or time zone/, 'expiry error names the clock');
try {
decodeDescriptor(bytes, { nowMs: Date.now() + 3600_000 });
} catch (e) {
assert.equal(e.code, 'expired', 'expiry carries a machine-readable code');
}
// An attacker cannot mint a descriptor that never dies.
const eternal = await build(chrome.stun.offer, TYPE.OFFER, {
expiresAtMs: Date.now() + 365 * 24 * 3600 * 1000,
});
rejects(() => decodeDescriptor(eternal), /lifetime is implausibly long/, 'year-long lifetime');
// The answer's replay defence: its tag must be the binding tag of the exact
// offer being shown, so an answer to any other offer is refused and each
// offer is consumed once.
const offerA = await build(chrome.stun.offer, TYPE.OFFER);
const offerB = await build(chrome.turn_all.offer, TYPE.OFFER);
const tagA = await bindingTag(digest, offerA);
const answer = await build(chrome.stun.answer, TYPE.ANSWER, { bindingTag: tagA });
assert.deepEqual(decodeDescriptor(answer).bindingTag, tagA, 'answer binds to its offer');
assert.notDeepEqual(decodeDescriptor(answer).bindingTag, await bindingTag(digest, offerB), 'not to a different offer');
// The offer carries no nonce of its own; its uniqueness rides on ice-pwd,
// which is inside the hashed bytes. Two offers differing only there must
// still produce different tags.
const raw = parseSdp(chrome.stun.offer);
const mk = async (pwd) => encodeDescriptor({
type: TYPE.OFFER, expiresAtMs: EXPIRY(),
sdpFields: { ...raw, pwd, candidates: pruneCandidates(raw.candidates) },
commitment: null,
});
const t1 = await bindingTag(digest, await mk('0123456789abcdef01234567'));
const t2 = await bindingTag(digest, await mk('0123456789abcdef01234568'));
assert.notDeepEqual(t1, t2, 'ice-pwd alone makes the offer unique');
const tampered = Uint8Array.from(offerA); tampered[40] ^= 0x01;
assert.notDeepEqual(await bindingTag(digest, tampered), tagA, 'binding tag is sensitive to the offer bytes');
}
// ---------------------------------------------------------------------------
// SAS transcript coverage
// ---------------------------------------------------------------------------
{
const offer = await build(chrome.turn_all.offer, TYPE.OFFER);
const answer = await build(chrome.turn_all.answer, TYPE.ANSWER);
const extOffer = await build([
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('DD').join(':'),
'a=setup:actpass', 'a=max-message-size:131072',
].join('\r\n') + '\r\n', TYPE.OFFER);
const blobO = rnd(400);
const blobA = rnd(400);
const base = await digest(sasTranscript(offer, answer, blobO, blobA));
for (const [label, mutate] of [
['offer version byte', () => { const x = Uint8Array.from(offer); x[0] = 3; return [x, answer, blobO, blobA]; }],
['offer flags', () => { const x = Uint8Array.from(offer); x[1] ^= 0x10; return [x, answer, blobO, blobA]; }],
['offer expiry', () => { const x = Uint8Array.from(offer); x[4] ^= 0x01; return [x, answer, blobO, blobA]; }],
['offer fingerprint', () => { const x = Uint8Array.from(offer); x[6] ^= 0xff; return [x, answer, blobO, blobA]; }],
['offer ice-pwd', () => { const x = Uint8Array.from(offer); x[50] ^= 0x01; return [x, answer, blobO, blobA]; }],
['offer candidate port', () => { const x = Uint8Array.from(offer); x[x.length - 20] ^= 0x01; return [x, answer, blobO, blobA]; }],
['answer binding tag', () => { const x = Uint8Array.from(answer); x[6] ^= 0xff; return [offer, x, blobO, blobA]; }],
['answer bytes', () => { const x = Uint8Array.from(answer); x[20] ^= 0xff; return [offer, x, blobO, blobA]; }],
['extension area', () => { const x = Uint8Array.from(extOffer); x[x.length - 1] ^= 0x01; return [x, answer, blobO, blobA]; }],
['offer blob', () => { const x = Uint8Array.from(blobO); x[7] ^= 0x01; return [offer, answer, x, blobA]; }],
['answer blob', () => { const x = Uint8Array.from(blobA); x[7] ^= 0x01; return [offer, answer, blobO, x]; }],
]) {
const args = mutate();
const h = await digest(sasTranscript(...args));
const ref = label === 'extension area'
? await digest(sasTranscript(extOffer, answer, blobO, blobA))
: base;
assert.notDeepEqual(h, ref, `SAS must change when the ${label} changes`);
}
// Length prefixes stop a boundary shift from producing a colliding
// transcript.
const shifted = await digest(sasTranscript(
offer.subarray(0, offer.length - 1),
new Uint8Array([offer[offer.length - 1], ...answer]),
blobO, blobA,
));
assert.notDeepEqual(shifted, base, 'field boundaries are unambiguous');
}
// ---------------------------------------------------------------------------
// commitment
// ---------------------------------------------------------------------------
{
const blob = rnd(400);
const c = await commitBlob(digest, blob);
assert.equal(c.length, LIMITS.COMMITMENT_BYTES);
const other = Uint8Array.from(blob); other[123] ^= 0x01;
assert.notDeepEqual(await commitBlob(digest, other), c, 'commitment binds every blob byte');
assert.deepEqual(decodeDescriptor(await build(chrome.stun.offer, TYPE.OFFER, { commitment: c })).commitment, c);
assert.equal(decodeDescriptor(await build(chrome.stun.offer, TYPE.OFFER, { commitment: null })).commitment, null);
}
// ---------------------------------------------------------------------------
// text transport
// ---------------------------------------------------------------------------
{
for (let n = 0; n < 200; n++) {
const b = rnd(n);
assert.deepEqual(fromBase64Url(toBase64Url(b)), b, `base64url round-trip at ${n} bytes`);
}
const bytes = await build(chrome.turn_all.offer, TYPE.OFFER);
const text = encodeText(bytes);
assert.ok(/^SB2:[A-Za-z0-9_-]+$/.test(text), 'text form uses only URL-safe characters');
assert.deepEqual(decodeText(text), bytes);
const wrapped = text.slice(0, 40) + '\n' + text.slice(40, 90) + ' \r\n' + text.slice(90);
assert.deepEqual(decodeText(wrapped), bytes, 'wrapped paste still decodes');
rejects(() => decodeText('SB1:bin:abcd'), /not an SB2 descriptor/, 'old prefix');
rejects(() => decodeText('SB2:abc$def'), /outside base64url/, 'bad alphabet');
rejects(() => decodeText('SB2:' + 'A'.repeat(5000)), /too long/, 'over-long text');
rejects(() => fromBase64Url('AAAAA'), /impossible length/, 'impossible base64 length');
rejects(() => fromBase64Url('AB'), /non-zero padding bits/, 'non-canonical tail');
}
// ---------------------------------------------------------------------------
// encoder-side input validation
// ---------------------------------------------------------------------------
{
const raw = parseSdp(chrome.stun.offer);
const fields = { ...raw, candidates: pruneCandidates(raw.candidates) };
const base = { type: TYPE.OFFER, expiresAtMs: EXPIRY(), sdpFields: fields };
rejects(() => encodeDescriptor({ ...base, type: 7 }), /invalid descriptor type/, 'bad type');
rejects(() => encodeDescriptor({ ...base, bindingTag: rnd(8) }), /offers do not carry a binding tag/, 'tag on an offer');
rejects(() => encodeDescriptor({ ...base, type: TYPE.ANSWER }), /answer needs an 8-byte binding tag/, 'answer without a tag');
rejects(() => encodeDescriptor({ ...base, type: TYPE.ANSWER, bindingTag: rnd(7) }), /8-byte binding tag/, 'short tag');
rejects(() => encodeDescriptor({ ...base, commitment: rnd(15) }), /commitment must be 16 bytes/, 'short commitment');
rejects(() => encodeDescriptor({ ...base, sdpFields: { ...fields, ufrag: 'a b' } }), /invalid ice-ufrag/, 'ufrag with a space');
rejects(() => encodeDescriptor({ ...base, sdpFields: { ...fields, pwd: 'short' } }), /invalid ice-pwd/, 'short pwd');
for (const bad of [null, undefined, 0, -1, 1.5, 2 ** 31]) {
rejects(
() => encodeDescriptor({ ...base, sdpFields: { ...fields, maxMessageSize: bad } }),
/max-message-size must be an integer/, `max-message-size ${bad}`,
);
}
rejects(
() => encodeDescriptor({ ...base, sdpFields: { ...fields, candidates: new Array(9).fill(fields.candidates[0]) } }),
/too many candidates/, 'too many candidates',
);
rejects(() => parseSdp('v=0\r\n'), /missing ICE credentials/, 'no ICE creds');
rejects(
() => parseSdp('a=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\n'),
/missing a DTLS fingerprint/, 'no fingerprint',
);
rejects(
() => parseSdp('a=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\na=fingerprint:sha-1 AA:BB\r\n'),
/unsupported DTLS fingerprint algorithm/, 'sha-1 fingerprint',
);
}
console.log(`descriptor-sbq2: all assertions passed${firefox ? ' (chrome + firefox fixtures)' : ' (chrome fixtures only)'}`);