docs: reorganise documentation; derive header version from package.json; release v5.7.2
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

No protocol or message-protection changes.

The version in the application header was a literal and had fallen behind,
showing v5.6.0 while running 5.7.1. It now comes from package.json, and a test
fails if a hard-coded one reappears or if meta.json, the README badge, the
changelog and the docs disagree about the release.

Documentation reorganised so that everything technical lives in doc/ with an
index, and the root keeps only what belongs there by convention: README,
SECURITY, CHANGELOG and LICENSE.

- SECURITY.md rewritten. It listed a supported release line three major versions
  out of date and made claims the software does not make. It now states what is
  guaranteed, what is not, and how to report a problem.
- SECURITY_DISCLAIMER.md and RESPONSIBLE_USE.md merged into doc/USE-POLICY.md,
  which says what the software cannot protect against rather than listing
  generic advice.
- doc/SECURITY-ARCHITECTURE.md renamed to doc/ARCHITECTURE.md and rewritten
  around the session lifecycle, what verification gates, and how recovery works.
- doc/CRYPTOGRAPHY.md rewritten: key schedule, the Double Ratchet, framing, and
  memory handling, with values taken from the source rather than restated.
- doc/CONFIGURATION.md rewritten with the real file-type policy, ICE and TURN
  guidance, and the deployment caching rules that matter.
- docs/webrtc-config.md moved to doc/CALLS.md and rewritten; the obsolete
  docs/webrtc-audit.md, a working document full of stale line numbers, removed
  along with the docs/ directory.
- doc/CONTRIBUTING.md records what the recent regressions taught us about
  writing tests that can actually fail.
- doc/README.md added as an index.

