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 frozen enum e2e_rc error 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:

  1. Submodule. adapt-e2e-core is a git submodule under src/external/adapt-e2e-core, pinned to a reviewed revision.
  2. Build. setup.sh builds 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, …).
  3. Link. CMake adds the include and link directories and links the static library into the native targets.
  4. 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 only std::vector<uint8_t> and the C ABI — it has no dependency on the rest of the core, so it is unit-tested in isolation.
  5. MUFL primitives. A new domain_e2e primitive 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.
  6. 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-advancing encrypt); 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_inbound returns 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 kind is 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-core ships 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 no getrandom/OsRng on 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. The pickle_key that 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.