Olm (X3DH-class handshake + Double Ratchet) provided by a reused, vendored Rust engine over a C ABI; the ADAPT core holds the state; MUFL is a thin primitive layer.
0. Status & scope
What this is. Signal-style end-to-end encryption is available in the ADAPT core: an X3DH-class asynchronous handshake (over parked one-time keys, with a fallback key for exhaustion) followed by a Double Ratchet with a skipped-message-key store. It is exposed to MUFL as a small set of _e2e_* primitives.
Honest status. The integration and its engine have been hardening-reviewed (an independent external security review, plus the engine’s own adversarial test suite and this integration’s boundary/functional tests). It has not yet undergone a formal third-party audit. Do not treat it as audit-certified.
Architectural stance. The entire cryptographic protocol — handshake and ratchet — lives in the reused engine. MUFL contains no protocol state machine; it exposes only a thin control surface (account/one-time-key lifecycle, session establishment, encrypt, decrypt, session id, re-delivery check). All handshake/ratchet state crosses the boundary as opaque pickled blobs that the ADAPT core persists in MUFL packet-state.
1. The engine: adapt-e2e-core
The cryptographic engine is adapt-e2e-core — a standalone, open-source, pure-Rust crate:
- Public:
github.com/adapt-toolkit/adapt-e2e-core, Apache-2.0. - A vendored, pinned fork of vodozemac (the Least-Authority-audited Olm engine) with one behavioural change: keygen is threaded from an injected 32-byte seed (a ChaCha20 CSPRNG) instead of an OS RNG, plus a thin management layer.
- Stateless. It holds no long-lived state — no statics, no thread-locals, no internal RNG. Every call is a pure function
f(state, seed, msg) → (state', out): an opaque pickled blob in, a new pickled blob out. The consumer owns and persists state. - Deterministic + getrandom-free. Because every keygen consumes the caller’s seed, the engine draws no OS entropy; identical
(state, seed, msg)yields byte-identical output. This is what makes it embeddable in ADAPT’s deterministic, replayable packet model (§5) and buildable bare-metal. - C ABI. Ten
extern "C"functions (e2e_account_create,e2e_account_gen_otks,e2e_account_gen_fallback,e2e_account_bundle,e2e_session_outbound,e2e_session_inbound,e2e_encrypt,e2e_decrypt,e2e_session_id,e2e_matches_inbound), a frozenenum e2e_rcerror set, and a two-call length convention (call with a NULL out-buffer to learn the length, then again to fill it). The header is generated with cbindgen and committed.
Two blob kinds cross the ABI: an account pickle (identity keypair, one-time keys, fallback key) and a session pickle (the full Double-Ratchet state, including the skipped-message-key store). Both are wrapped in a self-describing, AEAD-protected envelope bound to a caller-supplied 32-byte pickle_key.
2. Integration architecture
The engine is wired into the ADAPT monorepo natively:
- Submodule.
adapt-e2e-coreis a git submodule undersrc/external/adapt-e2e-core, pinned to a reviewed revision. - Build.
setup.shbuilds it as a cached external dependency — a native static library (libadapt_e2e_core.a) plus the generated C header — alongside the other vendored libraries (libsodium, gmp, …). - Link. CMake adds the include and link directories and links the static library into the native targets.
- C++ wrapper. A thin binding (
src/eval/e2e_core.{h,cpp}) implements the two-call length convention over the ten C-ABI functions and maps every non-OK return code to a typed error. The wrapper deliberately speaks onlystd::vector<uint8_t>and the C ABI — it has no dependency on the rest of the core, so it is unit-tested in isolation. - MUFL primitives. A new
domain_e2eprimitive collection (_e2e_*) bridges MUFL binaries to the wrapper: it pops opaque blobs and public-key material, draws the keygen seed from packet entropy, calls the wrapper, and returns the new blobs. No key material is interpreted in the MUFL layer — everything is opaque bytes. - State in packet-state. The pickled account/session blobs live in MUFL packet-state (module state, the same
m_*map pattern used by the existing key store). The caller threads the returned blob back on the next call; the ADAPT core’s deterministic, Merkle-sealed packet state provides atomic persistence and integrity.
The non-native build lanes (wasm, RISC-V, iOS, Android) are gated out at compile time until their toolchains are wired; the crate itself is no_std-clean and builds on those targets consumer-side.
2.1 The MUFL primitive surface
| Primitive | Purpose |
|---|---|
_e2e_account_create pk |
Create an account (identity keypair) → account pickle |
_e2e_account_gen_otks acct n pk |
Generate n one-time keys → account pickle |
_e2e_account_bundle acct pk |
Emit the public prekey-bundle material |
_e2e_session_outbound acct ik otk pk |
Start an outbound session → {session, account} |
_e2e_session_inbound acct ik prekey pk |
Establish an inbound session from a pre-key message → {session, account, plaintext} |
_e2e_encrypt session pt pk |
Encrypt → {msg, msg_type, session} |
_e2e_decrypt session msg_type msg pk |
Decrypt → {plaintext, session} |
_e2e_session_id session pk |
The session’s 32-byte id (pure read) |
_e2e_matches_inbound session prekey pk |
Idempotent PRE_KEY re-delivery detection (1/0) |
Every keygen-bearing primitive draws its 32-byte seed from the packet’s injected entropy; every primitive validates fixed-length key inputs and surfaces engine errors as MUFL evaluation errors. Pickled blobs are opaque; the caller keeps them in packet-state.
3. Protocol
The protocol is standard Olm, provided by the engine:
- Identity & prekeys. Each account has a long-term identity keypair (Curve25519 + Ed25519), a set of parked one-time keys, and a fallback key for one-time-key exhaustion. The public material is published as a prekey bundle; the host signs the bundle and binds it to an address (root→identity binding is host policy, not engine crypto).
- X3DH-class handshake. An initiator fetches the peer’s bundle and creates an outbound session (a 3DH over the peer’s identity + one-time keys and its own ephemeral). The first message is a PRE_KEY message carrying the material the responder needs to establish the matching inbound session; establishing it consumes the one-time key.
- Double Ratchet. After the handshake, messages advance a symmetric-key ratchet, with a DH ratchet step whenever the direction changes. A bounded skipped-message-key store allows out-of-order and delayed delivery; beyond the bound, superseded keys are evicted (forward secrecy).
- Framing. Messages are typed PRE_KEY vs normal; the ciphertext is carried inside a MUFL transaction as opaque bytes. ADAPT’s existing outer Ed25519 signature (bound to the root address) is retained as the authenticity layer.
4. Caller contracts — and how ADAPT fulfills them
Because the engine is a pure, stateless function f(state, seed, msg) → (state', out), three security invariants that a stateful library would enforce internally cannot be enforced by the engine. The engine is deliberately honest about this: it documents them as caller obligations (docs/CALLER-CONTRACTS.md) and ships a boundary test that drives each one to its reachable break, proving the obligation is real and external.
ADAPT is the caller, and it fulfills all three. Each is verified by a functional/boundary test in the ADAPT core, including a negative witness demonstrating the break is reachable if the obligation is not met.
4.1 Seed uniqueness — a distinct entropy window per entropy-bearing call
- Obligation: supply a fresh, distinct seed to every entropy-bearing call (keygen,
outbound, DH-ratchet-advancingencrypt); never feed the same seed to two different operations. (Re-supplying the same seed to reproduce the same committed message — a deterministic replay — is required and safe.) - How ADAPT fulfills it: the seed is drawn from the packet’s per-transaction injected entropy via
Packet::useEntropy, which is an advancing keystream — each draw returns a distinct, non-overlapping 32-byte window, seeded per transaction. Two entropy-bearing operations in the same transaction therefore receive different seeds, while a replay of the same transaction re-derives the identical stream (determinism-under-replay). - Verified: the functional suite asserts two account-creations in one transaction differ (distinct windows), and the wrapper tests assert that a given seed drives keygen (same seed → identical output; a different seed → different output — the reachable-break witness that the seed genuinely controls the ephemeral).
4.2 One-time-key one-time-ness — persist the consumed account
- Obligation: after a successful inbound handshake, atomically persist the returned account pickle (with the used one-time key removed) and use only it thereafter; never reuse a pre-consumption pickle.
- How ADAPT fulfills it:
_e2e_session_inboundreturns the mutated account pickle, and the ADAPT core stores it in MUFL packet-state atomically (Merkle-sealed per transaction). The next inbound uses the persisted, post-consumption account. - Verified (both directions): reusing the same one-time key against the persisted (mutated) account is refused by the engine (
SESSION_MISMATCH); the negative-witness test shows that reuse against a stale pre-consumption pickle would succeed — which is exactly why persistence is mandatory, and why ADAPT’s packet-state persistence is the property that upholds one-time-ness.
4.3 Pickle persistence + integrity — persist the returned state, feed back untampered
- Obligation: after each state-advancing call, persist the returned pickle and discard the prior one (atomic, no rollback); keep pickles under integrity protection and feed back only untampered blobs.
- How ADAPT fulfills it: pickles live in MUFL packet-state, which is snapshot/Merkle-sealed and integrity-protected; the caller threads the freshly returned blob forward. The engine additionally defends the blob itself — the pickle body is AEAD (encrypt-then-MAC) so tampering is rejected on load, and the envelope
kindis checked so an account pickle cannot be confused for a session pickle. - Verified: the suite rejects tampered, truncated, empty, garbage, and wrong-kind pickles; and demonstrates that a stale-yet-valid pickle (correct MAC, old state) is only prevented by the caller’s persistence discipline — which packet-state provides.
The engine cannot provide these three properties without holding mutable, durable state, which would break the purity that its determinism and bare-metal guarantees depend on. ADAPT supplies exactly the missing piece — durable, atomic, integrity-protected state — in the packet model.
5. Determinism & packet-state
MUFL packet execution is deterministic and replayable, and external entropy is injected per transaction (CarryEntropy, Packet::useEntropy). This composes cleanly with the engine’s seed-injection design:
- Every keygen seed comes from the injected per-transaction entropy stream — never from an OS RNG — so a re-executed transaction re-derives identical keys and ciphertext (replay is byte-identical reproduction, not a key-reusing rewind).
- Session/account pickles are ordinary packet-state, so ratchet advancement is captured by the normal snapshot/restore machinery; there is no separate, out-of-band crypto state to reconcile.
- Distinct transactions receive distinct entropy, so genuinely-new ratchet steps draw fresh ephemerals (the seed-uniqueness contract, §4.1).
6. Testing & verification
- Engine:
adapt-e2e-coreships known-answer vectors, cross-implementation interop against upstream vodozemac/libolm, determinism-under-replay goldens, an adversarial retry-vs-replay entropy guard, an RNG-isolation gate (asserts nogetrandom/OsRngon the crypto path), fuzzing over the C ABI, and boundary tests for the three caller contracts. - Integration (ADAPT core): a C++ wrapper test drives the full handshake → encrypt → decrypt round-trip, a bidirectional reply, determinism, session-id distinctness, re-delivery detection, and the beyond-skipped-key-bound eviction case. A MUFL functional suite exercises the primitives end-to-end: happy-path round-trip, distinct session ids, re-delivery detection, distinct entropy windows, out-of-order/skipped-key delivery, and a comprehensive set of reject cases (tampered/truncated/empty/garbage/wrong-kind pickle, bad-length and bad-type inputs, cross-session decrypt, replay, and one-time-key reuse).
- External review: an independent security-hardening review confirmed the retry-vs-replay entropy design is structurally sound (per-transaction, advancing distinct windows; attempts to force a reuse break failed) and that the getrandom-severance is sound.
7. Deferred / follow-ups
- Identity-bound
pickle_key. Thepickle_keythat binds each pickle envelope is currently supplied by the caller; deriving it from the host root key (so pickles are cryptographically bound to the identity) is a tracked follow-up and must land before these primitives are used from shipped MUFL beyond tests. - Non-native build lanes. wasm / RISC-V / iOS / Android are gated out until their toolchains are wired; the engine is
no_std-clean and builds on those targets. - Formal audit. The integration is hardening-reviewed; a formal third-party audit is future work.
- Transport/policy. Prekey publication/fetch over the broker, anti-downgrade capability pinning, and historical-state destruction are host-side concerns layered on top of the primitives.