Internal security review notes are excluded from the repository via .gitignore.
Those describe attack paths against specific releases in enough detail to
reproduce them, which is useful privately and harmful in public while users are
still updating.
This commit is contained in:
lockbitchat
2026-08-05 23:54:50 -04:00
parent 27279ae7c6
commit 3212138a0d
24 changed files with 1147 additions and 832 deletions
+2 -2
View File
@@ -20,7 +20,7 @@
connection. It is negotiated: both peers advertise `RATCHET_VERSION` in the
offer and answer, and a peer that does not falls back to per-session keys.
- `_ratchet.canEncrypt` is false on the joining peer until the inviting peer's
first message arrives the sending chain does not exist until then. Callers
first message arrives, because the sending chain does not exist until then. Callers
must check it rather than assume; the send path falls back to session keys for
those first frames.
- `_ratchet.getState()` returns counters and the number of retained keys for
@@ -52,7 +52,7 @@
- The `isVoice` a callback receives is the **receiver's** verdict, not the
sender's claim: `validateIncomingMetadata` clears it unless the transfer
declares a recognised audio MIME type and fits the per-note and per-session
size budgets. A transfer that fails those checks is not rejected it simply
size budgets. A transfer that fails those checks is not rejected; it simply
loses the consent-free shortcut and is offered as a normal file.
## EnhancedSecureFileTransfer
+175
View File
@@ -0,0 +1,175 @@
# Architecture
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.
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.
## 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.
2. Response Peer B validates the invitation, derives the shared secret,
and returns its own keys and SDP as a response.
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.
4. Verification Both sides display the same safety code. The users compare it
over a channel an attacker cannot impersonate and enter it.
5. 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.
## What verification gates
Verification is enforced, not merely displayed. Until both sides confirm:
- reconnection signalling is refused
- call setup is refused
- message deletion and delivery receipts are refused
- incoming file transfers are refused
The verification exchange itself and liveness probes run earlier, because they
have to. That set is an allowlist in the code
(`POST_VERIFICATION_CONTROL_TYPES`), and anything not on it is rejected by
default rather than passed through.
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.
## Message protection layers
```text
ECDH P-384 exchange
|
HKDF key schedule
|
.........................................................
| | | | |
message MAC metadata fingerprint ratchet root
key key key (safety code) |
Double Ratchet
per-message keys
```
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.
## Forward secrecy
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.
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.
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.
[CRYPTOGRAPHY.md](CRYPTOGRAPHY.md) has the key schedule and the frame format.
## Session recovery
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.
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.
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.
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.
## File transfer
1. The sender emits metadata.
2. The receiver validates name, size, type and abuse limits.
3. The receiver is shown an Accept or Reject prompt.
4. No receive buffers are allocated before acceptance.
5. Chunks are transmitted only after acceptance.
6. Completed buffers are retained within a bounded window.
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.
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.
## Disconnect
The disconnect path clears:
- WebRTC channels and peer connection handles
- timers, deferred retries, cover traffic and decoy traffic
- pending transfer state and consent waits
- verification state and session key material
- ratchet state: the root key, both chain keys and every retained message key are
overwritten rather than only dereferenced
- React file transfer callbacks and stale interface state
Values that cannot be overwritten in JavaScript are documented as such rather
than reported as cleared. See the memory handling section of
[CRYPTOGRAPHY.md](CRYPTOGRAPHY.md).
## Multiple conversations
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.
## Code layout
| Path | Responsibility |
| --- | --- |
| `src/network/EnhancedSecureWebRTCManager.js` | Connection lifecycle, verification, session state, message routing |
| `src/network/webrtc/` | Call stack: SDP handling, audio and video senders, network adaptation |
| `src/crypto/DoubleRatchet.js` | Forward secrecy: root and chain keys, DH ratchet, skipped keys |
| `src/crypto/EnhancedSecureCryptoUtils.js` | Key generation, key schedule, message encryption, sanitization |
| `src/crypto/cose-qr.js` | Invitation packing for QR transport |
| `src/transfer/EnhancedSecureFileTransfer.js` | Chunked encrypted transfers, consent, type policy |
| `src/state/sessionsStore.js` | Reducer for the set of open conversations |
| `src/app.jsx` | Interface and message rendering |
+170
View File
@@ -0,0 +1,170 @@
# Voice and video calls
Calls run over the same peer connection as the chat. Media is added to the
existing connection and renegotiated onto it, and the call SDP is exchanged over
the encrypted data channel rather than through any signalling service. Media
therefore inherits the session's verification: the DTLS-SRTP fingerprints
negotiated for the media were themselves carried over an authenticated channel.
All tunable values live in `src/network/webrtc/config.js`. This document explains
where they come from, because the numbers are otherwise indistinguishable from
arbitrary choices.
## Where the settings attach
A single `RTCRtpSender` cannot express codec ordering or fmtp parameters, so
configuring a sender is spread across three WebRTC surfaces, each at the point in
the lifecycle where it works:
| Concern | Surface | When | Implementation |
| --- | --- | --- | --- |
| Codec ordering (RED before Opus) | `transceiver.setCodecPreferences` | before creating the offer or answer | `applyAudioCodecPreferences` in `audio.js` |
| Opus FEC, DTX, bitrate | SDP `a=fmtp` rewriting | after create, before `setLocalDescription` | `applyOpusSettings` in `sdp.js` |
| Priority and maximum bitrate | `sender.setParameters` | after `setLocalDescription` | `configureAudioSender` in `audio.js` |
Both peers apply the same rewriting, so the negotiated session carries the
parameters regardless of who called.
## Audio
Speech has to stay intelligible on a bad link, and that goal drives every value
below.
### Opus parameters
| Parameter | Value | Reason |
| --- | --- | --- |
| `minptime` | 10 | Smaller packetisation interval, lower latency (RFC 7587, section 7) |
| `useinbandfec` | 1 | In-band forward error correction reconstructs a lost packet from the next one. This is the main lever for staying intelligible at 15 to 20 percent loss (RFC 6716, section 2.1.7) |
| `usedtx` | 1 | Discontinuous transmission stops sending during silence, leaving the transport free for video and FEC (RFC 7587, section 3.1.3) |
| `stereo` | 0 | Mono halves the bitrate with no loss for speech |
| `maxaveragebitrate` | 32000 | Comfortable wideband speech |
| `cbr` | 0 | Variable bitrate spends bits only when needed |
### Redundancy
RED (RFC 2198) carries the previous frame's payload alongside each packet, so
isolated losses recover without retransmission. It is enabled only when the
browser advertises `audio/red` in `RTCRtpSender.getCapabilities('audio')`, which
Chromium does and others vary on; when absent it is skipped silently. RED is
ordered before Opus in codec preferences.
### Sender parameters
| Parameter | Value | Reason |
| --- | --- | --- |
| `maxBitrate` | 40000 bps | Headroom above the 32 kbps Opus target for RED redundancy |
| `priority` | `high` | Audio wins bandwidth arbitration against video within the connection |
| `networkPriority` | `high` | DSCP hint so audio is prioritised on the wire |
Audio is never throttled by the adaptation controller. Under sustained loss the
video degrades and speech continues.
## Video
Codec preference order is VP9, AV1, H.264, VP8, applied through
`setCodecPreferences`. Retransmission and FEC codecs are kept after the media
codecs so they still function. VP9 and AV1 provide scalable coding; H.264 and VP8
do not.
### Scalable coding rather than simulcast
This is a one-to-one connection with a single receiver, so one encoding with SVC
is the right tool: a single stream that degrades by spatial or temporal layer. It
is applied through `sender.setParameters` and needs no `addTransceiver` or rid
configuration, which keeps it away from the media path that actually works.
| Codec | scalabilityMode | maxBitrate | degradationPreference |
| --- | --- | --- | --- |
| VP9 | `L3T3_KEY` (3 spatial, 3 temporal, key-aligned) | 1.5 Mbps | `balanced` |
| AV1 | `L1T3` | 1.2 Mbps | `maintain-framerate` |
| H.264, VP8 | none | 1.5 Mbps | `balanced` |
`networkPriority` is `medium`, below audio. If a browser rejects the scalability
mode, which Firefox and Safari do in places, `configureVideoSender` retries with
a plain encoding.
### Why media is attached with addTrack
An explicit `addTransceiver({ sendEncodings })` path was built and then removed,
because it broke media on real devices in two ways. On the answering side,
reusing the transceiver created by `setRemoteDescription` rejected the SVC
parameters outright. On repeat and role-reversed calls, the reused transceiver
directions desynchronised: the call connected and `ontrack` fired, but no media
flowed.
What ships instead attaches media with `addTrack`, reused across calls through
`replaceTrack`, and lets the browser manage transceiver direction. This is what
keeps audio and video flowing across reversed and repeated calls.
Multi-rid simulcast primitives (`buildVideoSendEncodings`) are kept and tested for
a future group-call path, but are not wired into the one-to-one flow. They need
`addTransceiver({ sendEncodings })`, which requires the problems above to be
solved first, ideally with a two-connection browser test rig that is not set up.
The adaptation controller is already simulcast-aware for when that lands.
## Transport feedback
The call m-lines need RTCP feedback and header extensions present. Most browsers
emit them already, so this is an idempotent safety net:
| Media | RTCP feedback | Header extension |
| --- | --- | --- |
| Video | `transport-cc`, `nack`, `nack pli`, `ccm fir`, `goog-remb` | transport-wide congestion control |
| Audio | `transport-cc`, `nack` | transport-wide congestion control |
These are added only when missing, never duplicated, and applied only to primary
codecs. Transport-wide congestion control is what feeds the bandwidth estimator
that adaptation reads.
Rewritten local SDP is applied with progressive fallback: full rewrite, then Opus
only, then raw. A browser that rejects an injected line cannot break the call.
## Adaptation
`NetworkAdaptationController` samples `pc.getStats()` every second and reacts:
| Condition | Action |
| --- | --- |
| Loss above 10 percent, or round trip above 300 ms | Reduce video `maxBitrate` by 20 percent, floor 100 kbps |
| Loss below 3 percent and round trip below 150 ms, sustained 5 samples | Raise video `maxBitrate` by 10 percent, up to the ceiling |
| `qualityLimitationReason` is `cpu` | Scale resolution down by 1.5, bitrate unchanged |
Every change goes through `sender.setParameters`. There is no renegotiation and
no track restart, so adaptation is invisible to the call. Audio is never touched.
The decision function and the stats parsing are pure and unit-tested against
recorded `getStats` output.
### Quality indicator
The same sample produces a coarse label shown in the call interface:
| Label | Condition |
| --- | --- |
| Excellent | Loss below 3 percent and round trip below 150 ms |
| Good | Loss below 7 percent and round trip below 250 ms |
| Fair | Loss below 15 percent and round trip below 400 ms |
| Weak | Anything else |
It appears in the voice overlay, the video top bar, and as compact bars in the
minimized widget, and stays hidden until the first sample has data.
## Verifying a change
Unit tests cover SDP rewriting, video codec selection and the adaptation
decision:
```bash
npm test
```
For anything touching media in practice, open `chrome://webrtc-internals` during
a call and check that:
- outbound audio shows Opus with the fmtp parameters above, appearing as `red`
and `opus` on Chromium
- throttling the link steps outbound video `targetBitrate` down within a second
or two and recovers when the link clears
- audio bitrate holds steady while video adapts
- the in-call indicator moves through Fair and Weak as the link degrades
+122 -34
View File
@@ -1,12 +1,13 @@
# Configuration Guide
# Configuration
## Requirements
- modern browser with WebRTC and Web Crypto support
- Node.js 18+ for local development
- TURN service only when relay-only privacy mode is required
- A browser with WebRTC and Web Crypto support
- Node.js 18 or later, for building
- A TURN service, if you need relay-only privacy mode or you expect users behind
strict NAT
## Local setup
## Building and running
```bash
npm install
@@ -14,50 +15,137 @@ npm run build
npm run serve
```
## ICE server configuration
`npm run build` compiles the CSS, bundles the JavaScript into `dist/`, and
regenerates `meta.json` with a build stamp. That stamp is what the update
mechanism compares, and it is also written into `sw.js` so the browser reinstalls
the service worker on each release. A deployment that skips `post-build` will not
notify anyone that an update exists.
SecureBit.chat keeps existing STUN support for ordinary WebRTC connectivity. Deployments that require relay-only privacy must provide their own TURN service credentials through deployment configuration; public TURN credentials are intentionally not bundled.
The application is static. Any web server can host it, and there is no backend to
run.
## ICE and TURN
WebRTC needs to discover network paths between the two browsers. STUN is enough
to learn a public address; TURN is needed when a direct path cannot be
established, and is the only way to keep peers from seeing each other's IP
addresses.
Configuration comes from three places, in order of precedence:
1. User settings under Advanced network settings, stored in IndexedDB per device
2. `config/ice-servers.js`, an operator override loaded before the application
3. Built-in public STUN defaults
`config/ice-servers.js` is not committed, because it is where operator TURN
credentials would go. Use `config/ice-servers.example.js` as the template. The
Docker image copies `config/ice-servers.prod.js` into place at build time.
```js
window.SECUREBIT_ICE_SERVERS = [
{ urls: 'stun:stun.example.org:3478' },
{
urls: [
'turn:turn.example.org:3478?transport=udp',
'turn:turn.example.org:3478?transport=tcp',
'turns:turn.example.org:443?transport=tcp'
],
username: '...',
credential: '...'
}
];
```
Offering several transports is worth the extra lines. UDP is the most widely
usable, TCP covers networks that block UDP, and TURNS on 443 gets through
firewalls that only allow HTTPS. Some browsers also fail to resolve STUN and TURN
hostnames inside their WebRTC layer even when ordinary page DNS works, so listing
a raw IP alongside the hostname is a reasonable fallback.
Any TURN credential shipped to a browser is public by definition, because the
browser has to be able to read it. Treat it as a shared resource and apply quotas
on the TURN server rather than relying on the credential staying secret.
### User-supplied servers
Users can paste their own STUN and TURN servers. Input is validated against an
allowlist before it reaches `RTCPeerConnection`: only the `stun`, `stuns`, `turn`
and `turns` schemes, a hostname or bracketed IPv6 address with an optional port,
and an optional `transport=udp` or `transport=tcp` query. At most 10 servers with
8 URLs each. Anything else is rejected with a specific reason.
### Privacy modes
| Mode | Behavior | IP privacy |
| Mode | Behaviour | IP exposure |
| --- | --- | --- |
| default | standard WebRTC candidate gathering | direct candidates may expose IP addresses |
| relay-only | `iceTransportPolicy: "relay"` | requires TURN and avoids direct peer candidates when configured correctly |
| Default | Standard candidate gathering | Direct candidates can reveal addresses to the peer |
| Relay-only | Sets `iceTransportPolicy: "relay"` | Requires TURN; peers see only the relay |
### Operational rules
STUN is not a substitute for TURN here. It reveals your public address to the
peer by design. Relay-only mode without a configured TURN server cannot connect
at all, and the interface warns when TURN is missing.
- STUN is not a privacy substitute for TURN.
- Relay-only mode without TURN cannot establish a working relay connection.
- The UI warns users when TURN is missing.
- Validate TURN deployment with browser WebRTC diagnostics before production rollout.
Validate a TURN deployment with `chrome://webrtc-internals` before relying on it.
A relay candidate should appear in the gathered set; if none does, the credentials
or the ports are wrong.
## Verification flow
### When connections fail
Protocol `4.1` requires interactive SAS verification:
Candidate gathering finishes only when every configured server has replied or
timed out. Behind a VPN or a restrictive firewall that may never happen, and the
console will show `701` errors for each unreachable server.
1. both peers derive the same SAS from shared session material
2. users compare the code out of band
3. each user enters the matching code manually
4. the chat unlocks only after both confirmations succeed
The application handles this: it proceeds as soon as there are usable candidates
and only keeps waiting while there are none, up to 25 seconds. Host candidates
alone are often enough on a local network. If nothing at all is gathered, the
message names the likely causes, which in practice are a VPN binding the browser
to an interface that cannot reach the servers, or a firewall dropping UDP.
Three failed local attempts disconnect the session.
## File transfer policy
## File-transfer policy
Incoming transfers are validated before the consent prompt and require explicit
approval.
Incoming file requests are validated before the consent prompt and require explicit user approval.
| Category | Extensions | Size limit |
| --- | --- | --- |
| Images | `.jpg` `.jpeg` `.png` `.gif` `.webp` `.bmp` `.ico` | 25 MB |
| Documents | `.pdf` | 50 MB |
| Text | `.txt` | 10 MB |
| Archives | `.zip` | 100 MB |
| Voice | `.webm` `.ogg` `.oga` `.opus` `.m4a` `.mp4` `.mp3` `.wav` | 20 MB |
Allowed categories:
Overall ceiling is 100 MB per file.
- common raster images
- PDF
- plain text
- ZIP archives
Blocked outright: `.exe` `.bat` `.cmd` `.sh` `.js` `.msi` `.dmg` `.app` `.jar`
`.scr` `.ps1` `.vbs` `.html` `.svg`
Blocked examples:
The extension list is the security boundary. MIME type is treated as advisory,
because it is client-supplied, varies between browsers and operating systems, and
is frequently absent. An allowed extension is accepted when the MIME type is
absent, generic, or one of the recognised types, but a clearly contradictory MIME
type is rejected as a spoofing signal.
- `.exe`, `.bat`, `.cmd`, `.sh`, `.js`
- `.msi`, `.dmg`, `.app`, `.jar`, `.scr`
- `.ps1`, `.vbs`, `.html`, `.svg`
Voice notes are the one transfer accepted without a prompt, so they are checked
more strictly. The receiver decides, not the sender: a genuine audio MIME type,
at most 4 MB, and a 64 MB budget for the whole session. A transfer that fails
those checks is not rejected, it simply loses the shortcut and appears as a normal
file with the usual prompt.
Both MIME type and extension must be acceptable. Missing or unknown MIME types are treated as unsafe unless explicitly covered by policy.
## Deployment notes
The repository includes an nginx configuration (`deploy/nginx.conf`) and an
Apache one (`.htaccess`). Both set the same policy, and the important parts are:
- `index.html`, `sw.js`, `manifest.json`, `meta.json` and `config/ice-servers.js`
must not be cached. A stale `meta.json` breaks update notification, and a stale
`sw.js` freezes the service worker.
- `dist/` bundles are query-versioned, so `no-cache` with revalidation is enough
and avoids re-downloading unchanged bundles.
- `CDN-Cache-Control` is set separately, because a CDN reads it independently of
the browser directive and will otherwise happily serve a stale app shell.
- `frame-ancestors` and HSTS have to be sent as headers. The rest of the content
security policy is a meta tag in `index.html`.
- `.jsx` and `.mjs` must be served as JavaScript, or module loading fails.
Asset requests should return 404 when a file is missing rather than falling back
to the HTML shell. A missing script served as HTML fails in a confusing way.
+71 -24
View File
@@ -1,42 +1,89 @@
# Contributing
## Development workflow
## Workflow
```bash
npm install
npm test
npm test # 41 suites, plain node:assert, no framework
npm audit
npm run build
```
## Security-sensitive areas
Tests are individual `.mjs` files run in sequence by `npm test`. There is no test
runner and no mocking library. A new suite is a new file, added to the `test`
script in `package.json`.
Changes involving any of the following require extra review and focused tests:
## Areas that need extra care
- SAS verification and protocol compatibility
- WebRTC ICE/TURN behavior
- encrypted payload validation or display sanitization
- file-transfer consent and type policy
- IndexedDB migration logic
- disconnect and resource lifecycle cleanup
Changes touching any of these should come with tests that would fail without the
change:
## Documentation expectations
- Verification: the safety code, the gate on control frames, protocol
compatibility
- The Double Ratchet: key derivation, chain advance, skipped-key bounds, the
order in which state is committed
- The inbound message path: anything that decides what reaches the interface
- ICE and TURN behaviour, and the connection recovery cycle
- File transfer consent and type policy
- IndexedDB migration
- Disconnect and resource cleanup
When behavior changes, update the corresponding release-facing documentation in the same change:
## Writing tests that are worth having
- `README.md`
- `SECURITY.md`
- `doc/CONFIGURATION.md`
- `doc/CRYPTOGRAPHY.md`
- `doc/SECURITY-ARCHITECTURE.md`
- `CHANGELOG.md`
Two bugs reached production during recent work, and both had the same cause: the
test built its own input instead of using what the application actually produces.
One test generated its own ECDH key pairs with usages the real generator did not
grant, and passed against a build that could not establish a session at all.
Another passed a locally generated public key where the application always
supplies an imported, non-extractable one, and missed a failure that disabled
forward secrecy for one side of every conversation. Locally generated public keys
are always extractable in WebCrypto regardless of the flag you pass, so that
difference is invisible unless you look for it.
The lesson is worth stating plainly: use the real factory functions, and where a
value crosses a boundary in the application, make the test cross the same
boundary. A test that constructs its inputs verifies the algorithm. Only a test
that uses the shipped path verifies the code.
Before relying on a new test, confirm it fails when the fix is removed. A test
that cannot fail is worse than no test, because it is read as coverage.
## Documentation
When behaviour changes, update the documentation in the same commit:
| Change | Documents |
| --- | --- |
| Anything user-visible | `README.md`, `CHANGELOG.md` |
| Verification, keys, the ratchet | `doc/CRYPTOGRAPHY.md`, `doc/ARCHITECTURE.md` |
| Deployment, ICE, file policy | `doc/CONFIGURATION.md` |
| Calls, codecs, adaptation | `doc/CALLS.md` |
| Internal interfaces | `doc/API.md` |
| Anything security relevant | `SECURITY.md` |
Values in the documentation (limits, timeouts, algorithm parameters) are taken
from the source. If you change one in code, change it in the documentation too,
otherwise the next person will trust the wrong number.
## Release notes and security fixes
Release notes describe what improved. They do not spell out how a weakness could
have been exploited, and neither do source comments. Users who have not updated
are the ones exposed by that detail, and with no server there is no way to update
everyone at once.
Comments explaining why a guard exists are valuable and should stay, because they
are what stops the guard being removed later. The distinction is between "this
check exists because completing the handshake does not prove identity" and a
reproduction recipe.
## Pull requests
Please include:
Include:
- concise problem statement
- implementation summary
- tests run
- regression risks
- screenshots or logs for user-visible changes when relevant
- what the problem is
- what the change does
- which tests you ran, and which new ones you added
- what could regress
- for user-visible changes, a screenshot or a log
+181 -42
View File
@@ -1,79 +1,218 @@
# Cryptography and Verification
# Cryptography
## Release context
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.
- Product release: `v5.7.1`
- Protocol version: `4.1`
- Ratchet wire version: `1`
| | |
| --- | --- |
| Release | v5.7.2 |
| Protocol version | 4.1 |
| Ratchet wire version | 1 |
## Primitives
| Purpose | Algorithm |
| --- | --- |
| Key agreement | ECDH P-384, falling back to P-256 if the browser refuses P-384 |
| Signatures | ECDSA P-384 with SHA-384, falling back to P-256 with SHA-256 |
| Key derivation | HKDF-SHA256 |
| Message encryption | AES-256-GCM |
| Message authentication | HMAC-SHA256, and AES-GCM's own tag on the ratcheted path |
| Password derivation | PBKDF2-SHA256, 310,000 iterations, 32-byte salt |
Session keys are non-extractable `CryptoKey` 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.
## Session establishment
SecureBit.chat uses ECDH-derived session material, DTLS-protected WebRTC transport, and a mandatory Short Authentication String (SAS) verification step.
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.
The SAS is deterministic for both peers in the same authenticated session: it is derived with HKDF from the ECDH-derived key fingerprint together with both peers' DTLS fingerprints, canonicalised so each side computes the same value. Users compare the displayed code through an out-of-band channel and enter the matching code manually. Local success alone is insufficient: the session becomes verified only after both peers confirm.
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:
Verification is the gate for the session, not a label on it. Until both peers have confirmed, the connection does not act on control messages from the other side — reconnection signalling, call setup, message deletion and delivery receipts all wait. The verification exchange itself is the deliberate exception, since it necessarily runs first.
## Key schedule
A single ECDH exchange produces the session's root material. From it, HKDF-SHA256 derives four independent keys plus the ratchet root, each under its own `info` label so that compromise of one reveals nothing about the others:
| Derived key | Purpose |
| Label | Use |
| --- | --- |
| `message-encryption-v4` | AES-256-GCM payload key (static path) |
| `message-encryption-v4` | AES-256-GCM payload key on the static path |
| `message-authentication-v4` | HMAC-SHA256 message authentication |
| `metadata-protection-v4` | AES-256-GCM for message metadata |
| `fingerprint-generation-v4` | Key fingerprint shown to the user and fed to the SAS |
| `fingerprint-generation-v4` | Key fingerprint shown to the user and fed into the safety code |
| `double-ratchet-root-v1` | Root key for the Double Ratchet |
The raw ECDH output is derived with `deriveBits`, used as HKDF input material, and the buffer holding it is overwritten as soon as the derivation completes. Session keys themselves are non-extractable `CryptoKey` handles.
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.
## Forward secrecy — the Double Ratchet
The 64-byte session salt is generated by the inviting peer and travels in the
invitation, so both sides derive the same schedule.
Message protection does not rest on the keys agreed during the handshake. On top of them the client runs the Double Ratchet (Signal's design), implemented in `src/crypto/DoubleRatchet.js`.
## Verification
**Symmetric ratchet.** Each message key is derived from a chain key with `KDF_CK` (HMAC-SHA256 over the chain key with distinct constants for the message key and the next chain key), then discarded after a single use. The construction is one-way, so possession of the current chain key does not yield any earlier message key.
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.
**DH ratchet.** Each time the conversation changes direction, the replying peer introduces a fresh ECDH key pair and both sides mix a new shared secret into the root key with `KDF_RK` (HKDF-SHA256, root key as salt). A session therefore re-keys continuously as messages go back and forth.
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.
**Initialisation.** No additional handshake data is exchanged. Both peers already hold each other's authenticated ECDH public key — the same keys the SAS covers — so the inviting peer begins with a fresh ratchet key against the peer's handshake key, and the joining peer begins with its own handshake key pair. The first DH step converges on the same secret from both directions.
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.
The joining peer has no sending chain until the inviting peer's first message arrives; this is inherent to the ratchet, since both sides derive it from the same exchange. Frames sent before that point use the session keys.
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.
**Message framing.** Each ratcheted frame carries a header — the sender's current ratchet public key, the length of the previous sending chain and the message number in the current one. The header is transmitted in the clear, because the receiver needs it before it can derive a key, and is passed to AES-GCM as additional authenticated data. Any modification to it causes decryption to fail rather than redirecting the ratchet.
## Forward secrecy
**Out-of-order messages.** Keys for messages that have not yet arrived are retained so they can still be read, within fixed bounds:
The session keys above would last the whole conversation on their own. The Double
Ratchet, implemented in `src/crypto/DoubleRatchet.js`, replaces them for message
traffic so that protection does not rest on a single set of keys.
### Symmetric ratchet
Each message key comes from the current chain key through `KDF_CK`, 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.
### DH ratchet
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
`KDF_RK` (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.
### Initialisation
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.
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.
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.
### Frame format
A ratcheted message is `{ type: "ratchet_message", h, c }`, where `h` is a header
string and `c` is the base64 body.
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.
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.
### Out-of-order messages
Keys for messages that have not yet arrived are retained so they can still be
read, within fixed bounds:
| Bound | Value |
| --- | --- |
| Maximum skip within one chain | 512 |
| Total retained keys | 1024 (oldest evicted first) |
| Total retained keys | 1024, oldest evicted first |
| Retention period | 5 minutes |
These are a resource control, not a tuning parameter: the message number is supplied by the peer, so the jump a single frame may claim has to be limited.
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.
**State changes are applied only after authentication.** Receiving stages the chain advance and any DH step, attempts decryption, and commits only on success. A frame that fails authentication leaves the ratchet untouched, so a malformed or forged frame cannot desynchronise an established session.
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.
**Negotiation.** Support is advertised in the invitation and in the response, and the ratchet is used only when both sides advertise it. A peer on an earlier release negotiates it away and the session runs on the per-session keys described above. The security panel reports which of the two is in force for the current connection.
### State is committed only after authentication
## Message protection
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.
- encrypted payloads are validated before decryption
- chat content reaches the interface through one authenticated path only; unauthenticated frames are rejected rather than rendered
- decrypted chat text is sanitized before entering React state or the UI
- replay and ordering controls remain part of the session layer; on the ratcheted path replay protection is intrinsic, since a message key is destroyed on use
- voice messages are transported over the file-transfer channel: each is
encrypted with a per-file AES-GCM session key and integrity-checked with a
signed SHA-256 hash before playback
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.
## Local key metadata
### Negotiation
Sensitive IndexedDB metadata is stored in encrypted envelopes. Legacy plaintext metadata remains readable through a migration path and is re-written in encrypted form when accessed. Corrupted encrypted metadata fails closed.
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.
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.
## Message protection on the static path
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.
Payloads are padded to a 16-byte boundary with random bytes, with the true length
carried in the encrypted metadata.
## Rendering
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.
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.
## Local storage
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.
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.
## Memory handling
Values that can be overwritten are overwritten: the ECDH output, HKDF intermediates, ratchet root and chain keys, and retained message keys are all zeroed when no longer needed. Values that cannot be overwritten in JavaScript — immutable strings, and non-extractable `CryptoKey` handles whose material lives outside the JS heap — are documented as such rather than reported as cleared; for those, non-extractability is the protection.
Values that can be overwritten are overwritten: the ECDH output, HKDF
intermediates, the ratchet root and chain keys, and retained message keys.
## Scope note
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 `CryptoKey` 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.
This document describes the current browser implementation behavior relevant to the v5.7.1 release. It does not replace independent cryptographic review.
## Scope
This describes the browser implementation as it stands in v5.7.2. It is not a
substitute for independent cryptographic review.
+25
View File
@@ -0,0 +1,25 @@
# Documentation
Technical documentation for SecureBit.chat. Start with the project [README](../README.md)
if you are looking for an overview or a quick start.
| Document | What it covers |
| --- | --- |
| [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 |
| [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 |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Development workflow and what needs extra review |
| [USE-POLICY.md](USE-POLICY.md) | Terms of use, intended use, and the limits of what the software can protect |
Security policy and vulnerability reporting live in [SECURITY.md](../SECURITY.md) at
the repository root, where GitHub expects to find them.
## Keeping this accurate
These documents describe the current release and are expected to change with it.
Every value quoted here (limits, timeouts, algorithm parameters) is taken from the
source rather than restated from memory, so if you change one in code, change it
here in the same commit. [CONTRIBUTING.md](CONTRIBUTING.md) lists which documents
are affected by which areas of the code.
-99
View File
@@ -1,99 +0,0 @@
# Security Architecture
## Current baseline
| Area | Current behavior |
| --- | --- |
| Protocol | `4.1` with mismatch rejection |
| Peer verification | mandatory manual SAS entry; control messages gated on it |
| Forward secrecy | Double Ratchet (wire version `1`), negotiated per session |
| Transport | WebRTC over DTLS |
| Privacy mode | optional TURN relay-only mode |
| Message UI safety | one authenticated inbound path; decrypted text sanitized before display |
| File transfer | validated metadata, explicit consent, allowlist policy |
| Voice messages | same chunked AES-GCM transfer as files; auto-accepted within audio-type and size limits |
| Local metadata | encrypted IndexedDB envelopes with migration |
| Lifecycle | unified disconnect cleanup and bounded resource retention |
## Verification state machine
```text
connection established
shared keys derived
deterministic SAS displayed
manual out-of-band comparison
local input validated
peer confirmation received
verified session
```
The verified state is reached only when both local and remote confirmation flags are true, and it is set in a single place so the transition cannot be reached by another route.
Verification is enforced, not merely displayed. Before it completes, the session declines to act on control messages from the peer — reconnection signalling, call setup, message deletion and delivery receipts. Only the verification exchange itself and liveness probes run earlier, because they have to.
## Message protection layers
```text
ECDH (P-384)
HKDF key schedule
┌─────────┬───────┬──────────┬─────────────┐
↓ ↓ ↓ ↓ ↓
message MAC metadata fingerprint ratchet root
key key key (→ SAS) ↓
Double Ratchet
per-message keys
```
Chat content is encrypted under a ratchet-derived key when both peers support it, and under the session message key otherwise. Either way it reaches the interface through one authenticated path; frames that fail authentication are dropped rather than displayed.
## Forward secrecy
Per-message keys are derived from a chain key by a one-way function and destroyed after use, and each change of direction introduces a fresh ECDH key pair that re-keys the session root. Out-of-order delivery is supported within fixed bounds (512 skipped keys per chain, 1024 retained in total, five-minute expiry), which limits how much state a peer can cause to be held. Incoming frames are authenticated before any ratchet state is committed, so a bad frame cannot desynchronise a live session.
See [`CRYPTOGRAPHY.md`](CRYPTOGRAPHY.md) for the key schedule and framing details.
## File-transfer architecture
1. sender emits metadata
2. receiver validates name, size, type, and abuse limits
3. receiver sees Accept / Reject prompt
4. no receive buffers are allocated before acceptance
5. sender transmits chunks only after acceptance
6. completed received buffers are retained within a bounded window
## Voice messages
Voice notes reuse the file-transfer pipeline, so they inherit its per-file
AES-GCM session key, chunking, and SHA-256 integrity check. Differences from a
regular file:
1. audio is recorded in-browser and encoded as PCM/WAV before sending
2. duration and a downsampled waveform travel as **unsigned** presentation
metadata; the audio bytes remain integrity-protected by the signed file hash
3. the receiver **auto-accepts** voice transfers (no consent prompt) and plays
them inline from an in-memory blob — nothing is written to disk
The auto-accept decision belongs to the receiver, not the sender. A transfer
qualifies only if it declares a recognised audio MIME type and stays within a
4 MB per-note limit and a per-session budget; anything else is handled as an
ordinary file and goes through the normal consent prompt. This keeps the
convenience of voice notes from becoming an unattended transfer channel.
## Disconnect cleanup
The canonical disconnect path clears:
- WebRTC channels and peer connection handles
- timers, deferred retries, fake traffic, and decoy traffic
- pending transfer state and consent waits
- verification state and crypto/PFS state
- ratchet state: root key, both chain keys and every retained message key are
overwritten, not merely dereferenced
- React file-transfer callbacks and stale UI transfer state
+86
View File
@@ -0,0 +1,86 @@
# Use policy and limitations
SecureBit.chat is open-source software for private communication, research and
education. It is provided as is, without warranty of any kind. This document sets
out what the software can and cannot do for you, and what is expected of you when
you use it.
## What it protects
Message content between two verified peers, against anyone observing or
manipulating the network between them. That is a real guarantee and it is what
the design is built around.
## What it does not protect
Be clear about these before relying on the software for anything that matters.
**A compromised device.** Malware, a hostile browser extension, a keylogger or
someone with access to an unlocked machine sees your messages as you do. No
transport encryption helps. This is the most common way private communication is
actually broken.
**The person you are talking to.** They can screenshot, photograph the screen,
copy the text, or simply repeat what you said. View-once and disappearing
messages are cooperative features that a normal client honours; they are not a
technical restriction on a determined recipient.
**Verification you skipped.** If you do not compare the safety code, or you
compare it over a channel the attacker controls, the software cannot tell that
someone is in the middle. The comparison must happen over something an attacker
cannot impersonate: in person, or a voice you recognise.
**Metadata, depending on your setup.** A direct connection reveals your IP
address to the peer. Relay-only mode with your own TURN server prevents that, but
the relay operator can then see both addresses and the timing of traffic, though
never the content. Choose according to who you are protecting against.
**The fact that you are using it.** Someone watching your network can see a
WebRTC connection and can see you loading this application. The software does not
hide its own use.
## Your responsibilities
- Comply with the law where you are and with any policies that apply to you
- Keep your device and browser current and under your control
- Compare the safety code out of band, every time, on every new session
- Configure TURN correctly if you need relay-only mode, and verify it works
- Understand that endpoint compromise defeats everything above the endpoint
## Intended use
The software is meant for legitimate private communication: journalism and source
protection, human rights work, business confidentiality, medical and legal
matters, research and education, and ordinary personal conversation that is
nobody else's business.
It is not meant to facilitate unlawful activity, abuse, harassment, exploitation
or harm, and being able to communicate privately does not make any of those
acceptable.
If you become aware of the software being used to harm someone, report it to the
appropriate authorities. Vulnerabilities in the software itself go to the
maintainers first: see [SECURITY.md](../SECURITY.md).
## Operational notes
If your threat model is serious, the software is only one part of it.
Use a device you control and keep it patched. Consider a separate device for
sensitive conversations. Be aware of who can see your screen and who can hear
you. Understand that a camera and microphone are attached to the machine you are
typing on. Consider what your network operator can observe, and whether a VPN or
Tor changes that in your favour or simply moves the observation point.
Know the law where you are. Encryption is regulated differently in different
places, and in some jurisdictions there are disclosure requirements attached to
it.
## Contributing
Contributions are welcome under the same expectations. Report vulnerabilities
through the process in [SECURITY.md](../SECURITY.md) rather than publishing them,
and give a fix reasonable time to reach users before disclosure. Users who have
not updated yet are the ones exposed by early publication.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.