extant2000git d254ac2566
ci / ratchet (push) Successful in 5s
ci / complete (push) Successful in 3s
ci / spec-counts (push) Successful in 30s
ci / secret-sweep (push) Successful in 5s
ci / vault (push) Successful in 3s
ci / conformance (push) Successful in 9s
ci / doc-references (push) Successful in 3s
ci / client (push) Successful in 1m4s
ci / analysis (push) Successful in 1m27s
Prove the repository URLs with a file in the repository (#86)
wellKnown now points at .well-known/funding-manifest-urls inside each repository, per the spec example.
2026-10-15 22:43:28 +00:00
2026-10-14 10:05:52 -04:00

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 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.

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

S
Description
X3DH + Double Ratchet with hybrid ML-KEM-768 key agreement. Dependency-free, WebCrypto-based. Reference implementation of the FlameNet Messenger E2E spec.
Readme MIT
5.5 MiB
Languages
JavaScript 93.2%
Swift 3%
Python 2.1%
Shell 1.7%