From 35014acafc2efc8a007e17059218d50e3c3621b7 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 13:41:41 -0700 Subject: [PATCH 1/7] Attachment CEK export: apq primitives + session ledgers (GER-1985, WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cryptographic foundation for attachment content-encryption keys: CEK = ExpandWithLabel(SafeExportSecret(0xFF03), "attachment", keyId, 32) apq gains ATTACHMENT_COMPONENT_ID (disjoint from both PSK components, so an attachment export can never consume a leaf a PSK binding needs), export_attachment_component, and attachment_cek. ExpandWithLabel is implemented locally: mls-rs keeps kdf_expand_with_label pub(crate), and its one public door, Group::derive_secret, hard-codes an empty context and so cannot take a keyId. The local KdfLabel is verified field-for-field against mls-rs's own private Label — that comparison, not the interop test, is the correctness basis, and the doc comment says so: both sides of the interop test run this same struct, so it cannot catch a wrong label. Session-side, two ledgers, because safe_export_secret consumes the leaf: - send: lazy per-epoch memo, so many attachments in one epoch cost one export. - recv: EAGER capture of the departing epoch, hooked where a staple advances the recv group (both the plain-commit and bind arms). This is the subtle half. A frame decrypts at the epoch it was SENT from, which mls-rs still retains, but safe_export_secret only exports at the CURRENT epoch — so a delayed attachment would derive from the wrong epoch and fail only at SEAL-open, as an opaque commitment mismatch. Capturing on the advance is the only moment the right component still exists. Both ledgers ride the archive: consumed exporter output cannot be re-derived after a restore. They go in ArchiveTail IN PLACE rather than bumping the version — v3 is unreleased (introduced after v0.14.0, freezes at 0.15.0), which is exactly the unreleased-byte exception the archive header documents. Scope note: classical-only export, per the ruling recorded on GER-1985 — the both-halves combine is overruled there with its rationale and its accepted caveat. Still to come: the uniffi surface, the Swift wrapper, session-level tests. Co-Authored-By: Claude Sonnet 5 --- rust/apq/src/component.rs | 5 + rust/apq/src/group.rs | 123 +++++++++++++++++++++++ rust/apq/src/lib.rs | 11 +- rust/apq/tests/provider_interop.rs | 40 ++++++++ rust/two-mls-pq/src/session/archive.rs | 60 ++++++++++- rust/two-mls-pq/src/session/messaging.rs | 98 ++++++++++++++++++ rust/two-mls-pq/src/session/mod.rs | 35 +++++++ 7 files changed, 364 insertions(+), 8 deletions(-) diff --git a/rust/apq/src/component.rs b/rust/apq/src/component.rs index 64f9961..e9fbd69 100644 --- a/rust/apq/src/component.rs +++ b/rust/apq/src/component.rs @@ -39,6 +39,11 @@ pub const APQ_COMPONENT_ID: u32 = 0xFF01; /// (see [`APQ_COMPONENT_ID`]). pub const TWOMLS_COMPONENT_ID: u32 = 0xFF02; +/// Germ's attachment-CEK domain (GER-1985), disjoint from both PSK components so an +/// attachment export can never consume a leaf a PSK binding needs — the exporter tree +/// deletes each component's leaf on first export. Also 16-bit (see [`APQ_COMPONENT_ID`]). +pub const ATTACHMENT_COMPONENT_ID: u32 = 0xFF03; + /// The `APQInfo` GroupContext extension type (RFC 9420 private-use extension range). pub const APQINFO_EXTENSION_TYPE: ExtensionType = ExtensionType::new(0xF0A1); diff --git a/rust/apq/src/group.rs b/rust/apq/src/group.rs index acf305b..c446d34 100644 --- a/rust/apq/src/group.rs +++ b/rust/apq/src/group.rs @@ -412,6 +412,79 @@ pub fn export_psk( }) } +/// Export the attachment-CEK parent for `group`'s current epoch (GER-1985): a bare +/// `SafeExportSecret(ATTACHMENT_COMPONENT_ID)` off the epoch's exporter tree. Like +/// [`export_psk`], the leaf is **consumed** — a given (group, epoch) can be exported at +/// most once, so callers memoize (the session's attachment ledgers). Both parties derive +/// identical bytes from the same epoch. Per-attachment CEKs are then expanded from this +/// one parent by [`attachment_cek`], so many attachments in one epoch cost one export. +pub fn export_attachment_component( + group: &mut Group, +) -> Result>> { + group + .safe_export_secret(crate::component::ATTACHMENT_COMPONENT_ID) + .map(|secret| Zeroizing::new(secret.as_bytes().to_vec())) + .map_err(|_| CombinerError::Mls) +} + +/// RFC 9420 §8 `KDFLabel`, encoded with the MLS codec so the expansion below is exactly +/// the spec's `ExpandWithLabel` — mls-rs keeps its own implementation `pub(crate)` +/// (`group::key_schedule::kdf_expand_with_label`), and the one function it exposes, +/// `Group::derive_secret`, hard-codes an empty context and cannot take `key_id`. +/// +/// **This struct is verified field-for-field against mls-rs's own private `Label`** +/// (`group/key_schedule.rs`, pinned rev — struct defined a few lines above +/// `kdf_expand_with_label`): identical field order (`length, label, context`), identical +/// `#[mls_codec(with = "mls_rs_codec::byte_vec")]` on both variable-length fields, and an +/// identical `label` construction (`[b"MLS 1.0 ", label].concat()`, matched below by +/// `[b"MLS 1.0 ".as_slice(), b"attachment"].concat()`). This is the actual correctness +/// basis — NOT the cross-provider interop test in `tests/provider_interop.rs`, which +/// only proves two providers' `kdf_expand` agree given identical input bytes; both sides +/// of that test run this same struct, so it cannot catch a wrong label independent of +/// this comparison. If mls-rs's `Label` ever changes shape, this must change with it. +mod kdf_label { + use mls_rs::mls_rs_codec::{self, MlsEncode, MlsSize}; + + #[derive(MlsSize, MlsEncode)] + pub(super) struct KdfLabel { + pub length: u16, + #[mls_codec(with = "mls_rs_codec::byte_vec")] + pub label: Vec, + #[mls_codec(with = "mls_rs_codec::byte_vec")] + pub context: Vec, + } +} + +/// The CEK length: 32 bytes, the SEAL profile's key size. +pub const ATTACHMENT_CEK_LEN: u16 = 32; + +/// Expand one attachment's wire CEK from the epoch's exported component (GER-1985): +/// `CEK = ExpandWithLabel(component, "attachment", key_id, 32)` per RFC 9420 §8 — label +/// prefixed `"MLS 1.0 "`, context the caller's random 32-byte `key_id`, which namespaces +/// the expansion only and never reaches any server. Deterministic given (component, +/// key_id), so both parties derive the same CEK; distinct `key_id`s yield independent +/// CEKs from one component. +/// +/// The suite must be the pinned classical suite both parties share — never inferred from +/// ambient state (a KDF disagreement here fails only at SEAL-open, as an opaque +/// commitment mismatch). +pub fn attachment_cek( + suite: &C, + component: &[u8], + key_id: &[u8], +) -> Result>> { + use mls_rs::mls_rs_codec::MlsEncode; + let label = kdf_label::KdfLabel { + length: ATTACHMENT_CEK_LEN, + label: [b"MLS 1.0 ".as_slice(), b"attachment"].concat(), + context: key_id.to_vec(), + }; + let info = label.mls_encode_to_vec().map_err(|_| CombinerError::Mls)?; + suite + .kdf_expand(component, &info, usize::from(ATTACHMENT_CEK_LEN)) + .map_err(|_| CombinerError::Mls) +} + /// Register an exported PSK into every store in `stores` — the caller's registry of /// every store its groups resolve PSKs from. The single fan-out loop shared by the /// session layer and the PQ ratchet. @@ -1292,6 +1365,56 @@ mod tests { assert!(export_psk(send.pq.as_mut().unwrap(), PskDomain::Apq).is_err()); } + #[test] + fn test_attachment_component_agrees_across_parties_and_consumes_once() { + let alice = client(); + let bob = client(); + + let (mut send, welcome) = create_combiner_send_group( + &bob.generate_classical_key_package().unwrap(), + &bob.generate_pq_key_package().unwrap(), + &alice, + None, + ) + .unwrap(); + let mut recv = join_combiner_group(&welcome, &bob).unwrap(); + + // The 0xFF03 leaf is untouched by establishment (which consumes only the Apq + // leaves), so both parties export it fresh at the same epoch and agree. + let a = export_attachment_component(&mut send.classical).unwrap(); + let b = export_attachment_component(&mut recv.classical).unwrap(); + assert_eq!(*a, *b); + + // Consumed: a second export at the same (group, epoch) is rejected. + assert!(export_attachment_component(&mut send.classical).is_err()); + + // The attachment export did not consume the PSK components' leaves. + assert!(export_psk(&mut send.classical, PskDomain::CrossParty).is_ok()); + } + + #[test] + fn test_attachment_cek_expansion_is_deterministic_and_key_id_separated() { + let suite = AwsLcCryptoProvider::new() + .cipher_suite_provider(mls_rs::CipherSuite::CURVE25519_CHACHA) + .unwrap(); + let component = vec![0x42u8; 32]; + let key_a = vec![0x01u8; 32]; + let key_b = vec![0x02u8; 32]; + + let cek_a1 = attachment_cek(&suite, &component, &key_a).unwrap(); + let cek_a2 = attachment_cek(&suite, &component, &key_a).unwrap(); + let cek_b = attachment_cek(&suite, &component, &key_b).unwrap(); + + assert_eq!(cek_a1.len(), usize::from(ATTACHMENT_CEK_LEN)); + // Deterministic given (component, key_id)... + assert_eq!(*cek_a1, *cek_a2); + // ...and distinct key_ids yield independent CEKs from one component. + assert_ne!(*cek_a1, *cek_b); + // A different component never collides. + let other = attachment_cek(&suite, &[0x43u8; 32], &key_a).unwrap(); + assert_ne!(*cek_a1, *other); + } + #[test] fn test_sender_client_id_returns_group_creator() { let alice = client(); diff --git a/rust/apq/src/lib.rs b/rust/apq/src/lib.rs index ece6f07..98fda96 100644 --- a/rust/apq/src/lib.rs +++ b/rust/apq/src/lib.rs @@ -33,13 +33,14 @@ pub use client::{ }; pub use group::{ - create_bound_classical_send_group, create_bound_combiner_send_group, + attachment_cek, create_bound_classical_send_group, create_bound_combiner_send_group, create_combiner_send_group, create_group_with_member, decode_apq_private_message, decode_apq_welcome, encode_apq_private_message, encode_apq_welcome, ensure_two_party, - export_and_register_psk, export_psk, forget_psk, forget_psk_stores, join_combiner_group, - join_combiner_group_from_halves, join_group_from_welcome, load_combiner_group, register_psk, - register_psk_stores, sender_client_id, CombinerGroup, CombinerGroupState, ExportedPsk, - GroupCreation, MlsGroup, PqMlsGroup, PskDomain, APQ_PRIVATE_MESSAGE_TAG, APQ_TAG, + export_and_register_psk, export_attachment_component, export_psk, forget_psk, + forget_psk_stores, join_combiner_group, join_combiner_group_from_halves, + join_group_from_welcome, load_combiner_group, register_psk, register_psk_stores, + sender_client_id, CombinerGroup, CombinerGroupState, ExportedPsk, GroupCreation, MlsGroup, + PqMlsGroup, PskDomain, APQ_PRIVATE_MESSAGE_TAG, APQ_TAG, ATTACHMENT_CEK_LEN, }; /// Failure categories for the combiner layer. The two-mls layer maps these onto its diff --git a/rust/apq/tests/provider_interop.rs b/rust/apq/tests/provider_interop.rs index 2122795..704554f 100644 --- a/rust/apq/tests/provider_interop.rs +++ b/rust/apq/tests/provider_interop.rs @@ -216,6 +216,46 @@ mod cryptokit_interop { send_and_check(&mut d_recv.classical, &mut c_send.classical, b"aws->ck"); } + /// GER-1985: the attachment-CEK export crosses providers — both the + /// `SafeExportSecret` component and the local `ExpandWithLabel` expansion must agree + /// bit-for-bit across awslc and CryptoKit, or two peers on different platforms would + /// derive different CEKs for the same attachment. Also pins the local + /// `ExpandWithLabel` encoding (RFC 9420 §8) against a second provider, since mls-rs + /// keeps its own implementation `pub(crate)` and this crate reimplements it. + #[test] + fn attachment_cek_crosses_providers() { + let alice = aws_combiner(b"alice-awslc"); + let bob = ck_combiner(b"bob-cryptokit"); + let (mut a_send, welcome) = create_combiner_send_group( + &bob.generate_classical_key_package().unwrap(), + &bob.generate_pq_key_package().unwrap(), + &alice, + None, + ) + .unwrap(); + let mut b_recv = join_combiner_group(&welcome, &bob).unwrap(); + + let component_a = apq::export_attachment_component(&mut a_send.classical).unwrap(); + let component_b = apq::export_attachment_component(&mut b_recv.classical).unwrap(); + assert_eq!( + *component_a, *component_b, + "component export disagrees across providers" + ); + + let suite = alice.cipher_suite().classical; + let suite_a = AwsLcCryptoProvider::new() + .cipher_suite_provider(suite) + .unwrap(); + let suite_b = CryptoKitProvider::default() + .cipher_suite_provider(suite) + .unwrap(); + let key_id = vec![0x07u8; 32]; + + let cek_a = apq::attachment_cek(&suite_a, &component_a, &key_id).unwrap(); + let cek_b = apq::attachment_cek(&suite_b, &component_b, &key_id).unwrap(); + assert_eq!(*cek_a, *cek_b, "CEK expansion disagrees across providers"); + } + /// A full A.4 PQ ratchet round on a cross-provider session: each side runs its own /// provider's ML-KEM for the EK/ct exchange, then the pathless PQ commit and the /// classical apq-PSK bind cross providers. Messaging must still flow in the diff --git a/rust/two-mls-pq/src/session/archive.rs b/rust/two-mls-pq/src/session/archive.rs index ec59bb7..1fa3a7e 100644 --- a/rust/two-mls-pq/src/session/archive.rs +++ b/rust/two-mls-pq/src/session/archive.rs @@ -286,6 +286,28 @@ pub(crate) mod archive_wire { /// mutations being real. A latch that healed on restore would hand the honest label /// back to the retriable lie the restored state still embodies. pub(in crate::session) pq_wedged: Option, + /// Attachment-CEK components for our SEND group's recent epochs (GER-1985). + /// + /// ARCHIVED because the exporter leaf is CONSUMED on first export: a restore that + /// dropped these could never re-derive them, so every attachment sent at a + /// still-live epoch would become unreadable to us on retry. Same reasoning as + /// `send_psk_ledger`, which rides the body for the same reason. + pub(in crate::session) send_attachment_ledger: Vec, + /// The same for our RECV group — and MORE load-bearing, because these entries can + /// only ever be captured at the instant their epoch departs (see + /// `SessionInner::recv_attachment_ledger`). A restore that lost them would leave + /// every in-flight attachment from a superseded epoch permanently underivable, + /// which the receive path cannot distinguish from a corrupt key. + pub(in crate::session) recv_attachment_ledger: Vec, + } + + /// One ledgered attachment-CEK component: the classical epoch it was exported at, and + /// the 32-byte component itself (per-attachment CEKs expand from it by `key_id`). + #[derive(MlsSize, MlsEncode, MlsDecode)] + pub(in crate::session) struct AttachmentEntry { + pub(in crate::session) epoch: u64, + #[mls_codec(with = "mls_rs_codec::byte_vec")] + pub(in crate::session) component: Vec, } impl ArchiveTail { @@ -297,6 +319,8 @@ pub(crate) mod archive_wire { Self { responder_wire_ct: None, pq_wedged: None, + send_attachment_ledger: Vec::new(), + recv_attachment_ledger: Vec::new(), } } } @@ -571,23 +595,38 @@ fn wire_pq_inflight(inflight: &PqInflight) -> archive_wire::WirePqInflight { } } -/// The v3 tail for `inner`: a `Responding` round's retained `wire_ct`, plus the wedge latch. +/// The v3 tail for `inner`: a `Responding` round's retained `wire_ct`, the wedge latch, and +/// the two attachment-CEK component ledgers. /// /// A whole-state view rather than an inflight one, because the wedge is not round state and /// must ride BOTH blob kinds: it is set inside a `Checkpoint` closure, but a later ordinary /// `Core` push can win the `state_seq` race in `reconcile_persisted`, and a winner without -/// the verdict would restore a session that reports healthy and deadlocks. +/// the verdict would restore a session that reports healthy and deadlocks. The attachment +/// ledgers ride both kinds for the same reason — they are written from the ordinary +/// message path (`Core`), so a `Checkpoint` winner that omitted them would drop consumed, +/// unrecoverable exporter output. fn tail_from(inner: &SessionInner) -> archive_wire::ArchiveTail { - use archive_wire::{ArchiveTail, CtBlob}; + use archive_wire::{ArchiveTail, AttachmentEntry, CtBlob}; let responder_wire_ct = match inner.pq_inflight.as_ref() { Some(PqInflight::Responding { wire_ct: Some(ct), .. }) => Some(CtBlob { bytes: ct.clone() }), _ => None, }; + let entries = |ledger: &VecDeque<(u64, Zeroizing>)>| { + ledger + .iter() + .map(|(epoch, component)| AttachmentEntry { + epoch: *epoch, + component: component.to_vec(), + }) + .collect() + }; ArchiveTail { responder_wire_ct, pq_wedged: inner.pq_wedged.map(|w| w as u8), + send_attachment_ledger: entries(&inner.send_attachment_ledger), + recv_attachment_ledger: entries(&inner.recv_attachment_ledger), } } @@ -679,6 +718,11 @@ fn session_from_wire( if wire.send_psk_ledger.len() > SEND_PSK_WINDOW { return Err(TwoMlsPqError::ArchiveInvalid); } + if tail.send_attachment_ledger.len() > ATTACHMENT_LEDGER_WINDOW + || tail.recv_attachment_ledger.len() > ATTACHMENT_LEDGER_WINDOW + { + return Err(TwoMlsPqError::ArchiveInvalid); + } let digest_ok = |d: &[u8]| d.len() == 32; if wire .pending_proposal_hash @@ -910,6 +954,16 @@ fn session_from_wire( .map(|exported| (entry.epoch, exported)) }) .collect::>()?, + send_attachment_ledger: tail + .send_attachment_ledger + .iter() + .map(|entry| (entry.epoch, Zeroizing::new(entry.component.clone()))) + .collect(), + recv_attachment_ledger: tail + .recv_attachment_ledger + .iter() + .map(|entry| (entry.epoch, Zeroizing::new(entry.component.clone()))) + .collect(), retired_send_psks: wire.retired_send_psks, last_cross_injected: wire.last_cross_injected, peer_applied_send_epoch: wire.peer_applied_send_epoch, diff --git a/rust/two-mls-pq/src/session/messaging.rs b/rust/two-mls-pq/src/session/messaging.rs index df3bd24..7e9282a 100644 --- a/rust/two-mls-pq/src/session/messaging.rs +++ b/rust/two-mls-pq/src/session/messaging.rs @@ -453,6 +453,97 @@ impl SessionInner { Ok(()) } + /// Push `(epoch, component)` onto a ledger, skipping a duplicate epoch and trimming to + /// [`ATTACHMENT_LEDGER_WINDOW`]. Shared by both attachment ledgers so their eviction + /// policy cannot drift apart. + fn ledger_attachment_component( + ledger: &mut VecDeque<(u64, Zeroizing>)>, + epoch: u64, + component: Zeroizing>, + ) { + if ledger.iter().any(|(e, _)| *e == epoch) { + return; + } + ledger.push_back((epoch, component)); + while ledger.len() > ATTACHMENT_LEDGER_WINDOW { + ledger.pop_front(); + } + } + + /// The attachment-CEK component for our SEND group's current epoch (GER-1985), + /// exporting and ledgering it on first use at that epoch. + /// + /// Lazy, unlike [`Self::remember_recv_attachment_component`]: our own send epoch is + /// always exportable when we are the one sending, so there is no departing-epoch race + /// to beat — and exporting eagerly on every commit would consume the 0xFF03 leaf on + /// every session whether or not it ever sends an attachment. + pub(in crate::session) fn send_attachment_component(&mut self) -> Result>> { + let epoch = self + .send_group + .as_ref() + .ok_or(TwoMlsPqError::SessionNotReady)? + .classical + .current_epoch(); + if let Some((_, component)) = self + .send_attachment_ledger + .iter() + .find(|(e, _)| *e == epoch) + { + return Ok(component.clone()); + } + let send = self + .send_group + .as_mut() + .ok_or(TwoMlsPqError::SessionNotReady)?; + let component = apq::export_attachment_component(&mut send.classical) + .map_err(|_| TwoMlsPqError::Mls)?; + Self::ledger_attachment_component( + &mut self.send_attachment_ledger, + epoch, + component.clone(), + ); + Ok(component) + } + + /// Capture the attachment-CEK component of our RECV group's CURRENT epoch, before a + /// staple advances past it (GER-1985). + /// + /// EAGER, and it has to be: `safe_export_secret` only exports at the current epoch, so + /// once the staple below applies, this epoch's component is gone forever — while frames + /// SENT at this epoch remain decryptable and may still arrive. Deriving such a frame's + /// CEK at whatever epoch we had reached by then would produce a wrong key that fails + /// only at SEAL-open, as an opaque commitment mismatch. Called on the epoch-advance + /// path for that reason, not on demand. + /// + /// Best-effort by design: a session that never receives an attachment still pays one + /// 32-byte export per applied staple, and a failure here (e.g. the leaf already + /// consumed at this epoch) must not fail the frame — the message itself is unaffected, + /// and a later attachment fetch surfaces the miss as retriable. + pub(in crate::session) fn remember_recv_attachment_component(&mut self) { + let Some(recv) = self.recv_group.as_mut() else { + return; + }; + let epoch = recv.classical.current_epoch(); + if self.recv_attachment_ledger.iter().any(|(e, _)| *e == epoch) { + return; + } + if let Ok(component) = apq::export_attachment_component(&mut recv.classical) { + Self::ledger_attachment_component(&mut self.recv_attachment_ledger, epoch, component); + } + } + + /// The ledgered RECV component for `epoch` — the epoch the frame was SENT from, which + /// the caller reads off the decrypted message, never from the group's current state. + pub(in crate::session) fn recv_attachment_component( + &self, + epoch: u64, + ) -> Option>> { + self.recv_attachment_ledger + .iter() + .find(|(e, _)| *e == epoch) + .map(|(_, component)| component.clone()) + } + /// Live-inject the session's PSK ledger, immediately before processing a frame whose /// commit may reference one of these PSKs. Injection targets the stores each live /// group actually resolves from (captured at the group's creation — the current @@ -1316,6 +1407,9 @@ impl TwoMlsPqSession { // the honest `BindApplyFailed`, and so a host can ask. In-memory // only: this closure persists on success, so the latch never reaches // a blob and a restore predates the failed take. + // Same departing-epoch capture as the plain-commit arm below: a + // bind's classical half advances the recv group too. + inner.remember_recv_attachment_component(); let moved = match inner.apply_bind(&s, &stores, &pq_message, &t_message) { Ok(moved) => moved, Err(_) => { @@ -1358,6 +1452,10 @@ impl TwoMlsPqSession { if inner.send_group.is_some() { inner.inject_send_psks()?; } + // Capture the departing recv epoch's attachment component before + // this commit moves past it — see the method's doc for why this + // must happen here rather than when an attachment is fetched. + inner.remember_recv_attachment_component(); let recv = inner .recv_group .as_mut() diff --git a/rust/two-mls-pq/src/session/mod.rs b/rust/two-mls-pq/src/session/mod.rs index a1289b4..de18601 100644 --- a/rust/two-mls-pq/src/session/mod.rs +++ b/rust/two-mls-pq/src/session/mod.rs @@ -317,6 +317,28 @@ struct SessionInner { /// (the -02 exporter tree consumes each component's leaf on first export), and the /// [`ExportedPsk`] carries the store key + value the peer's commit will look up. send_psk_ledger: VecDeque<(u64, apq::ExportedPsk)>, + /// Attachment-CEK components (GER-1985) for OUR SEND group's recent classical epochs, + /// keyed by that epoch. Exported lazily by `remember_attachment_component` at + /// `export_attachment_cek_send` time, then reused for every further attachment in the + /// same epoch: the exporter tree consumes the 0xFF03 leaf on first export, so the + /// per-epoch component must be memoized exactly as the PSK ledger memoizes its own. + /// Per-attachment CEKs are expanded from the component by `key_id`, so one export + /// serves any number of attachments at that epoch. + send_attachment_ledger: VecDeque<(u64, Zeroizing>)>, + /// The same, for our RECV group — and the reason this side exists at all is subtler + /// than the send side's. A frame is decrypted at the epoch it was SENT from, which + /// mls-rs still retains after we have applied later commits; but `safe_export_secret` + /// only ever exports at the group's CURRENT epoch. So a delayed frame's attachment + /// would derive its CEK from the wrong epoch's component — silently, producing a key + /// that fails only at SEAL-open as an opaque commitment mismatch, which the receive + /// path is required to treat as retry-forever rather than a decodable failure. + /// + /// The fix is to capture the DEPARTING epoch's component just before an applied + /// staple advances the recv group (the same "capture before committing past it" hook + /// `remember_send_psk` uses), and to key derivation by the frame's own epoch. An entry + /// is therefore written on the epoch-advance path, not on demand — by the time a + /// delayed frame arrives, its epoch is no longer exportable. + recv_attachment_ledger: VecDeque<(u64, Zeroizing>)>, /// PSK ids evicted from the ledger (or consumed one-shot) but possibly still present in /// the mls-rs secret stores from an earlier injection; the next `inject_send_psks` /// deletes them so the stores never resolve PSKs the session no longer vouches for. @@ -542,6 +564,17 @@ impl BootstrapKpCommitment { /// unboundedly between peer frames. const SEND_PSK_WINDOW: usize = 8; +/// Ledger depth for the attachment-CEK component ledgers (GER-1985), send and recv. +/// +/// The recv side is what sets this: it must cover every epoch a still-undelivered +/// attachment-bearing frame could have been sent from, i.e. how far our recv group can +/// advance while one peer frame is in flight. That is the same protocol-unbounded quantity +/// `SEND_PSK_WINDOW` reasons about, and the same answer applies — a generous window over +/// 32-byte secrets, relying on hosts not committing unboundedly between frames. A miss is +/// not silent: `export_attachment_cek_recv` errors rather than deriving at the wrong epoch, +/// which the app surfaces as a retriable fetch failure (the message itself still decrypts). +const ATTACHMENT_LEDGER_WINDOW: usize = 8; + /// Retained staged rotation candidates. Only one is usually in flight; the window /// exists because the peer's commit picks the winner among candidates proposed on /// different frames, so recently staged principals must survive until one wins. @@ -1251,6 +1284,8 @@ fn build_session( pending_side_band: None, owed_bind: None, send_psk_ledger: VecDeque::new(), + send_attachment_ledger: VecDeque::new(), + recv_attachment_ledger: VecDeque::new(), retired_send_psks: Vec::new(), last_cross_injected: None, // No evidence until the peer's first frame: a fresh session has nothing From 699486e9bcefc0581ae1c392608f49566d2bc475 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 14:14:26 -0700 Subject: [PATCH 2/7] Attachment CEK: uniffi surface, contract bump, session tests (GER-1985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export_attachment_cek_send/recv on TwoMlsPqSession, composing the component-export + ExpandWithLabel primitives from the prior commit. recv_attachment_component now also live-exports for a not-yet-departed current epoch (ledger-only missed the common "attachment fetched before anything commits past it" case). BINDING_CONTRACT_VERSION 32->33. Along the way, fixed a real bug the delayed-frame test caught: both app-message decrypt sites reported MlsSenderMessage.epoch as the RECV GROUP's current epoch rather than the frame's own epoch (MlsMessage::epoch(), same accessor already used for commits) — invisible for in-order delivery, wrong for a frame that arrives after a later commit already landed. Session tests cover send/recv agreement (live and ledgered), key_id separation, the delayed-frame-crossing-a-commit case (mutation-verified: breaking the eager capture's epoch keying fails exactly this test), an explicit-not-silent ledger miss, and archive round-trip. Full CI gate set (fmt, taplo, clippy x2) and the full two-mls-pq + apq suites are green. Still WIP, not pushed: Swift wrapper methods + error mapping + regenerated uniffi binding (needs an actual build), germDM wire types, GER-1944 flags. --- Sources/TwoMLSPQ/PQSession.swift | 10 +- rust/two-mls-pq/src/lib.rs | 27 ++++- rust/two-mls-pq/src/providers.rs | 15 +++ rust/two-mls-pq/src/session/messaging.rs | 107 +++++++++++++++-- rust/two-mls-pq/src/session/tests.rs | 143 +++++++++++++++++++++++ 5 files changed, 292 insertions(+), 10 deletions(-) diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 36cf1ba..73ee54a 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -237,7 +237,15 @@ import TwoMLSPQBinding // the group-id accessors. Nothing to do here either — the removed function was never // reachable from this product, and the whole app stack already keys on the group id. The // bump covers the dropped FFI symbols. No wire, API, or error-variant change. -private let expectedBindingContract: UInt64 = 32 +// v33 (contract 33, GER-1985): two FFI additions, `exportAttachmentCekSend(keyId:)` and +// `exportAttachmentCekRecv(keyId:epoch:)` — the wire attachment CEK, derived +// classical-only from the group's 0xFF03 exporter component. Send must be called after +// `prepareToEncrypt`, before `encrypt`; recv is keyed by the frame's own classical epoch +// (already on the decrypt outcome — no new field). New crate error +// `.attachmentComponentUnavailable` appended (the recv ledger missed or evicted the +// requested epoch — not retriable; the attachment is unopenable by this session). Hosts +// must handle the new case (the error map is exhaustive). Archive layout stays v3. +private let expectedBindingContract: UInt64 = 33 enum TwoMLSPQBindingContract { static let verified: Void = { diff --git a/rust/two-mls-pq/src/lib.rs b/rust/two-mls-pq/src/lib.rs index aa22645..f253d4d 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -462,7 +462,21 @@ pub fn version() -> String { // released 0.14 archive still decodes under the v3 migration. `pair_session_id` and the // `SessionId` uniffi record are gone. Drops two FFI symbols — re-pair the vendored binding — // no wire (archive layout stays v3) or error-variant change. -const BINDING_CONTRACT_VERSION: u64 = 32; +// +// v33 (GER-1985): two FFI additions, `export_attachment_cek_send(key_id) -> Vec` and +// `export_attachment_cek_recv(key_id, epoch) -> Vec` — the wire attachment CEK, +// `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`, derived +// classical-only (Linear ruling on GER-1985: the classical key schedule already absorbs a +// PQ-derived PSK, so the export is downstream of ML-KEM entropy without needing to combine +// both APQ halves). Send must be called after `prepare_to_encrypt`, before `encrypt`; recv +// is keyed by the frame's OWN classical epoch (`MlsSenderMessage.epoch`, already on the +// decrypt outcome — no new field needed), read from a session-owned ledger since +// `safe_export_secret` only exports at a group's CURRENT epoch and a delayed frame's epoch +// may already be behind it. One error variant appended, `AttachmentComponentUnavailable` +// (the recv ledger missed or evicted the requested epoch — not retriable; the attachment is +// unopenable by this session). Archive layout stays v3 (still unreleased — see +// `SESSION_ARCHIVE_VERSION`): the two ledgers ride the existing tail in place. +const BINDING_CONTRACT_VERSION: u64 = 33; /// See `BINDING_CONTRACT_VERSION`. Exported so the Swift layer can verify the /// binding it was generated with matches the binary it loaded. @@ -1012,6 +1026,17 @@ pub enum TwoMlsPqError { // renumber the survivors). #[error("PQ bind trigger failed past its point of no return; re-establish")] BindTriggerFailed, + /// `export_attachment_cek_recv` was asked for an epoch this session never ledgered + /// (GER-1985): either the epoch predates `ATTACHMENT_LEDGER_WINDOW`'s retention, or + /// `remember_recv_attachment_component` lost the race with a commit that advanced + /// past it before capturing it (best-effort by design — see its doc comment). + /// RETRIABLE from the app's perspective in neither sense of "try again now" (the + /// component is gone for good once evicted or missed) nor "this frame is broken" (the + /// frame itself decrypted fine) — it means the attachment behind this specific frame + /// cannot be opened by this session and the app should treat the fetch as failed, not + /// retry it against this session. + #[error("no ledgered attachment component for the requested epoch")] + AttachmentComponentUnavailable, } /// The protocol digest over `bytes` — the single hashing primitive behind every diff --git a/rust/two-mls-pq/src/providers.rs b/rust/two-mls-pq/src/providers.rs index 9f024f0..78c0262 100644 --- a/rust/two-mls-pq/src/providers.rs +++ b/rust/two-mls-pq/src/providers.rs @@ -129,6 +129,21 @@ pub(crate) fn header_aead_suite( .ok_or(TwoMlsPqError::Mls) } +/// The suite provider backing `apq::attachment_cek`'s `ExpandWithLabel` (GER-1985) — the +/// `classical` facet of `APQ_SUITE`, i.e. the SAME cipher suite that negotiated the +/// classical group the 0xFF03 component was exported from. Only the suite's KDF +/// (`kdf_expand`) is used. Deliberately the classical facet, not `header_aead`: the +/// component is classical-group material, so its expansion must track that group's own +/// negotiated suite, not the header layer's independently-facet-pinned AEAD. +pub(crate) fn attachment_cek_suite( +) -> Result> +{ + use mls_rs::CryptoProvider; + classical() + .cipher_suite_provider(APQ_SUITE.classical) + .ok_or(TwoMlsPqError::Mls) +} + #[cfg(test)] mod tests { /// Tripwire for the cryptokit backend's compile-time KEM pin: its `selected::PqKem` diff --git a/rust/two-mls-pq/src/session/messaging.rs b/rust/two-mls-pq/src/session/messaging.rs index 7e9282a..2907013 100644 --- a/rust/two-mls-pq/src/session/messaging.rs +++ b/rust/two-mls-pq/src/session/messaging.rs @@ -513,7 +513,11 @@ impl SessionInner { /// SENT at this epoch remain decryptable and may still arrive. Deriving such a frame's /// CEK at whatever epoch we had reached by then would produce a wrong key that fails /// only at SEAL-open, as an opaque commitment mismatch. Called on the epoch-advance - /// path for that reason, not on demand. + /// path for that reason, not on demand — [`Self::recv_attachment_component`] ALSO + /// exports live for the still-current (not yet departing) epoch, so together the two + /// cover both "attachment fetched before anything commits past it" and "fetched after." + /// They cannot race each other into a double-export: both route through + /// `Self::ledger_attachment_component`'s skip-if-already-ledgered guard. /// /// Best-effort by design: a session that never receives an attachment still pays one /// 32-byte export per applied staple, and a failure here (e.g. the leaf already @@ -532,16 +536,43 @@ impl SessionInner { } } - /// The ledgered RECV component for `epoch` — the epoch the frame was SENT from, which - /// the caller reads off the decrypted message, never from the group's current state. + /// The RECV component for `epoch` — the epoch the frame was SENT from, which the + /// caller reads off the decrypted message, never from the group's current state. + /// + /// Two sources: a ledger hit (an already-DEPARTED epoch, captured by + /// [`Self::remember_recv_attachment_component`] before the commit that moved past + /// it), or — the common "just arrived, nothing has committed past it yet" case a + /// ledger-only lookup would miss — a LIVE export when `epoch` is still the recv + /// group's current one. `None` only when `epoch` is neither: evicted past + /// `ATTACHMENT_LEDGER_WINDOW`, never captured, or simply stale. + /// + /// `&mut self`/fallible export means this can mutate and must run inside + /// `mutate_and_persist` like [`Self::send_attachment_component`] — the live branch + /// ledgers exactly like the eager capture does, so a later `remember_recv_attachment_component` + /// call for the same epoch (once it does depart) sees it already ledgered and skips + /// re-exporting (the leaf tolerates only one export, ever). pub(in crate::session) fn recv_attachment_component( - &self, + &mut self, epoch: u64, ) -> Option>> { - self.recv_attachment_ledger + if let Some((_, component)) = self + .recv_attachment_ledger .iter() .find(|(e, _)| *e == epoch) - .map(|(_, component)| component.clone()) + { + return Some(component.clone()); + } + let recv = self.recv_group.as_mut()?; + if recv.classical.current_epoch() != epoch { + return None; + } + let component = apq::export_attachment_component(&mut recv.classical).ok()?; + Self::ledger_attachment_component( + &mut self.recv_attachment_ledger, + epoch, + component.clone(), + ); + Some(component) } /// Live-inject the session's PSK ledger, immediately before processing a frame whose @@ -1256,6 +1287,14 @@ impl TwoMlsPqSession { let (staple, proposal_bytes, app_bytes) = decode_message_frame(&ciphertext)?; let app_msg = MlsMessage::from_bytes(&app_bytes).map_err(|_| TwoMlsPqError::DecryptionFailed)?; + // The frame's OWN epoch (its plaintext framing field, read before `app_msg` is + // consumed below) — NOT `recv.classical.current_epoch()` once decrypted, which + // is the GROUP's epoch at THAT MOMENT and silently disagrees with the frame's + // for a frame delayed past an intervening commit (GER-1985's recv-side ledger + // exists precisely to key on the frame's own epoch in that case). Trustworthy + // once paired with a successful decrypt below: a forged value here would derive + // the wrong epoch's key and fail AEAD auth, never reach this far. + let frame_epoch = app_msg.epoch(); let mut inner = self.lock(); @@ -1568,7 +1607,7 @@ impl TwoMlsPqSession { let sender = ClientId { bytes: sender_client_id(&recv.classical, desc.sender_index)?, }; - let ep = recv.classical.current_epoch(); + let ep = frame_epoch.unwrap_or_else(|| recv.classical.current_epoch()); (desc.data().to_vec(), sender, ep) } _ => return Err(TwoMlsPqError::DecryptionFailed), @@ -1676,6 +1715,9 @@ impl TwoMlsPqSession { if ciphertext.first() == Some(&PRE_ESTABLISHMENT_APP_TAG) { let app_msg = MlsMessage::from_bytes(&ciphertext[1..]) .map_err(|_| TwoMlsPqError::DecryptionFailed)?; + // See the identical capture in the message-frame arm above: the frame's own + // epoch, read before `app_msg` is consumed, not the group's current epoch. + let frame_epoch = app_msg.epoch(); let mut inner = self.lock(); let (app_data, sender_id, epoch) = { let recv = inner @@ -1691,7 +1733,7 @@ impl TwoMlsPqSession { let sender = ClientId { bytes: sender_client_id(&recv.classical, desc.sender_index)?, }; - let ep = recv.classical.current_epoch(); + let ep = frame_epoch.unwrap_or_else(|| recv.classical.current_epoch()); (desc.data().to_vec(), sender, ep) } _ => return Err(TwoMlsPqError::DecryptionFailed), @@ -1768,6 +1810,55 @@ impl TwoMlsPqSession { }) } + /// Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + /// `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. + /// + /// Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + /// commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this + /// must derive from the epoch that commit lands at, the same one `encrypt`'s staple + /// commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; + /// deriving after `encrypt` is too late for that frame to carry an attachment sealed + /// under it. + /// + /// `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that + /// separates every attachment's CEK from every other's, even within the same epoch. + /// Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` + /// blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + pub fn export_attachment_cek_send(&self, key_id: Vec) -> Result> { + let component = self.mutate_and_persist(crate::BlobKind::Core, |inner| { + inner.send_attachment_component() + })?; + let suite = providers::attachment_cek_suite()?; + let cek = apq::attachment_cek(&suite, &component, &key_id)?; + Ok(cek.to_vec()) + } + + /// Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + /// + /// `epoch` is the classical epoch the frame was SENT from — read off the frame's own + /// decrypted result, never the recv group's current epoch (the two diverge the moment + /// any later commit lands on the recv group). + /// + /// `AttachmentComponentUnavailable` means the component is unrecoverable for that + /// epoch: neither still current (a live export would have covered it) nor ledgered — + /// evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved + /// past it. This attachment cannot be opened by this session; it is not a transient + /// condition worth retrying. + /// + /// May mutate (a live export for a not-yet-departed current epoch ledgers it, like + /// [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside + /// `mutate_and_persist` and persists a `Core` blob on that path. + pub fn export_attachment_cek_recv(&self, key_id: Vec, epoch: u64) -> Result> { + let component = self.mutate_and_persist(crate::BlobKind::Core, |inner| { + inner + .recv_attachment_component(epoch) + .ok_or(TwoMlsPqError::AttachmentComponentUnavailable) + })?; + let suite = providers::attachment_cek_suite()?; + let cek = apq::attachment_cek(&suite, &component, &key_id)?; + Ok(cek.to_vec()) + } + /// Encrypt `app_message` using the PQ send group. /// Must be called after `prepare_to_encrypt`; the pending proposal hash is used as /// authenticated data and cleared on return. diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index fdc5fc3..49b83d6 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -8279,3 +8279,146 @@ fn test_each_bootstrap_leg_re_sends_until_it_is_answered() { // The staple answered the welcome: the responder's part is over too. assert!(bob.pq_pending_outbound(SideBandSealing::Fresh).is_none()); } + +// =========================================================================== +// Attachment CEK export (GER-1985) +// =========================================================================== + +/// The common case: alice derives send-side at the epoch her message went out at, bob +/// decrypts it with no commit in between (the epoch never departs during this test), and +/// `recv_attachment_component`'s LIVE fallback derives the same value for that +/// still-current epoch — no eager capture involved. +#[test] +fn test_attachment_cek_send_recv_agree_for_current_epoch() { + let (alice, bob) = establish_sessions(); + let key_id = vec![0xAAu8; 32]; + + assert_ok!(alice.prepare_to_encrypt(None)); + let cek_send = assert_ok!(alice.export_attachment_cek_send(key_id.clone())); + let enc = assert_ok!(alice.encrypt(b"attachment-bearing".to_vec())); + let result = assert_some!(assert_ok!(bob.process_incoming(enc.cipher_text))); + let epoch = assert_some!(result.application_message).epoch; + + let cek_recv = assert_ok!(bob.export_attachment_cek_recv(key_id, epoch)); + assert_eq!( + cek_send, cek_recv, + "send/recv CEKs disagree for the live-current epoch" + ); +} + +/// Two attachments riding the same epoch under different `key_id`s must derive distinct +/// CEKs — the label context is what separates them (the 0xFF03 leaf exports once per +/// epoch; the repeat call for an already-ledgered `key_id` is a ledger hit, not a second +/// export, and must reproduce the same value). +#[test] +fn test_attachment_cek_key_id_separates_ciphertexts_within_one_epoch() { + let (alice, _bob) = establish_sessions(); + assert_ok!(alice.prepare_to_encrypt(None)); + + let cek_a = assert_ok!(alice.export_attachment_cek_send(vec![0x01u8; 32])); + let cek_b = assert_ok!(alice.export_attachment_cek_send(vec![0x02u8; 32])); + assert_ne!( + cek_a, cek_b, + "distinct key_ids must not collide within one epoch" + ); + + let cek_a_again = assert_ok!(alice.export_attachment_cek_send(vec![0x01u8; 32])); + assert_eq!( + cek_a, cek_a_again, + "the same (epoch, key_id) must re-derive identically, not fail as a second export" + ); +} + +/// The review's key catch: a frame sent at epoch 1, but not PROCESSED by the receiver +/// until AFTER a later commit has moved his receive group past it — the delayed-frame +/// case `remember_recv_attachment_component`'s eager capture exists for. Modeled on +/// `test_psk_ledger_resolves_frame_that_crossed_a_commit`, which proves the same +/// "commit crosses a still-in-flight frame" shape at the classical layer. +/// +/// Mutation-verify this one: break the epoch keying (e.g. make the ledger lookup ignore +/// `epoch`, or key the eager capture by something other than the pre-commit epoch) and +/// confirm exactly this test fails — the send/recv CEKs would then silently disagree +/// instead of erroring, which is the failure mode GER-1978's fail-as-retry invariant +/// forbids. +#[test] +fn test_attachment_cek_resolves_frame_delayed_past_a_commit() { + let (alice, bob) = establish_sessions(); + let key_id = vec![0x07u8; 32]; + + // Epoch 1: alice mints and ledgers the attachment CEK, then encrypts — but the frame + // is held back ("delayed"): bob does not process it yet. + assert_ok!(alice.prepare_to_encrypt(None)); + let cek_send_epoch1 = assert_ok!(alice.export_attachment_cek_send(key_id.clone())); + let delayed = assert_ok!(alice.encrypt(b"delayed-attachment".to_vec())); + + // Bob's routine self-Update rides to alice; she approves it, and her NEXT prepare + // folds it into a commit on her own send group (== bob's recv group) — advancing it + // past epoch 1. + assert_ok!(bob.prepare_to_encrypt(None)); + let proposal = assert_ok!(bob.encrypt(b"routine".to_vec())); + let result = assert_some!(assert_ok!(alice.process_incoming(proposal.cipher_text))); + assert_ok!(alice.queue_proposal(assert_some!(result.proposal).digest)); + assert_ok!(alice.prepare_to_encrypt(None)); + let crossing = assert_ok!(alice.encrypt(b"crossing-commit".to_vec())); + + // Bob applies the crossing commit — this is where `remember_recv_attachment_component` + // captures epoch 1's component, right before his recv group moves past it. + let result = assert_some!(assert_ok!(bob.process_incoming(crossing.cipher_text))); + assert!( + assert_some!(result.application_message).epoch > 1, + "bob's recv group must have advanced past epoch 1" + ); + + // NOW the delayed epoch-1 frame finally arrives. + let result = assert_some!(assert_ok!(bob.process_incoming(delayed.cipher_text))); + let epoch = assert_some!(result.application_message).epoch; + assert_eq!(epoch, 1, "the delayed frame's own epoch must still read 1"); + + let cek_recv = assert_ok!(bob.export_attachment_cek_recv(key_id, epoch)); + assert_eq!( + cek_send_epoch1, cek_recv, + "recv-side CEK for a delayed frame must match what the sender derived at its own epoch" + ); +} + +/// An epoch bob never captured — neither still current nor ledgered — is a clean, typed +/// failure, never a silent wrong-epoch derivation. +#[test] +fn test_attachment_cek_recv_miss_is_explicit_not_silent() { + let (_alice, bob) = establish_sessions(); + let err = bob + .export_attachment_cek_recv(vec![0x00u8; 32], 999) + .unwrap_err(); + assert!(matches!(err, TwoMlsPqError::AttachmentComponentUnavailable)); +} + +/// Both ledgers survive an archive/restore round trip at the exact values held before +/// the cut — a restored session must derive identically to the live one it replaced. +#[test] +fn test_archive_preserves_attachment_ledgers() { + let (alice, bob) = establish_sessions(); + let key_id = vec![0x55u8; 32]; + + assert_ok!(alice.prepare_to_encrypt(None)); + let cek_send = assert_ok!(alice.export_attachment_cek_send(key_id.clone())); + let enc = assert_ok!(alice.encrypt(b"pre-archive".to_vec())); + let result = assert_some!(assert_ok!(bob.process_incoming(enc.cipher_text))); + let epoch = assert_some!(result.application_message).epoch; + let cek_recv = assert_ok!(bob.export_attachment_cek_recv(key_id.clone(), epoch)); + assert_eq!(cek_send, cek_recv); + + let alice_restored = round_trip(&alice); + let bob_restored = round_trip(&bob); + + let cek_send_restored = assert_ok!(alice_restored.export_attachment_cek_send(key_id.clone())); + assert_eq!( + cek_send, cek_send_restored, + "restored send ledger must reproduce the same CEK" + ); + + let cek_recv_restored = assert_ok!(bob_restored.export_attachment_cek_recv(key_id, epoch)); + assert_eq!( + cek_recv, cek_recv_restored, + "restored recv ledger must reproduce the same CEK" + ); +} From 6781ff0624b46d5d9eaaef41ce39a28c62f14ace Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 14:22:08 -0700 Subject: [PATCH 3/7] Swift wrapper + error mapping for attachment CEK export (GER-1985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PQSession.exportAttachmentCEKSend(keyId:)/exportAttachmentCEKRecv(keyId:epoch:), routed through mapPQErrors(.encrypt) — out-of-order calls surface as .sequenceViolation, matching prepareToEncrypt/encrypt. New SessionError.Code .attachmentComponentUnavailable (.discardFrame: the session is unaffected, only that one attachment is unopenable) wired through the exhaustive TwoMlsPqError bridge and both ErrorContractTests tables. Rebuilt the dynamic xcframework for contract v33 and re-synced Sources/TwoMLSPQBinding/two_mls_pq.swift from it (matches CI's build -> re-sync -> git-diff-clean sequence). Added AttachmentCEKTests.swift: send/recv agreement at a live epoch, key_id separation, and the typed recv-miss error, over the concrete PQSession wrapper — the Rust crate suite already covers ledger/epoch-keying correctness in depth. Full local Swift suite green (19 tests) against the rebuilt xcframework. --- Sources/TwoMLSPQ/PQSession.swift | 25 +++++ Sources/TwoMLSPQ/SessionError.swift | 12 +- Sources/TwoMLSPQ/SessionErrorBridge.swift | 2 + Sources/TwoMLSPQBinding/two_mls_pq.swift | 112 +++++++++++++++++++ Tests/TwoMLSPQTests/AttachmentCEKTests.swift | 95 ++++++++++++++++ Tests/TwoMLSPQTests/ErrorContractTests.swift | 6 +- 6 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 Tests/TwoMLSPQTests/AttachmentCEKTests.swift diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 73ee54a..1b60904 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -670,6 +670,31 @@ public struct PQSession { } } + /// Derive the wire attachment CEK for this session's SEND group at its current + /// epoch (GER-1985). Call order is load-bearing: **after `prepareToEncrypt`, + /// before `encrypt`** — a commit inside `prepareToEncrypt` can advance the send + /// epoch, and this must derive from the epoch that commit lands at, the same one + /// `encrypt`'s staple commits to. `keyId` is the caller-minted + /// `AttachmentHeader.keyId`, separating every attachment's CEK from every + /// other's even within one epoch. + public func exportAttachmentCEKSend(keyId: Data) throws(SessionError) -> Data { + try mapPQErrors(.encrypt) { + try base.exportAttachmentCekSend(keyId: keyId) + } + } + + /// Derive the wire attachment CEK for a RECEIVED frame's classical epoch + /// (GER-1985). `epoch` is the epoch the frame was SENT from — read off + /// `PQSenderMessage.epoch` on the decrypted result, never this session's current + /// epoch (they diverge the moment a later commit lands). `.attachmentComponentUnavailable` + /// means the component was never captured for that epoch and this attachment + /// cannot be opened by this session — not a transient condition to retry. + public func exportAttachmentCEKRecv(keyId: Data, epoch: UInt64) throws(SessionError) -> Data { + try mapPQErrors(.encrypt) { + try base.exportAttachmentCekRecv(keyId: keyId, epoch: epoch) + } + } + public func processIncoming( ciphertext: Data ) throws(SessionError) -> PQProcessOutcome { diff --git a/Sources/TwoMLSPQ/SessionError.swift b/Sources/TwoMLSPQ/SessionError.swift index 6f0175e..874a74d 100644 --- a/Sources/TwoMLSPQ/SessionError.swift +++ b/Sources/TwoMLSPQ/SessionError.swift @@ -186,15 +186,23 @@ public struct SessionError: Error, Sendable { /// Opaque / internal failure: an MLS protocol error, a PSK-binding failure, an FFI decode /// error, or a Rust panic. Discard the session object; do not persist it. case internalError + /// `exportAttachmentCEKRecv` was asked for an epoch this session never captured + /// (GER-1985): evicted past the recv ledger's retention window, or the + /// best-effort eager capture lost its race against the commit that moved past + /// it. The session itself is unaffected — only this specific attachment is + /// unopenable by it; not worth retrying against this session. + case attachmentComponentUnavailable public var disposition: Disposition { switch self { case .decryptionFailed: return .retryLater case .staleFrame, .duplicateWelcome, .duplicateSideBand, - .unopenableFrame, .malformedFrame, .bootstrapKpMismatch: + .unopenableFrame, .malformedFrame, .bootstrapKpMismatch, + .attachmentComponentUnavailable: // A.3 KP′ not matching the signed commitment: drop the bad frame, the session is - // intact and the genuine re-stapled KP′ still works. + // intact and the genuine re-stapled KP′ still works. An unavailable attachment + // component is the same shape: this one fetch fails, the session is unaffected. return .discardFrame case .epochDesync, .bindDischargeFailed, .bindTriggerFailed: // The crate words this "re-establish the session" too; the recovery is diff --git a/Sources/TwoMLSPQ/SessionErrorBridge.swift b/Sources/TwoMLSPQ/SessionErrorBridge.swift index 1431804..cf969c1 100644 --- a/Sources/TwoMLSPQ/SessionErrorBridge.swift +++ b/Sources/TwoMLSPQ/SessionErrorBridge.swift @@ -162,6 +162,8 @@ extension SessionError { code = .establishmentEnvelopeConflict detail = "a different establishment envelope is already installed on this " + "session; one session binds exactly one envelope." + case .AttachmentComponentUnavailable: + code = .attachmentComponentUnavailable } self.init(code: code, underlying: pq, detail: detail) diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index 4234e03..cc6b3b0 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -1980,6 +1980,43 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable { */ func encrypt(appMessage: Data) throws -> EncryptResult + /** + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + * + * `epoch` is the classical epoch the frame was SENT from — read off the frame's own + * decrypted result, never the recv group's current epoch (the two diverge the moment + * any later commit lands on the recv group). + * + * `AttachmentComponentUnavailable` means the component is unrecoverable for that + * epoch: neither still current (a live export would have covered it) nor ledgered — + * evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved + * past it. This attachment cannot be opened by this session; it is not a transient + * condition worth retrying. + * + * May mutate (a live export for a not-yet-departed current epoch ledgers it, like + * [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside + * `mutate_and_persist` and persists a `Core` blob on that path. + */ + func exportAttachmentCekRecv(keyId: Data, epoch: UInt64) throws -> Data + + /** + * Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + * `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. + * + * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + * commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this + * must derive from the epoch that commit lands at, the same one `encrypt`'s staple + * commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; + * deriving after `encrypt` is too late for that frame to carry an attachment sealed + * under it. + * + * `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that + * separates every attachment's CEK from every other's, even within the same epoch. + * Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` + * blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + */ + func exportAttachmentCekSend(keyId: Data) throws -> Data + /** * Acknowledge a re-delivered pre-establishment frame routed here by the * invitation's forward table. `spawn_token` is the caller's opaque identifier for @@ -2824,6 +2861,58 @@ open func encrypt(appMessage: Data)throws -> EncryptResult { FfiConverterData.lower(appMessage),$0 ) }) +} + + /** + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + * + * `epoch` is the classical epoch the frame was SENT from — read off the frame's own + * decrypted result, never the recv group's current epoch (the two diverge the moment + * any later commit lands on the recv group). + * + * `AttachmentComponentUnavailable` means the component is unrecoverable for that + * epoch: neither still current (a live export would have covered it) nor ledgered — + * evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved + * past it. This attachment cannot be opened by this session; it is not a transient + * condition worth retrying. + * + * May mutate (a live export for a not-yet-departed current epoch ledgers it, like + * [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside + * `mutate_and_persist` and persists a `Core` blob on that path. + */ +open func exportAttachmentCekRecv(keyId: Data, epoch: UInt64)throws -> Data { + return try FfiConverterData.lift(try rustCallWithError(FfiConverterTypeTwoMlsPqError_lift) { + uniffi_two_mls_pq_fn_method_twomlspqsession_export_attachment_cek_recv( + self.uniffiCloneHandle(), + FfiConverterData.lower(keyId), + FfiConverterUInt64.lower(epoch),$0 + ) +}) +} + + /** + * Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + * `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. + * + * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + * commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this + * must derive from the epoch that commit lands at, the same one `encrypt`'s staple + * commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; + * deriving after `encrypt` is too late for that frame to carry an attachment sealed + * under it. + * + * `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that + * separates every attachment's CEK from every other's, even within the same epoch. + * Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` + * blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + */ +open func exportAttachmentCekSend(keyId: Data)throws -> Data { + return try FfiConverterData.lift(try rustCallWithError(FfiConverterTypeTwoMlsPqError_lift) { + uniffi_two_mls_pq_fn_method_twomlspqsession_export_attachment_cek_send( + self.uniffiCloneHandle(), + FfiConverterData.lower(keyId),$0 + ) +}) } /** @@ -5553,6 +5642,18 @@ public enum TwoMlsPqError: Swift.Error, Equatable, Hashable, Foundation.Localize * `pq_side_band_wedged`. */ case BindTriggerFailed + /** + * `export_attachment_cek_recv` was asked for an epoch this session never ledgered + * (GER-1985): either the epoch predates `ATTACHMENT_LEDGER_WINDOW`'s retention, or + * `remember_recv_attachment_component` lost the race with a commit that advanced + * past it before capturing it (best-effort by design — see its doc comment). + * RETRIABLE from the app's perspective in neither sense of "try again now" (the + * component is gone for good once evicted or missed) nor "this frame is broken" (the + * frame itself decrypted fine) — it means the attachment behind this specific frame + * cannot be opened by this session and the app should treat the fetch as failed, not + * retry it against this session. + */ + case AttachmentComponentUnavailable @@ -5613,6 +5714,7 @@ public struct FfiConverterTypeTwoMlsPqError: FfiConverterRustBuffer { case 29: return .EstablishmentEnvelopeConflict case 30: return .StaleFrame case 31: return .BindTriggerFailed + case 32: return .AttachmentComponentUnavailable default: throw UniffiInternalError.unexpectedEnumCase } @@ -5748,6 +5850,10 @@ public struct FfiConverterTypeTwoMlsPqError: FfiConverterRustBuffer { case .BindTriggerFailed: writeInt(&buf, Int32(31)) + + case .AttachmentComponentUnavailable: + writeInt(&buf, Int32(32)) + } } } @@ -6416,6 +6522,12 @@ private let initializationResult: InitializationResult = { if (uniffi_two_mls_pq_checksum_method_twomlspqsession_encrypt() != 14453) { return InitializationResult.apiChecksumMismatch } + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_recv() != 46987) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_send() != 18660) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_two_mls_pq_checksum_method_twomlspqsession_forwarded() != 11226) { return InitializationResult.apiChecksumMismatch } diff --git a/Tests/TwoMLSPQTests/AttachmentCEKTests.swift b/Tests/TwoMLSPQTests/AttachmentCEKTests.swift new file mode 100644 index 0000000..36d61ec --- /dev/null +++ b/Tests/TwoMLSPQTests/AttachmentCEKTests.swift @@ -0,0 +1,95 @@ +// +// AttachmentCEKTests.swift +// TwoMLSPQ +// +// Swift-surface coverage for GER-1985's exportAttachmentCEKSend/Recv, over the concrete +// PQSession wrapper. The Rust crate suite (two-mls-pq/src/session/tests.rs) already covers +// the ledger/epoch-keying correctness in depth, including the delayed-frame mutation-verified +// case; this file only pins that the SWIFT surface — argument/return marshalling, the typed +// error mapping — reaches the same crate behavior. +// + +import CommProtocol +import Foundation +import Testing + +import TwoMLSPQBinding + +@testable import TwoMLSPQ + +struct AttachmentCEKTests { + + /// A classical-established pair (born-dedicated, PQ half deferred) — enough for the + /// attachment CEK, which only ever touches the classical groups. Mirrors + /// `LifecycleTests.testExchange`'s steps 1-3, trimmed to just the establishment. + private func establishedPair() throws -> (local: PQSession, remote: PQSession) { + let local = try ClientWrapper() + let remote = try ClientWrapper() + + let (localSession, welcome, myKeyPackage, bootstrapKpCommitment) = + try local.client.reply( + keyPackageMessage: remote.currentInvitation.encodedKeyPackage + ) + + let dedicatedId: ClientID = .mock() + let (remoteSession, _) = try remote.currentInvitation.receive( + sendGroupWelcome: welcome, + remoteKeyPackage: myKeyPackage, + bootstrapKpCommitment: bootstrapKpCommitment, + remoteClientId: try local.clientId, + welcomeToken: WelcomeToken(PQDigest.over(welcome)), + stapledMessage: nil, + newClientId: dedicatedId + ) + try remoteSession.installMockEstablishmentEnvelope() + try localSession.acceptEstablishment(from: remoteSession, dedicatedId: dedicatedId) + + return (localSession, remoteSession) + } + + /// The common case: local derives send-side, remote decrypts local's frame and derives + /// recv-side at that frame's own epoch — the two must agree. + @Test func sendRecvAgreeForLiveEpoch() throws { + let (local, remote) = try establishedPair() + let keyId = Data(repeating: 0xAA, count: 32) + + _ = try local.prepareToEncrypt(proposing: nil) + let cekSend = try local.exportAttachmentCEKSend(keyId: keyId) + #expect(cekSend.count == 32) + + let frame = try local.encrypt(appMessage: Data("attachment-bearing".utf8)) + let decrypted = try #require(try remote.decrypt(frame.cipherText)) + let epoch = try decrypted.applicationMessage.tryUnwrap.epoch + + let cekRecv = try remote.exportAttachmentCEKRecv(keyId: keyId, epoch: epoch) + #expect(cekSend == cekRecv, "send/recv CEKs disagree for the live-current epoch") + } + + /// Two attachments riding the same epoch under different `keyId`s must derive distinct + /// CEKs; the same `keyId` at the same epoch must re-derive identically. + @Test func keyIdSeparatesCiphertextsWithinOneEpoch() throws { + let (local, _) = try establishedPair() + _ = try local.prepareToEncrypt(proposing: nil) + + let cekA = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x01, count: 32)) + let cekB = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x02, count: 32)) + #expect(cekA != cekB, "distinct key ids must not collide within one epoch") + + let cekAAgain = try local.exportAttachmentCEKSend(keyId: Data(repeating: 0x01, count: 32)) + #expect(cekA == cekAAgain, "the same (epoch, keyId) must re-derive identically") + } + + /// An epoch the receiver never captured — neither still current nor ledgered — is a + /// typed, discardable failure, never a silently wrong derivation. + @Test func recvMissIsTypedNotSilent() throws { + let (_, remote) = try establishedPair() + do { + _ = try remote.exportAttachmentCEKRecv( + keyId: Data(repeating: 0, count: 32), epoch: 999) + Issue.record("expected .attachmentComponentUnavailable") + } catch { // exportAttachmentCEKRecv is throws(SessionError) — error is typed + #expect(error.code == .attachmentComponentUnavailable) + #expect(error.disposition == .discardFrame) + } + } +} diff --git a/Tests/TwoMLSPQTests/ErrorContractTests.swift b/Tests/TwoMLSPQTests/ErrorContractTests.swift index 952b78d..3d47305 100644 --- a/Tests/TwoMLSPQTests/ErrorContractTests.swift +++ b/Tests/TwoMLSPQTests/ErrorContractTests.swift @@ -61,6 +61,7 @@ struct ErrorContractTests { (.establishmentCreatorMismatch, .rejectEstablishment), (.establishmentEnvelopeConflict, .callerBug), (.internalError, .fatal), + (.attachmentComponentUnavailable, .discardFrame), ] for (code, disposition) in table { #expect(code.disposition == disposition, "\(code) -> \(code.disposition)") @@ -103,10 +104,11 @@ struct ErrorContractTests { (.EstablishmentEnvelopeConflict, .establishmentEnvelopeConflict), (.StaleFrame, .staleFrame), (.BindTriggerFailed, .bindTriggerFailed), + (.AttachmentComponentUnavailable, .attachmentComponentUnavailable), ] // + the two per-surface cases (SessionNotReady, - // EstablishmentEnvelopeRequired) = all 31 crate cases - #expect(expected.count == 29) + // EstablishmentEnvelopeRequired) = all 32 crate cases + #expect(expected.count == 30) for (crate, code) in expected { let mapped = SessionError(pqError: crate, at: .client) #expect(mapped.code == code, "\(crate) -> \(mapped.code)") From 7ee285d37a9eb463c3d851eff6d686fe0511675d Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 15:06:44 -0700 Subject: [PATCH 4/7] Fix archive versioning: v3 shipped, bump to v4 instead of mutating in place (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fix: v0.15.0/v0.15.1 already shipped SESSION_ARCHIVE_VERSION=3 with a two-field ArchiveTail (responder_wire_ct, pq_wedged) — confirmed via `git tag`/`git merge-base --is-ancestor` against the release remote, which this branch's earlier archaeology had missed (it only checked up to v0.14.0, before fetching tags). Adding the attachment ledgers to that same struct in place, as the previous commit did, would make every session a 0.15.x build persisted fail ArchiveInvalid on restore under this code — the "unreleased byte" in-place-mutation exception documented at the top of archive.rs explicitly closes the instant a release ships the byte, which 0.15.0 did. Fix: bump SESSION_ARCHIVE_VERSION 3->4. A new ArchiveTailV3 (frozen to the exact two-field shape 0.15.0/0.15.1 wrote) is the decode target for a v3 blob, lifted into the current four-field ArchiveTail with empty attachment ledgers via into_current(). v3 joins v2 as an accepted older layout, mirroring the existing v2-compat mechanism one layer out. Added test_v3_archive_restores_with_empty_attachment_ledgers, mutation-verified against the exact bug this fixes (decoding a v3 tail as the current shape reproduces ArchiveInvalid). Updated test_session_archive_version_is_pinned for the new byte. Also: moved the "deliberately the LAST variant" uniffi-ordinal comment onto the actual last error variant, and expanded both the v33 Rust and Swift changelog entries to state that MlsSenderMessage.epoch's MEANING changed (frame's own authenticated epoch, not the group's current epoch — identical in-order, diverges only for a frame processed after a later commit already landed) and to correct the now-stale "archive stays v3" claims. Full CI gate set (fmt, taplo, clippy x2) and the full two-mls-pq (313 tests) + apq (9 tests) suites are green. --- Sources/TwoMLSPQ/PQSession.swift | 14 ++- rust/two-mls-pq/src/lib.rs | 37 +++++-- rust/two-mls-pq/src/session/archive.rs | 138 +++++++++++++++++-------- rust/two-mls-pq/src/session/tests.rs | 77 +++++++++++++- 4 files changed, 208 insertions(+), 58 deletions(-) diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 1b60904..bcd0842 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -240,11 +240,15 @@ import TwoMLSPQBinding // v33 (contract 33, GER-1985): two FFI additions, `exportAttachmentCekSend(keyId:)` and // `exportAttachmentCekRecv(keyId:epoch:)` — the wire attachment CEK, derived // classical-only from the group's 0xFF03 exporter component. Send must be called after -// `prepareToEncrypt`, before `encrypt`; recv is keyed by the frame's own classical epoch -// (already on the decrypt outcome — no new field). New crate error -// `.attachmentComponentUnavailable` appended (the recv ledger missed or evicted the -// requested epoch — not retriable; the attachment is unopenable by this session). Hosts -// must handle the new case (the error map is exhaustive). Archive layout stays v3. +// `prepareToEncrypt`, before `encrypt`; recv is keyed by the frame's own classical epoch — +// `PQSenderMessage.epoch`'s MEANING changes here (no signature change): it used to report +// the recv group's current epoch at decrypt time, now reports the frame's own +// authenticated epoch, which only diverges for a frame processed after a later commit has +// already landed. New crate error `.attachmentComponentUnavailable` appended (the recv +// ledger missed or evicted the requested epoch — not retriable; the attachment is +// unopenable by this session). Hosts must handle the new case (the error map is +// exhaustive). Archive layout bumps 3→4: v3 shipped at 0.15.0/0.15.1 before this change, +// so the attachment ledgers could not land in v3 in place the way earlier tail fields did. private let expectedBindingContract: UInt64 = 33 enum TwoMLSPQBindingContract { diff --git a/rust/two-mls-pq/src/lib.rs b/rust/two-mls-pq/src/lib.rs index f253d4d..41faa9b 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -469,13 +469,28 @@ pub fn version() -> String { // classical-only (Linear ruling on GER-1985: the classical key schedule already absorbs a // PQ-derived PSK, so the export is downstream of ML-KEM entropy without needing to combine // both APQ halves). Send must be called after `prepare_to_encrypt`, before `encrypt`; recv -// is keyed by the frame's OWN classical epoch (`MlsSenderMessage.epoch`, already on the -// decrypt outcome — no new field needed), read from a session-owned ledger since +// is keyed by the frame's OWN classical epoch, read from a session-owned ledger since // `safe_export_secret` only exports at a group's CURRENT epoch and a delayed frame's epoch -// may already be behind it. One error variant appended, `AttachmentComponentUnavailable` -// (the recv ledger missed or evicted the requested epoch — not retriable; the attachment is -// unopenable by this session). Archive layout stays v3 (still unreleased — see -// `SESSION_ARCHIVE_VERSION`): the two ledgers ride the existing tail in place. +// may already be behind it. +// +// `MlsSenderMessage.epoch`'s MEANING CHANGES here, not just its plumbing: it used to report +// the recv group's CURRENT epoch at the moment of decrypt; it now reports the FRAME's own +// authenticated epoch (`MlsMessage::epoch()`, read before the frame is consumed — the same +// accessor `commit.epoch()` already used elsewhere in this crate). The two agree for every +// in-order frame, which is every frame any existing caller has ever observed — this is why +// the change is field-compatible rather than a new field — and diverge only for a frame +// processed after a LATER commit has already landed, which is exactly the case +// `export_attachment_cek_recv` exists to key correctly. No API signature change; a +// behavior change worth knowing if anything ever keyed on epoch during a crossed-commit +// window before GER-1985. +// +// One error variant appended, `AttachmentComponentUnavailable` (the recv ledger missed or +// evicted the requested epoch — not retriable; the attachment is unopenable by this +// session). Archive layout bumps 3→4 (`SESSION_ARCHIVE_VERSION`): v3 shipped at +// 0.15.0/0.15.1 with its two-field tail before this change, closing the in-place-mutation +// exception that field itself documents — the attachment ledgers could NOT land in v3 the +// way `pq_wedged` did, or every session persisted by a released 0.15.x build would fail +// `ArchiveInvalid` on restore. v3 joins v2 as an accepted older layout on decode. const BINDING_CONTRACT_VERSION: u64 = 33; /// See `BINDING_CONTRACT_VERSION`. Exported so the Swift layer can verify the @@ -1019,11 +1034,6 @@ pub enum TwoMlsPqError { /// reachable from any honest flow (a peer-forced trigger failure is refused in the guard /// phase before anything is consumed); route to re-establishment. Queryable via /// `pq_side_band_wedged`. - // - // Deliberately the LAST variant: uniffi numbers error cases by position, so appending - // keeps every prior variant's ordinal stable. Keep appending future variants here (the - // contract bump already forces binding/binary pairing, but there is no reason to - // renumber the survivors). #[error("PQ bind trigger failed past its point of no return; re-establish")] BindTriggerFailed, /// `export_attachment_cek_recv` was asked for an epoch this session never ledgered @@ -1035,6 +1045,11 @@ pub enum TwoMlsPqError { /// frame itself decrypted fine) — it means the attachment behind this specific frame /// cannot be opened by this session and the app should treat the fetch as failed, not /// retry it against this session. + // + // Deliberately the LAST variant: uniffi numbers error cases by position, so appending + // keeps every prior variant's ordinal stable. Keep appending future variants here (the + // contract bump already forces binding/binary pairing, but there is no reason to + // renumber the survivors). #[error("no ledgered attachment component for the requested epoch")] AttachmentComponentUnavailable, } diff --git a/rust/two-mls-pq/src/session/archive.rs b/rust/two-mls-pq/src/session/archive.rs index 1fa3a7e..7ec6836 100644 --- a/rust/two-mls-pq/src/session/archive.rs +++ b/rust/two-mls-pq/src/session/archive.rs @@ -1,19 +1,19 @@ //! Session archive (de)serialization: the versioned single-blob layout, the //! `archive_wire` TLS structs, the state<->wire conversions, and the //! `archive` / `from_archive` endpoints. The layout version is a whole-blob -//! compatibility gate, and since v3 it admits exactly one older layout -- see +//! compatibility gate, and since v4 it admits exactly two older layouts -- see //! the note on `SESSION_ARCHIVE_VERSION`. use super::*; // The session archive layout version. The byte covers the WHOLE layout, and it is the ONLY // thing that decides which layouts a build will read: `decode_wire` accepts the current -// version and — since v3 — the one named by `SESSION_ARCHIVE_VERSION_V2`, rejecting every -// other as `ArchiveInvalid`. Anything not on that list simply fails to decode and is -// regenerated. The header also carries the concrete `ApqCipherSuite` pair (4 bytes, classical -// then pq, big-endian) in place of the old PQ-mode byte: the suite is a stored session -// property, and a restored archive whose pair differs from this build's pinned suite fails -// loudly. +// version and — since v4 — the two named by `SESSION_ARCHIVE_VERSION_V3` and +// `SESSION_ARCHIVE_VERSION_V2`, rejecting every other as `ArchiveInvalid`. Anything not on +// that list simply fails to decode and is regenerated. The header also carries the concrete +// `ApqCipherSuite` pair (4 bytes, classical then pq, big-endian) in place of the old PQ-mode +// byte: the suite is a stored session property, and a restored archive whose pair differs +// from this build's pinned suite fails loudly. // // MONOTONIC ACROSS RELEASES. Every change to the archive's layout OR its acceptance semantics // (a new field, a reshaped field, or a tightened restore-time validation) that a RELEASED @@ -28,7 +28,10 @@ use super::*; // introduced v3 and before any release wrote it. The hatch CLOSES the instant the byte ships: // the first RELEASE to write byte N freezes N, and the next layout change bumps to N+1 like // any other. So mutating in place is legal only behind a check that no tag has been cut while -// this byte was current — at 0.15.0, v3 freezes. +// this byte was current — v3 froze exactly this way, at 0.15.0 (confirmed: `git tag` on the +// release remote shows v0.15.0/v0.15.1 both shipping `SESSION_ARCHIVE_VERSION = 3`, which is +// what makes the v4 bump below a hard requirement rather than a nice-to-have — an in-place +// change here would have made every 0.15.x-persisted session fail `ArchiveInvalid` on restore). // // This ends the earlier pre-release convention of leaving the byte untouched (and the // 2026-07-13 floor reset to 1); those and the original @@ -38,30 +41,46 @@ use super::*; // // ACCEPTING AN OLD VERSION IS THE EXCEPTION, NOT THE NEW RULE. It costs a decode path that // must stay correct for a layout nobody writes any more, so it is worth paying only to carry -// real sessions across a release — as v3 does for 0.14 — and the acceptance should be dropped -// again once those sessions are gone. Keeping the layouts one `else` apart, rather than -// forking the whole struct, is what makes that removal a deletion instead of a merge. +// real sessions across a release — as v3 does for 0.14, and v4 now does for 0.15.x — and the +// acceptance should be dropped again once those sessions are gone. Keeping the layouts one +// `else`/`match` arm apart, rather than forking the whole struct, is what makes that removal a +// deletion instead of a merge. // // v2: restore-time validation tightened — the bootstrap twin-field invariant and the 32-byte // commitment length are now enforced on decode (see `session_from_wire`). // -// v3 (this change): the A.4 legs moved to the classical groups, so a `Responding` round now -// retains its `wire_ct` for re-wrapping (see `PqInflight::Responding`). THIS IS THE FIRST -// VERSION WITH A MIGRATION, and the hard-cut rule above is relaxed exactly this far: v2 is -// still ACCEPTED on decode, because 0.14 shipped to real sessions whose connections must -// survive the upgrade. The mechanism is append-only — the new state rides an `ArchiveTail` -// encoded AFTER the (byte-unchanged) `SessionArchive`, so a v2 blob decodes as the same -// prefix and its absent tail restores as `None`, which is exactly right: a v2 round's legs -// rode the PQ groups, whose `pq_epoch` cannot move mid-round, so they never re-wrap. Writing -// is always v3. A v3 blob on a 0.14 build still fails there, which is the hard cut's -// remaining, intended direction. +// v3: the A.4 legs moved to the classical groups, so a `Responding` round now retains its +// `wire_ct` for re-wrapping (see `PqInflight::Responding`). THIS IS THE FIRST VERSION WITH A +// MIGRATION, and the hard-cut rule above is relaxed exactly this far: v2 is still ACCEPTED on +// decode, because 0.14 shipped to real sessions whose connections must survive the upgrade. +// The mechanism is append-only — the new state rides an `ArchiveTail` encoded AFTER the +// (byte-unchanged) `SessionArchive`, so a v2 blob decodes as the same prefix and its absent +// tail restores as empty, which is exactly right: a v2 round's legs rode the PQ groups, whose +// `pq_epoch` cannot move mid-round, so they never re-wrap. v3 also LATER took a second tail +// field, `pq_wedged` (the side-band wedge verdict), in place rather than bumping — the +// unreleased-byte exception, valid at the time because v3 had not yet shipped. SHIPPED at +// 0.15.0/0.15.1 with exactly those two tail fields (`responder_wire_ct`, `pq_wedged`) and +// nothing else — that two-field shape is now frozen as `archive_wire::ArchiveTailV3`, decoded +// only to translate into the current `ArchiveTail` with empty attachment ledgers (see +// `decode_wire`). A v3 or v2 blob on a build predating either still fails there, which is the +// hard cut's remaining, intended direction. // -// v3 also LATER took a second tail field, `pq_wedged` (the side-band wedge verdict), in place -// rather than bumping — the unreleased-byte exception above, valid because v3 has not shipped. -// Once 0.15.0 writes v3 that door closes; the next tail change bumps to v4. -const SESSION_ARCHIVE_VERSION: u8 = 3; -/// The one older layout still accepted on decode (see the version note): identical to v3 -/// minus the trailing [`archive_wire::ArchiveTail`]. +// v4 (this change, GER-1985): the attachment-CEK send/recv ledgers +// (`ArchiveTail::send_attachment_ledger` / `recv_attachment_ledger`) join the tail. This MUST +// bump rather than land in place, unlike v3's own two in-place tail additions: v3 is no longer +// an unreleased byte (0.15.0/0.15.1 both shipped it with the OLD two-field tail), so mutating +// `ArchiveTail` further in place would make every session either release persisted fail +// `ArchiveInvalid` on restore under this build. v3 joins v2 as an accepted old layout — the +// same append-only mechanism, one version further out. +const SESSION_ARCHIVE_VERSION: u8 = 4; +/// The newer of the two older layouts still accepted on decode (see the version note): +/// identical to v2 plus a trailing two-field tail — `responder_wire_ct` and `pq_wedged`, the +/// exact shape SHIPPED at 0.15.0/0.15.1, before the attachment ledgers existed. Decoded via +/// [`archive_wire::ArchiveTailV3`], never `archive_wire::ArchiveTail` directly (whose current +/// shape a v3 blob was never encoded against). +const SESSION_ARCHIVE_VERSION_V3: u8 = 3; +/// The older of the two layouts still accepted on decode (see the version note): identical to +/// v3 minus the trailing tail entirely (empty, not merely absent fields). const SESSION_ARCHIVE_VERSION_V2: u8 = 2; // In its own module because the derive-generated impls reference the std `Result`, which @@ -266,10 +285,39 @@ pub(crate) mod archive_wire { pub(in crate::session) bytes: Vec, } - /// State appended AFTER [`SessionArchive`] in a v3 blob. Append-only by construction: + /// The v3 shape of the tail — SHIPPED at 0.15.0/0.15.1 with exactly these two fields and + /// nothing else (see the `SESSION_ARCHIVE_VERSION` note). Frozen: a v3 blob's tail bytes + /// must decode against THIS struct, never the current `ArchiveTail`, whose additional + /// fields no v3 writer ever encoded. Exists only as a decode target for + /// `decode_wire` — [`Self::into_current`] is the sole consumer. `MlsEncode` is derived + /// too, but ONLY so the test suite can construct a genuine v3-shaped fixture through the + /// type system rather than hand-slicing bytes (mirroring how the existing v2 fixtures + /// build theirs) — production code never encodes this type. + #[derive(MlsSize, MlsEncode, MlsDecode)] + pub(in crate::session) struct ArchiveTailV3 { + pub(in crate::session) responder_wire_ct: Option, + pub(in crate::session) pq_wedged: Option, + } + + impl ArchiveTailV3 { + /// Lift a decoded v3 tail into the current shape: the two shared fields carry over + /// verbatim, and the attachment ledgers restore empty — correct by construction, since + /// a v3 writer never captured them (the ledgers did not exist yet). + pub(in crate::session) fn into_current(self) -> ArchiveTail { + ArchiveTail { + responder_wire_ct: self.responder_wire_ct, + pq_wedged: self.pq_wedged, + send_attachment_ledger: Vec::new(), + recv_attachment_ledger: Vec::new(), + } + } + } + + /// State appended AFTER [`SessionArchive`] in a v4 blob. Append-only by construction: /// a v2 blob simply ends where this begins, decoding as the same prefix with an - /// all-`None` tail (see the `SESSION_ARCHIVE_VERSION` note). Future additive state - /// belongs here too, one field per addition, never reordered. + /// all-empty tail; a v3 blob's tail decodes as [`ArchiveTailV3`] and lifts via + /// [`ArchiveTailV3::into_current`] (see the `SESSION_ARCHIVE_VERSION` note). Future + /// additive state belongs here too, one field per addition, never reordered. #[derive(MlsSize, MlsEncode, MlsDecode)] pub(in crate::session) struct ArchiveTail { /// Set only for a `Responding` round whose CT rode the classical carrier. `None` @@ -708,7 +756,9 @@ impl TwoMlsPqSession { /// `restore`. The restored session starts with no sink — attach one with /// `install_sink` (which pushes a fresh baseline checkpoint). /// -/// `tail` is the blob's v3 append-only section, empty for a restored v2 archive. +/// `tail` is the blob's append-only section (see the `SESSION_ARCHIVE_VERSION` note), +/// already normalized to the current shape — empty for a restored v2 archive, lifted via +/// `ArchiveTailV3::into_current` for a restored v3 one. fn session_from_wire( wire: archive_wire::SessionArchive, tail: archive_wire::ArchiveTail, @@ -1293,12 +1343,14 @@ pub(super) fn encode_core(inner: &mut SessionInner) -> Result> { encode_archive(&inner.suite, &wire, &tail) } -/// Decode + header-validate a single archive blob into its wire struct and v3 tail. +/// Decode + header-validate a single archive blob into its wire struct and current-shape tail. /// -/// Two layouts are accepted (see the `SESSION_ARCHIVE_VERSION` note): v3, whose body is -/// followed by an [`archive_wire::ArchiveTail`], and v2, which ends at the body and restores -/// with an empty tail. Both still require the body to be followed by EXACTLY its version's -/// remaining bytes, so a truncated or over-long blob fails as before. +/// Three layouts are accepted (see the `SESSION_ARCHIVE_VERSION` note): v4, whose body is +/// followed by an [`archive_wire::ArchiveTail`]; v3, whose body is followed by the older +/// two-field [`archive_wire::ArchiveTailV3`] (lifted via `into_current`); and v2, which ends +/// at the body and restores with an empty tail. All three still require the body to be +/// followed by EXACTLY its version's remaining bytes, so a truncated or over-long blob fails +/// as before. fn decode_wire( archive: &Archive, ) -> Result<(archive_wire::SessionArchive, archive_wire::ArchiveTail)> { @@ -1307,7 +1359,9 @@ fn decode_wire( // build's declared suite — fail loudly across builds rather than misinterpret the group // snapshots (a recognized `TwoMlsSuite` variant is a coherent APQ pair by construction). let (version, mut rest) = match archive.bytes.as_slice() { - [version @ (SESSION_ARCHIVE_VERSION | SESSION_ARCHIVE_VERSION_V2), s0, s1, s2, s3, rest @ ..] + [version @ (SESSION_ARCHIVE_VERSION + | SESSION_ARCHIVE_VERSION_V3 + | SESSION_ARCHIVE_VERSION_V2), s0, s1, s2, s3, rest @ ..] if crate::suite::TwoMlsSuite::from_wire([*s0, *s1, *s2, *s3]) == Some(crate::suite::TwoMlsSuite::CURRENT) => { @@ -1317,11 +1371,13 @@ fn decode_wire( }; let wire = archive_wire::SessionArchive::mls_decode(&mut rest) .map_err(|_| TwoMlsPqError::ArchiveInvalid)?; - let tail = if version == SESSION_ARCHIVE_VERSION { - archive_wire::ArchiveTail::mls_decode(&mut rest) + let tail = match version { + SESSION_ARCHIVE_VERSION => archive_wire::ArchiveTail::mls_decode(&mut rest) + .map_err(|_| TwoMlsPqError::ArchiveInvalid)?, + SESSION_ARCHIVE_VERSION_V3 => archive_wire::ArchiveTailV3::mls_decode(&mut rest) .map_err(|_| TwoMlsPqError::ArchiveInvalid)? - } else { - archive_wire::ArchiveTail::empty() + .into_current(), + _ => archive_wire::ArchiveTail::empty(), }; if !rest.is_empty() { return Err(TwoMlsPqError::ArchiveInvalid); diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index 49b83d6..2a604e2 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -328,7 +328,7 @@ fn test_archive_reencode_is_byte_identical() { fn test_session_archive_version_is_pinned() { let (alice, _bob) = establish_sessions(); let archive = assert_ok!(alice.archive()); - assert_eq!(archive.bytes[0], 3); + assert_eq!(archive.bytes[0], 4); } /// The pre-committed bootstrap KP carries the FROZEN establishment credential. Enough @@ -8422,3 +8422,78 @@ fn test_archive_preserves_attachment_ledgers() { "restored recv ledger must reproduce the same CEK" ); } + +/// The compatibility floor v4 exists for: restore a v3 archive shaped EXACTLY like what +/// 0.15.0/0.15.1 actually shipped — `responder_wire_ct` and `pq_wedged` only, no attachment +/// ledgers, because those releases predate GER-1985 — and confirm it restores usable rather +/// than `ArchiveInvalid`. Mirrors the existing v2 fixtures' technique (decode the current +/// body, re-encode under the older version byte) one layer further out: truncate the CURRENT +/// tail down to its v3-shaped subset instead of dropping it entirely. +/// +/// The recv ledger (not send) is the load-bearing half of this test: a send-side re-export at +/// the SAME epoch would succeed whether or not the ledger truly survived the downgrade (lazy +/// hit or fresh export are both silent successes), so it can't distinguish "restored empty" from +/// "restored with a leftover entry". The recv side can: ledger a DEPARTED epoch's component +/// before downgrading, and if the v3 fixture wrongly carried it forward, the post-restore fetch +/// at that epoch would SUCCEED instead of reporting `AttachmentComponentUnavailable`. +#[test] +fn test_v3_archive_restores_with_empty_attachment_ledgers() { + use mls_rs::mls_rs_codec::{MlsDecode, MlsEncode}; + + let (alice, bob) = establish_sessions(); + let key_id = vec![0x09u8; 32]; + + // Epoch 1: alice mints and ledgers the attachment CEK, then encrypts. + assert_ok!(alice.prepare_to_encrypt(None)); + let cek_epoch1 = assert_ok!(alice.export_attachment_cek_send(key_id.clone())); + let delayed = assert_ok!(alice.encrypt(b"pre-downgrade-attachment".to_vec())); + + // A routine crossing commit — bob proposes, alice folds and commits — advances alice's + // send group (bob's recv group) past epoch 1. Bob applies it via a SEPARATE frame first, + // which is exactly where his eager capture ledgers the departing epoch 1 for him. + assert_ok!(bob.prepare_to_encrypt(None)); + let proposal = assert_ok!(bob.encrypt(b"routine".to_vec())); + let result = assert_some!(assert_ok!(alice.process_incoming(proposal.cipher_text))); + assert_ok!(alice.queue_proposal(assert_some!(result.proposal).digest)); + assert_ok!(alice.prepare_to_encrypt(None)); + let crossing = assert_ok!(alice.encrypt(b"crossing-commit".to_vec())); + let result = assert_some!(assert_ok!(bob.process_incoming(crossing.cipher_text))); + assert!(assert_some!(result.application_message).epoch > 1); + + // Sanity baseline BEFORE any downgrade: bob's ledger genuinely holds epoch 1 — proves the + // fixture below is stripping something real, not asserting on an already-empty ledger. + let result = assert_some!(assert_ok!(bob.process_incoming(delayed.cipher_text))); + let epoch = assert_some!(result.application_message).epoch; + assert_eq!(epoch, 1); + let cek_before_downgrade = assert_ok!(bob.export_attachment_cek_recv(key_id.clone(), epoch)); + assert_eq!(cek_before_downgrade, cek_epoch1); + + // Bob's current (v4) archive, rewritten into the v3 layout 0.15.0/0.15.1 shipped: same + // header suite bytes, same body, tail truncated to the two fields that layout carried. + let v4 = assert_ok!(bob.archive()).bytes; + let v3 = { + let mut rest = &v4[5..]; + let body = assert_ok!(super::archive_wire::SessionArchive::mls_decode(&mut rest)); + let tail = assert_ok!(super::archive_wire::ArchiveTail::mls_decode(&mut rest)); + let mut out = v4[..5].to_vec(); + out[0] = 3; + assert_ok!(body.mls_encode(&mut out)); + assert_ok!(super::archive_wire::ArchiveTailV3 { + responder_wire_ct: tail.responder_wire_ct, + pq_wedged: tail.pq_wedged, + } + .mls_encode(&mut out)); + out + }; + let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { bytes: v3 })); + + // The departed epoch's component is gone — a v3 writer never captured it — so the fetch + // must fail explicitly, never silently succeed with stale or wrong material. + assert_err!( + restored.export_attachment_cek_recv(key_id, epoch), + TwoMlsPqError::AttachmentComponentUnavailable + ); + + // And the session is otherwise perfectly usable across the downgrade-then-restore. + message_round(&restored, &alice, b"after-v3-restore"); +} From e8a12a9ed1e35018a2e8e4d92bee612054036c0d Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 15:25:09 -0700 Subject: [PATCH 5/7] Add changeset for the attachment CEK export (GER-1985) --- .changeset/attachment-cek-export.md | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .changeset/attachment-cek-export.md diff --git a/.changeset/attachment-cek-export.md b/.changeset/attachment-cek-export.md new file mode 100644 index 0000000..159ae12 --- /dev/null +++ b/.changeset/attachment-cek-export.md @@ -0,0 +1,54 @@ +--- +"@germ-network/two-mls-pq": minor +--- + +Export the attachment wire CEK (GER-1985) + +Two FFI additions, `export_attachment_cek_send(key_id)` and +`export_attachment_cek_recv(key_id, epoch)`, deriving the symmetric key an +attachment's sealed container is opened with: +`ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. +Classical-only by design — the classical key schedule already absorbs a +PQ-derived PSK via the existing APQ-PSK binding, so the export is downstream +of ML-KEM entropy without needing to combine both APQ halves, and 3.0.11's +receive-only staging freezes whatever recipe ships. `ExpandWithLabel` is +reimplemented locally in `apq` (mls-rs keeps its own `pub(crate)`, and its one +public door hard-codes empty context) — the reimplementation is verified +field-for-field against mls-rs's own private `Label` struct, since neither the +determinism test nor the cross-provider interop test can catch a wrong RFC +9420 label on its own: both sides of either test run the same code. + +Send is lazy — export and ledger the send-classical epoch's component on +first use, since a session that never sends an attachment should not pay a +0xFF03 export at all. Recv is the harder half: `safe_export_secret` only +works at a group's CURRENT epoch, but mls-rs retains older epoch secrets, so +a frame delayed past a later commit can still decrypt from an epoch the recv +group has since moved past. The session now captures the recv-classical +component EAGERLY, immediately before a commit advances past it, into a +small session-owned ledger — and, for the common case of an attachment +fetched before anything has committed past its epoch yet, `export_attachment_cek_recv` +also exports live rather than requiring the eager path to have already run. +A ledger miss reports the new `AttachmentComponentUnavailable` — not +retriable, since the component is either evicted or was never captured, and +the frame that needs it decrypted fine; only the attachment behind it is +unopenable by this session. + +Finding the recv-side ledger correctness case exposed a real, pre-existing +bug: `MlsSenderMessage.epoch` reported the recv group's CURRENT epoch at +decrypt time rather than the frame's own authenticated epoch +(`MlsMessage::epoch()`, the same accessor `commit.epoch()` already used +elsewhere in this crate). The two agree for every in-order frame — which is +every frame any existing caller has ever observed, so this is a behavior fix +rather than an API change — and diverge only for a frame processed after a +later commit has already landed, exactly the case this ticket needed keyed +correctly. + +Archive layout bumps 3 → 4 rather than adding the two new ledger fields to +the shipped v3 tail in place: v3's own "unreleased byte" exception closed at +0.15.0, which shipped v3's two-field tail (`responder_wire_ct`, `pq_wedged`) +for real. A v3 blob's tail now decodes against a frozen `ArchiveTailV3` +shape and lifts into the current tail with empty attachment ledgers; v3 +joins v2 as an accepted older layout on restore, exactly the mechanism v3 +itself used to carry 0.14 sessions across its own introduction. + +Binding contract 32 → 33. From 49fe56ba41ef0967241c88228144d306b86c8c1a Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 15:47:28 -0700 Subject: [PATCH 6/7] Tighten comments to state the current design, not its history --- Sources/TwoMLSPQ/PQSession.swift | 15 ++-- rust/two-mls-pq/src/lib.rs | 39 ++++------ rust/two-mls-pq/src/session/archive.rs | 85 ++++++++------------- rust/two-mls-pq/src/session/messaging.rs | 94 ++++++++++-------------- 4 files changed, 87 insertions(+), 146 deletions(-) diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index bcd0842..26d919b 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -240,15 +240,12 @@ import TwoMLSPQBinding // v33 (contract 33, GER-1985): two FFI additions, `exportAttachmentCekSend(keyId:)` and // `exportAttachmentCekRecv(keyId:epoch:)` — the wire attachment CEK, derived // classical-only from the group's 0xFF03 exporter component. Send must be called after -// `prepareToEncrypt`, before `encrypt`; recv is keyed by the frame's own classical epoch — -// `PQSenderMessage.epoch`'s MEANING changes here (no signature change): it used to report -// the recv group's current epoch at decrypt time, now reports the frame's own -// authenticated epoch, which only diverges for a frame processed after a later commit has -// already landed. New crate error `.attachmentComponentUnavailable` appended (the recv -// ledger missed or evicted the requested epoch — not retriable; the attachment is -// unopenable by this session). Hosts must handle the new case (the error map is -// exhaustive). Archive layout bumps 3→4: v3 shipped at 0.15.0/0.15.1 before this change, -// so the attachment ledgers could not land in v3 in place the way earlier tail fields did. +// `prepareToEncrypt`, before `encrypt`; recv is keyed by the frame's own classical epoch. +// `PQSenderMessage.epoch` changes MEANING, not signature: it now reports the frame's own +// authenticated epoch rather than the recv group's at decrypt time, differing only for a +// frame processed after a later commit landed. New crate error +// `.attachmentComponentUnavailable` appended — hosts must handle it (the error map is +// exhaustive). Archive layout bumps 3→4. private let expectedBindingContract: UInt64 = 33 enum TwoMLSPQBindingContract { diff --git a/rust/two-mls-pq/src/lib.rs b/rust/two-mls-pq/src/lib.rs index 41faa9b..9694dca 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -463,34 +463,23 @@ pub fn version() -> String { // `SessionId` uniffi record are gone. Drops two FFI symbols — re-pair the vendored binding — // no wire (archive layout stays v3) or error-variant change. // -// v33 (GER-1985): two FFI additions, `export_attachment_cek_send(key_id) -> Vec` and -// `export_attachment_cek_recv(key_id, epoch) -> Vec` — the wire attachment CEK, +// v33 (GER-1985): two FFI additions, `export_attachment_cek_send(key_id)` and +// `export_attachment_cek_recv(key_id, epoch)` — the wire attachment CEK, // `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`, derived -// classical-only (Linear ruling on GER-1985: the classical key schedule already absorbs a -// PQ-derived PSK, so the export is downstream of ML-KEM entropy without needing to combine -// both APQ halves). Send must be called after `prepare_to_encrypt`, before `encrypt`; recv -// is keyed by the frame's OWN classical epoch, read from a session-owned ledger since -// `safe_export_secret` only exports at a group's CURRENT epoch and a delayed frame's epoch -// may already be behind it. +// classical-only: the classical key schedule already absorbs a PQ-derived PSK, so the export +// is downstream of ML-KEM entropy without combining both APQ halves. Send must be called +// after `prepare_to_encrypt`, before `encrypt`. Recv is keyed by the frame's OWN classical +// epoch and read from a session-owned ledger, since `safe_export_secret` exports only at a +// group's CURRENT epoch while a delayed frame's may already be behind it. // -// `MlsSenderMessage.epoch`'s MEANING CHANGES here, not just its plumbing: it used to report -// the recv group's CURRENT epoch at the moment of decrypt; it now reports the FRAME's own -// authenticated epoch (`MlsMessage::epoch()`, read before the frame is consumed — the same -// accessor `commit.epoch()` already used elsewhere in this crate). The two agree for every -// in-order frame, which is every frame any existing caller has ever observed — this is why -// the change is field-compatible rather than a new field — and diverge only for a frame -// processed after a LATER commit has already landed, which is exactly the case -// `export_attachment_cek_recv` exists to key correctly. No API signature change; a -// behavior change worth knowing if anything ever keyed on epoch during a crossed-commit -// window before GER-1985. +// `MlsSenderMessage.epoch` changes MEANING, not signature: it now reports the frame's own +// authenticated epoch rather than the recv group's epoch at decrypt time. Identical for +// in-order frames — every frame any caller has observed — and different only for one +// processed after a later commit landed, the case recv derivation must key correctly. // -// One error variant appended, `AttachmentComponentUnavailable` (the recv ledger missed or -// evicted the requested epoch — not retriable; the attachment is unopenable by this -// session). Archive layout bumps 3→4 (`SESSION_ARCHIVE_VERSION`): v3 shipped at -// 0.15.0/0.15.1 with its two-field tail before this change, closing the in-place-mutation -// exception that field itself documents — the attachment ledgers could NOT land in v3 the -// way `pq_wedged` did, or every session persisted by a released 0.15.x build would fail -// `ArchiveInvalid` on restore. v3 joins v2 as an accepted older layout on decode. +// One error variant appended, `AttachmentComponentUnavailable`. Archive layout bumps 3→4 +// (`SESSION_ARCHIVE_VERSION`): v3 was already released, so the ledgers could not join its +// tail in place without failing every 0.15.x-persisted session on restore. const BINDING_CONTRACT_VERSION: u64 = 33; /// See `BINDING_CONTRACT_VERSION`. Exported so the Swift layer can verify the diff --git a/rust/two-mls-pq/src/session/archive.rs b/rust/two-mls-pq/src/session/archive.rs index 7ec6836..83fc337 100644 --- a/rust/two-mls-pq/src/session/archive.rs +++ b/rust/two-mls-pq/src/session/archive.rs @@ -28,10 +28,7 @@ use super::*; // introduced v3 and before any release wrote it. The hatch CLOSES the instant the byte ships: // the first RELEASE to write byte N freezes N, and the next layout change bumps to N+1 like // any other. So mutating in place is legal only behind a check that no tag has been cut while -// this byte was current — v3 froze exactly this way, at 0.15.0 (confirmed: `git tag` on the -// release remote shows v0.15.0/v0.15.1 both shipping `SESSION_ARCHIVE_VERSION = 3`, which is -// what makes the v4 bump below a hard requirement rather than a nice-to-have — an in-place -// change here would have made every 0.15.x-persisted session fail `ArchiveInvalid` on restore). +// this byte was current: check `git tag`, not memory. v3 froze at 0.15.0. // // This ends the earlier pre-release convention of leaving the byte untouched (and the // 2026-07-13 floor reset to 1); those and the original @@ -49,38 +46,24 @@ use super::*; // v2: restore-time validation tightened — the bootstrap twin-field invariant and the 32-byte // commitment length are now enforced on decode (see `session_from_wire`). // -// v3: the A.4 legs moved to the classical groups, so a `Responding` round now retains its -// `wire_ct` for re-wrapping (see `PqInflight::Responding`). THIS IS THE FIRST VERSION WITH A -// MIGRATION, and the hard-cut rule above is relaxed exactly this far: v2 is still ACCEPTED on -// decode, because 0.14 shipped to real sessions whose connections must survive the upgrade. -// The mechanism is append-only — the new state rides an `ArchiveTail` encoded AFTER the -// (byte-unchanged) `SessionArchive`, so a v2 blob decodes as the same prefix and its absent -// tail restores as empty, which is exactly right: a v2 round's legs rode the PQ groups, whose -// `pq_epoch` cannot move mid-round, so they never re-wrap. v3 also LATER took a second tail -// field, `pq_wedged` (the side-band wedge verdict), in place rather than bumping — the -// unreleased-byte exception, valid at the time because v3 had not yet shipped. SHIPPED at -// 0.15.0/0.15.1 with exactly those two tail fields (`responder_wire_ct`, `pq_wedged`) and -// nothing else — that two-field shape is now frozen as `archive_wire::ArchiveTailV3`, decoded -// only to translate into the current `ArchiveTail` with empty attachment ledgers (see -// `decode_wire`). A v3 or v2 blob on a build predating either still fails there, which is the -// hard cut's remaining, intended direction. +// v3: the A.4 legs moved to the classical groups, so a `Responding` round retains its +// `wire_ct` for re-wrapping (see `PqInflight::Responding`). First version with a migration: +// v2 is still ACCEPTED on decode, because 0.14 shipped to real sessions. The mechanism is +// append-only — the state rides an `ArchiveTail` encoded AFTER the (byte-unchanged) +// `SessionArchive`, so a v2 blob decodes as the same prefix with an empty tail, which is +// right: a v2 round's legs rode the PQ groups, whose `pq_epoch` cannot move mid-round, so +// they never re-wrap. Shipped at 0.15.x carrying exactly two tail fields, frozen as +// `archive_wire::ArchiveTailV3`. // -// v4 (this change, GER-1985): the attachment-CEK send/recv ledgers -// (`ArchiveTail::send_attachment_ledger` / `recv_attachment_ledger`) join the tail. This MUST -// bump rather than land in place, unlike v3's own two in-place tail additions: v3 is no longer -// an unreleased byte (0.15.0/0.15.1 both shipped it with the OLD two-field tail), so mutating -// `ArchiveTail` further in place would make every session either release persisted fail -// `ArchiveInvalid` on restore under this build. v3 joins v2 as an accepted old layout — the -// same append-only mechanism, one version further out. +// v4 (GER-1985): the attachment-CEK send/recv ledgers join the tail. v3 was already released, +// so this bumps rather than landing in place — an in-place tail change would fail every +// 0.15.x-persisted session on restore. v3 joins v2 as an accepted old layout. const SESSION_ARCHIVE_VERSION: u8 = 4; -/// The newer of the two older layouts still accepted on decode (see the version note): -/// identical to v2 plus a trailing two-field tail — `responder_wire_ct` and `pq_wedged`, the -/// exact shape SHIPPED at 0.15.0/0.15.1, before the attachment ledgers existed. Decoded via -/// [`archive_wire::ArchiveTailV3`], never `archive_wire::ArchiveTail` directly (whose current -/// shape a v3 blob was never encoded against). +/// Accepted on decode: v2 plus a two-field tail (`responder_wire_ct`, `pq_wedged`). Decodes +/// via [`archive_wire::ArchiveTailV3`], never the current `ArchiveTail` — a v3 blob carries +/// no attachment-ledger bytes for it to read. const SESSION_ARCHIVE_VERSION_V3: u8 = 3; -/// The older of the two layouts still accepted on decode (see the version note): identical to -/// v3 minus the trailing tail entirely (empty, not merely absent fields). +/// Accepted on decode: the body with no tail at all. const SESSION_ARCHIVE_VERSION_V2: u8 = 2; // In its own module because the derive-generated impls reference the std `Result`, which @@ -285,14 +268,10 @@ pub(crate) mod archive_wire { pub(in crate::session) bytes: Vec, } - /// The v3 shape of the tail — SHIPPED at 0.15.0/0.15.1 with exactly these two fields and - /// nothing else (see the `SESSION_ARCHIVE_VERSION` note). Frozen: a v3 blob's tail bytes - /// must decode against THIS struct, never the current `ArchiveTail`, whose additional - /// fields no v3 writer ever encoded. Exists only as a decode target for - /// `decode_wire` — [`Self::into_current`] is the sole consumer. `MlsEncode` is derived - /// too, but ONLY so the test suite can construct a genuine v3-shaped fixture through the - /// type system rather than hand-slicing bytes (mirroring how the existing v2 fixtures - /// build theirs) — production code never encodes this type. + /// The frozen v3 tail: exactly these two fields, the shape 0.15.x shipped. A v3 blob's + /// tail bytes decode against THIS struct, never the current [`ArchiveTail`] — its extra + /// fields have no bytes to read there. `MlsEncode` is derived only so tests can build a + /// v3 fixture through the type system; production never encodes it. #[derive(MlsSize, MlsEncode, MlsDecode)] pub(in crate::session) struct ArchiveTailV3 { pub(in crate::session) responder_wire_ct: Option, @@ -300,9 +279,8 @@ pub(crate) mod archive_wire { } impl ArchiveTailV3 { - /// Lift a decoded v3 tail into the current shape: the two shared fields carry over - /// verbatim, and the attachment ledgers restore empty — correct by construction, since - /// a v3 writer never captured them (the ledgers did not exist yet). + /// Lift into the current shape: shared fields verbatim, attachment ledgers empty — + /// a v3 writer never captured them. pub(in crate::session) fn into_current(self) -> ArchiveTail { ArchiveTail { responder_wire_ct: self.responder_wire_ct, @@ -334,18 +312,15 @@ pub(crate) mod archive_wire { /// mutations being real. A latch that healed on restore would hand the honest label /// back to the retriable lie the restored state still embodies. pub(in crate::session) pq_wedged: Option, - /// Attachment-CEK components for our SEND group's recent epochs (GER-1985). - /// - /// ARCHIVED because the exporter leaf is CONSUMED on first export: a restore that - /// dropped these could never re-derive them, so every attachment sent at a - /// still-live epoch would become unreadable to us on retry. Same reasoning as - /// `send_psk_ledger`, which rides the body for the same reason. + /// Attachment-CEK components for our SEND group's recent epochs. Archived because + /// the exporter leaf is CONSUMED on first export — a restore that dropped these + /// could never re-derive them, stranding every attachment sent at a still-live + /// epoch. Same reasoning as `send_psk_ledger`. pub(in crate::session) send_attachment_ledger: Vec, - /// The same for our RECV group — and MORE load-bearing, because these entries can - /// only ever be captured at the instant their epoch departs (see - /// `SessionInner::recv_attachment_ledger`). A restore that lost them would leave - /// every in-flight attachment from a superseded epoch permanently underivable, - /// which the receive path cannot distinguish from a corrupt key. + /// The same for our RECV group, and more load-bearing: these can only be captured + /// at the instant their epoch departs (see `SessionInner::recv_attachment_ledger`), + /// so losing them strands every in-flight attachment from a superseded epoch — + /// indistinguishable, at the receive path, from a corrupt key. pub(in crate::session) recv_attachment_ledger: Vec, } diff --git a/rust/two-mls-pq/src/session/messaging.rs b/rust/two-mls-pq/src/session/messaging.rs index 2907013..83c9c30 100644 --- a/rust/two-mls-pq/src/session/messaging.rs +++ b/rust/two-mls-pq/src/session/messaging.rs @@ -505,24 +505,18 @@ impl SessionInner { Ok(component) } - /// Capture the attachment-CEK component of our RECV group's CURRENT epoch, before a - /// staple advances past it (GER-1985). + /// Capture our RECV group's current-epoch attachment component before a staple advances + /// past it. Called on the epoch-advance path, not on demand, and it has to be: + /// `safe_export_secret` exports only at the current epoch, but frames SENT at this epoch + /// stay decryptable and may still arrive. Deriving one of those later would silently key + /// on the wrong epoch and fail at SEAL-open as an opaque commitment mismatch. /// - /// EAGER, and it has to be: `safe_export_secret` only exports at the current epoch, so - /// once the staple below applies, this epoch's component is gone forever — while frames - /// SENT at this epoch remain decryptable and may still arrive. Deriving such a frame's - /// CEK at whatever epoch we had reached by then would produce a wrong key that fails - /// only at SEAL-open, as an opaque commitment mismatch. Called on the epoch-advance - /// path for that reason, not on demand — [`Self::recv_attachment_component`] ALSO - /// exports live for the still-current (not yet departing) epoch, so together the two - /// cover both "attachment fetched before anything commits past it" and "fetched after." - /// They cannot race each other into a double-export: both route through - /// `Self::ledger_attachment_component`'s skip-if-already-ledgered guard. + /// [`Self::recv_attachment_component`] covers the other direction, exporting live while + /// the epoch is still current. Neither can double-export: both route through + /// `Self::ledger_attachment_component`'s skip-if-ledgered guard. /// - /// Best-effort by design: a session that never receives an attachment still pays one - /// 32-byte export per applied staple, and a failure here (e.g. the leaf already - /// consumed at this epoch) must not fail the frame — the message itself is unaffected, - /// and a later attachment fetch surfaces the miss as retriable. + /// Best-effort: a failure here must not fail the frame — the message is unaffected, and + /// the miss surfaces later as `AttachmentComponentUnavailable`. pub(in crate::session) fn remember_recv_attachment_component(&mut self) { let Some(recv) = self.recv_group.as_mut() else { return; @@ -536,21 +530,17 @@ impl SessionInner { } } - /// The RECV component for `epoch` — the epoch the frame was SENT from, which the - /// caller reads off the decrypted message, never from the group's current state. + /// The RECV component for `epoch` — the epoch the frame was SENT from, read off the + /// decrypted message, never from the group's current state. /// - /// Two sources: a ledger hit (an already-DEPARTED epoch, captured by - /// [`Self::remember_recv_attachment_component`] before the commit that moved past - /// it), or — the common "just arrived, nothing has committed past it yet" case a - /// ledger-only lookup would miss — a LIVE export when `epoch` is still the recv - /// group's current one. `None` only when `epoch` is neither: evicted past - /// `ATTACHMENT_LEDGER_WINDOW`, never captured, or simply stale. + /// Two sources: a ledger hit for an already-departed epoch, or a LIVE export while + /// `epoch` is still current. `None` when it is neither — evicted past + /// `ATTACHMENT_LEDGER_WINDOW`, or never captured. /// - /// `&mut self`/fallible export means this can mutate and must run inside - /// `mutate_and_persist` like [`Self::send_attachment_component`] — the live branch - /// ledgers exactly like the eager capture does, so a later `remember_recv_attachment_component` - /// call for the same epoch (once it does depart) sees it already ledgered and skips - /// re-exporting (the leaf tolerates only one export, ever). + /// The live branch mutates, so this runs inside `mutate_and_persist` like + /// [`Self::send_attachment_component`]. It ledgers what it exports, so the later + /// eager capture for that epoch finds it already there and skips — the leaf tolerates + /// exactly one export. pub(in crate::session) fn recv_attachment_component( &mut self, epoch: u64, @@ -1287,13 +1277,10 @@ impl TwoMlsPqSession { let (staple, proposal_bytes, app_bytes) = decode_message_frame(&ciphertext)?; let app_msg = MlsMessage::from_bytes(&app_bytes).map_err(|_| TwoMlsPqError::DecryptionFailed)?; - // The frame's OWN epoch (its plaintext framing field, read before `app_msg` is - // consumed below) — NOT `recv.classical.current_epoch()` once decrypted, which - // is the GROUP's epoch at THAT MOMENT and silently disagrees with the frame's - // for a frame delayed past an intervening commit (GER-1985's recv-side ledger - // exists precisely to key on the frame's own epoch in that case). Trustworthy - // once paired with a successful decrypt below: a forged value here would derive - // the wrong epoch's key and fail AEAD auth, never reach this far. + // The frame's OWN epoch, read before `app_msg` is consumed — not the group's + // epoch after decrypt, which disagrees for a frame delayed past an intervening + // commit. Trustworthy once the decrypt below succeeds: a forged value keys the + // wrong epoch and fails AEAD auth. let frame_epoch = app_msg.epoch(); let mut inner = self.lock(); @@ -1810,20 +1797,17 @@ impl TwoMlsPqSession { }) } - /// Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + /// Derive the wire attachment CEK for our SEND group's current epoch: /// `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. /// - /// Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + /// Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**. A /// commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this - /// must derive from the epoch that commit lands at, the same one `encrypt`'s staple - /// commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; - /// deriving after `encrypt` is too late for that frame to carry an attachment sealed - /// under it. + /// must derive from the epoch that commit lands at, the one `encrypt`'s staple commits + /// to. Earlier risks a superseded epoch; later is too late for the frame to carry it. /// - /// `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that - /// separates every attachment's CEK from every other's, even within the same epoch. - /// Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` - /// blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + /// `key_id` is the caller-minted `AttachmentHeader.keyId`, the label context separating + /// each attachment's CEK within an epoch. A cold epoch exports and ledgers the 0xFF03 + /// component (persisted as `Core`); a warm one is a pure ledger read. pub fn export_attachment_cek_send(&self, key_id: Vec) -> Result> { let component = self.mutate_and_persist(crate::BlobKind::Core, |inner| { inner.send_attachment_component() @@ -1833,21 +1817,17 @@ impl TwoMlsPqSession { Ok(cek.to_vec()) } - /// Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + /// Derive the wire attachment CEK for a RECEIVED frame's classical epoch. /// - /// `epoch` is the classical epoch the frame was SENT from — read off the frame's own - /// decrypted result, never the recv group's current epoch (the two diverge the moment - /// any later commit lands on the recv group). + /// `epoch` is the epoch the frame was SENT from, read off its decrypted result — never + /// the recv group's current epoch, which diverges once any later commit lands. /// /// `AttachmentComponentUnavailable` means the component is unrecoverable for that - /// epoch: neither still current (a live export would have covered it) nor ledgered — - /// evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved - /// past it. This attachment cannot be opened by this session; it is not a transient - /// condition worth retrying. + /// epoch: neither current nor ledgered. The attachment cannot be opened by this + /// session — not a transient condition worth retrying. /// - /// May mutate (a live export for a not-yet-departed current epoch ledgers it, like - /// [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside - /// `mutate_and_persist` and persists a `Core` blob on that path. + /// May mutate (a live export ledgers what it derives), so this runs inside + /// `mutate_and_persist` and pushes a `Core` blob on that path. pub fn export_attachment_cek_recv(&self, key_id: Vec, epoch: u64) -> Result> { let component = self.mutate_and_persist(crate::BlobKind::Core, |inner| { inner From d80e1dd1275a7a3c6a4903f7f47f478e9adad8c0 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Wed, 5 Aug 2026 15:53:31 -0700 Subject: [PATCH 7/7] Re-sync the vendored binding after the doc-comment edits uniffi folds exported doc comments into its per-method checksums, so the comment tightening moved export_attachment_cek_send/recv's values and the binding had to be regenerated from a real build, not hand-edited. --- Sources/TwoMLSPQBinding/two_mls_pq.swift | 74 ++++++++++-------------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index cc6b3b0..a585ef5 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -1981,39 +1981,32 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable { func encrypt(appMessage: Data) throws -> EncryptResult /** - * Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch. * - * `epoch` is the classical epoch the frame was SENT from — read off the frame's own - * decrypted result, never the recv group's current epoch (the two diverge the moment - * any later commit lands on the recv group). + * `epoch` is the epoch the frame was SENT from, read off its decrypted result — never + * the recv group's current epoch, which diverges once any later commit lands. * * `AttachmentComponentUnavailable` means the component is unrecoverable for that - * epoch: neither still current (a live export would have covered it) nor ledgered — - * evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved - * past it. This attachment cannot be opened by this session; it is not a transient - * condition worth retrying. + * epoch: neither current nor ledgered. The attachment cannot be opened by this + * session — not a transient condition worth retrying. * - * May mutate (a live export for a not-yet-departed current epoch ledgers it, like - * [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside - * `mutate_and_persist` and persists a `Core` blob on that path. + * May mutate (a live export ledgers what it derives), so this runs inside + * `mutate_and_persist` and pushes a `Core` blob on that path. */ func exportAttachmentCekRecv(keyId: Data, epoch: UInt64) throws -> Data /** - * Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + * Derive the wire attachment CEK for our SEND group's current epoch: * `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. * - * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**. A * commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this - * must derive from the epoch that commit lands at, the same one `encrypt`'s staple - * commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; - * deriving after `encrypt` is too late for that frame to carry an attachment sealed - * under it. + * must derive from the epoch that commit lands at, the one `encrypt`'s staple commits + * to. Earlier risks a superseded epoch; later is too late for the frame to carry it. * - * `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that - * separates every attachment's CEK from every other's, even within the same epoch. - * Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` - * blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + * `key_id` is the caller-minted `AttachmentHeader.keyId`, the label context separating + * each attachment's CEK within an epoch. A cold epoch exports and ledgers the 0xFF03 + * component (persisted as `Core`); a warm one is a pure ledger read. */ func exportAttachmentCekSend(keyId: Data) throws -> Data @@ -2864,21 +2857,17 @@ open func encrypt(appMessage: Data)throws -> EncryptResult { } /** - * Derive the wire attachment CEK for a RECEIVED frame's classical epoch (GER-1985). + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch. * - * `epoch` is the classical epoch the frame was SENT from — read off the frame's own - * decrypted result, never the recv group's current epoch (the two diverge the moment - * any later commit lands on the recv group). + * `epoch` is the epoch the frame was SENT from, read off its decrypted result — never + * the recv group's current epoch, which diverges once any later commit lands. * * `AttachmentComponentUnavailable` means the component is unrecoverable for that - * epoch: neither still current (a live export would have covered it) nor ledgered — - * evicted past `ATTACHMENT_LEDGER_WINDOW`, or never captured before a commit moved - * past it. This attachment cannot be opened by this session; it is not a transient - * condition worth retrying. + * epoch: neither current nor ledgered. The attachment cannot be opened by this + * session — not a transient condition worth retrying. * - * May mutate (a live export for a not-yet-departed current epoch ledgers it, like - * [`Self::export_attachment_cek_send`]'s cold-epoch path), so this runs inside - * `mutate_and_persist` and persists a `Core` blob on that path. + * May mutate (a live export ledgers what it derives), so this runs inside + * `mutate_and_persist` and pushes a `Core` blob on that path. */ open func exportAttachmentCekRecv(keyId: Data, epoch: UInt64)throws -> Data { return try FfiConverterData.lift(try rustCallWithError(FfiConverterTypeTwoMlsPqError_lift) { @@ -2891,20 +2880,17 @@ open func exportAttachmentCekRecv(keyId: Data, epoch: UInt64)throws -> Data { } /** - * Derive the wire attachment CEK for our SEND group's current epoch (GER-1985): + * Derive the wire attachment CEK for our SEND group's current epoch: * `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`. * - * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**: a + * Call order is load-bearing — **after `prepare_to_encrypt`, before `encrypt`**. A * commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this - * must derive from the epoch that commit lands at, the same one `encrypt`'s staple - * commits to. Deriving before `prepare_to_encrypt` risks a since-superseded epoch; - * deriving after `encrypt` is too late for that frame to carry an attachment sealed - * under it. + * must derive from the epoch that commit lands at, the one `encrypt`'s staple commits + * to. Earlier risks a superseded epoch; later is too late for the frame to carry it. * - * `key_id` is the caller-minted `AttachmentHeader.keyId` — the label context that - * separates every attachment's CEK from every other's, even within the same epoch. - * Exports and ledgers the 0xFF03 component on a cold epoch (persisted as a `Core` - * blob, like every other classical-only mutation); a warm epoch is a pure ledger read. + * `key_id` is the caller-minted `AttachmentHeader.keyId`, the label context separating + * each attachment's CEK within an epoch. A cold epoch exports and ledgers the 0xFF03 + * component (persisted as `Core`); a warm one is a pure ledger read. */ open func exportAttachmentCekSend(keyId: Data)throws -> Data { return try FfiConverterData.lift(try rustCallWithError(FfiConverterTypeTwoMlsPqError_lift) { @@ -6522,10 +6508,10 @@ private let initializationResult: InitializationResult = { if (uniffi_two_mls_pq_checksum_method_twomlspqsession_encrypt() != 14453) { return InitializationResult.apiChecksumMismatch } - if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_recv() != 46987) { + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_recv() != 38824) { return InitializationResult.apiChecksumMismatch } - if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_send() != 18660) { + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_export_attachment_cek_send() != 35511) { return InitializationResult.apiChecksumMismatch } if (uniffi_two_mls_pq_checksum_method_twomlspqsession_forwarded() != 11226) {