Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/attachment-cek-export.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 35 additions & 1 deletion Sources/TwoMLSPQ/PQSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions Sources/TwoMLSPQ/SessionError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/TwoMLSPQ/SessionErrorBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
98 changes: 98 additions & 0 deletions Sources/TwoMLSPQBinding/two_mls_pq.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
})
}

/**
Expand Down Expand Up @@ -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



Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -5748,6 +5836,10 @@ public struct FfiConverterTypeTwoMlsPqError: FfiConverterRustBuffer {
case .BindTriggerFailed:
writeInt(&buf, Int32(31))


case .AttachmentComponentUnavailable:
writeInt(&buf, Int32(32))

}
}
}
Expand Down Expand Up @@ -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
}
Expand Down
95 changes: 95 additions & 0 deletions Tests/TwoMLSPQTests/AttachmentCEKTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
6 changes: 4 additions & 2 deletions Tests/TwoMLSPQTests/ErrorContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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)")
Expand Down
Loading
Loading