API Reference
This is a narrative overview; the authoritative reference is rustdoc
(cargo doc -p two-mls-pq --open). All exported names are flat because UniFFI has no
module paths — hence the TwoMlsPq* / Combiner* / Mls* prefixes (Combiner* is
the code-name prefix for the APQ pieces — e.g. CombinerKeyPackage, an APQ group’s
paired key packages).
Binding contract
binding_contract_version() -> u64 — a canary the Swift layer asserts at first
construction. UniFFI’s own load-time checksums cover function signatures only; this
value is bumped on any shape change to an exported record or the error enum, so a
stale binding/binary pairing fails fast with an actionable message instead of
trapping mid-flow.
References (Digests)
The FFI holds on to some state internally, e.g. queued Proposals, and passes references across the FFI to the app to use in subsequent calls to indicate the queued state.
The app can treat these references as opaque bytes-typed identifiers. They are in practice raw 32-byte values: SHA-256 over the stated object. That is this library’s own wire convention; no app-layer type tags appear on this surface.
The Swift wrapper applies the tag: everything it vends is [kind][digest] (33 bytes),
derived and compared by PQDigest (Sources/TwoMLSPQ/PQDigest.swift). The kind tag
belongs to the Swift package rather than to a shared identity type precisely because
the hash is a suite facet — TwoMlsSuite::CURRENT.digest — so it must version with
this crate. Since the crate carries no tags, the two sides restate the algorithm
independently; DigestContractTests.digestDerivationMatchesTheCrate is what holds
them together, and a suite whose digest is not SHA-256 must fail there.
TwoMlsPqPrincipal
The principal and key-package/invitation mint — deliberately not an mls-rs-style hub for group operations (see Concepts).
new(client_id) -> Arc<TwoMlsPqPrincipal>— build an identity for opaqueClientIdbytes (carried as the Basic Credential); the MLS signing keys are generated internally and are independent of it.client_id() -> ClientId— the identity bytes.generate_key_package(suite) -> Vec<u8>— one MLS key package.generate_combiner_key_package() -> CombinerKeyPackage— paired classical + ML-KEM-768 key packages sharing oneClientId.generate_invitation(last_resort) -> Vec<u8>— capture a combiner key package’s private material, with the signing identity, into a self-contained invitation archive, purging the identity’s own copies.last_resortpicks the key package’s lifetime, which TwoMLS manages itself rather than via mls-rs’s on-the-wire last-resort extension:trueretains the key package so the invitation accepts many welcomes;falsemakes it single-use (consumed after the first accepted session).
TwoMlsPqInvitation
The receiving side of a published key package — no live client required.
restore(archive)— materialise from serialised bytes (fromgenerate_invitationon first use, or a pushed blob on restore); they carry the signing identity, the key package’s private material, the consumed-remote set, the spawned-group forward table, and the processed-welcome ledger. Namedrestore, notnew— the state lives in the bytes.install_sink(sink)— attach theArchiveSinkthis invitation pushes to after every state-advancingreceive(once-only — a second call isSinkAlreadyInstalled; the first pushes a baselineCheckpoint). Persistence is push, not pull — the oldarchive()getter is off the FFI (seeTwoMlsPqSessionbelow for why).state_seq()reports the current push sequence.client_id(),combiner_key_package()— what to publish.receive(welcome, their_classical_key_package, bootstrap_kp_commitment, spawn_token, new_client_id, expected_remote, expected_app_binding) -> TwoMlsPqSession— establish from a remote initiator’s welcome; rejects a re-delivered welcome (byte-identical, via the processed-welcome ledger) and a repeat remote (bothDuplicateWelcome), and, for a single-use invitation whose key package has already been consumed, any further welcome (InvitationSpent).their_classical_key_packageis the initiator’s CLASSICAL return key package (a bare MLS KeyPackage message — §A.1: the return group starts classical-only).bootstrap_kp_commitmentisH(initiator's PQ keyPackage)from the signed establishment payload, exactly 32 bytes:pq_bootstrap_respondrefuses a bootstrap KP′ hashing to anything else (BootstrapKpMismatch).spawn_tokenis an opaque, replay-stable identifier for the initial frame, keying the forward table.new_client_idis an optional dedicated per-session principal: whenSome, the spawned session’s send group is created directly under a freshly-minted principal carrying that ClientId (signing keys minted internally) — so the acceptor runs as the dedicated agent from birth (its creator leaf carriesnew_client_id) and the initiator sees the dedicated principal from the very first frame, with no founding→dedicated rotation, so nothing can displace the welcome staple. The receive-group join still uses the invitation identity (the welcome was addressed to its key package), and the session id — the initiator’s random group id — is unaffected by any of this, so both sides still name the same one.expected_remoteis the identity the caller already expects the welcome from (Germ validates it from the decrypted initial frame): a key package naming anyone else is rejected asRemoteIdentityMismatchbefore any invitation state is claimed, so the invitation stays fully reusable. Independently of it, the welcome’s creator leaf must match the supplied key package (see Group Rules).expected_app_bindingis the app-state binding the welcome must carry (the bytes the initiator passed toinitiate(app_binding:)): an exact, symmetric match —Somerequires byte-equality,Nonerequires an unbound welcome; anything else (a stripped, unequal, or unexpected binding, or a PQ half smuggling one) isAppBindingMismatch, as is an empty expectation (empty is reserved — no group can carry an empty binding). Necessarily verified after the join (GroupContext rides the encrypted welcome) but still before any invitation state is claimed — a rejected welcome consumes nothing. The spawned session mirrors the verified binding onto its own send group.forward_group_id(spawn_token) -> Option<MlsGroupId>— resolve a replayed initial frame to the spawned session’s receive group (its classical message-half id).processed_welcome_group_id(welcome) -> Option<MlsGroupId>— the content-keyed counterpart: resolve a re-delivered welcome by the digest of its exact bytes, no host token convention needed.bootstrap_kp_group_id(kp_frame) -> Option<MlsGroupId>— resolve a §A.1 bootstrap-KP frame ([0x13][KP′], theBootstrapKpvariantopen_initialopens) to the session that owes A.3 for it, keyed onH(KP′)against the commitmentreceivepinned. Lets a KP′ delivered as an envelope (rather than a rendezvous side-band frame) self-route even when a reusable invitation has spawned many sessions;Nonefor a KP′ no session pinned. (A KP′ whose session already finished A.3 still resolves — the table is not pruned — and the duplicate is caught atpq_bootstrap_respond.) Route the frame to that session’spq_bootstrap_respond.open_initial(blob) -> OpenedInitial— open the initiator’s first frame (the §A.1 envelopeinitiateproduced), dispatching on the plaintext’s inner tag toEstablishment { frame }(the app-layer welcome and the MLSwelcometo pass toreceive) orBootstrapKp { frame }(the parallel A.3 KP′). Decrypt-only and does not consume the invitation (validate before joining);InvitationSpentonce a single-use invitation is consumed. The main receive path isopen_initial→ validate →receive.hpke_open(kem_output, ciphertext, info, aad)— the lower-level decrypt used byopen_initial, kept exported for other stacks; the counterpart free function ishpke_seal_to_key_package.
Parsing & routing helpers
parse_mls_key_package(bytes) -> MlsKeyPackage { client_id, cipher_suite }parse_combiner_key_package(kp) -> ParsedCombinerKeyPackage— validates both halves share aClientId.encode_combiner_key_package/decode_combiner_key_package— the pair as one opaque blob for layers that carry it as a single value.MlsCipherSuite::is_combiner_pq()/is_combiner_classical()— routing signals (true for the PQ0xFDEAand classical0x0003halves respectively).
The session id is the initiator’s group id. derive_session_id(a, b) was removed in
contract 31 and its stored replacement active_session_id() in contract 32, because both
were a hash of the two client ids — SHA-256(min(a,b) ‖ max(a,b)) — and that is a
participant-pair fingerprint, not a session id: it is computable by anyone holding the
two public ClientIds, and it is identical across every session the pair ever opens.
A real session id must be fresh and unpredictable per session. The one already in hand is
the initiator’s randomly-generated group id: seeded at group creation, unique per
session, and shared — the initiator’s send group is the acceptor’s receive group. Read it
with send_group_id() on the initiator and
receive_group_id() on the acceptor (the classical half, present from
construction; both sides name the same value). It survives archive restore with the group
state.
If you genuinely need a stable key for a pair before any session exists, compute your own
SHA-256(min(a,b) ‖ max(a,b)) over the two ClientIds — nothing secret, nothing
protocol-specific — but do not call it a session id.
TwoMlsPqSession
Constructors: initiate(client, their_key_package, app_binding) — the host’s opaque
app-layer welcome is attached AFTER construction with set_initial_app_payload (it
typically signs over the welcome and the return key package, so it cannot exist before
initiate returns); the library composes it with the MLS welcome and HPKE-envelopes it to
the peer’s KP′ so pending_outbound is one opaque blob the
peer opens with TwoMlsPqInvitation::open_initial; app_binding is the optional
app-state binding welded into the send group’s GroupContext at this moment and immutable
for the session’s lifetime — pass a digest of the app’s immutable relationship
identity, not raw identifiers, and never empty (empty is reserved as invalid; None is
the unbound state) — the crate never interprets the bytes (see
Group Rules rule 8);
accept(client, welcome, their_classical_key_package, bootstrap_kp_commitment, expected_app_binding) — the plaintext-welcome path (tests/embedded); restore(core, checkpoint) —
self-contained restore from the two pushed blobs: they carry the session’s signing
identity, so restore rebuilds the exact client internally (no client argument),
byte-exact in ClientId and signing keys, and the restored groups still sign with the keys
embedded in their snapshots. It reconciles the pair (PQ halves from the checkpoint, the
rest by higher state_seq) and fails closed (ArchiveInvalid) on a PQ-epoch manifest
mismatch.
State: is_established, is_fully_established, has_receive_group,
send_group_id / receive_group_id (the initiator’s send_group_id is the session id —
see above; each is a CombinerGroupId whose classical half is present from construction),
my_principal_state, their_principal_state,
pending_outbound (the standalone copy of the own welcome — not consumed by
encrypt; the welcome also rides every pre-commit frame as the staple), epochs,
app_binding() -> Result<Option<Vec<u8>>> (Swift try appBinding() -> Data?; the
app-state binding the session was created with, read from the send group’s GroupContext —
it rides the persisted group state, so a restored session’s owner re-verifies here;
errors only on a present-but-undecodable extension, so corruption can never read back as
“unbound”).
Messaging: prepare_to_encrypt(proposing) — Some(id) proposes a rotation to that
ClientId on this round’s Upd, admitting the candidate on the fly (minting the successor’s
signing keys and authorizing it if id is not already staged — so a rotation can ride the
very first frame); None re-proposes the current identity and the commit path is
unchanged; its result carries the staged Upd both raw
(proposal_message — the exact message the paired encrypt staples) and digested
(proposal_hash), from one critical section, so a host binding a signature to the
proposal (the anchor agent handoff) applies its own digest to the returned bytes with
no staged-slot read a later prepare could have replaced; encrypt;
process_incoming; proposal_context;
queue_proposal — approve the peer’s Upd (single-occupancy running tally,
latest-wins; validates then leaves the proposal cache untouched, so a rejected call is
a no-op and a replacement never doubles up; dropped when the send epoch advances via an
A.4 bind); queued_remote_successor() -> Option<ClientId> — the credential currently
queued, for the app’s replace policy. Proposing another Some(id) adds a candidate,
never evicting one already sent, so several may be in flight (my_principal_state is
Pending while any are); overflow beyond the in-flight window defers to a single slot
proposed next round, and the peer’s commit picks the winner. See
Group Rules for the Authentication Service semantics.
Header encryption: open_incoming(blob) -> Option<OpenedFrame { kind, frame }> removes
the outer seal from a rendezvous-channel blob and returns the plaintext frame plus a
routing kind (OpenedFrameKind::Message → process_incoming; PqSideBand { kind }
→ the named pq_* method); None means no key opened it (drop it). Every outbound
blob (EncryptResult.cipher_text, pending_outbound, pq_take_pending_outbound, the
pq_*_begin returns) is already sealed. process_incoming and the pq_* receivers
also accept a sealed blob directly (they open it transparently), so open_incoming is
strictly required only to route side-band frames. See
Header Encryption.
Transport routing: should_listen_on() -> ListenChannels (send-group ids + one
rendezvous address per retained epoch), send_rendezvous() (where to post),
forwarded(spawn_token) (acknowledge a replayed initial frame routed here by the
invitation’s forward table).
PQ side-band (see Session Lifecycle): my_pq_turn,
pq_pending_outbound(sealing) / pq_take_pending_outbound, pq_bootstrap_begin(rotating) /
pq_bootstrap_respond / pq_bootstrap_bind, and the ratchet/re-key responder and bind/apply
legs — pq_ratchet_respond / pq_ratchet_bind and pq_rekey_respond / pq_rekey_apply. There
is no pq_ratchet_begin / pq_rekey_begin: the session self-drives A.4 and A.5. On each
encrypt, when it is our turn and the side-band is idle, the session auto-stages the next round’s
opening frame (A.5 on a credential lag — announcing the session’s current principal as the handoff
— else A.4), and the host takes it from pq_pending_outbound/pq_take_pending_outbound to send
alongside the message. A.3 bootstrap stays host-driven (pq_bootstrap_begin, whose rotating
parameter carries the principal credential handoff and must name the session’s current principal).
The A.4 ratchet and A.3 bootstrap have no separate apply call: the initiator ingests the
responder’s reply and stages the owed bind with pq_ratchet_bind / pq_bootstrap_bind, and that
bind then rides the next message frame’s staple, which the peer applies through process_incoming
(the v18 “a bind is the staple” model).
Side-band frame sizing (Feature B): set_pad_target(target) declares the frame-sizing intent.
Some(n) pads each side-band frame up to the co-stapled message’s size, capped at the
push-payload budget n bytes, so the two co-stapled payloads are size-indistinguishable to an
on-path observer; None (the default) sends frames at their natural size. Like install_sink,
it is live plumbing outside the archive — set it right after restore, before use.
Persistence (push): attach an ArchiveSink with install_sink (once-only —
SinkAlreadyInstalled on a second call; the first pushes a baseline Checkpoint). The
session then PUSHES its new state after every state-advancing mutation via
persist(seq, kind, bytes), where kind is Core (everything but the two ML-KEM
trees; written on classical mutations) or Checkpoint (the complete state incl. the
PQ trees; written on PQ-touching mutations and at baseline). The state is total — a
session is always encodable, in any state, so a push never refuses: it serialises the
current signing identity, both group snapshots, the cross-party PSK ledger, the per-epoch
listen map, the spawn token, a staged-but-uncommitted rotation, the full PQ round state
(including a mid-A.4 KEM round), and every parked one-shot frame. The bytes are
plaintext secret material: the sink must seal them before writing (the key belongs in
the platform keystore). Serializing a mid-A.4 round costs at most one round of PCS against
an archive thief who already holds the epoch secrets; discarding the round state instead
would permanently desync the side-band, so it is not an option.
Push replaced a pull archive() getter that was a move, not a copy — using the live
session after snapshotting it, then restoring, rewound the sender ratchet into AEAD nonce
reuse against a real transcript (security review finding H1). The getter survives only as
an in-crate test/fuzz helper, off the FFI. Every classical mutation pushes one Core and
every PQ op one Checkpoint, atomically and independently; the PQ trees never move
between checkpoints, so a Core is always consistent with the latest Checkpoint
(the restore constructor above reconciles the pair).
Transmit gating: EncryptResult.depends_on_seq is the state_seq at which the commit a
frame staples was persisted. A frame that publishes stored-private-key material (a fresh
commit) must not go out until the app has durably persisted that seq — otherwise a
crash-restore could rewind past keys the peer will rely on. A routine app message
re-staples an already-persisted commit, so its depends_on_seq is already durable and
imposes no wait; the durability gate covers only key-material frames (routine frames rely
on MLS’s per-message reuse_guard, by design). state_seq() reports the current sequence
for the frames — the establishment envelope, PQ side-band — whose return type carries none.
In-library desync recovery is not planned — recovery is a re-establishment at the host layer.
Errors
All failures map to the flat TwoMlsPqError enum (Mls, InvalidKeyPackage,
MissingWelcome, PskBinding, PqNotAvailable, SessionNotEstablished,
SessionNotReady, ProposalRejected, DecryptionFailed, DuplicateWelcome,
InvitationSpent, ArchiveInvalid, UnsupportedCipherSuite, CipherSuiteMismatch,
EpochDesync, UnexpectedWelcome, InvalidClientId, RemoteIdentityMismatch,
CredentialRejected, ApqInfoMismatch, AppBindingMismatch,
SinkAlreadyInstalled, DuplicateSideBand, BootstrapKpMismatch,
BindDischargeFailed, BindApplyFailed, StaleFrame, BindTriggerFailed).
mls-rs error types never cross the FFI boundary. The three PQ-bind failures carry
recovery semantics a caller must branch on, and they name the three places one bind can
break. BindDischargeFailed is fatal — our classical commit discharging an owed bind
failed, so the host re-establishes the session. BindApplyFailed (paired with the queryable
pq_receive_broken()) is the peer’s staple failing on us: it marks receive-side PQ state as
broken but leaves sending intact, and a restore from the last persisted state heals it.
BindTriggerFailed (paired with the queryable pq_side_band_wedged()) is our own trigger
failing past its point of no return — A.3’s join, A.4’s decapsulation or A.5’s applied
Commit’ had already landed — so the round cannot be rebuilt and re-establishment is the
exit. It is the one of the three whose latch is ARCHIVED, and deliberately: the bind
triggers run inside a persist that captures partial mutations even on failure, so the tear
reaches the blob and a restore would otherwise reproduce it while reporting healthy.
Classical messaging keeps working under all three. DuplicateSideBand is a benign no-op (a re-delivered side-band frame);
BootstrapKpMismatch rejects an A.3 bootstrap key package whose hash does not match the
commitment receive was given. InvalidClientId rejects an empty
principal id supplied to receive(new_client_id:) or prepare_to_encrypt(Some(id)) — empty is
reserved (it is the ratchet-commit authenticated-data discriminator, so an empty id
could never be announced to the peer). RemoteIdentityMismatch is an establishment
identity-binding failure: an expected_remote the key package does not name, a
welcome whose creator leaf differs from the supplied key package, or an A.3
bootstrap key package naming a principal that is not the established peer (see
Group Rules). CredentialRejected is the Authentication
Service’s refusal (an unauthorized credential succession) — retryable where it arises
from a staple: authorize (queue_proposal on a fresh delivery) and reprocess.
EpochDesync means a stapled commit is more than one epoch ahead of the receive group
(the bridging commit no longer rides any frame) — re-establish the session, distinct from the
transient DecryptionFailed. StaleFrame is an application message whose message key the
group already spent — a replay — and is likewise distinct from DecryptionFailed: it is
terminal, so a host discards it rather than retrying. Expect it as steady-state traffic
wherever a host runs two delivery channels over one queue (a push relay alongside a socket),
where the second copy of every frame is a replay. Epoch misses stay DecryptionFailed: an
application message carries no epoch bound check, so a miss cannot be told apart from a frame
that arrived ahead of the commit it needs.
UnexpectedWelcome means a welcome differing from the one a
live session was joined from arrived (re-deliveries of the same welcome are silently
idempotent). CipherSuiteMismatch is raised when a peer key
package or welcome carries a cipher-suite pair that isn’t the session’s fixed suite
(PqNotAvailable when the peer offers no PQ half at all); UnsupportedCipherSuite is a local
provider-capability gap at construction. AppBindingMismatch is the app-state
binding’s verification failure: a welcome that does not carry the caller’s
expected_app_binding (absent or unequal), a binding-carrying welcome against no
expectation (never silently accepted), or a return welcome that fails to mirror the
initiator’s own binding — on receive it is raised before any invitation state is
claimed, so the invitation stays fully reusable (see
Group Rules).