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
470 lines
29 KiB
HTML
470 lines
29 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>
|
|
: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'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.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>
|