502 lines
28 KiB
HTML
502 lines
28 KiB
HTML
<!DOCTYPE html>
|
||||
|
|
<!-- Generated by scripts/build-docs.js from doc/*.md. Edits here are overwritten;
|
|||
|
|
change the Markdown instead. -->
|
|||
|
|
<html lang="en" dir="ltr">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;">
|
|||
|
|
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
|||
|
|
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
|
|||
|
|
<title>SBQ2 — connection descriptor v2 - SecureBit.chat</title>
|
|||
|
|
<meta name="description" content="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…">
|
|||
|
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
|
|||
|
|
<link rel="canonical" href="https://securebit.chat/docs/descriptor-sbq2/">
|
|||
|
|
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
|
|||
|
|
<meta property="og:site_name" content="SecureBit.chat">
|
|||
|
|
<meta property="og:title" content="SBQ2 — connection descriptor v2 - SecureBit.chat">
|
|||
|
|
<meta property="og:description" content="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…">
|
|||
|
|
<meta property="og:url" content="https://securebit.chat/docs/descriptor-sbq2/">
|
|||
|
|
<meta property="og:type" content="article">
|
|||
|
|
<meta property="og:locale" content="en_US">
|
|||
|
|
<meta property="og:image" content="https://securebit.chat/assets/social-card.png">
|
|||
|
|
<meta name="twitter:card" content="summary_large_image">
|
|||
|
|
<meta name="twitter:title" content="SBQ2 — connection descriptor v2 - SecureBit.chat">
|
|||
|
|
<meta name="twitter:description" content="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…">
|
|||
|
|
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
|
|||
|
|
<script type="application/ld+json">
|
|||
|
|
{
|
|||
|
|
"@context": "https://schema.org",
|
|||
|
|
"@type": "TechArticle",
|
|||
|
|
"headline": "SBQ2 — connection descriptor v2",
|
|||
|
|
"description": "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…",
|
|||
|
|
"url": "https://securebit.chat/docs/descriptor-sbq2/",
|
|||
|
|
"inLanguage": "en",
|
|||
|
|
"isPartOf": {
|
|||
|
|
"@type": "WebSite",
|
|||
|
|
"@id": "https://securebit.chat/#website"
|
|||
|
|
},
|
|||
|
|
"publisher": {
|
|||
|
|
"@id": "https://securebit.chat/#organization"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
<style>
|
|||
|
|
:root { color-scheme: dark; }
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
body {
|
|||
|
|
margin: 0;
|
|||
|
|
background: #0f0f11;
|
|||
|
|
color: #d6d6dc;
|
|||
|
|
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|||
|
|
font-size: 16px;
|
|||
|
|
line-height: 1.68;
|
|||
|
|
}
|
|||
|
|
.wrap { max-width: 46rem; margin: 0 auto; padding: 28px 24px 90px; }
|
|||
|
|
.top {
|
|||
|
|
display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: baseline;
|
|||
|
|
padding-bottom: 16px; margin-bottom: 40px;
|
|||
|
|
border-bottom: 1px solid rgba(255,255,255,.08);
|
|||
|
|
font-size: 13.5px;
|
|||
|
|
}
|
|||
|
|
.top a { color: #8a8a92; text-decoration: none; }
|
|||
|
|
.top a:hover, .top a:focus-visible { color: #f0892a; }
|
|||
|
|
.top .brand { color: #f0892a; font-weight: 700; letter-spacing: .04em; }
|
|||
|
|
.top .here { color: #d6d6dc; margin-inline-start: auto; }
|
|||
|
|
h1 { font-size: clamp(28px, 5vw, 36px); font-weight: 800; letter-spacing: -1px; line-height: 1.14; color: #f4f4f6; margin: 0 0 24px; }
|
|||
|
|
h2 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; color: #f4f4f6; margin: 46px 0 12px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.07); }
|
|||
|
|
h3 { font-size: 17.5px; font-weight: 700; color: #e8e8eb; margin: 30px 0 8px; }
|
|||
|
|
h4 { font-size: 15.5px; font-weight: 700; color: #e8e8eb; margin: 22px 0 6px; }
|
|||
|
|
p, li { color: #a9a9b3; }
|
|||
|
|
p { margin: 0 0 16px; }
|
|||
|
|
ul, ol { padding-inline-start: 22px; margin: 0 0 16px; }
|
|||
|
|
li { margin: 5px 0; }
|
|||
|
|
a { color: #f0892a; text-underline-offset: 2px; }
|
|||
|
|
strong { color: #e8e8eb; }
|
|||
|
|
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .88em; background: #17171c; border: 1px solid rgba(255,255,255,.07); border-radius: 4px; padding: 1px 5px; color: #e8e8eb; }
|
|||
|
|
pre { background: #0b0b0e; border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 18px; }
|
|||
|
|
pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.62; color: #c9c9d1; }
|
|||
|
|
.tablewrap { overflow-x: auto; margin: 0 0 20px; }
|
|||
|
|
table { border-collapse: collapse; width: 100%; font-size: 14.5px; min-width: 30rem; }
|
|||
|
|
th { text-align: start; color: #8a8a92; font-weight: 600; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; padding: 0 14px 8px 0; border-bottom: 1px solid rgba(255,255,255,.12); }
|
|||
|
|
td { padding: 9px 14px 9px 0; border-bottom: 1px solid rgba(255,255,255,.06); vertical-align: top; color: #a9a9b3; }
|
|||
|
|
td:first-child, th:first-child { padding-inline-start: 0; }
|
|||
|
|
blockquote { margin: 0 0 18px; padding: 2px 0 2px 16px; border-inline-start: 3px solid rgba(240,137,42,.4); color: #8a8a92; }
|
|||
|
|
hr { border: 0; border-top: 1px solid rgba(255,255,255,.08); margin: 34px 0; }
|
|||
|
|
img { max-width: 100%; height: auto; }
|
|||
|
|
a:focus-visible { outline: 2px solid #f0892a; outline-offset: 2px; border-radius: 2px; }
|
|||
|
|
.more { margin-top: 64px; padding-top: 22px; border-top: 1px solid rgba(255,255,255,.08); }
|
|||
|
|
.more h2 { font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: #6b6b73; border: 0; margin: 0 0 12px; padding: 0; font-weight: 700; }
|
|||
|
|
.more ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 4px 24px; }
|
|||
|
|
.more li { margin: 0; padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,.05); font-size: 14.5px; }
|
|||
|
|
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="wrap">
|
|||
|
|
<nav class="top">
|
|||
|
|
<a class="brand" href="/">SecureBit.chat</a>
|
|||
|
|
<a href="/docs/">Documentation</a>
|
|||
|
|
<a href="https://github.com/SecureBitChat/securebit-chat" rel="noopener">GitHub</a>
|
|||
|
|
<span class="here">SBQ2 — connection descriptor v2</span>
|
|||
|
|
</nav>
|
|||
|
|
<h1>SBQ2 — connection descriptor v2</h1>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<p>SBQ2 replaces the <code>SB1:bin:</code> format (CBOR + zlib + base64url of the whole offer
|
|||
|
|
package). Measured on real Chrome and Firefox SDP, a descriptor went from
|
|||
|
|
2000–2400 characters to <strong>98–149 bytes</strong>, and the QR from version 38–40 down to
|
|||
|
|
<strong>version 6–8</strong> at error-correction level M.</p>
|
|||
|
|
<p><strong>Status: live.</strong> Shipped in v5.9.0 with the in-band key exchange; new invitations
|
|||
|
|
use SBQ2 and SB1 is still read. See <a href="#7-migration">Migration</a>.</p>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="1-what-travels-where">1. What travels where</h2>
|
|||
|
|
<p>Out of band (this descriptor): ICE credentials, the DTLS certificate
|
|||
|
|
fingerprint, the candidate list, an expiry, and a 16-byte commitment.</p>
|
|||
|
|
<p>In band (over the DataChannel, once DTLS is up): identity key, ECDH key,
|
|||
|
|
signatures — everything that used to make the descriptor large.</p>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="2-wire-layout">2. Wire layout</h2>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<pre><code>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
|
|||
|
|
</code></pre>
|
|||
|
|
<h3 id="flags">flags</h3>
|
|||
|
|
<div class="tablewrap"><table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>bits</th>
|
|||
|
|
<th>meaning</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody><tr>
|
|||
|
|
<td>0–1</td>
|
|||
|
|
<td>type: 0 offer, 1 answer. 2 and 3 are reserved → <strong>reject</strong></td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>2–3</td>
|
|||
|
|
<td>DTLS setup role: 0 actpass, 1 active, 2 passive. 3 reserved → <strong>reject</strong></td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>4–5</td>
|
|||
|
|
<td>max-message-size: 0 = 262144, 1 = 1073741823, 2 = 65536, 3 = explicit, in extension <code>0x01</code></td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>6</td>
|
|||
|
|
<td>a commitment follows the candidates</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>7</td>
|
|||
|
|
<td>a TLV extension area follows</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody></table></div>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<h3 id="candidate">candidate</h3>
|
|||
|
|
<pre><code>1 byte kind << 4 | tcptype
|
|||
|
|
n bytes address (v4 = 4, v6 = 16, mDNS UUID = 16)
|
|||
|
|
2 bytes port
|
|||
|
|
</code></pre>
|
|||
|
|
<p><code>kind</code>: 0 host-v4, 1 host-mDNS, 2 srflx-v4, 3 relay-v4, 4 host-v6, 5 srflx-v6,
|
|||
|
|
6 relay-v6. 7–15 reserved → <strong>reject</strong>.
|
|||
|
|
<code>tcptype</code>: 0 udp, 1 tcp/passive, 2 tcp/active, 3 tcp/so. 4–15 → <strong>reject</strong>.</p>
|
|||
|
|
<p>Foundation and priority are <strong>not</strong> 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 <code>localPref = 65535 - index</code>,
|
|||
|
|
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. <code>generation</code> and
|
|||
|
|
<code>network-cost</code> are Chrome extensions and are dropped.</p>
|
|||
|
|
<h3 id="what-the-serializer-must-emit-anyway">What the serializer must emit anyway</h3>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<ul>
|
|||
|
|
<li><p><strong><code>raddr</code> / <code>rport</code> on every srflx, prflx and relay candidate.</strong> 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; <strong>Firefox drops the candidate.</strong>
|
|||
|
|
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. <code>0.0.0.0</code>/<code>0</code> (or <code>::</code>/<code>0</code>) is emitted — the
|
|||
|
|
same placeholder Chrome uses when it has no base address to disclose.</p>
|
|||
|
|
</li>
|
|||
|
|
<li><p><strong>No <code>a=ice-options:trickle</code>, and an explicit <code>a=end-of-candidates</code>.</strong> 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.</p>
|
|||
|
|
</li>
|
|||
|
|
<li><p><strong>A real default candidate on the <code>m=</code> port and <code>c=</code> line.</strong> <code>m=application 9</code>
|
|||
|
|
with <code>c=IN IP4 0.0.0.0</code> 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.</p>
|
|||
|
|
</li>
|
|||
|
|
</ul>
|
|||
|
|
<h3 id="tlv-extension-area">TLV extension area</h3>
|
|||
|
|
<pre><code>ext_len u8, 1..255, must be consumed exactly
|
|||
|
|
record: type u8, len u8, value[len]
|
|||
|
|
</code></pre>
|
|||
|
|
<p>Records must appear in <strong>ascending type order with no duplicates</strong>, so every
|
|||
|
|
descriptor has exactly one valid spelling. An unknown type is a hard error.</p>
|
|||
|
|
<div class="tablewrap"><table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>type</th>
|
|||
|
|
<th>len</th>
|
|||
|
|
<th>value</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody><tr>
|
|||
|
|
<td><code>0x01</code></td>
|
|||
|
|
<td>4</td>
|
|||
|
|
<td>max-message-size, u32, 1024..2^31-1, and not equal to a value the flags already encode</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody></table></div>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="3-sizes-measured">3. Sizes measured</h2>
|
|||
|
|
<p>Real SDP, identical ICE configuration on both peers, each peer gathering in its
|
|||
|
|
own browser process. QR versions are byte mode at level M.</p>
|
|||
|
|
<div class="tablewrap"><table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>browser</th>
|
|||
|
|
<th>profile</th>
|
|||
|
|
<th>offer</th>
|
|||
|
|
<th>QR</th>
|
|||
|
|
<th>answer</th>
|
|||
|
|
<th>QR</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody><tr>
|
|||
|
|
<td>Chrome</td>
|
|||
|
|
<td>host_only</td>
|
|||
|
|
<td>103 B</td>
|
|||
|
|
<td>v6</td>
|
|||
|
|
<td>111 B</td>
|
|||
|
|
<td>v7</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Chrome</td>
|
|||
|
|
<td>stun</td>
|
|||
|
|
<td>110 B</td>
|
|||
|
|
<td>v7</td>
|
|||
|
|
<td>118 B</td>
|
|||
|
|
<td>v7</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Chrome</td>
|
|||
|
|
<td>turn_all</td>
|
|||
|
|
<td>124 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
<td>132 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Chrome</td>
|
|||
|
|
<td>turn_relay_only</td>
|
|||
|
|
<td>98 B</td>
|
|||
|
|
<td>v6</td>
|
|||
|
|
<td>106 B</td>
|
|||
|
|
<td>v6</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Firefox</td>
|
|||
|
|
<td>host_only</td>
|
|||
|
|
<td>134 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
<td>142 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Firefox</td>
|
|||
|
|
<td>stun</td>
|
|||
|
|
<td>141 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
<td>149 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Firefox</td>
|
|||
|
|
<td>turn_all</td>
|
|||
|
|
<td>136 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
<td>144 B</td>
|
|||
|
|
<td>v8</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>Firefox</td>
|
|||
|
|
<td>turn_relay_only</td>
|
|||
|
|
<td>110 B</td>
|
|||
|
|
<td>v7</td>
|
|||
|
|
<td>118 B</td>
|
|||
|
|
<td>v7</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody></table></div>
|
|||
|
|
<p>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).</p>
|
|||
|
|
<p>Transport: <strong>raw bytes in QR byte mode</strong>. 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 <code>SB2:</code> + base64url,
|
|||
|
|
whose alphabet survives messenger auto-formatting; the decoder strips whitespace
|
|||
|
|
so a wrapped paste still works. <strong>DEFLATE is not used</strong> — on this payload it adds
|
|||
|
|
2 to 11 bytes, and dropping it removes the decompression-bomb surface with it.</p>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="4-candidate-pruning-coverage-before-count">4. Candidate pruning: coverage before count</h2>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<p>The rule, in order:</p>
|
|||
|
|
<ol>
|
|||
|
|
<li><strong>Coverage.</strong> Every <code>(address family, candidate type, transport)</code> combination
|
|||
|
|
present in the input keeps its highest-priority representative. Families are
|
|||
|
|
<code>v4</code>, <code>v6</code> and <code>mdns</code> — 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.</li>
|
|||
|
|
<li><strong>Surplus,</strong> by the sender's own priority, until either <code>MAX_CANDIDATES</code> (8)
|
|||
|
|
or <code>SURPLUS_CANDIDATE_BYTES</code> (48) runs out, with relays capped at 2.</li>
|
|||
|
|
</ol>
|
|||
|
|
<p>Candidates a peer cannot dial — ICE-TCP <code>active</code> and <code>so</code>, which are
|
|||
|
|
outbound-only sockets on the discard port — are excluded from coverage and
|
|||
|
|
compete only for surplus. Firefox advertises an <code>active</code> host candidate on every
|
|||
|
|
connection; at 19 bytes for an mDNS address it must not hold a coverage slot it
|
|||
|
|
cannot use.</p>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="5-freshness-uniqueness-one-shot">5. Freshness, uniqueness, one-shot</h2>
|
|||
|
|
<p><strong>Expiry</strong> 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.</p>
|
|||
|
|
<p><strong>The offer carries no nonce.</strong> It does not need one: <code>ice-pwd</code> 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.</p>
|
|||
|
|
<p><strong>The answer carries an 8-byte binding tag</strong>, <code>SHA-256("sbq2/bind\0" || offer_bytes)[0..8]</code>. 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.</p>
|
|||
|
|
<blockquote>
|
|||
|
|
<p><strong>Limitation, on the record:</strong> 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.</p>
|
|||
|
|
</blockquote>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="6-sas">6. SAS</h2>
|
|||
|
|
<pre><code>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
|
|||
|
|
</code></pre>
|
|||
|
|
<p>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.</p>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="7-migration">7. Migration</h2>
|
|||
|
|
<p>The two formats separate without heuristics: an SBQ2 QR starts with byte <code>0x02</code>,
|
|||
|
|
an SB1 payload starts with ASCII <code>S</code> (<code>0x53</code>); in text, the prefixes are <code>SB2:</code>
|
|||
|
|
and <code>SB1:bin:</code> / <code>SB1:gz:</code>.</p>
|
|||
|
|
<div class="tablewrap"><table>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>phase</th>
|
|||
|
|
<th>state</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody><tr>
|
|||
|
|
<td>1</td>
|
|||
|
|
<td>Codec and tests in the tree. <strong>Done, v5.8.0/v5.8.1.</strong></td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>2</td>
|
|||
|
|
<td>Receiver accepts both formats. <strong>Done, v5.9.0</strong> — reception is unconditional and not governed by the send flag.</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>3</td>
|
|||
|
|
<td>Sender emits SBQ2. <strong>Done, v5.9.0</strong>, together with the in-band key exchange that made it safe.</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr>
|
|||
|
|
<td>4</td>
|
|||
|
|
<td>Remove SB1 emission (already gone) and, after a deprecation window, SB1 parsing along with <code>cose-qr.js</code>, <code>inflateBounded</code> and the animated multi-frame QR path. <strong>Not yet</strong> — the animated path is still required while SB1 invitations are accepted.</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody></table></div>
|
|||
|
|
<h3 id="rollback">Rollback</h3>
|
|||
|
|
<p><code>EnhancedSecureWebRTCManager.SBQ2_SEND_ENABLED = false</code> 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.</p>
|
|||
|
|
<p>The switch is deliberately not consulted in the receive path and never inside an
|
|||
|
|
established session. <code>_handshakeMode</code> 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.</p>
|
|||
|
|
<h3 id="what-phase-3-depended-on-and-what-landed">What phase 3 depended on, and what landed</h3>
|
|||
|
|
<p>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 <code>_computeSAS</code>, the salt derived instead of
|
|||
|
|
transmitted, <code>authProof</code> replaced by a signature over the transcript, and the
|
|||
|
|
Double Ratchet started from the transcript-derived material.</p>
|
|||
|
|
<h3 id="compatibility">Compatibility</h3>
|
|||
|
|
<p>A client older than v5.9.0 cannot read an SBQ2 invitation.</p>
|
|||
|
|
<p>From v5.9.0 onward an unrecognised <code>SB<n>:</code> family is reported as <em>"This
|
|||
|
|
invitation was created by a newer version of SecureBit. Please update the app to
|
|||
|
|
connect."</em> — so the next format change explains itself.</p>
|
|||
|
|
<p>Versions <strong>5.8.1 and earlier predate that check</strong>: they fall through to
|
|||
|
|
<code>JSON.parse</code> and show <code>Invalid invitation format: Unexpected token 'S', "SB2:Ak…" is not valid JSON</code>. That is accurate but unhelpful, and it cannot be fixed
|
|||
|
|
retroactively in builds already shipped. Both ends must be on 5.9.0+.</p>
|
|||
|
|
<h2 id="8-decoder-rules">8. Decoder rules</h2>
|
|||
|
|
<p>The decoder parses fully attacker-controlled input.</p>
|
|||
|
|
<ul>
|
|||
|
|
<li>Payload ceiling (512 B) checked before any structure is walked.</li>
|
|||
|
|
<li>Version compared first; a mismatch throws.</li>
|
|||
|
|
<li>Reserved values (descriptor type 2–3, setup role 3, candidate kind 7–15,
|
|||
|
|
tcptype 4–15) are refused, never coerced.</li>
|
|||
|
|
<li>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.</li>
|
|||
|
|
<li>Flags and extension area must agree in both directions: <code>mms = 3</code> without
|
|||
|
|
extension <code>0x01</code> is an error, and extension <code>0x01</code> without <code>mms = 3</code> is too.</li>
|
|||
|
|
<li>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.</li>
|
|||
|
|
<li>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.</li>
|
|||
|
|
<li>Base64url input must be canonical: non-zero padding bits are refused, so a
|
|||
|
|
descriptor has exactly one textual spelling.</li>
|
|||
|
|
</ul>
|
|||
|
|
<hr>
|
|||
|
|
<h2 id="9-provenance-of-the-numbers">9. Provenance of the numbers</h2>
|
|||
|
|
<p>Everything above was measured, not estimated.</p>
|
|||
|
|
<ul>
|
|||
|
|
<li>Chrome fixtures: <code>tests/fixtures/sdp-chrome.json</code>, captured over CDP from a
|
|||
|
|
real Chrome across four network profiles, both peers configured identically,
|
|||
|
|
each gathering in isolation.</li>
|
|||
|
|
<li>Firefox fixtures: <code>tests/fixtures/sdp-firefox.json</code>, Firefox 153 over
|
|||
|
|
Marionette, same method.</li>
|
|||
|
|
<li>Round-trip, rejection, coverage, TLV, skew and transcript tests:
|
|||
|
|
<code>tests/descriptor-sbq2.test.mjs</code>.</li>
|
|||
|
|
</ul>
|
|||
|
|
<p>The original brief's payload (CBOR 2391 B, 991 B of SDP) was an <strong>offer</strong> — it
|
|||
|
|
carries <code>sl</code>, <code>si</code>, <code>vc</code> and <code>ac</code>, and has no <code>ap</code> 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.</p>
|
|||
|
|
|
|||
|
|
<nav class="more">
|
|||
|
|
<h2>More documentation</h2>
|
|||
|
|
<ul>
|
|||
|
|
<li><a href="/docs/">Documentation</a></li>
|
|||
|
|
<li><a href="/docs/faq/">Frequently asked questions</a></li>
|
|||
|
|
<li><a href="/docs/architecture/">Architecture</a></li>
|
|||
|
|
<li><a href="/docs/cryptography/">Cryptography</a></li>
|
|||
|
|
<li><a href="/docs/configuration/">Configuration</a></li>
|
|||
|
|
<li><a href="/docs/calls/">Voice and video calls</a></li>
|
|||
|
|
<li><a href="/docs/api/">API Notes</a></li>
|
|||
|
|
<li><a href="/docs/contributing/">Contributing</a></li>
|
|||
|
|
<li><a href="/docs/use-policy/">Use policy and limitations</a></li>
|
|||
|
|
</ul>
|
|||
|
|
</nav>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>
|