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. diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 36cf1ba..26d919b 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -237,7 +237,16 @@ 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. +// `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 { static let verified: Void = { @@ -662,6 +671,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..a585ef5 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -1980,6 +1980,36 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable { */ func encrypt(appMessage: Data) throws -> EncryptResult + /** + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch. + * + * `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 current nor ledgered. The attachment cannot be opened by this + * session — not a transient condition worth retrying. + * + * 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: + * `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 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 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 + /** * 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 +2854,51 @@ open func encrypt(appMessage: Data)throws -> EncryptResult { FfiConverterData.lower(appMessage),$0 ) }) +} + + /** + * Derive the wire attachment CEK for a RECEIVED frame's classical epoch. + * + * `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 current nor ledgered. The attachment cannot be opened by this + * session — not a transient condition worth retrying. + * + * 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) { + 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: + * `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 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 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) { + uniffi_two_mls_pq_fn_method_twomlspqsession_export_attachment_cek_send( + self.uniffiCloneHandle(), + FfiConverterData.lower(keyId),$0 + ) +}) } /** @@ -5553,6 +5628,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 +5700,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 +5836,10 @@ public struct FfiConverterTypeTwoMlsPqError: FfiConverterRustBuffer { case .BindTriggerFailed: writeInt(&buf, Int32(31)) + + case .AttachmentComponentUnavailable: + writeInt(&buf, Int32(32)) + } } } @@ -6416,6 +6508,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() != 38824) { + return InitializationResult.apiChecksumMismatch + } + 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) { 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)") 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/lib.rs b/rust/two-mls-pq/src/lib.rs index aa22645..9694dca 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -462,7 +462,25 @@ 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)` and +// `export_attachment_cek_recv(key_id, epoch)` — the wire attachment CEK, +// `ExpandWithLabel(SafeExportSecret_classical(0xFF03), "attachment", key_id, 32)`, derived +// 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` 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`. 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 /// binding it was generated with matches the binary it loaded. @@ -1005,13 +1023,24 @@ 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`. + #[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. // // 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, + #[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/archive.rs b/rust/two-mls-pq/src/session/archive.rs index ec59bb7..83fc337 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,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 — at 0.15.0, v3 freezes. +// 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 @@ -38,30 +38,32 @@ 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 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`. // -// 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 (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; +/// 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; +/// 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 @@ -266,10 +268,34 @@ pub(crate) mod archive_wire { pub(in crate::session) bytes: Vec, } - /// State appended AFTER [`SessionArchive`] in a v3 blob. Append-only by construction: + /// 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, + pub(in crate::session) pq_wedged: Option, + } + + impl ArchiveTailV3 { + /// 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, + 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` @@ -286,6 +312,25 @@ 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. 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: 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, + } + + /// 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 +342,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 +618,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), } } @@ -669,7 +731,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, @@ -679,6 +743,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 +979,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, @@ -1239,12 +1318,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)> { @@ -1253,7 +1334,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) => { @@ -1263,11 +1346,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/messaging.rs b/rust/two-mls-pq/src/session/messaging.rs index df3bd24..83c9c30 100644 --- a/rust/two-mls-pq/src/session/messaging.rs +++ b/rust/two-mls-pq/src/session/messaging.rs @@ -453,6 +453,118 @@ 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 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. + /// + /// [`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: 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; + }; + 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 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 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. + /// + /// 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, + ) -> Option>> { + if let Some((_, component)) = self + .recv_attachment_ledger + .iter() + .find(|(e, _)| *e == epoch) + { + 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 /// 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 @@ -1165,6 +1277,11 @@ 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, 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(); @@ -1316,6 +1433,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 +1478,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() @@ -1470,7 +1594,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), @@ -1578,6 +1702,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 @@ -1593,7 +1720,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), @@ -1670,6 +1797,48 @@ impl TwoMlsPqSession { }) } + /// 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 + /// commit inside `prepare_to_encrypt` can advance the send-classical epoch, and this + /// 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 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() + })?; + 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. + /// + /// `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 current nor ledgered. The attachment cannot be opened by this + /// session — not a transient condition worth retrying. + /// + /// 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 + .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/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 diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index fdc5fc3..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 @@ -8279,3 +8279,221 @@ 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" + ); +} + +/// 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"); +}