GER-1985: attachment CEK export (apq primitives + session ledgers + uniffi) - #124
Merged
Conversation
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 <noreply@anthropic.com>
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.
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.
… place (review) 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.
🦋 Changeset detectedLatest commit: d80e1dd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the attachment-CEK primitive GER-1977/1978 build on: a classical-only export from the group's 0xFF03 exporter component, expanded via a local
ExpandWithLabel(mls-rs keeps its ownpub(crate)) with the attachment'skey_idas context.apq:ATTACHMENT_COMPONENT_ID,export_attachment_component,attachment_cek— field-for-field verified against mls-rs's ownLabelstruct (the cross-provider interop test alone can't catch a wrong RFC 9420 label, since both sides run the same code).two-mls-pq: send/recv attachment-CEK ledgers onSessionInner, with a live-export fallback for the "fetched before anything committed past it" case;export_attachment_cek_send/export_attachment_cek_recvon the uniffi surface; contract bump 32→33.MlsSenderMessage.epochreported the recv group's current epoch rather than the frame's own authenticated epoch — invisible for in-order delivery, wrong for a frame processed after a later commit lands. Now readsMlsMessage::epoch(), matching howcommit.epoch()is already used elsewhere in this crate.SESSION_ARCHIVE_VERSION3→4 rather than mutating the shipped v3ArchiveTailin place — v0.15.0/v0.15.1 already shipped v3's two-field tail, so an in-place change would have failedArchiveInvalidon restore for every session either release persisted. v3 joins v2 as an accepted older layout.exportAttachmentCEKSend/Recv), a new.attachmentComponentUnavailableerror case wired through the exhaustiveTwoMlsPqErrorbridge, and a rebuilt/re-synced xcframework binding.Test plan
cargo fmt --all -- --check,taplo fmt --check,cargo clippy -p apq --all-targets -- -D warnings,cargo clippy --all-targets --features awslc,benchmark_util -- -D warnings— all cleancargo test -p two-mls-pq --features awslc(313 passed) +cargo test -p apq(9 passed)ArchiveInvalid)swift testagainst the rebuilt local xcframework (19 tests) — includes send/recv agreement, key-id separation, and the typed recv-miss error over the Swift wrapper