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. Two other engines
implement the same document and are checked against this one rather than
trusted; where they disagree, this engine is the tiebreaker.
| Engine | Checked by | State |
|---|---|---|
| iOS (CryptoKit) | test/interop.mjs, both directions |
v1 + post-quantum |
| Android (Kotlin, plain JVM) | test/interop-kotlin.mjs, 43 assertions |
v1 only — no ML-KEM |
Both harnesses compile and drive the apps' own engine sources rather than a reimplementation of them, which is the only version of this test worth running: a driver carrying its own HKDF proves the driver agrees, and the driver is not what runs on a phone.
⚠️ The iOS app deploys to iOS 26, which is when
CryptoKit gained MLKEM768 and MLDSA65. That
floor is load-bearing: lowering it without restoring the
#available gates would not fail to compile, it would go
missing at runtime. 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 published too, as flamenet-relay —
its own repository, with its own pipeline. Self-hosting is not a claim
you have to take on trust: the relay is the same code, published. (It
used to be copied into this repository as
server/; two public copies of one server meant one of them was stale, so now there is one.)
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
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 | v3 | |
|---|---|---|---|
| Key agreement | X3DH (X25519) | X3DH + ML-KEM-768, hybrid | unchanged from v2 |
| KDF info | FlamenetE2E_X3DH_v1 |
FlamenetE2E_X3DH_v2 |
FlamenetE2E_Ratchet_v3 on the ratchet |
| Associated data | 64 B | 96 B (adds a binding over the KEM ciphertext) | unchanged from v2 |
| Ratchet | — | unchanged | every step mixes a fresh ML-KEM secret |
| AEAD, safety numbers | — | unchanged | unchanged |
v3 is the current version (§13). This table stopped at v2 for a while after v3 shipped, which understated what the engine does — the opposite of the failure a page like this usually has, and corrected the same way: by what has been RUN, not by what is present in the source.
What has been run: this engine implements v3; two Android phones negotiate it and have since the 0.8 build; and the iPhone build carries it but has not shipped, so an iPhone in a conversation today is not running a post-quantum ratchet, because there is no iPhone build to run one.
Each version speaks the one below it to peers that do not advertise
higher, so they interoperate during a rollout. 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/.
Contributing and security
- CONTRIBUTING.md — running the suites (no network, no install), and what a change to the protocol has to carry with it.
- SECURITY.md — audit status, threat model, deployment notes. Vulnerabilities go to security@flamenetmessenger.com, never to a public issue.