wellKnown now points at .well-known/funding-manifest-urls inside each repository, per the spec example.
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.
test/bridge-message.mjs: one real message through a live obfs4 bridge, against
a relay on the internet rather than a local one. Two accounts, two devices, a sealed
envelope from this engine, and the recipient decrypting it, with every byte carried over
obfs4. It exists because the claim that bridge users can reach the relay was made, in
writing, while the bridge forwarded to plain HTTP and no app session could complete; a
handshake check and a single GET do not settle it. Needs bridgeforward from
flamenet-elligator2 listening locally, so it skips when nothing is on the port:
go run ./Reference/bridgeforward -line "obfs4 <host:port> <fingerprint> cert=<cert> iat-mode=0"
node test/bridge-message.mjs
FN_PROOF_HOST picks the relay (default relay1.flamenet.io) and FN_PROOF_PORT the
local port. It creates accounts, so point it at a test relay.
test/bridge-peer.mjs: the account on the other side of a message the app sent.
bridge-message.mjs uses this engine on both ends, which leaves the apps untested: their
own socket factory, sign-up, device registration and send are a different code path. The
Android suite BridgeMessageTest drives that path over a live bridge and can only assert
what the app's own screen says. This is the independent half, holding nothing but the
peer's keys:
node test/bridge-peer.mjs create # prints the handle to give the app
node test/bridge-peer.mjs receive # after the app has sent
Proven on 2026-10-14 with the Android app on an emulator through the live bridge:
3 envelopes, message numbers 0,1,2
n=0 control: delivery_key
n=1 control: kt_head
n=2 MESSAGE from user 1613: through a bridge from the app at 88070
Three things that cost time there. Nothing acknowledges a message, so every poll returns
the same envelopes and counting per poll made three look like sixty. The ratchet opens in
order, so respond goes to the envelope with header.n === 0 and the rest decrypt on
that session. And kind: "text" is the message, with the words in caption, so filtering
out everything carrying fnc throws the message away with the control traffic.
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, bridge-message a live bridge and a local forwarder, 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.