# 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](#7-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` / `rport` on 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 explicit `a=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 and `c=` line.** `m=application 9`
  with `c=IN IP4 0.0.0.0` is 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:

1. **Coverage.** Every `(address family, candidate type, transport)` combination
   present in the input keeps its highest-priority representative. Families are
   `v4`, `v6` and `mdns` — 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.
2. **Surplus,** by the sender's own priority, until either `MAX_CANDIDATES` (8)
   or `SURPLUS_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 = 3` without
  extension `0x01` is an error, and extension `0x01` without `mms = 3` is 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.
