Skip to content

GER-1985: attachment CEK export (apq primitives + session ledgers + uniffi) - #124

Merged
germ-mark merged 7 commits into
mainfrom
feat/attachment-cek-export
Aug 5, 2026
Merged

GER-1985: attachment CEK export (apq primitives + session ledgers + uniffi)#124
germ-mark merged 7 commits into
mainfrom
feat/attachment-cek-export

Conversation

@germ-mark

Copy link
Copy Markdown
Contributor

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 own pub(crate)) with the attachment's key_id as context.

  • apq: ATTACHMENT_COMPONENT_ID, export_attachment_component, attachment_cek — field-for-field verified against mls-rs's own Label struct (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 on SessionInner, with a live-export fallback for the "fetched before anything committed past it" case; export_attachment_cek_send/export_attachment_cek_recv on the uniffi surface; contract bump 32→33.
  • Along the way, found and fixed a real pre-existing bug: MlsSenderMessage.epoch reported 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 reads MlsMessage::epoch(), matching how commit.epoch() is already used elsewhere in this crate.
  • Archive versioning: bumped SESSION_ARCHIVE_VERSION 3→4 rather than mutating the shipped v3 ArchiveTail in place — v0.15.0/v0.15.1 already shipped v3's two-field tail, so an in-place change would have failed ArchiveInvalid on restore for every session either release persisted. v3 joins v2 as an accepted older layout.
  • Swift wrapper (exportAttachmentCEKSend/Recv), a new .attachmentComponentUnavailable error case wired through the exhaustive TwoMlsPqError bridge, 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 clean
  • cargo test -p two-mls-pq --features awslc (313 passed) + cargo test -p apq (9 passed)
  • Mutation-verified: the delayed-frame epoch-keying test (breaks epoch keying → exactly that test fails) and the v3-archive-restore test (decodes a v3 tail as v4 → reproduces ArchiveInvalid)
  • swift test against the rebuilt local xcframework (19 tests) — includes send/recv agreement, key-id separation, and the typed recv-miss error over the Swift wrapper

germ-mark and others added 4 commits August 5, 2026 13:41
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-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d80e1dd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@germ-network/two-mls-pq Minor

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.
@germ-mark
germ-mark merged commit 2548c8d into main Aug 5, 2026
10 checks passed
@germ-mark
germ-mark deleted the feat/attachment-cek-export branch August 5, 2026 22:58
@github-actions github-actions Bot mentioned this pull request Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant