Flamenet Messenger — End-to-End Encryption Spec (v1)
Status: DRAFT / Phase 1. This document is the canonical, cross-platform contract. iOS (CryptoKit), Android (Kotlin + BouncyCastle), and Web (WebCrypto; plus a vendored ML-KEM fallback, §12.2) MUST all implement exactly what is written here, or they will not interoperate. Whether they actually do is §10 — which is a record of runs, not of intent.
The server (flamenet-messenger) is a dumb relay
+ public-key directory. It stores public keys and opaque
ciphertext envelopes only. It never sees plaintext or any private key.
There is no key escrow.
1. Design summary
- Protocol: Signal-style X3DH (asynchronous key agreement) + Double Ratchet (per-message forward secrecy + post-compromise security).
- Identity: per device, not per user. A user may register multiple devices (e.g. iPhone + web). Each device is its own ratchet endpoint. A sender encrypts a message once per recipient device.
- History: none synced. A new device / reinstall = new identity; it cannot read messages sent before it registered. Losing the device key loses the history. (By design.)
- Verification: safety numbers from day one (§7). Clients SHOULD show a key-change warning when a peer device's identity key changes.
Choice note
Wire AEAD is AES-256-GCM, not ChaCha20-Poly1305, so the browser (WebCrypto) can participate natively. If web support is ever dropped, ChaChaPoly may be substituted by bumping the protocol version.
2. Cryptographic primitives
| Purpose | Algorithm | Notes |
|---|---|---|
| DH key agreement | X25519 | CryptoKit Curve25519.KeyAgreement; WebCrypto
X25519; BouncyCastle X25519Agreement |
| Signatures | Ed25519 | CryptoKit Curve25519.Signing; WebCrypto
Ed25519; BouncyCastle Ed25519Signer |
| KDF | HKDF-SHA256 | RFC 5869 |
| AEAD | AES-256-GCM | 12-byte nonce, 16-byte tag |
| Hash (safety #) | SHA-512 | |
| KEM (v2 only) | ML-KEM-768 | FIPS 203 — see §12.2 |
All public keys, signatures, and ciphertext blobs are transported as standard base64 (with padding) inside JSON.
Each device holds two long-term keypairs (this avoids XEdDSA, which WebCrypto/CryptoKit do not expose):
IK_dh— X25519, used for X3DH Diffie-Hellman.IK_sig— Ed25519, used to sign prekeys and to compute the safety number. This is the identity that verification is anchored to.
device_id is a client-generated UUIDv4 string
(lowercase, hyphenated).
3. Key material per device
| Key | Type | Lifetime | Published |
|---|---|---|---|
IK_dh |
X25519 | long-term | public part |
IK_sig |
Ed25519 | long-term | public part |
SPK |
X25519 | rotated (weeks) | public + signature |
SPK_sig |
Ed25519 sig | — | Ed25519_sign(IK_sig_priv, SPK_pub) |
OPK_i |
X25519 | one-time | pool of public parts |
PQSPK |
ML-KEM-768 | rotated with SPK |
v2 only — see §12.3 |
PQOPK_i |
ML-KEM-768 | one-time | v2 only — see §12.3 |
Private parts never leave the device (iOS Keychain / Android Keystore-wrapped / browser IndexedDB, non-extractable where the platform allows).
4. Registration (publishing the bundle)
On first run a device generates IK_dh,
IK_sig, one SPK (+ signature), and an initial
pool of one-time prekeys (RECOMMENDED 100), then calls
POST /e2e/devices (§8.1).
Replenish the one-time pool via POST /e2e/prekeys (§8.5)
whenever the server-reported opk_remaining drops below
20.
SPK rotation and retention
SPK MUST be rotated every 30 days:
generate a new one, increment spk_id, re-sign, and
re-publish via POST /e2e/devices with the same
device_id (upsert). This was a SHOULD with no stated
interval, which in practice meant no implementation ever rotated at
all.
A rotating device MUST retain each retired SPK
private key for 45 days after retirement, and MUST
select the private key by the spk_id carried in the
prekey envelope (§6.5) rather than always using
its current one.
Both halves are required, and omitting either is silent message loss
rather than a degraded guarantee. A sender fetches a bundle, encrypts to
that spk_id, and the envelope may then sit undelivered for
the server's full 30-day retention window (§9). A responder that
discarded the key, or that ignored spk_id and used its
current key, derives a different SK; the failure surfaces
as an AEAD authentication error on the first message, which is
indistinguishable from tampering. 45 days is 30 plus margin for a sender
that fetched a bundle before going offline.
A spk_id of 0 or absent means the sender
did not state one — every envelope produced before rotation existed —
and MUST resolve to the current SPK. A spk_id
that is stated but unknown MUST be rejected outright
(unknownSignedPreKey); it is either a key retired past
retention or a fabrication, and guessing produces a garbage shared
secret.
Implementation status. Web and iOS both implement rotation, retention and
spk_idselection. Android implements none of it, which is safe only for as long as it does not rotate: a responder that rotates withoutspk_idselection loses in-flight sessions. Ship the selection half before the rotation half there.The two differ in rotation interval and that is fine — iOS rotates weekly, web every 30 days, and a shorter window is a smaller blast radius for a leaked key. What must NOT differ is retention, which both set to 45 days, because that is the number the server's 30-day undelivered window (§9) constrains. iOS originally retained a single previous key against a weekly rotation, giving it roughly a fortnight of coverage; an envelope queued longer than that became permanently unopenable, and the failure looked like tampering.
5. X3DH — initiating a session
This section describes protocol v1. v2 adds a hybrid ML-KEM-768 secret to the same construction and is specified as a delta in §12. A v2 client runs §5 unchanged when its peer does not advertise v2.
Alice (initiator) wants to message Bob's device D. She
has never talked to D before.
- Alice fetches Bob/D's bundle:
GET /e2e/keys/{bob_user_id}(§8.2). The server pops one one-time prekey per device and returns{ ik_dh, ik_sig, spk_id, spk, spk_sig, opk }(opkmay benullif the pool is exhausted — X3DH proceeds without it). - Alice MUST verify
Ed25519_verify(ik_sig, spk_sig, spk). Abort on failure. - Alice generates an ephemeral X25519 keypair
EK. - Compute the four (or three) DHs:
DH1 = X25519(IK_dh_A_priv, SPK_B_pub) DH2 = X25519(EK_A_priv, IK_dh_B_pub) DH3 = X25519(EK_A_priv, SPK_B_pub) DH4 = X25519(EK_A_priv, OPK_B_pub) // omitted if opk == null SK = HKDF-SHA256(IKM = DH1 || DH2 || DH3 || DH4, salt = 0x00*32, info = "FlamenetE2E_X3DH_v1", L = 32). Ifopk == null,DH4is omitted from the concatenation.- The associated data for the session is
AD = IK_sig_A_pub || IK_sig_B_pub(raw 32-byte Ed25519 public keys, in that order). - Alice initializes a Double Ratchet (§6) as the sender, with
RK = SKand Bob/D'sSPK_pubas the initial remote ratchet key (DH_remote = SPK_B_pub). - The first message Alice sends is a
type: "prekey"envelope (§6.4) that additionally carriesIK_dh_A_pub,IK_sig_A_pub,EK_A_pub, and thespk_id/opk_idshe used, so Bob can run the matching X3DH.
Bob, on receiving a prekey envelope, runs the symmetric
X3DH with the roles reversed (his private IK_dh,
SPK, OPK matching the supplied ids; Alice's
supplied publics), derives the same SK and AD,
initializes his ratchet as the receiver with
DH_remote = EK_A_pub... — see §6.
6. Double Ratchet
Standard Signal Double Ratchet with unencrypted headers (header encryption is out of scope for v1). Reference: Signal "The Double Ratchet Algorithm".
6.1 State (per session = per remote device)
RK root key (32B)
CKs, CKr sending / receiving chain keys (32B or null)
DHs our current ratchet X25519 keypair
DHr remote current ratchet X25519 public key
Ns, Nr message numbers in sending / receiving chains
PN number of messages in the previous sending chain
MKSKIPPED map {(DHr_pub, N) -> message_key} for out-of-order / skipped messages
(bounded; drop oldest beyond 2000 to limit memory)
6.2 KDF functions
KDF_RK(rk, dh_out):
out = HKDF-SHA256(IKM = dh_out, salt = rk, info = "FlamenetE2E_Ratchet_v1", L = 64)
return (RK' = out[0:32], CK = out[32:64])
KDF_CK(ck):
MK = HMAC-SHA256(key = ck, msg = 0x01)
CK' = HMAC-SHA256(key = ck, msg = 0x02)
return (CK', MK)
6.3 Message key → AEAD parameters
From each 32-byte message key MK:
okm = HKDF-SHA256(IKM = MK, salt = 0x00*32, info = "FlamenetE2E_MsgKey_v1", L = 44)
AESKey = okm[0:32] // AES-256
Nonce = okm[32:44] // 12-byte GCM nonce
ciphertext = AES-256-GCM(AESKey, Nonce, plaintext, AAD)
where AAD = AD || header_bytes (§6.4). The GCM tag is
appended to the ciphertext (standard).
6.4 Envelope (the only thing the server stores per message)
JSON object, then base64 of the UTF-8 JSON is what
goes in the payload transport field.
{
"v": 1,
"type": "prekey" | "msg",
"header": {
"dh": "<b64 DHs_pub>",
"pn": 0,
"n": 0
},
"x3dh": { // present iff type == "prekey"
"ik_dh": "<b64>",
"ik_sig": "<b64>",
"ek": "<b64>",
"spk_id": 7,
"opk_id": 42 // -1 if no one-time prekey was used
},
"ct": "<b64 AES-GCM ciphertext+tag>"
}
A v2 prekey envelope additionally
carries a pq object and sets "v": 2 (§12.6).
msg envelopes are identical in both versions.
header_bytes used in AAD = the UTF-8 bytes of the
canonical compact JSON of the header object exactly as
serialized: {"dh":"...","pn":N,"n":N} (keys in this order,
no spaces). This binds the ratchet header to the ciphertext.
6.5 Sending / receiving
Follow the canonical Double Ratchet RatchetEncrypt /
RatchetDecrypt, including the DH ratchet step when
header.dh != DHr, and MKSKIPPED handling for
gaps. plaintext is the UTF-8 message body — either plain
text, or a content envelope (§6.6) for richer types.
6.6 Content envelope (plaintext layer)
The ratchet is content-agnostic; richer message types live inside the plaintext, so the crypto and the server contract are untouched. Detection rule, applied by receivers after decryption:
If the plaintext begins with the exact bytes
{"fnc":and parses as JSON with an integerfncfield, it is a content envelope. Anything else is a plain text message, byte-for-byte (this preserves every message sent before this section existed).
{
"fnc": 1, // content-envelope version
"kind": "image", // "image" | "audio" (voice note)
"caption": "optional text", // optional; may be absent or empty
"att": {
"id": "<attachment id from POST /e2e/attachments>",
"key": "<b64 32-byte AES-256 key>",
"digest": "<b64 SHA-256 of the uploaded ciphertext blob>",
"bytes": 123456, // ciphertext blob length, for progress UI
"mime": "image/jpeg",
"w": 1280, "h": 960, // pixel dimensions, for layout before download
"dur": 12.4 // audio only: duration in seconds; w/h are 0
}
}
Kinds defined at fnc=1:
image (w/h
required, dur absent), audio
— a voice note (dur required, w/h
sent as 0; RECOMMENDED encoding AAC in an MPEG-4 container,
mime: "audio/mp4", so every platform's native decoder opens
it), and story.
Stories. A story is an image attachment
addressed to every buddy: the sender seals and uploads the blob
once, then sends one kind: "story"
envelope per buddy through that buddy's normal ratchet session. No new
server surface exists — the server sees only ordinary sealed envelopes
plus one blob, and cannot tell a story from a photo message. A story
envelope carries a top-level exp (unix
seconds, RECOMMENDED now + 24 h). Receivers MUST NOT render a story into
the message thread; it belongs to a separate story strip, MUST be hidden
once exp passes, and SHOULD NOT trigger a notification.
Expiry is client-enforced and therefore advisory between honest clients
— the blob itself dies at the server's retention purge (§9)
regardless.
Attachment blob format. The sender generates a fresh
random 32-byte key per attachment, seals the media bytes with
AES-256-GCM (fresh random 12-byte nonce), and uploads
nonce || ciphertext || tag as one opaque blob (CryptoKit's
AES.GCM.SealedBox.combined layout). digest is
SHA-256 over that entire uploaded blob.
Receiver rules. Download the blob by
id, verify digest before
decrypting (the server hands you the bytes; the ratchet only
authenticated the reference), then open the sealed box with
key. A digest or AEAD failure marks the attachment
undisplayable; the message (and any caption) still renders.
The key travels only inside the ratchet, so the server relays a blob it cannot read and cannot link to a conversation beyond what envelope metadata already leaks (§11).
An unknown kind MUST render as a placeholder
("unsupported attachment"), not be silently dropped — the sender meant
to say something.
6.7 Groups (pairwise fan-out)
Groups add no new cryptography. A group message is the same content envelope, encrypted independently to every member through the sender's existing pairwise ratchet sessions — the model WhatsApp and Signal shipped for small groups before sender keys. Fan-out cost is N encryptions per message, so groups are capped small (§8.9); in exchange, membership changes need no rekey: a removed member simply stops being encrypted to, and a new member reads nothing sent before they joined (no history sync, same as a new device, §1).
Routing rides two additions to the content envelope:
- a top-level
gid(the group's server-issued id): a receiver MUST render the message into that group's thread, keyed by the sender for attribution — never into the 1:1 thread with the sender. kind: "text"— a plain group text message;captioncarries the body andattis absent (attis required for every other kind).image/audioenvelopes may also carrygid, so group photos and voice notes work unchanged.
Group metadata — name and roster — lives in a server-side
registry (§8.9) in plaintext, exactly like the buddy list already does.
The server already sees who messages whom (§11); the registry adds the
grouping label, not the content. A receiver that gets a gid
it does not know refreshes GET /e2e/groups; if the group
still is not listed, the message MUST be dropped (the sender's roster
was stale — we are not a member).
6.8 Voice calls
Calls use WebRTC for media (DTLS-SRTP encrypts the
audio) and the ratchet as the signalling channel —
three content-envelope kinds, all with att absent:
{ "fnc": 1, "kind": "call-offer", "call": { "id": "<uuid>", "sdp": "<offer sdp>" } }
{ "fnc": 1, "kind": "call-answer", "call": { "id": "<uuid>", "sdp": "<answer sdp>" } }
{ "fnc": 1, "kind": "call-end", "call": { "id": "<uuid>", "reason": "hangup" | "declined" | "busy" | "timeout" } }
Why this is end-to-end. The SDP carries the DTLS certificate fingerprint that the media channel's keys are derived against. Because the SDP travels inside the Double Ratchet, the fingerprints are authenticated end-to-end: the server relays sealed envelopes it cannot read or alter, so it cannot MITM the media keys. This is the same argument Signal makes for its calls. No per-call key agreement beyond DTLS is needed.
ICE. Offers and answers are
non-trickle: the caller gathers all candidates
(including TURN relay candidates) before sending one complete SDP,
because the signalling channel is a polled queue, not a socket. Clients
SHOULD poll at ~1 s while a call is being set up (call.id
outstanding) and MAY return to their normal cadence once connected or
ended.
Ringing. There is no push channel; an incoming call
rings only while the recipient's app is polling. The caller SHOULD give
up with call-end / timeout after ~45 s. A
call-offer for a call already ended, or arriving while
another call is active, is answered with call-end /
busy. Multi-device: every device of the callee receives the
offer; the first call-answer wins and other devices stop
ringing when they see the winner's answer come back through their own
poll (or the call-end).
TURN. GET /e2e/turn (§8.10) returns
time-limited credentials for the coturn relay. Clients put both STUN and
TURN entries in their ICE configuration; media flows peer-to-peer when
NATs allow and falls back to the relay (which sees only DTLS-SRTP
ciphertext).
7. Safety numbers (verification)
For a local identity IK_sig_local_pub and remote
IK_sig_remote_pub (raw 32-byte Ed25519):
fingerprint(pub, user_id):
h = pub
for i in range(5200): // iterated hash, Signal "version 1" style
h = SHA-512( h || pub )[0:32]
// first 30 bytes -> six 5-digit groups
take 30 bytes; for each 5-byte chunk: int(big-endian) % 100000, zero-pad to 5 digits
The displayed safety number is
sorted([fingerprint_local, fingerprint_remote])
concatenated (local+remote ordered by the raw key bytes, ascending),
rendered as twelve 5-digit groups. Two devices that compute the same
string are talking to each other with no MITM. Clients MUST provide a
screen to compare this (and SHOULD support a QR encoding of the two raw
keys for scan-to-verify).
8. Server REST contract
Base URL: https://<relay>/e2e. The routes are also
mounted under https://<relay>/api/flamenet/v1/e2e as
a compatibility alias; the proof signature commits to the
/e2e/* path either way. Namespace flamenet/v1.
All routes below are under /e2e.
Auth: every route requires an authenticated user,
satisfied by either
Authorization: Bearer <token> — a short-lived token
scoped to one device and one relay, (web client). No subscription tier
is required. The authenticated user is "me".
All request bodies are JSON. All responses are JSON. Base64 fields are standard base64.
8.1
POST /e2e/devices — register / update my device
Request:
{
"device_id": "<uuid>",
"ik_dh": "<b64>",
"ik_sig": "<b64>",
"spk_id": 7,
"spk": "<b64>",
"spk_sig": "<b64>",
"prekeys": [ { "id": 1, "pub": "<b64>" }, ... ] // optional on update
}
Upserts the device for the current user (keyed by
device_id; a device_id may only ever belong to one user).
Stores/replaces the identity + signed prekey, and inserts any supplied
one-time prekeys. Response:
{ "ok": true, "device_id": "...", "opk_remaining": 100 }.
8.2
GET /e2e/keys/{user_id} — fetch prekey bundles to start
sessions
Rate limited. This route pops a one-time prekey per device on every call, so an unmetered version is a prekey-pool drain: fetch a target repeatedly and every later correspondent falls back to the no-OPK path, losing initial forward secrecy, until that target's client replenishes. Metered per requester (120/hour) and per requester-target pair (6/hour); a client needs one bundle per target device to establish and should use §8.3, which pops nothing, for everything after that. Deliberately not metered per target alone — that would let one abuser make a popular account unstartable for everyone, trading a forward-secrecy downgrade for a denial of service. For each of the target user's devices, atomically pops one unused one-time prekey (marks it used) and returns the bundle. Response:
{
"user_id": 12,
"devices": [
{
"device_id": "...", "ik_dh": "<b64>", "ik_sig": "<b64>",
"spk_id": 7, "spk": "<b64>", "spk_sig": "<b64>",
"opk": { "id": 42, "pub": "<b64>" } // or null if pool exhausted
}
]
}
8.3
GET /e2e/devices/{user_id} — list identity keys (no prekey
consumption)
For addressing + safety-number recompute + key-change detection. Response:
{ "user_id": 12, "devices": [ { "device_id": "...", "ik_dh": "<b64>", "ik_sig": "<b64>" } ] }
8.3a
DELETE /e2e/devices/{device_id} — revoke one of my own
devices
Removes the device, both of its prekey pools, and any envelopes still
queued for it. Scoped to the caller: revoking a device owned by another
account is 403, not a silent no-op. 404 when
no such device exists. Response:
{ "ok": true, "device_id": "..." }.
Per-device identity means a lost or retired device otherwise stays a valid ratchet endpoint forever — senders keep fanning out to it and whoever holds it keeps decrypting. Listing devices was possible from the start; removing one was not.
Peers observe a revocation the same way they observe any key change: the device stops appearing in §8.3. There is deliberately no revocation certificate — a relay willing to hide a revocation could withhold a certificate just as easily, so it would buy nothing against the threat it appears to address.
8.4
POST /e2e/messages — submit sealed envelopes
The client encrypts the same logical message once per recipient device and submits them together. Request:
{
"to": 12,
"messages": [ { "to_device": "<uuid>", "payload": "<b64-envelope>" }, ... ]
}
Server validates to is a real user, then stores one row
per envelope. It does not inspect payload.
Response: { "ok": true, "ids": [101, 102] }.
8.5
GET /e2e/messages?device_id={uuid}&after={id}&limit={n}
— poll my inbox
Returns undelivered envelopes addressed to device_id
with id > after, oldest first, marks them delivered, and
returns a cursor. Response:
{
"messages": [
{ "id": 101, "from_user": 7, "from_device": "<uuid>",
"payload": "<b64-envelope>", "created_at": "2026-06-05T12:00:00Z" }
],
"cursor": 101
}
The client persists cursor and passes it as
after next poll.
8.6
POST /e2e/prekeys — replenish one-time prekeys
Request:
{ "device_id": "<uuid>", "prekeys": [ { "id": 101, "pub": "<b64>" }, ... ] }.
Response: { "ok": true, "opk_remaining": 118 }.
8.7
POST /e2e/attachments — upload a sealed attachment
blob
Body is the raw ciphertext blob
(Content-Type: application/octet-stream), not JSON — the
nonce || ct || tag bytes of §6.6, already sealed
client-side. The server never sees a key and cannot distinguish the blob
from noise.
- Size cap: 10 MiB per blob (larger →
413). - Rate limit: rolling per-user hourly caps on count and total bytes.
Response:
{ "ok": true, "id": "<64-hex token>", "bytes": 123456 }.
The id is a server-generated random token — possession of
the id is the download capability, which is why it only ever travels
inside the ratchet.
8.8
GET /e2e/attachments/{id} — download a sealed attachment
blob
Requires an authenticated user (any member — the unguessable id is
the gate, mirroring the Signal CDN model; the server cannot know the
intended recipient of a sealed reference). Streams the blob back as
application/octet-stream. 404 after retention
expiry (§9).
8.9 Group registry
Plaintext metadata only (§6.7) — the server never relays a group message differently from a 1:1 message and cannot tell them apart.
POST /e2e/groups— create. Request:{ "name": "...", "members": [12, 34] }. The creator is always a member and the owner. Caps: name ≤ 80 chars, 32 members including the creator (fan-out cost is per-member; see §6.7).Every member must be reachable: at least one registered device. The relay cannot answer "is this a real account" — that is the identity provider's question, and the earlier implementation could only answer it because it was the identity provider. Reachability is the stricter check anyway: it rejects a typo'd id, and it also rejects a real account that has never set up encryption, which for an encrypted group is the honest answer rather than a silent black hole (fan-out is per device, so such a member would receive nothing). Response:
{ "ok": true, "gid": "<64-hex>", "name": "...", "members": [...] }.GET /e2e/groups— list every group I am a member of, with rosters:{ "groups": [ { "gid": "...", "name": "...", "owner": 7, "members": [ { "user_id": 7 }, ... ] } ] }.Deviation, deliberate. The earlier implementation also returned a
nameper member, joined from its user table. It could do that because it was the identity provider. The standalone relay has no user table and must not grow one — profile data staying with the IdP is the boundary the split is about, and a relay that caches display names is a relay that leaks them. Clients resolve names against the portal, where they already do for buddy lists. Nothing consumed this field at the time it was removed.POST /e2e/groups/{gid}/members— owner only. Request:{ "add": [56], "remove": [34] }. The owner cannot remove themselves (use leave, which transfers or dissolves). Response: the updated group object.POST /e2e/groups/{gid}/leave— remove myself. If the owner leaves, ownership passes to the longest-standing remaining member; the last member leaving deletes the group. Response:{ "ok": true }.
Senders fan out to the roster as of send time. Receivers attribute by
envelope from_user, which the transport already
authenticates via the ratchet session.
8.10
GET /e2e/turn — TURN relay credentials
Mints time-limited credentials for the coturn relay using the
standard REST-secret scheme
(username = <unix expiry>:<user id>,
credential = base64(HMAC-SHA1(secret, username))). TTL 2
hours. Response:
{
"urls": [ "stun:turn.flamenet.io:3478", "turn:turn.flamenet.io:3478?transport=udp" ],
"username": "1787260000:12",
"credential": "<b64>",
"ttl": 7200
}
The relay never sees plaintext — it forwards DTLS-SRTP ciphertext between peers that authenticated each other through the ratchet (§6.8).
Error shape
Standard WP REST error:
{ "code": "...", "message": "...", "data": { "status": 4xx } }.
9. Storage (server tables, all opaque)
{p}fnmsg_e2e_devices— one row per device (identity + current signed prekey).{p}fnmsg_e2e_prekeys— one-time prekey pool,usedflag.{p}fnmsg_e2e_envelopes— sealed message queue,deliveredflag.{p}fnmsg_e2e_attachments— sealed blob index:token, uploader, size, file path,created_at. The blob bytes live on disk underuploads/fnmsg-e2e/, direct web access denied; the REST route is the only reader.
No table contains plaintext or a private key. Envelope retention: delivered envelopes MAY be purged by a cron after 7 days; undelivered are retained until fetched. Attachment blobs are purged after 30 days regardless of download state — a receiver that wants to keep an image keeps the decrypted copy locally, not the server blob.
10. Cross-engine interop status
Verified means one engine's output was opened by another. Self-consistency — an engine agreeing with itself — is not interop and is recorded separately below.
Verified
- iOS (CryptoKit) ↔︎ Web (WebCrypto), both directions,
v1 and v2: one engine seals a
prekeyenvelope, the other runs X3DH respond + ratchet decrypt and replies, and the first opens the reply. Also covered: identical associated data, the v2 last-resort prekey path, tamper rejection on both the body and the KEM ciphertext, and safety-number agreement on shared keys. Harness:engine/test/interop.mjs, whose Swift half is compiled from the app's ownSources/E2E/*.swiftrather than being a reimplementation. - Web ↔︎ relay, end to end over HTTP against the
running binary: registration, bundle assembly, prekey popping and pool
exhaustion, with every signature the server returns verified
client-side. Harness:
engine/test/relay.mjs, which drives the compiled Swift relay rather than a stand-in. - iOS ↔︎ live server, v1 only: a real
register → send → poll → decryptround trip against production (2026-08-11). - Each of the iOS and Web engines separately passes X3DH, Double Ratchet, out-of-order (skipped keys), GCM tamper rejection and safety numbers.
Not verified
🔴 There is no Android engine in this repository. An
earlier version of this section described one at
android/…/data/e2e/E2E.kt (Kotlin + BouncyCastle) with
three JVM tests in E2EEngineTest.kt that "cannot currently
be run" for want of a gradlew wrapper. No such files are
present here, and README.md states plainly that an Android
engine does not exist yet. The two documents contradicted each other for
as long as both were published.
Whether that code exists in some other checkout is not something this
repository can answer, and a spec that names file paths a reader cannot
open is worse than one that says nothing. So: Android is
absent, not merely unverified. When it is written, it is a
plain JVM library — Kotlin and BouncyCastle, no Android APIs — which
means it can be exchanged with the web engine through
engine/test/interop.mjs from its first commit. Doing that
before it is trusted is the whole lesson of this section.
⚠️ Do not infer interop from structural similarity. An earlier version of this section did exactly that, and it was wrong for months: the iOS engine was structurally correct and still could not talk to the server, because the two disagreed about a wire format no component test exercised. Byte-identical constructions are necessary and nowhere near sufficient. Until an Android fixture is exchanged with another engine, treat Android interop as unknown, not as likely.
11. Threat model / limits (v1)
The "harvest now, decrypt later" limit below is what §12 addresses. Everything else in this section applies unchanged to v2.
- Server can see metadata: who messages whom, when, message sizes, device counts. (Sealed-sender is out of scope for v1.)
- Server could attempt a key-swap MITM; safety numbers (§7) are the defense — clients MUST surface identity-key changes.
- No multi-device history sync; no encrypted backups; no group messaging (all deferred).
- One-time prekey exhaustion degrades a session's initial forward
secrecy slightly (X3DH without
OPK) but does not break confidentiality. - No post-quantum protection in v1. A passive
adversary who records envelopes now and breaks X25519 later recovers
SKand, through the ratchet, the entire session. §12 specifies the hybrid ML-KEM-768 key agreement that closes this.
12. Protocol v2 — hybrid post-quantum X3DH (PQX3DH)
Status: DRAFT / proposed. Not yet implemented on iOS
or Android. The web engine (assets/js/e2e.js) is the
reference implementation, per the convention in §10.
This section is a delta against v1. Everything in
§§1–11 still holds for "v": 1 sessions; a v2 client MUST
continue to speak v1 to peers that do not advertise v2. The Double
Ratchet (§6), content envelope (§6.6), safety numbers (§7), and all AEAD
constructions are unchanged.
12.1 Motivation and scope
v1's session key rests entirely on X25519. An adversary who records
envelopes today and obtains a cryptographically relevant quantum
computer later can recover every DH, hence SK, hence the
whole session — the "harvest now, decrypt later" attack. Because the
ratchet chains RK_{n+1} = KDF(RK_n, DH_n), recovering
RK_0 unrolls the entire conversation.
v2 mixes an ML-KEM-768 (FIPS 203) shared secret into
the X3DH IKM. The construction is hybrid: the classical
DHs are retained unchanged and concatenated with the KEM secret before
the KDF. SK is therefore secure if either
X25519 or ML-KEM-768 is secure. This is deliberate and
is the load-bearing property of the design — see §12.9.
What v2 does buy. Confidentiality of a session
against a passive adversary who records now and breaks X25519
later. Once RK_0 is PQ-secure, no later ratchet step is
recoverable from broken DHs alone, so the protection covers the whole
session, not just the first message.
What v2 does not buy. Authentication is still
Ed25519. An adversary with a quantum computer at the time of the
exchange can forge SPK_sig and mount an active MITM;
PQ signatures (ML-DSA) are a separate, later change. The ratchet's
ongoing DH steps stay classical. Device compromise is unaffected. This
is the same posture as Signal's PQXDH, and it should be described that
way and no more strongly.
12.2 Added primitive
| Purpose | Algorithm | Sizes |
|---|---|---|
| KEM | ML-KEM-768 (FIPS 203) | encapsulation key 1184 B · ciphertext 1088 B · shared secret 32 B · seed 64 B |
Key material is stored and transported as the 64-byte FIPS
203 seed (d ‖ z), not the 2400-byte expanded
decapsulation key. Both a compliant native implementation and the
vendored fallback derive byte-identical keys from the same seed
(verified — §12.10).
Implementations MUST use crypto.subtle / platform
ML-KEM-768 where available and fall back to a vetted
library otherwise. Nobody writes ML-KEM by hand.
12.3 Added key material per device
| Key | Type | Lifetime | Published |
|---|---|---|---|
PQSPK |
ML-KEM-768 | rotated with SPK |
public ek + signature |
PQSPK_sig |
Ed25519 sig | — | see below |
PQOPK_i |
ML-KEM-768 | one-time | pool of public eks, each signed |
PQOPK_sig_i |
Ed25519 sig | — | see below |
PQSPK is the last-resort PQ prekey:
always present, never consumed, so a session can always be established
with PQ protection even when the one-time pool is drained.
Signatures are domain-separated, and the one-time signature binds the id:
PQSPK_sig = Ed25519_sign(IK_sig_priv, "FlamenetE2E_PQSPK_v2" ‖ PQSPK_pub)
PQOPK_sig = Ed25519_sign(IK_sig_priv, "FlamenetE2E_PQOPK_v2" ‖ uint32be(id) ‖ PQOPK_pub)
Both prefixes are ASCII, no terminator. Binding id stops
the server relabelling a prekey (a denial of service, not a break, but
free to prevent).
⚠️ v1's
SPK_sigsigns the raw 32-byte key with no domain prefix (§3). That asymmetry is intentional and load-bearing for compatibility with shipped clients. Do not "fix" it.
Pool size. The PQ one-time pool MUST be the same
size as the classical one-time pool (RECOMMENDED 100) and MUST be
replenished on the same trigger (opk_remaining < 20).
Equal sizing is a deliberate invariant: if the PQ pool were smaller it
would drain first and sessions would silently fall back to
PQSPK, losing per-session PQ forward secrecy with no
signal. They must exhaust together or not at all.
Cost of that choice. A full registration payload
carries 100 × (1184 + 64 + 4) ≈ 125 KB raw, ≈ 167 KB base64. Servers
MUST accept a body of at least 256 KB on POST /e2e/devices
(the relay caps request bodies). Replenishment via
POST /e2e/prekeys is incremental and small.
12.4 Version advertisement and capability signature
Each device row gains proto — the highest protocol
version that device implements — plus a signature proving the device
really made that claim:
caps_sig = Ed25519_sign(IK_sig_priv, "FlamenetE2E_Caps_v2" ‖ uint8(proto))
proto and caps_sig are relayed by the
server but authenticated by the device. A server that
rewrites proto downward cannot produce a matching
caps_sig; it can only omit the pair entirely. §12.7 is what
closes that remaining gap.
12.5 PQX3DH — initiating a session
Alice initiates to Bob's device D. Steps that differ
from §5 are marked NEW.
- Alice fetches the bundle (§12.11). It now carries
proto,caps_sig, the PQ signed prekey, and one popped PQ one-time prekey (ornull). - Alice MUST verify
Ed25519_verify(ik_sig, spk_sig, spk)— unchanged. Abort on failure. - NEW. Alice MUST decide the protocol version by the rules in §12.7. If v1, run §5 unchanged and stop here.
- NEW. Alice selects the PQ target: the one-time
PQOPKif the bundle supplied one, otherwisePQSPK. She recordspq_kind ∈ {"opk", "spk"}andpq_id. - NEW. Alice MUST verify the PQ prekey's signature with the matching domain string from §12.3. Abort on failure. A v2 bundle whose PQ signature does not verify is a hard error, never a silent fallback to v1.
- NEW. Encapsulate:
(PQ_CT, SS) = ML-KEM-768.Encaps(PQ_target_pub). - Alice generates an ephemeral X25519 keypair
EKand computesDH1..DH4exactly as §5.4. - NEW. The IKM appends
SSlast:IKM = DH1 ‖ DH2 ‖ DH3 ‖ DH4 ‖ SS // DH4 omitted if opk == null SK = HKDF-SHA256(IKM, salt = 0x00*32, info = "FlamenetE2E_X3DH_v2", L = 32)The info string MUST change from v1. Without it,
DH1‖DH2‖DH3‖DH4(v1, with OPK) andDH1‖DH2‖DH3‖SS(v2, no OPK) are both 128 bytes and would be indistinguishable inputs to the same KDF. The version string is the domain separator that makes the concatenation unambiguous. - NEW. The associated data binds the KEM ciphertext:
ML-KEM's implicit rejection already makes a tamperedPQAD = SHA-256("FlamenetE2E_PQAD_v2" ‖ PQ_CT) // 32 bytes AD = IK_sig_A_pub ‖ IK_sig_B_pub ‖ PQAD // 96 bytesPQ_CTyield a differentSSand therefore a failed AEAD open; this binding makes that explicit and permanent, sinceADprefixes the AAD of every message in the session (§6.3). - Ratchet initialisation is unchanged: sender,
RK = SK,DH_remote = SPK_B_pub. - The first envelope is
type: "prekey"with"v": 2and the added fields in §12.6.
Bob runs the symmetric procedure: same DHs with roles reversed (§5),
then SS = ML-KEM-768.Decaps(PQ_CT, sk) where
sk is selected by pq_kind/pq_id.
If pq_kind == "opk" the prekey is consumed
(deleted) exactly like a classical OPK; if pq_kind == "spk"
it is not. A pq_id Bob does not hold is a hard error
(unknownPQPreKey) — he MUST NOT fall back to
PQSPK.
ML-KEM decapsulation never fails. FIPS 203 implicit rejection returns a pseudorandom shared secret for a malformed or substituted ciphertext. The failure therefore surfaces one layer up, as an AEAD authentication failure on the first message. Implementations MUST NOT treat "decaps succeeded" as any kind of validation.
12.6 Envelope changes
The prekey envelope gains a pq object and
bumps v. msg envelopes are
byte-identical to v1 — the PQ material appears exactly
once, in the initial message.
{
"v": 2,
"type": "prekey",
"header": { "dh": "<b64>", "pn": 0, "n": 0 },
"x3dh": { "ik_dh": "<b64>", "ik_sig": "<b64>", "ek": "<b64>", "spk_id": 7, "opk_id": 42 },
"pq": { "kind": "opk", "id": 42, "ct": "<b64 1088-byte ML-KEM ciphertext>" },
"ct": "<b64 AES-GCM ciphertext+tag>"
}
header_bytes (§6.4) is unchanged — the
pq object is not part of the ratchet header and is not in
the AAD directly; it is bound through PQAD in
AD instead. This is what keeps msg envelopes
and the entire ratchet identical across versions.
A receiver MUST reject "v": 2 with
type: "prekey" and a missing or malformed pq
object rather than treating it as v1. The classical opk_id
and the PQ pq.id are independent id
spaces; they may coincide numerically and MUST NOT be assumed
equal.
12.7 Version selection and downgrade resistance
This is the part of v2 most likely to be got wrong. The threat is a malicious or compromised relay stripping the PQ fields from a bundle so both honest parties negotiate v1 and the adversary harvests as before.
Clients hold a policy pqMode:
"optional"(rollout): use v2 when the bundle carriesproto ≥ 2with a validcaps_sigand a valid PQ prekey; otherwise v1."required"(target): refuse to establish any v1 session. Peers not yet on v2 become unreachable, so this flips only once the fleet has migrated.
Per-device pinning is the actual defence, and it
reuses the trust store that already holds identity pins (§11,
Trust):
The first time a client observes a device with a valid
caps_sigatproto ≥ 2, it MUST persistminProto = 2for that device beside the identity pin. Thereafter a bundle for that device without valid v2 material is a downgrade attempt: the client MUST refuse to send and MUST surface it to the user through the same path as an identity-key change.
minProto MUST be monotonic (never lowered by anything
the server says) and MUST survive the same lifecycle as the identity
pin, including acceptIdentityChange — accepting a new
identity key does not reset minProto.
Residual risk, stated plainly. First contact with a
device the client has never seen is still downgradable: with nothing
pinned, there is nothing to compare against. Safety numbers (§7) do not
close this — they cover IK_sig only and are identical for a
v1 and a v2 session. The only complete fix is
pqMode: "required". Any user-facing claim about
post-quantum protection MUST NOT be made while the fleet is on
"optional".
12.8 Server contract changes
POST /e2e/devices (§8.1) — request gains:
{ "proto": 2, "caps_sig": "<b64>",
"pqspk_id": 3, "pqspk": "<b64>", "pqspk_sig": "<b64>",
"pq_prekeys": [ { "id": 1, "pub": "<b64>", "sig": "<b64>" } ] }
All PQ fields are OPTIONAL, so v1 clients keep registering unchanged.
If any is present all of proto, caps_sig,
pqspk_id, pqspk, pqspk_sig MUST
be present. The server MUST reject a pqspk that is not
exactly 1184 bytes and a signature that is not 64 bytes, in the same
manner as valid_b64key in v1 — it cannot verify signatures
(it holds no private key) but it MUST enforce lengths.
GET /e2e/keys/{user_id} (§8.2) — each device object
gains:
{ "proto": 2, "caps_sig": "<b64>",
"pqspk_id": 3, "pqspk": "<b64>", "pqspk_sig": "<b64>",
"pqopk": { "id": 42, "pub": "<b64>", "sig": "<b64>" } // or null if pool exhausted
}
Each pop is individually atomic (a conditional
UPDATE ... WHERE used = 0, as v1 already does), but the two
are not wrapped in a shared transaction, and should not
be. There is no cross-pool state to corrupt: if the PQ pop returns null
while the classical one succeeded, the caller simply gets a bundle with
an opk and no pqopk, which is the ordinary
exhaustion case §12.3 already covers.
The invariant that actually matters is equal pool sizes and paired replenishment. Both depths are therefore reported on every registration and replenishment response so a client can see the pools diverging:
{ "ok": true, "device_id": "...", "proto": 2,
"opk_remaining": 100, "pq_opk_remaining": 100 }
pq_opk_remaining is null — not
0 — for a device that has never published PQ material. "v1
device" and "v2 device with a drained pool" are different states and
must not be conflated by the client's refill logic.
⚠️ This route already pops a prekey per device, per call. That drain hazard is unchanged by v2 and now applies to the PQ pool as well — an unauthenticated-ish caller can exhaust both pools with 100 fetches, degrading later sessions to
SPK+PQSPK. PreferGET /e2e/deviceswhen not establishing a session. Rate limiting this route is an open item inherited from v1, not introduced here.
POST /e2e/prekeys (§8.6) — accepts a
pq_prekeys array of the same shape.
GET /e2e/devices/{user_id} (§8.3) — gains
proto and caps_sig so a client can learn a
peer's capability, and pin it, without consuming prekeys.
New table {p}fnmsg_e2e_pq_prekeys:
device_id, prekey_id, pub (1184 B
base64), sig (64 B base64), used flag —
mirroring {p}fnmsg_e2e_prekeys.
{p}fnmsg_e2e_devices gains proto,
caps_sig, pqspk_id, pqspk,
pqspk_sig, all NULLable. Additive only; no v1 column
changes, and a v1 client registers exactly as before.
Retention. Consumed PQ prekey rows are purged after 7 days by the existing daily cron. The classical pool has no equivalent sweep and needs none — a spent X25519 row is 44 characters, where an ML-KEM-768 row is 1580 plus an 88-character signature, so a device cycling 100 prekeys leaves ~160 KB behind per refill.
Re-registration must not strip a v2 advertisement.
POST /e2e/devices is an upsert, and a body with no PQ block
MUST leave the stored one intact rather than nulling it. Otherwise a
v1-era build of the same device could silently retract its own
capability, and every peer that had pinned minProto = 2
would refuse to send to it — the device would go dark for exactly the
users who had verified it.
A partial stored block MUST be suppressed entirely
when serving a bundle. If pqspk, pqspk_sig or
caps_sig is missing for any reason, the server emits no v2
fields at all; half a block reads as a downgrade to a peer pinned at
minProto = 2.
12.9 Why hybrid, and why that is not a hedge
ML-KEM-768 is young, and every JavaScript and Swift implementation of
it is younger. The hybrid construction is what makes deploying it
responsible: because SS is concatenated with the classical
DHs before a single KDF, an ML-KEM implementation bug — wrong shared
secret, biased sampling, a broken NTT — degrades SK to
exactly v1's classical security. It cannot make v2 weaker than v1. The
converse also holds. Only a break of both loses the
session.
This means the correct rollout order is PQ-additive first, PQ-only never.
12.10 Conformance requirements for this section
An implementation conforms to §12 only if it demonstrates, not merely implements:
- Seed agreement — native and fallback ML-KEM produce
byte-identical
ekfrom the same 64-byte seed, and each decapsulates the other's ciphertext to the sameSS. - Hybrid KAT — a fixed
(IK, SPK, OPK, EK, PQ seed)vector produces a fixedSKandAD, checked in against the spec so drift is caught by a test rather than by a user. - Cross-engine — a v2 prekey envelope sealed by one engine opens on another, both directions, as §10 requires for v1.
- Downgrade refusal — a device pinned at
minProto = 2refuses a bundle with the PQ fields stripped, and the refusal reaches the UI. - Tamper rejection — flipping one byte of
pq.ctfails the AEAD open, provingPQADis genuinely bound. - Exhaustion path — with the PQ one-time pool empty,
the session establishes against
PQSPKand still round-trips.
§10's warning applies with full force: do not write these claims into the spec until the runs have actually happened. v1 asserted interop for months that was impossible.
Status as of 2026-08-19 — what has actually been run, and on what:
| Status | Where | |
|---|---|---|
| 1. Seed agreement | ✅ verified, both directions, 8 random seeds | engine/test/conformance.mjs |
| 2. Hybrid KAT | ✅ locked vector, replayed on both backends | engine/test/vectors/pqx3dh-v2.json |
| 3. Cross-engine | ⚠️ partial — iOS (CryptoKit) ↔︎ Web
(WebCrypto) verified in both directions, including identical
AD, the last-resort path, tamper rejection and safety numbers; the Swift
driver is compiled from the app's own Sources/E2E/*.swift,
not a reimplementation. JS ↔︎ relay also verified end to
end over HTTP. There is no Android engine in this repository
(§10). |
engine/test/interop.mjs, relay.mjs |
| 4. Downgrade refusal | ✅ verified through the client — a pinned device refuses a
PQ-stripped bundle with ProtocolDowngradeError; the pin is
monotonic, survives a reload, and is not cleared by
accepting an identity change |
engine/test/client.mjs |
| 5. Tamper rejection | ✅ verified — one flipped byte of pq.ct fails the AEAD
open |
engine/test/conformance.mjs |
| 6. Exhaustion path | ✅ verified through the real server's popping path | engine/test/relay.mjs §8.2 |
Client wiring has 50 tests (engine/test/client.mjs) run
against a deliberately hostile in-process relay — one
that strips PQ fields and forges capability signatures, which the real
server will not do. Server behaviour has 16 tests against the real route
methods (engine/test/relay.mjs), driven against the running
relay rather than a stub.
Cross-engine interop has 24 tests
(engine/test/interop.mjs) driving the Swift engine against
the JS engine in both directions.
🔴 pqMode MUST stay
"optional", for two reasons that are now specific
rather than general: Android is v1 only, and
iOS devices below 26 cannot do post-quantum at all
(§12.11). Until both are closed, first contact with an unpinned device
remains downgradable and no post-quantum claim may be made to
users. See §12.7.
12.11 Implementation status
| Layer | State |
|---|---|
Web engine (engine/src/e2e.js,
mlkem.js) |
implemented |
Relay (Swift, server/) |
implemented |
Web client wiring (e2e-client.js,
e2e-bootstrap.js) |
implemented |
iOS (Sources/E2E/, CryptoKit) |
implemented — iOS 26+ only, see below |
| Android | absent — not in this repository (§10) |
The web stack is complete end to end. A new device
registers both prekey pools; a device that registered before v2 upgrades
itself on next load via ensureV2Published(); peers are
pinned at their proven version and a stripped bundle raises
ProtocolDowngradeError rather than falling back.
iOS is complete but gated. CryptoKit gained
MLKEM768 in iOS 26, while the app deploys
to iOS 16, so the primitive sits behind
PQKEM.isSupported. A device on iOS 26+ publishes PQ
prekeys, pins peers and refuses downgrades exactly as the web client
does; a device on iOS 16–25 gets no post-quantum protection at
all and registers as a v1 client. That is visible rather than
hidden — E2EInbox never advertises proto: 2
for a device that cannot honour it.
Closing the gap needs either a deployment target of iOS 26 (the app has never shipped, so there are no users to break — but it cuts off a large share of devices) or a bundled implementation for older systems. Both are product decisions, not engine ones.
🔴 pqMode stays "optional" because
iOS and Android are still v1. Flipping it to
"required" today would make every native client
unreachable. Until then, first contact with an unpinned device remains
downgradable, and no post-quantum claim may be made to
users — see §12.7.
12.12 Open items
- PQ authentication. Identity remains Ed25519; a quantum adversary present at the exchange can still MITM. ML-DSA identity keys are the next step and would change safety numbers, which is a migration of its own.
- Android. No post-quantum support. BouncyCastle has
ML-KEM (
MLKEMKeyPairGenerator), so the primitive is available; the work is the engine, the wire fields and the pinning. Before that, Android needs a fixture exchange with another engine — it has never had one, and adding v2 on top of an unverified v1 compounds the risk rather than reducing it. - iOS below 26. CryptoKit's
MLKEM768needs iOS 26; the app deploys to iOS 16, so older devices are v1 (§12.11). Until Android and this are both closed,pqModecannot leave"optional". - Prekey-pool drain. Inherited from v1, now doubled
in impact. Rate limiting
GET /e2e/keysis the fix.GET /e2e/devicesnow carriesproto+caps_sigso a client can pin a peer's version without consuming a prekey, which removes discovery as a drain vector but not session establishment. - The web delivery problem. Unchanged and unaddressed by v2: the relay still serves the JavaScript that performs the encapsulation. PQ key agreement does not make the web client trustworthy against its own operator.
13. Protocol v3 — post-quantum ratchet
Status: DRAFT / proposed. Delta against §12.
Everything in §§1–12 still holds for "v": 2 sessions, and a
v3 client MUST still speak v2 and v1 to peers that do not advertise
v3.
13.1 What v2 left open
§12 makes the initial key agreement post-quantum.
Because the ratchet chains RK_{n+1} = KDF(RK_n, DH_n), an
adversary who cannot recover RK_0 cannot recover any later
root key either, so v2 already protects a whole session against
harvest-now-decrypt-later.
What v2 does not give is post-quantum
post-compromise security. If an endpoint is compromised
and RK_n leaks, recovery in v2 depends entirely on
subsequent X25519 ratchet steps — which a quantum adversary can break.
The session never heals against that attacker. Signal moved past PQXDH
for exactly this reason.
v3 mixes a fresh ML-KEM-768 secret into every ratchet step, so healing is post-quantum too.
13.2 Construction
The PQ ratchet mirrors the DH ratchet exactly. Each party holds a current ML-KEM keypair and knows the peer's current ML-KEM public key.
Added state per session:
PQs our current ML-KEM keypair (64-byte seed + 1184-byte public key)
PQr the peer's current ML-KEM public key
PQpending {pub, ct} to attach to outgoing messages until the peer answers
The root-key KDF takes both secrets:
KDF_RK_v3(rk, dh_out, ss):
out = HKDF-SHA256(IKM = dh_out || ss, salt = rk,
info = "FlamenetE2E_Ratchet_v3", L = 64)
return (RK' = out[0:32], CK = out[32:64])
The info string MUST change from
_v1.dh_outis 32 bytes andssis 32 bytes, so a v3 IKM is 64 bytes where a v1 IKM is 32 — they cannot collide by length, but the version string is what makes the domain separation explicit rather than incidental.
Initiator, at session start. PQr is
initialised to the PQ prekey chosen by X3DH (§12.5) — the same key the
X3DH encapsulation targeted:
DHs = X25519_generate()
PQs = MLKEM768_generate()
(ct, ss) = MLKEM768.Encaps(PQr)
RK, CKs = KDF_RK_v3(RK, X25519(DHs, DHr), ss)
attach {pqdh: PQs.pub, pqct: ct} to outgoing messages
Responder mirrors it: PQs is the PQ
prekey the initiator used (so it can decapsulate), and PQr
is adopted from the first header.
Every DH ratchet step then performs a PQ step in lockstep:
on receiving a header with a new DHr:
PN = Ns; Ns = 0; Nr = 0
ss_recv = MLKEM768.Decaps(header.pqct, PQs.seed) // peer encapsulated to our current PQs
DHr = header.dh
RK, CKr = KDF_RK_v3(RK, X25519(DHs, DHr), ss_recv)
PQr = header.pqdh // adopt the peer's new PQ key
DHs = X25519_generate()
PQs = MLKEM768_generate()
(ct, ss_send) = MLKEM768.Encaps(PQr)
RK, CKs = KDF_RK_v3(RK, X25519(DHs, DHr), ss_send)
attach {pqdh: PQs.pub, pqct: ct} to outgoing messages
Decapsulation uses the key the peer encapsulated to, which is our PQ key from before we adopt theirs. Doing those two in the wrong order silently derives the wrong root key.
13.3 Header and message size
The header gains two optional fields:
{ "dh": "<b64>", "pn": 0, "n": 0, "pqdh": "<b64 1184B>", "pqct": "<b64 1088B>" }
header_bytes for the AAD is the canonical compact JSON
with keys in exactly this order, omitting
pqdh/pqct entirely when they are absent:
{"dh":"...","pn":N,"n":N} // no PQ step
{"dh":"...","pn":N,"n":N,"pqdh":"...","pqct":"..."} // PQ step
The PQ fields are repeated on every message of a sending chain until the peer answers. Sending them only on the chain's first message would mean that losing that one message strands the entire chain: the receiver would never get the material to advance its root key, and every later message in the chain would be undecryptable. This is the same reasoning that keeps the prekey block attached until answered (§12.6), and it uses the same mechanism.
The cost is ~3.0 KB of base64 per message while a chain is unanswered, falling to zero once the peer replies. In a normal back-and-forth that is one inflated message per turn. This is a deliberate trade against Signal's sparse/erasure-coded approach, which chunks the PQ material across many messages to keep headers small: that complexity buys bandwidth this transport does not need, and every chunking scheme adds reassembly state that can strand a session. A relay over HTTP can afford 3 KB.
13.4 Negotiation
Unchanged from §12.4 and §12.7, which already generalise: a device
advertises proto: 3 with a capability signature over
"FlamenetE2E_Caps_v2" ‖ uint8(3), peers pin
minProto = 3 on first proof, and the pin is monotonic. A v3
device talking to a v2 peer runs v2; the
pqdh/pqct fields are simply absent and
KDF_RK stays on _v1.
A "v": 3 message whose header lacks
pqdh/pqct while the DH key changed MUST be
rejected, not treated as a v2 ratchet step — that would be a downgrade
inside an established session.
13.5 What v3 still does not do
- Authentication is still Ed25519. A quantum
adversary present at the exchange can forge
SPK_sigand MITM. PQ signatures (ML-DSA) remain unimplemented, and this is the last purely-cryptographic gap against Signal. - Nothing here changes metadata exposure. See §14.
14. Sealed sender
Status: DRAFT / proposed. A transport feature, not a
protocol version: it changes what the relay learns, never how
SK is derived. It composes with v1, v2 and v3
unchanged.
14.1 The problem
§11 concedes that the relay sees who messages whom. Today that is
worse than a concession — it is recorded.
POST /e2e/messages is authenticated, so the server knows
the sender by construction, and {p}fnmsg_e2e_envelopes
stores from_user_id and from_device on every
row. Even with delivered envelopes purged after a day, the relay is a
live social-graph feed.
This is the axis on which the project loses hardest to Signal, and unlike PQ or audits it is entirely within our control.
14.2 What sealed sender does and does not achieve
Achieves: the relay no longer learns who sent a message. It sees the recipient, the time, and an opaque blob.
Does not achieve: the relay still sees the recipient, timing, size, and the sender's IP address. Sealed sender is not anonymity — Signal's has the same limits. Do not describe it as "the server knows nothing".
14.3 Construction
The sender identity moves inside the ciphertext. Because a
prekey message arrives before any session exists, the outer layer cannot
use the ratchet; it uses a one-shot seal to the recipient device's
published IK_dh:
eph = X25519_generate()
shared = X25519(eph_priv, IK_dh_recipient_pub)
K = HKDF-SHA256(IKM = shared, salt = 0x00*32,
info = "FlamenetE2E_SealedSender_v1", L = 44)
key, nonce = K[0:32], K[32:44]
inner = UTF-8 JSON { "from_user": <int>, "from_device": "<uuid>", "envelope": {…} }
sealed_ct = AES-256-GCM(key, nonce, inner, AAD = to_device ‖ eph_pub)
Wire (payload field of POST /e2e/messages,
base64 of the JSON):
{ "s": 1, "eph": "<b64 32B>", "ct": "<b64>" }
The recipient trial-decrypts with each of its devices'
IK_dh private keys. AAD binds the seal to the
destination device and the ephemeral key, so a relay cannot replay one
user's sealed blob into another user's inbox.
⚠️ The seal is confidentiality only, not authentication. Anyone holding the recipient's public
IK_dhcan produce a well-formed seal claiming anyfrom_user. The claim is worth nothing until the inner envelope is processed: the ratchet and the identity pin (§11) are what authenticate the sender, exactly as before. A client MUST NOT display, notify on, or record a sealed message's claimed sender before the inner envelope decrypts. Treatingfrom_useras trusted would hand an attacker free sender-spoofing — a strictly worse outcome than the metadata leak this feature removes.
14.4 Delivery tokens
The relay must still refuse traffic to people who do not want it,
without learning who is sending. Each user holds a random 32-byte
delivery key; the server stores only
SHA-256(delivery_key).
POST /e2e/delivery-key— publishSHA-256(delivery_key)for the calling user.The delivery key is distributed to contacts inside E2E messages, so the relay never sees it in the clear from the owner. It travels as a content envelope (§6.6) with
kind: "delivery_key":{ "fnc": 1, "kind": "delivery_key", "key": "<b64 32B>" }Receivers MUST store it against the sender and MUST NOT render it in the thread. It is sent on the first message to a contact and again whenever the key rotates. Because it rides an ordinary ratchet session, the relay cannot distinguish it from a photo message.
A sealed submission presents the raw delivery key. The server hashes it, compares against the recipient's stored digest, and accepts on match — without authenticating the sender.
The delivery key is an authorization capability, not an identity. Anyone the user has ever messaged can send sealed; rotating the key is how a user cuts off a spammer, and rotation must therefore be cheap and re-distributable.
14.5 Server contract
POST /e2e/messages gains a sealed mode,
selected by presenting delivery_key instead of relying on
the session:
{ "to": 12, "delivery_key": "<b64 32B>",
"messages": [ { "to_device": "<uuid>", "payload": "<b64 sealed blob>" } ] }
In sealed mode the server MUST:
- accept the request without an authenticated user, and MUST NOT record one;
- store
from_user_id = 0andfrom_device = ''— not the session's values, and not NULL, so a sealed row is indistinguishable from any other sealed row; - rate-limit by recipient and by IP rather than by sender, since there is no sender;
- reject if the digest does not match, with the same generic error and timing as any other rejection, so the route cannot be used to probe who has published a delivery key.
GET /e2e/messages returns from_user: 0,
from_device: "" for sealed rows. The client learns the real
values from the inner JSON after decryption.
⚠️ The authenticated path must not be silently preferred. If a client falls back to authenticated submission whenever a delivery key is missing, the metadata leak returns with no signal. Clients SHOULD surface which mode a conversation is using, and a user who has enabled sealed sending SHOULD be told when a message could not be sent sealed.
14.6 Open
- Sender IP. Unaddressed and unaddressable at this layer; a relay operator correlating IPs defeats sealed sender for a targeted user.
- Recipient still visible. Signal's private contact discovery and group sending have no analogue here.
- Spam. Rotation is the only lever, and it costs a redistribution to every contact.
15. Implementation status for §13 and §14
Recorded the same way as §12.10: what has actually been run.
| Web engine | Server | Web client wiring | iOS | Android | |
|---|---|---|---|---|---|
| §13 PQ ratchet | ✅ | n/a — no server change needed | ✅ | ✅ verified cross-engine | ❌ |
| §14 sealed sender | ✅ | ✅ sealed mode + delivery keys | ✅ wired end to end | ✅ wired end to end | ❌ |
§13 is complete and verified end to end on the web:
32 tests in engine/test/ratchet.mjs covering lockstep
advance with distinct keys per step, repetition until the chain is
answered, out-of-order delivery, refusal of a stripped ratchet step, and
survival of a reload. The server required no change at
all — it validates lengths and relays whatever version the
device signed for, which the cross-layer suite confirms by running v3
traffic through the unmodified relay routes. A v3 client and a v2 peer
negotiate v2, verified against the real iOS engine.
§14 is wired end to end on the web (27 of the 77
tests in engine/test/client.mjs). A client mints and
publishes a delivery-key digest, hands the key itself to each contact as
a kind: "delivery_key" content envelope on first message,
and seals automatically once it holds a peer's key. Verified: the relay
stores from_user_id = 0 and from_device = "",
the real sender is recovered from inside the ciphertext, a forged sender
claim produces no message and no session, a wrong delivery key is
refused with the same generic error as an unknown recipient, and
rotation invalidates keys already handed out.
⚠️ The first message to a new contact is necessarily
unsealed — you cannot seal to someone whose delivery key you do
not yet hold, and the key arrives in that first exchange. So sealed
sender protects an ongoing conversation, not its first moment.
canSealTo() and lastSendWasSealed exist so a
client can show which mode was used rather than degrading silently.
⚠️ Still true regardless: the relay sees the recipient, timing, size and the sender's IP. Sealed sender is not anonymity.
iOS implements both. Verified cross-engine in
engine/test/interop.mjs: six alternating turns of the PQ
ratchet, each with a distinct ML-KEM key decapsulated by the other
engine; a Swift seal opened by JS and a JS seal opened by Swift; a seal
replayed at a different device refused; and delivery-key digests
agreeing. The whole iOS tree typechecks against the iOS
16 floor, so the availability gating still holds — but §12.11
still applies: a device below iOS 26 gets none of this,
because CryptoKit has no MLKEM768 there.
🔴 Android remains v1 and has still never been checked against another engine (§10).
16. Encrypted backups
Status: DRAFT / proposed.
16.1 What a backup may contain, and why that is the whole design
The obvious backup — serialise the vault, encrypt it, restore it — is
wrong here, and dangerously so. The vault holds live
ratchet state. Restore it onto a second device while the first is still
running and both devices derive the same message keys for the
same message numbers. Two AES-GCM encryptions under one key
with one nonce is a catastrophic failure, not a degraded one: it leaks
the XOR of both plaintexts and destroys the authentication guarantee. It
would also duplicate one device_id across two installs, so
both race on the same inbox and the same prekey pool.
So a backup here deliberately contains no ratchet state and no device identity:
| Included | Excluded |
|---|---|
| decrypted message history | ratchet sessions (rk, chain keys, skipped keys) |
| identity pins and verified flags for contacts | our own IK_dh / IK_sig |
pinned minProto per device |
signed and one-time prekeys |
| delivery keys held for contacts | our own delivery key |
A restored device therefore keeps its own new identity, registers as a new device, and re-establishes sessions from scratch. What it recovers is the conversation history and the trust the user had already built — which is what people actually lose when a device dies.
⚠️ Peers will see a new device. Restoring is not invisible: contacts get an identity-change prompt, exactly as they would for a reinstall. That is correct and must not be suppressed — §11 exists precisely so a new key is never silently accepted.
16.2 Format
{
"v": 1,
"kdf": { "alg": "PBKDF2-HMAC-SHA256", "salt": "<b64 16B>", "iters": 600000 },
"nonce": "<b64 12B>",
"ct": "<b64 AES-256-GCM ciphertext+tag>"
}
key = PBKDF2-HMAC-SHA256(passphrase, salt, iters, 32 bytes)
ct = AES-256-GCM(key, nonce, JSON(payload), AAD = canonical JSON of the header)
The AAD covers v and the whole kdf object,
so the iteration count and salt cannot be edited without invalidating
the tag — a restore therefore cannot be tricked into deriving a key with
weakened parameters.
PBKDF2 is a compromise, stated rather than hidden. It is not memory-hard; Argon2id would be materially better against GPU cracking. It is used because WebCrypto and CommonCrypto both provide it natively on every target platform, and shipping a hand-rolled or vendored Argon2 to browsers is a worse trade than a high iteration count. 600 000 iterations is the current OWASP guidance for PBKDF2-HMAC-SHA256. A weak passphrase is the real limit here, and any UI MUST say so rather than implying the backup is safe by construction.
16.3 Rules
- A backup file is as sensitive as the plaintext history, because that is what it is.
- Implementations MUST NOT upload backups anywhere by default. There is no server route for them and none should be added: a relay that stores backups is a relay that stores everything, which contradicts §14 entirely.
- Restoring MUST NOT resurrect a
device_id. The restoring device registers fresh. - Restoring MUST merge, never blindly overwrite: a pin that already exists locally and disagrees with the backup is an identity change and must be surfaced, not silently replaced by whichever copy is older.
17. Multi-device
Status: DRAFT / proposed.
17.1 What is already solved
Senders encrypt to every device of a recipient (§1), so inbound messages already reach all of a user's devices. Nothing needs to be built for that, and it is worth stating plainly because it means multi-device is a much smaller problem here than it looks.
What is missing is the other half: a user's own outbound messages exist only on the device that sent them.
17.2 Sent-copies
When a device sends a message, it also encrypts a copy to each of the user's other devices through an ordinary ratchet session, as a content envelope (§6.6):
{ "fnc": 1, "kind": "sent", "to": 12, "body": "…", "at": 1787260000 }
No new server surface: these are ordinary envelopes addressed to
devices of the sending user. Receivers MUST render a sent
copy as an outgoing message in the thread with to, and MUST
NOT notify on it.
17.3 Linking a new device — the part that must not be automated
🔴 A new device of your own is cryptographically indistinguishable from one an attacker planted under your account. This is the same problem as §11, and it is more dangerous here, because a device the user's other devices trust receives sent-copies of everything.
Therefore:
- A new own-device MUST NOT be trusted automatically, however convenient that would be.
- Linking MUST require explicit confirmation on an already-trusted device, and the confirmation SHOULD be a safety-number comparison between the two own devices (§7 works unchanged — they are just two identity keys).
- Until confirmed, the new device MUST NOT receive sent-copies and MUST NOT receive a history backfill.
- A client MUST surface the full list of linked devices and allow removing one.
⚠️ Adding a device is an identity change to your contacts. They will see a key they have never accepted and be prompted, exactly as for a reinstall (§11). This is correct and MUST NOT be suppressed to make linking feel smoother — the prompt is the only thing standing between a user and a planted device. Clients SHOULD warn the person doing the linking that their contacts will be asked to re-verify.
17.4 History backfill
On linking, an existing device MAY stream history to the new one as content envelopes:
{ "fnc": 1, "kind": "history", "seq": 3, "of": 12, "items": [ … ] }
Backfill is best-effort and bounded — it costs one envelope per chunk per device and is not worth stranding a link over. A client SHOULD send newest-first so the useful part arrives first, and MUST tolerate a partial backfill rather than retrying forever.
17.5 What this does not give
- No server-side history. A device that was never linked cannot recover messages that predate it, except from a backup (§16). This is a design choice, not an omission: the alternative is the relay holding conversation history.
- Prekey pools are per device, so each linked device registers and replenishes its own.
- Removing a device does not retract what it already received.
18. Implementation status for §16 and §17
| Web engine | Web client | Server | iOS | Android | |
|---|---|---|---|---|---|
| §16 encrypted backups | ✅ createBackup / openBackup |
✅ exportBackup / importBackup |
n/a — no server route, by design | ❌ | ❌ |
| §17 multi-device | ✅ reuses the ratchet unchanged | ✅ linking, sent-copies | n/a — no server change | ❌ | ❌ |
Neither needed a server change, and neither should ever get one: a relay that stores backups or brokers device linking is a relay that stores everything, which contradicts §14.
Covered by 51 of the 113 tests in
engine/test/client.mjs, including the three failure modes
that make these features dangerous if implemented naively:
- a backup payload containing no ratchet state and no identity (§16.1);
- a restore whose local pin disagrees with the backup reporting a conflict and keeping the local pin, rather than silently letting a stale backup re-trust a rejected key;
- a sent-copy from an own-device that was never linked being ignored, even though the ratchet authenticates it as ours.
🔴 Not implemented: history backfill on link
(§17.4). A newly linked device receives sent-copies from that
moment on, but gets no history. importBackup is the only
way to recover older messages today.
🔴 iOS and Android have neither feature. Backups and
sent-copies are per-client; nothing about them is negotiated, so a v3
iOS device simply has no backup export and ignores sent
content envelopes it does not implement.
19. Key transparency
The relay is the sole distributor of prekey bundles, so a dishonest one can hand out an identity key it controls. Safety numbers (§7) detect this, but only if both people compare a string by hand. This section makes substitution leave evidence instead.
19.1 The log
An append-only log in the RFC 6962 shape. Leaves are hashed under
0x00 and interior nodes under 0x01, so a leaf
can never be reinterpreted as a node. The tree is split at the largest
power of two strictly below the size, which is what makes its shape a
function of its size alone — two verifiers agree without exchanging
structure.
Every device key the relay serves is appended before it can be served, in the same database transaction as the write it records. A republication that does not change the identity key (prekey replenishment, SPK rotation) appends nothing: entries that mean something would otherwise be buried under entries that do not.
19.2 Leaf contents
leaf = SHA-256( 0x00 || "v1|" || kind || "|" || user_id || "|" || device_id
|| "|" || ik_sig || "|" || ik_dh )
kind is publish or revoke.
Built by concatenation rather than by a JSON encoder: a leaf hash must
be reproducible by every independent verifier forever, and an encoder is
free to change key order, spacing or escaping between releases.
19.3 Routes
All four are unauthenticated. A log only its own users can read is not a transparency log; the value is that anyone — a researcher, a monitor, the other party — can fetch a head and check it. They expose commitments to public keys, which were already public.
GET /e2e/log/head |
{size, root, signature, audience} |
GET /e2e/log/inclusion?index=&size= |
audit path for one leaf |
GET /e2e/log/consistency?from=&to= |
proof that from is a prefix of to |
GET /e2e/log/device/{device_id} |
every entry for a device, with its index |
The head is signed with the relay's issuer key over
"fnkt-v1|" || size || "|" || base64(root). The signature is
what makes a fork evidence: two heads of the same size with
different roots, both validly signed, are a statement the relay cannot
retract or blame on a client.
size on the inclusion route defaults to the current head
but accepts an older one, so a client can verify against a head it
already pinned rather than one it is being handed now — which is the
entire point of pinning.
19.4 What this does and does not buy
Does. The relay cannot serve a key it has not committed to. It cannot rewrite history: a consistency proof between any two heads it has signed would fail. A client can confirm that the key it was handed is the key in the log, and that its own device's entries are the ones it published.
Does not, on its own. A relay that forks the log — showing each victim a self-consistent branch — is invisible to inclusion and consistency proofs, which each victim can verify perfectly against the branch they were given. Detection requires the heads to be compared between clients: §19.4.
19.4a Gossip
Clients exchange the head each was last shown as a control envelope
(kind: "kt_head") inside the ratchet.
In-band is the point: the relay is the party being audited, so it must
not see which head each client holds and must not be able to rewrite one
in transit. A head exchanged in the clear lets a forked relay answer
each client with the branch that client already believes.
On receiving a peer's head:
- Equal size, equal root — agreement, nothing to do.
- Equal size, different root — a fork on its face. No proof is requested, because none could exist.
- Different sizes — ask the relay to prove the shorter is a prefix of the longer. Detection works precisely because the relay is the only party that could produce that proof, and if it served two branches, it cannot. A refusal and a bad proof are treated identically.
Once a longer head is proven to extend ours, it is adopted.
This detects a fork between any two people who talk to each other. It does not detect one against a user who talks to nobody, and a relay that forks consistently per social component still evades it. That is inherent to gossip and is why key transparency is described here as raising the cost of substitution rather than eliminating it.
Also does not. Nothing here is enforced on the client yet: the relay offers proofs, and no shipped client demands one. A log nobody checks is a log.
19.5 Implementation status
| Relay | Web engine | iOS | Android | |
|---|---|---|---|---|
| §19.1–19.3 log, proofs, signed head | ✅ | ✅ src/keylog.js |
✅ KeyLog.swift |
❌ |
| Client demands a proof before first use | n/a | ✅ opt-in | ✅ opt-in | ❌ |
| Append-only pinning across sessions | n/a | ✅ | ✅ | ❌ |
| §19.4 gossip between clients | n/a | ✅ | ✅ consumes kt_head |
❌ |
Verification is checked across engines and against the
running relay, not transitively: test/interop.mjs
proves the two engines compute the same leaf hash and both reject a
reversed audit path, and test/relay.mjs drives the iOS
verifier against proofs the real relay produced. Transitive agreement
would leave a shared misreading of the wire format invisible.
The web engine recomputes the leaf from the served
bundle rather than trusting the leaf the relay returns —
echoing back its own bytes would otherwise satisfy the check — and
refuses with KeyNotLoggedError or
KeyLogForkError. The head is pinned on first sight and
every later fetch must prove consistency with it.
requireKeyLog is opt-in because a relay predating the
log has none, and refusing every peer on a deployment that cannot answer
is a worse failure than the one being prevented.
⚠️ Audit-path ordering. RFC 6962 audit paths are
ordered leaf-first. A top-down reading of the same array produces the
correct root for any left-spine index, so an implementation that
reverses it passes any test that only checks index 0.
test/relay.mjs verifies every index in the
log for this reason — this was a real bug in the first verifier written
here, and it is exactly the kind that a happy-path test certifies as
correct.
20. Post-quantum authentication
§12 makes key agreement hybrid, so traffic recorded today is not readable once a quantum computer exists. Identity keys are still Ed25519, so an adversary with one can forge a bundle signature in real time and be believed as anybody. Harvest-now-decrypt-later is addressed; impersonate-later is not. This section addresses it.
20.1 Construction
A device MAY publish an ML-DSA-65 identity public
key (ik_pq, 1952 bytes) alongside its Ed25519 one, and sign
its signed prekey with both:
spk_sig = Ed25519-Sign(ik_sig_priv, spk) — §3, unchanged
spk_pq_sig = ML-DSA-65-Sign(ik_pq_priv, spk) — 3309 bytes
Both cover the same bytes. A verifier that holds
ik_pq MUST check both and accept only if both pass.
Hybrid rather than a replacement, for the same reason §12 is hybrid: an attacker must break both schemes rather than whichever turns out to be weaker, and a flaw later found in ML-DSA leaves the device no worse off than it is today. ML-DSA is the newer of the two and has had the less adversarial decade.
20.2 Optional by design
ik_pq is absent on a device that has not published one,
and a verifier treats absence as classical only rather than as
an error. A required field would make every existing device unreachable
the day it shipped.
This is deliberately independent of the §12 v2
block: post-quantum authentication and post-quantum key
agreement are separate capabilities, and a device may have either
without the other. The relay therefore serves ik_pq whether
or not the device has a complete v2 block.
20.3 Key derivation and storage
The signing key is derived from a 32-byte seed, so a device stores 32 bytes and rederives the 4032-byte secret key on demand. Storing the expanded key would have meant a new vault format; storing the seed did not.
20.4 Relay behaviour
The relay does not verify identity signatures — it never has, and §8 is explicit that verification is the client's job. It validates lengths at publication (1952 and 3309), because a wrong-length key can only ever fail on every peer, and failing once at publication names the problem better than failing silently per correspondent.
20.5 Downgrade
A relay that strips ik_pq from a served bundle presents
a device as classical when it is not. Clients MUST pin: once a peer
device has been seen with a post-quantum identity, a later bundle for
that device without one is a downgrade and must be
refused, exactly as §12.9 requires for the v2 block.
The web engine pins the fingerprint of the identity, not merely the fact that one existed: a relay that substituted its own ML-DSA key would satisfy a boolean pin. A device seen without an identity is pinned as classical and may publish one later; a device seen with one may never afterwards appear without it.
The pin is consulted when a session is established, which is the only moment a downgrade can be attempted — an existing session never refetches a bundle.
20.6 Implementation status
| Relay | Web engine | iOS | Android | |
|---|---|---|---|---|
| §20.1 publish and serve | ✅ | ✅ | ✅ iOS 26+ | ❌ |
| §20.1 verify both signatures | n/a — not the relay's job | ✅ | ✅ iOS 26+ | ❌ |
| §20.5 downgrade + substitution pinning | n/a | ✅ | ✅ | ❌ |
iOS below 26 has no MLDSA65, so it
publishes no identity and cannot check one. Such a device treats every
peer as classical and records no pin — accepting a
signature it cannot verify would be worse than admitting the limit. It
is otherwise unaffected, because §20.2 makes an absent identity a
supported state.
CryptoKit and the vendored @noble implementation were checked against each other: they derive the same public key from the same seed (FIPS 204 keygen is deterministic in the seed) and each verifies the other's signatures. Had they not, a device restoring on the other platform would have published a different identity and every contact would have read it as a substitution.
The web engine mints a §20 seed at device creation, publishes the
identity on registration, verifies the signature before establishing a
session, and refuses with PQDowngradeError,
PQVerificationError or PQIdentityChangedError
rather than falling back quietly. Covered by
test/client.mjs (a stripped identity and a forged signature
are both refused) and test/relay.mjs (the signature is
verified over a bundle the real relay served).
iOS and Android implement none of this. They are
unaffected — ik_pq is optional and absent devices verify
classically — but a conversation is only post-quantum-authenticated
where both ends are web.
21. Anonymous sender credentials
21.1 What exists, and what it does not do
Sealed submission (§14) is gated by a delivery key: a 32-byte secret the recipient issues, whose SHA-256 the relay stores. A sender presents the raw key, the relay hashes and compares, and accepts without learning who sent. Rotating the key is how a recipient cuts off a sender.
The limitation is precise: the key is a shared secret. One key goes to every contact, so cutting off one person means rotating for everyone and redistributing to all the rest. In practice that means it is rarely done, which means the anti-abuse lever is theoretical.
21.2 Why the obvious fix is a trade, not a win
The natural repair is a distinct delivery key per contact: revoke one digest, and only that contact is cut off. It needs no new cryptography.
It also makes the metadata worse. Today the relay sees "somebody holding Bob's key wrote to Bob." With per-contact keys it sees "the holder of Bob's key #3 wrote to Bob" — a stable pseudonym per sender, which lets it distinguish senders it currently cannot, and link every message from one sender to that recipient over time.
That is a real regression in exchange for a real improvement, and it should be recorded as such rather than shipped as an upgrade. It is not implemented for that reason.
21.3 The construction that gets both
Keyed-verification anonymous credentials (KVAC) over Ristretto255 — an algebraic MAC the issuer can verify with its own key, plus a zero-knowledge proof of possession. This is the mechanism Signal uses for the same problem.
Sketch, in the shape this protocol would need:
- Issuance. Alice's client asks the relay for a
credential scoped to Bob, presenting Bob's authorisation. The relay
issues an algebraic MAC over a commitment to
(sender_blinding, recipient_id). - Presentation. Sending sealed, Alice proves in zero knowledge that she holds a valid credential for that recipient — without revealing which credential, and therefore without being linkable to her other sends.
- Revocation. Bob rotates only the attribute that scopes Alice's credential; everyone else's continues to verify.
The relay verifies with a key it holds, so no pairing and no public-key signature scheme is required — which is why KVAC is the right family here rather than blind signatures.
21.4 Why it is not implemented
Implementing algebraic MACs and their proofs correctly is not a weekend of work, and a subtly wrong zero-knowledge proof fails open — it verifies, and nobody notices until someone looks. This project has already produced one bug of that shape in code far simpler (§19.5, an audit path read in the wrong direction that a happy-path test certified as correct).
The prerequisites, in order:
- a vetted Ristretto255 implementation with the constant-time
properties the scheme assumes (the vendored
@noble/curvesprovides the group; the credential scheme is what is missing); - a written proof transcript specification, so two implementations agree on what is being proven;
- review by someone who has implemented this class of protocol before.
Until those exist, §14's shared delivery key stands, with its limitation stated here rather than implied to be solved.
22. Private group membership
22.1 What exists
The group registry (§8.9) is server-side and authoritative. The relay stores the roster and enforces it, so it knows exactly who is in every group. Fan-out is per device, so it also sees the shape and size of every group over time.
This is the largest remaining metadata leak in the protocol. Message contents in a group are end-to-end encrypted; who is in it is not protected at all.
22.2 What it would take
The same primitive as §21, applied to membership rather than sending:
- On joining, a member receives a group membership
credential — an algebraic MAC over a commitment to
(group_id, member_blinding). - Operations against the group (fetching the roster for fan-out, posting, leaving) are authorised by a zero-knowledge proof of holding a valid credential, rather than by the relay looking the member up in a table.
- The relay stores an opaque group state it cannot enumerate, and applies changes it can verify are authorised without learning by whom.
22.3 The part that does not follow from the credential
Fan-out. Envelopes are addressed to devices (§8.4), so even a relay that cannot enumerate a roster still observes which devices receive a group's traffic and can reconstruct membership by correlation. A private roster without addressing the delivery pattern buys less than it appears to.
Closing that requires either per-recipient sealed fan-out with padding and delay, or moving group delivery onto a different addressing scheme entirely. Both are larger than the credential work and neither is designed here.
22.4 Status
Not implemented, and not partially implemented. §8.9 documents the current behaviour as a deliberate deviation rather than an oversight, and this section records what the alternative actually costs.