Skip to content

Releases: germ-network/TwoMLSPQ

v0.4.1

Choose a tag to compare

@github-actions github-actions released this 14 Jul 17:02
cb566f8

Patch Changes

  • #66 0c526e0 Thanks @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
    the context that process_incoming stamps on the QueuedRemoteProposal. 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 read proposal_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 correct test_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

Choose a tag to compare

@github-actions github-actions released this 13 Jul 22:05
c106b01

Minor Changes

  • #64 d3f33ef Thanks @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) —
    previously prepare_to_encrypt returned SessionNotReady until both groups were
    established, on live and restored sessions alike. Pre-establishment,
    prepare_to_encrypt is a no-op round (proposal_message empty; proposal_hash is
    the WELCOME digest — the documented carve-out on the v14 guarantee) and encrypt
    emits a fresh §A.1 envelope per frame (contract 16 atop v0.3.0 AppBinding — initiate keeps app_binding and loses app_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 host app_payload is establishment-SELF-SUFFICIENT
    (carries the welcome + return key package inside) and replaces the bare sections.
    initiate lost its app_payload parameter (a payload that signs over the welcome
    cannot exist before initiate returns); 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(); InitialFrame reshaped (all four sections, welcome now
    optional); new exported decode_initial_plaintext. Replay-stable token/dedup keying:
    the stable prefix (app_payload when present, else welcome); 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
    to process_incoming after 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, so receive(expected_app_binding:) verifies it on a join from any frame.

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 03:39
3535df4

Minor Changes

  • #62 b319e26 Thanks @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 new AppBinding GroupContext extension (0xF0A2, the APQInfo mechanism) binds the session to the app's immutable relationship identity: initiate gains app_binding: Option<Vec<u8>> (Swift: appBinding: Data?), receive/accept gain expected_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; None is the unbound state). New app_binding() getter reads it back (it rides the persisted group state, so a restored session's owner re-verifies); new error AppBindingMismatch. 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. Take two_mls_pq.swift from this release (TwoMlsPqError gained a variant; a stale pairing mis-reads FFI buffers). Key packages and invitations regenerate: leaves now advertise the AppBinding extension type, so COMBINER_KEY_PACKAGE_VERSION 2 → 3 and INVITATION_VERSION 3 → 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

Choose a tag to compare

@github-actions github-actions released this 12 Jul 17:20
2d4167a

Minor Changes

  • #60 3478ceb Thanks @germ-mark! - PrepareEncryptResult gains proposal_message: Vec<u8> (Swift: proposalMessage: Data) — the raw staged Upd(self) proposal, the exact message the paired encrypt staples and the peer independently digests.

    ⚠️ Binding contract 13 → 14 — binding and binary MUST pair. Take two_mls_pq.swift from 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's proposal_hash and the receiver's independently derived QueuedRemoteProposal.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 later prepare_to_encrypt staged (routine self-refreshes included), and a signature input must not be exposed to that race.

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 12 Jul 05:24
a714aa9

Minor Changes

  • #57 8115145 Thanks @germ-mark! - Push-based persistence; the pull archive() is removed from the FFI

    ⚠️ Binding contract 12 → 13 — binding and binary MUST pair. Take two_mls_pq.swift from this release. Persisted state is not portable: SESSION_ARCHIVE_VERSION → 9, INVITATION_VERSION → 3 — regenerate all persisted sessions and invitations.

    The pull archive() on TwoMlsPqSession and TwoMlsPqInvitation is 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_foreign trait) with persist(seq, kind: BlobKind, archive). Attach one per object with the new install_sink (pushes a baseline Checkpoint). The contract: enqueue-only, non-blocking, atomically upsert the one blob named by kind (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 full Checkpoint. Every mutation is one atomic single-blob push, so the sink needs no cross-object transaction. Restore is TwoMlsPqSession.restore(core, checkpoint) (reconciles PQ-from-checkpoint, rest by higher state_seq, fails closed on a manifest mismatch). The invitation is monolithic (no ML-KEM trees) and restores with TwoMlsPqInvitation.restore(archive:). Both restore constructors are named restore (renamed from the session's from_persisted and the invitation's new(archive:)) — the name signals materialising from serialised bytes, not minting fresh state.
    • EncryptResult.depends_on_seq + read-only state_seq() on both objects: the app waits until it has durably persisted the frame's depends_on_seq before 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_begin now 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

Choose a tag to compare

@github-actions github-actions released this 11 Jul 09:18
117726c

Patch Changes

  • #55 2324280 Thanks @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-exporter branch had never picked up the CryptoKit iOS-build fixes the previous pin (3743c75) carried: newer Xcode toolchains report librariesRequireRPath for 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_TARGET stays 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

Choose a tag to compare

@github-actions github-actions released this 11 Jul 08:54
4bfdd5a

Patch Changes

  • #53 66d12fb Thanks @germ-mark! - draft-ietf-mls-combiner-02 conformance (#51), session modularization (#52)

    ⚠️ Binding contract 8 → 12 — binding and binary MUST pair. Take two_mls_pq.swift from 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 apq crate 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 as psk_type = application(3) (components 0xFF01/0xFF02); the A.3 injected secret stays an external PSK. Requires the germ-network/mls-rs germ-shadow-safe-exporter build branch (safe_extensions feature).
    • 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 APQKeyPackage TLS 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_epoch at 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.rs is split into a session/ module directory (pure moves; no API change). The book chapters (psk-binding, group-rules, wire-format, cipher-suites) match the shipped recipe.

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

0.0.10 — cipher-suite binding, last-resort key packages, total session archive (first release off main)

Choose a tag to compare

@germ-mark germ-mark released this 10 Jul 01:10
030197e

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)TwoMlsPqError gained CipherSuiteMismatch (a peer key package/welcome carries a suite pair that doesn't match the session's fixed suite); MlsCipherSuite.is_supported renamed to is_combiner_pq (the name always meant "is the PQ combiner half", not "is a supported suite"); AgentState renamed to PrincipalState (part of #44).
  • v5 (#43)TwoMlsPqError gained InvitationSpent (a single-use invitation whose key package was already consumed rejects every further receive, from any remote — distinct from the per-remote DuplicateWelcome); generate_invitation gained a last_resort flag (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 from PqNotAvailable (peer offers no PQ half) and UnsupportedCipherSuite (a local provider gap). The suite classifier now covers all RFC 9420 suites and the PqNotAvailable diagnostic 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 later receive fails with InvitationSpent; 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 (AgentStatePrincipalState; 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 (awslc remains 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)

Choose a tag to compare

@germ-mark germ-mark released this 08 Jul 00:56

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's bindingContractVersion() assertion at first client/invitation construction catches a stale pairing immediately.
  • TwoMlsPqClientTwoMlsPqIdentity (#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): new spawnToken parameter keys the replay forward table.

What's new since 0.0.8

  • Crypto-provider agnostic core, RustCrypto removed (#35): apq compiles no crypto provider; providers are injected. Provider selection is per feature — cryptokit (Apple, the shipped configuration) or awslc (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 with UnsupportedCipherSuite.
  • Session persistence groundwork (#21, #34): per-group archival (PersistableGroupStorage export/import), archives moved to canonical mls_rs_codec framing (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), buildIosDynamic hardening (#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