flamenet-e2e
X3DH + Double Ratchet with hybrid ML-KEM-768 key agreement. Dependency-free JavaScript, WebCrypto only, runs in a browser or in Node 20+.
This is the reference implementation of docs/SPEC.md, the engine behind
Flamenet Messenger: https://flamenetmessenger.com.
Check it yourself
git clone https://git.extant2000.com/extant2000/flamenet-e2e && cd flamenet-e2e
npm test
274 passed, 0 failed
No install step, no dependencies to fetch, nothing to trust before it runs.
Two other engines implement the same spec and are tested against this one:
| Engine | Test | Coverage |
|---|---|---|
| iOS (CryptoKit) | test/interop.mjs, 92 assertions, both directions |
v1 + post-quantum |
| Android (Kotlin, plain JVM) | test/interop-kotlin.mjs, 56 assertions |
v1 + post-quantum |
| iOS ⇄ Android directly | test/interop-swift-kotlin.mjs, 77 assertions |
v1 + post-quantum |
The Android row read 43 assertions and
v1 only, no ML-KEM until 2026-09-21, by which time the
harness had driven a v3 session in both directions for some time. It was
a count that stopped being true rather than a claim that was never true,
which is the failure this file warns about two sections down, committed
here.
Those harnesses compile and drive the apps' own engine sources, not a reimplementation of them. A driver carrying its own HKDF only proves the driver agrees with itself, and the driver is not what runs on a phone. Where the three disagree, this engine is the tiebreaker.
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.
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. That
is not an audit. Read SECURITY.md
before deploying it for anyone whose safety depends on it.
It does not provide post-quantum authentication (identities are still Ed25519), or reproducible builds. A relay running this protocol still sees who messages whom and when.
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.
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/ run them before trusting anything here; the four CI runs need no network,
`npm test` adds the ones wanting a live relay and a Swift toolchain
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
Seven suites, four of which need nothing but node and are the four
this repository's pipeline runs: conformance,
ratchet, client, vault. The three
described below them need a relay binary, a Swift toolchain, or
both.
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).
test/ratchet.mjs: §13, the post-quantum
ratchet: lockstep advance with a distinct key per step, repetition until
the chain is answered, out-of-order delivery, refusal of a stripped
ratchet step, and survival of a reload mid-conversation.
test/vault.mjs:
src/e2e-vault.js, which holds the device identity, the
prekey pool and the decrypted thread buffers. The sealing key is
non-extractable and stays that way through a round trip into IndexedDB,
an altered record reads as absent instead of as data, one user's vault
cannot open another's, and the localStorage migration takes the
plaintext with it.
test/eddsa-session.mjs: sessions
established against a relay that authenticates with Ed25519.
The rest of test/ is run deliberately rather than on
every change, because each needs something npm test cannot
assume: federation and multihoming want a
second relay, interop-kotlin and
interop-swift-kotlin an Android toolchain,
fleet and load a throwaway stack, and
relay-today a relay built with the Today file route
(§8.12).
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 | n/a | unchanged | every step mixes a fresh ML-KEM secret |
| AEAD, safety numbers | n/a | 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; the iPhone build carries it and is installable as a TestFlight beta, but two iPhones negotiating v3 has not been exercised on real hardware, so this page does not yet claim it. What is written here is what has been run, and a build existing is not the same as a pairing having been observed.
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.