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. The iOS (CryptoKit) engine implements the same document and is checked against this one in both directions by test/interop.mjs; where they disagree, this engine is the tiebreaker. An Android engine does not exist yet.

⚠️ iOS post-quantum requires iOS 26+, because that is when CryptoKit gained MLKEM768. Older devices speak v1. 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 here too, in server/. 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, 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

server/              the relay: a standalone Swift service (see server/README.md)
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
Key agreement X3DH (X25519) X3DH + ML-KEM-768, hybrid
KDF info FlamenetE2E_X3DH_v1 FlamenetE2E_X3DH_v2
Associated data 64 B 96 B (adds a binding over the KEM ciphertext)
Ratchet, AEAD, safety numbers unchanged

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