flamenet-e2e
The end-to-end encryption engine behind FlameNet Messenger — X3DH + Double Ratchet with an optional hybrid post-quantum key agreement, in dependency-free JavaScript that runs in a browser or in Node.
This is the reference implementation of docs/SPEC.md. The iOS (CryptoKit)
engine implements the same document and is checked against this one in
both directions by test/interop.mjs; where they disagree,
this engine is the tiebreaker. An Android engine does not exist yet.
⚠️ iOS post-quantum requires iOS 26+, because that
is when CryptoKit gained MLKEM768. Older devices speak v1.
See §12.11 of the spec.
What it is
- X3DH asynchronous key agreement, Double Ratchet for per-message forward secrecy and post-compromise security.
- v2 adds hybrid post-quantum key agreement —
ML-KEM-768 (FIPS 203) mixed into the X3DH secret alongside the classical
X25519 DHs. Hybrid means
SKholds if either primitive holds; an ML-KEM failure degrades to v1's classical security rather than to nothing. - Per-device identities, safety numbers, identity pinning, and signed capability advertisement with per-device downgrade pinning. A device that has proved it speaks v2 can never be negotiated back down to v1 by anything the relay says.
- No dependencies at runtime. X25519, Ed25519, HKDF-SHA256 and AES-256-GCM come from WebCrypto. ML-KEM-768 uses the platform's when it has one and a single vendored bundle otherwise.
- Post-quantum ratchet (v3) — every ratchet step mixes a fresh ML-KEM secret, so post-compromise recovery is post-quantum too, not just session establishment.
- Sealed sender — the relay stores no sender identity for a sealed message.
- Encrypted backups and multi-device sent-copies, both client-side only: there is no server route for either, by design.
- The server is here too, in
server/. Self-hosting is not a claim you have to take on trust — the relay is the same code, published.
What it is not
This code has not been independently audited.
Neither has the vendored ML-KEM implementation it can fall back to (see
vendor/PROVENANCE.md).
It was reviewed internally in August 2026 and has a conformance suite,
which is not the same thing as an audit. Read SECURITY.md before deploying it for
anyone whose safety depends on it.
Specifically, it does not provide: post-quantum authentication (identities are still Ed25519), sealed sender, encrypted backups, multi-device history sync, or reproducible builds. A relay running this protocol still sees who messages whom and when.
Layout
server/ the relay: a standalone Swift service (see server/README.md)
src/e2e.js the engine — X3DH, PQX3DH, Double Ratchet, sessions, safety numbers
src/mlkem.js ML-KEM-768: native WebCrypto first, vendored fallback second
src/e2e-client.js REST client + manager — pinning, trust, registration, replenishment
src/e2e-bootstrap.js page wiring
src/e2e-vault.js encrypted IndexedDB store for keys and ratchet state
vendor/ bundled @noble/post-quantum, MIT, rebuildable via scripts/build-vendor.sh
test/ five suites; run them before trusting anything here
docs/SPEC.md the protocol contract — §12 is the post-quantum delta
Use
import { DeviceIdentity, E2ESession } from './src/e2e.js';
// One-time, per device.
const me = await DeviceIdentity.create();
await me.generateOneTimePreKeys(100);
await me.generatePQOneTimePreKeys(100); // keep both pools the same size
// Publish: ikDH.pub, ikSig.pub, signedPreKey.pub + signedPreKeySignature(),
// pqSignedPreKey.publicKey + pqSignedPreKeySignature(), capabilitySignature(),
// and the two prekey arrays.
// Start a session from a peer's bundle. `minProto` is what you pinned for that
// device: pass 2 once you have seen it advertise v2, and a stripped bundle then
// throws `protocolDowngrade` instead of quietly establishing v1.
const session = await E2ESession.initiate(me, peerBundle, minProto);
const envelope = await session.encrypt('hello');
// The other side:
const theirs = await E2ESession.respond(me, envelope);
await theirs.decrypt(envelope);
Sessions and identities serialize with archive() /
fromArchive(). Both hold secret key material — persist them
in an encrypted store, never in localStorage.
Tests
npm test
Two suites.
test/conformance.mjs — the engine
alone: backend agreement, v1 regression, PQX3DH establishment, prekey
exhaustion, tamper rejection, downgrade refusal, prekey consumption,
reload persistence, and a locked known-answer vector. Node ≥ 20 for
WebCrypto X25519/Ed25519; Node ≥ 25 additionally exercises native ML-KEM
and cross-checks it against the vendored implementation.
test/client.mjs —
E2EClient / E2EManager against a deliberately
hostile in-process relay: one that strips PQ fields
from bundles and forges capability signatures. Covers protocol-version
pinning and its monotonicity, downgrade refusal, upgrade of a device
that registered before v2, prekey replenishment from the server's
counts, and the rule that accepting an identity change must not reset a
protocol pin. The real server will not attack you, so this is the only
place that behaviour can be tested.
test/relay.mjs — the engine against the
real relay, over HTTP. It boots the compiled binary and
drives §8 against it: registration, bundle fetch, send and poll, the
sealed-sender delivery-key capability, device revocation, and TURN. The
negative cases are the point — a forged proof, a replayed proof, a token
minted for another relay, a token used against a device it was not
scoped to, a wrong delivery key, and path traversal on an attachment id.
This is the test that catches a client and a server disagreeing about
the wire format, which is the one bug this protocol has actually
shipped. Skips cleanly when the binary has not been built.
test/interop.mjs — the iOS
engine against this one, both directions. The Swift driver
(test/interop/) is compiled by
interop/build.sh from the iOS app's own
Sources/E2E/*.swift, so it is the real engine rather than a
reimplementation: if the two drift apart, this fails instead of a user's
phone. Covers both handshake directions, identical associated data, the
last-resort prekey path, tamper rejection and safety-number agreement.
Skips cleanly off macOS or without the app checkout
(FNMSG_APP_DIR overrides the default
~/Code/flamenetmessenger).
Server-side behaviour is covered by the relay's own Swift test target
alongside test/relay.mjs.
Protocol versions
| v1 | v2 | |
|---|---|---|
| Key agreement | X3DH (X25519) | X3DH + ML-KEM-768, hybrid |
| KDF info | FlamenetE2E_X3DH_v1 |
FlamenetE2E_X3DH_v2 |
| Associated data | 64 B | 96 B (adds a binding over the KEM ciphertext) |
| Ratchet, AEAD, safety numbers | — | unchanged |
A v2 client speaks v1 to peers that do not advertise v2, so the two
interoperate during rollout. Policy.pqMode = 'required'
disables that fallback once every client has migrated; until then,
downgrade resistance rests on per-device pinning, and no post-quantum
claim should be made to users. See §12.7.
Licence
MIT — see LICENSE. Vendored ML-KEM
is MIT (Paul Miller); its licences are in vendor/.