Skip to content

0.0.11 — wire-format rework (always-staple) + header encryption

Choose a tag to compare

@germ-mark germ-mark released this 10 Jul 18:15
39c0111

Integration build for downstream (AbstractTwoMLS, germDM feat/PQ) — built straight from main at 39c0111 (PRs #46 and #47 merged; no open PRs). Follows 0.0.10, which was the first release off main.

Built with Apple CryptoKit backing both halves (--features cryptokit), Xcode 27 beta 3 toolchain.

⚠️ FFI surface changed — binding and binary MUST pair

Take the attached two_mls_pq.swift from this release; a 0.0.10-era binding against this binary fails fast. AbstractTwoMLS's bindingContractVersion() assertion at first client/invitation construction catches a stale pairing immediately, and uniffi's own load-time checksum rejects a mismatched binding outright.

Binding contract 5 → 8 (three increments since 0.0.10):

  • v6 (#46) — wire format v2. The message path collapses to one self-healing frame (0x03 = [staple][proposal][app]): the sender's latest send-group commit (or its APQWelcome until the first commit) rides every frame, so any single received frame heals a peer that missed the one before. BUNDLED/PARTIAL/STAPLED_WELCOME and the bare-MLS send frames are gone. PQ side-band tags are renumbered to 0x050x11 — classify with pq_frame_kind, never raw bytes. TwoMlsPqError gained EpochDesync (a stapled commit more than one epoch ahead — a reconnect condition, distinct from transient DecryptionFailed) and UnexpectedWelcome (a welcome differing from the one a live session joined from; same-welcome re-deliveries stay idempotent). remote_commit is surfaced only on the frame whose staple first applied; prepare_to_encrypt(Some(_)) (rotation) returns SessionNotReady until a peer frame has been processed.
  • v7 (#47) — header encryption. Every rendezvous-channel frame now leaves the library sealed — an opaque [nonce][AEAD ct+tag] blob over the whole frame, carrying no plaintext tag, group id, epoch, content type, or Welcome metadata (EncryptResult.cipher_text, pending_outbound, pq_take_pending_outbound, and the pq_*_begin returns are all sealed). The host removes the seal with the new open_incoming(blob) -> Option<OpenedFrame { kind, frame }> and routes frame by kind (OpenedFrameKind: Messageprocess_incoming; PqSideBand { PqFrameKind } → the named pq_* method); None means no key opened it (the reconnect signal). process_incoming and the pq_* receivers also open a sealed blob transparently, so open_incoming is strictly required only to route side-band frames. Two key families seal the two streams on their own clocks: the message path under a classical-half exporter at the classical epoch, the PQ side-band under a PQ-half exporter keyed by pq_epoch so side-band protection tracks the PQ ratchet's independent cadence.
  • v8 (#47) — initiate-side envelope. initiate gains an app_payload: Option<Vec<u8>> parameter and now returns its initial frame via pending_outbound already HPKE-enveloped — [app_payload ∥ APQWelcome_A] sealed to the peer's KP′ — so the app-layer welcome that identifies the initiator is hidden on the invitation channel. The new TwoMlsPqInvitation::open_initial(blob) -> InitialFrame { app_payload, welcome } opens it (decrypt-only, does not consume the invitation), replacing the raw hpke_open + manual compose the host did before. The main receive path is open_initial → validate → receive.

⚠️ Persisted state is not portable across pre-release builds

The session archive layout changed (SESSION_ARCHIVE_VERSION → 4: the always-staple bookkeeping and both header-key windows now ride the archive; the invitation archive gained the processed-welcome ledger). Old archives fail to decode — regenerate all persisted sessions and invitations from this release rather than loading 0.0.10-era archives.

What's new since 0.0.10

  • Wire-format rework: always staple the commit (#46). One message frame that self-heals after a dropped frame; welcomes are idempotent via bookkeeping (the session records the digest it joined from; the invitation keeps a processed-welcome ledger) rather than assumed once-only; rotation is folded into an ordinary commit (no dedicated frame kind) and gated so a unilateral commit can never displace a welcome staple the peer still needs; the PQ side-band is retagged 0x050x11. New fuzz target for the message-frame decoder.
  • Header encryption: symmetric steady-state layer + initiator envelope (#47). Removes the plaintext MLS framing that leaked group id, epoch, content type, and Welcome metadata on the rendezvous channel — every outbound frame is one opaque sealed blob, opened via trial decryption over a bounded per-epoch window. Two key families (classical message path, PQ side-band keyed by pq_epoch) so each header key tracks the cadence of the frames it protects; the header AEAD is a single configurable choice. The initiator's first frame — previously the one plaintext exit — is closed with the shipped §A.1 HPKE envelope (PQ-only; the ML-KEM-only trade-off and the reverse classical→PQ hybridization are documented as an open question).

Carried over from 0.0.10: cipher-suite binding, last-resort/single-use invitation lifecycle, total self-contained session archive/restore, Principal terminology, provider-agnostic core (both halves on CryptoKit; awslc remains the portable/Linux-CI configuration), the raw-digest FFI convention, and the bindingContractVersion() canary.

Integration

.binaryTarget(
    name: "TwoMLSPQrs",
    url: "https://github.com/germ-network/TwoMLSPQ/releases/download/0.0.11/TwoMLSPQ.xcframework.zip",
    checksum: "52d1571bf8df136dd590f20c5498fd367091e6a710b7c1f073c4146e226ef9de"
)

Copy the attached two_mls_pq.swift into your wrapper target from this same release, and bump the consumer's expectedBindingContract to 8.

🤖 Generated with Claude Code