Live cross-browser testing found three defects in the SBQ2 SDP template, all
invisible to Chrome and all fatal to Firefox.
The candidate lines omitted raddr/rport on srflx and relay candidates. RFC 8839
section 5.1 makes rel-addr and rel-port mandatory for non-host candidates even
though ICE never reads them; Chrome tolerates the omission and Firefox drops the
candidate. Relay-only connections to Firefox failed 0/8 against 8/8 for the
browser's own SDP. The STUN and TURN profiles hid it because a host pair
connected instead -- the relay candidates were never actually needed there.
The template also advertised ice-options:trickle without ever closing the
candidate set, though a descriptor is a complete one-shot set with no channel to
trickle over, and hard-coded the m= port and c= line to the 9 / 0.0.0.0 null
default candidate, which is the trickle convention for "nothing gathered yet".
Both are now correct: no trickle, an explicit a=end-of-candidates, and the most
publicly reachable candidate as the default.
All three are serializer-side and cost zero descriptor bytes; sizes are
unchanged at 98-149 bytes, QR version 6-8. Verified 48/48 across all 16
combinations of {Chrome, Firefox} squared and four network profiles, with every
relay-only pair now connecting over the relay.
14 KiB
SBQ2 — connection descriptor v2
The descriptor is the blob a user carries from one device to the other by hand: a QR code, a deep link, or a paste into another messenger. There is no signalling server, so this is the only channel that exists before the peers can talk.
SBQ2 replaces the SB1:bin: format (CBOR + zlib + base64url of the whole offer
package). Measured on real Chrome and Firefox SDP, a descriptor went from
2000–2400 characters to 98–149 bytes, and the QR from version 38–40 down to
version 6–8 at error-correction level M.
Status: specified and implemented, not wired into the connection path. See Migration for the gate on phase 3.
1. What travels where
Out of band (this descriptor): ICE credentials, the DTLS certificate fingerprint, the candidate list, an expiry, and a 16-byte commitment.
In band (over the DataChannel, once DTLS is up): identity key, ECDH key, signatures — everything that used to make the descriptor large.
The fingerprint is what makes that split safe. It arrives over the channel the user already trusts, and DTLS completes only with the holder of the matching private key, so the transport is authenticated to whoever showed the code before any key material moves. The commitment makes substitution of that material fail closed automatically rather than relying on the human comparison, and the SAS covers a transcript containing both descriptors verbatim and both in-band blobs.
2. Wire layout
All integers big-endian. Offsets are for the offer; an answer inserts its 8-byte binding tag at offset 5 and everything after shifts by 8.
off len field
0 1 version = 0x02 mismatch is an error, never a reparse
1 1 flags
2 3 expiry u24, minutes since 2024-01-01T00:00:00Z
5 [8] binding_tag ANSWERS ONLY
.. 32 dtls_fingerprint SHA-256 of the certificate, raw
.. 1 ufrag_len 4..64
.. L1 ufrag ASCII, RFC 8839 ice-char alphabet
.. 1 pwd_len 22..64
.. L2 pwd ASCII, RFC 8839 ice-char alphabet
.. 1 candidate_count 0..8
.. .. candidates
.. [16] commitment if flags bit 6
.. [1] ext_len if flags bit 7
.. .. TLV records if flags bit 7
flags
| bits | meaning |
|---|---|
| 0–1 | type: 0 offer, 1 answer. 2 and 3 are reserved → reject |
| 2–3 | DTLS setup role: 0 actpass, 1 active, 2 passive. 3 reserved → reject |
| 4–5 | max-message-size: 0 = 262144, 1 = 1073741823, 2 = 65536, 3 = explicit, in extension 0x01 |
| 6 | a commitment follows the candidates |
| 7 | a TLV extension area follows |
Every bit is allocated. Future fields go in the TLV area, which is itself deny-by-default; there is deliberately no spare "ignore me" bit.
candidate
1 byte kind << 4 | tcptype
n bytes address (v4 = 4, v6 = 16, mDNS UUID = 16)
2 bytes port
kind: 0 host-v4, 1 host-mDNS, 2 srflx-v4, 3 relay-v4, 4 host-v6, 5 srflx-v6,
6 relay-v6. 7–15 reserved → reject.
tcptype: 0 udp, 1 tcp/passive, 2 tcp/active, 3 tcp/so. 4–15 → reject.
Foundation and priority are not transmitted. Priority only orders
connectivity checks, and each peer computes its own local priorities anyway; the
serializer re-derives RFC 8445 §5.1.2.1 values with localPref = 65535 - index,
so the sender's ordering intent survives at zero cost. Foundations are grouped by
kind and transport, satisfying both halves of §5.1.1.3. generation and
network-cost are Chrome extensions and are dropped.
What the serializer must emit anyway
Three things are not in the descriptor but must appear in the rebuilt SDP. All three are constants or derived from data already present, so none costs a byte — and each was found by a live cross-browser test rather than by reading the spec.
-
raddr/rporton every srflx, prflx and relay candidate. RFC 8839 §5.1 makes rel-addr and rel-port mandatory for non-host candidates even though ICE's own algorithm never reads them. An earlier draft left them out as "diagnostics". Chrome accepts such a line; Firefox drops the candidate. Measured: relay-only connections to Firefox went 0/8 while the browser's own SDP went 8/8, and the fault was invisible in the STUN and TURN profiles because a host pair connected instead.0.0.0.0/0(or::/0) is emitted — the same placeholder Chrome uses when it has no base address to disclose. -
No
a=ice-options:trickle, and an explicita=end-of-candidates. A descriptor is a complete one-shot candidate set; there is no channel to trickle over. Advertising trickle promises candidates that can never arrive and leaves the peer waiting for them. -
A real default candidate on the
m=port andc=line.m=application 9withc=IN IP4 0.0.0.0is the trickle convention for "nothing gathered yet" (RFC 8840 §4.1) and is simply false here. The serializer advertises the most publicly reachable candidate — relay, then srflx, then host, which is the order Chrome uses and the reverse of ICE priority — falling back to the null form only when every candidate is mDNS, which is also what Chrome does.
TLV extension area
ext_len u8, 1..255, must be consumed exactly
record: type u8, len u8, value[len]
Records must appear in ascending type order with no duplicates, so every descriptor has exactly one valid spelling. An unknown type is a hard error.
| type | len | value |
|---|---|---|
0x01 |
4 | max-message-size, u32, 1024..2^31-1, and not equal to a value the flags already encode |
3. Sizes measured
Real SDP, identical ICE configuration on both peers, each peer gathering in its own browser process. QR versions are byte mode at level M.
| browser | profile | offer | QR | answer | QR |
|---|---|---|---|---|---|
| Chrome | host_only | 103 B | v6 | 111 B | v7 |
| Chrome | stun | 110 B | v7 | 118 B | v7 |
| Chrome | turn_all | 124 B | v8 | 132 B | v8 |
| Chrome | turn_relay_only | 98 B | v6 | 106 B | v6 |
| Firefox | host_only | 134 B | v8 | 142 B | v8 |
| Firefox | stun | 141 B | v8 | 149 B | v8 |
| Firefox | turn_all | 136 B | v8 | 144 B | v8 |
| Firefox | turn_relay_only | 110 B | v7 | 118 B | v7 |
Firefox descriptors run ~12 bytes larger because its ICE credentials are longer (8-char ufrag and 32-char pwd against Chrome's 4 and 24).
Transport: raw bytes in QR byte mode. base45 buys nothing (198 alphanumeric
characters = 1089 bits against 1056 bits raw for the same 132-byte payload), and
base64url costs a QR version. For text channels the form is SB2: + base64url,
whose alphabet survives messenger auto-formatting; the decoder strips whitespace
so a wrapped paste still works. DEFLATE is not used — on this payload it adds
2 to 11 bytes, and dropping it removes the decompression-bomb surface with it.
4. Candidate pruning: coverage before count
A count limit is the wrong policy. Sorted v4-first it can evict the only usable candidate on an IPv6-only network, which is a normal mode on several mobile carriers, and one that ignores transport can evict the TCP candidate that exists precisely for networks where UDP is blocked.
The rule, in order:
- Coverage. Every
(address family, candidate type, transport)combination present in the input keeps its highest-priority representative. Families arev4,v6andmdns— mDNS is its own family because it resolves only on the sender's link, covering a case neither of the others does. Coverage is never cut, not even to stay inside the byte budget: a QR one version larger costs less than a connection that cannot be made. - Surplus, by the sender's own priority, until either
MAX_CANDIDATES(8) orSURPLUS_CANDIDATE_BYTES(48) runs out, with relays capped at 2.
Candidates a peer cannot dial — ICE-TCP active and so, which are
outbound-only sockets on the discard port — are excluded from coverage and
compete only for surplus. Firefox advertises an active host candidate on every
connection; at 19 bytes for an mDNS address it must not hold a coverage slot it
cannot use.
The 48-byte surplus budget is derived, not chosen: the largest answer head measured is Firefox's at 104 bytes, and QR version 8 at level M holds 152, so 152 − 104 = 48.
The relay cap of 2 applies only to surplus. A TURN server offering udp/tcp/tls hands out one allocation per transport and they all resolve to the same relayed address, so the third adds no reachability; two survive in case one allocation's binding dies.
5. Freshness, uniqueness, one-shot
Expiry is absolute, at minute granularity, with a ±2-minute skew allowance. Two minutes is sized against the failure it exists for: an NTP-synced device is within milliseconds and an unsynced modern device drifts seconds per day, so two minutes swallows every ordinary case while still refusing a grossly wrong clock (manually set, or reset by a dead battery) — a device that cannot be given a meaningful freshness guarantee should be told so, and the error message names the clock as the likely cause. The cost is a replay window of 12 minutes instead of 10.
The offer carries no nonce. It does not need one: ice-pwd is in the hashed
bytes, RFC 8839 §5.4 requires it to contain at least 128 bits of randomness, and
every browser regenerates it per peer connection and per ICE restart. A separate
8-byte random field would have been 8 bytes restating entropy already present.
The answer carries an 8-byte binding tag, SHA-256("sbq2/bind\0" || offer_bytes)[0..8]. The offerer keeps the tag of the offer it is currently
showing and refuses anything else, which is both the answer's replay defence and
what makes each offer exactly one-shot — without any stored state between
sessions.
Limitation, on the record: 64 bits is not a standalone integrity primitive. The tag is a duplicate-detection device whose security comes from the SAS transcript, which covers both descriptors in full. Nothing may be built on this tag alone. If a future change needs one, widen the field rather than lean on it.
6. SAS
transcript = "sbq2/sas/v1\0"
|| len32(offer_bytes) || offer_bytes
|| len32(answer_bytes) || answer_bytes
|| len32(offer_blob) || offer_blob
|| len32(answer_blob) || answer_blob
SAS = HKDF-SHA256(IKM = ECDH shared secret,
salt = SHA-256(transcript),
info = "sbq2-sas-v1") -> 64 bits -> 7 digits
The transcript covers both descriptors byte for byte — version, flags, expiry, binding tag, fingerprints, ICE credentials, every candidate, the commitment and the whole extension area — plus both in-band blobs. Lengths are prefixed so no field boundary can be shifted to produce a colliding transcript.
7. Migration
The two formats separate without heuristics: an SBQ2 QR starts with byte 0x02,
an SB1 payload starts with ASCII S (0x53); in text, the prefixes are SB2:
and SB1:bin: / SB1:gz:.
| phase | change |
|---|---|
| 1 (done) | Codec and tests in the tree. Nothing in the connection path changes. |
| 2 | Receiver accepts both. Try SBQ2 first, fall back to the SB1 parser. Sender still emits SB1:. |
| 3 | Sender switches to SBQ2 behind a flag. Gated — see below. |
| 4 | SB1: emission removed; SB1 parsing kept one more release, then deleted along with cose-qr.js, inflateBounded, and the animated multi-frame QR path in app.jsx. |
Phase 3 is blocked on the in-band key exchange
The security argument in §1 describes a protocol that does not exist in the
code yet. Today EnhancedSecureWebRTCManager still ships the key material
inside the descriptor, still computes the SAS in _computeSAS from the DTLS
fingerprints alone, and still sends authProof. Shrinking the descriptor without
that delivery would remove the key material from the QR with nothing carrying it
instead.
Phase 3 must not be enabled until a separate delivery lands:
- a key-exchange phase after the DataChannel opens,
- verification of the commitment before any use of the blob,
_computeSASreplaced by the transcript SAS above,- the session salt derived from the transcript instead of transmitted,
authProofreplaced by a signature over the transcript,- interlock with the Double Ratchet start,
each with its own tests. Phases 1 and 2 are safe to ship without it, because neither changes what is sent.
8. Decoder rules
The decoder parses fully attacker-controlled input.
- Payload ceiling (512 B) checked before any structure is walked.
- Version compared first; a mismatch throws.
- Reserved values (descriptor type 2–3, setup role 3, candidate kind 7–15, tcptype 4–15) are refused, never coerced.
- Unknown TLV types are refused. Records must be ascending and unique, and a TLV restating a value the flags already encode is refused as non-canonical.
- Flags and extension area must agree in both directions:
mms = 3without extension0x01is an error, and extension0x01withoutmms = 3is too. - ufrag and pwd are range-checked and alphabet-checked; any byte outside printable ASCII fails before the value is used, so a CR or LF cannot reach the serializer and inject an SDP line.
- Trailing bytes after the structure are an error. A decoder that tolerated them would let a second reading of the same QR slip past whatever hashed the canonical form.
- Base64url input must be canonical: non-zero padding bits are refused, so a descriptor has exactly one textual spelling.
9. Provenance of the numbers
Everything above was measured, not estimated.
- Chrome fixtures:
tests/fixtures/sdp-chrome.json, captured over CDP from a real Chrome across four network profiles, both peers configured identically, each gathering in isolation. - Firefox fixtures:
tests/fixtures/sdp-firefox.json, Firefox 153 over Marionette, same method. - Round-trip, rejection, coverage, TLV, skew and transcript tests:
tests/descriptor-sbq2.test.mjs.
The original brief's payload (CBOR 2391 B, 991 B of SDP) was an offer — it
carries sl, si, vc and ac, and has no ap block. An earlier draft of the
analysis matched it against a STUN-profile answer on the strength of the post-
zlib and post-base64 sizes; those agreed by coincidence while the CBOR sizes
differ by 135 bytes. Conclusions were unaffected, but the attribution was wrong.