0.0.11 — wire-format rework (always-staple) + header encryption
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 itsAPQWelcomeuntil the first commit) rides every frame, so any single received frame heals a peer that missed the one before.BUNDLED/PARTIAL/STAPLED_WELCOMEand the bare-MLS send frames are gone. PQ side-band tags are renumbered to0x05–0x11— classify withpq_frame_kind, never raw bytes.TwoMlsPqErrorgainedEpochDesync(a stapled commit more than one epoch ahead — a reconnect condition, distinct from transientDecryptionFailed) andUnexpectedWelcome(a welcome differing from the one a live session joined from; same-welcome re-deliveries stay idempotent).remote_commitis surfaced only on the frame whose staple first applied;prepare_to_encrypt(Some(_))(rotation) returnsSessionNotReadyuntil 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 thepq_*_beginreturns are all sealed). The host removes the seal with the newopen_incoming(blob) -> Option<OpenedFrame { kind, frame }>and routesframebykind(OpenedFrameKind:Message→process_incoming;PqSideBand { PqFrameKind }→ the namedpq_*method);Nonemeans no key opened it (the reconnect signal).process_incomingand thepq_*receivers also open a sealed blob transparently, soopen_incomingis 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 bypq_epochso side-band protection tracks the PQ ratchet's independent cadence. - v8 (#47) — initiate-side envelope.
initiategains anapp_payload: Option<Vec<u8>>parameter and now returns its initial frame viapending_outboundalready 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 newTwoMlsPqInvitation::open_initial(blob) -> InitialFrame { app_payload, welcome }opens it (decrypt-only, does not consume the invitation), replacing the rawhpke_open+ manual compose the host did before. The main receive path isopen_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
0x05–0x11. 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