1467 lines
62 KiB
Markdown
1467 lines
62 KiB
Markdown
# Security Concepts
|
|
|
|
This document covers the cryptographic and authentication foundations of
|
|
the encrypted P2P chat application. Every concept here maps directly to
|
|
code in the repository. If you can read and understand this document, you
|
|
will know exactly what happens to a message from the moment a user types
|
|
it to the moment it appears on the recipient's screen, and why every
|
|
step exists.
|
|
|
|
|
|
## End-to-End Encryption
|
|
|
|
### What It Is
|
|
|
|
End-to-end encryption (E2EE) is a communication model where only the
|
|
two endpoints of a conversation can read the messages exchanged between
|
|
them. The defining property is that the server acts as a blind relay: it
|
|
stores and forwards encrypted blobs, but it has no key material that
|
|
would allow it to decrypt those blobs. The encryption happens on the
|
|
sender's device, the decryption happens on the recipient's device, and
|
|
at no point between those two devices does the plaintext exist.
|
|
|
|
This is different from transport encryption (TLS/HTTPS), which encrypts
|
|
the link between your device and the server, and between the server and
|
|
the other device, but allows the server itself to read everything. With
|
|
transport encryption, the server is a trusted intermediary. With E2EE,
|
|
the server is an untrusted courier.
|
|
|
|
The distinction matters because trust is a vulnerability. If the server
|
|
can read your messages, then anyone who compromises the server can also
|
|
read your messages. That includes attackers who breach the server, rogue
|
|
employees, and government agencies with legal authority to compel the
|
|
server operator to produce data.
|
|
|
|
### Why It Matters
|
|
|
|
The history of messaging security is a history of servers being
|
|
compromised, coerced, or caught lying about their access to user data.
|
|
|
|
**2013: Edward Snowden and the NSA PRISM program.** Snowden leaked
|
|
classified NSA documents showing that major technology companies
|
|
including Google, Microsoft, Yahoo, Facebook, Apple, and others had
|
|
provided the NSA with direct server-side access to user communications
|
|
under the PRISM surveillance program. Because these services used
|
|
transport encryption rather than E2E encryption, the companies held
|
|
decryption keys on their servers and could comply with government
|
|
requests (or be compelled to comply through FISA court orders). The
|
|
server had access, so the government got access. This was the single
|
|
largest catalyst for the adoption of end-to-end encryption in consumer
|
|
products.
|
|
|
|
**2019: Jeff Bezos WhatsApp hack.** In January 2020, forensic analysis
|
|
by FTI Consulting concluded that Amazon CEO Jeff Bezos's iPhone was
|
|
compromised via a malicious video file sent through WhatsApp from Saudi
|
|
Crown Prince Mohammed bin Salman's account. While WhatsApp's Signal
|
|
Protocol E2E encryption protected the message content in transit, the
|
|
attack targeted the endpoint device itself with spyware (attributed to
|
|
NSO Group's Pegasus). This case is instructive because it shows both
|
|
the strength and the boundary of E2E encryption: it protects messages
|
|
between devices, but if the device itself is compromised, the attacker
|
|
reads the plaintext after decryption. E2E encryption defends against
|
|
network and server compromise, not endpoint compromise.
|
|
|
|
**2020: Zoom E2E encryption scandal.** Zoom Video Communications
|
|
marketed their product as providing "end-to-end encryption" for video
|
|
calls. Investigation by The Intercept (March 2020) and the Citizen Lab
|
|
at University of Toronto revealed that Zoom held the encryption keys on
|
|
their servers. Calls were encrypted with AES-128 in ECB mode (a weak
|
|
cipher mode that leaks patterns) between the client and Zoom's server,
|
|
but Zoom's infrastructure could decrypt all call content. The FTC
|
|
settlement in November 2020 required Zoom to implement actual security
|
|
measures and prohibited them from misrepresenting their encryption
|
|
practices. This case demonstrates that the word "encrypted" without the
|
|
qualifier "end-to-end" is meaningless for privacy.
|
|
|
|
**2021: ProtonMail logging controversy.** ProtonMail, a Swiss encrypted
|
|
email provider that markets itself on privacy, was compelled by a Swiss
|
|
court order (requested via Europol on behalf of French authorities) to
|
|
log the IP address and browser fingerprint of a French climate activist.
|
|
While ProtonMail's E2E encryption meant they could not read email
|
|
content, they were compelled to collect metadata. The server could not
|
|
read messages, but it could identify who was sending them. This case
|
|
shows that E2E encryption solves the content problem but does not
|
|
automatically solve the metadata problem.
|
|
|
|
The pattern is consistent: any server that CAN read your messages WILL
|
|
eventually be compelled to, whether by government subpoena, by
|
|
attackers who breach the infrastructure, or by insiders who abuse their
|
|
access. The only reliable defense is to make it architecturally
|
|
impossible for the server to read the data in the first place.
|
|
|
|
### How It Works (in this project)
|
|
|
|
```
|
|
Alice's Device Server Bob's Device
|
|
+----------------+ +----------------+ +----------------+
|
|
| Plaintext | | | | |
|
|
| "Hello Bob" | | Encrypted | | Plaintext |
|
|
| | | ---> | blob only | ---> | "Hello Bob" |
|
|
| v | | No keys | | ^ |
|
|
| AES-256-GCM | | No access | | AES-256-GCM |
|
|
| encrypt | | No decrypt | | decrypt |
|
|
+----------------+ +----------------+ +----------------+
|
|
|
|
Keys derived from Server stores Keys derived from
|
|
Double Ratchet on ciphertext, nonce, Double Ratchet on
|
|
Alice's device and header verbatim Bob's device
|
|
```
|
|
|
|
On the server side, `backend/app/services/message_service.py` implements the
|
|
`store_encrypted_message` method. Look at the docstring :
|
|
`"Stores client-encrypted message in SurrealDB (pass-through, no server encryption)"`.
|
|
The function receives `ciphertext`, `nonce`, and `header` as strings
|
|
from the client, and stores them directly in SurrealDB
|
|
without any decryption or re-encryption step. The server is a
|
|
passthrough. It writes what it receives and reads what it wrote. At no
|
|
point does it import any cryptographic key or call any decryption
|
|
function on these message parameters.
|
|
|
|
On the client side, `frontend/src/crypto/crypto-service.ts` implements the
|
|
`encrypt` method. The client calls `encryptMessage` from
|
|
`double-ratchet.ts` , receives ciphertext and nonce, then
|
|
sends these as base64-encoded strings to the server. The plaintext
|
|
never leaves the client's process. The actual symmetric encryption
|
|
happens in `frontend/src/crypto/primitives.ts` using the WebCrypto API's AES-GCM
|
|
implementation, which runs in the browser's native cryptographic module
|
|
rather than in JavaScript. This means the plaintext is never even
|
|
accessible to JavaScript debugging tools during the encryption
|
|
operation itself.
|
|
|
|
|
|
## The Signal Protocol
|
|
|
|
### What It Is
|
|
|
|
The Signal Protocol is a cryptographic ratcheting protocol originally
|
|
developed by Trevor Perrin and Moxie Marlinspike at Open Whisper Systems
|
|
(now the Signal Foundation). It was designed to provide end-to-end
|
|
encryption for instant messaging with strong forward secrecy and
|
|
post-compromise security properties.
|
|
|
|
The protocol was first deployed in the TextSecure application (the
|
|
predecessor to Signal) and was formally described in a series of
|
|
technical specifications published at signal.org/docs. It was
|
|
independently analyzed and formally verified in academic papers,
|
|
including "A Formal Security Analysis of the Signal Messaging Protocol"
|
|
by Cohn-Gordon et al. at IEEE EuroS&P 2017, which proved that the
|
|
protocol meets its claimed security properties under standard
|
|
cryptographic assumptions.
|
|
|
|
The Signal Protocol is now deployed at massive scale. WhatsApp completed
|
|
its rollout of Signal Protocol encryption to all users in April 2016,
|
|
covering over 1 billion users at the time (now over 2 billion). Google
|
|
Messages adopted the Signal Protocol for RCS messaging. Facebook
|
|
Messenger offered it as an optional "Secret Conversations" mode. Skype
|
|
implemented it as "Private Conversations." The protocol's design has
|
|
been influential enough that it is effectively the industry standard for
|
|
secure messaging.
|
|
|
|
### The Two Core Components
|
|
|
|
The Signal Protocol combines two distinct cryptographic mechanisms, each
|
|
solving a different problem:
|
|
|
|
```
|
|
Signal Protocol = X3DH (initial handshake) + Double Ratchet (ongoing encryption)
|
|
|
|
X3DH: "How do Alice and Bob agree on a shared secret
|
|
when Bob might be offline?"
|
|
|
|
Solves the ASYNCHRONOUS key agreement problem.
|
|
Bob uploads prekey bundles to the server ahead of time.
|
|
Alice can start a conversation using Bob's prekeys
|
|
without Bob being online.
|
|
|
|
Double Ratchet: "Once they share a secret, how do they
|
|
encrypt each message with a UNIQUE key
|
|
that can never be recovered?"
|
|
|
|
Solves the FORWARD SECRECY and POST-COMPROMISE
|
|
SECURITY problem. Every message gets its own
|
|
ephemeral encryption key. Compromising one key
|
|
does not reveal past or future messages.
|
|
```
|
|
|
|
X3DH runs once at the start of a conversation. The Double Ratchet runs
|
|
continuously for every message after that. The output of X3DH (a shared
|
|
secret) is the input to the Double Ratchet (the initial root key).
|
|
|
|
|
|
## X3DH (Extended Triple Diffie-Hellman)
|
|
|
|
### What It Is
|
|
|
|
Standard Diffie-Hellman key exchange requires both parties to be online
|
|
at the same time. Alice generates a value, sends it to Bob, Bob
|
|
generates a value, sends it back to Alice, and they both compute the
|
|
shared secret. This works fine for a phone call or a live connection,
|
|
but it does not work for asynchronous messaging. If Alice wants to send
|
|
Bob a message at 3 AM while Bob's phone is off, standard DH cannot
|
|
proceed because Bob is not there to generate and send his half.
|
|
|
|
X3DH solves this by having Bob pre-generate a set of key material and
|
|
upload it to the server before going offline. This pre-generated material
|
|
is called a "prekey bundle." When Alice wants to start a conversation,
|
|
she downloads Bob's prekey bundle from the server and uses it to compute
|
|
a shared secret without Bob's participation. When Bob comes back online,
|
|
he can compute the same shared secret from the information Alice sends
|
|
him, because the mathematics of Diffie-Hellman allow both parties to
|
|
independently arrive at the same result.
|
|
|
|
The "Extended Triple" in X3DH refers to the fact that the protocol
|
|
performs three or four separate Diffie-Hellman operations (not just one)
|
|
to achieve stronger security properties than a single DH exchange would
|
|
provide.
|
|
|
|
### Key Types
|
|
|
|
X3DH uses four types of keys. Each has a different lifetime and purpose.
|
|
Understanding why four types exist (instead of just one) is essential to
|
|
understanding the security model.
|
|
|
|
**Identity Key (IK) -- Long-term, generated once per user**
|
|
|
|
The identity key is a permanent keypair that represents the user's
|
|
cryptographic identity. It is generated once and kept for the lifetime
|
|
of the account. This project generates two identity keypairs per user:
|
|
|
|
- An X25519 keypair for Diffie-Hellman operations
|
|
(ref: `frontend/src/crypto/x3dh.ts`)
|
|
- An Ed25519 keypair for digital signatures
|
|
(ref: `frontend/src/crypto/x3dh.ts`)
|
|
|
|
The X25519 keypair participates directly in the DH calculations. The
|
|
Ed25519 keypair signs the signed prekey to prove it belongs to the same
|
|
identity. These are separate curves because X25519 is a Diffie-Hellman
|
|
function (it computes shared secrets) and Ed25519 is a signature scheme
|
|
(it signs and verifies data). They cannot be interchanged. The private
|
|
keys are stored in the database (server-side) or in IndexedDB
|
|
(client-side), and they never change unless the user explicitly resets
|
|
their identity.
|
|
|
|
**Signed Prekey (SPK) -- Medium-term, rotated every 48 hours**
|
|
|
|
The signed prekey is an X25519 keypair that rotates periodically. In
|
|
this project, rotation happens every 48 hours as configured at
|
|
`config.py` (`SIGNED_PREKEY_ROTATION_HOURS=48`).
|
|
|
|
When a new SPK is generated (`frontend/src/crypto/x3dh.ts`), the public
|
|
key is signed using the Ed25519 identity key :
|
|
`signature = identity_private.sign(spk_public_bytes)`. This signature
|
|
proves that the SPK was created by the holder of the identity key. When
|
|
Alice downloads Bob's prekey bundle, she verifies this signature before
|
|
using the SPK, which prevents a man-in-the-middle from substituting
|
|
their own SPK.
|
|
|
|
The rotation period is a tradeoff. Shorter rotation provides better
|
|
forward secrecy (because old SPKs are deleted, and any DH secrets
|
|
computed with them become unrecoverable). Longer rotation means fewer
|
|
key management operations and less complexity. The 48-hour window used
|
|
here is consistent with Signal's recommendation.
|
|
|
|
Old signed prekeys are kept around (with `is_active = False`) so
|
|
messages-in-flight that were encrypted against the previous SPK can
|
|
still complete their initial X3DH on the receiver. There is no
|
|
automated reaper in this codebase; pruning very old inactive SPKs is a
|
|
suggested extension in `04-CHALLENGES.md`.
|
|
|
|
**One-Time Prekey (OPK) -- Single use, consumed and deleted**
|
|
|
|
One-time prekeys are X25519 keypairs that are used exactly once and then
|
|
discarded. They are generated in batches
|
|
(ref: `frontend/src/crypto/x3dh.ts` for generation,
|
|
`backend/app/services/prekey_service.py` for batch replenishment) and uploaded to the
|
|
server.
|
|
|
|
When Alice initiates a conversation with Bob, the server gives Alice one
|
|
of Bob's unused OPKs and marks it as consumed
|
|
(`backend/app/services/prekey_service.py`). This OPK participates in the fourth DH
|
|
operation (DH4) of the X3DH handshake. Because the OPK is used only
|
|
once and then deleted, it provides an additional layer of forward
|
|
secrecy specifically for the initial message of a conversation.
|
|
|
|
If Bob has no unused OPKs remaining (they have all been consumed by
|
|
other users initiating conversations), X3DH falls back to three DH
|
|
operations instead of four. The protocol still works, but the initial
|
|
message has slightly weaker forward secrecy because the fourth DH
|
|
operation is skipped. The system generates 100 OPKs initially
|
|
(`config.py`, `DEFAULT_ONE_TIME_PREKEY_COUNT=100`) and replenishes
|
|
them when the supply drops below half.
|
|
|
|
**Ephemeral Key (EK) -- Generated per session, never stored**
|
|
|
|
The ephemeral key is a fresh X25519 keypair generated by Alice (the
|
|
sender) at the moment she initiates a conversation. It is used in DH2,
|
|
DH3, and DH4 of the X3DH handshake. It is never stored on disk; it
|
|
exists only in memory for the duration of the key exchange computation.
|
|
|
|
In the code, it is generated at `frontend/src/crypto/x3dh.ts`:
|
|
|
|
```python
|
|
alice_ek_private = X25519PrivateKey.generate
|
|
alice_ek_public = alice_ek_private.public_key
|
|
```
|
|
|
|
After the shared secret is computed, Alice sends the ephemeral public
|
|
key to Bob (so Bob can perform the same DH operations on his side), and
|
|
the ephemeral private key is discarded. Because the private component is
|
|
never persisted, even if Alice's device is later compromised, the
|
|
attacker cannot recover the ephemeral private key and therefore cannot
|
|
recompute the initial shared secret.
|
|
|
|
### The Math
|
|
|
|
X3DH performs four Diffie-Hellman operations between different
|
|
combinations of keys. Each operation produces a 32-byte shared secret.
|
|
The four secrets are concatenated and fed into HKDF to produce the final
|
|
shared key.
|
|
|
|
```
|
|
Alice (sender) has: Bob (receiver) has:
|
|
IK_A (identity private key) IK_B (identity public key)
|
|
EK_A (ephemeral, just generated) SPK_B (signed prekey public)
|
|
OPK_B (one-time prekey public)
|
|
|
|
DH Operations (each produces 32 bytes):
|
|
|
|
DH1 = X25519(IK_A_private, SPK_B_public)
|
|
Alice's identity x Bob's signed prekey
|
|
|
|
DH2 = X25519(EK_A_private, IK_B_public)
|
|
Alice's ephemeral x Bob's identity
|
|
|
|
DH3 = X25519(EK_A_private, SPK_B_public)
|
|
Alice's ephemeral x Bob's signed prekey
|
|
|
|
DH4 = X25519(EK_A_private, OPK_B_public) [optional]
|
|
Alice's ephemeral x Bob's one-time prekey
|
|
|
|
Key Material Derivation:
|
|
|
|
input = 0xFF * 32 || DH1 || DH2 || DH3 || DH4
|
|
salt = 0x00 * 32
|
|
info = "X3DH"
|
|
SK = HKDF-SHA256(salt, input, info, length=32)
|
|
```
|
|
|
|
In the codebase, the sender side is at `frontend/src/crypto/x3dh.ts`:
|
|
|
|
- Line 241: `dh1 = alice_ik_private.exchange(bob_spk_public)` -- DH1
|
|
- Line 242: `dh2 = alice_ek_private.exchange(bob_ik_public)` -- DH2
|
|
- Line 243: `dh3 = alice_ek_private.exchange(bob_spk_public)` -- DH3
|
|
- Line 251: `dh4 = alice_ek_private.exchange(bob_opk_public)` -- DH4 (if OPK available)
|
|
- Line 252: `key_material = dh1 + dh2 + dh3 + dh4` -- concatenation
|
|
- Lines 257-264: HKDF derivation with `0xFF * 32` prefix and `b'X3DH'` info string
|
|
|
|
The receiver side at `frontend/src/crypto/x3dh.ts` performs the same
|
|
operations but with the roles reversed. DH1 becomes
|
|
`bob_spk_private.exchange(alice_ik_public)` , because Bob has
|
|
the SPK private key and Alice's IK public key. The property of
|
|
Diffie-Hellman guarantees that `X25519(a_priv, B_pub)` produces the
|
|
same result as `X25519(b_priv, A_pub)`, so both sides compute identical
|
|
shared secrets.
|
|
|
|
The `0xFF * 32` prefix prepended (`f = b'\xff' * X25519_KEY_SIZE`)
|
|
is a fixed padding specified by the X3DH standard. It ensures the HKDF
|
|
input is at least 32 bytes long even in edge cases and provides domain
|
|
separation from other uses of the same keys.
|
|
|
|
### Why Four DH Operations?
|
|
|
|
Each DH operation provides a specific security property. If any single
|
|
operation were removed, a specific class of attack would become possible.
|
|
|
|
**DH1: IK_A x SPK_B -- Authenticates Alice to Bob**
|
|
|
|
This operation uses Alice's long-term identity key. Only Alice (the
|
|
holder of IK_A_private) could have produced this DH output with
|
|
SPK_B_public. When Bob computes the same value using SPK_B_private and
|
|
IK_A_public, he has cryptographic proof that the message came from
|
|
Alice. Without DH1, anyone who knows Bob's public SPK could impersonate
|
|
any sender.
|
|
|
|
**DH2: EK_A x IK_B -- Authenticates Bob to Alice**
|
|
|
|
This operation uses Bob's long-term identity key. Only Bob (the holder
|
|
of IK_B_private) could reproduce this DH output. This ensures Alice is
|
|
actually talking to Bob, not to an impersonator who uploaded their own
|
|
prekey bundle to the server. Without DH2, a malicious server could
|
|
substitute its own keys for Bob's and perform a man-in-the-middle attack.
|
|
|
|
**DH3: EK_A x SPK_B -- Provides forward secrecy**
|
|
|
|
This operation uses Alice's ephemeral key (generated fresh, never stored)
|
|
and Bob's signed prekey (rotated every 48 hours). Because EK_A_private
|
|
is discarded immediately and SPK_B_private is eventually deleted during
|
|
rotation, this DH output becomes unrecoverable after both keys are gone.
|
|
Even if both Alice and Bob's identity keys are later compromised, past
|
|
session keys derived partly from DH3 cannot be recomputed. This is the
|
|
core forward secrecy guarantee.
|
|
|
|
**DH4: EK_A x OPK_B -- Additional forward secrecy for initial messages**
|
|
|
|
This operation uses Bob's one-time prekey, which is consumed and deleted
|
|
immediately after use. It provides forward secrecy specifically for the
|
|
first message in a conversation. Without DH4, if an attacker compromised
|
|
Bob's SPK_private (which exists for up to 48 hours), they could
|
|
retroactively decrypt initial messages sent during that window. DH4
|
|
ensures that even a compromised SPK is insufficient, because OPK_private
|
|
was deleted the moment it was used.
|
|
|
|
DH4 also prevents replay attacks on the initial handshake. Because the
|
|
OPK is single-use, an attacker who records Alice's initial message
|
|
cannot replay it later: Bob has already consumed the OPK, so the server
|
|
will not provide the same one again, and Bob's side will not have the
|
|
OPK private key available for a replayed handshake.
|
|
|
|
### Prekey Bundle Verification
|
|
|
|
Before performing any DH operations, Alice must verify that Bob's signed
|
|
prekey actually belongs to Bob. A malicious server could substitute its
|
|
own SPK and intercept communications.
|
|
|
|
The verification happens at `frontend/src/crypto/x3dh.ts` inside
|
|
`perform_x3dh_sender`:
|
|
|
|
```python
|
|
if not self.verify_signed_prekey(bob_bundle.signed_prekey,
|
|
bob_bundle.signed_prekey_signature,
|
|
bob_identity_public_ed25519):
|
|
raise ValueError("Invalid signed prekey signature")
|
|
```
|
|
|
|
The `verify_signed_prekey` method at `frontend/src/crypto/x3dh.ts` uses
|
|
Ed25519 signature verification. It decodes the SPK public key bytes, the
|
|
signature bytes, and the Ed25519 identity public key bytes, then calls
|
|
`identity_public.verify(signature_bytes, spk_public_bytes)` .
|
|
Ed25519 verification either succeeds or raises `InvalidSignature`. If
|
|
verification fails, the entire X3DH handshake is aborted.
|
|
|
|
This verification is critical. Without it, a server-side attacker could
|
|
replace Bob's SPK with one they control, perform DH operations using
|
|
their own private key, and transparently proxy messages between Alice
|
|
and Bob while reading everything. The Ed25519 signature binds the SPK
|
|
to Bob's identity key, making substitution detectable.
|
|
|
|
Note that this only works if Alice has Bob's authentic identity public
|
|
key. In practice, identity key verification is done through "safety
|
|
numbers" or "key verification" -- a separate out-of-band process where
|
|
Alice and Bob compare fingerprints of each other's identity keys in
|
|
person or through a trusted secondary channel.
|
|
|
|
|
|
## Double Ratchet Algorithm
|
|
|
|
### What It Is
|
|
|
|
The Double Ratchet is an algorithm for managing encryption keys in an
|
|
ongoing conversation. It was developed by Trevor Perrin and Moxie
|
|
Marlinspike as part of the Signal Protocol, building on earlier work
|
|
from the Off-the-Record (OTR) messaging protocol.
|
|
|
|
The Double Ratchet provides two critical security properties that go
|
|
beyond what a static shared key could provide:
|
|
|
|
1. **Forward secrecy** -- Compromising a current key does not expose
|
|
past messages. Even if an attacker steals the current encryption key,
|
|
they cannot derive previous keys and therefore cannot decrypt earlier
|
|
messages.
|
|
|
|
2. **Post-compromise security (break-in recovery)** -- After a key
|
|
compromise, future messages become secure again once a new DH ratchet
|
|
step occurs. If an attacker temporarily gains access to key material,
|
|
they lose access to the conversation as soon as the keys advance
|
|
through a new Diffie-Hellman exchange.
|
|
|
|
The name "Double Ratchet" refers to the fact that it combines two
|
|
ratcheting mechanisms: a **DH ratchet** (Diffie-Hellman ratchet) that
|
|
advances when the conversation's turn changes, and a **symmetric
|
|
ratchet** (hash ratchet) that advances with every single message.
|
|
|
|
### The Three Chains
|
|
|
|
The Double Ratchet maintains three linked KDF chains: the root chain,
|
|
the sending chain, and the receiving chain.
|
|
|
|
```
|
|
Root Chain (KDF_RK)
|
|
|
|
|
|-- [DH ratchet step] --> new root key + new chain key
|
|
|
|
|
+-- Sending Chain (KDF_CK)
|
|
| |-- advance --> Message Key 1 --> Encrypt msg 1
|
|
| |-- advance --> Message Key 2 --> Encrypt msg 2
|
|
| +-- advance --> Message Key 3 --> Encrypt msg 3
|
|
|
|
|
+-- Receiving Chain (KDF_CK)
|
|
|-- advance --> Message Key 1 --> Decrypt msg 1
|
|
|-- advance --> Message Key 2 --> Decrypt msg 2
|
|
+-- advance --> Message Key 3 --> Decrypt msg 3
|
|
```
|
|
|
|
Each chain is a sequence of key derivation operations. The root chain
|
|
produces new sending and receiving chain keys through the DH ratchet.
|
|
The sending and receiving chains produce individual message keys through
|
|
the symmetric ratchet. Every message key is used exactly once and then
|
|
discarded.
|
|
|
|
### KDF Chain Operations
|
|
|
|
**KDF_RK: Root Key Derivation**
|
|
|
|
The root chain advances during a DH ratchet step. It takes the current
|
|
root key and a fresh DH output (from a new DH key exchange) and
|
|
produces a new root key and a new chain key.
|
|
|
|
Reference: `frontend/src/crypto/double-ratchet.ts`
|
|
|
|
```python
|
|
def _kdf_rk(self, root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]:
|
|
hkdf = HKDF(
|
|
algorithm = hashes.SHA256,
|
|
length = HKDF_OUTPUT_SIZE * 2, # 64 bytes total
|
|
salt = root_key, # current root key as salt
|
|
info = b'',
|
|
)
|
|
output = hkdf.derive(dh_output)
|
|
new_root_key = output[: HKDF_OUTPUT_SIZE] # first 32 bytes
|
|
new_chain_key = output[HKDF_OUTPUT_SIZE :] # last 32 bytes
|
|
return new_root_key, new_chain_key
|
|
```
|
|
|
|
HKDF-SHA256 is used with the current root key as the salt and the DH
|
|
output as the input key material. The output is 64 bytes, split in half:
|
|
the first 32 bytes become the new root key, the last 32 bytes become the
|
|
new chain key. This split ensures that knowing the chain key does not
|
|
reveal the root key, maintaining the separation between the root chain
|
|
and the message chains.
|
|
|
|
**KDF_CK: Chain Key Derivation**
|
|
|
|
The symmetric chains advance with every message. Each step takes the
|
|
current chain key and produces the next chain key and a message key.
|
|
|
|
Reference: `frontend/src/crypto/double-ratchet.ts`
|
|
|
|
```python
|
|
def _kdf_ck(self, chain_key: bytes) -> tuple[bytes, bytes]:
|
|
h_chain = hmac.HMAC(chain_key, hashes.SHA256)
|
|
h_chain.update(b'\x01')
|
|
next_chain_key = h_chain.finalize
|
|
|
|
h_message = hmac.HMAC(chain_key, hashes.SHA256)
|
|
h_message.update(b'\x02')
|
|
message_key = h_message.finalize
|
|
|
|
return next_chain_key, message_key
|
|
```
|
|
|
|
Two separate HMAC-SHA256 computations are performed using the same chain
|
|
key but with different constants:
|
|
|
|
```
|
|
chain_key --+-- HMAC(chain_key, 0x01) --> next_chain_key (kept for future)
|
|
|
|
|
+-- HMAC(chain_key, 0x02) --> message_key (used once, discarded)
|
|
```
|
|
|
|
The use of different constants (0x01 and 0x02) is essential. If the same
|
|
constant were used, the chain key and message key would be identical,
|
|
which would mean that learning the message key (perhaps through a chosen
|
|
plaintext attack) would reveal the chain key and allow derivation of all
|
|
future keys. By using different HMAC inputs, the message key and the
|
|
next chain key are cryptographically independent: knowing one does not
|
|
reveal the other.
|
|
|
|
The message key is used exactly once to encrypt or decrypt a single
|
|
message, then discarded. The next chain key replaces the current chain
|
|
key and is used to derive the next message key. This one-way chain is
|
|
what provides forward secrecy within a single DH ratchet epoch.
|
|
|
|
### The DH Ratchet Step
|
|
|
|
The symmetric ratchet handles the simple case: sequential messages from
|
|
the same sender. But it cannot, on its own, provide post-compromise
|
|
security. If an attacker compromises a chain key, they can derive all
|
|
future message keys from that chain. The DH ratchet solves this.
|
|
|
|
A DH ratchet step occurs whenever the conversation's direction changes.
|
|
When Alice receives a message from Bob that includes a new DH public
|
|
key (one she has not seen before), she performs a DH ratchet step: she
|
|
generates a new DH keypair, performs a DH exchange with Bob's new public
|
|
key, and uses the output to derive new root and chain keys through
|
|
KDF_RK.
|
|
|
|
```
|
|
Message flow and DH ratchet steps:
|
|
|
|
Alice sends msgs 1,2,3 using DH keypair A1:
|
|
A1 --> msg1(mk1), msg2(mk2), msg3(mk3)
|
|
[symmetric ratchet advances 3 times, same DH key]
|
|
|
|
Bob receives, generates new DH keypair B1, sends reply:
|
|
DH ratchet: root_key' = KDF_RK(root_key, DH(B1_priv, A1_pub))
|
|
B1 --> msg4(mk1'), msg5(mk2')
|
|
[new chain, new keys, A1 compromise no longer helps]
|
|
|
|
Alice receives, generates new DH keypair A2, sends reply:
|
|
DH ratchet: root_key'' = KDF_RK(root_key', DH(A2_priv, B1_pub))
|
|
A2 --> msg6(mk1''), msg7(mk2'')
|
|
[new chain again, B1 compromise no longer helps]
|
|
```
|
|
|
|
Each DH ratchet step introduces fresh random entropy (from the newly
|
|
generated DH keypair) into the key derivation chain. This means that
|
|
even if an attacker had compromised all previous key material, the new
|
|
DH exchange produces a shared secret they cannot predict, and all
|
|
subsequent keys are secure again.
|
|
|
|
The implementation spans `frontend/src/crypto/double-ratchet.ts`:
|
|
|
|
- `_dh_ratchet_send` : Called when the sender needs to
|
|
advance the ratchet. Generates a new DH keypair , performs
|
|
DH with the peer's public key , and derives new root and
|
|
sending chain keys .
|
|
|
|
- `_dh_ratchet_receive` : Called when a received message
|
|
contains a new DH public key. Updates the peer public key ,
|
|
performs DH with the existing private key to derive a new receiving
|
|
chain key , then generates a new private key at line
|
|
200 and performs another DH to derive a new sending chain key at lines
|
|
208-211. This double DH step on the receiver side ensures both
|
|
receiving and sending chains are updated.
|
|
|
|
### Out-of-Order Message Handling
|
|
|
|
Internet messages can arrive out of order. If Alice sends messages 1, 2,
|
|
3 and Bob receives 1, 3 (message 2 is delayed), Bob needs to:
|
|
|
|
1. Process message 1 normally (derive mk1, decrypt)
|
|
2. When processing message 3, recognize that message 2 was skipped
|
|
3. Derive and cache mk2 (so it can be used later when message 2 arrives)
|
|
4. Derive mk3 and decrypt message 3
|
|
|
|
The skipped message key mechanism handles this. Reference:
|
|
`frontend/src/crypto/double-ratchet.ts`.
|
|
|
|
`_store_skipped_message_keys` is called when the
|
|
received message number is greater than the expected message number. It
|
|
iterates through the gap, deriving and caching each skipped message key:
|
|
|
|
```python
|
|
chain_key = state.receiving_chain_key
|
|
for msg_num in range(state.receiving_message_number, until_message_number):
|
|
chain_key, message_key = self._kdf_ck(chain_key)
|
|
state.skipped_message_keys[(dh_public_key, msg_num)] = message_key
|
|
state.receiving_chain_key = chain_key
|
|
```
|
|
|
|
The skipped keys are stored in a dictionary keyed by `(dh_public_key,
|
|
message_number)`. This tuple key is necessary because message numbers
|
|
reset with each DH ratchet step: message 0 under DH key A1 is different
|
|
from message 0 under DH key A2.
|
|
|
|
`_try_skipped_message_key` checks whether a received
|
|
message matches a previously cached skipped key. If it does, the cached
|
|
key is used for decryption and then removed from the cache (it is
|
|
consumed by `dict.pop` ).
|
|
|
|
Security limits prevent abuse. An attacker who sends messages with
|
|
enormous message numbers could force the ratchet to derive and store
|
|
millions of keys, exhausting memory. Two limits are enforced:
|
|
|
|
- `MAX_SKIP_MESSAGE_KEYS = 1000` (`config.py`): No more than 1000
|
|
message keys can be skipped in a single gap. If a message arrives
|
|
claiming to be message number 5000 when we expect message 0, the
|
|
decryption is rejected .
|
|
|
|
- `MAX_CACHED_MESSAGE_KEYS = 2000` (`config.py`): The total number
|
|
of cached skipped keys across all ratchet epochs. If the cache is
|
|
full, the oldest keys are evicted via
|
|
`_evict_oldest_skipped_keys`.
|
|
|
|
### Forward Secrecy Proof
|
|
|
|
Here is a step-by-step walkthrough of why compromising key material at
|
|
time T does not expose messages before T.
|
|
|
|
Assume at message N, an attacker steals the current chain_key_N.
|
|
|
|
```
|
|
What the attacker CAN compute (forward direction):
|
|
chain_key_N -----> HMAC(chain_key_N, 0x01) = chain_key_N+1
|
|
chain_key_N+1 ---> HMAC(chain_key_N+1, 0x01) = chain_key_N+2
|
|
... and so on for all future chain keys
|
|
|
|
What the attacker CANNOT compute (backward direction):
|
|
chain_key_N <-/-- chain_key_N-1
|
|
|
|
Why? Because HMAC is a one-way function.
|
|
|
|
chain_key_N = HMAC(chain_key_N-1, 0x01)
|
|
|
|
Given chain_key_N, you cannot solve for chain_key_N-1.
|
|
This would require inverting HMAC-SHA256, which is
|
|
computationally infeasible (preimage resistance).
|
|
|
|
Therefore:
|
|
message_key_N-1 = HMAC(chain_key_N-1, 0x02) <-- UNREACHABLE
|
|
message_key_N-2 = HMAC(chain_key_N-2, 0x02) <-- UNREACHABLE
|
|
message_key_1 = HMAC(chain_key_1, 0x02) <-- UNREACHABLE
|
|
```
|
|
|
|
The attacker can decrypt messages N+1, N+2, N+3, and so on (until the
|
|
next DH ratchet step introduces new entropy). But they cannot decrypt
|
|
any message before N. All past message keys are derived from chain keys
|
|
that are computationally inaccessible given only chain_key_N.
|
|
|
|
Now consider what happens at the next DH ratchet step. Bob sends a
|
|
message with a new DH public key B2. Alice generates a new keypair A3
|
|
and performs DH(A3_priv, B2_pub). This produces a fresh DH output that
|
|
the attacker cannot predict (because they do not know A3_priv, which
|
|
was just generated from secure random data). The new root key and chain
|
|
key are derived from this fresh DH output through KDF_RK. The
|
|
attacker's knowledge of the old chain key becomes useless. This is
|
|
post-compromise security: the system self-heals.
|
|
|
|
|
|
## AES-256-GCM Encryption
|
|
|
|
### What It Is
|
|
|
|
AES-256-GCM is the symmetric cipher used to encrypt each individual
|
|
message. It is an AEAD (Authenticated Encryption with Associated Data)
|
|
cipher, meaning it provides both confidentiality (nobody can read the
|
|
message without the key) and integrity (nobody can modify the message
|
|
without detection) in a single operation.
|
|
|
|
AES-256-GCM combines the AES block cipher in Counter mode (CTR) for
|
|
encryption with GHASH for authentication. The "256" refers to the key
|
|
size (256 bits / 32 bytes). The "GCM" stands for Galois/Counter Mode.
|
|
|
|
Each message key derived from the Double Ratchet's symmetric chain is
|
|
used as the AES-256-GCM key. A fresh random nonce (also called IV -
|
|
initialization vector) is generated for every message. The ciphertext
|
|
includes a 128-bit authentication tag that detects any tampering.
|
|
|
|
### How It's Used
|
|
|
|
The encryption flow is:
|
|
|
|
1. The Double Ratchet derives a message key (32 bytes) via KDF_CK
|
|
2. A random 12-byte nonce is generated using `os.urandom` (backend) or
|
|
`crypto.getRandomValues` (frontend)
|
|
3. AES-256-GCM encrypts the plaintext using the message key and nonce
|
|
4. Associated data (sender and recipient identifiers) is authenticated
|
|
but not encrypted
|
|
5. The output is ciphertext + a 16-byte authentication tag (GCM appends
|
|
the tag to the ciphertext)
|
|
|
|
Backend implementation at `frontend/src/crypto/double-ratchet.ts`:
|
|
|
|
```python
|
|
def _encrypt_with_message_key(self, message_key, plaintext, associated_data):
|
|
aesgcm = AESGCM(message_key)
|
|
nonce = os.urandom(AES_GCM_NONCE_SIZE) # 12 bytes from config.py
|
|
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
|
|
return nonce, ciphertext
|
|
```
|
|
|
|
Backend decryption at `frontend/src/crypto/double-ratchet.ts` catches `InvalidTag`
|
|
exceptions , which indicate that the ciphertext was tampered
|
|
with, the wrong key was used, or the associated data does not match.
|
|
The error is re-raised as `ValueError("Message tampered or corrupted")`
|
|
.
|
|
|
|
Frontend implementation at `frontend/src/crypto/primitives.ts` uses the WebCrypto
|
|
API:
|
|
|
|
```typescript
|
|
const nonce = generateRandomBytes(AES_GCM_NONCE_SIZE)
|
|
const ciphertext = await subtle.encrypt(
|
|
{
|
|
name: "AES-GCM",
|
|
iv: nonce.buffer,
|
|
additionalData: associatedData?.buffer,
|
|
},
|
|
cryptoKey,
|
|
plaintext.buffer
|
|
)
|
|
```
|
|
|
|
Frontend decryption at `frontend/src/crypto/primitives.ts` mirrors this with
|
|
`subtle.decrypt`. The WebCrypto API throws a `DOMException` if
|
|
authentication fails, which is functionally equivalent to the Python
|
|
`InvalidTag` exception.
|
|
|
|
### Why Not Just AES-CBC?
|
|
|
|
AES-CBC (Cipher Block Chaining) is the other commonly seen AES mode. It
|
|
provides confidentiality but not integrity. A CBC-encrypted message can
|
|
be modified by an attacker (bit-flipping attacks) without the recipient
|
|
detecting the modification. To add integrity, you need a separate HMAC
|
|
computation (Encrypt-then-MAC or MAC-then-Encrypt), which adds
|
|
complexity and opportunities for implementation errors.
|
|
|
|
GCM handles both in a single operation. It also has practical
|
|
performance advantages: the CTR-mode encryption in GCM is parallelizable
|
|
across CPU cores and benefits from AES-NI hardware instructions, while
|
|
CBC is inherently sequential (each block depends on the previous
|
|
ciphertext block).
|
|
|
|
AES-GCM is the NIST-recommended mode for new applications (NIST SP
|
|
800-38D). It is the mandatory cipher suite in TLS 1.3. There is no
|
|
security reason to prefer CBC over GCM for new implementations.
|
|
|
|
### Parameters
|
|
|
|
As defined in `config.py`:
|
|
|
|
```
|
|
AES_GCM_KEY_SIZE = 32 (256 bits)
|
|
AES_GCM_NONCE_SIZE = 12 (96 bits)
|
|
HKDF_OUTPUT_SIZE = 32 (256 bits)
|
|
```
|
|
|
|
The 12-byte (96-bit) nonce is the recommended size for GCM. Longer
|
|
nonces are allowed by the specification but require additional internal
|
|
processing. The authentication tag is 128 bits (16 bytes), which is the
|
|
full-length tag and the default for both the Python `cryptography`
|
|
library and WebCrypto.
|
|
|
|
With a 256-bit key, AES-256-GCM provides 128-bit security against key
|
|
recovery attacks (Grover's algorithm would reduce AES-256 to 128-bit
|
|
security on a quantum computer, but AES-128 would drop to 64-bit, which
|
|
is why 256-bit keys are the forward-looking choice).
|
|
|
|
The nonce must be unique per key. Because each message key from the
|
|
Double Ratchet is used exactly once, the nonce uniqueness requirement is
|
|
automatically satisfied even if the random number generator produced a
|
|
collision: the same nonce with a different key is not a problem. This is
|
|
a subtle but important point. The Double Ratchet's key-per-message
|
|
design means that nonce reuse (which would be catastrophic with a static
|
|
key) is not a realistic concern here.
|
|
|
|
|
|
## WebAuthn / Passkeys
|
|
|
|
### What It Is
|
|
|
|
WebAuthn (Web Authentication) is a W3C standard (first published in
|
|
March 2019, Level 2 in April 2021, Level 3 in progress) for
|
|
passwordless authentication using public key cryptography. Instead of
|
|
passwords, users authenticate using asymmetric key pairs managed by an
|
|
authenticator: a hardware security key (YubiKey, SoloKey), a platform
|
|
authenticator (Touch ID, Face ID, Windows Hello, Android biometrics), or
|
|
a cross-platform authenticator accessed through a phone.
|
|
|
|
The term "Passkey" refers to a discoverable credential (also called a
|
|
"resident key") that is synced across devices through a platform
|
|
credential manager (iCloud Keychain, Google Password Manager, 1Password,
|
|
etc.). Passkeys are the consumer-friendly branding for WebAuthn
|
|
discoverable credentials.
|
|
|
|
The key insight is that with WebAuthn, the private key never leaves the
|
|
authenticator. The server stores only the public key and a credential
|
|
ID. Authentication is a challenge-response protocol: the server sends a
|
|
random challenge, the authenticator signs it with the private key, and
|
|
the server verifies the signature with the stored public key. No secret
|
|
is transmitted, no secret is stored on the server, and there is nothing
|
|
for an attacker to steal from the server that would allow them to
|
|
impersonate the user.
|
|
|
|
### Why Not Passwords?
|
|
|
|
Passwords are the primary attack vector for authentication compromise.
|
|
Here is why, specifically in the context of an encrypted messaging
|
|
application:
|
|
|
|
**Phishing.** An attacker creates a convincing replica of the login page
|
|
and tricks the user into entering their password. With WebAuthn, the
|
|
authenticator cryptographically binds the credential to the origin
|
|
(domain name) of the website. If the user visits `evil-chat.com`
|
|
instead of `real-chat.com`, the authenticator will not use the credential
|
|
for `real-chat.com` because the origin does not match. The user cannot
|
|
be tricked into authenticating to the wrong site because the
|
|
authenticator will simply not respond to the challenge. This is
|
|
automatic and requires no user awareness of the attack.
|
|
|
|
**Credential stuffing.** Users reuse passwords across services. When one
|
|
service is breached (and breaches of password databases happen
|
|
constantly -- Collection #1 in 2019 exposed 773 million email/password
|
|
pairs), attackers try those passwords against other services. WebAuthn
|
|
credentials are unique per relying party (website). There is no password
|
|
to reuse.
|
|
|
|
**Keyloggers.** Malware that captures keystrokes can record passwords as
|
|
users type them. WebAuthn authentication uses biometric verification
|
|
(fingerprint, face) or PIN entry on the authenticator device, not the
|
|
keyboard. Even if a keylogger captured a PIN, the PIN alone is useless
|
|
without physical possession of the authenticator device.
|
|
|
|
**Server compromise.** If a server stores password hashes and the
|
|
database is stolen, attackers can attempt offline cracking. The 2023
|
|
LastPass breach exposed encrypted password vaults for 25+ million users;
|
|
users with weak master passwords had their vaults cracked. With
|
|
WebAuthn, the server stores only public keys. Stealing public keys gives
|
|
the attacker nothing: you cannot derive a private key from a public key,
|
|
and you cannot forge a signature without the private key.
|
|
|
|
### Registration Flow
|
|
|
|
```
|
|
Step 1: Client requests registration options
|
|
Client ----> POST /auth/register/begin ----> Server
|
|
|
|
Server:
|
|
- Generates 32-byte random challenge (secrets.token_bytes)
|
|
- Stores challenge in Redis with 10-minute TTL
|
|
- Returns WebAuthn PublicKeyCredentialCreationOptions
|
|
|
|
Step 2: Browser creates credential
|
|
Browser ----> navigator.credentials.create(options) ----> Authenticator
|
|
|
|
Authenticator:
|
|
- Prompts user for biometric/PIN verification
|
|
- Generates new ECDSA or EdDSA keypair
|
|
- Stores private key internally (NEVER exported)
|
|
- Returns attestation object (signed credential public key)
|
|
|
|
Step 3: Client sends attestation for verification
|
|
Client ----> POST /auth/register/complete ----> Server
|
|
|
|
Server:
|
|
- Verifies attestation signature
|
|
- Verifies challenge matches stored value
|
|
- Extracts credential public key and credential ID
|
|
- Stores PUBLIC KEY + credential ID in PostgreSQL
|
|
- Deletes challenge from Redis
|
|
- Private key STAYS on authenticator -- server never sees it
|
|
```
|
|
|
|
Implementation reference: `backend/app/core/passkey/passkey_manager.py`
|
|
(`generate_registration_options`). a 32-byte challenge is
|
|
generated: `challenge = secrets.token_bytes(WEBAUTHN_CHALLENGE_BYTES)`.
|
|
At lines 74-87, the WebAuthn options are constructed with RP
|
|
configuration, user information, and authenticator requirements. The
|
|
authenticator selection specifies
|
|
`ResidentKeyRequirement.REQUIRED`, which forces creation of a
|
|
discoverable credential (passkey).
|
|
|
|
Registration verification at `backend/app/core/passkey/passkey_manager.py`
|
|
(`verify_registration`) calls `verify_registration_response` at lines
|
|
105-110, which validates the attestation object, checks the challenge,
|
|
verifies the RP ID, and confirms the origin.
|
|
|
|
### Authentication Flow
|
|
|
|
```
|
|
Step 1: Client requests authentication options
|
|
Client ----> POST /auth/authenticate/begin ----> Server
|
|
|
|
Server:
|
|
- Generates new 32-byte challenge
|
|
- Stores challenge in Redis with 10-minute TTL
|
|
- Returns WebAuthn PublicKeyCredentialRequestOptions
|
|
|
|
Step 2: Browser signs challenge
|
|
Browser ----> navigator.credentials.get(options) ----> Authenticator
|
|
|
|
Authenticator:
|
|
- Prompts user for biometric/PIN
|
|
- Signs challenge with stored private key
|
|
- Increments signature counter
|
|
- Returns assertion (signed challenge + counter)
|
|
|
|
Step 3: Client sends assertion for verification
|
|
Client ----> POST /auth/authenticate/complete ----> Server
|
|
|
|
Server:
|
|
- Retrieves stored public key from PostgreSQL
|
|
- Verifies signature using stored public key
|
|
- Verifies challenge matches stored value
|
|
- Checks signature counter INCREASED (clone detection!)
|
|
- Updates stored counter value
|
|
- Returns authenticated session
|
|
```
|
|
|
|
Implementation reference: `backend/app/core/passkey/passkey_manager.py`
|
|
(`generate_authentication_options`). a fresh challenge is
|
|
generated. At lines 148-153, WebAuthn authentication options are
|
|
constructed.
|
|
|
|
Authentication verification at `backend/app/core/passkey/passkey_manager.py`
|
|
(`verify_authentication`). At lines 173-180, the assertion is verified
|
|
against the expected challenge, RP ID, origin, and stored credential
|
|
public key. The critical clone detection check follows.
|
|
|
|
### Clone Detection
|
|
|
|
Hardware authenticators (YubiKeys, Titan keys, etc.) maintain an
|
|
internal signature counter that increments every time the authenticator
|
|
is used. This counter is included in the signed assertion data. The
|
|
server stores the latest counter value and checks that each new
|
|
authentication presents a higher counter.
|
|
|
|
If the server receives an assertion with a counter value that has not
|
|
increased (or has decreased), it indicates one of two things:
|
|
|
|
1. The authenticator hardware was cloned (its key material was
|
|
extracted and loaded onto a second device)
|
|
2. A replay attack is being attempted
|
|
|
|
Both scenarios are security incidents that warrant blocking
|
|
authentication and alerting the user.
|
|
|
|
Reference: `backend/app/core/passkey/passkey_manager.py`:
|
|
|
|
```python
|
|
if (credential_current_sign_count != 0 and new_sign_count != 0
|
|
and new_sign_count <= credential_current_sign_count):
|
|
logger.error(
|
|
"Signature counter did not increase: current=%s, new=%s. "
|
|
"Possible cloned authenticator detected!",
|
|
credential_current_sign_count,
|
|
new_sign_count
|
|
)
|
|
raise ValueError(
|
|
"Signature counter anomaly detected - potential cloned authenticator"
|
|
)
|
|
```
|
|
|
|
The conditions `credential_current_sign_count != 0` and
|
|
`new_sign_count != 0` are defensive: some authenticators (particularly
|
|
platform authenticators and passkeys) always report a counter of 0,
|
|
indicating that they do not implement counter-based clone detection.
|
|
For those authenticators, the clone detection check is skipped because
|
|
it would always trigger a false positive. This is consistent with the
|
|
WebAuthn specification's guidance on handling authenticators that do not
|
|
support signature counters.
|
|
|
|
|
|
## Constant-Time Comparison
|
|
|
|
A detail worth calling out: `frontend/src/crypto/primitives.ts` implements a
|
|
constant-time byte array comparison:
|
|
|
|
```typescript
|
|
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
if (a.length !== b.length) return false
|
|
let result = 0
|
|
for (let i = 0; i < a.length; i++) {
|
|
result |= a[i] ^ b[i]
|
|
}
|
|
return result === 0
|
|
}
|
|
```
|
|
|
|
This function compares two byte arrays in constant time, meaning the
|
|
execution time does not depend on where the first difference occurs.
|
|
A naive comparison (`a[i] !== b[i]` with an early return) leaks
|
|
information through timing: if the first byte differs, the function
|
|
returns immediately, and an attacker measuring response time can deduce
|
|
that the first byte was wrong. By iterating through all bytes and
|
|
OR-ing the XOR results, the function always takes the same amount of
|
|
time regardless of whether the arrays match at byte 0 or byte 31.
|
|
|
|
Timing side-channel attacks are not theoretical. In 2009, Nate Lawson
|
|
and Taylor Nelson demonstrated practical timing attacks against HMAC
|
|
verification in a web application framework, recovering the correct HMAC
|
|
one byte at a time. The constant-time comparison eliminates this attack
|
|
vector entirely.
|
|
|
|
|
|
## How These Concepts Relate
|
|
|
|
The following diagram shows how the five major concepts connect to form
|
|
the full security architecture of the application:
|
|
|
|
```
|
|
+-----------------------------------------------------------+
|
|
| AUTHENTICATION LAYER |
|
|
| |
|
|
| WebAuthn/Passkeys |
|
|
| | |
|
|
| +--> User identity established |
|
|
| +--> No password to steal, phish, or brute force |
|
|
| +--> Clone detection via signature counter |
|
|
| | |
|
|
| v |
|
|
| +----------------------------------------------------+ |
|
|
| | KEY AGREEMENT LAYER | |
|
|
| | | |
|
|
| | X3DH Key Exchange | |
|
|
| | +--> Asynchronous (works when peer is offline) | |
|
|
| | +--> 4 DH operations for mutual authentication | |
|
|
| | +--> Produces initial shared secret (32 bytes) | |
|
|
| | | | |
|
|
| | v | |
|
|
| | Double Ratchet Initialization | |
|
|
| | +--> Shared secret becomes root key | |
|
|
| | +--> Sending and receiving chains created | |
|
|
| +----------------------------------------------------+ |
|
|
| | |
|
|
| v |
|
|
| +----------------------------------------------------+ |
|
|
| | MESSAGE ENCRYPTION LAYER | |
|
|
| | | |
|
|
| | Double Ratchet (ongoing) | |
|
|
| | +--> KDF_CK derives per-message keys | |
|
|
| | +--> DH ratchet provides post-compromise security| |
|
|
| | +--> Skipped key cache handles out-of-order msgs | |
|
|
| | | | |
|
|
| | v | |
|
|
| | AES-256-GCM | |
|
|
| | +--> Encrypts plaintext with message key | |
|
|
| | +--> Random 12-byte nonce per message | |
|
|
| | +--> Authentication tag detects tampering | |
|
|
| +----------------------------------------------------+ |
|
|
| | |
|
|
| v |
|
|
| +----------------------------------------------------+ |
|
|
| | TRANSPORT LAYER | |
|
|
| | | |
|
|
| | WebSocket (real-time delivery) | |
|
|
| | +--> Carries encrypted blobs between clients | |
|
|
| | +--> Server sees only ciphertext + metadata | |
|
|
| | | |
|
|
| | SurrealDB (persistence) | |
|
|
| | +--> Stores encrypted messages at rest | |
|
|
| | +--> No decryption capability on server | |
|
|
| +----------------------------------------------------+ |
|
|
+-----------------------------------------------------------+
|
|
```
|
|
|
|
The layers interact in a strict top-down sequence for new conversations:
|
|
|
|
1. The user authenticates with WebAuthn (proving their identity without
|
|
a password)
|
|
2. X3DH establishes a shared secret with the peer (even if the peer is
|
|
offline)
|
|
3. The shared secret initializes the Double Ratchet
|
|
(`frontend/src/crypto/double-ratchet.ts`)
|
|
4. Each message is encrypted with a unique AES-256-GCM key derived from
|
|
the ratchet (`frontend/src/crypto/double-ratchet.ts`)
|
|
5. The encrypted message is transmitted via WebSocket and stored in
|
|
SurrealDB (`backend/app/services/message_service.py`)
|
|
|
|
For ongoing conversations, only steps 4 and 5 repeat. The X3DH
|
|
handshake happens once per conversation. The Double Ratchet then runs
|
|
autonomously, deriving fresh keys for every message without any further
|
|
server interaction for key management.
|
|
|
|
|
|
## Industry Standards
|
|
|
|
This section maps the project's security measures to specific industry
|
|
frameworks. These mappings are useful for compliance discussions,
|
|
security audits, and threat modeling.
|
|
|
|
### OWASP Top 10 (2021)
|
|
|
|
**A02: Cryptographic Failures** -- Formerly "Sensitive Data Exposure,"
|
|
this category covers failures in cryptographic implementation. This
|
|
project addresses it through:
|
|
- AES-256-GCM with HKDF-SHA256 key derivation (no weak algorithms)
|
|
- 256-bit keys meeting NIST minimum requirements for post-2030 use
|
|
- Per-message unique keys (no key reuse)
|
|
- Proper nonce generation via CSPRNG (`os.urandom`, `crypto.getRandomValues`)
|
|
- No storage of plaintext on the server
|
|
|
|
**A04: Insecure Design** -- Covers architecture-level security
|
|
weaknesses. The zero-knowledge architecture prevents entire classes of
|
|
server-side attacks:
|
|
- Server compromise does not reveal message content
|
|
- No server-side decryption keys to steal
|
|
- Prekey bundles contain only public key material
|
|
- Store-and-forward model treats all messages as opaque blobs
|
|
|
|
**A07: Identification and Authentication Failures** -- Covers broken
|
|
authentication. WebAuthn eliminates the most common authentication
|
|
attacks:
|
|
- No passwords means no credential stuffing (CWE-521)
|
|
- Origin binding prevents phishing (CWE-352)
|
|
- Signature counters detect cloned authenticators
|
|
- Challenge-response prevents replay attacks
|
|
|
|
### MITRE ATT&CK
|
|
|
|
**T1557: Adversary-in-the-Middle** -- An attacker intercepts
|
|
communications between two parties. E2E encryption with X3DH mutual
|
|
authentication prevents meaningful MITM attacks. Even if an attacker
|
|
controls the network path, they see only AES-256-GCM ciphertext. The
|
|
X3DH signed prekey verification (using Ed25519 signatures) prevents the
|
|
attacker from substituting their own keys.
|
|
|
|
**T1528: Steal Application Access Token** -- An attacker steals an
|
|
authentication token to impersonate a user. WebAuthn credentials are
|
|
cryptographically bound to the relying party origin. A token stolen from
|
|
one site cannot be used on another. The private key never leaves the
|
|
authenticator, so there is no token to steal from the server or from
|
|
network traffic.
|
|
|
|
**T1110: Brute Force** -- An attacker attempts to guess credentials
|
|
through exhaustive trial. With WebAuthn, there are no passwords to brute
|
|
force. Authentication requires physical possession of the authenticator
|
|
and biometric verification (or PIN), making remote brute force
|
|
impossible. The challenge changes with every authentication attempt,
|
|
preventing replay of captured assertions.
|
|
|
|
### CWE References
|
|
|
|
**CWE-327: Use of a Broken or Risky Cryptographic Algorithm** -- This
|
|
project uses exclusively NIST-approved and widely-vetted algorithms:
|
|
X25519 (Curve25519 ECDH), Ed25519 (EdDSA), AES-256-GCM, HMAC-SHA256,
|
|
HKDF-SHA256. No custom cryptographic primitives are implemented. Both
|
|
the Python `cryptography` library and the browser WebCrypto API provide
|
|
well-tested implementations.
|
|
|
|
**CWE-326: Inadequate Encryption Strength** -- 256-bit symmetric keys
|
|
(AES-256) and 256-bit elliptic curve keys (X25519, Ed25519) provide a
|
|
128-bit security level, which exceeds NIST's minimum recommendation of
|
|
112 bits for use through 2030+ (NIST SP 800-57 Part 1).
|
|
|
|
**CWE-330: Use of Insufficiently Random Values** -- Randomness comes
|
|
from two sources: `os.urandom` on the backend (which reads from the
|
|
operating system's CSPRNG -- `/dev/urandom` on Linux) and
|
|
`crypto.getRandomValues` on the frontend (which uses the browser's
|
|
CSPRNG). Both are cryptographically secure. Nonce generation at
|
|
`frontend/src/crypto/double-ratchet.ts` and `frontend/src/crypto/primitives.ts` use these
|
|
exclusively.
|
|
|
|
**CWE-311: Missing Encryption of Sensitive Data** -- All message content
|
|
is encrypted at rest (SurrealDB stores only ciphertext) and in transit
|
|
(WebSocket over TLS carries AES-256-GCM ciphertext). The Double Ratchet
|
|
state itself is serialized and stored, but this state does not contain
|
|
any plaintext; it contains key material for future messages.
|
|
|
|
|
|
## Real-World Case Studies
|
|
|
|
### Case Study 1: The 2020 Zoom E2E Encryption Controversy
|
|
|
|
**Timeline.** In March 2020, The Intercept published an investigation
|
|
revealing that Zoom's claims of "end-to-end encryption" were false. The
|
|
Citizen Lab at the University of Toronto published a follow-up report in
|
|
April 2020, identifying that Zoom used AES-128 in ECB mode and that
|
|
encryption keys were generated by Zoom's servers and transmitted to
|
|
participants through Zoom's infrastructure. In November 2020, the FTC
|
|
issued a complaint, and Zoom agreed to a settlement requiring them to
|
|
implement a comprehensive security program and cease misrepresenting
|
|
their encryption.
|
|
|
|
**What failed architecturally.** Zoom's design placed the encryption
|
|
keys on the server. The connection between the client and the server was
|
|
encrypted (transport encryption via TLS), and the media streams between
|
|
the server and the participants were encrypted with AES-128, but the
|
|
server generated and held all key material. This meant Zoom's servers
|
|
could decrypt every call. The use of ECB mode (Electronic Codebook) was
|
|
an additional failure: ECB encrypts each block independently, meaning
|
|
identical plaintext blocks produce identical ciphertext blocks, which
|
|
leaks structural patterns in the data. ECB has been considered insecure
|
|
for decades and is explicitly warned against in every modern
|
|
cryptography textbook.
|
|
|
|
**How this project prevents the same failure.** In this project, the
|
|
server never generates or holds encryption keys for message content.
|
|
Key generation happens in two places:
|
|
|
|
1. On the backend, `frontend/src/crypto/x3dh.ts` generates X25519 identity
|
|
keypairs using `X25519PrivateKey.generate`, which calls into
|
|
OpenSSL's random number generator. These keys are for the X3DH
|
|
protocol, and the private keys are stored in the database for the
|
|
server-side key exchange path.
|
|
|
|
2. On the frontend, `frontend/src/crypto/primitives.ts` generates X25519 keypairs
|
|
using the WebCrypto API (`subtle.generateKey`), which uses the
|
|
browser's hardware-backed CSPRNG. In the client-side encryption
|
|
model, these keys never leave the browser.
|
|
|
|
The `store_encrypted_message` function (`backend/app/services/message_service.py`)
|
|
receives pre-encrypted ciphertext from the client and stores it directly
|
|
in SurrealDB without any server-side decryption. The
|
|
server's role is explicitly that of a blind relay. Even if the entire
|
|
server infrastructure were compromised, the attacker would obtain only
|
|
encrypted blobs with no corresponding decryption keys.
|
|
|
|
### Case Study 2: Signal Protocol Adoption by WhatsApp (2016)
|
|
|
|
**Background.** In November 2014, Open Whisper Systems announced a
|
|
partnership with WhatsApp to integrate the Signal Protocol into the
|
|
WhatsApp messaging client. The rollout happened incrementally:
|
|
TextSecure's Axolotl protocol (later renamed to the Signal Protocol)
|
|
was first deployed for Android-to-Android messages, then extended to
|
|
group messages, media, and voice calls on all platforms. Full deployment
|
|
was announced in April 2016, making WhatsApp the largest deployment of
|
|
E2E encryption in history, covering over 1 billion users at the time.
|
|
|
|
**Technical details.** WhatsApp implemented the same X3DH + Double
|
|
Ratchet combination used in this project. Each message gets a unique
|
|
AES key through the ratchet mechanism, providing forward secrecy across
|
|
billions of daily messages. WhatsApp's implementation stores prekey
|
|
bundles on their servers (just as this project does via
|
|
`backend/app/services/prekey_service.py`), allowing asynchronous session
|
|
establishment. The X3DH handshake is performed when a user initiates a
|
|
new conversation, and the Double Ratchet runs continuously thereafter.
|
|
|
|
**Impact.** When the Brazilian government ordered WhatsApp to provide
|
|
message content in 2016, WhatsApp demonstrated that they architecturally
|
|
could not comply: they did not possess the decryption keys. This was not
|
|
a policy decision or a promise; it was a mathematical fact. The protocol
|
|
makes it provably impossible for the server to decrypt messages. The
|
|
same situation occurred with FBI requests in the United States and
|
|
government demands in India and the UK.
|
|
|
|
**Connection to this project.** The X3DH implementation at
|
|
`frontend/src/crypto/x3dh.ts` and the Double Ratchet at
|
|
`frontend/src/crypto/double-ratchet.ts` implement the same cryptographic operations
|
|
described in the Signal Protocol specification. The same four DH
|
|
operations , the same HKDF derivation ,
|
|
the same KDF chain operations (lines 79-109 of frontend/src/crypto/double-ratchet.ts), and
|
|
the same skipped message key mechanism . The protocol
|
|
specifications are public, the formal security proofs are published, and
|
|
the implementation follows them directly.
|
|
|
|
### Case Study 3: The 2023 LastPass Breach
|
|
|
|
**Timeline.** In August 2022, an attacker compromised a LastPass
|
|
developer's workstation through a vulnerable third-party media software
|
|
package. Using the developer's credentials, the attacker accessed
|
|
LastPass's development environment and stole source code and technical
|
|
information. In a second incident, the attacker used information from
|
|
the first breach to target a DevOps engineer's home computer, installing
|
|
a keylogger that captured the engineer's master password for a LastPass
|
|
corporate vault. With this access, the attacker exfiltrated encrypted
|
|
customer password vaults and backup data from LastPass's cloud storage.
|
|
|
|
**What was exposed.** The encrypted vaults for approximately 25.6
|
|
million users were stolen. While the vaults were encrypted with AES-256
|
|
using each user's master password as the key derivation input, the
|
|
security of the vaults depended entirely on the strength of the user's
|
|
master password. Users with short, common, or previously-breached master
|
|
passwords had their vaults cracked through offline brute-force attacks.
|
|
The breach also exposed unencrypted metadata including website URLs,
|
|
which revealed which services each user had accounts with.
|
|
|
|
**Connection to this project.** The LastPass breach demonstrates
|
|
precisely why WebAuthn/Passkeys are superior to password-based
|
|
authentication, even when the passwords are used to derive encryption
|
|
keys.
|
|
|
|
In this project's WebAuthn implementation
|
|
(`backend/app/core/passkey/passkey_manager.py`), there is no password. The user
|
|
authenticates with a biometric or PIN on their authenticator device. The
|
|
authenticator holds an ECDSA or EdDSA private key that is hardware-bound
|
|
and never exported. If the server database is fully compromised, the
|
|
attacker obtains only credential public keys and credential IDs. Public
|
|
keys cannot be reversed to obtain private keys (this would require
|
|
solving the elliptic curve discrete logarithm problem, which is
|
|
computationally infeasible). There is no master password to brute force,
|
|
no password hash to crack, and no password-equivalent secret stored on
|
|
the server.
|
|
|
|
The contrast is stark: LastPass stored user secrets protected by a
|
|
user-chosen password. This project stores user secrets protected by a
|
|
hardware-bound private key that the user cannot choose, cannot weaken,
|
|
and cannot accidentally reuse on another site.
|
|
|
|
|
|
## Testing Your Understanding
|
|
|
|
Before moving on to the architecture document, you should be able to
|
|
answer these questions. If you cannot answer one confidently, re-read the
|
|
relevant section.
|
|
|
|
1. **Why does X3DH need four separate DH operations instead of just
|
|
one?** What specific security property does each operation provide?
|
|
What attack becomes possible if any single operation is removed?
|
|
|
|
2. **If an attacker compromises a Double Ratchet chain key at message N,
|
|
which messages can they decrypt?** Which messages remain protected?
|
|
When does the attacker lose access? Trace through the KDF_CK function
|
|
to prove your answer.
|
|
|
|
3. **Why is WebAuthn resistant to phishing attacks, while traditional
|
|
passwords are not?** What property of the WebAuthn protocol prevents
|
|
a fake login page from capturing usable credentials? Hint: think
|
|
about what the authenticator checks before signing the challenge.
|
|
|
|
4. **What happens if Alice sends Bob messages 1, 2, 3, 4, 5 and Bob
|
|
receives them in order 1, 3, 5, 2, 4?** Walk through the skipped
|
|
message key mechanism for each received message. How many keys are
|
|
cached after processing message 3? After processing message 5?
|
|
|
|
5. **Why does the Double Ratchet use two different HMAC constants (0x01
|
|
and 0x02) in KDF_CK?** What would go wrong if both the chain key and
|
|
the message key were derived with the same constant?
|
|
|
|
|
|
## Further Reading
|
|
|
|
### Essential Specifications
|
|
|
|
- The Signal Protocol specifications: https://signal.org/docs/
|
|
- X3DH specification: https://signal.org/docs/specifications/x3dh/
|
|
- Double Ratchet specification: https://signal.org/docs/specifications/doubleratchet/
|
|
- WebAuthn Level 3 specification: https://www.w3.org/TR/webauthn-3/
|
|
|
|
### Academic Analysis
|
|
|
|
- "A Formal Security Analysis of the Signal Messaging Protocol" --
|
|
Cohn-Gordon, Cremers, Dowling, Garratt, Stebila. IEEE EuroS&P 2017.
|
|
Formal proof that the Signal Protocol meets its claimed security
|
|
properties (authenticated key exchange, forward secrecy, post-compromise
|
|
security) under the Gap-DH assumption.
|
|
|
|
- "The Signal Protocol: A Cryptographic Analysis" -- Cohn-Gordon et al.
|
|
(2017). Extended version with proofs covering the X3DH and Double
|
|
Ratchet components individually and composed.
|
|
|
|
- "On Ends-to-Ends Encryption: Asynchronous Group Messaging with Strong
|
|
Security Guarantees" -- Cohn-Gordon et al. IEEE S&P 2018. Extends the
|
|
analysis to group messaging scenarios.
|
|
|
|
### FIDO and WebAuthn
|
|
|
|
- FIDO2 technical overview: https://fidoalliance.org/fido2/
|
|
- FIDO Alliance whitepaper on passkeys:
|
|
https://fidoalliance.org/passkeys/
|
|
- NIST SP 800-63B Digital Identity Guidelines (authenticator types and
|
|
assurance levels)
|
|
|
|
### Cryptographic Primitives
|
|
|
|
- Daniel J. Bernstein, "Curve25519: new Diffie-Hellman speed records"
|
|
(2006) -- The paper introducing the X25519 function used for DH in
|
|
this project.
|
|
|
|
- Daniel J. Bernstein, Niels Duif, Tanja Lange, Peter Schwabe, Bo-Yin
|
|
Yang, "High-speed high-security signatures" (2012) -- The paper
|
|
introducing Ed25519 used for signing prekeys.
|
|
|
|
- NIST SP 800-38D, "Recommendation for Block Cipher Modes of Operation:
|
|
Galois/Counter Mode (GCM) and GMAC" -- The specification for the
|
|
AES-GCM mode used for message encryption.
|
|
|
|
- Hugo Krawczyk, "Cryptographic Extraction and Key Derivation: The HKDF
|
|
Scheme" (2010) -- The paper behind HKDF, the key derivation function
|
|
used throughout the Double Ratchet.
|
|
|
|
### Historical Context
|
|
|
|
- Nikita Borisov, Ian Goldberg, Eric Brewer, "Off-the-Record
|
|
Communication, or, Why Not To Use PGP" (2004) -- The OTR protocol
|
|
that introduced the concept of deniable, forward-secret messaging and
|
|
directly inspired the Signal Protocol's design.
|
|
|
|
- Whitfield Diffie and Martin Hellman, "New Directions in Cryptography"
|
|
(1976) -- The original paper introducing public key cryptography and
|
|
the Diffie-Hellman key exchange that underlies X3DH.
|
|
|
|
- Phil Zimmermann and PGP: The "Crypto Wars" of the 1990s, where the
|
|
US government attempted to restrict the export of strong cryptography.
|
|
Zimmermann published PGP's source code in a printed book to circumvent
|
|
export controls under First Amendment protection. This history is why
|
|
cryptographic software can be freely distributed today.
|