Open Free and open source — read the code

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 SK holds 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.mjsE2EClient / 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