Releases: germ-network/TwoMLSPQ
Release list
v0.4.1
Patch Changes
-
#66
0c526e0Thanks @germ-mark! - Fix the agent-handoff binding context so cross-endpoint handoffs validate.An agent handoff is signed by the sender against its
proposal_context
(SHA-256 of its recv group's classical id) and validated by the receiver against
thecontextthatprocess_incomingstamps on theQueuedRemoteProposal. That
stamp used the receiver's recv group id — but the two endpoints' recv groups
are distinct MLS groups (A's recv is B's send), so the values never matched and
every cross-endpoint handoff signature failed to validate. It stayed latent
because the only prior consumer never readproposal_context; the first consumer
that does could not complete its first agent rotation (a Signature-validation
failure that cascaded to a dropped decrypt).Stamp the queued proposal's context from our send group's classical id — the
reverse channel, which is the sender's recv group — so sign and validate agree.
Also correcttest_proposal_hash_is_digest_of_the_staple_both_sides_agree_on,
which asserted the receiver's context equalled the receiver's own
proposal_context(trivially true under the bug); it now asserts equality with
the sender's, the contract that actually gates handoff validation.
v0.4.0
Minor Changes
-
#64
d3f33efThanks @germ-mark! - §A.1 pre-establishment initiator sends (binding contract 16; archive versions reset to the
pre-release floor).The initiator can now send app messages immediately after
initiate, before the
acceptor's return welcome exists (architecture-diagrams 08-twoMLSPQ-APQ §A.1) —
previouslyprepare_to_encryptreturnedSessionNotReadyuntil both groups were
established, on live and restored sessions alike. Pre-establishment,
prepare_to_encryptis a no-op round (proposal_messageempty;proposal_hashis
the WELCOME digest — the documented carve-out on the v14 guarantee) andencrypt
emits a fresh §A.1 envelope per frame (contract 16 atop v0.3.0 AppBinding —initiatekeepsapp_bindingand losesapp_payload), HPKE-sealed to the retained peer KP′,
re-stapling the establishment sections plus the app message — any single frame lets
the invitation holder join and read it.Envelope wire v2: tagged
[0x15][u32 kem_len][kem][ct]; plaintext is four optional
u32-LE length-prefixed sections[app_payload][welcome][return_kp][stapled_message]
under the either/or rule — a hostapp_payloadis establishment-SELF-SUFFICIENT
(carries the welcome + return key package inside) and replaces the bare sections.
initiatelost itsapp_payloadparameter (a payload that signs over the welcome
cannot exist beforeinitiatereturns); attach with the new
set_initial_app_payload/set_initial_return_key_package(initiator-only,
pre-establishment-only; capture AFTER attaching — the retained state rides the
archive, so a birth-captured replier restores send-ready). New read-only
initial_welcome();InitialFramereshaped (all four sections,welcomenow
optional); new exporteddecode_initial_plaintext. Replay-stable token/dedup keying:
the stable prefix (app_payloadwhen present, elsewelcome); all consequential
state keys off the signed, JOINED welcome — the other sections are unauthenticated
routing hints. The stapled app message is[0x13][classical PrivateMessage], handed
toprocess_incomingafter the join. Establishment clears the retained state.Archive layout versions reset to the pre-release floor (SESSION_ARCHIVE and INVITATION
both → 1 — the accumulated ladders carried no compatibility value pre-release; history
stays in git): ALL persisted sessions and invitations regenerate, fail-closed
(ArchiveInvalid). The v0.3.0 key-package WIRE cut (KP v3, a published artifact) is
untouched. Composes
with v0.3.0 AppBinding: the binding rides the welcome every pre-establishment frame
re-staples, soreceive(expected_app_binding:)verifies it on a join from any frame.
v0.3.0
Minor Changes
-
#62
b319e26Thanks @germ-mark! - AppBinding — an optional app-state binding welded into a session at creation and immutable for its lifetime. A TwoMLS session is definitionally bound to its two agents, but agents are mutable (the rotation/candidate lifecycle); the newAppBindingGroupContext extension (0xF0A2, the APQInfo mechanism) binds the session to the app's immutable relationship identity:initiategainsapp_binding: Option<Vec<u8>>(Swift:appBinding: Data?),receive/acceptgainexpected_app_binding— verified on the joined welcome with an exact, symmetric match (a stripped or unequal binding is a wrong-relationship welcome or downgrade attempt; a binding the caller did not state is never silently accepted) before any invitation state is claimed, so a rejected welcome leaves the invitation fully reusable. The acceptor's return group mirrors the verified binding; the initiator requires the return welcome to carry its own binding back unchanged. The binding lives on the classical halves only — a PQ half smuggling one is rejected at every PQ join — and an empty binding is reserved as invalid (rejected at creation and as an expectation;Noneis the unbound state). Newapp_binding()getter reads it back (it rides the persisted group state, so a restored session's owner re-verifies); new errorAppBindingMismatch. GroupContextExtensions proposals remain outside the TwoMLS operation whitelist — now a deliberately tested guarantee — so the binding can never be rewritten.⚠️ Binding contract 14 → 15 — binding and binary MUST pair. Taketwo_mls_pq.swiftfrom this release (TwoMlsPqErrorgained a variant; a stale pairing mis-reads FFI buffers). Key packages and invitations regenerate: leaves now advertise the AppBinding extension type, soCOMBINER_KEY_PACKAGE_VERSION2 → 3 andINVITATION_VERSION3 → 4 (prerelease hard cut — v2 published key-package blobs and v3 invitation archives are rejected outright; re-pair sessions). Session archives are unaffected: the binding is optional, and existing (unbound) sessions restore and keep working.Adopter guidance: pass a digest, not raw identifiers — the first adopter (germDM) binds
H(domain-tag ‖ role-ordered did:did), sharing its canonicalization with the companion CommProtocol relationship-scoped introduction context so the delegation binding and the session binding cannot drift. The crate never interprets the bytes.
v0.2.0
Minor Changes
-
#60
3478cebThanks @germ-mark! -PrepareEncryptResultgainsproposal_message: Vec<u8>(Swift:proposalMessage: Data) — the raw staged Upd(self) proposal, the exact message the pairedencryptstaples and the peer independently digests.⚠️ Binding contract 13 → 14 — binding and binary MUST pair. Taketwo_mls_pq.swiftfrom this release (a Record shape change; a stale pairing mis-reads FFI buffers). No wire, archive, or semantic change — persisted state carries over.Unblocks the anchor "agent handoff" flow: the app signs over its own
sha256(proposal_message), which equals the same result'sproposal_hashand the receiver's independently derivedQueuedRemoteProposal.digest(cross-side coherence, covered by new tests — including at the establishment moment, before any peer frame). Bytes and digest come from the same critical section, so there is deliberately NO staged-slot getter: a decoupled read could return whatever Upd a laterprepare_to_encryptstaged (routine self-refreshes included), and a signature input must not be exposed to that race.
v0.1.0
Minor Changes
-
#57
8115145Thanks @germ-mark! - Push-based persistence; the pullarchive()is removed from the FFI⚠️ Binding contract 12 → 13 — binding and binary MUST pair. Taketwo_mls_pq.swiftfrom this release. Persisted state is not portable:SESSION_ARCHIVE_VERSION→ 9,INVITATION_VERSION→ 3 — regenerate all persisted sessions and invitations.The pull
archive()onTwoMlsPqSessionandTwoMlsPqInvitationis removed from the exported surface. Its contract was a move, not a copy — using the live object after archiving, then restoring, rewound the sender ratchet and re-derived AEAD keys/nonces (security review finding H1: keystream reuse against a real transcript). The crate could not enforce the discipline while the app decided when to pull.The live object now pushes its state to a foreign persistence hook after every state-advancing mutation:
ArchiveSink(with_foreigntrait) withpersist(seq, kind: BlobKind, archive). Attach one per object with the newinstall_sink(pushes a baselineCheckpoint). The contract: enqueue-only, non-blocking, atomically upsert the one blob named bykind(never a multi-object write), newest-seq-wins per slot, and seal the plaintext bytes before writing.- Two-blob session model: a classical mutation rewrites
Core(identity + both classical halves + meta — the ML-KEM ratchet trees omitted); a PQ op (and the baseline) writes a fullCheckpoint. Every mutation is one atomic single-blob push, so the sink needs no cross-object transaction. Restore isTwoMlsPqSession.restore(core, checkpoint)(reconciles PQ-from-checkpoint, rest by higherstate_seq, fails closed on a manifest mismatch). The invitation is monolithic (no ML-KEM trees) and restores withTwoMlsPqInvitation.restore(archive:). Both restore constructors are namedrestore(renamed from the session'sfrom_persistedand the invitation'snew(archive:)) — the name signals materialising from serialised bytes, not minting fresh state. EncryptResult.depends_on_seq+ read-onlystate_seq()on both objects: the app waits until it has durably persisted the frame'sdepends_on_seqbefore transmitting a frame that publishes stored-private-key material (a routine app message re-staples an already-persisted commit, so it imposes no wait). Transmission stays entirely the app's concern.
Internals: the invitation's four mutexes are consolidated into one (removing a torn-archive class);
pq_bootstrap_beginnow persists its pending PQ key package (previously at risk of a restore-time strand). No protocol/wire changes to messages — only persistence and the removed pull surface.
v0.0.13
Patch Changes
-
#55
2324280Thanks @germ-mark! - Fix the iOS XCFramework build (restore the CryptoKit iOS-build fixes)v0.0.12's artifact build panicked in mls-rs-crypto-cryptokit's build.rs ("Libraries require RPath!"). The
germ-shadow-safe-exporterbranch had never picked up the CryptoKit iOS-build fixes the previous pin (3743c75) carried: newer Xcode toolchains reportlibrariesRequireRPathfor varying deployment targets, and that guard is spurious for this artifact — the cdylib ships inside an@rpath/…framework, so rpath-based loading is exactly what is wanted. The bumped mls-rs pin restores those fixes (panic → warning;MIN_IOS_DEPLOYMENT_TARGETstays 17.0, so the bridge still compiles for iOS 17+ deployment). No library code changes; binding contract, session archive, and key package versions are unchanged from 0.0.12 (which shipped no artifacts).
v0.0.12
Patch Changes
-
#53
66d12fbThanks @germ-mark! - draft-ietf-mls-combiner-02 conformance (#51), session modularization (#52)⚠️ Binding contract 8 → 12 — binding and binary MUST pair. Taketwo_mls_pq.swiftfrom this release; uniffi's load-time checksum rejects a stale pairing. Persisted state is not portable:SESSION_ARCHIVE_VERSION→ 8 and the combiner key package framing → v2 — regenerate all persisted sessions, invitations, and published key packages.The
apqcrate and session layer now conform to draft-ietf-mls-combiner-02 (with mls-extensions-08 for the component framework):- APQInfo GroupContext extension (
0xF0A1) in both halves of every APQ group and in Welcomes — written once at creation, verified by joiners (group ids, mode, suite pair). - AppDataUpdate (
0x0008) on both commits of every FULL, attesting the new epochs of both halves; receivers verify both copies against the actual post-commit epochs before any app data is decrypted. - Safe Extensions PSK recipe: the APQ-PSK and cross-party TwoMLS-PSK derive via
SafeExportSecret(component_id)+DeriveSecret(exporter, "psk_id"/"psk")and import aspsk_type = application(3)(components0xFF01/0xFF02); the A.3 injected secret stays an external PSK. Requires the germ-network/mls-rsgerm-shadow-safe-exporterbuild branch (safe_extensionsfeature). - Event-driven cross-party binding: a commit re-binds the peer's group only when it has advanced since the last binding; three epoch watermarks make each
(group, epoch, component)export happen at most once, as the consuming exporter requires. - Combiner key package v2 encloses the -02 §7
APQKeyPackageTLS payload.
Documented extensions beyond -02: A.3 substitutes the injected KEM secret for the PQ updatePath as the PQ-PCS source; A.5 re-keys on the PQ groups alone, reconciling
pq_epochat the next A.3 bind.A security and functional review (wire/versioning, downgrade, crypto/PSK, fork, state machine) found no correctness or security defect; its hardening fixes are included.
session.rsis split into asession/module directory (pure moves; no API change). The book chapters (psk-binding, group-rules, wire-format, cipher-suites) match the shipped recipe. - APQInfo GroupContext extension (
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
0.0.10 — cipher-suite binding, last-resort key packages, total session archive (first release off main)
Integration build for downstream (AbstractTwoMLS, germDM feat/PQ) — built straight from main at 030197e (all PRs merged; #41, #42/#37, #43, #44 and the provider-agnostic landing #36). Unlike 0.0.8/0.0.9 (cut from throwaway integration branches), this is the first release off main now that every open PR has landed.
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.9-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 3 → 5 (two increments since 0.0.9):
- v4 (#41) —
TwoMlsPqErrorgainedCipherSuiteMismatch(a peer key package/welcome carries a suite pair that doesn't match the session's fixed suite);MlsCipherSuite.is_supportedrenamed tois_combiner_pq(the name always meant "is the PQ combiner half", not "is a supported suite");AgentStaterenamed toPrincipalState(part of #44). - v5 (#43) —
TwoMlsPqErrorgainedInvitationSpent(a single-use invitation whose key package was already consumed rejects every furtherreceive, from any remote — distinct from the per-remoteDuplicateWelcome);generate_invitationgained alast_resortflag (that signature change is also caught by uniffi's load-time checksum).
⚠️ Persisted state is not portable across pre-release builds
Archive layouts changed again (total/self-contained session archive, retained key packages folded in). Per the pre-release policy the archive version bytes are intentionally not bumped (still pinned to 1), so old archives are not auto-detected — regenerate all persisted sessions and invitations from this release rather than loading 0.0.9-era archives.
What's new since 0.0.9
- Cipher-suite binding, hardened (#41): the classical+PQ cipher-suite pair is now a fixed, validated property of a session — established once at construction and enforced on every peer key package and welcome. A wrong-suite peer is rejected with
CipherSuiteMismatch, distinct fromPqNotAvailable(peer offers no PQ half) andUnsupportedCipherSuite(a local provider gap). The suite classifier now covers all RFC 9420 suites and thePqNotAvailablediagnostic is corrected. - Last-resort key packages + invitation key-package lifecycle (#43): invitations are explicitly single-use or last-resort (
generate_invitation(last_resort:)). A single-use invitation's key package is consumed on the first accepted session and every laterreceivefails withInvitationSpent; a last-resort invitation never spends. Session key packages self-prune on use (mls-rs delete + store honors it); invitation key packages are erased explicitly. The session archive now carries the client's retained key packages so restore is self-contained. - Total, self-contained session archive/restore (#37, #42): archive/restore is parameterless and total — no external state threaded in at restore, id-based
stage_rotation, retained key packages included. - Principal terminology (#44): the Identity/Agent concept is renamed to Principal, CommProtocol-agnostic (
AgentState→PrincipalState; a terminology bridge is documented on the client). - Session rendezvous (#38) and a rotation listen-window regression test (#39, guarding the 0.0.9 pruning fix).
- Provider-agnostic core fully landed on
main(#36) — both halves run on Apple CryptoKit in this binary (awslcremains the portable/Linux-CI configuration); plus doc-staleness fixes across README/book/benches (#40).
Carried over from 0.0.9: crypto-provider-agnostic core (both halves on CryptoKit), session persistence groundwork, A.5 PQ rekey, invitation forward table (spawn-token routing), raw-digest FFI convention, honest epoch reporting, bindingContractVersion() canary.
Integration
.binaryTarget(
name: "TwoMLSPQrs",
url: "https://github.com/germ-network/TwoMLSPQ/releases/download/0.0.10/TwoMLSPQ.xcframework.zip",
checksum: "f83f43d1d35afadcfc9adbadae290b6c83209c9c53e6680fcaa4d243108a3acf"
)Copy the attached two_mls_pq.swift into your wrapper target from this same release, and bump the consumer's expectedBindingContract to 5.
🤖 Generated with Claude Code
0.0.9 — all open PRs + provider-agnostic core (CryptoKit both halves)
Integration build for downstream (AbstractTwoMLS, germDM feat/PQ) — built from the llm/release-0-0-9-open-prs-dcbd89 integration branch at 0322ec8 (= main + every open PR reconciled: #8, #15, #21, #24/#25 stack continuation #28–#32, #27, #33, #34, #35). Not a release off main.
⚠️ FFI surface changed — binding and binary MUST pair
Take the attached two_mls_pq.swift from this release; a 0.0.8-era binding against this binary fails fast:
- Binding contract 2 → 3 (#35): the error enum gained
UnsupportedCipherSuite. AbstractTwoMLS'sbindingContractVersion()assertion at first client/invitation construction catches a stale pairing immediately. TwoMlsPqClient→TwoMlsPqIdentity(#34): the identity/key-package mint is renamed; group state now lives in sessions.- Digests are raw values (#32): FFI references (proposal digests, contexts) are raw 32-byte SHA-256, no app-layer type tags.
TwoMlsPqInvitation.receive(welcome, theirKeyPackage, spawnToken)(#31): newspawnTokenparameter keys the replay forward table.
What's new since 0.0.8
- Crypto-provider agnostic core, RustCrypto removed (#35):
apqcompiles no crypto provider; providers are injected. Provider selection is per feature —cryptokit(Apple, the shipped configuration) orawslc(portable; first full Linux CI coverage of every ML-KEM-768 path). This binary's classical half now runs on Apple CryptoKit (was RustCrypto) — wire-compatible and interop-tested (cross-provider sessions in CI), but deserves normal beta soak. Simulated-PQ fallbacks are gone: APQ always has a real PQ half; a provider that can't supply a suite fails withUnsupportedCipherSuite. - Session persistence groundwork (#21, #34): per-group archival (
PersistableGroupStorageexport/import), archives moved to canonicalmls_rs_codecframing (invitation archive v2 — v1 archives are rejected; regenerate invitations), session-owned cross-party PSK ledger (multi-epoch window, one-shot cleanup), KEM zeroization on every exit path. - A.5 PQ rekey (#29, #30): isolated updatePath commits on the PQ groups; the rekey carries the agent credential handoff (
pq_rekey_begin(rotating)/respond/apply). - Invitation forward table (#31): replayed initial frames resolve to the spawned session (
forward_group_id(spawnToken)on the invitation,forwarded(spawnToken)on the session); the table persists in the invitation archive. - Rotation-time listen-map pruning fix + session hygiene (#28).
- Fuzz targets (#15),
buildIosDynamichardening (#27, atomic artifact swap + stale bridge-cache purge), rust book refreshed with corrected PSK-chain direction (#8, #33).
Carried over from 0.0.8: session routing (shouldListenOn() / sendRendezvous(), #24), honest epoch reporting (EncryptResult.epochs, #25), bindingContractVersion() canary.
Integration
.binaryTarget(
name: "TwoMLSPQrs",
url: "https://github.com/germ-network/TwoMLSPQ/releases/download/0.0.9/TwoMLSPQ.xcframework.zip",
checksum: "fdd4a7bc05f74ec05a9c8a7d150adbd2114700dfab503191a5d57aaab114390c"
)Copy the attached two_mls_pq.swift into your wrapper target from this same release.
🤖 Generated with Claude Code