15 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: live. Shipped in v5.9.0 with the in-band key exchange; new invitations use SBQ2 and SB1 is still read. See Migration.
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 | state |
|---|---|
| 1 | Codec and tests in the tree. Done, v5.8.0/v5.8.1. |
| 2 | Receiver accepts both formats. Done, v5.9.0 — reception is unconditional and not governed by the send flag. |
| 3 | Sender emits SBQ2. Done, v5.9.0, together with the in-band key exchange that made it safe. |
| 4 | Remove SB1 emission (already gone) and, after a deprecation window, SB1 parsing along with cose-qr.js, inflateBounded and the animated multi-frame QR path. Not yet — the animated path is still required while SB1 invitations are accepted. |
Rollback
EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED = false and redeploy. That single
value governs only what is emitted; a client built with it off still reads SBQ2
invitations, so the two ends never have to be rolled back together.
The switch is deliberately not consulted in the receive path and never inside an
established session. _handshakeMode latches per connection, so a session that
began as SBQ2 cannot be pushed back onto SB1 partway through — every SBQ2 failure
tears the connection down instead.
What phase 3 depended on, and what landed
Phase 3 was blocked on the in-band key exchange, because shrinking the descriptor
without it would have removed key material from the invitation with nothing
carrying it instead. v5.9.0 delivered that: a key-exchange phase after the
DataChannel opens, the commitment verified before the blob is parsed, the
transcript SAS in place of _computeSAS, the salt derived instead of
transmitted, authProof replaced by a signature over the transcript, and the
Double Ratchet started from the transcript-derived material.
Compatibility
A client older than v5.9.0 cannot read an SBQ2 invitation.
From v5.9.0 onward an unrecognised SB<n>: family is reported as "This
invitation was created by a newer version of SecureBit. Please update the app to
connect." — so the next format change explains itself.
Versions 5.8.1 and earlier predate that check: they fall through to
JSON.parse and show Invalid invitation format: Unexpected token 'S', "SB2:Ak…" is not valid JSON. That is accurate but unhelpful, and it cannot be fixed
retroactively in builds already shipped. Both ends must be on 5.9.0+.
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.