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
313 lines
19 KiB
HTML
313 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<!-- Generated by scripts/build-docs.js from doc/*.md. Edits here are overwritten;
|
|
change the Markdown instead. -->
|
|
<html lang="en" dir="ltr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;">
|
|
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
|
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
|
|
<title>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'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'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'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'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>
|