The palette lived as ~620 hex literals in inline styles plus a few hundred more in
the stylesheets, so there was no single thing to change. It is now 113 custom
properties in src/styles/theme.css, in two blocks.
src/scripts/theme-boot.js decides the theme before first paint — blocking, in <head>,
above the stylesheet, because a deferred script paints dark first and corrects itself.
It stores the mode ('system' | 'light' | 'dark'), never the colour it resolved to, and
stamps data-theme so an explicit choice can beat the media query. The switcher in the
header is a view onto it.
A filled accent stays the brand colour in both themes — the ink on it is near-black
either way — while an accent used as text darkens to clear 4.5:1 on white. A colour
reaches a fill by four routes (a style property, a constant, a helper argument, an SVG
source string), and tests/theme-switching.test.mjs covers all four.
The dark theme is unchanged: every colour declaration the previous build produced comes
out of this one identically once the properties are resolved.
Also: the roadmap drops its status chips on mobile, and Roadmap.jsx no longer splits a
colour with parseInt at runtime, which a var() reference cannot survive.
352 lines
20 KiB
HTML
352 lines
20 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>
|
|
|
|
/* These pages carry no script — that is the point of them — so the theme can only
|
|
come from the media query. There is no toggle here and no stored preference: a
|
|
reference page follows the reader's system and nothing else. The values are the
|
|
same two palettes as src/styles/theme.css, restated because these pages do not
|
|
load the app's stylesheet and are not going to start for eleven declarations. */
|
|
:root {
|
|
color-scheme: dark;
|
|
--d-ink: 255, 255, 255;
|
|
--d-bg: #0f0f11;
|
|
--d-bg-deep: #0b0b0e;
|
|
--d-code-bg: #17171c;
|
|
--d-text: #d6d6dc;
|
|
--d-heading: #f4f4f6;
|
|
--d-strong: #e8e8eb;
|
|
--d-body: #a9a9b3;
|
|
--d-pre: #c9c9d1;
|
|
--d-muted: #8a8a92;
|
|
--d-faint: #6b6b73;
|
|
--d-accent: #f0892a;
|
|
--d-accent-rgb: 240, 137, 42;
|
|
}
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
color-scheme: light;
|
|
--d-ink: 0, 0, 0;
|
|
--d-bg: #fbfbfc;
|
|
--d-bg-deep: #f2f3f5;
|
|
--d-code-bg: #f2f3f5;
|
|
--d-text: #26262c;
|
|
--d-heading: #0e0e12;
|
|
--d-strong: #1b1b20;
|
|
--d-body: #43434c;
|
|
--d-pre: #2f3340;
|
|
--d-muted: #63636c;
|
|
--d-faint: #7c7c85;
|
|
--d-accent: #b05c08;
|
|
--d-accent-rgb: 176, 92, 8;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--d-bg);
|
|
color: var(--d-text);
|
|
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(var(--d-ink),.08);
|
|
font-size: 13.5px;
|
|
}
|
|
.top a { color: var(--d-muted); text-decoration: none; }
|
|
.top a:hover, .top a:focus-visible { color: var(--d-accent); }
|
|
.top .brand { color: var(--d-accent); font-weight: 700; letter-spacing: .04em; }
|
|
.top .here { color: var(--d-text); margin-inline-start: auto; }
|
|
h1 { font-size: clamp(28px, 5vw, 36px); font-weight: 800; letter-spacing: -1px; line-height: 1.14; color: var(--d-heading); margin: 0 0 24px; }
|
|
h2 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; color: var(--d-heading); margin: 46px 0 12px; padding-top: 14px; border-top: 1px solid rgba(var(--d-ink),.07); }
|
|
h3 { font-size: 17.5px; font-weight: 700; color: var(--d-strong); margin: 30px 0 8px; }
|
|
h4 { font-size: 15.5px; font-weight: 700; color: var(--d-strong); margin: 22px 0 6px; }
|
|
p, li { color: var(--d-body); }
|
|
p { margin: 0 0 16px; }
|
|
ul, ol { padding-inline-start: 22px; margin: 0 0 16px; }
|
|
li { margin: 5px 0; }
|
|
a { color: var(--d-accent); text-underline-offset: 2px; }
|
|
strong { color: var(--d-strong); }
|
|
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .88em; background: var(--d-code-bg); border: 1px solid rgba(var(--d-ink),.07); border-radius: 4px; padding: 1px 5px; color: var(--d-strong); }
|
|
pre { background: var(--d-bg-deep); border: 1px solid rgba(var(--d-ink),.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: var(--d-pre); }
|
|
.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: var(--d-muted); font-weight: 600; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; padding: 0 14px 8px 0; border-bottom: 1px solid rgba(var(--d-ink),.12); }
|
|
td { padding: 9px 14px 9px 0; border-bottom: 1px solid rgba(var(--d-ink),.06); vertical-align: top; color: var(--d-body); }
|
|
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(var(--d-accent-rgb),.4); color: var(--d-muted); }
|
|
hr { border: 0; border-top: 1px solid rgba(var(--d-ink),.08); margin: 34px 0; }
|
|
img { max-width: 100%; height: auto; }
|
|
a:focus-visible { outline: 2px solid var(--d-accent); outline-offset: 2px; border-radius: 2px; }
|
|
.more { margin-top: 64px; padding-top: 22px; border-top: 1px solid rgba(var(--d-ink),.08); }
|
|
.more h2 { font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--d-faint); 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(var(--d-ink),.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>
|