v6.7.3: faster loading, and pages search engines can read

The bundles carried all thirteen translations at once and a page fetched them a
third time as raw source; each page now loads only its own language. Alongside
that: JavaScript is minified, the eight stylesheets are served as one file, the
QR scanner is fetched after the app is up instead of on every visit, Inter ships
once rather than five copies of the same file, and Font Awesome is subset to the
82 icons this app draws instead of all 2468.

1.85 MB across 43 requests becomes under 700 KB across 33. On mobile the page
starts drawing in 1.6 s instead of 6.3 s and is usable in 4.5 s instead of 11 s.

Pages also carry their text in the HTML now. Everything was drawn by JavaScript
into an empty div, so crawlers saw correct metadata around nothing, and twelve of
the thirteen language pages had never been shown to anyone. The documentation is
published under /docs/ with a new FAQ, and unknown addresses return a real 404.

Separately: the localized shells were served with the year-long immutable cache
header meant for static assets, which pinned anyone who opened /de/ or /ru/ to
that build. The header is fixed and the service worker refreshes what it cached.

Claude-Session: https://claude.ai/code/session_014KjzTXxrhzYoDDWChYQ4u2
This commit is contained in:
lockbitchat
2026-09-04 00:41:46 -04:00
parent 0691ce618c
commit 414c27fda6
111 changed files with 19469 additions and 107393 deletions
+196
View File
@@ -0,0 +1,196 @@
<!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>API Notes - SecureBit.chat</title>
<meta name="description" content="API Notes — SecureBit.chat technical documentation.">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/api/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="API Notes - SecureBit.chat">
<meta property="og:description" content="API Notes — SecureBit.chat technical documentation.">
<meta property="og:url" content="https://securebit.chat/docs/api/">
<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="API Notes - SecureBit.chat">
<meta name="twitter:description" content="API Notes — SecureBit.chat technical documentation.">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "API Notes",
"description": "API Notes — SecureBit.chat technical documentation.",
"url": "https://securebit.chat/docs/api/",
"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">API Notes</span>
</nav>
<h1>API Notes</h1>
<h2 id="enhancedsecurewebrtcmanager">EnhancedSecureWebRTCManager</h2>
<h3 id="verification">Verification</h3>
<ul>
<li><code>confirmVerification(userCode)</code> validates a manually entered SAS code.</li>
<li>Verification succeeds only after both local and remote confirmations are present.</li>
<li><code>isVerified</code> is assigned in one place (<code>_setVerifiedStatus</code>), which refuses any
SAS-based transition without a recorded local confirmation.</li>
<li>Control frames listed in <code>POST_VERIFICATION_CONTROL_TYPES</code> (reconnection
signalling, call setup, message deletion, delivery receipts) are only acted on
after verification. The set is an allowlist; unrecognised frame types are
rejected by the chat channel&#39;s default-deny branch.</li>
<li>Protocol version <code>4.1</code> is enforced during offer/answer processing.</li>
</ul>
<h3 id="forward-secrecy">Forward secrecy</h3>
<ul>
<li><code>isRatchetActive()</code> reports whether the Double Ratchet is running on this
connection. It is negotiated: both peers advertise <code>RATCHET_VERSION</code> in the
offer and answer, and a peer that does not falls back to per-session keys.</li>
<li><code>_ratchet.canEncrypt</code> is false on the joining peer until the inviting peer&#39;s
first message arrives, because the sending chain does not exist until then. Callers
must check it rather than assume; the send path falls back to session keys for
those first frames.</li>
<li><code>_ratchet.getState()</code> returns counters and the number of retained keys for
diagnostics. It exposes no key material.</li>
<li>Ratcheted chat arrives as <code>MESSAGE_TYPES.RATCHET_MESSAGE</code> with <code>h</code> (the header
string, used verbatim as AES-GCM additional data) and <code>c</code> (base64 body). The
header must be passed back to <code>decrypt()</code> exactly as received; re-serialising
it can change a byte and fail authentication.</li>
</ul>
<h3 id="privacy-mode">Privacy mode</h3>
<ul>
<li>relay-only configuration sets WebRTC <code>iceTransportPolicy</code> to <code>&quot;relay&quot;</code>.</li>
<li>TURN availability is checked before claiming IP protection.</li>
</ul>
<h3 id="file-transfer-callbacks">File transfer callbacks</h3>
<ul>
<li><code>setFileTransferCallbacks(onProgress, onReceived, onError, onIncomingRequest)</code> updates manager fields and any live <code>EnhancedSecureFileTransfer</code> instance.</li>
<li>Passing <code>null</code> values detaches callbacks from the active transfer system.</li>
</ul>
<h3 id="voice-messages">Voice messages</h3>
<ul>
<li><code>sendFile(file, options)</code> accepts an optional <code>options</code> object. <code>options.voice</code>
(<code>{ dur, bars }</code>) marks the transfer as a voice note and rides along as unsigned
metadata; <code>options.uiId</code> correlates progress events to a UI bubble before the
<code>fileId</code> resolves.</li>
<li><code>onProgress</code> receives <code>{ fileId, uiId, direction, progress, isVoice, voice }</code>.
<code>onIncomingFileRequest</code> and <code>onReceived</code> include <code>isVoice</code> and <code>voice</code> so the UI
can auto-accept and render a voice bubble instead of a file card.</li>
<li>The <code>isVoice</code> a callback receives is the <strong>receiver&#39;s</strong> verdict, not the
sender&#39;s claim: <code>validateIncomingMetadata</code> clears it unless the transfer
declares a recognised audio MIME type and fits the per-note and per-session
size budgets. A transfer that fails those checks is not rejected; it simply
loses the consent-free shortcut and is offered as a normal file.</li>
</ul>
<h2 id="enhancedsecurefiletransfer">EnhancedSecureFileTransfer</h2>
<h3 id="incoming-transfers">Incoming transfers</h3>
<ul>
<li>metadata is validated before prompting</li>
<li>acceptance is explicit</li>
<li>receive buffers are allocated only after consent</li>
<li>file type acceptance is allowlist-based</li>
</ul>
<h3 id="cleanup">Cleanup</h3>
<ul>
<li>pending sender consent promises are rejected on cleanup</li>
<li>consent timeouts are cleared immediately</li>
<li>retained received buffers are bounded</li>
<li>evicted download handles fail with a user-facing availability message</li>
</ul>
<h2 id="securepersistentkeystorage">SecurePersistentKeyStorage</h2>
<ul>
<li>metadata is encrypted before storage</li>
<li>legacy plaintext records migrate lazily</li>
<li>corrupted encrypted metadata is ignored safely</li>
</ul>
<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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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/contributing/">Contributing</a></li>
<li><a href="/docs/use-policy/">Use policy and limitations</a></li>
</ul>
</nav>
</div>
</body>
</html>
+312
View File
@@ -0,0 +1,312 @@
<!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>Architecture - SecureBit.chat</title>
<meta name="description" content="SecureBit.chat is a browser application with no backend. Two browsers negotiate a direct WebRTC connection, derive keys from an ECDH exchange, and confirm…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/architecture/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Architecture - SecureBit.chat">
<meta property="og:description" content="SecureBit.chat is a browser application with no backend. Two browsers negotiate a direct WebRTC connection, derive keys from an ECDH exchange, and confirm…">
<meta property="og:url" content="https://securebit.chat/docs/architecture/">
<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="Architecture - SecureBit.chat">
<meta name="twitter:description" content="SecureBit.chat is a browser application with no backend. Two browsers negotiate a direct WebRTC connection, derive keys from an ECDH exchange, and confirm…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Architecture",
"description": "SecureBit.chat is a browser application with no backend. Two browsers negotiate a direct WebRTC connection, derive keys from an ECDH exchange, and confirm…",
"url": "https://securebit.chat/docs/architecture/",
"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">Architecture</span>
</nav>
<h1>Architecture</h1>
<p>SecureBit.chat is a browser application with no backend. Two browsers negotiate a
direct WebRTC connection, derive keys from an ECDH exchange, and confirm each
other&#39;s identity by comparing a short code out of band. Everything after that
runs between the two endpoints.</p>
<p>There is no server in the message path, and there is no signalling service. The
offer and the answer are moved between the two people by whatever channel they
already have (a QR code, a pasted block of text, a link). That choice shapes the
rest of the design: the out-of-band channel is untrusted, so the protocol assumes
an attacker can read and rewrite anything travelling over it, and the safety code
comparison is what closes that gap.</p>
<h2 id="session-lifecycle">Session lifecycle</h2>
<pre><code class="language-text">1. Invitation Peer A generates its key pairs and an SDP offer, and exports a
compact descriptor: ICE candidates, the DTLS certificate
fingerprint, an expiry, and a 16-byte commitment to its key
material. 110-150 bytes; one QR code. No keys travel in it.
2. Response Peer B validates the descriptor strictly, answers the SDP, and
returns a descriptor of the same shape, tagged so that it can
only be an answer to this particular invitation.
3. Transport up DTLS completes and the data channel opens. Only the peer
holding the private key behind the fingerprint in the
invitation can reach this point.
4. Key exchange Each side sends its public keys over the open channel as the
first frame. Each verifies the other&#39;s blob against the
commitment from the invitation BEFORE parsing it, then derives
the session from a transcript of both descriptors and both
blobs, and signs that transcript to prove it owns its identity
key. Any failure closes the connection.
5. Verification Both sides display the same safety code, derived from that
transcript. The users compare it over a channel an attacker
cannot impersonate and enter it.
6. Verified Only now does the session accept traffic that changes state,
and only now does the chat open.
</code></pre>
<p>Steps 3 and 4 are the ones worth dwelling on. The fingerprint in the invitation
authenticates the transport to whoever showed you the code, and the commitment
means substituted key material is refused automatically rather than noticed by a
human. But neither proves <em>who</em> showed you the code. Anyone positioned on the
out-of-band channel can rewrite the whole invitation, commitment included, and
complete steps 3 and 4 with both people at once. Step 5 is the only step that
distinguishes the intended peer, so everything that could be useful to an
impostor waits for it — and because the safety code is computed over the full
transcript, a rewritten handshake cannot produce matching digits.</p>
<p>The invitation format and its decoder rules are in
<a href="/docs/descriptor-sbq2/">DESCRIPTOR-SBQ2.md</a>.</p>
<h2 id="what-verification-gates">What verification gates</h2>
<p>Verification is enforced, not merely displayed. Until both sides confirm:</p>
<ul>
<li>reconnection signalling is refused</li>
<li>call setup is refused</li>
<li>message deletion and delivery receipts are refused</li>
<li>incoming file transfers are refused</li>
</ul>
<p>The verification exchange itself and liveness probes run earlier, because they
have to. That set is an allowlist in the code
(<code>POST_VERIFICATION_CONTROL_TYPES</code>), and anything not on it is rejected by
default rather than passed through.</p>
<p>The verified state is set in one place, which refuses the transition unless the
local user has actually confirmed the code. Three incorrect entries end the
session.</p>
<h2 id="message-protection-layers">Message protection layers</h2>
<pre><code class="language-text"> ECDH P-384 exchange
|
HKDF key schedule
|
.........................................................
| | | | |
message MAC metadata fingerprint ratchet root
key key key (safety code) |
Double Ratchet
per-message keys
</code></pre>
<p>Chat content is encrypted with a ratchet-derived key when both peers support the
ratchet, and with the session message key otherwise. Either way it reaches the
interface through a single authenticated path. Frames that fail authentication
are dropped rather than displayed, so nothing appears in a conversation that has
not been verified as coming from the peer holding the session keys.</p>
<h2 id="forward-secrecy">Forward secrecy</h2>
<p>Per-message keys come from a chain key through a one-way function and are
destroyed after a single use, so a key held now cannot reconstruct an earlier
one. Each change of direction in the conversation introduces a fresh ECDH key
pair, which re-keys the session root and moves it away from any state an attacker
may have captured.</p>
<p>Out-of-order delivery is supported within fixed bounds: 512 skipped keys per
chain, 1024 retained in total, expiring after five minutes. These are a resource
control rather than a tuning parameter, because the message number is supplied by
the peer.</p>
<p>Incoming frames are authenticated before any ratchet state is committed. A frame
that fails leaves the ratchet untouched, so a malformed or forged frame cannot
desynchronise an established session.</p>
<p><a href="/docs/cryptography/">CRYPTOGRAPHY.md</a> has the key schedule and the frame format.</p>
<h2 id="session-recovery">Session recovery</h2>
<p>A network path can break without anything closing: switching from Wi-Fi to a
mobile network, a NAT rebind, a tunnel. The data channel keeps reporting itself
as open while packets stop arriving.</p>
<p>Recovery renegotiates only the transport path, using an ICE restart carried over
the existing encrypted channel. The DTLS session, the session keys, the ratchet
state and the message history all sit above ICE and survive it, so a repaired
connection is the same session and needs no new verification.</p>
<p>Silence alone is not treated as a dead peer. A backgrounded tab is frozen by the
browser and answers nothing, while ICE consent checks continue in the browser&#39;s
network stack. A connected ICE state therefore means a silent peer is asleep, not
gone. Only an unanswered probe on a degraded path starts recovery.</p>
<p>Recovery gives up when it cannot succeed: when nothing has arrived from the peer
since the break (no route exists for the renegotiation), or when the ICE agent
produces no candidate pairs at all (restarting cannot rebind it). A session that
cannot be recovered is closed and its data wiped rather than left half alive.</p>
<h2 id="file-transfer">File transfer</h2>
<ol>
<li>The sender emits metadata.</li>
<li>The receiver validates name, size, type and abuse limits.</li>
<li>The receiver is shown an Accept or Reject prompt.</li>
<li>No receive buffers are allocated before acceptance.</li>
<li>Chunks are transmitted only after acceptance.</li>
<li>Completed buffers are retained within a bounded window.</li>
</ol>
<p>Voice notes reuse this pipeline and inherit its per-file AES-GCM session key,
chunking and SHA-256 integrity check. They differ in three ways: the audio is
recorded in the browser, the duration and waveform travel as unsigned
presentation metadata (the audio bytes stay covered by the signed hash), and the
receiver accepts them without a prompt so they can play inline.</p>
<p>That last point is why the receiver decides what counts as a voice note. The
sender&#39;s claim is not enough: a transfer qualifies only if it declares a
recognised audio MIME type, stays under 4 MB, and fits a per-session budget of
64 MB. Anything else is handled as an ordinary file and goes through the normal
prompt. This keeps the convenience of voice notes from becoming a channel for
unattended transfers.</p>
<h2 id="disconnect">Disconnect</h2>
<p>The disconnect path clears:</p>
<ul>
<li>WebRTC channels and peer connection handles</li>
<li>timers, deferred retries, cover traffic and decoy traffic</li>
<li>pending transfer state and consent waits</li>
<li>verification state and session key material</li>
<li>ratchet state: the root key, both chain keys and every retained message key are
overwritten rather than only dereferenced</li>
<li>React file transfer callbacks and stale interface state</li>
</ul>
<p>Values that cannot be overwritten in JavaScript are documented as such rather
than reported as cleared. See the memory handling section of
<a href="/docs/cryptography/">CRYPTOGRAPHY.md</a>.</p>
<h2 id="multiple-conversations">Multiple conversations</h2>
<p>Each conversation gets its own manager instance, peer connection, key material
and verification state, held in a map keyed by session. Nothing is shared between
them, so two conversations cannot mix, and closing one leaves the others
connected.</p>
<h2 id="code-layout">Code layout</h2>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Path</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td><code>src/network/EnhancedSecureWebRTCManager.js</code></td>
<td>Connection lifecycle, verification, session state, message routing</td>
</tr>
<tr>
<td><code>src/network/webrtc/</code></td>
<td>Call stack: SDP handling, audio and video senders, network adaptation</td>
</tr>
<tr>
<td><code>src/crypto/DoubleRatchet.js</code></td>
<td>Forward secrecy: root and chain keys, DH ratchet, skipped keys</td>
</tr>
<tr>
<td><code>src/crypto/EnhancedSecureCryptoUtils.js</code></td>
<td>Key generation, key schedule, message encryption, sanitization</td>
</tr>
<tr>
<td><code>src/crypto/cose-qr.js</code></td>
<td>Invitation packing for QR transport</td>
</tr>
<tr>
<td><code>src/transfer/EnhancedSecureFileTransfer.js</code></td>
<td>Chunked encrypted transfers, consent, type policy</td>
</tr>
<tr>
<td><code>src/state/sessionsStore.js</code></td>
<td>Reducer for the set of open conversations</td>
</tr>
<tr>
<td><code>src/app.jsx</code></td>
<td>Interface and message rendering</td>
</tr>
</tbody></table></div>
<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/cryptography/">Cryptography</a></li>
<li><a href="/docs/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
+391
View File
@@ -0,0 +1,391 @@
<!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>Voice and video calls - SecureBit.chat</title>
<meta name="description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/calls/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Voice and video calls - SecureBit.chat">
<meta property="og:description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta property="og:url" content="https://securebit.chat/docs/calls/">
<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="Voice and video calls - SecureBit.chat">
<meta name="twitter:description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Voice and video calls",
"description": "Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…",
"url": "https://securebit.chat/docs/calls/",
"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">Voice and video calls</span>
</nav>
<h1>Voice and video calls</h1>
<p>Calls run over the same peer connection as the chat. Media is added to the
existing connection and renegotiated onto it, and the call SDP is exchanged over
the encrypted data channel rather than through any signalling service. Media
therefore inherits the session&#39;s verification: the DTLS-SRTP fingerprints
negotiated for the media were themselves carried over an authenticated channel.</p>
<p>All tunable values live in <code>src/network/webrtc/config.js</code>. This document explains
where they come from, because the numbers are otherwise indistinguishable from
arbitrary choices.</p>
<h2 id="where-the-settings-attach">Where the settings attach</h2>
<p>A single <code>RTCRtpSender</code> cannot express codec ordering or fmtp parameters, so
configuring a sender is spread across three WebRTC surfaces, each at the point in
the lifecycle where it works:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Concern</th>
<th>Surface</th>
<th>When</th>
<th>Implementation</th>
</tr>
</thead>
<tbody><tr>
<td>Codec ordering (RED before Opus)</td>
<td><code>transceiver.setCodecPreferences</code></td>
<td>before creating the offer or answer</td>
<td><code>applyAudioCodecPreferences</code> in <code>audio.js</code></td>
</tr>
<tr>
<td>Opus FEC, DTX, bitrate</td>
<td>SDP <code>a=fmtp</code> rewriting</td>
<td>after create, before <code>setLocalDescription</code></td>
<td><code>applyOpusSettings</code> in <code>sdp.js</code></td>
</tr>
<tr>
<td>Priority and maximum bitrate</td>
<td><code>sender.setParameters</code></td>
<td>after <code>setLocalDescription</code></td>
<td><code>configureAudioSender</code> in <code>audio.js</code></td>
</tr>
</tbody></table></div>
<p>Both peers apply the same rewriting, so the negotiated session carries the
parameters regardless of who called.</p>
<h2 id="audio">Audio</h2>
<p>Speech has to stay intelligible on a bad link, and that goal drives every value
below.</p>
<h3 id="opus-parameters">Opus parameters</h3>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>minptime</code></td>
<td>10</td>
<td>Smaller packetisation interval, lower latency (RFC 7587, section 7)</td>
</tr>
<tr>
<td><code>useinbandfec</code></td>
<td>1</td>
<td>In-band forward error correction reconstructs a lost packet from the next one. This is the main lever for staying intelligible at 15 to 20 percent loss (RFC 6716, section 2.1.7)</td>
</tr>
<tr>
<td><code>usedtx</code></td>
<td>1</td>
<td>Discontinuous transmission stops sending during silence, leaving the transport free for video and FEC (RFC 7587, section 3.1.3)</td>
</tr>
<tr>
<td><code>stereo</code></td>
<td>0</td>
<td>Mono halves the bitrate with no loss for speech</td>
</tr>
<tr>
<td><code>maxaveragebitrate</code></td>
<td>32000</td>
<td>Comfortable wideband speech</td>
</tr>
<tr>
<td><code>cbr</code></td>
<td>0</td>
<td>Variable bitrate spends bits only when needed</td>
</tr>
</tbody></table></div>
<h3 id="redundancy">Redundancy</h3>
<p>RED (RFC 2198) carries the previous frame&#39;s payload alongside each packet, so
isolated losses recover without retransmission. It is enabled only when the
browser advertises <code>audio/red</code> in <code>RTCRtpSender.getCapabilities(&#39;audio&#39;)</code>, which
Chromium does and others vary on; when absent it is skipped silently. RED is
ordered before Opus in codec preferences.</p>
<h3 id="sender-parameters">Sender parameters</h3>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>maxBitrate</code></td>
<td>40000 bps</td>
<td>Headroom above the 32 kbps Opus target for RED redundancy</td>
</tr>
<tr>
<td><code>priority</code></td>
<td><code>high</code></td>
<td>Audio wins bandwidth arbitration against video within the connection</td>
</tr>
<tr>
<td><code>networkPriority</code></td>
<td><code>high</code></td>
<td>DSCP hint so audio is prioritised on the wire</td>
</tr>
</tbody></table></div>
<p>Audio is never throttled by the adaptation controller. Under sustained loss the
video degrades and speech continues.</p>
<h2 id="video">Video</h2>
<p>Codec preference order is VP9, AV1, H.264, VP8, applied through
<code>setCodecPreferences</code>. Retransmission and FEC codecs are kept after the media
codecs so they still function. VP9 and AV1 provide scalable coding; H.264 and VP8
do not.</p>
<h3 id="scalable-coding-rather-than-simulcast">Scalable coding rather than simulcast</h3>
<p>This is a one-to-one connection with a single receiver, so one encoding with SVC
is the right tool: a single stream that degrades by spatial or temporal layer. It
is applied through <code>sender.setParameters</code> and needs no <code>addTransceiver</code> or rid
configuration, which keeps it away from the media path that actually works.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Codec</th>
<th>scalabilityMode</th>
<th>maxBitrate</th>
<th>degradationPreference</th>
</tr>
</thead>
<tbody><tr>
<td>VP9</td>
<td><code>L3T3_KEY</code> (3 spatial, 3 temporal, key-aligned)</td>
<td>1.5 Mbps</td>
<td><code>balanced</code></td>
</tr>
<tr>
<td>AV1</td>
<td><code>L1T3</code></td>
<td>1.2 Mbps</td>
<td><code>maintain-framerate</code></td>
</tr>
<tr>
<td>H.264, VP8</td>
<td>none</td>
<td>1.5 Mbps</td>
<td><code>balanced</code></td>
</tr>
</tbody></table></div>
<p><code>networkPriority</code> is <code>medium</code>, below audio. If a browser rejects the scalability
mode, which Firefox and Safari do in places, <code>configureVideoSender</code> retries with
a plain encoding.</p>
<h3 id="why-media-is-attached-with-addtrack">Why media is attached with addTrack</h3>
<p>An explicit <code>addTransceiver({ sendEncodings })</code> path was built and then removed,
because it broke media on real devices in two ways. On the answering side,
reusing the transceiver created by <code>setRemoteDescription</code> rejected the SVC
parameters outright. On repeat and role-reversed calls, the reused transceiver
directions desynchronised: the call connected and <code>ontrack</code> fired, but no media
flowed.</p>
<p>What ships instead attaches media with <code>addTrack</code>, reused across calls through
<code>replaceTrack</code>, and lets the browser manage transceiver direction. This is what
keeps audio and video flowing across reversed and repeated calls.</p>
<p>Multi-rid simulcast primitives (<code>buildVideoSendEncodings</code>) are kept and tested for
a future group-call path, but are not wired into the one-to-one flow. They need
<code>addTransceiver({ sendEncodings })</code>, which requires the problems above to be
solved first, ideally with a two-connection browser test rig that is not set up.
The adaptation controller is already simulcast-aware for when that lands.</p>
<h2 id="transport-feedback">Transport feedback</h2>
<p>The call m-lines need RTCP feedback and header extensions present. Most browsers
emit them already, so this is an idempotent safety net:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Media</th>
<th>RTCP feedback</th>
<th>Header extension</th>
</tr>
</thead>
<tbody><tr>
<td>Video</td>
<td><code>transport-cc</code>, <code>nack</code>, <code>nack pli</code>, <code>ccm fir</code>, <code>goog-remb</code></td>
<td>transport-wide congestion control</td>
</tr>
<tr>
<td>Audio</td>
<td><code>transport-cc</code>, <code>nack</code></td>
<td>transport-wide congestion control</td>
</tr>
</tbody></table></div>
<p>These are added only when missing, never duplicated, and applied only to primary
codecs. Transport-wide congestion control is what feeds the bandwidth estimator
that adaptation reads.</p>
<p>Rewritten local SDP is applied with progressive fallback: full rewrite, then Opus
only, then raw. A browser that rejects an injected line cannot break the call.</p>
<h2 id="adaptation">Adaptation</h2>
<p><code>NetworkAdaptationController</code> samples <code>pc.getStats()</code> every second and reacts:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Condition</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td>Loss above 10 percent, or round trip above 300 ms</td>
<td>Reduce video <code>maxBitrate</code> by 20 percent, floor 100 kbps</td>
</tr>
<tr>
<td>Loss below 3 percent and round trip below 150 ms, sustained 5 samples</td>
<td>Raise video <code>maxBitrate</code> by 10 percent, up to the ceiling</td>
</tr>
<tr>
<td><code>qualityLimitationReason</code> is <code>cpu</code></td>
<td>Scale resolution down by 1.5, bitrate unchanged</td>
</tr>
</tbody></table></div>
<p>Every change goes through <code>sender.setParameters</code>. There is no renegotiation and
no track restart, so adaptation is invisible to the call. Audio is never touched.</p>
<p>The decision function and the stats parsing are pure and unit-tested against
recorded <code>getStats</code> output.</p>
<h3 id="quality-indicator">Quality indicator</h3>
<p>The same sample produces a coarse label shown in the call interface:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Label</th>
<th>Condition</th>
</tr>
</thead>
<tbody><tr>
<td>Excellent</td>
<td>Loss below 3 percent and round trip below 150 ms</td>
</tr>
<tr>
<td>Good</td>
<td>Loss below 7 percent and round trip below 250 ms</td>
</tr>
<tr>
<td>Fair</td>
<td>Loss below 15 percent and round trip below 400 ms</td>
</tr>
<tr>
<td>Weak</td>
<td>Anything else</td>
</tr>
</tbody></table></div>
<p>It appears in the voice overlay, the video top bar, and as compact bars in the
minimized widget, and stays hidden until the first sample has data.</p>
<h2 id="verifying-a-change">Verifying a change</h2>
<p>Unit tests cover SDP rewriting, video codec selection and the adaptation
decision:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>For anything touching media in practice, open <code>chrome://webrtc-internals</code> during
a call and check that:</p>
<ul>
<li>outbound audio shows Opus with the fmtp parameters above, appearing as <code>red</code>
and <code>opus</code> on Chromium</li>
<li>throttling the link steps outbound video <code>targetBitrate</code> down within a second
or two and recovers when the link clears</li>
<li>audio bitrate holds steady while video adapts</li>
<li>the in-call indicator moves through Fair and Weak as the link degrades</li>
</ul>
<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/descriptor-sbq2/">SBQ2 — connection descriptor v2</a></li>
<li><a href="/docs/configuration/">Configuration</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>
+283
View File
@@ -0,0 +1,283 @@
<!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>Configuration - SecureBit.chat</title>
<meta name="description" content="npm run build compiles the CSS, bundles the JavaScript into dist/, and regenerates meta.json with a build stamp. That stamp is what the update mechanism…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/configuration/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Configuration - SecureBit.chat">
<meta property="og:description" content="npm run build compiles the CSS, bundles the JavaScript into dist/, and regenerates meta.json with a build stamp. That stamp is what the update mechanism…">
<meta property="og:url" content="https://securebit.chat/docs/configuration/">
<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="Configuration - SecureBit.chat">
<meta name="twitter:description" content="npm run build compiles the CSS, bundles the JavaScript into dist/, and regenerates meta.json with a build stamp. That stamp is what the update mechanism…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Configuration",
"description": "npm run build compiles the CSS, bundles the JavaScript into dist/, and regenerates meta.json with a build stamp. That stamp is what the update mechanism…",
"url": "https://securebit.chat/docs/configuration/",
"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">Configuration</span>
</nav>
<h1>Configuration</h1>
<h2 id="requirements">Requirements</h2>
<ul>
<li>A browser with WebRTC and Web Crypto support</li>
<li>Node.js 18 or later, for building</li>
<li>A TURN service, if you need relay-only privacy mode or you expect users behind
strict NAT</li>
</ul>
<h2 id="building-and-running">Building and running</h2>
<pre><code class="language-bash">npm install
npm run build
npm run serve
</code></pre>
<p><code>npm run build</code> compiles the CSS, bundles the JavaScript into <code>dist/</code>, and
regenerates <code>meta.json</code> with a build stamp. That stamp is what the update
mechanism compares, and it is also written into <code>sw.js</code> so the browser reinstalls
the service worker on each release. A deployment that skips <code>post-build</code> will not
notify anyone that an update exists.</p>
<p>The application is static. Any web server can host it, and there is no backend to
run.</p>
<h2 id="ice-and-turn">ICE and TURN</h2>
<p>WebRTC needs to discover network paths between the two browsers. STUN is enough
to learn a public address; TURN is needed when a direct path cannot be
established, and is the only way to keep peers from seeing each other&#39;s IP
addresses.</p>
<p>Configuration comes from three places, in order of precedence:</p>
<ol>
<li>User settings under Advanced network settings, stored in IndexedDB per device</li>
<li><code>config/ice-servers.js</code>, an operator override loaded before the application</li>
<li>Built-in public STUN defaults</li>
</ol>
<p><code>config/ice-servers.js</code> is not committed, because it is where operator TURN
credentials would go. Use <code>config/ice-servers.example.js</code> as the template. The
Docker image copies <code>config/ice-servers.prod.js</code> into place at build time.</p>
<pre><code class="language-js">window.SECUREBIT_ICE_SERVERS = [
{ urls: &#39;stun:stun.example.org:3478&#39; },
{
urls: [
&#39;turn:turn.example.org:3478?transport=udp&#39;,
&#39;turn:turn.example.org:3478?transport=tcp&#39;,
&#39;turns:turn.example.org:443?transport=tcp&#39;
],
username: &#39;...&#39;,
credential: &#39;...&#39;
}
];
</code></pre>
<p>Offering several transports is worth the extra lines. UDP is the most widely
usable, TCP covers networks that block UDP, and TURNS on 443 gets through
firewalls that only allow HTTPS. Some browsers also fail to resolve STUN and TURN
hostnames inside their WebRTC layer even when ordinary page DNS works, so listing
a raw IP alongside the hostname is a reasonable fallback.</p>
<p>Any TURN credential shipped to a browser is public by definition, because the
browser has to be able to read it. Treat it as a shared resource and apply quotas
on the TURN server rather than relying on the credential staying secret.</p>
<h3 id="user-supplied-servers">User-supplied servers</h3>
<p>Users can paste their own STUN and TURN servers. Input is validated against an
allowlist before it reaches <code>RTCPeerConnection</code>: only the <code>stun</code>, <code>stuns</code>, <code>turn</code>
and <code>turns</code> schemes, a hostname or bracketed IPv6 address with an optional port,
and an optional <code>transport=udp</code> or <code>transport=tcp</code> query. At most 10 servers with
8 URLs each. Anything else is rejected with a specific reason.</p>
<h3 id="privacy-modes">Privacy modes</h3>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Mode</th>
<th>Behaviour</th>
<th>IP exposure</th>
</tr>
</thead>
<tbody><tr>
<td>Default</td>
<td>Standard candidate gathering</td>
<td>Direct candidates can reveal addresses to the peer</td>
</tr>
<tr>
<td>Relay-only</td>
<td>Sets <code>iceTransportPolicy: &quot;relay&quot;</code></td>
<td>Requires TURN; peers see only the relay</td>
</tr>
</tbody></table></div>
<p>STUN is not a substitute for TURN here. It reveals your public address to the
peer by design. Relay-only mode without a configured TURN server cannot connect
at all, and the interface warns when TURN is missing.</p>
<p>Validate a TURN deployment with <code>chrome://webrtc-internals</code> before relying on it.
A relay candidate should appear in the gathered set; if none does, the credentials
or the ports are wrong.</p>
<h3 id="when-connections-fail">When connections fail</h3>
<p>Candidate gathering finishes only when every configured server has replied or
timed out. Behind a VPN or a restrictive firewall that may never happen, and the
console will show <code>701</code> errors for each unreachable server.</p>
<p>The application handles this: it proceeds as soon as there are usable candidates
and only keeps waiting while there are none, up to 25 seconds. Host candidates
alone are often enough on a local network. If nothing at all is gathered, the
message names the likely causes, which in practice are a VPN binding the browser
to an interface that cannot reach the servers, or a firewall dropping UDP.</p>
<h2 id="file-transfer-policy">File transfer policy</h2>
<p>Incoming transfers are validated before the consent prompt and require explicit
approval.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Category</th>
<th>Extensions</th>
<th>Size limit</th>
</tr>
</thead>
<tbody><tr>
<td>Images</td>
<td><code>.jpg</code> <code>.jpeg</code> <code>.png</code> <code>.gif</code> <code>.webp</code> <code>.bmp</code> <code>.ico</code></td>
<td>25 MB</td>
</tr>
<tr>
<td>Documents</td>
<td><code>.pdf</code></td>
<td>50 MB</td>
</tr>
<tr>
<td>Text</td>
<td><code>.txt</code></td>
<td>10 MB</td>
</tr>
<tr>
<td>Archives</td>
<td><code>.zip</code></td>
<td>100 MB</td>
</tr>
<tr>
<td>Voice</td>
<td><code>.webm</code> <code>.ogg</code> <code>.oga</code> <code>.opus</code> <code>.m4a</code> <code>.mp4</code> <code>.mp3</code> <code>.wav</code></td>
<td>20 MB</td>
</tr>
</tbody></table></div>
<p>Overall ceiling is 100 MB per file.</p>
<p>Blocked outright: <code>.exe</code> <code>.bat</code> <code>.cmd</code> <code>.sh</code> <code>.js</code> <code>.msi</code> <code>.dmg</code> <code>.app</code> <code>.jar</code>
<code>.scr</code> <code>.ps1</code> <code>.vbs</code> <code>.html</code> <code>.svg</code></p>
<p>The extension list is the security boundary. MIME type is treated as advisory,
because it is client-supplied, varies between browsers and operating systems, and
is frequently absent. An allowed extension is accepted when the MIME type is
absent, generic, or one of the recognised types, but a clearly contradictory MIME
type is rejected as a spoofing signal.</p>
<p>Voice notes are the one transfer accepted without a prompt, so they are checked
more strictly. The receiver decides, not the sender: a genuine audio MIME type,
at most 4 MB, and a 64 MB budget for the whole session. A transfer that fails
those checks is not rejected, it simply loses the shortcut and appears as a normal
file with the usual prompt.</p>
<h2 id="deployment-notes">Deployment notes</h2>
<p>The repository includes an nginx configuration (<code>deploy/nginx.conf</code>) and an
Apache one (<code>.htaccess</code>). Both set the same policy, and the important parts are:</p>
<ul>
<li><code>index.html</code>, <code>sw.js</code>, <code>manifest.json</code>, <code>meta.json</code> and <code>config/ice-servers.js</code>
must not be cached. A stale <code>meta.json</code> breaks update notification, and a stale
<code>sw.js</code> freezes the service worker.</li>
<li><code>dist/</code> bundles are query-versioned, so <code>no-cache</code> with revalidation is enough
and avoids re-downloading unchanged bundles.</li>
<li><code>CDN-Cache-Control</code> is set separately, because a CDN reads it independently of
the browser directive and will otherwise happily serve a stale app shell.</li>
<li><code>frame-ancestors</code> and HSTS have to be sent as headers. The rest of the content
security policy is a meta tag in <code>index.html</code>.</li>
<li><code>.jsx</code> and <code>.mjs</code> must be served as JavaScript, or module loading fails.</li>
</ul>
<p>Asset requests should return 404 when a file is missing rather than falling back
to the HTML shell. A missing script served as HTML fails in a confusing way.</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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
+219
View File
@@ -0,0 +1,219 @@
<!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>Contributing - SecureBit.chat</title>
<meta name="description" content="Tests are individual .mjs files run in sequence by npm test. There is no test runner and no mocking library. A new suite is a new file, added to the test…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/contributing/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Contributing - SecureBit.chat">
<meta property="og:description" content="Tests are individual .mjs files run in sequence by npm test. There is no test runner and no mocking library. A new suite is a new file, added to the test…">
<meta property="og:url" content="https://securebit.chat/docs/contributing/">
<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="Contributing - SecureBit.chat">
<meta name="twitter:description" content="Tests are individual .mjs files run in sequence by npm test. There is no test runner and no mocking library. A new suite is a new file, added to the test…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Contributing",
"description": "Tests are individual .mjs files run in sequence by npm test. There is no test runner and no mocking library. A new suite is a new file, added to the test…",
"url": "https://securebit.chat/docs/contributing/",
"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">Contributing</span>
</nav>
<h1>Contributing</h1>
<h2 id="workflow">Workflow</h2>
<pre><code class="language-bash">npm install
npm test # 41 suites, plain node:assert, no framework
npm audit
npm run build
</code></pre>
<p>Tests are individual <code>.mjs</code> files run in sequence by <code>npm test</code>. There is no test
runner and no mocking library. A new suite is a new file, added to the <code>test</code>
script in <code>package.json</code>.</p>
<h2 id="areas-that-need-extra-care">Areas that need extra care</h2>
<p>Changes touching any of these should come with tests that would fail without the
change:</p>
<ul>
<li>Verification: the safety code, the gate on control frames, protocol
compatibility</li>
<li>The Double Ratchet: key derivation, chain advance, skipped-key bounds, the
order in which state is committed</li>
<li>The inbound message path: anything that decides what reaches the interface</li>
<li>ICE and TURN behaviour, and the connection recovery cycle</li>
<li>File transfer consent and type policy</li>
<li>IndexedDB migration</li>
<li>Disconnect and resource cleanup</li>
</ul>
<h2 id="writing-tests-that-are-worth-having">Writing tests that are worth having</h2>
<p>Two bugs reached production during recent work, and both had the same cause: the
test built its own input instead of using what the application actually produces.</p>
<p>One test generated its own ECDH key pairs with usages the real generator did not
grant, and passed against a build that could not establish a session at all.
Another passed a locally generated public key where the application always
supplies an imported, non-extractable one, and missed a failure that disabled
forward secrecy for one side of every conversation. Locally generated public keys
are always extractable in WebCrypto regardless of the flag you pass, so that
difference is invisible unless you look for it.</p>
<p>The lesson is worth stating plainly: use the real factory functions, and where a
value crosses a boundary in the application, make the test cross the same
boundary. A test that constructs its inputs verifies the algorithm. Only a test
that uses the shipped path verifies the code.</p>
<p>Before relying on a new test, confirm it fails when the fix is removed. A test
that cannot fail is worse than no test, because it is read as coverage.</p>
<h2 id="documentation">Documentation</h2>
<p>When behaviour changes, update the documentation in the same commit:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Change</th>
<th>Documents</th>
</tr>
</thead>
<tbody><tr>
<td>Anything user-visible</td>
<td><code>README.md</code>, <code>CHANGELOG.md</code></td>
</tr>
<tr>
<td>Verification, keys, the ratchet</td>
<td><code>doc/CRYPTOGRAPHY.md</code>, <code>doc/ARCHITECTURE.md</code></td>
</tr>
<tr>
<td>The invitation format or the in-band key exchange</td>
<td><code>doc/DESCRIPTOR-SBQ2.md</code>, <code>doc/CRYPTOGRAPHY.md</code>, <code>doc/ARCHITECTURE.md</code></td>
</tr>
<tr>
<td>Deployment, ICE, file policy</td>
<td><code>doc/CONFIGURATION.md</code></td>
</tr>
<tr>
<td>Calls, codecs, adaptation</td>
<td><code>doc/CALLS.md</code></td>
</tr>
<tr>
<td>Internal interfaces</td>
<td><code>doc/API.md</code></td>
</tr>
<tr>
<td>Anything security relevant</td>
<td><code>SECURITY.md</code></td>
</tr>
</tbody></table></div>
<p>Values in the documentation (limits, timeouts, algorithm parameters) are taken
from the source. If you change one in code, change it in the documentation too,
otherwise the next person will trust the wrong number.</p>
<h2 id="release-notes-and-security-fixes">Release notes and security fixes</h2>
<p>Release notes describe what improved. They do not spell out how a weakness could
have been exploited, and neither do source comments. Users who have not updated
are the ones exposed by that detail, and with no server there is no way to update
everyone at once.</p>
<p>Comments explaining why a guard exists are valuable and should stay, because they
are what stops the guard being removed later. The distinction is between &quot;this
check exists because completing the handshake does not prove identity&quot; and a
reproduction recipe.</p>
<h2 id="pull-requests">Pull requests</h2>
<p>Include:</p>
<ul>
<li>what the problem is</li>
<li>what the change does</li>
<li>which tests you ran, and which new ones you added</li>
<li>what could regress</li>
<li>for user-visible changes, a screenshot or a log</li>
</ul>
<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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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/use-policy/">Use policy and limitations</a></li>
</ul>
</nav>
</div>
</body>
</html>
+469
View File
@@ -0,0 +1,469 @@
<!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>Cryptography - SecureBit.chat</title>
<meta name="description" content="Everything here runs in the browser on the Web Crypto API. There are no hand-rolled primitives. What is written by hand is the composition: the key…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/cryptography/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Cryptography - SecureBit.chat">
<meta property="og:description" content="Everything here runs in the browser on the Web Crypto API. There are no hand-rolled primitives. What is written by hand is the composition: the key…">
<meta property="og:url" content="https://securebit.chat/docs/cryptography/">
<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="Cryptography - SecureBit.chat">
<meta name="twitter:description" content="Everything here runs in the browser on the Web Crypto API. There are no hand-rolled primitives. What is written by hand is the composition: the key…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Cryptography",
"description": "Everything here runs in the browser on the Web Crypto API. There are no hand-rolled primitives. What is written by hand is the composition: the key…",
"url": "https://securebit.chat/docs/cryptography/",
"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">Cryptography</span>
</nav>
<h1>Cryptography</h1>
<p>Everything here runs in the browser on the Web Crypto API. There are no
hand-rolled primitives. What is written by hand is the composition: the key
schedule, the ratchet, the verification flow and the framing, and that is what
this document describes.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Release</td>
<td>v6.7.3</td>
</tr>
<tr>
<td>Protocol version</td>
<td>4.1</td>
</tr>
<tr>
<td>Ratchet wire version</td>
<td>1</td>
</tr>
</tbody></table></div>
<h2 id="primitives">Primitives</h2>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Purpose</th>
<th>Algorithm</th>
</tr>
</thead>
<tbody><tr>
<td>Key agreement</td>
<td>ECDH P-384, falling back to P-256 if the browser refuses P-384</td>
</tr>
<tr>
<td>Signatures</td>
<td>ECDSA P-384 with SHA-384, falling back to P-256 with SHA-256</td>
</tr>
<tr>
<td>Key derivation</td>
<td>HKDF-SHA256</td>
</tr>
<tr>
<td>Message encryption</td>
<td>AES-256-GCM</td>
</tr>
<tr>
<td>Message authentication</td>
<td>HMAC-SHA256, and AES-GCM&#39;s own tag on the ratcheted path</td>
</tr>
<tr>
<td>Password derivation</td>
<td>PBKDF2-SHA256, 310,000 iterations, 32-byte salt</td>
</tr>
</tbody></table></div>
<p>Session keys are non-extractable <code>CryptoKey</code> handles. The exceptions are the
values a ratchet has to chain itself, which Web Crypto cannot do behind an opaque
handle; those are raw bytes and are overwritten when finished with.</p>
<h2 id="session-establishment">Session establishment</h2>
<p>A session begins with one ECDH exchange. The public keys do <strong>not</strong> travel in the
invitation — they are sent over the data channel once it opens, and are checked
against a 16-byte commitment carried in the invitation before they are parsed or
imported. The receiving side then validates the SPKI structure (algorithm OID,
curve, point format and length) before importing anything. See
<a href="/docs/descriptor-sbq2/">DESCRIPTOR-SBQ2.md</a> for the wire format and the reasoning.</p>
<p>From the shared secret, HKDF-SHA256 derives five independent values, each under
its own <code>info</code> label so that recovering one reveals nothing about the others:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Label</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td><code>message-encryption-v4</code></td>
<td>AES-256-GCM payload key on the static path</td>
</tr>
<tr>
<td><code>message-authentication-v4</code></td>
<td>HMAC-SHA256 message authentication</td>
</tr>
<tr>
<td><code>metadata-protection-v4</code></td>
<td>AES-256-GCM for message metadata</td>
</tr>
<tr>
<td><code>fingerprint-generation-v4</code></td>
<td>Key fingerprint shown to the user and fed into the safety code</td>
</tr>
<tr>
<td><code>double-ratchet-root-v1</code></td>
<td>Root key for the Double Ratchet</td>
</tr>
</tbody></table></div>
<p>The raw ECDH output is produced with <code>deriveBits</code>, used as HKDF input material,
and the buffer holding it is overwritten as soon as derivation completes. It is
never exported through an extractable key.</p>
<p>The 64-byte session salt is <strong>not transmitted</strong>. Both sides derive it as
SHA-512 of the handshake transcript — both invitations byte for byte, and both
key blobs, each length-prefixed. That has two consequences: the salt cannot be
steered by either side alone, and every key in the schedule is bound to both DTLS
fingerprints and every ICE candidate that was exchanged.</p>
<h2 id="verification">Verification</h2>
<p>Both peers compute the same safety code with HKDF-SHA256, using the raw ECDH
shared secret as input material and the SHA-256 of the handshake transcript as
salt. The transcript covers <strong>both invitations verbatim</strong> — version byte, flags,
expiry, fingerprints, ICE credentials, every candidate, the commitments — and
<strong>both key blobs</strong>, each with a length prefix so no field boundary can be shifted.
Components are ordered by role rather than by who is computing, so both sides
reach the same seven digits.</p>
<p>Because the shared secret is the input material, an attacker who observes the
entire transcript still cannot predict the digits. Because the transcript is the
salt, nothing exchanged anywhere in the handshake, in either direction, can be
altered without changing them.</p>
<p>Possession of the identity key is proved separately: each side signs the
transcript with its ECDSA key and sends the signature over the channel. This
replaced an earlier challenge/response that echoed a nonce back across seven
fields; one signature binds the whole handshake at once.</p>
<p>Users compare the code through a channel an attacker cannot impersonate and enter
it manually. Local success is not sufficient: the session becomes verified only
after both peers confirm. Three incorrect entries end the session.</p>
<p>This is the step that makes the rest meaningful. Completing the key exchange
proves only that someone completed it; anyone able to rewrite the invitation can
do that with both people at once. The safety code covers the keys actually in use
and the invitations they arrived with, so a substitution anywhere changes the code
the users read to each other.</p>
<p>Key substitution alone — an attacker who can rewrite the in-band blob but not the
invitation — does not get that far: the commitment check fails first and the
connection is closed without anyone comparing anything.</p>
<p>Verification is also a gate rather than a label. Before it completes, the session
declines to act on control messages from the peer: reconnection signalling, call
setup, message deletion and delivery receipts. The verification exchange itself is
the deliberate exception, since it necessarily runs first.</p>
<h2 id="forward-secrecy">Forward secrecy</h2>
<p>The session keys above would last the whole conversation on their own. The Double
Ratchet, implemented in <code>src/crypto/DoubleRatchet.js</code>, replaces them for message
traffic so that protection does not rest on a single set of keys.</p>
<h3 id="symmetric-ratchet">Symmetric ratchet</h3>
<p>Each message key comes from the current chain key through <code>KDF_CK</code>, which is
HMAC-SHA256 over the chain key with one constant for the message key and another
for the next chain key. The message key is used once and destroyed. Because the
construction is one-way, holding the current chain key yields no earlier message
key.</p>
<h3 id="dh-ratchet">DH ratchet</h3>
<p>Each time the conversation changes direction, the replying peer generates a fresh
ECDH key pair, and both sides mix the new shared secret into the root key with
<code>KDF_RK</code> (HKDF-SHA256, root key as salt, producing the next root and a new chain
key). A session therefore re-keys continuously as messages go back and forth, and
an attacker who captured the full state is excluded again after one message in
each direction.</p>
<h3 id="initialisation">Initialisation</h3>
<p>No extra handshake data is exchanged. Both peers already hold each other&#39;s
authenticated ECDH public key, which is exactly what the safety code covers.</p>
<p>The inviting peer starts with a fresh ratchet key pair against the peer&#39;s
handshake key and steps the root once, so even its first message has left the
handshake key behind. The joining peer keeps its handshake key pair as its
current ratchet pair, which is what the inviting peer derived against, and takes
no chain until the first message arrives.</p>
<p>That asymmetry is inherent to the ratchet, not an implementation shortcut: both
sides must derive the first chain from the same exchange. The consequence is that
the joining peer has no sending chain until it receives something. The
application sends a presence update from both sides as soon as verification
completes, so those first frames use the session keys, and everything after them
is ratcheted.</p>
<h3 id="frame-format">Frame format</h3>
<p>A ratcheted message is <code>{ type: &quot;ratchet_message&quot;, h, c }</code>, where <code>h</code> is a header
string and <code>c</code> is the base64 body.</p>
<p>The header carries the sender&#39;s current ratchet public key, the length of the
previous sending chain, and the message number in the current one. It travels in
the clear because the receiver needs it before it can derive a key, and it is
passed to AES-GCM as additional authenticated data. Modifying any field causes
decryption to fail rather than redirecting the ratchet.</p>
<p>The header must be handed back to the decrypt call exactly as received. It is the
authenticated data itself, so re-serialising it can change a byte and fail
authentication for no reason.</p>
<h3 id="out-of-order-messages">Out-of-order messages</h3>
<p>Keys for messages that have not yet arrived are retained so they can still be
read, within fixed bounds:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Bound</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Maximum skip within one chain</td>
<td>512</td>
</tr>
<tr>
<td>Total retained keys</td>
<td>1024, oldest evicted first</td>
</tr>
<tr>
<td>Retention period</td>
<td>5 minutes</td>
</tr>
</tbody></table></div>
<p>The message number comes off the wire, so the distance a single frame may claim
has to be limited. Without a cap, one frame claiming a number in the millions
would force the receiver to derive and hold that many keys.</p>
<p>Replay protection is intrinsic here. A message key is destroyed on use, so a
number behind the current chain has no key left to open it.</p>
<h3 id="state-is-committed-only-after-authentication">State is committed only after authentication</h3>
<p>Receiving stages the chain advance and any DH step, attempts decryption, and
commits only on success. A frame that fails authentication leaves the ratchet
exactly as it was.</p>
<p>This matters because the header is reachable by anyone on the channel. Advancing
the chains before verifying would let a single bad frame push the receiver past
the sender and break the session permanently, which would be a remote denial of
service against an established conversation.</p>
<h3 id="negotiation">Negotiation</h3>
<p>Support is advertised in the invitation and in the response, and the ratchet runs
only when both sides advertise it. A peer on an earlier release negotiates it
away and the session uses the per-session keys described above.</p>
<p>The fallback is deliberate. With no server there is no way to update both ends at
once, and a one-sided ratchet decrypts nothing. The security panel reports which
of the two is in force for the current connection rather than what the client is
capable of.</p>
<h2 id="message-protection-on-the-static-path">Message protection on the static path</h2>
<p>Messages encrypted with the session keys carry their metadata (identifier,
timestamp, sequence number, original length) encrypted separately under the
metadata key, and the whole payload is covered by an HMAC. Sequence numbers are
checked against a sliding window: a number behind the expected one is rejected as
a replay, and a gap beyond the window is rejected as well.</p>
<p>Payloads are padded to a 16-byte boundary with random bytes, with the true length
carried in the encrypted metadata.</p>
<h2 id="rendering">Rendering</h2>
<p>Decrypted text is sanitized with DOMPurify configured to allow no tags and no
attributes at all, then rendered through React text nodes. Fenced code blocks are
tokenised by Prism, which escapes its input before highlighting and never
evaluates it. The content security policy permits no inline or remote scripts.</p>
<p>Chat content reaches the interface through one authenticated path. Frames that
are not authenticated are rejected rather than displayed, so nothing can appear
in a conversation that did not come from the peer holding the session keys.</p>
<h2 id="local-storage">Local storage</h2>
<p>Sensitive IndexedDB metadata is stored in encrypted envelopes. Legacy plaintext
records remain readable through a migration path and are rewritten encrypted when
next accessed. Corrupted encrypted metadata fails closed.</p>
<p>The master key for persistent storage is derived from a password with PBKDF2 and
is non-extractable. The application supplies the password interface; there is no
browser dialog fallback.</p>
<h2 id="memory-handling">Memory handling</h2>
<p>Values that can be overwritten are overwritten: the ECDH output, HKDF
intermediates, the ratchet root and chain keys, and retained message keys.</p>
<p>Values that cannot be overwritten are documented rather than reported as cleared.
JavaScript strings are immutable, so a secret held as a string can only be
dereferenced. A non-extractable <code>CryptoKey</code> has no bytes visible to JavaScript at
all, so dropping the handle is the only available action and non-extractability is
what protects it. Functions that cannot wipe say so in their logs instead of
reporting success, because a cleanup path that reports work it did not do is
worse than one that reports nothing.</p>
<h2 id="groups">Groups</h2>
<p>A group owns no transport and no key of its own. Every byte it sends leaves over
one of the pairwise sessions described above, already ratcheted and already
authenticated by that session. There is no shared group key, so there is nothing
to rotate when a member leaves: a removed member simply stops being sent
anything.</p>
<p>Each member holds a per-group ECDSA P-384 identity key, generated in the browser,
non-extractable, and discarded with the group. A member is identified by the
SHA-256 fingerprint of that key&#39;s SPKI encoding — never by a session, never by a
name a peer supplied.</p>
<h3 id="membership">Membership</h3>
<p>The admin signs the full member set for an epoch: group id, epoch, operation,
name, and the member fingerprints in canonical order. Members verify that
signature against the key whose fingerprint they recorded when they were invited,
not against whatever key the frame carries, and accept only an epoch that has not
gone backwards. Identity keys travel in separate frames because eight SPKIs would
overrun the transport&#39;s frame budget; each is checked against the fingerprint the
signed roster commits to, so a substituted key is refused whichever frame carried
it.</p>
<h3 id="group-safety-code">Group safety code</h3>
<p>Every member commits to a random 32-byte nonce, and nonces are published only
once every commitment has arrived. The code is derived with HKDF-SHA-256 over the
full set of fingerprints and nonces for that epoch, and rendered as seven digits.
The commit-then-reveal ordering is what makes seven digits safe: a member who
learnt the others&#39; nonces first could otherwise grind its own until the digits
came out however it liked. Membership changes open a new epoch, so the code
changes with the member set and the old one no longer says anything about who is
in the room.</p>
<h3 id="messages">Messages</h3>
<p>Group messages are signed with the sender&#39;s identity key over group id, epoch,
sequence number, sender fingerprint and the SHA-256 of the body. Signing is not
about confidentiality — each copy already travels inside a pairwise session — but
about consistency: a member could otherwise send different text to different
people under one sequence number and no recipient could tell. Two valid
signatures on one sequence number are non-repudiable evidence of that, which is
what a group without a shared transcript can honestly offer. It makes the split
detectable, not impossible.</p>
<h3 id="the-mesh">The mesh</h3>
<p>A group starts as a star and dials itself into a mesh: once the code is
confirmed, each pair with no link between them opens one, with the compact
descriptors travelling over the relay path that already exists. The member with
the smaller fingerprint dials, which is the whole glare protocol.</p>
<p>A relayed descriptor is signed with the sender&#39;s group identity key, over the
direction, both fingerprints, a per-attempt nonce and the descriptor bytes. The
relaying member can drop a dial or delay it; it cannot substitute one, so it
cannot place itself inside the link built to route around it. On the new
connection the ordinary in-band key exchange runs unchanged, and the link is then
released on the group&#39;s authority rather than by a human comparing digits — the
descriptor was signed by a key the roster names and the group code already
covers. The release path refuses any session whose in-band handshake has not
completed and whose peer has not proved possession of that key.</p>
<p>An existing 1:1 chat between two members is claimed instead of re-dialled, using
a probe signed over the group id, epoch, member fingerprint and that session&#39;s
own key fingerprint. Both endpoints of a session derive the same key fingerprint
from the shared secret and nobody else can, so a probe replayed onto a different
chat does not verify — which is what stops a member claiming to be someone else
and receiving their group traffic.</p>
<h3 id="group-calls">Group calls</h3>
<p>A group call is not a conference. It is N-1 ordinary 1:1 calls, one to each other
member, each carried by that member&#39;s own pairwise session — so every leg&#39;s media
rides a DTLS-SRTP transport that a human already authenticated by comparing the
pairwise safety code. There is no mixer and no selective forwarding unit, and at
no point does two members&#39; media meet anywhere but on a device.</p>
<p>Call control is separate from call media, because the two can reach different
sets of people. Control — a call was opened, a member joined, a member left —
travels as group frames and therefore reaches members who are currently reachable
only through a relay; media flows only where a direct link exists, so a member
without one is shown as connecting rather than omitted.</p>
<p>Each control frame is signed with the sender&#39;s group identity key over group id,
epoch, call id, action, sender fingerprint, a per-sender sequence number and
whether the call carries video. The signature is what makes relaying these frames
safe: a relaying member can refuse to carry one — the availability cost relaying
always has — but cannot add a member to a call, remove one, or end a call on
somebody else&#39;s behalf. The sequence number is checked before the action is
considered, so a captured frame cannot be replayed to drag a member back into a
call they left or to close one that is running. Frames are refused outright
unless the group is READY and its code confirmed.</p>
<p>Call ids are 16 random bytes. Two members opening a call in the same instant
therefore produce two ids, and every member resolves it identically by keeping
the lower one — nothing is negotiated and no member arbitrates. Which side of a
pair places its leg is the mesh&#39;s rule again: the smaller fingerprint dials, the
other answers.</p>
<p>A leg answers without prompting the user, and that is the one place a call is
opened without a per-call confirmation. The flag permitting it is set only by the
local group-call controller, only while this user is in that call, and is cleared
when they leave; nothing arriving on the wire can set it. The alternative —
prompting once per member — would be seven prompts for one decision and would
train users to accept them. A single capture is opened when the user joins,
shared across every leg, and stopped when the call, the group or the tab ends;
no leg may stop a capture it borrowed.</p>
<h2 id="scope">Scope</h2>
<p>This describes the browser implementation as it stands in v6.7.3. It is not a
substitute for independent cryptographic review.</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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
+501
View File
@@ -0,0 +1,501 @@
<!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
20002400 characters to <strong>98149 bytes</strong>, and the QR from version 3840 down to
<strong>version 68</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>01</td>
<td>type: 0 offer, 1 answer. 2 and 3 are reserved → <strong>reject</strong></td>
</tr>
<tr>
<td>23</td>
<td>DTLS setup role: 0 actpass, 1 active, 2 passive. 3 reserved → <strong>reject</strong></td>
</tr>
<tr>
<td>45</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 &quot;ignore me&quot; bit.</p>
<h3 id="candidate">candidate</h3>
<pre><code>1 byte kind &lt;&lt; 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. 715 reserved → <strong>reject</strong>.
<code>tcptype</code>: 0 udp, 1 tcp/passive, 2 tcp/active, 3 tcp/so. 415 → <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&#39;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&#39;s
own algorithm never reads them. An earlier draft left them out as
&quot;diagnostics&quot;. Chrome accepts such a line; <strong>Firefox drops the candidate.</strong>
Measured: relay-only connections to Firefox went 0/8 while the browser&#39;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 &quot;nothing gathered yet&quot;
(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&#39;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&#39;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&#39;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&#39;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&#39;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(&quot;sbq2/bind\0&quot; || 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&#39;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 = &quot;sbq2/sas/v1\0&quot;
|| 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 = &quot;sbq2-sas-v1&quot;) -&gt; 64 bits -&gt; 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&lt;n&gt;:</code> family is reported as <em>&quot;This
invitation was created by a newer version of SecureBit. Please update the app to
connect.&quot;</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 &#39;S&#39;, &quot;SB2:Ak…&quot; 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 23, setup role 3, candidate kind 715,
tcptype 415) 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&#39;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>
+319
View File
@@ -0,0 +1,319 @@
<!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>Frequently asked questions - SecureBit.chat</title>
<meta name="description" content="Short answers to what people ask before they trust the software with anything. Every one of them is drawn from the documents linked alongside it, so if an…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/faq/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Frequently asked questions - SecureBit.chat">
<meta property="og:description" content="Short answers to what people ask before they trust the software with anything. Every one of them is drawn from the documents linked alongside it, so if an…">
<meta property="og:url" content="https://securebit.chat/docs/faq/">
<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="Frequently asked questions - SecureBit.chat">
<meta name="twitter:description" content="Short answers to what people ask before they trust the software with anything. Every one of them is drawn from the documents linked alongside it, so if an…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"url": "https://securebit.chat/docs/faq/",
"inLanguage": "en",
"isPartOf": {
"@type": "WebSite",
"@id": "https://securebit.chat/#website"
},
"publisher": {
"@id": "https://securebit.chat/#organization"
},
"mainEntity": [
{
"@type": "Question",
"name": "Do I need an account?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>No. There is nothing to register, no email address, no phone number and no password. You open the page, your browser generates a key pair, and you send the resulting invitation to the person you want to talk to. Nothing about you is stored anywhere, because there is nowhere to store it.</p>"
}
},
{
"@type": "Question",
"name": "Where are my messages stored?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Nowhere. Messages travel directly between the two browsers over WebRTC and are held only in the memory of the two devices taking part. There is no message server, no database and no backup — which also means a conversation you close is gone, and nobody can hand it over later because nobody has it.</p>"
}
},
{
"@type": "Question",
"name": "Is there a server in the middle?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>No, and there is not even a signalling service. The offer and the answer that set up a connection are moved between the two people by whatever channel they already have: a QR code, a pasted block of text, a link. That is the unusual part of the design and it shapes everything else — the out-of-band channel is assumed to be readable and rewritable by an attacker, which is exactly why the safety code comparison exists. See <a href=\"/docs/architecture/\">ARCHITECTURE.md</a>.</p> <p>A TURN server, if you use one, relays encrypted packets. It never sees message content, and it is optional.</p>"
}
},
{
"@type": "Question",
"name": "What encryption does SecureBit.chat use?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Key exchange is ECDH on P-384, message payloads are AES-256-GCM, and messages carry ECDSA signatures with full ASN.1 validation of every key that arrives. Session keys rotate continuously through a Double Ratchet and are discarded after use, so a key recovered later cannot open earlier conversations. The transport underneath is DTLS, as WebRTC requires. Everything runs on the browser&#39;s Web Crypto API rather than hand-rolled primitives — <a href=\"/docs/cryptography/\">CRYPTOGRAPHY.md</a> sets out the key schedule in full.</p>"
}
},
{
"@type": "Question",
"name": "What is the safety code, and why do I have to compare it?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>It is a short code derived from both sides of the completed key exchange. If the two of you see the same code, nobody sat in the middle of the exchange; if someone did, the codes differ and the session is not what it claims to be.</p> <p>The comparison has to happen over a channel an attacker cannot impersonate — in person, or a voice you recognise. Comparing it inside the chat itself proves nothing. It is enforced rather than merely displayed: until both sides confirm, reconnection signalling, call setup, delivery receipts and incoming file transfers are all refused, and three incorrect entries end the session.</p>"
}
},
{
"@type": "Question",
"name": "Is it free, and can I read the source?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Yes to both. The software is free, has no paid tier and no upsell, and is published under the MIT licence at <a href=\"https://github.com/SecureBitChat/securebit-chat\">github.com/SecureBitChat/securebit-chat</a>. Nothing is minified beyond recognition or hidden behind a build you cannot reproduce; <a href=\"/docs/configuration/\">CONFIGURATION.md</a> describes building and running it yourself.</p>"
}
},
{
"@type": "Question",
"name": "Do I have to install anything?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>No. It runs in any current browser with WebRTC and the Web Crypto API. It can be installed as a Progressive Web App if you would rather have an icon, and there are native desktop builds for Windows, macOS and Linux. Calls are not yet available on the Linux desktop build; they work everywhere else, including in the browser.</p>"
}
},
{
"@type": "Question",
"name": "Can the person I am talking to see my IP address?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>On a default connection, yes — a direct WebRTC path means each side learns the other&#39;s address. That is how a direct connection works, not a flaw in this implementation.</p> <p>Relay-only mode prevents it: it sets <code>iceTransportPolicy: &quot;relay&quot;</code>, so all traffic goes through a TURN server and the peers see only the relay. The trade is that the relay operator can then see both addresses and the timing of the traffic, though never the content. Choose according to who you are protecting against.</p>"
}
},
{
"@type": "Question",
"name": "Do I need my own TURN server?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Only for relay-only mode, or when a direct path cannot be established at all. Public STUN defaults are built in and are enough for most connections. You can paste your own STUN and TURN servers under Advanced network settings; they are validated against an allowlist of schemes, hosts and transports before they reach the connection.</p> <p>One thing worth knowing: any TURN credential sent to a browser is public by definition, because the browser has to read it. Apply quotas on the TURN server rather than relying on the credential staying secret. <a href=\"/docs/configuration/\">CONFIGURATION.md</a> covers the setup and how to verify it works.</p>"
}
},
{
"@type": "Question",
"name": "Can I send files, and how large?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Yes, over the same encrypted channel, with a per-file AES-GCM key and a SHA-256 integrity check. The receiver sees name, size and type and has to accept before any buffer is allocated or a single chunk is sent.</p> <p>The ceiling is 100 MB per file, with lower limits by category: images 25 MB, PDFs 50 MB, text 10 MB, archives 100 MB, audio 20 MB. Executable and scriptable formats are blocked outright — <code>.exe</code>, <code>.js</code>, <code>.html</code>, <code>.svg</code> and others — and the file extension, not the browser-supplied MIME type, is the boundary that decides.</p>"
}
},
{
"@type": "Question",
"name": "Are group chats and calls supported?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Yes. Encrypted voice and video calls shipped in early 2026, and group communications are current work. Group sessions keep the same properties as one-to-one ones: no server holds the conversation, and members verify each other. <a href=\"/docs/calls/\">CALLS.md</a> covers codec choices and how the connection adapts to a poor network.</p>"
}
},
{
"@type": "Question",
"name": "Is there a mobile app?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Not yet. The web application works in mobile browsers today and can be installed to the home screen as a PWA. Native iOS and Android builds are planned; the roadmap on the front page lists where they sit relative to the rest of the work.</p>"
}
},
{
"@type": "Question",
"name": "What does it not protect against?",
"acceptedAnswer": {
"@type": "Answer",
"text": "<p>Read <a href=\"/docs/use-policy/\">USE-POLICY.md</a> before relying on this for anything that matters. In short: a compromised device sees your messages exactly as you do, and no transport encryption helps. The person you are talking to can screenshot or repeat what you said. A safety code you skipped protects nothing. And someone watching your network can see that you are using a WebRTC application, even though they cannot see what you send — the software does not hide its own use.</p>"
}
}
]
}
</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">Frequently asked questions</span>
</nav>
<h1>Frequently asked questions</h1>
<p>Short answers to what people ask before they trust the software with anything.
Every one of them is drawn from the documents linked alongside it, so if an answer
here and a document disagree, the document is right and this page is stale.</p>
<h2 id="do-i-need-an-account">Do I need an account?</h2>
<p>No. There is nothing to register, no email address, no phone number and no
password. You open the page, your browser generates a key pair, and you send the
resulting invitation to the person you want to talk to. Nothing about you is
stored anywhere, because there is nowhere to store it.</p>
<h2 id="where-are-my-messages-stored">Where are my messages stored?</h2>
<p>Nowhere. Messages travel directly between the two browsers over WebRTC and are
held only in the memory of the two devices taking part. There is no message
server, no database and no backup — which also means a conversation you close is
gone, and nobody can hand it over later because nobody has it.</p>
<h2 id="is-there-a-server-in-the-middle">Is there a server in the middle?</h2>
<p>No, and there is not even a signalling service. The offer and the answer that set
up a connection are moved between the two people by whatever channel they already
have: a QR code, a pasted block of text, a link. That is the unusual part of the
design and it shapes everything else — the out-of-band channel is assumed to be
readable and rewritable by an attacker, which is exactly why the safety code
comparison exists. See <a href="/docs/architecture/">ARCHITECTURE.md</a>.</p>
<p>A TURN server, if you use one, relays encrypted packets. It never sees message
content, and it is optional.</p>
<h2 id="what-encryption-does-securebitchat-use">What encryption does SecureBit.chat use?</h2>
<p>Key exchange is ECDH on P-384, message payloads are AES-256-GCM, and messages
carry ECDSA signatures with full ASN.1 validation of every key that arrives.
Session keys rotate continuously through a Double Ratchet and are discarded after
use, so a key recovered later cannot open earlier conversations. The transport
underneath is DTLS, as WebRTC requires. Everything runs on the browser&#39;s Web
Crypto API rather than hand-rolled primitives — <a href="/docs/cryptography/">CRYPTOGRAPHY.md</a>
sets out the key schedule in full.</p>
<h2 id="what-is-the-safety-code-and-why-do-i-have-to-compare-it">What is the safety code, and why do I have to compare it?</h2>
<p>It is a short code derived from both sides of the completed key exchange. If the
two of you see the same code, nobody sat in the middle of the exchange; if someone
did, the codes differ and the session is not what it claims to be.</p>
<p>The comparison has to happen over a channel an attacker cannot impersonate — in
person, or a voice you recognise. Comparing it inside the chat itself proves
nothing. It is enforced rather than merely displayed: until both sides confirm,
reconnection signalling, call setup, delivery receipts and incoming file
transfers are all refused, and three incorrect entries end the session.</p>
<h2 id="is-it-free-and-can-i-read-the-source">Is it free, and can I read the source?</h2>
<p>Yes to both. The software is free, has no paid tier and no upsell, and is
published under the MIT licence at
<a href="https://github.com/SecureBitChat/securebit-chat">github.com/SecureBitChat/securebit-chat</a>.
Nothing is minified beyond recognition or hidden behind a build you cannot
reproduce; <a href="/docs/configuration/">CONFIGURATION.md</a> describes building and running it
yourself.</p>
<h2 id="do-i-have-to-install-anything">Do I have to install anything?</h2>
<p>No. It runs in any current browser with WebRTC and the Web Crypto API. It can be
installed as a Progressive Web App if you would rather have an icon, and there are
native desktop builds for Windows, macOS and Linux. Calls are not yet available on
the Linux desktop build; they work everywhere else, including in the browser.</p>
<h2 id="can-the-person-i-am-talking-to-see-my-ip-address">Can the person I am talking to see my IP address?</h2>
<p>On a default connection, yes — a direct WebRTC path means each side learns the
other&#39;s address. That is how a direct connection works, not a flaw in this
implementation.</p>
<p>Relay-only mode prevents it: it sets <code>iceTransportPolicy: &quot;relay&quot;</code>, so all traffic
goes through a TURN server and the peers see only the relay. The trade is that the
relay operator can then see both addresses and the timing of the traffic, though
never the content. Choose according to who you are protecting against.</p>
<h2 id="do-i-need-my-own-turn-server">Do I need my own TURN server?</h2>
<p>Only for relay-only mode, or when a direct path cannot be established at all.
Public STUN defaults are built in and are enough for most connections. You can
paste your own STUN and TURN servers under Advanced network settings; they are
validated against an allowlist of schemes, hosts and transports before they reach
the connection.</p>
<p>One thing worth knowing: any TURN credential sent to a browser is public by
definition, because the browser has to read it. Apply quotas on the TURN server
rather than relying on the credential staying secret. <a href="/docs/configuration/">CONFIGURATION.md</a>
covers the setup and how to verify it works.</p>
<h2 id="can-i-send-files-and-how-large">Can I send files, and how large?</h2>
<p>Yes, over the same encrypted channel, with a per-file AES-GCM key and a SHA-256
integrity check. The receiver sees name, size and type and has to accept before
any buffer is allocated or a single chunk is sent.</p>
<p>The ceiling is 100 MB per file, with lower limits by category: images 25 MB, PDFs
50 MB, text 10 MB, archives 100 MB, audio 20 MB. Executable and scriptable formats
are blocked outright — <code>.exe</code>, <code>.js</code>, <code>.html</code>, <code>.svg</code> and others — and the file
extension, not the browser-supplied MIME type, is the boundary that decides.</p>
<h2 id="are-group-chats-and-calls-supported">Are group chats and calls supported?</h2>
<p>Yes. Encrypted voice and video calls shipped in early 2026, and group
communications are current work. Group sessions keep the same properties as
one-to-one ones: no server holds the conversation, and members verify each other.
<a href="/docs/calls/">CALLS.md</a> covers codec choices and how the connection adapts to a poor
network.</p>
<h2 id="is-there-a-mobile-app">Is there a mobile app?</h2>
<p>Not yet. The web application works in mobile browsers today and can be installed
to the home screen as a PWA. Native iOS and Android builds are planned; the
roadmap on the front page lists where they sit relative to the rest of the work.</p>
<h2 id="what-does-it-not-protect-against">What does it not protect against?</h2>
<p>Read <a href="/docs/use-policy/">USE-POLICY.md</a> before relying on this for anything that
matters. In short: a compromised device sees your messages exactly as you do, and
no transport encryption helps. The person you are talking to can screenshot or
repeat what you said. A safety code you skipped protects nothing. And someone
watching your network can see that you are using a WebRTC application, even though
they cannot see what you send — the software does not hide its own use.</p>
<nav class="more">
<h2>More documentation</h2>
<ul>
<li><a href="/docs/">Documentation</a></li>
<li><a href="/docs/architecture/">Architecture</a></li>
<li><a href="/docs/cryptography/">Cryptography</a></li>
<li><a href="/docs/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
+171
View File
@@ -0,0 +1,171 @@
<!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>Documentation - SecureBit.chat</title>
<meta name="description" content="Technical documentation for SecureBit.chat. Start with the project README if you are looking for an overview or a quick start.">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Documentation - SecureBit.chat">
<meta property="og:description" content="Technical documentation for SecureBit.chat. Start with the project README if you are looking for an overview or a quick start.">
<meta property="og:url" content="https://securebit.chat/docs/">
<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="Documentation - SecureBit.chat">
<meta name="twitter:description" content="Technical documentation for SecureBit.chat. Start with the project README if you are looking for an overview or a quick start.">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Documentation",
"description": "Technical documentation for SecureBit.chat. Start with the project README if you are looking for an overview or a quick start.",
"url": "https://securebit.chat/docs/",
"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">Documentation</span>
</nav>
<h1>Documentation</h1>
<p>Technical documentation for SecureBit.chat. Start with the project <a href="https://github.com/SecureBitChat/securebit-chat#readme">README</a>
if you are looking for an overview or a quick start.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Document</th>
<th>What it covers</th>
</tr>
</thead>
<tbody><tr>
<td><a href="/docs/architecture/">ARCHITECTURE.md</a></td>
<td>How a session is established, verified and torn down, and where each guarantee comes from</td>
</tr>
<tr>
<td><a href="/docs/cryptography/">CRYPTOGRAPHY.md</a></td>
<td>Key schedule, the Double Ratchet, SAS verification, memory handling</td>
</tr>
<tr>
<td><a href="/docs/descriptor-sbq2/">DESCRIPTOR-SBQ2.md</a></td>
<td>The invitation format: wire layout, why it is small, the in-band key exchange, decoder rules</td>
</tr>
<tr>
<td><a href="/docs/configuration/">CONFIGURATION.md</a></td>
<td>Deployment, ICE and TURN setup, privacy modes, file transfer policy</td>
</tr>
<tr>
<td><a href="/docs/calls/">CALLS.md</a></td>
<td>Voice and video: codec choices, adaptation, and why each value was picked</td>
</tr>
<tr>
<td><a href="/docs/api/">API.md</a></td>
<td>Internal interfaces of the WebRTC manager and file transfer system</td>
</tr>
<tr>
<td><a href="/docs/contributing/">CONTRIBUTING.md</a></td>
<td>Development workflow and what needs extra review</td>
</tr>
<tr>
<td><a href="/docs/use-policy/">USE-POLICY.md</a></td>
<td>Terms of use, intended use, and the limits of what the software can protect</td>
</tr>
</tbody></table></div>
<p>Security policy and vulnerability reporting live in <a href="https://github.com/SecureBitChat/securebit-chat/blob/main/SECURITY.md">SECURITY.md</a> at
the repository root, where GitHub expects to find them.</p>
<h2 id="keeping-this-accurate">Keeping this accurate</h2>
<p>These documents describe the current release and are expected to change with it.
Every value quoted here (limits, timeouts, algorithm parameters) is taken from the
source rather than restated from memory, so if you change one in code, change it
here in the same commit. <a href="/docs/contributing/">CONTRIBUTING.md</a> lists which documents
are affected by which areas of the code.</p>
<nav class="more">
<h2>More documentation</h2>
<ul>
<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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
+185
View File
@@ -0,0 +1,185 @@
<!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>Use policy and limitations - SecureBit.chat</title>
<meta name="description" content="SecureBit.chat is open-source software for private communication, research and education. It is provided as is, without warranty of any kind. This…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/use-policy/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Use policy and limitations - SecureBit.chat">
<meta property="og:description" content="SecureBit.chat is open-source software for private communication, research and education. It is provided as is, without warranty of any kind. This…">
<meta property="og:url" content="https://securebit.chat/docs/use-policy/">
<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="Use policy and limitations - SecureBit.chat">
<meta name="twitter:description" content="SecureBit.chat is open-source software for private communication, research and education. It is provided as is, without warranty of any kind. This…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Use policy and limitations",
"description": "SecureBit.chat is open-source software for private communication, research and education. It is provided as is, without warranty of any kind. This…",
"url": "https://securebit.chat/docs/use-policy/",
"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">Use policy and limitations</span>
</nav>
<h1>Use policy and limitations</h1>
<p>SecureBit.chat is open-source software for private communication, research and
education. It is provided as is, without warranty of any kind. This document sets
out what the software can and cannot do for you, and what is expected of you when
you use it.</p>
<h2 id="what-it-protects">What it protects</h2>
<p>Message content between two verified peers, against anyone observing or
manipulating the network between them. That is a real guarantee and it is what
the design is built around.</p>
<h2 id="what-it-does-not-protect">What it does not protect</h2>
<p>Be clear about these before relying on the software for anything that matters.</p>
<p><strong>A compromised device.</strong> Malware, a hostile browser extension, a keylogger or
someone with access to an unlocked machine sees your messages as you do. No
transport encryption helps. This is the most common way private communication is
actually broken.</p>
<p><strong>The person you are talking to.</strong> They can screenshot, photograph the screen,
copy the text, or simply repeat what you said. View-once and disappearing
messages are cooperative features that a normal client honours; they are not a
technical restriction on a determined recipient.</p>
<p><strong>Verification you skipped.</strong> If you do not compare the safety code, or you
compare it over a channel the attacker controls, the software cannot tell that
someone is in the middle. The comparison must happen over something an attacker
cannot impersonate: in person, or a voice you recognise.</p>
<p><strong>Metadata, depending on your setup.</strong> A direct connection reveals your IP
address to the peer. Relay-only mode with your own TURN server prevents that, but
the relay operator can then see both addresses and the timing of traffic, though
never the content. Choose according to who you are protecting against.</p>
<p><strong>The fact that you are using it.</strong> Someone watching your network can see a
WebRTC connection and can see you loading this application. The software does not
hide its own use.</p>
<h2 id="your-responsibilities">Your responsibilities</h2>
<ul>
<li>Comply with the law where you are and with any policies that apply to you</li>
<li>Keep your device and browser current and under your control</li>
<li>Compare the safety code out of band, every time, on every new session</li>
<li>Configure TURN correctly if you need relay-only mode, and verify it works</li>
<li>Understand that endpoint compromise defeats everything above the endpoint</li>
</ul>
<h2 id="intended-use">Intended use</h2>
<p>The software is meant for legitimate private communication: journalism and source
protection, human rights work, business confidentiality, medical and legal
matters, research and education, and ordinary personal conversation that is
nobody else&#39;s business.</p>
<p>It is not meant to facilitate unlawful activity, abuse, harassment, exploitation
or harm, and being able to communicate privately does not make any of those
acceptable.</p>
<p>If you become aware of the software being used to harm someone, report it to the
appropriate authorities. Vulnerabilities in the software itself go to the
maintainers first: see <a href="https://github.com/SecureBitChat/securebit-chat/blob/main/SECURITY.md">SECURITY.md</a>.</p>
<h2 id="operational-notes">Operational notes</h2>
<p>If your threat model is serious, the software is only one part of it.</p>
<p>Use a device you control and keep it patched. Consider a separate device for
sensitive conversations. Be aware of who can see your screen and who can hear
you. Understand that a camera and microphone are attached to the machine you are
typing on. Consider what your network operator can observe, and whether a VPN or
Tor changes that in your favour or simply moves the observation point.</p>
<p>Know the law where you are. Encryption is regulated differently in different
places, and in some jurisdictions there are disclosure requirements attached to
it.</p>
<h2 id="contributing">Contributing</h2>
<p>Contributions are welcome under the same expectations. Report vulnerabilities
through the process in <a href="https://github.com/SecureBitChat/securebit-chat/blob/main/SECURITY.md">SECURITY.md</a> rather than publishing them,
and give a fix reasonable time to reach users before disclosure. Users who have
not updated yet are the ones exposed by early publication.</p>
<p>See <a href="/docs/contributing/">CONTRIBUTING.md</a> for the development workflow.</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/descriptor-sbq2/">SBQ2 — connection descriptor v2</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>
</ul>
</nav>
</div>
</body>
</html>