docs: reorganise documentation; derive header version from package.json; release v5.7.2
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:
+181
-42
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user