docs: describe the minimal invitation and where its security comes from
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

The README, ARCHITECTURE.md and CRYPTOGRAPHY.md still described the old
handshake: keys and a session salt travelling inside the invitation, and a safety
code derived from the two DTLS fingerprints. None of that has been true since
5.9.0.

Adds a "The invitation" section to the README covering what the exchange was
reduced to and why that is a security change and not only a smaller QR code:
less material exposed before anyone is authenticated, the DTLS fingerprint as the
anchor, substituted keys failing closed on the commitment instead of on a human
comparison, a safety code that now covers the whole transcript rather than two
fingerprints, and the plain fact that a single QR is scanned in person where a
four-frame animated one pushes people to paste the invitation through a chat app.

Session lifecycle in ARCHITECTURE.md gains the in-band key exchange as its own
step. CRYPTOGRAPHY.md now states that the salt is derived from the transcript
rather than transmitted, and describes the transcript SAS and the signature that
replaced the challenge/response. DESCRIPTOR-SBQ2.md is listed in the doc index
and in the CONTRIBUTING impact table.
This commit is contained in:
lockbitchat
2026-08-06 18:53:51 -04:00
parent 32b642f0e6
commit 808fd99b73
8 changed files with 157 additions and 62 deletions
+58 -8
View File
@@ -36,6 +36,7 @@ It is designed for people who need a small, auditable, zero-infrastructure way t
- ECDH P-384 key agreement with derived per-session keys, AES-256-GCM payloads, and DTLS-protected transport.
- **Double Ratchet forward secrecy.** Every message is encrypted with its own key, and the session re-keys itself each time the conversation changes direction. See [Forward secrecy](#forward-secrecy).
- Interactive **safety code** verification. You confirm a short code out of band before the session is trusted, which is what defeats a man in the middle.
- **A minimal invitation.** The code you hand over carries only what is needed to open the connection — roughly 110 to 150 bytes, one QR code. Keys and signatures no longer travel with it; they move over the connection itself, pinned by a commitment inside the invitation. See [The invitation](#the-invitation).
- Replay protection, message integrity (HMAC), and a live security report you can open at any time during a call.
**Privacy by design**
@@ -76,26 +77,30 @@ SecureBit never sees your conversation. A session is built directly between the
```
Peer A Peer B
| |
| 1. invitation |
| 1. invitation (one QR, ~110-150 B)
|.................................> | carried by QR, link or paste
| |
| 2. response |
| <.................................|
| |
| 3. both read the same safety code
| 3. keys exchanged over the open |
|<===== connection, checked =======>| against the invitation's commitment
| |
| 4. both read the same safety code
| and compare it out loud |
| |
| 4. both confirm, session verified
| 5. both confirm, session verified
| |
|===== end-to-end encrypted ========|
```
1. **Peer A** creates an invitation, shareable as a QR code, a link or plain text.
1. **Peer A** creates an invitation, shareable as a QR code, a link or plain text. It is small enough to be a single QR code — point a camera at it and it is read in one go.
2. **Peer B** opens it and returns a response the same way.
3. Both sides now show the same **safety code**. Compare it over something an attacker cannot impersonate: in person, or a voice you recognise.
4. The chat unlocks only after both people confirm the matching code. Three incorrect attempts end the session.
3. The two browsers finish the key exchange **over the connection they just opened**, not inside the invitation. Each side checks the other's keys against a fingerprint-sized commitment that was in the invitation before accepting them.
4. Both sides now show the same **safety code**. Compare it over something an attacker cannot impersonate: in person, or a voice you recognise.
5. The chat unlocks only after both people confirm the matching code. Three incorrect attempts end the session.
Step 3 is not a formality. Completing the key exchange proves that someone completed it, not who. Anyone able to intercept and rewrite the invitation can do that with both of you at once, and comparing the code is what catches it.
Step 4 is not a formality. Completing the key exchange proves that someone completed it, not who. Anyone able to intercept and rewrite the invitation can do that with both of you at once, and comparing the code is what catches it.
## Security model
@@ -105,13 +110,58 @@ Step 3 is not a formality. Completing the key exchange proves that someone compl
| Forward secrecy | Double Ratchet: per-message keys, re-keyed on each reply |
| Transport | WebRTC data channel over DTLS |
| Message encryption | AES-256-GCM, end-to-end |
| Authentication | Interactive safety code bound to both peers' DTLS fingerprints |
| Authentication | Interactive safety code bound to a transcript of the entire handshake |
| Invitation | ~110-150 bytes; carries a DTLS fingerprint and a commitment, never key material |
| Integrity | HMAC + replay protection |
| Sanitization | DOMPurify text-only rendering boundary |
| Local storage | Encrypted key metadata in IndexedDB |
A session is **not** treated as verified until both peers complete the safety code comparison. This is the step that protects you against a man-in-the-middle: the code must be compared through a channel an attacker cannot impersonate. Until it is completed, the session will not act on control messages from the peer.
### The invitation
The invitation is the only thing that travels outside the encrypted connection,
so the less it carries, the less there is to get wrong.
It used to carry everything: both public keys, their signatures, a session salt,
a challenge and the full SDP — around **2,300 characters**, which does not fit in
a QR code. The app split it into **four frames and animated them**, and you
scanned the same code four times over, or gave up and pasted the text through
whatever messenger was to hand.
It now carries only what is needed to open the connection — ICE candidates, a
DTLS certificate fingerprint, an expiry, and a 16-byte commitment to the key
material — in **110 to 150 bytes: one QR code, read in a single glance.**
This is a security change as much as a usability one:
- **Less is exposed before anyone is authenticated.** Key material no longer sits
in a blob that gets pasted into other apps, photographed, or left in a
clipboard. It moves over the connection instead, and is rejected unless it
matches the commitment that was in the invitation.
- **The DTLS fingerprint is the anchor.** It travels in the invitation you
handed over in person, and the connection completes only with the holder of the
matching private key — so the channel is authenticated to whoever showed you
the code before any key material moves at all.
- **Substituted keys fail closed, automatically.** The commitment is checked
before the key material is even parsed. Previously a substitution was caught
only when two humans compared digits; now the connection drops on its own, and
the safety code is the second line rather than the only one.
- **The safety code now covers everything.** It is computed over a transcript of
both invitations byte for byte plus both sets of keys, so nothing exchanged
anywhere in the handshake can be altered without changing the digits you read
to each other. It used to cover only the two fingerprints.
- **One QR means fewer bad habits.** A four-frame animated code pushes people
toward copy-pasting the invitation through a chat app. A single frame is
scanned in person, which is the channel the whole security model assumes.
The session salt is no longer sent at all — both sides derive it from that same
transcript, which binds every session key to both fingerprints and every
candidate.
Full wire format, decoder rules and the measurements behind these numbers:
[`doc/DESCRIPTOR-SBQ2.md`](doc/DESCRIPTOR-SBQ2.md).
### Forward secrecy
Message protection does not rest on the keys agreed during the handshake. On top of them SecureBit runs the **Double Ratchet**, the design used by Signal:
+33 -14
View File
@@ -15,27 +15,46 @@ comparison is what closes that gap.
## Session lifecycle
```text
1. Invitation Peer A generates an ECDH key pair, an SDP offer and a session
salt, and exports them as a single invitation.
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 invitation, derives the shared secret,
and returns its own keys and SDP as a response.
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. At this point both
sides hold session keys, but neither knows who the other is.
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. Verification Both sides display the same safety code. The users compare it
over a channel an attacker cannot impersonate and enter it.
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. Verified Only now does the session accept traffic that changes state,
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.
```
Step 3 is the one worth dwelling on. Completing the handshake proves that someone
performed a key exchange. It does not prove who. Anyone positioned on the
out-of-band channel can substitute their own keys and complete step 3 with both
people at once. Step 4 is the only step that distinguishes the intended peer, so
everything that could be useful to an impostor waits for it.
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 *who* 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.
The invitation format and its decoder rules are in
[DESCRIPTOR-SBQ2.md](DESCRIPTOR-SBQ2.md).
## What verification gates
+1
View File
@@ -57,6 +57,7 @@ When behaviour changes, update the documentation in the same commit:
| --- | --- |
| Anything user-visible | `README.md`, `CHANGELOG.md` |
| Verification, keys, the ratchet | `doc/CRYPTOGRAPHY.md`, `doc/ARCHITECTURE.md` |
| The invitation format or the in-band key exchange | `doc/DESCRIPTOR-SBQ2.md`, `doc/CRYPTOGRAPHY.md`, `doc/ARCHITECTURE.md` |
| Deployment, ICE, file policy | `doc/CONFIGURATION.md` |
| Calls, codecs, adaptation | `doc/CALLS.md` |
| Internal interfaces | `doc/API.md` |
+35 -11
View File
@@ -28,9 +28,12 @@ handle; those are raw bytes and are overwritten when finished with.
## Session establishment
A session begins with one ECDH exchange. The public keys travel inside signed
packages, and the receiving side validates the SPKI structure (algorithm OID,
curve, point format and length) before importing anything.
A session begins with one ECDH exchange. The public keys do **not** 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
[DESCRIPTOR-SBQ2.md](DESCRIPTOR-SBQ2.md) for the wire format and the reasoning.
From the shared secret, HKDF-SHA256 derives five independent values, each under
its own `info` label so that recovering one reveals nothing about the others:
@@ -47,15 +50,31 @@ The raw ECDH output is produced with `deriveBits`, 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.
The 64-byte session salt is generated by the inviting peer and travels in the
invitation, so both sides derive the same schedule.
The 64-byte session salt is **not transmitted**. 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.
## Verification
Both peers compute the same safety code with HKDF, from the ECDH-derived key
fingerprint together with both DTLS fingerprints. The fingerprints are
canonicalised and sorted so that each side reaches the same value regardless of
role.
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 **both invitations verbatim** — version byte, flags,
expiry, fingerprints, ICE credentials, every candidate, the commitments — and
**both key blobs**, 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.
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.
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.
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
@@ -63,8 +82,13 @@ after both peers confirm. Three incorrect entries end the session.
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, so a substitution changes the code the users read to each other.
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.
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.
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
+1
View File
@@ -7,6 +7,7 @@ if you are looking for an overview or a quick start.
| --- | --- |
| [ARCHITECTURE.md](ARCHITECTURE.md) | How a session is established, verified and torn down, and where each guarantee comes from |
| [CRYPTOGRAPHY.md](CRYPTOGRAPHY.md) | Key schedule, the Double Ratchet, SAS verification, memory handling |
| [DESCRIPTOR-SBQ2.md](DESCRIPTOR-SBQ2.md) | The invitation format: wire layout, why it is small, the in-band key exchange, decoder rules |
| [CONFIGURATION.md](CONFIGURATION.md) | Deployment, ICE and TURN setup, privacy modes, file transfer policy |
| [CALLS.md](CALLS.md) | Voice and video: codec choices, adaptation, and why each value was picked |
| [API.md](API.md) | Internal interfaces of the WebRTC manager and file transfer system |
+22 -22
View File
@@ -24,7 +24,7 @@
<!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786054741114">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786056807121">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
@@ -90,7 +90,7 @@
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786054741114">
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786056807121">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
@@ -99,7 +99,7 @@
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786054741114">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786056807121">
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35">
@@ -183,7 +183,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the
app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1786054741114"></script>
<script defer src="config/ice-servers.js?v=1786056807121"></script>
<script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
@@ -191,8 +191,8 @@
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1786054741114">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786054741114">
<link rel="stylesheet" href="assets/tailwind.css?v=1786056807121">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786056807121">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
@@ -200,31 +200,31 @@
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1786054741114">
<link rel="stylesheet" href="src/styles/animations.css?v=1786054741114">
<link rel="stylesheet" href="src/styles/components.css?v=1786054741114">
<link rel="stylesheet" href="src/styles/main.css?v=1786056807121">
<link rel="stylesheet" href="src/styles/animations.css?v=1786056807121">
<link rel="stylesheet" href="src/styles/components.css?v=1786056807121">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1786054741114"></script>
<script defer src="src/scripts/load-async-css.js?v=1786056807121"></script>
<noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css">
</noscript>
<script defer src="src/scripts/fa-check.js?v=1786054741114"></script>
<script defer src="src/scripts/fa-check.js?v=1786056807121"></script>
<!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1786054741114"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786054741114"></script>
<script type="module" src="dist/qr-local.js?v=1786054741114"></script>
<script type="module" src="src/components/QRScanner.js?v=1786054741114"></script>
<script defer src="src/utils/updateManager.js?v=1786056807121"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786056807121"></script>
<script type="module" src="dist/qr-local.js?v=1786056807121"></script>
<script type="module" src="src/components/QRScanner.js?v=1786056807121"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1786054741114"></script>
<script type="module" src="dist/app.js?v=1786054741114"></script>
<script type="module" src="dist/app-boot.js?v=1786056807121"></script>
<script type="module" src="dist/app.js?v=1786056807121"></script>
<script defer src="src/scripts/pwa-register.js?v=1786054741114"></script>
<script src="./src/pwa/install-prompt.js?v=1786054741114" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786054741114" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786054741114"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786054741114">
<script defer src="src/scripts/pwa-register.js?v=1786056807121"></script>
<script src="./src/pwa/install-prompt.js?v=1786056807121" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786056807121" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786056807121"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786056807121">
</body>
</html>
+6 -6
View File
@@ -1,10 +1,10 @@
{
"version": "1786054741114",
"buildVersion": "1786054741114",
"version": "1786056807121",
"buildVersion": "1786056807121",
"appVersion": "5.9.0",
"buildTime": "2026-08-06T22:19:01.153Z",
"buildId": "1786054741114-fb959d7",
"gitHash": "fb959d7",
"buildTime": "2026-08-06T22:53:27.174Z",
"buildId": "1786056807121-7754f6b",
"gitHash": "7754f6b",
"generated": true,
"generatedAt": "2026-08-06T22:19:01.154Z"
"generatedAt": "2026-08-06T22:53:27.175Z"
}
+1 -1
View File
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1786054741114';
const SW_BUILD_VERSION = '1786056807121';
// Load version from meta.json on install
async function getAppVersion() {