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.
509 lines
31 KiB
HTML
509 lines
31 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>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>
|
|
|
|
/* 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">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.8.0</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'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'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'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: "ratchet_message", 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'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'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'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' 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'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'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'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'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's own pairwise session — so every leg'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' 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'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'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'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.8.0. 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>
|