From baf049d39f8d5f2dcdfc19ed50d28f4f36f96987 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 21:54:32 -0700 Subject: [PATCH 01/16] Export born-dedicated and undrained acceptors for migration Born-dedicated acceptors (installed, classical-converged) and acceptors still holding a parked return welcome were refused by migration_export, which left most card sessions unmigratable. Admit them: the recv-PQ leaf nothing in Rust catches up exports as pq_leaf_custody, stalled PQ rounds carry across, and group suites are validated before any crypto. Binding contract 36. Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 14 + Sources/TwoMLSPQ/PQSession.swift | 9 +- Sources/TwoMLSPQ/SessionErrorBridge.swift | 7 +- Sources/TwoMLSPQBinding/two_mls_pq.swift | 133 ++- Sources/TwoMLSPQMigrate/SessionMigrator.swift | 32 + .../BornDedicatedMigrationTests.swift | 246 +++++ .../LegacyRowFixtureTests.swift | 950 +++++++++++++++++- .../NonDedicatedEarlyExportTests.swift | 151 +++ .../RustSessionTestHelpers.swift | 152 +++ rust/Cargo.toml | 10 +- rust/fuzz/Cargo.toml | 2 +- rust/two-mls-pq/src/lib.rs | 12 +- rust/two-mls-pq/src/session/migration.rs | 291 ++++-- rust/two-mls-pq/src/session/tests.rs | 531 ++++++++++ 14 files changed, 2417 insertions(+), 123 deletions(-) create mode 100644 .changeset/born-dedicated-acceptor-export.md create mode 100644 Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift create mode 100644 Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md new file mode 100644 index 0000000..47cda74 --- /dev/null +++ b/.changeset/born-dedicated-acceptor-export.md @@ -0,0 +1,14 @@ +--- +"@germ-network/two-mls-pq": minor +--- + +`TwoMlsPqSession.migrationExport()` now admits a born-dedicated acceptor, once its +establishment envelope has installed and its recv-classical leaf has caught up, and an +acceptor still holding its parked return welcome (previously refused on both). The +recv-PQ leaf a born-dedicated acceptor's session never catches up now exports as a new +`pqLeafCustody` field, so the app's migrator can seat it correctly on the migrated +side. `BINDING_CONTRACT_VERSION` bumps 35 → 36 for the new record and field. + +Also corrects the 0.17.0 changelog's claim that group state written before that +release ("pre-v35") no longer loads: it does. Sessions written by v0.16.0 restore and +keep messaging under this engine, pinned by fixtures. diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 962f73c..6e6b607 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -264,7 +264,14 @@ public typealias SideBandSealing = TwoMLSPQTypes.SideBandSealing // `TwoMlsPqSession.migrationExport()`. The consumer lives in `TwoMLSPQMigrate` // (`SessionMigrator`), mapping onto twomlspq-swift's `SessionMigration.mintArchive`. // No wire, API, or error-variant change beyond the new symbols. -private let expectedBindingContract: UInt64 = 35 +// v36: the session migration export now admits a born-dedicated acceptor once its +// establishment envelope has installed and its recv-classical leaf has caught up, and +// an acceptor still holding a parked return welcome. One new record, +// `SessionMigrationPqLeafCustody`, and one field (`pqLeafCustody`) appended to +// `SessionMigrationExport` — its memberwise init gains a parameter — mapped by +// `SessionMigrator` onto twomlspq-swift's `MigratedRecvLeafPrincipal`. No wire or +// error-variant change. +private let expectedBindingContract: UInt64 = 36 enum TwoMLSPQBindingContract { static let verified: Void = { diff --git a/Sources/TwoMLSPQ/SessionErrorBridge.swift b/Sources/TwoMLSPQ/SessionErrorBridge.swift index 48ebf81..cf38188 100644 --- a/Sources/TwoMLSPQ/SessionErrorBridge.swift +++ b/Sources/TwoMLSPQ/SessionErrorBridge.swift @@ -26,10 +26,9 @@ enum PQErrorSurface { case pqOperation /// The side-band entry point. `SessionNotReady` here is an ill-timed /// frame (v18 narrowed `DuplicateSideBand` to steps PROVABLY done, so - /// merely ill-timed re-sends still surface as `SessionNotReady`) — mapped - /// to `.misroutedFrame` so its disposition stays a frame-level discard - /// rather than a caller bug; retention means the peer re-sends until - /// answered, so discarding is lossless. + /// merely ill-timed re-sends still surface as `SessionNotReady`) — + /// mapped to `.misroutedFrame`, whose disposition is `.callerBug` (see + /// `SessionError.Code.disposition`). case ingest case receive case decodeHeader diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index 94ab0bc..8c1139a 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -2222,12 +2222,14 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable { /** * Export this session as the migration payload for the twomlspq-swift - * session mint (GER-2433 C1): every group half as a format-2 snapshot plus - * the session metadata `SessionMigration.mintArchive` mints a native - * `SessionArchive` from. + * session mint: every group half as a format-2 snapshot plus the session + * metadata `SessionMigration.mintArchive` mints a native `SessionArchive` + * from. * - * Admits only an ESTABLISHED, quiescent session — see the module note for - * the refused states (`SessionNotReady`; `ArchiveInvalid` for torn or + * Admits only an established session — including one with a parked + * side-band leg, an in-flight PQ round, or an owed bind, all carried so + * the round completes after migration — see the module note for the + * refused states (`SessionNotReady`; `ArchiveInvalid` for torn or * unrecoverable state; `Mls` when a group half refuses its own export, * e.g. a pending commit). * @@ -3199,12 +3201,14 @@ open func shouldListenOn()throws -> ListenChannels { /** * Export this session as the migration payload for the twomlspq-swift - * session mint (GER-2433 C1): every group half as a format-2 snapshot plus - * the session metadata `SessionMigration.mintArchive` mints a native - * `SessionArchive` from. + * session mint: every group half as a format-2 snapshot plus the session + * metadata `SessionMigration.mintArchive` mints a native `SessionArchive` + * from. * - * Admits only an ESTABLISHED, quiescent session — see the module note for - * the refused states (`SessionNotReady`; `ArchiveInvalid` for torn or + * Admits only an established session — including one with a parked + * side-band leg, an in-flight PQ round, or an owed bind, all carried so + * the round completes after migration — see the module note for the + * refused states (`SessionNotReady`; `ArchiveInvalid` for torn or * unrecoverable state; `Mls` when a group half refuses its own export, * e.g. a pending commit). * @@ -5497,6 +5501,11 @@ public struct SessionMigrationExport: Equatable, Hashable { * `requires_establishment_envelope` under its native name. */ public var owesEstablishmentEnvelope: Bool + /** + * `Some` exactly when the recv-PQ leaf still presents a key other than the + * identity's — a born-dedicated acceptor's uncaught-up PQ leaf. + */ + public var pqLeafCustody: SessionMigrationPqLeafCustody? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -5518,7 +5527,11 @@ public struct SessionMigrationExport: Equatable, Hashable { */stagedUpdates: [SessionMigrationStagedUpdate], joinedWelcomeDigest: Data?, bootstrapKpSecret: SessionMigrationBootstrapKp?, expectedBootstrapKpCommitment: Data?, pqTurnMine: Bool, owedBind: SessionMigrationOwedBind?, pqInflight: SessionMigrationPqInflight?, pendingSideBand: Data?, peerAppliedSendEpoch: UInt64?, lastCrossInjected: UInt64?, lastCrossInjectedPq: UInt64?, lastSendPqExported: UInt64?, offeredProposal: SessionMigrationDigestedProposal?, queuedProposal: SessionMigrationDigestedProposal?, sendCrossPskLedger: [SessionMigrationPskEntry], spawnToken: Data?, listenRendezvous: [SessionMigrationEpochEntry], recvHeaderKeys: [SessionMigrationEpochEntry], recvHeaderKeysPq: [SessionMigrationEpochEntry], sendAttachmentLedger: [SessionMigrationEpochEntry], recvAttachmentLedger: [SessionMigrationEpochEntry], initialTheirKp: SessionMigrationCombinerKp?, /** * `requires_establishment_envelope` under its native name. - */owesEstablishmentEnvelope: Bool) { + */owesEstablishmentEnvelope: Bool, + /** + * `Some` exactly when the recv-PQ leaf still presents a key other than the + * identity's — a born-dedicated acceptor's uncaught-up PQ leaf. + */pqLeafCustody: SessionMigrationPqLeafCustody?) { self.stateSeq = stateSeq self.initiated = initiated self.identity = identity @@ -5551,6 +5564,7 @@ public struct SessionMigrationExport: Equatable, Hashable { self.recvAttachmentLedger = recvAttachmentLedger self.initialTheirKp = initialTheirKp self.owesEstablishmentEnvelope = owesEstablishmentEnvelope + self.pqLeafCustody = pqLeafCustody } @@ -5600,7 +5614,8 @@ public struct FfiConverterTypeSessionMigrationExport: FfiConverterRustBuffer { sendAttachmentLedger: FfiConverterSequenceTypeSessionMigrationEpochEntry.read(from: &buf), recvAttachmentLedger: FfiConverterSequenceTypeSessionMigrationEpochEntry.read(from: &buf), initialTheirKp: FfiConverterOptionTypeSessionMigrationCombinerKp.read(from: &buf), - owesEstablishmentEnvelope: FfiConverterBool.read(from: &buf) + owesEstablishmentEnvelope: FfiConverterBool.read(from: &buf), + pqLeafCustody: FfiConverterOptionTypeSessionMigrationPqLeafCustody.read(from: &buf) ) } @@ -5637,6 +5652,7 @@ public struct FfiConverterTypeSessionMigrationExport: FfiConverterRustBuffer { FfiConverterSequenceTypeSessionMigrationEpochEntry.write(value.recvAttachmentLedger, into: &buf) FfiConverterOptionTypeSessionMigrationCombinerKp.write(value.initialTheirKp, into: &buf) FfiConverterBool.write(value.owesEstablishmentEnvelope, into: &buf) + FfiConverterOptionTypeSessionMigrationPqLeafCustody.write(value.pqLeafCustody, into: &buf) } } @@ -5942,6 +5958,73 @@ public func FfiConverterTypeSessionMigrationPartySequence_lower(_ value: Session } +/** + * Custody over the PQ signing key that one of this session's own PQ leaves still + * presents in place of the identity's — today, a born-dedicated acceptor's recv-PQ + * leaf, which keeps presenting the INVITATION identity's key because nothing in + * Rust ever catches it up (see the module note). Only the PQ pair: every classical + * own leaf must already present the identity's key, and mls-rs drops the old + * classical signer at that catch-up, so no classical half is ever left to custody. + * A follow-up reuses this for a lagging send-PQ leaf after a rotation. + */ +public struct SessionMigrationPqLeafCustody: Equatable, Hashable { + public var clientId: Data + public var pqSigningKey: Data + public var pqSignatureKey: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(clientId: Data, pqSigningKey: Data, pqSignatureKey: Data) { + self.clientId = clientId + self.pqSigningKey = pqSigningKey + self.pqSignatureKey = pqSignatureKey + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationPqLeafCustody: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPqLeafCustody: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPqLeafCustody { + return + try SessionMigrationPqLeafCustody( + clientId: FfiConverterData.read(from: &buf), + pqSigningKey: FfiConverterData.read(from: &buf), + pqSignatureKey: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationPqLeafCustody, into buf: inout [UInt8]) { + FfiConverterData.write(value.clientId, into: &buf) + FfiConverterData.write(value.pqSigningKey, into: &buf) + FfiConverterData.write(value.pqSignatureKey, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqLeafCustody_lift(_ buf: RustBuffer) throws -> SessionMigrationPqLeafCustody { + return try FfiConverterTypeSessionMigrationPqLeafCustody.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqLeafCustody_lower(_ value: SessionMigrationPqLeafCustody) -> RustBuffer { + return FfiConverterTypeSessionMigrationPqLeafCustody.lower(value) +} + + /** * The staged Upd(self) awaiting the peer's fold: `pending_proposal_hash` + * `pending_proposal_message` combined into the native `PendingProposal` @@ -7707,6 +7790,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationOwedBind: FfiConverterR } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationPqLeafCustody: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationPqLeafCustody? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationPqLeafCustody.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationPqLeafCustody.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -8256,7 +8363,7 @@ private let initializationResult: InitializationResult = { if (uniffi_two_mls_pq_checksum_method_twomlspqsession_should_listen_on() != 34726) { return InitializationResult.apiChecksumMismatch } - if (uniffi_two_mls_pq_checksum_method_twomlspqsession_migration_export() != 17140) { + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_migration_export() != 59429) { return InitializationResult.apiChecksumMismatch } if (uniffi_two_mls_pq_checksum_method_twomlspqsession_my_pq_turn() != 12380) { diff --git a/Sources/TwoMLSPQMigrate/SessionMigrator.swift b/Sources/TwoMLSPQMigrate/SessionMigrator.swift index d005e2f..fba3db3 100644 --- a/Sources/TwoMLSPQMigrate/SessionMigrator.swift +++ b/Sources/TwoMLSPQMigrate/SessionMigrator.swift @@ -104,9 +104,41 @@ public enum SessionMigrator { initialTheirKP: export.initialTheirKp.map { (classical: $0.classical, pq: $0.pq) }, + recvLeafPrincipal: try export.pqLeafCustody.map { + try recvLeafPrincipal($0, identity: export.identity) + }, owesEstablishmentEnvelope: export.owesEstablishmentEnvelope) } + /// The Rust session no longer holds the invitation's classical signer — + /// mls-rs drops it at the recv-classical catch-up this export requires — + /// so the classical slot carries the identity's own pair instead. That is + /// exactly what the converged recv-classical leaf presents. + /// + /// This makes native's classical custody arm for `recvLeafPrincipal` + /// (`classicalSigningKey(presenting:)`) unreachable for a session this + /// mapper produces: that arm is gated on `recvLeafPrincipal.signatureKey` + /// matching the presented key, but this mapper sets it to + /// `identity.signatureKey` — the SAME value the identity arm above it + /// already matches first. The recv-leaf catch-up arms, which read the + /// classical custody key directly, never fire either: they require the + /// recv-classical leaf to still lag the canonical identity and to present + /// `clientID` (the invitation id), and this export requires that leaf to + /// have converged. Only the PQ half of this mixed record is ever read back + /// out (`pqSigningKey(presenting:)`'s `recvLeafPrincipal` arm, where + /// `pqSignatureKey` is the genuinely different custodied key). + private static func recvLeafPrincipal( + _ custody: TwoMLSPQBinding.SessionMigrationPqLeafCustody, + identity: TwoMLSPQBinding.SessionMigrationIdentity + ) throws -> TwoMLSPQSession.MigratedRecvLeafPrincipal { + try TwoMLSPQSession.MigratedRecvLeafPrincipal( + clientID: custody.clientId, + signingKey: SecretBytes(bytes: identity.signingKey), + signatureKey: identity.signatureKey, + pqSigningKey: SecretBytes(bytes: custody.pqSigningKey), + pqSignatureKey: custody.pqSignatureKey) + } + private static func migratedIdentity( _ identity: TwoMLSPQBinding.SessionMigrationIdentity ) throws -> TwoMLSPQSession.MigratedSessionIdentity { diff --git a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift new file mode 100644 index 0000000..51c4448 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift @@ -0,0 +1,246 @@ +import CryptoKit +import Foundation +import MLSCrypto +import SecretBytes +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// Contract 26 (born-dedicated) migration: a REAL Rust born-dedicated pair, driven through +// classical convergence (folding the acceptor's catch-up Upd) and the A.3 bootstrap, then +// the ACCEPTOR's `migrationExport()` +// is minted into a native archive and shown to keep messaging (and committing) with the Rust +// peer, in both directions, and to survive a native re-archive/restore. Companion to +// `SessionMigrationTests` (the non-dedicated differential); see `RustSessionTestHelpers` for +// the shared FFI establishment scaffolding. +// +// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI +// record types are module-qualified throughout. + +@available(macOS 26, iOS 26, *) +final class BornDedicatedMigrationTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + // MARK: - Migrate the born-dedicated ACCEPTOR to native; Rust initiator stays live + + func testMigratedBornDedicatedAcceptorKeepsMessagingAndCommittingWithRustPeer() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPair() + let export = try pair.bob.migrationExport() + XCTAssertNotNil(export.pqLeafCustody, "the recv-PQ leaf never catches up in Rust") + XCTAssertEqual(export.pqLeafCustody?.clientId, pair.invitationId) + XCTAssertFalse(export.owesEstablishmentEnvelope) + XCTAssertFalse(export.initiated) + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // A message alice -> bob and bob -> alice. + try aliceSays(pair.alice, "alice-to-native-bob", to: &nativeBob) + try bobSays(&nativeBob, "native-bob-to-alice", to: pair.alice) + + // One committing round EACH way. + // (i) alice folds native bob's Upd. + _ = try nativeBob.prepareToEncrypt() + let bobUpdFrame = try nativeBob.encrypt(Data("bob-upd".utf8)) + let aliceOffered = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: bobUpdFrame.frame)?.proposal) + try pair.alice.queueProposal(digest: aliceOffered.digest) + let alicePrepared = try pair.alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared.didCommit, "alice's fold of native bob's Upd should commit") + let aliceCommitFrame = try pair.alice.encrypt(appMessage: Data("alice-commit".utf8)) + let bobCommitOpened = try nativeBob.processIncoming(aliceCommitFrame.cipherText) + guard case .decrypted(let bobCommitDecrypted) = bobCommitOpened else { + XCTFail("expected a decrypted application frame, got \(bobCommitOpened)") + return + } + XCTAssertTrue( + bobCommitDecrypted.didApplyRemoteCommit, + "native bob should see alice's remote commit applied") + + // (ii) native bob folds alice's Upd. + _ = try pair.alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try pair.alice.encrypt(appMessage: Data("alice-upd".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue( + bobPrepared.didCommit, "native bob's fold of alice's Upd should commit") + let bobCommitFrame = try nativeBob.encrypt(Data("bob-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, Data("bob-commit".utf8)) + + // A message each way, again — `makeSessionArchive` is `internal` (unreachable + // here), so re-archive via the return-cadence `StateUpdate` `encrypt()` already + // carries (`EncryptResult.update` — the pending-advance state a caller needs to + // persist rides every ordinary send, never a separate opt-in step), paired with + // the ORIGINAL `.checkpoint` mint. `encrypt` never touches a PQ tree, so this + // stays `.core` — + // exactly the app's own two-slot persistence shape, and nothing moved the PQ + // trees since the mint, so the checkpoint pairs with it cleanly. + try aliceSays(pair.alice, "alice-to-native-bob-2", to: &nativeBob) + let latestCore = try bobSaysCapturingCore( + &nativeBob, "native-bob-to-alice-2", to: pair.alice) + + var restoredBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: latestCore, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + try aliceSays(pair.alice, "alice-to-restored-bob", to: &restoredBob) + try bobSays(&restoredBob, "restored-bob-to-alice", to: pair.alice) + } + + // MARK: - Exercise the exported PQ custody key via a mechanical A.5 + + /// A.2/A.4 never sign in recv.pq, so a mechanical A.5 (native bob as initiator) is the + /// only round that ever uses the custodied PQ signing key: `pqRekeyBegin` proposes a + /// plain (non-rotating) self-Update signed with it into `recvGroup.pq`. + func testMigratedAcceptorSignsWithCustodiedPQKeyDuringA5Rekey() throws { + // The AT-DISCHARGE point, not `bornDedicatedSessionPair`: nothing has sent since + // the A.3 bind discharge, so nothing has auto-staged, and `pqRekeyBegin`'s clean + // slate precondition holds without needing to drain anything (a drain would + // discharge a full round and pass the PQ turn away, defeating the point). + let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() + let export = try pair.bob.migrationExport() + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Preconditions `pqRekeyBegin` requires: bob holds the PQ turn (the bootstrap + // discharge just passed it to him) and nothing else is mid-flight. + XCTAssertTrue(nativeBob.myPQTurn) + + // Native bob (turn holder) begins the round: an Upd' signed with the custodied key. + let begin = try nativeBob.pqRekeyBegin() + + // Rust alice (committer) folds it into an includePath commit on her send-PQ group. + let announced = try pair.alice.pqRekeyRespond(updMsg: begin.frame) + XCTAssertNil(announced, "the mechanical rekey carries no credential handoff") + let commitFrame = try XCTUnwrap(pair.alice.pqTakePendingOutbound()) + + // Native bob applies alice's Commit' and owes the classical bind. + _ = try nativeBob.pqRekeyApply(commitFrame) + + // Bind discharge: alice offers an Upd, native bob (the binder) folds + commits it. + _ = try pair.alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try pair.alice.encrypt(appMessage: Data("a5-bind-upd".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue(bobPrepared.didCommit, "the bind discharge needs a committing round") + let bobCommitFrame = try nativeBob.encrypt(Data("a5-bind-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("a5-bind-commit".utf8)) + + // A message each way — the round closed cleanly. + try aliceSays(pair.alice, "post-a5-alice", to: &nativeBob) + try bobSays(&nativeBob, "post-a5-bob", to: pair.alice) + } + + // MARK: Mutations + + func testNilPQLeafCustodyThrowsArchiveInvalid() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPair() + var export = try pair.bob.migrationExport() + export.pqLeafCustody = nil + XCTAssertThrowsError( + try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + ) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .archiveInvalid) + } + } + + func testFlippedPQLeafCustodySigningKeyThrowsArchiveInvalid() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPair() + var export = try pair.bob.migrationExport() + export.pqLeafCustody?.pqSigningKey[0] ^= 0xFF + XCTAssertThrowsError( + try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + ) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .archiveInvalid) + } + } + + // MARK: Negatives — refused before convergence + + func testPreInstallBobExportThrowsSessionNotReady() throws { + let pair = try RustSessionTestHelpers.bornDedicatedPending() + XCTAssertThrowsError(try pair.bob.migrationExport()) { error in + XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + } + } + + func testInstalledButUnfoldedBobExportThrowsSessionNotReady() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() + XCTAssertThrowsError(try pair.bob.migrationExport()) { error in + XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + } + } + + // MARK: - Native <-> Rust one-frame helpers (mirrors `RustSessionTestHelpers`) + + private func aliceSays( + _ alice: TwoMLSPQBinding.TwoMlsPqSession, _ text: String, + to nativeBob: inout TwoMLSPQSession.TwoMLSSession + ) throws { + _ = try alice.prepareToEncrypt(proposing: nil) + let frame = try alice.encrypt(appMessage: Data(text.utf8)) + let opened = try nativeBob.processIncoming(frame.cipherText) + guard case .decrypted(let decrypted) = opened else { + XCTFail("expected a decrypted application frame, got \(opened)") + return + } + XCTAssertEqual(decrypted.applicationMessage, Data(text.utf8)) + } + + private func bobSays( + _ nativeBob: inout TwoMLSPQSession.TwoMLSSession, _ text: String, + to alice: TwoMLSPQBinding.TwoMlsPqSession + ) throws { + _ = try bobSaysCapturingCore(&nativeBob, text, to: alice) + } + + /// Like `bobSays`, but returns the `EncryptResult`'s own `.core`-kind `StateUpdate` + /// archive — the only public way to pull a fresh archive out of a live + /// `TwoMLSSession` (`makeSessionArchive` is `internal`). + @discardableResult + private func bobSaysCapturingCore( + _ nativeBob: inout TwoMLSPQSession.TwoMLSSession, _ text: String, + to alice: TwoMLSPQBinding.TwoMlsPqSession + ) throws -> SecretArchive { + _ = try nativeBob.prepareToEncrypt() + let result = try nativeBob.encrypt(Data(text.utf8)) + let got = try XCTUnwrap(alice.processIncoming(ciphertext: result.frame)) + XCTAssertEqual(got.applicationMessage?.appMessageData, Data(text.utf8)) + XCTAssertEqual(result.update.kind, .core, "encrypt never touches a PQ tree") + return result.update.archive + } +} diff --git a/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift b/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift index ca6f983..26d5ba8 100644 --- a/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift +++ b/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift @@ -480,39 +480,953 @@ class LegacyRowFixtureTests: XCTestCase { aliceFinalDecrypted.applicationMessage, Data("\(point)-final-bob".utf8)) } - // MARK: - Acceptor export still refused (every point) + // MARK: - Acceptor export: p1 still refused, p2-p6 now migrate + /// p1: bob has sent but alice has not yet folded his catch-up Upd — his recv-classical + /// leaf still presents the invitation identity's key, and the custody gate refuses it + /// (`SessionNotReady`, not `Mls`). func testP1AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p1-bob-sent-unfolded") + let bob = try restoreAndVerify(point: "p1-bob-sent-unfolded", side: "acceptor") + XCTAssertThrowsError(try bob.migrationExport()) { error in + XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + } } - func testP2AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p2-converged") + func testP2AcceptorMigratesAndMessagesRustInitiator() throws { + try assertAcceptorMigrates(point: "p2-converged") } - func testP3AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p3-steady") + func testP3AcceptorMigratesAndMessagesRustInitiator() throws { + try assertAcceptorMigrates(point: "p3-steady") } - func testP4AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p4-a3-stalled") + func testP4AcceptorMigratesAndMessagesRustInitiator() throws { + try assertAcceptorMigrates(point: "p4-a3-stalled") } - func testP5AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p5-a4-stalled") + func testP5AcceptorMigratesAndMessagesRustInitiator() throws { + try assertAcceptorMigrates(point: "p5-a4-stalled") } - func testP6AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p6-a3-responded-stalled") + func testP6AcceptorMigratesAndMessagesRustInitiator() throws { + try assertAcceptorMigrates(point: "p6-a3-responded-stalled") } - /// The born-dedicated acceptor's `migrationExport()` is refused at every captured point — - /// admitting an installed, classical-converged acceptor is a separate, later change. - private func assertAcceptorExportRefused(point: String) throws { - let bob = try restoreAndVerify(point: point, side: "acceptor") - XCTAssertThrowsError(try bob.migrationExport()) { error in - XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + /// The 0.16.0 ACCEPTOR row at `point` (converged onward) now exports, mints, and + /// restores natively: a message each way with a FRESH Rust restore of the initiator + /// row, then a cross-engine committing round each way — mirrors + /// `assertInitiatorMigrates` with the roles swapped. This is the app's real + /// population: born-dedicated, 0.16-written, never-drained parked welcome. + private func assertAcceptorMigrates(point: String) throws { + let freshBob = try restoreAndVerify(point: point, side: "acceptor") + let export = try freshBob.migrationExport() + XCTAssertNotNil( + export.pqLeafCustody, "\(point): the acceptor's PQ custody should export") + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + let alice = try restoreAndVerify(point: point, side: "initiator") + + // Message each way. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceFrame = try alice.encrypt( + appMessage: Data("\(point)-alice-to-migrated-bob".utf8)) + let opened = try nativeBob.processIncoming(aliceFrame.cipherText) + guard case .decrypted(let decrypted) = opened else { + XCTFail("expected a decrypted application frame, got \(opened)") + return + } + XCTAssertEqual( + decrypted.applicationMessage, Data("\(point)-alice-to-migrated-bob".utf8)) + + _ = try nativeBob.prepareToEncrypt() + let reply = try nativeBob.encrypt(Data("\(point)-migrated-bob-to-alice".utf8)) + let aliceGot = try XCTUnwrap(alice.processIncoming(ciphertext: reply.frame)) + XCTAssertEqual( + aliceGot.applicationMessage?.appMessageData, + Data("\(point)-migrated-bob-to-alice".utf8)) + + // Cross-engine committing round, native bob as binder: Rust alice offers an Upd, + // native bob queues it and his next `prepareToEncrypt` must commit it; Rust alice + // applies the resulting commit. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try alice.encrypt(appMessage: Data("\(point)-alice-upd".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue( + bobPrepared.didCommit, "native bob's fold of alice's Upd should commit") + let bobCommitFrame = try nativeBob.encrypt(Data("\(point)-bob-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("\(point)-bob-commit".utf8)) + + // Cross-engine committing round, Rust alice as binder: native bob offers an Upd, + // Rust alice queues + commits it; native bob sees the remote commit applied. + _ = try nativeBob.prepareToEncrypt() + let bobUpdFrame = try nativeBob.encrypt(Data("\(point)-bob-upd".utf8)) + let aliceDecrypted = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpdFrame.frame)) + let aliceOffered = try XCTUnwrap(aliceDecrypted.proposal) + try alice.queueProposal(digest: aliceOffered.digest) + let alicePrepared = try alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared.didCommit, "Rust alice's fold of bob's Upd should commit") + let aliceCommitFrame = try alice.encrypt( + appMessage: Data("\(point)-alice-commit".utf8)) + let bobCommitOpened = try nativeBob.processIncoming(aliceCommitFrame.cipherText) + guard case .decrypted(let bobCommitDecrypted) = bobCommitOpened else { + XCTFail("expected a decrypted application frame, got \(bobCommitOpened)") + return + } + XCTAssertEqual( + bobCommitDecrypted.applicationMessage, Data("\(point)-alice-commit".utf8)) + XCTAssertTrue( + bobCommitDecrypted.didApplyRemoteCommit, + "native bob should see the remote commit applied") + + // Final message each way. + _ = try nativeBob.prepareToEncrypt() + let finalFromBob = try nativeBob.encrypt(Data("\(point)-final-bob".utf8)) + let aliceGotFinal = try XCTUnwrap( + alice.processIncoming(ciphertext: finalFromBob.frame)) + XCTAssertEqual( + aliceGotFinal.applicationMessage?.appMessageData, + Data("\(point)-final-bob".utf8)) + + _ = try alice.prepareToEncrypt(proposing: nil) + let finalFromAlice = try alice.encrypt( + appMessage: Data("\(point)-final-alice".utf8)) + let bobFinalOpened = try nativeBob.processIncoming(finalFromAlice.cipherText) + guard case .decrypted(let bobFinalDecrypted) = bobFinalOpened else { + XCTFail("expected a decrypted application frame, got \(bobFinalOpened)") + return + } + XCTAssertEqual( + bobFinalDecrypted.applicationMessage, Data("\(point)-final-alice".utf8)) + } + + // MARK: - Field-state healing across engines (the real card shape) + + /// p6(i): the acceptor (bob) migrates to native; the initiator (alice) stays Rust. Bob's + /// parked Welcome' response (never shipped before capture) must survive BOTH the export + /// and the native restore — shown by taking it off native bob's OWN side-band peek, + /// exactly as a live native host would to re-send a dropped leg. Delivered to Rust + /// alice, the owed bind then discharges with a cross-engine committing round (native bob + /// as the peer, Rust alice as the binder — `assertAcceptorMigrates`'s own pattern). + func testP6AcceptorMigratedHealsA3WithRustInitiator() throws { + let alice = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "initiator") + let freshBob = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "acceptor") + let export = try freshBob.migrationExport() + XCTAssertNotNil( + export.pendingSideBand, "bob's parked Welcome' should be in the export") + XCTAssertNotNil(export.pqInflight, "bob's bootstrap-responded state should export") + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Bob's own send.pq was already founded before capture (he answered A.3 himself), + // and his recv.pq (mirroring alice's Group_A.pq) has been his from the initial + // establishment — so native bob is ALREADY fully established right off the + // restore, before the heal below ever runs. Migration must preserve that. + XCTAssertTrue( + nativeBob.isFullyEstablished, + "native bob's own A.3 response should already be complete after restore") + + // The native side-band take: bob still holds the parked Welcome' after migration. + let welcomePrimeSealed = try XCTUnwrap( + nativeBob.pqPendingOutbound(), + "native bob should still hold the parked Welcome' after migration") + + // Deliver to Rust alice and discharge the owed bind across engines: native bob (the + // peer) offers an Upd, Rust alice (the binder) queues + commits it. + let opened = try XCTUnwrap(try alice.openIncoming(blob: welcomePrimeSealed)) + try alice.pqBootstrapBind(welcomeMsg: opened.frame) + + _ = try nativeBob.prepareToEncrypt() + let bobUpd = try nativeBob.encrypt(Data("p6h-bob-upd".utf8)) + let offered = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpd.frame)?.proposal) + try alice.queueProposal(digest: offered.digest) + let alicePrepared = try alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared.didCommit, + "alice's discharge commit should fold bob's offered Upd") + let aliceCommit = try alice.encrypt(appMessage: Data("p6h-alice-commit".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceCommit.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + XCTAssertEqual(bobDecrypted.applicationMessage, Data("p6h-alice-commit".utf8)) + + XCTAssertTrue(alice.isFullyEstablished(), "the healed round should establish alice") + XCTAssertNil( + alice.pqPendingOutbound(sealing: .fresh), "alice should have nothing parked" + ) + XCTAssertNil( + nativeBob.pqPendingOutbound(), "native bob should have nothing parked") + // The discharge passes the PQ turn from the binder (alice, who owed the bind) to + // the peer (bob) — same shape as the Rust-only p6 heal test above. + XCTAssertFalse( + alice.myPqTurn(), "the discharge should pass the PQ turn away from alice") + XCTAssertTrue( + nativeBob.myPQTurn, "the discharge should pass the PQ turn to native bob") + + // A message each way. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceMsg = try alice.encrypt(appMessage: Data("p6h-alice-msg".utf8)) + let bobGotMsg = try nativeBob.processIncoming(aliceMsg.cipherText) + guard case .decrypted(let bobMsgDecrypted) = bobGotMsg else { + XCTFail("expected a decrypted application frame, got \(bobGotMsg)") + return + } + XCTAssertEqual(bobMsgDecrypted.applicationMessage, Data("p6h-alice-msg".utf8)) + + _ = try nativeBob.prepareToEncrypt() + let bobMsg = try nativeBob.encrypt(Data("p6h-bob-msg".utf8)) + let aliceGotMsg = try XCTUnwrap(alice.processIncoming(ciphertext: bobMsg.frame)) + XCTAssertEqual( + aliceGotMsg.applicationMessage?.appMessageData, Data("p6h-bob-msg".utf8)) + + // A committing round each way. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try alice.encrypt(appMessage: Data("p6h-alice-upd".utf8)) + let bobOpened2 = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted2) = bobOpened2 else { + XCTFail("expected a decrypted application frame, got \(bobOpened2)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted2.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue( + bobPrepared.didCommit, "native bob's fold of alice's Upd should commit") + let bobCommitFrame = try nativeBob.encrypt(Data("p6h-bob-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("p6h-bob-commit".utf8)) + + _ = try nativeBob.prepareToEncrypt() + let bobUpdFrame2 = try nativeBob.encrypt(Data("p6h-bob-upd2".utf8)) + let aliceDecrypted2 = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpdFrame2.frame)) + let aliceOffered2 = try XCTUnwrap(aliceDecrypted2.proposal) + try alice.queueProposal(digest: aliceOffered2.digest) + let alicePrepared2 = try alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared2.didCommit, "Rust alice's fold of bob's Upd should commit") + let aliceCommitFrame2 = try alice.encrypt( + appMessage: Data("p6h-alice-commit2".utf8)) + let bobCommitOpened2 = try nativeBob.processIncoming(aliceCommitFrame2.cipherText) + guard case .decrypted(let bobCommitDecrypted2) = bobCommitOpened2 else { + XCTFail("expected a decrypted application frame, got \(bobCommitOpened2)") + return + } + XCTAssertEqual( + bobCommitDecrypted2.applicationMessage, Data("p6h-alice-commit2".utf8)) + XCTAssertTrue( + bobCommitDecrypted2.didApplyRemoteCommit, + "native bob should see the remote commit applied") + } + + /// p6(ii): the initiator (alice) migrates to native; the acceptor (bob) stays Rust. + /// Bob's parked Welcome' (his own §A.3 response, still on the Rust side) is delivered + /// straight into native alice's `pqBootstrapJoin` — which unseals it itself, same as + /// `processIncoming` — joining Group_B.pq and owing the classical bind in one call. The + /// bind then discharges with native alice as the binder (mirrors `assertInitiatorMigrates`). + func testP6InitiatorMigratedHealsA3WithRustAcceptor() throws { + let freshAlice = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "initiator") + let export = try freshAlice.migrationExport() + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + let bob = try restoreAndVerify(point: "p6-a3-responded-stalled", side: "acceptor") + let sealedWelcomePrime = try XCTUnwrap( + bob.pqPendingOutbound(sealing: .fresh), + "expected bob's parked A.3 Welcome' response to survive restore") + + // Native alice's `pqBootstrapJoin` unseals `inbound` itself (like `processIncoming`) + // — no separate open step, unlike the Rust-side `openIncoming` + `pqBootstrapBind`. + _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) + + // Discharge across engines: Rust bob (the peer) offers an Upd, native alice (the + // binder) queues + commits it. + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame = try bob.encrypt(appMessage: Data("p6h2-bob-upd".utf8)) + let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) + guard case .decrypted(let aliceDecrypted) = aliceOpened else { + XCTFail("expected a decrypted application frame, got \(aliceOpened)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted.queuedProposal.digest) + let alicePrepared = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared.didCommit, + "native alice's discharge commit should fold bob's offered Upd") + let aliceCommitFrame = try nativeAlice.encrypt(Data("p6h2-alice-commit".utf8)) + let bobGotCommit = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame.frame)) + XCTAssertEqual( + bobGotCommit.applicationMessage?.appMessageData, + Data("p6h2-alice-commit".utf8)) + + XCTAssertTrue( + nativeAlice.isFullyEstablished, + "the healed round should establish native alice") + XCTAssertNil( + nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") + XCTAssertNil( + bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") + // The discharge passes the PQ turn from the binder (native alice) to the peer + // (Rust bob). + XCTAssertFalse( + nativeAlice.myPQTurn, + "the discharge should pass the PQ turn away from alice") + XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") + + // A message each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceMsg = try nativeAlice.encrypt(Data("p6h2-alice-msg".utf8)) + let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) + XCTAssertEqual( + bobGotMsg.applicationMessage?.appMessageData, Data("p6h2-alice-msg".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobMsg = try bob.encrypt(appMessage: Data("p6h2-bob-msg".utf8)) + let aliceGotMsg = try nativeAlice.processIncoming(bobMsg.cipherText) + guard case .decrypted(let aliceMsgDecrypted) = aliceGotMsg else { + XCTFail("expected a decrypted application frame, got \(aliceGotMsg)") + return + } + XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p6h2-bob-msg".utf8)) + + // A committing round each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceUpdFrame = try nativeAlice.encrypt(Data("p6h2-alice-upd".utf8)) + let bobDecrypted = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceUpdFrame.frame)) + let bobOffered = try XCTUnwrap(bobDecrypted.proposal) + try bob.queueProposal(digest: bobOffered.digest) + let bobPrepared = try bob.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "Rust bob's fold of alice's Upd should commit") + let bobCommitFrame = try bob.encrypt(appMessage: Data("p6h2-bob-commit".utf8)) + let aliceCommitOpened = try nativeAlice.processIncoming(bobCommitFrame.cipherText) + guard case .decrypted(let aliceCommitDecrypted) = aliceCommitOpened else { + XCTFail("expected a decrypted application frame, got \(aliceCommitOpened)") + return + } + XCTAssertEqual( + aliceCommitDecrypted.applicationMessage, Data("p6h2-bob-commit".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame2 = try bob.encrypt(appMessage: Data("p6h2-bob-upd2".utf8)) + let aliceOpened2 = try nativeAlice.processIncoming(bobUpdFrame2.cipherText) + guard case .decrypted(let aliceDecrypted2) = aliceOpened2 else { + XCTFail("expected a decrypted application frame, got \(aliceOpened2)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted2.queuedProposal.digest) + let alicePrepared2 = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared2.didCommit, "native alice's fold of bob's Upd should commit") + let aliceCommitFrame2 = try nativeAlice.encrypt(Data("p6h2-alice-commit2".utf8)) + let bobGotCommit2 = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame2.frame)) + XCTAssertEqual( + bobGotCommit2.applicationMessage?.appMessageData, + Data("p6h2-alice-commit2".utf8)) + } + + /// p4(iii): the initiator (alice) migrates to native; the acceptor (bob) stays Rust. + /// p4's shape is earlier than p6's — alice's KP' was parked but bob never responded — so + /// native alice's OWN side-band take is what must survive the migration this time, and + /// the round runs its FULL A.3 shape: deliver KP' to Rust bob (`pqBootstrapRespond`), his + /// Welcome' response back to native alice (`pqBootstrapJoin`), then discharge. + func testP4InitiatorMigratedHealsA3WithRustAcceptor() throws { + let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") + let export = try freshAlice.migrationExport() + XCTAssertNotNil( + export.pendingSideBand, "alice's parked KP' leg should be in the export") + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // The native side-band take: alice still holds her parked KP' after migration. + let sealedKp = try XCTUnwrap( + nativeAlice.pqPendingOutbound(), + "native alice should still hold her parked KP' after migration") + + let bob = try restoreAndVerify(point: "p4-a3-stalled", side: "acceptor") + let openedKp = try XCTUnwrap(try bob.openIncoming(blob: sealedKp)) + try bob.pqBootstrapRespond(kpMsg: openedKp.frame) + + let sealedWelcomePrime = try XCTUnwrap(bob.pqTakePendingOutbound()) + // Native's `pqBootstrapJoin` unseals `inbound` itself. + _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) + + // Discharge across engines: Rust bob (the peer) offers, native alice (the binder) + // commits. + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame = try bob.encrypt(appMessage: Data("p4h-bob-upd".utf8)) + let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) + guard case .decrypted(let aliceDecrypted) = aliceOpened else { + XCTFail("expected a decrypted application frame, got \(aliceOpened)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted.queuedProposal.digest) + let alicePrepared = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared.didCommit, + "native alice's discharge commit should fold bob's offered Upd") + let aliceCommitFrame = try nativeAlice.encrypt(Data("p4h-alice-commit".utf8)) + let bobGotCommit = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame.frame)) + XCTAssertEqual( + bobGotCommit.applicationMessage?.appMessageData, + Data("p4h-alice-commit".utf8)) + + XCTAssertTrue( + nativeAlice.isFullyEstablished, + "the healed round should establish native alice") + XCTAssertNil( + nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") + XCTAssertNil( + bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") + // The discharge passes the PQ turn from the binder (native alice) to the peer + // (Rust bob). + XCTAssertFalse( + nativeAlice.myPQTurn, + "the discharge should pass the PQ turn away from alice") + XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") + + // A message each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceMsg = try nativeAlice.encrypt(Data("p4h-alice-msg".utf8)) + let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) + XCTAssertEqual( + bobGotMsg.applicationMessage?.appMessageData, Data("p4h-alice-msg".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobMsg = try bob.encrypt(appMessage: Data("p4h-bob-msg".utf8)) + let aliceGotMsg = try nativeAlice.processIncoming(bobMsg.cipherText) + guard case .decrypted(let aliceMsgDecrypted) = aliceGotMsg else { + XCTFail("expected a decrypted application frame, got \(aliceGotMsg)") + return + } + XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p4h-bob-msg".utf8)) + + // A committing round each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceUpdFrame = try nativeAlice.encrypt(Data("p4h-alice-upd".utf8)) + let bobDecrypted = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceUpdFrame.frame)) + let bobOffered = try XCTUnwrap(bobDecrypted.proposal) + try bob.queueProposal(digest: bobOffered.digest) + let bobPrepared = try bob.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "Rust bob's fold of alice's Upd should commit") + let bobCommitFrame = try bob.encrypt(appMessage: Data("p4h-bob-commit".utf8)) + let aliceCommitOpened = try nativeAlice.processIncoming(bobCommitFrame.cipherText) + guard case .decrypted(let aliceCommitDecrypted) = aliceCommitOpened else { + XCTFail("expected a decrypted application frame, got \(aliceCommitOpened)") + return + } + XCTAssertEqual(aliceCommitDecrypted.applicationMessage, Data("p4h-bob-commit".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame2 = try bob.encrypt(appMessage: Data("p4h-bob-upd2".utf8)) + let aliceOpened2 = try nativeAlice.processIncoming(bobUpdFrame2.cipherText) + guard case .decrypted(let aliceDecrypted2) = aliceOpened2 else { + XCTFail("expected a decrypted application frame, got \(aliceOpened2)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted2.queuedProposal.digest) + let alicePrepared2 = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared2.didCommit, "native alice's fold of bob's Upd should commit") + let aliceCommitFrame2 = try nativeAlice.encrypt(Data("p4h-alice-commit2".utf8)) + let bobGotCommit2 = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame2.frame)) + XCTAssertEqual( + bobGotCommit2.applicationMessage?.appMessageData, + Data("p4h-alice-commit2".utf8)) + } + + /// p5's A.4 heal, cross-engine: unlike p4/p6's A.3, p5's rows are already fully + /// established (both PQ halves live) and mid a ROUTINE A.4 ratchet instead. meta pins + /// the initiator as the turn holder with the parked leg (`legParked: true`), so THAT + /// side migrates to native; the acceptor stays Rust. Native's side-band take returns + /// the parked EK, Rust bob responds with the CT, and native binds it directly + /// (`pqRatchetBind` unseals its own input, like `pqBootstrapJoin`). + func testP5A4HealMigratesInitiatorToNative() throws { + let freshAlice = try restoreAndVerify(point: "p5-a4-stalled", side: "initiator") + let export = try freshAlice.migrationExport() + XCTAssertNotNil( + export.pendingSideBand, "alice's parked A.4 EK should be in the export") + XCTAssertNotNil(export.pqInflight, "the in-flight A.4 round should export") + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + let bob = try restoreAndVerify(point: "p5-a4-stalled", side: "acceptor") + XCTAssertTrue(nativeAlice.myPQTurn, "the turn holder should still hold the PQ turn") + + let sealedEk = try XCTUnwrap( + nativeAlice.pqPendingOutbound(), + "native alice should still hold the parked A.4 EK after migration") + let openedEk = try XCTUnwrap(try bob.openIncoming(blob: sealedEk)) + try bob.pqRatchetRespond(ekMsg: openedEk.frame) + + let sealedCt = try XCTUnwrap(bob.pqTakePendingOutbound()) + // Native's `pqRatchetBind` unseals `inbound` itself. The pinned twomlspq-swift + // release (0.2.1) exposes no public PQ-epoch accessor to assert the numeric + // advance directly (unlike Rust's `epochs()`) — `pqRatchetBind` succeeding + // without error already proves the CT decrypted and verified against the + // correct epoch's derived secret, and the round's full discharge plus the + // continued messaging below is the completion proof this test relies on. + _ = try nativeAlice.pqRatchetBind(sealedCt) + + XCTAssertNil( + nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") + XCTAssertNil( + bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") + + // Discharge the turn holder's own owed bind: the peer (bob) offers, native alice + // (the binder) queues + commits it. + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame = try bob.encrypt(appMessage: Data("p5h-bob-upd".utf8)) + let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) + guard case .decrypted(let aliceDecrypted) = aliceOpened else { + XCTFail("expected a decrypted application frame, got \(aliceOpened)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted.queuedProposal.digest) + let alicePrepared = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared.didCommit, + "native alice's discharge commit should fold bob's offered Upd") + let aliceCommitFrame = try nativeAlice.encrypt(Data("p5h-alice-commit".utf8)) + let bobGotCommit = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame.frame)) + XCTAssertEqual( + bobGotCommit.applicationMessage?.appMessageData, + Data("p5h-alice-commit".utf8)) + + // The discharge passes the PQ turn away from the holder (alice) to the peer (bob). + XCTAssertFalse( + nativeAlice.myPQTurn, + "the discharge should pass the PQ turn away from alice") + XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") + + // A message each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceMsg = try nativeAlice.encrypt(Data("p5h-alice-msg".utf8)) + let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) + XCTAssertEqual( + bobGotMsg.applicationMessage?.appMessageData, Data("p5h-alice-msg".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobMsg = try bob.encrypt(appMessage: Data("p5h-bob-msg".utf8)) + let aliceGotMsg = try nativeAlice.processIncoming(bobMsg.cipherText) + guard case .decrypted(let aliceMsgDecrypted) = aliceGotMsg else { + XCTFail("expected a decrypted application frame, got \(aliceGotMsg)") + return + } + XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p5h-bob-msg".utf8)) + + // A committing round each way. + _ = try nativeAlice.prepareToEncrypt() + let aliceUpdFrame = try nativeAlice.encrypt(Data("p5h-alice-upd".utf8)) + let bobDecrypted = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceUpdFrame.frame)) + let bobOffered = try XCTUnwrap(bobDecrypted.proposal) + try bob.queueProposal(digest: bobOffered.digest) + let bobPrepared = try bob.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "Rust bob's fold of alice's Upd should commit") + let bobCommitFrame = try bob.encrypt(appMessage: Data("p5h-bob-commit".utf8)) + let aliceCommitOpened = try nativeAlice.processIncoming(bobCommitFrame.cipherText) + guard case .decrypted(let aliceCommitDecrypted) = aliceCommitOpened else { + XCTFail("expected a decrypted application frame, got \(aliceCommitOpened)") + return + } + XCTAssertEqual(aliceCommitDecrypted.applicationMessage, Data("p5h-bob-commit".utf8)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame2 = try bob.encrypt(appMessage: Data("p5h-bob-upd2".utf8)) + let aliceOpened2 = try nativeAlice.processIncoming(bobUpdFrame2.cipherText) + guard case .decrypted(let aliceDecrypted2) = aliceOpened2 else { + XCTFail("expected a decrypted application frame, got \(aliceOpened2)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted2.queuedProposal.digest) + let alicePrepared2 = try nativeAlice.prepareToEncrypt() + XCTAssertTrue( + alicePrepared2.didCommit, "native alice's fold of bob's Upd should commit") + let aliceCommitFrame2 = try nativeAlice.encrypt(Data("p5h-alice-commit2".utf8)) + let bobGotCommit2 = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame2.frame)) + XCTAssertEqual( + bobGotCommit2.applicationMessage?.appMessageData, + Data("p5h-alice-commit2".utf8)) + } + + /// An OWED bind — bound (A.3 or A.4) but NOT YET discharged — is itself part of the + /// pending-advance state the export carries, independent of any parked side-band leg + /// (the p6 shape above already exercises `pendingSideBand`/`pqInflight` together; + /// this isolates `owedBind` on a session where NEITHER of those is set). Reaches an + /// owed-bind-but-undischarged state via an ordinary A.4 ratchet round stopped right + /// after the bind (bob, the turn holder, opens with a plain send; alice responds; bob + /// binds the CT — owing the classical bind — with nothing parked on either side once + /// bound). Migrate BOB; native must still discharge the owed bind via an ordinary + /// committing round, and the peer applies it. + func testOwedBindCarriesAcrossTheExport() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() + _ = try pair.bob.prepareToEncrypt(proposing: nil) + let opener = try pair.bob.encrypt(appMessage: Data("ratchet-open".utf8)) + _ = try pair.alice.processIncoming(ciphertext: opener.cipherText) + let sealedEk = try XCTUnwrap(pair.bob.pqPendingOutbound(sealing: .fresh)) + let openedEk = try XCTUnwrap(try pair.alice.openIncoming(blob: sealedEk)) + try pair.alice.pqRatchetRespond(ekMsg: openedEk.frame) + let sealedCt = try XCTUnwrap(pair.alice.pqTakePendingOutbound()) + let openedCt = try XCTUnwrap(try pair.bob.openIncoming(blob: sealedCt)) + try pair.bob.pqRatchetBind(ctMsg: openedCt.frame) + + let export = try pair.bob.migrationExport() + XCTAssertNotNil( + export.owedBind, "bob's owed classical bind should be in the export") + XCTAssertNil(export.pendingSideBand, "nothing is parked at this point") + XCTAssertNil(export.pqInflight, "no A.3/A.4/A.5 round is in flight at this point") + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Discharge across engines: alice (Rust, the peer) offers, native bob (the + // binder, who owes the bind) queues + commits it. + _ = try pair.alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try pair.alice.encrypt( + appMessage: Data("owed-bind-alice-upd".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue(bobPrepared.didCommit, "the owed bind needs a committing round") + let bobCommitFrame = try nativeBob.encrypt(Data("owed-bind-bob-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("owed-bind-bob-commit".utf8)) + + XCTAssertTrue( + pair.alice.isFullyEstablished(), "the discharge should not disturb this") + XCTAssertTrue(nativeBob.isFullyEstablished, "the discharge should not disturb this") + } + + /// Mutation: blank `owedBind` in the export before minting — the discharge round then + /// runs with nothing to fold in: native bob commits alice's offered Upd as an ORDINARY + /// classical round (a real, successful commit), but with no PQ half riding it, so + /// alice's PQ epoch never advances and the ratchet she already completed on her side + /// is never acknowledged back to her — the round SUCCEEDS at the classical layer + /// while the dropped PQ bind is silently lost rather than surfacing as an error. + func testBlankedOwedBindLosesTheBindSilently() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() + _ = try pair.bob.prepareToEncrypt(proposing: nil) + let opener = try pair.bob.encrypt(appMessage: Data("ratchet-open".utf8)) + _ = try pair.alice.processIncoming(ciphertext: opener.cipherText) + let sealedEk = try XCTUnwrap(pair.bob.pqPendingOutbound(sealing: .fresh)) + let openedEk = try XCTUnwrap(try pair.alice.openIncoming(blob: sealedEk)) + try pair.alice.pqRatchetRespond(ekMsg: openedEk.frame) + let sealedCt = try XCTUnwrap(pair.alice.pqTakePendingOutbound()) + let openedCt = try XCTUnwrap(try pair.bob.openIncoming(blob: sealedCt)) + try pair.bob.pqRatchetBind(ctMsg: openedCt.frame) + let pqEpochBefore = pair.alice.epochs().pqEpoch + + var export = try pair.bob.migrationExport() + XCTAssertNotNil(export.owedBind) + export.owedBind = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + _ = try pair.alice.prepareToEncrypt(proposing: nil) + let aliceUpdFrame = try pair.alice.encrypt(appMessage: Data("blank-owed-upd".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + let bobPrepared = try nativeBob.prepareToEncrypt() + XCTAssertTrue( + bobPrepared.didCommit, + "the classical fold succeeds on its own — nothing detects the missing PQ half" + ) + let bobCommitFrame = try nativeBob.encrypt(Data("blank-owed-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: bobCommitFrame.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("blank-owed-commit".utf8)) + + XCTAssertEqual( + pair.alice.epochs().pqEpoch, pqEpochBefore, + "alice's PQ epoch never advances — the ratchet she already applied is never acked" + ) + } + // MARK: - Mutation: blanking the pending-advance state breaks the heal + + /// p6, blanking ONLY `pendingSideBand` (bob's parked Welcome'; `pqInflight` intact). + /// A REAL heal attempt, not just a peek: Rust alice still independently holds HER OWN + /// parked KP' (the p6 shape keeps a copy on both sides), so re-send it to native bob — + /// exactly the retry a host would attempt after a dropped delivery. It cannot recover: + /// bob's send.pq is ALREADY founded, so `pqBootstrapRespond`'s idempotent branch needs + /// a retained frame to re-serve, and there is none. Alice can therefore never receive + /// a fresh Welcome' to bind, and never establishes — proving `pendingSideBand` is + /// load-bearing, not `pqPendingOutbound`'s tautological pass-through of it. + func testBlankedPendingSideBandBreaksP6AcceptorHeal() throws { + let alice = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "initiator") + let freshBob = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "acceptor") + var export = try freshBob.migrationExport() + export.pendingSideBand = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + XCTAssertNil( + nativeBob.pqPendingOutbound(), + "the blanked export leaves nothing for native bob's own side-band take") + + let sealedKp = try XCTUnwrap( + alice.pqPendingOutbound(sealing: .fresh), + "alice's own parked KP' should survive her own restore, independent of bob's" + ) + XCTAssertThrowsError(try nativeBob.pqBootstrapRespond(sealedKp)) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .duplicateSideBand) + } + + XCTAssertFalse( + alice.isFullyEstablished(), + "with no Welcome' ever returned, alice can never bind and never establishes" + ) + } + + /// The same p6 shape, blanking ONLY `pqInflight` this time (`pendingSideBand` intact). + /// This field is NOT what `pqPendingOutbound()`/`pqBootstrapRespond`'s idempotent + /// branch read (both key off `pendingSideBand` and `sendGroup.pq`, the real group + /// state) — so native bob's own side-band take and re-respond BOTH still work, and + /// alice DOES receive and bind a fresh Welcome'. The heal breaks one step later + /// instead: bob's `applyBind` (`TwoMLSSession+ClassicalCommit.swift`) explicitly + /// switches on `pqInflight`, requiring `.bootstrapResponded`/`.responding`/ + /// `.rekeyResponded` before it will apply an incoming bind commit at all — with + /// `pqInflight` blanked, that switch's `default` arm throws `sessionNotReady` when + /// bob tries to fold alice's discharge commit. So the heal still fails, just later + /// and differently than blanking `pendingSideBand` does. + func testBlankedPqInflightBreaksP6AcceptorHealAtTheBindApply() throws { + let alice = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "initiator") + let freshBob = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "acceptor") + var export = try freshBob.migrationExport() + export.pqInflight = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Native bob's own side-band take is UNAFFECTED — it reads `pendingSideBand`, + // left intact here — and alice binds it successfully. + let welcomePrimeSealed = try XCTUnwrap( + nativeBob.pqPendingOutbound(), + "pendingSideBand alone (left untouched here) is what pqPendingOutbound reads" + ) + let opened = try XCTUnwrap(try alice.openIncoming(blob: welcomePrimeSealed)) + try alice.pqBootstrapBind(welcomeMsg: opened.frame) + + // The discharge round: bob offers, alice commits — bob's own APPLY of that + // commit is where the blanked `pqInflight` actually bites. + _ = try nativeBob.prepareToEncrypt() + let bobUpd = try nativeBob.encrypt(Data("p6mut-bob-upd".utf8)) + let offered = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpd.frame)?.proposal) + try alice.queueProposal(digest: offered.digest) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceCommit = try alice.encrypt(appMessage: Data("p6mut-alice-commit".utf8)) + XCTAssertThrowsError(try nativeBob.processIncoming(aliceCommit.cipherText)) { + error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .sessionNotReady) + } + // Retrying changes nothing — the guard is on bob's own (blanked) state, not + // anything about the frame — so bob is durably stuck on this commit, not just + // racing a transient condition. + XCTAssertThrowsError(try nativeBob.processIncoming(aliceCommit.cipherText)) { + error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .sessionNotReady) + } + } + + /// The same PAIR of mutations, on the p4 initiator shape — with a genuinely different + /// (and instructive) result. p4 migrates alice BEFORE bob ever responds, so alice is + /// still pre-join: blanking ONLY `pendingSideBand` does NOT break this heal, because + /// native alice's own `pqBootstrapBegin()` re-derives the SAME KP' bytes from her + /// still-intact identity material (never a fresh mint) and simply restarts the round + /// — bob's pinned commitment still matches it. Unlike p6's acceptor, whose send.pq is + /// ALREADY founded and so has no "start over" available to it, the pre-join initiator + /// always does. Documented precisely, per the brief, rather than asserting a failure + /// that does not happen. + func testBlankedPendingSideBandDoesNotBreakP4InitiatorHeal() throws { + let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") + var export = try freshAlice.migrationExport() + export.pendingSideBand = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + XCTAssertNil( + nativeAlice.pqPendingOutbound(), + "the blanked export leaves nothing for native alice's own side-band take") + + // The self-heal: alice is still pre-join and holds the turn, so she can just + // restart the bootstrap — `pqBootstrapBegin` re-derives, it does not re-mint. + let restarted = try nativeAlice.pqBootstrapBegin() + + let bob = try restoreAndVerify(point: "p4-a3-stalled", side: "acceptor") + let openedKp = try XCTUnwrap(try bob.openIncoming(blob: restarted.frame)) + try bob.pqBootstrapRespond(kpMsg: openedKp.frame) + let sealedWelcomePrime = try XCTUnwrap(bob.pqTakePendingOutbound()) + _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) + XCTAssertTrue( + nativeAlice.isFullyEstablished, "the restarted round completes normally") + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame = try bob.encrypt(appMessage: Data("p4mut-bob-upd".utf8)) + let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) + guard case .decrypted(let aliceDecrypted) = aliceOpened else { + XCTFail("expected a decrypted application frame, got \(aliceOpened)") + return } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted.queuedProposal.digest) + XCTAssertTrue(try nativeAlice.prepareToEncrypt().didCommit) + let aliceCommitFrame = try nativeAlice.encrypt(Data("p4mut-alice-commit".utf8)) + let bobGotCommit = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame.frame)) + XCTAssertEqual( + bobGotCommit.applicationMessage?.appMessageData, + Data("p4mut-alice-commit".utf8) + ) + XCTAssertFalse( + nativeAlice.myPQTurn, "the discharge passes the turn away from alice") + XCTAssertTrue(bob.myPqTurn(), "the discharge passes the turn to bob") + } + + /// The p4 initiator shape, blanking ONLY `pqInflight`. Also does not break the heal: + /// `pqBootstrapJoin` never reads `pqInflight` (only `bootstrapKPSecret` and + /// `pendingProposal`), and it clears `pqInflight` itself as part of ordinary + /// completion regardless of what the export carried — so the blanked value is + /// overwritten before it could matter. Contrast p6's acceptor, where the PEER + /// (native bob) applying an INCOMING bind is what reads `pqInflight`; here alice is + /// the one who JOINS and then COMMITS the discharge herself, never on the receiving + /// end of that specific gate. + func testBlankedPqInflightDoesNotBreakP4InitiatorHeal() throws { + let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") + var export = try freshAlice.migrationExport() + export.pqInflight = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + let sealedKp = try XCTUnwrap( + nativeAlice.pqPendingOutbound(), + "pendingSideBand alone (left untouched here) is what pqPendingOutbound reads" + ) + + let bob = try restoreAndVerify(point: "p4-a3-stalled", side: "acceptor") + let openedKp = try XCTUnwrap(try bob.openIncoming(blob: sealedKp)) + try bob.pqBootstrapRespond(kpMsg: openedKp.frame) + let sealedWelcomePrime = try XCTUnwrap(bob.pqTakePendingOutbound()) + _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) + XCTAssertTrue( + nativeAlice.isFullyEstablished, + "blanking pqInflight alone did not break the heal" + ) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobUpdFrame = try bob.encrypt(appMessage: Data("p4mut2-bob-upd".utf8)) + let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) + guard case .decrypted(let aliceDecrypted) = aliceOpened else { + XCTFail("expected a decrypted application frame, got \(aliceOpened)") + return + } + _ = try nativeAlice.queueProposal(digest: aliceDecrypted.queuedProposal.digest) + XCTAssertTrue(try nativeAlice.prepareToEncrypt().didCommit) + let aliceCommitFrame = try nativeAlice.encrypt(Data("p4mut2-alice-commit".utf8)) + let bobGotCommit = try XCTUnwrap( + bob.processIncoming(ciphertext: aliceCommitFrame.frame)) + XCTAssertEqual( + bobGotCommit.applicationMessage?.appMessageData, + Data("p4mut2-alice-commit".utf8)) + XCTAssertFalse( + nativeAlice.myPQTurn, "the discharge passes the turn away from alice") + XCTAssertTrue(bob.myPqTurn(), "the discharge passes the turn to bob") } } diff --git a/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift b/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift new file mode 100644 index 0000000..0e49fe6 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift @@ -0,0 +1,151 @@ +import CryptoKit +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// A NON-dedicated acceptor that never drains `pendingOutbound()` exports fine even BEFORE +// the initiator has processed anything from it — the acceptor's parked return +// welcome rides `current_staple` until its own first send-group commit, so the initiator +// still joins off the very first ordinary frame. See the module note in +// `rust/two-mls-pq/src/session/migration.rs`. + +@available(macOS 26, iOS 26, *) +final class NonDedicatedEarlyExportTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + func testMigratedAcceptorsFirstFrameEstablishesRustInitiator() throws { + let (aliceSession, bobSession) = try establishNonDedicatedPair() + + // Export bob BEFORE alice has processed anything from him: he never drained + // `pendingOutbound()`, and alice has not yet joined Group_B. + let export = try bobSession.migrationExport() + XCTAssertNil(export.pqLeafCustody, "a non-dedicated acceptor carries no PQ custody") + XCTAssertNotNil( + export.joinedWelcomeDigest, + "bob's own join of Group_A is recorded even though it came via the `welcome:` " + + "parameter, not a staple — he needs it to dedup alice's later re-staples" + ) + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Native bob's FIRST frame: its staple is still his birth return-welcome (he has not + // committed his own send group yet) — delivering it must JOIN Rust alice AND decrypt + // the app message in the same call. + _ = try nativeBob.prepareToEncrypt() + let firstFrame = try nativeBob.encrypt(Data("bob-first".utf8)) + let aliceResult = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: firstFrame.frame)) + XCTAssertEqual( + aliceResult.applicationMessage?.appMessageData, Data("bob-first".utf8)) + + // Messages both ways. Alice has not committed either, so HER frames still re-staple + // HER OWN birth welcome (Welcome_A) — native bob's `recvGroup` is already established + // (joined via the `welcome:` parameter at `receive`, not a staple), so he must + // recognize the repeat and skip re-joining (`joinedWelcomeDigest`), just decrypting. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let aliceFrame1 = try aliceSession.encrypt(appMessage: Data("alice-1".utf8)) + let bobOpened1 = try nativeBob.processIncoming(aliceFrame1.cipherText) + guard case .decrypted(let bobDecrypted1) = bobOpened1 else { + XCTFail("expected a decrypted application frame, got \(bobOpened1)") + return + } + XCTAssertEqual(bobDecrypted1.applicationMessage, Data("alice-1".utf8)) + + _ = try nativeBob.prepareToEncrypt() + let bobFrame2 = try nativeBob.encrypt(Data("bob-2".utf8)) + let aliceGot2 = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: bobFrame2.frame)) + XCTAssertEqual(aliceGot2.applicationMessage?.appMessageData, Data("bob-2".utf8)) + + // A second alice frame, STILL re-stapling Welcome_A (she still has not committed): + // the dedup must keep holding, not just work once. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let aliceFrame2 = try aliceSession.encrypt(appMessage: Data("alice-2".utf8)) + let bobOpened2 = try nativeBob.processIncoming(aliceFrame2.cipherText) + guard case .decrypted(let bobDecrypted2) = bobOpened2 else { + XCTFail("expected a decrypted application frame, got \(bobOpened2)") + return + } + XCTAssertEqual(bobDecrypted2.applicationMessage, Data("alice-2".utf8)) + } + + /// Mutation: blank `joinedWelcomeDigest` in the export — the mint still succeeds (the + /// field is optional data, not cross-checked at mint time), but the flow it exists to + /// support breaks downstream: native bob can no longer recognize alice's re-stapled + /// birth welcome as an already-joined repeat, and `processIncoming` throws + /// `.unexpectedWelcome` instead of decrypting. + func testNilJoinedWelcomeDigestBreaksTheReStapleDedup() throws { + let (aliceSession, bobSession) = try establishNonDedicatedPair() + var export = try bobSession.migrationExport() + export.joinedWelcomeDigest = nil + + let archive = try SessionMigrator.mintArchive( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Bob's own first frame still establishes alice fine — this direction never + // consults bob's `joinedWelcomeDigest` (that guards HIS reads of HER re-staples). + _ = try nativeBob.prepareToEncrypt() + let firstFrame = try nativeBob.encrypt(Data("bob-first".utf8)) + _ = try XCTUnwrap(aliceSession.processIncoming(ciphertext: firstFrame.frame)) + + // Alice's first ordinary frame re-staples her own birth welcome (Welcome_A) — with + // no `joinedWelcomeDigest` to recognize it, native bob's dedup guard fails closed. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let aliceFrame = try aliceSession.encrypt(appMessage: Data("alice-1".utf8)) + XCTAssertThrowsError(try nativeBob.processIncoming(aliceFrame.cipherText)) { + error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .unexpectedWelcome) + } + } + + // MARK: - Scaffolding + + /// alice initiates, bob `receive`s under NO dedicated id (`newClientId: nil`) and never + /// drains `pendingOutbound()` — the app's real acceptor shape (it never drains one). + private func establishNonDedicatedPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("nd-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("nd-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let envelope = try XCTUnwrap(aliceSession.pendingOutbound()) + guard case .establishment(let frame) = try bobInvitation.openInitial(blob: envelope) + else { + throw NSError( + domain: "establishNonDedicatedPair", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope" + ]) + } + let welcome = try XCTUnwrap(frame.welcome) + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("nd-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + return (aliceSession, bobSession) + } +} diff --git a/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift b/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift index 9bd873b..1a692d4 100644 --- a/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift +++ b/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift @@ -1,7 +1,20 @@ +import CryptoKit import Foundation import TwoMLSPQBinding import XCTest +/// A born-dedicated pair from `RustSessionTestHelpers.bornDedicatedSessionPair()`: both raw +/// FFI sessions, the dedicated id `bob` runs under, and the ORIGINAL invitation identity +/// `alice` still knows him as (what a converged recv-PQ leaf that never catches up keeps +/// presenting — see `pqLeafCustody`). +@available(macOS 26, iOS 26, *) +struct BornDedicatedPair { + let alice: TwoMLSPQBinding.TwoMlsPqSession + let bob: TwoMLSPQBinding.TwoMlsPqSession + let dedicatedId: Data + let invitationId: Data +} + // Shared raw-FFI session helpers, adapted from `SessionMigrationTests`'s private `rustSay` / // `dischargeBind` (kept small and generic so `LegacyRowFixtureTests` can reuse the shape // without depending on that file's establishment scaffolding). @@ -49,4 +62,143 @@ enum RustSessionTestHelpers { got.applicationMessage?.appMessageData, Data("commit".utf8), file: file, line: line) } + + /// A born-dedicated pair PRE-INSTALL, raw FFI: alice initiates to bob's invitation, and + /// bob `receive`s under a DEDICATED client id (≠ the invitation id) — matching the Rust + /// suite's `born_dedicated_pending` (NOT `SessionMigrationTests`' `establishedSessionPair` + /// pipelined-A.3 shape: no parallel bootstrap-KP envelope is read here, matching how + /// `TwoMlsPqInvitation.receive` is actually driven with a dedicated id). Bob owes his + /// establishment envelope; alice still knows him as the invitation identity. + static func bornDedicatedPending( + file: StaticString = #filePath, line: UInt = #line + ) throws -> BornDedicatedPair { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("bd-alice".utf8)) + let invitationId = Data("bd-bob-invitation".utf8) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: invitationId) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap( + aliceSession.bootstrapKpCommitment(), file: file, line: line) + let envelope = try XCTUnwrap(aliceSession.pendingOutbound(), file: file, line: line) + guard case .establishment(let frame) = try bobInvitation.openInitial(blob: envelope) + else { + throw NSError( + domain: "bornDedicatedPending", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope" + ]) + } + let welcome = try XCTUnwrap(frame.welcome, file: file, line: line) + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let dedicatedId = Data("bd-bob-dedicated".utf8) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("bd-spawn".utf8), + newClientId: dedicatedId, expectedRemote: nil, expectedAppBinding: nil) + + return BornDedicatedPair( + alice: aliceSession, bob: bobSession, dedicatedId: dedicatedId, + invitationId: invitationId) + } + + /// Contract 26: install the mock delegation on `pair.bob`, returning its bytes. + @discardableResult + static func installMockEstablishmentEnvelope(_ pair: BornDedicatedPair) throws -> Data { + let signedEnvelope = Data("bd-signed-establishment-delegation".utf8) + try pair.bob.installEstablishmentEnvelope(envelope: signedEnvelope) + return signedEnvelope + } + + /// Installed but NOT yet classically converged: bob sends his first (handoff) frame, + /// alice PAUSES on it and APPROVES — but neither queues nor commits bob's catch-up Upd. + /// Returns bob's offered Upd (digest + proposing) for the caller to fold, or ignore. + static func bornDedicatedInstalledUnfolded( + file: StaticString = #filePath, line: UInt = #line + ) throws -> (pair: BornDedicatedPair, bobUpd: QueuedRemoteProposal) { + let pair = try bornDedicatedPending(file: file, line: line) + let signedEnvelope = try installMockEstablishmentEnvelope(pair) + _ = try pair.bob.prepareToEncrypt(proposing: nil) + let confirmB = try pair.bob.encrypt(appMessage: Data("confirm-b".utf8)) + let paused = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: confirmB.cipherText), file: file, + line: line) + let pending = try XCTUnwrap(paused.pendingEstablishment, file: file, line: line) + XCTAssertEqual(pending.envelope, signedEnvelope, file: file, line: line) + XCTAssertEqual(pending.welcome.first, 0x01, file: file, line: line) + let resumed = try pair.alice.processIncomingApproved( + ciphertext: confirmB.cipherText, + approvedEnvelopeDigest: Data(SHA256.hash(data: pending.envelope)), + approvedWelcomeDigest: Data(SHA256.hash(data: pending.welcome)), + expectedCreator: pair.dedicatedId) + let bobUpd = try XCTUnwrap(resumed?.proposal, file: file, line: line) + return (pair, bobUpd) + } + + /// A born-dedicated pair, raw FFI, through classical convergence and the A.3 bootstrap + + /// bind discharge — the shape of the Rust suite's `test_dedicated_principal_full_lifecycle` + /// up to (not including) its trailing message exchange. Bob holds the PQ turn and NOTHING + /// is mid-flight right at return (no message has been sent since the discharge, so + /// nothing has auto-staged — contrast `bornDedicatedSessionPair`, whose trailing sends + /// legitimately do). NEVER calls `bob`'s `pendingOutbound()` — the app never drains an + /// acceptor's parked return welcome, so this pins that shape rather than the drained one. + static func bornDedicatedSessionPairAtDischarge( + file: StaticString = #filePath, line: UInt = #line + ) throws -> BornDedicatedPair { + let (pair, bobUpd) = try bornDedicatedInstalledUnfolded(file: file, line: line) + let aliceSession = pair.alice + let bobSession = pair.bob + + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let confirmA = try aliceSession.encrypt(appMessage: Data("confirm-a".utf8)) + _ = try bobSession.processIncoming(ciphertext: confirmA.cipherText) + + // Alice folds Bob's catch-up Upd: his recv-classical leaf converges. + try aliceSession.queueProposal(digest: bobUpd.digest) + let prep = try aliceSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(prep.didCommit, file: file, line: line) + let fullA = try aliceSession.encrypt(appMessage: Data("full-a".utf8)) + let res = try XCTUnwrap( + bobSession.processIncoming(ciphertext: fullA.cipherText), file: file, + line: line) + let aliceUpd = try XCTUnwrap(res.proposal, file: file, line: line) + + // And in Bob's direction: his send group commits Alice's Upd. + try bobSession.queueProposal(digest: aliceUpd.digest) + let prepB = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(prepB.didCommit, file: file, line: line) + let fullB = try bobSession.encrypt(appMessage: Data("full-b".utf8)) + _ = try aliceSession.processIncoming(ciphertext: fullB.cipherText) + + // A.3 bootstrap + bind. + let kp = try aliceSession.pqBootstrapBegin(rotating: nil) + try bobSession.pqBootstrapRespond(kpMsg: kp) + let welcomePrime = try XCTUnwrap( + bobSession.pqTakePendingOutbound(), file: file, line: line) + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + try committingRound(binder: aliceSession, peer: bobSession, file: file, line: line) + XCTAssertTrue(aliceSession.isFullyEstablished(), file: file, line: line) + XCTAssertTrue(bobSession.isFullyEstablished(), file: file, line: line) + + return pair + } + + /// `bornDedicatedSessionPairAtDischarge` plus a message each way. Bob's own send here + /// legitimately auto-stages a speculative A.4 EK (mirrors Rust's + /// `maybe_stage_next_round`, which fires on every turn-holder send) — a caller that + /// needs `pqRekeyBegin`'s clean-slate precondition should use the discharge-point + /// helper above instead, not drain this one (draining a completed round passes the PQ + /// turn away, same as any other bind discharge). + static func bornDedicatedSessionPair( + file: StaticString = #filePath, line: UInt = #line + ) throws -> BornDedicatedPair { + let pair = try bornDedicatedSessionPairAtDischarge(file: file, line: line) + try rustSay(pair.bob, "pq-app", deliverTo: pair.alice, file: file, line: line) + try rustSay(pair.alice, "pq-app-2", deliverTo: pair.bob, file: file, line: line) + return pair + } } diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1e8317a..14831df 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,18 +39,18 @@ mls-rs = { version = "0.55", features = [ "safe_extensions", "swift_export", ] } -mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "74917834ccba56ed56399bfa6b679dff2e760e8b" } -mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "74917834ccba56ed56399bfa6b679dff2e760e8b", features = [ +mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } +mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4", features = [ "post-quantum", ] } -mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "74917834ccba56ed56399bfa6b679dff2e760e8b", features = [ +mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4", features = [ "post-quantum", ] } -mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "74917834ccba56ed56399bfa6b679dff2e760e8b" } +mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } sha2 = "0.10" thiserror = "1" uniffi = "0.31" zeroize = { version = "1", features = ["derive"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "74917834ccba56ed56399bfa6b679dff2e760e8b" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml index a44e186..a7d0faf 100644 --- a/rust/fuzz/Cargo.toml +++ b/rust/fuzz/Cargo.toml @@ -20,7 +20,7 @@ libfuzzer-sys = "0.4" two-mls-pq = { path = "../two-mls-pq", default-features = false, features = ["awslc"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "ec69dc251db66ae6eb117079564814998bb55dec" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } [[bin]] bench = false diff --git a/rust/two-mls-pq/src/lib.rs b/rust/two-mls-pq/src/lib.rs index ce124fd..eeca943 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -499,7 +499,17 @@ pub fn version() -> String { // enabled. Stored group state is unaffected, and a pre-v35-written session archive (down // through v0.15.0 (archive layout 3)) still restores here, pinned by the // `TwoMLSPQMigrateTests` legacy-row fixtures. -const BINDING_CONTRACT_VERSION: u64 = 35; +// +// v36: the session migration export now admits a born-dedicated acceptor once its +// establishment envelope has installed and its recv-classical leaf has caught up, and an +// acceptor still holding a parked return welcome (dropped from the export rather than +// refused). One new Record, `SessionMigrationPqLeafCustody`, and one field appended to +// `SessionMigrationExport` — a stale binding/binary pair would mis-read the export buffer. +// No wire, archive, or error-variant change. The mls-rs pin moves to the fork's +// `germ-integration` head (fcd822c), adding a read-only accessor for a group's current +// signing secret (gated behind the same `swift_export` feature as v35's pin) — no +// serialized struct changed. +const BINDING_CONTRACT_VERSION: u64 = 36; /// See `BINDING_CONTRACT_VERSION`. Exported so the Swift layer can verify the /// binding it was generated with matches the binary it loaded. diff --git a/rust/two-mls-pq/src/session/migration.rs b/rust/two-mls-pq/src/session/migration.rs index 7194bcf..5b00957 100644 --- a/rust/two-mls-pq/src/session/migration.rs +++ b/rust/two-mls-pq/src/session/migration.rs @@ -1,31 +1,63 @@ -//! Session migration export (GER-2433 C1): `TwoMlsPqSession::migration_export`, -//! the session-level analogue of `TwoMlsPqInvitation::migration_export` -//! (GER-2484). Emits everything twomlspq-swift's `SessionMigration.mintArchive` -//! needs to mint a native `SessionArchive`: each group half as a swift-mls -//! format-2 snapshot (mls-rs's `Group::export_for_swift`, slice A) plus the -//! combiner/session metadata as flat records. +//! Session migration export: `TwoMlsPqSession::migration_export`, the +//! session-level analogue of `TwoMlsPqInvitation::migration_export`. Emits +//! everything twomlspq-swift's `SessionMigration.mintArchive` needs to mint a +//! native `SessionArchive`: each group half as a swift-mls format-2 snapshot +//! (mls-rs's `Group::export_for_swift`, slice A) plus the combiner/session +//! metadata as flat records. //! -//! Scope: an ESTABLISHED, quiescent session. The export refuses (rather than -//! mis-maps) the states the native archive cannot represent: +//! Admits an established session — an established initiator or acceptor need +//! not be quiescent first: a parked side-band leg, an in-flight PQ round +//! (`pq_inflight`), and an owed classical bind (`owed_bind`) are all carried +//! in the export rather than required to settle before migrating, so the +//! round in progress completes normally on whichever engine finishes it: +//! +//! * one still holding a parked return welcome (an acceptor's +//! `pending_outbound`) — the export drops that parked copy rather than +//! refusing it. The same welcome rides `current_staple` until the +//! acceptor's own first send-group commit, and the native session +//! re-staples `currentStaple` the same way, so nothing is stranded; +//! * a born-dedicated acceptor, once its establishment envelope has +//! installed AND its recv-classical leaf has caught up to the dedicated +//! identity. Its recv-PQ leaf, which nothing in Rust ever catches up, +//! exports as `pq_leaf_custody` instead of refusing (see the custody gate +//! below). +//! +//! Refuses (rather than mis-maps) the states the native archive cannot +//! represent: //! //! * pre-establishment initiator (no recv group yet): the parked §A.1 //! envelope and app payload have no native slots, so the migrated session //! could never complete establishment; +//! * a born-dedicated acceptor pre-install, or installed but pre-convergence: +//! until the peer folds the classical catch-up Upd, the recv-classical +//! leaf still presents the invitation identity's key, and mls-rs replaces +//! that leaf's only signer exactly when the catch-up commit lands — before +//! then there is nothing native could sign that leaf with; //! * mid-rotation (`staged_candidates` / `deferred_candidate`): the Rust //! model holds up to `CANDIDATE_WINDOW` full successor identities where the //! native one holds a single rotation candidate; -//! * born-dedicated, either latch state: the delegation blob has no native -//! slot post-install, and pre-install the recv-group leaves present the -//! INVITATION identity's keys, which the session no longer holds (the -//! native `recvLeafPrincipal` custody arm can't be populated); -//! * post-rotation leaf-lag: a canonicalized rotation swaps `inner.client` -//! to fresh keys while lagging leaves still present the old ones, and the -//! export has no `rotationCandidate`/`recvLeafPrincipal` to custody them — -//! so every group half's own leaf must present the identity's per-half -//! signing key (the custody gate below); +//! * post-rotation leaf-lag: every group half's own leaf must present the +//! identity's per-half signing key, or (recv-PQ only) resolve through the +//! custody gate — anything else refuses. Applies to a born-dedicated +//! session too, for every half EXCEPT its already-custodied recv-PQ leaf +//! (e.g. a lagging send-PQ leaf after a rotation, which has no custody +//! slot of its own yet); //! * a wedged or bind-broken side-band: the native archive carries no wedge //! verdict, so the migrated session would report healthy and deadlock. //! +//! These refusals return `SessionNotReady` — retryable in principle, once the +//! session advances past the blocking state. One retry ceiling: custody also +//! requires the leaf's id still be within the `auth.mine` history window (8 +//! entries) — past enough further rotations without ever migrating, that +//! stops being true and the refusal becomes permanent. Rare in practice, but +//! not retryable when it happens. `ArchiveInvalid` means something else +//! entirely: the stored state is corrupt and retrying will not help — e.g. a +//! stored group whose cipher suite no longer matches the session's expected +//! suite (checked before any half's signer touches a provider, since deriving +//! a key through the wrong suite's provider is unrecoverable rather than a +//! clean failure), or a custodied PQ signer that doesn't derive to its +//! presented key. +//! //! A pending mls-rs commit or a signer-rotating pending self-Update fails //! inside `export_for_swift` itself. //! @@ -35,7 +67,7 @@ //! on any record: a derived impl would print plaintext key material. use mls_rs::mls_rs_codec::{MlsDecode, MlsEncode}; -use mls_rs::MlsMessage; +use mls_rs::{CipherSuiteProvider, MlsMessage}; use zeroize::Zeroizing; use crate::key_package_store::{CombinerGroup, KeyPackageSecret, SyntheticKeyPackageStore}; @@ -163,6 +195,20 @@ pub struct SessionMigrationBootstrapKp { pub key_package: Vec, } +/// Custody over the PQ signing key that one of this session's own PQ leaves still +/// presents in place of the identity's — today, a born-dedicated acceptor's recv-PQ +/// leaf, which keeps presenting the INVITATION identity's key because nothing in +/// Rust ever catches it up (see the module note). Only the PQ pair: every classical +/// own leaf must already present the identity's key, and mls-rs drops the old +/// classical signer at that catch-up, so no classical half is ever left to custody. +/// A follow-up reuses this for a lagging send-PQ leaf after a rotation. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationPqLeafCustody { + pub client_id: Vec, + pub pq_signing_key: Vec, + pub pq_signature_key: Vec, +} + /// The archivable `PqInflight` round state in the native `MigratedPQInflight` /// shape: the A.4 variants carry the round's KEM material, `RekeyInitiated` /// the leg-1 Upd' (lifted out of the retained side-band frame, whose `0x1B` @@ -236,6 +282,9 @@ pub struct SessionMigrationExport { pub initial_their_kp: Option, /// `requires_establishment_envelope` under its native name. pub owes_establishment_envelope: bool, + /// `Some` exactly when the recv-PQ leaf still presents a key other than the + /// identity's — a born-dedicated acceptor's uncaught-up PQ leaf. + pub pq_leaf_custody: Option, } /// The stored Ed25519 secrets use the mls-rs provider convention — the @@ -373,15 +422,60 @@ fn export_pq_inflight(inner: &SessionInner) -> Result( + group: &mls_rs::Group, +) -> Result> { + Ok(group + .current_member_signing_identity() + .map_err(|_| TwoMlsPqError::Mls)? + .signature_key + .as_bytes() + .to_vec()) +} + +/// A PQ half's own leaf, once it no longer presents the identity's key: builds custody +/// over its signer, provided the leaf's own credential is one of THIS session's own past +/// identities (`auth.mine` history, not the peer's) and the signer derives to the +/// presented key. Derivation runs through the EXPECTED suite's provider (never one chosen +/// by the group's own stored suite — the caller checks that separately, first). +pub(super) fn leaf_pq_custody( + group: &crate::key_package_store::PqMlsGroup, + presented: &[u8], + identity_client_id: &[u8], + auth_mine_history: &[Vec], +) -> Result { + let client_id = apq::sender_client_id(group, group.current_member_index()) + .map_err(|_| TwoMlsPqError::Mls)?; + if client_id == identity_client_id || !auth_mine_history.iter().any(|id| id == &client_id) { + return Err(TwoMlsPqError::SessionNotReady); + } + let signer = group.signer_for_swift_export(); + let derived = crate::providers::pq_envelope_suite()? + .signature_key_derive_public(signer) + .map_err(|_| TwoMlsPqError::ArchiveInvalid)?; + if derived.as_bytes() != presented { + return Err(TwoMlsPqError::ArchiveInvalid); + } + Ok(SessionMigrationPqLeafCustody { + client_id, + pq_signing_key: bare_ed25519(signer.as_bytes())?, + pq_signature_key: presented.to_vec(), + }) +} + #[uniffi::export] impl TwoMlsPqSession { /// Export this session as the migration payload for the twomlspq-swift - /// session mint (GER-2433 C1): every group half as a format-2 snapshot plus - /// the session metadata `SessionMigration.mintArchive` mints a native - /// `SessionArchive` from. + /// session mint: every group half as a format-2 snapshot plus the session + /// metadata `SessionMigration.mintArchive` mints a native `SessionArchive` + /// from. /// - /// Admits only an ESTABLISHED, quiescent session — see the module note for - /// the refused states (`SessionNotReady`; `ArchiveInvalid` for torn or + /// Admits only an established session — including one with a parked + /// side-band leg, an in-flight PQ round, or an owed bind, all carried so + /// the round completes after migration — see the module note for the + /// refused states (`SessionNotReady`; `ArchiveInvalid` for torn or /// unrecoverable state; `Mls` when a group half refuses its own export, /// e.g. a pending commit). /// @@ -391,34 +485,49 @@ impl TwoMlsPqSession { /// under `awslc` the length guards fail the export as `ArchiveInvalid`. pub fn migration_export(&self) -> Result { let mut inner = self.lock(); - - // The unmigratable states (module note): pre-establishment, mid-rotation, - // born-dedicated (either latch state), or a torn side-band. + let initiated = inner.expected_bootstrap_kp_commitment.is_none(); + + // The unmigratable states (module note): pre-establishment, mid-rotation, a + // born-dedicated session not yet installed, or a torn side-band. An + // acceptor's parked return welcome is fine — dropped below, not refused + // here, because the app never drains it and the same welcome still rides + // `current_staple` — so only an initiator's park blocks the export + // (already implied by `recv_group.is_none()`; kept as belt-and-braces). if inner.recv_group.is_none() - || inner.pending_outbound.is_some() + || (inner.pending_outbound.is_some() && initiated) || inner.initial_app_payload.is_some() || inner.initial_return_kp.is_some() || !inner.staged_candidates.is_empty() || inner.deferred_candidate.is_some() - || inner.requires_establishment_envelope - || inner.establishment_envelope.is_some() + || (inner.requires_establishment_envelope && inner.establishment_envelope.is_none()) || inner.pq_wedged.is_some() || inner.bind_apply_broken { return Err(TwoMlsPqError::SessionNotReady); } - let send_group = export_group_half( + // SUITE CHECK FIRST, before any crypto: a stored group half whose cipher + // suite disagrees with the session's own declared suite is corrupt — and + // must never reach a provider it doesn't belong to (see the module note). + let expected_suite = inner.client.combiner().cipher_suite(); + for half in [ inner .send_group - .as_mut() + .as_ref() .ok_or(TwoMlsPqError::SessionNotReady)?, - )?; - let recv_group = inner - .recv_group - .as_mut() - .map(export_group_half) - .transpose()?; + inner + .recv_group + .as_ref() + .ok_or(TwoMlsPqError::SessionNotReady)?, + ] { + let pq_ok = half + .pq + .as_ref() + .is_none_or(|pq| pq.cipher_suite() == expected_suite.pq); + if half.classical.cipher_suite() != expected_suite.classical || !pq_ok { + return Err(TwoMlsPqError::ArchiveInvalid); + } + } // The identity: signing keys from the session client, key packages // picked (or freshly minted) per half — see `identity_kp`. @@ -467,46 +576,65 @@ impl TwoMlsPqSession { }; // The custody gate: every group half's own leaf must present the - // identity's per-half signing key. The mint's custody arms resolve a - // presented key against identity ∪ rotationCandidate ∪ - // recvLeafPrincipal, and this export carries only the identity — so a - // post-rotation leaf-lag or born-dedicated session admitted here would - // fail the mint as `archiveInvalid` (corruption semantics on a healthy - // session). Refuse as `SessionNotReady` instead. - let leaf_matches = |group: &CombinerGroup| -> Result { - let classical_ok = group - .classical - .current_member_signing_identity() - .map_err(|_| TwoMlsPqError::Mls)? - .signature_key - .as_bytes() - == identity.signature_key; - let pq_ok = match group.pq.as_ref() { - Some(pq) => { - pq.current_member_signing_identity() - .map_err(|_| TwoMlsPqError::Mls)? - .signature_key - .as_bytes() - == identity.pq_signature_key + // identity's per-half signing key, except a born-dedicated acceptor's + // recv-PQ leaf, which nothing in Rust ever catches up (module note) — + // captured as `pq_leaf_custody` instead of matched. The mint's custody + // arms resolve a presented key against identity ∪ rotationCandidate ∪ + // recvLeafPrincipal; anything else admitted here would fail the mint as + // `archiveInvalid` (corruption semantics on a healthy session), so this + // refuses as `SessionNotReady` first — before the export ever surfaces + // an `Mls` error for merely not having converged yet. + let send = inner + .send_group + .as_ref() + .ok_or(TwoMlsPqError::SessionNotReady)?; + let recv = inner + .recv_group + .as_ref() + .ok_or(TwoMlsPqError::SessionNotReady)?; + if own_signature_key(&send.classical)? != identity.signature_key + || own_signature_key(&recv.classical)? != identity.signature_key + { + return Err(TwoMlsPqError::SessionNotReady); + } + if let Some(pq) = send.pq.as_ref() { + if own_signature_key(pq)? != identity.pq_signature_key { + return Err(TwoMlsPqError::SessionNotReady); + } + } + let pq_leaf_custody = match recv.pq.as_ref() { + None => None, + Some(pq) => { + let presented = own_signature_key(pq)?; + if presented == identity.pq_signature_key { + None + } else if inner.requires_establishment_envelope { + let auth_mine_history = inner.with_auth(|core| core.mine.to_parts().0); + Some(leaf_pq_custody( + pq, + &presented, + &identity.client_id, + &auth_mine_history, + )?) + } else { + // The existing post-rotation leaf-lag refusal for a + // non-dedicated session: no custody slot to fall back to. + return Err(TwoMlsPqError::SessionNotReady); } - None => true, - }; - Ok(classical_ok && pq_ok) + } }; - if !leaf_matches( + + let send_group = export_group_half( inner .send_group - .as_ref() + .as_mut() .ok_or(TwoMlsPqError::SessionNotReady)?, - )? || !inner + )?; + let recv_group = inner .recv_group - .as_ref() - .map(leaf_matches) - .transpose()? - .unwrap_or(false) - { - return Err(TwoMlsPqError::SessionNotReady); - } + .as_mut() + .map(export_group_half) + .transpose()?; let (auth_mine, auth_theirs) = inner.with_auth(|core| { let seq = |s: &apq::authentication::PartySequence| { @@ -588,7 +716,7 @@ impl TwoMlsPqSession { Ok(SessionMigrationExport { state_seq: inner.state_seq, - initiated: inner.expected_bootstrap_kp_commitment.is_none(), + initiated, identity, auth_mine, auth_theirs, @@ -660,7 +788,11 @@ impl TwoMlsPqSession { }) }) .transpose()?, - owes_establishment_envelope: inner.requires_establishment_envelope, + // Always false on an admitted export — the pre-install latch above + // refuses otherwise — kept for native-shape parity. + owes_establishment_envelope: inner.requires_establishment_envelope + && inner.establishment_envelope.is_none(), + pq_leaf_custody, }) } } @@ -721,9 +853,11 @@ mod tests { )); } - /// A fresh acceptor (recv group joined, its send-PQ half still deferred, - /// return welcome already drained) exports fine — the native mint's - /// topology gate keys off the RECV pair for a responder. + /// A fresh acceptor (recv group joined, its send-PQ half still deferred) exports fine + /// with its parked return welcome still UNDRAINED: the app never drains an + /// acceptor's `pending_outbound`, so refusing it would mean no acceptor session ever + /// migrates. The export drops the parked copy; the same welcome still rides + /// `current_staple` until this acceptor's own first send-group commit. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_acceptor_pre_bootstrap() { @@ -751,13 +885,10 @@ mod tests { None, None, )); - // Drain the parked return welcome — a session whose pre-establishment - // output is still parked is refused (the native archive has no slot - // for it). - assert_some!(bob_s.pending_outbound()); let export = assert_ok!(bob_s.migration_export()); assert!(!export.initiated); assert!(export.send_group.pq.is_none()); assert!(assert_some!(export.recv_group).pq.is_some()); + assert!(export.pq_leaf_custody.is_none()); } } diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index e916810..540e29c 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -8501,3 +8501,534 @@ fn test_v3_archive_restores_with_empty_attachment_ledgers() { // And the session is otherwise perfectly usable across the downgrade-then-restore. message_round(&restored, &alice, b"after-v3-restore"); } + +/// A born-dedicated acceptor's `migration_export` at the moment its recv-classical leaf +/// catches up (before its own first send-group commit, so the staple is still the §26 +/// handoff) and again once the full lifecycle (A.3 bootstrap, bind, an A.4 round each way) +/// has completed — including once more after an archive/restore of Bob, proving the +/// custodied signer survives the ordinary (non-swift) archive round-trip. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_born_dedicated_converged() { + use crate::key_packages::TwoMlsPqInvitation; + use mls_rs::{CipherSuiteProvider, CryptoProvider}; + + let alice = make_client(); + let bob = make_client(); + let alice_kp = make_classical_kp(&alice); + let bob_inv = assert_ok!(TwoMlsPqInvitation::restore(assert_ok!( + bob.generate_invitation(true) + ))); + let bob_kp = bob_inv.combiner_key_package(); + let invitation_identity = bob_inv.client_id().bytes; + + let alice_s = assert_ok!(TwoMlsPqSession::initiate(Arc::clone(&alice), bob_kp, None)); + let opened = assert_ok!(bob_inv.open_establishment(assert_some!(alice_s.pending_outbound()))); + let dedicated = crate::test_utils::test_client_id(); + let bob_s = assert_ok!(bob_inv.receive( + assert_some!(opened.welcome), + alice_kp, + commitment_of(&alice_s), + b"tok".to_vec(), + Some(dedicated.clone()), + None, + None + )); + + let envelope = crate::test_utils::install_mock_envelope(&bob_s); + assert_ok!(bob_s.prepare_to_encrypt(None)); + let enc = assert_ok!(bob_s.encrypt(b"confirm-b".to_vec())); + let res = assert_some!(crate::test_utils::approve_establishment( + &alice_s, + enc.cipher_text, + &envelope, + &dedicated + )); + let bob_upd = assert_some!(res.proposal); + assert_ok!(alice_s.prepare_to_encrypt(None)); + let enc = assert_ok!(alice_s.encrypt(b"confirm-a".to_vec())); + let res = assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + assert_some!(res.proposal); + + // Alice folds Bob's catch-up Upd: his recv-classical leaf converges. + assert_ok!(alice_s.queue_proposal(bob_upd.digest)); + let prep = assert_ok!(alice_s.prepare_to_encrypt(None)); + assert!(prep.did_commit); + let enc = assert_ok!(alice_s.encrypt(b"full-a".to_vec())); + let res = assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + let alice_upd = assert_some!(res.proposal); + + // Converged, but Bob has not yet committed into his OWN send group: the staple is + // still the §26 handoff frame installed at `install_mock_envelope`. + assert_eq!( + bob_s.lock().current_staple.first(), + Some(&super::frames::ESTABLISHMENT_HANDOFF_TAG), + "staple still the handoff — Bob's own first send-group commit hasn't happened" + ); + let export = assert_ok!(bob_s.migration_export()); + assert!(!export.initiated); + assert!(!export.owes_establishment_envelope); + assert_eq!( + assert_some!(export.pq_leaf_custody).client_id, + invitation_identity + ); + + // And in Bob's direction: his send group commits Alice's Upd. + assert_ok!(bob_s.queue_proposal(alice_upd.digest)); + let prep = assert_ok!(bob_s.prepare_to_encrypt(None)); + assert!(prep.did_commit); + let enc = assert_ok!(bob_s.encrypt(b"full-b".to_vec())); + assert_some!(assert_ok!(alice_s.process_incoming(enc.cipher_text))); + + // A.3 bootstrap + bind, then an A.4 round each way. + let kp = assert_ok!(alice_s.pq_bootstrap_begin(None)); + assert_ok!(bob_s.pq_bootstrap_respond(kp)); + let welcome = assert_some!(bob_s.pq_take_pending_outbound()); + assert_ok!(alice_s.pq_bootstrap_bind(welcome)); + discharge_bind(&alice_s, &bob_s, b"bootstrap-bind"); + assert!(alice_s.is_fully_established()); + assert!(bob_s.is_fully_established()); + ratchet_round(&bob_s, &alice_s, b"pq-app-1"); + ratchet_round(&alice_s, &bob_s, b"pq-app-2"); + + let export = assert_ok!(bob_s.migration_export()); + assert!(!export.initiated); + assert!(!export.owes_establishment_envelope); + let custody = assert_some!(export.pq_leaf_custody); + assert_eq!(custody.client_id, invitation_identity); + // The pq pair derives (the same cross-check the mint performs). + let pq_cs = assert_some!( + crate::providers::pq().cipher_suite_provider(crate::providers::pq_cipher_suite()) + ); + let signer = mls_rs::crypto::SignatureSecretKey::new(custody.pq_signing_key.clone()); + let derived = assert_ok!(pq_cs.signature_key_derive_public(&signer)); + assert_eq!(derived.as_bytes(), custody.pq_signature_key); + + // The export also succeeds after an archive→restore of Bob — the restored group's + // signer is not swift-export-only state, so it survives the ordinary archive. + let archive = assert_ok!(bob_s.archive()); + let restored = assert_ok!(TwoMlsPqSession::from_archive(archive)); + let export = assert_ok!(restored.migration_export()); + assert_eq!( + assert_some!(export.pq_leaf_custody).client_id, + invitation_identity + ); + + // The pre-install latch is what the gate actually checks, not an incidental side + // effect of having gone through installation: erase Bob's installed envelope (as if + // it had never landed) and the export must refuse again, even though every OTHER + // convergence condition still holds. + bob_s.lock().establishment_envelope = None; + assert!(matches!( + bob_s.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A born-dedicated acceptor pre-install: the establishment envelope has not landed yet, so +/// the export refuses rather than mis-mapping the missing delegation. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_born_dedicated_pre_install() { + let d = crate::test_utils::born_dedicated_pending(); + assert!(matches!( + d.bob.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A born-dedicated acceptor, installed and sent, but whose catch-up Upd the peer has only +/// PROCESSED (paused → approved) and not yet folded: Bob's recv-classical leaf still +/// presents the invitation identity's key, and mls-rs still holds a signer-carrying pending +/// update for it — the custody gate must catch this as `SessionNotReady` before +/// `export_for_swift` ever runs and surfaces it as `Mls`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_born_dedicated_pre_convergence() { + let d = crate::test_utils::born_dedicated_pending(); + let envelope = crate::test_utils::install_mock_envelope(&d.bob); + assert_ok!(d.bob.prepare_to_encrypt(None)); + let enc = assert_ok!(d.bob.encrypt(b"confirm-b".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &d.alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + assert!(matches!( + d.bob.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A staged (in-flight, unfolded) rotation candidate blocks the export. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_staged_rotation_candidate() { + let (alice, _bob) = establish_confirmed_sessions(); + let new_id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(new_id))); + assert!(matches!( + alice.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A staged candidate that is never committed stays in `staged_candidates` indefinitely — +/// canonicalization only prunes the set when a candidate itself is the thing committed, not +/// on any unrelated commit — so a later PLAIN round that the peer folds instead still +/// leaves the export refused. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_superseded_staged_candidate() { + let (alice, bob) = establish_confirmed_sessions(); + let new_id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(new_id))); + let rotation = assert_ok!(alice.encrypt(b"rotate".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(rotation.cipher_text))); + + // Bob never queues/commits the candidate; a later PLAIN round supersedes it instead. + assert_ok!(alice.prepare_to_encrypt(None)); + let plain = assert_ok!(alice.encrypt(b"plain".to_vec())); + let got = assert_some!(assert_ok!(bob.process_incoming(plain.cipher_text))); + let offered = assert_some!(got.proposal); + assert_ok!(bob.queue_proposal(offered.digest)); + assert!(assert_ok!(bob.prepare_to_encrypt(None)).did_commit); + let staple = assert_ok!(bob.encrypt(b"fold-plain".to_vec())); + assert_some!(assert_ok!(alice.process_incoming(staple.cipher_text))); + + assert!(matches!( + alice.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// Post-rotation leaf lag: the peer's commit canonicalizes the new identity, but the +/// rotated party's own send-group leaf lags until its own next commit. The custody gate +/// must refuse this as `SessionNotReady` — pinning that the gate runs BEFORE +/// `export_group_half`, so a lagging leaf never reaches `export_for_swift` and surfaces as +/// the unrelated `Mls` error instead. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_post_rotation_leaf_lag() { + let (alice, bob) = establish_confirmed_sessions(); + let new_alice = make_client().client_id(); + rotate_round(&alice, &bob, new_alice); + assert!(matches!( + alice.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A rotation's catch-up A.5 mid-round (Upd' sent, Commit' produced but not yet applied): +/// the rotated party's send-PQ leaf still lags, same as the plain post-rotation case, and +/// must refuse as `SessionNotReady` even with a genuinely in-flight round. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_mid_a5_round_on_rotated_session() { + let (alice, bob) = establish_full(); + // Flip the turn so Bob is the non-turn-holder rekey initiator (`rekey_to_commit`'s + // precondition), then rotate him and drive the A.5 up to — but not through — his apply. + ratchet_round(&bob, &alice, b"flip"); + let new_bob_id = make_client().client_id(); + let _responder_commit = rekey_to_commit(&bob, &alice, new_bob_id); + assert!(matches!( + bob.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A ROTATED party (Bob) at rest — no A.5 in flight, nothing owed — whose credential +/// handoff has converged everywhere except his own send-PQ leaf: `rekey_round`'s Upd' +/// moves his recv-PQ mirror (the proposal replaces the proposer) and its own trailing +/// discharge catches up his send-classical/recv-classical, but his send-PQ leaf moves +/// only on a round HE responds to (`own_pq_leaf_signature_keys`'s doc), which hasn't +/// happened. The export must still refuse this settled state (`SessionNotReady`) — only +/// recv.pq has a custody slot; send.pq has none. Named descriptively because a follow-up +/// (W2d) is expected to extend custody to this exact shape (a lagging send-PQ leaf after +/// a rotation). +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_send_pq_lag_after_rotation_converges_everything_else() { + let (alice, bob) = establish_full(); + // Flip the turn so Bob is the non-turn-holder — `rekey_round`'s precondition for + // driving his own catch-up A.5 as its initiator. + ratchet_round(&bob, &alice, b"flip"); + let new_bob_id = make_client().client_id(); + rekey_round(&bob, &alice, new_bob_id); + + assert!(bob.lock().owed_bind.is_none(), "settled, not mid-bind"); + assert!(bob.lock().pq_inflight.is_none(), "no A.5 in flight"); + let new_key = bob + .lock() + .client + .combiner() + .pq_signature_keypair() + .1 + .as_bytes() + .to_vec(); + let (send_pq, recv_pq) = own_pq_leaf_signature_keys(&bob); + assert_eq!(recv_pq, new_key, "recv.pq should have converged already"); + assert_ne!( + send_pq, new_key, + "send.pq should still lag — the state under test" + ); + + assert!(matches!( + bob.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// `leaf_pq_custody`'s history-membership check in isolation: an empty `auth_mine` +/// history can never contain the leaf's own credential, whatever it is, so custody must +/// refuse — even though the leaf's id here genuinely differs from `identity_client_id` +/// (the OTHER check this call could otherwise trip instead, which would mask this one). +#[cfg(feature = "cryptokit")] +#[test] +fn test_leaf_pq_custody_refuses_when_history_is_empty() { + let (_alice, bob) = establish_full(); + let inner = bob.lock(); + let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); + let presented = assert_ok!(super::migration::own_signature_key(recv_pq)); + let other_identity = crate::test_utils::test_client_id(); + assert!(matches!( + super::migration::leaf_pq_custody(recv_pq, &presented, &other_identity, &[]), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// `leaf_pq_custody`'s identity-mismatch check in isolation: the leaf's own credential +/// equalling `identity_client_id` refuses even though the history genuinely DOES contain +/// it (the OTHER check this call could otherwise trip instead) — a leaf cannot be both +/// "the identity itself" and "an identity needing custody" at once. +#[cfg(feature = "cryptokit")] +#[test] +fn test_leaf_pq_custody_refuses_when_leaf_is_the_identity() { + let (_alice, bob) = establish_full(); + let inner = bob.lock(); + let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); + let presented = assert_ok!(super::migration::own_signature_key(recv_pq)); + let own_client_id = assert_ok!(apq::sender_client_id( + recv_pq, + recv_pq.current_member_index() + )); + assert!(matches!( + super::migration::leaf_pq_custody( + recv_pq, + &presented, + &own_client_id, + std::slice::from_ref(&own_client_id) + ), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A wedged PQ side-band blocks the export: the native archive carries no wedge verdict, +/// so a migrated session would report healthy and deadlock instead. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_pq_wedged() { + let (alice, _bob) = establish_confirmed_sessions(); + alice.lock().pq_wedged = Some(super::pq_ops::PqWedge::Bootstrap); + assert!(matches!( + alice.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A torn bind application blocks the export for the same reason a wedge does — no native +/// slot represents it, and the migrated session must not silently drop the tear. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_bind_apply_broken() { + let (alice, _bob) = establish_confirmed_sessions(); + alice.lock().bind_apply_broken = true; + assert!(matches!( + alice.migration_export(), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// The custody derive check catches a corrupted signer: locate the born-dedicated +/// acceptor's ACTUAL recv-PQ signer bytes inside his own archive (a unique 64-byte hit — +/// the cryptokit raw‖public form `signer_for_swift_export` returns), flip one bit, and +/// restore. The presented public key (read off the group's leaf, untouched by the +/// corruption) no longer matches what the corrupted signer derives to, so the export +/// must refuse as `ArchiveInvalid`, not silently custody an unusable key. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_rejects_corrupted_custody_signer() { + use crate::key_packages::TwoMlsPqInvitation; + + let alice = make_client(); + let bob = make_client(); + let alice_kp = make_classical_kp(&alice); + let bob_inv = assert_ok!(TwoMlsPqInvitation::restore(assert_ok!( + bob.generate_invitation(true) + ))); + let bob_kp = bob_inv.combiner_key_package(); + let alice_s = assert_ok!(TwoMlsPqSession::initiate(Arc::clone(&alice), bob_kp, None)); + let opened = assert_ok!(bob_inv.open_establishment(assert_some!(alice_s.pending_outbound()))); + let dedicated = crate::test_utils::test_client_id(); + let bob_s = assert_ok!(bob_inv.receive( + assert_some!(opened.welcome), + alice_kp, + commitment_of(&alice_s), + b"tok".to_vec(), + Some(dedicated.clone()), + None, + None, + )); + let envelope = crate::test_utils::install_mock_envelope(&bob_s); + assert_ok!(bob_s.prepare_to_encrypt(None)); + let enc = assert_ok!(bob_s.encrypt(b"confirm-b".to_vec())); + let res = assert_some!(crate::test_utils::approve_establishment( + &alice_s, + enc.cipher_text, + &envelope, + &dedicated + )); + let bob_upd = assert_some!(res.proposal); + assert_ok!(alice_s.prepare_to_encrypt(None)); + let enc = assert_ok!(alice_s.encrypt(b"confirm-a".to_vec())); + assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + // Alice folds Bob's catch-up Upd — his recv-classical leaf converges, and his + // recv-PQ leaf's custody becomes exportable (still presenting the invitation key). + assert_ok!(alice_s.queue_proposal(bob_upd.digest)); + let prep = assert_ok!(alice_s.prepare_to_encrypt(None)); + assert!(prep.did_commit); + let enc = assert_ok!(alice_s.encrypt(b"full-a".to_vec())); + assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + assert_some!(assert_ok!(bob_s.migration_export()).pq_leaf_custody); + + let signer_bytes = { + let inner = bob_s.lock(); + inner + .recv_group + .as_ref() + .unwrap() + .pq + .as_ref() + .unwrap() + .signer_for_swift_export() + .as_bytes() + .to_vec() + }; + assert_eq!(signer_bytes.len(), 64, "cryptokit raw‖public form"); + + let mut archive = assert_ok!(bob_s.archive()).bytes; + let hits: Vec<_> = archive + .windows(signer_bytes.len()) + .enumerate() + .filter(|(_, w)| *w == signer_bytes.as_slice()) + .map(|(i, _)| i) + .collect(); + assert_eq!(hits.len(), 1, "expected a unique hit for the signer bytes"); + archive[hits[0]] ^= 0xFF; + + let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { + bytes: archive + })); + assert!(matches!( + restored.migration_export(), + Err(TwoMlsPqError::ArchiveInvalid) + )); +} + +/// A restored session whose CLASSICAL group's stored `GroupContext.cipher_suite` disagrees +/// with the session's declared classical suite must refuse `migration_export` rather than +/// restore silently and let a later crypto operation derive a key through the wrong suite's +/// provider — unrecoverable in the native CryptoKit bridge (see the module note). The +/// classical provider recognizes several suites, so `Group::load` itself does not catch +/// this: build the corrupted row by locating the GroupContext via its own (unique) group id +/// and flipping the suite byte, the way a torn or bit-rotted archive would look. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_rejects_corrupted_classical_suite() { + let (_alice, bob) = establish_confirmed_sessions(); + let send_gid = bob + .lock() + .send_group + .as_ref() + .unwrap() + .classical + .group_id() + .to_vec(); + let mut archive = assert_ok!(bob.archive()).bytes; + + let mut pattern = vec![0x00u8, 0x01]; + pattern.extend_from_slice(&u16::from(mls_rs::CipherSuite::CURVE25519_CHACHA).to_be_bytes()); + pattern.push(send_gid.len() as u8); + pattern.extend_from_slice(&send_gid); + // The group id can appear more than once (e.g. a creator's own cached creation + // record) — empirically, the byte-for-byte earliest match is always the live + // GroupContext `Group::load` actually reads; a later one, if any, is inert. + let pos = archive + .windows(pattern.len()) + .position(|w| w == pattern.as_slice()) + .unwrap(); + // 0x0003 (CURVE25519_CHACHA) -> 0x0002 (P256_AES128): still a suite the classical + // provider recognizes, so `Group::load` does not itself catch the corruption. + archive[pos + 3] = 0x02; + + let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { + bytes: archive + })); + assert_eq!( + restored + .lock() + .send_group + .as_ref() + .unwrap() + .classical + .cipher_suite(), + mls_rs::CipherSuite::new(2), + "the corrupted suite really does restore — the hazard the suite check guards against" + ); + assert!(matches!( + restored.migration_export(), + Err(TwoMlsPqError::ArchiveInvalid) + )); +} + +/// The PQ-half analogue does NOT reproduce the same hazard in this build: unlike the +/// classical provider (which recognizes several suites), `CryptoKitMlKemProvider` — the PQ +/// half's sole crypto provider — recognizes EXACTLY ML-KEM-768, so `Group::load` itself +/// already refuses any other stored suite value for a PQ group before the suite check in +/// `migration_export` ever runs. There is no PQ-half suite corruption that "restores and +/// lies" the way the classical half's does; this pins that boundary instead of asserting a +/// scenario that cannot occur under this build's single-PQ-suite provider. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_pq_suite_corruption_fails_at_restore() { + let (_alice, bob) = establish_full(); + let send_gid = bob + .lock() + .send_group + .as_ref() + .unwrap() + .pq + .as_ref() + .unwrap() + .group_id() + .to_vec(); + let mut archive = assert_ok!(bob.archive()).bytes; + + let pq_suite = crate::providers::pq_cipher_suite(); + let mut pattern = vec![0x00u8, 0x01]; + pattern.extend_from_slice(&u16::from(pq_suite).to_be_bytes()); + pattern.push(send_gid.len() as u8); + pattern.extend_from_slice(&send_gid); + // As above: take the earliest match — the live GroupContext `Group::load` reads. + let pos = archive + .windows(pattern.len()) + .position(|w| w == pattern.as_slice()) + .unwrap(); + archive[pos + 3] ^= 0x01; + + assert!(matches!( + TwoMlsPqSession::from_archive(crate::Archive { bytes: archive }), + Err(TwoMlsPqError::Mls) + )); +} From f5d398f17bee512a4a80e2ef8685589827df570a Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 23:28:08 -0700 Subject: [PATCH 02/16] Discard misrouted frames instead of flagging a caller bug An ill-timed side-band re-send surfaces as misroutedFrame, and it is normal traffic: the peer re-sends until answered, so dropping it is lossless. This changes app-side handling and analytics buckets. Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 4 ++++ Sources/TwoMLSPQ/SessionErrorBridge.swift | 7 ++++--- Sources/TwoMLSPQTypes/SessionError.swift | 8 ++++---- Tests/TwoMLSPQTests/ErrorContractTests.swift | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md index 47cda74..a7cd3aa 100644 --- a/.changeset/born-dedicated-acceptor-export.md +++ b/.changeset/born-dedicated-acceptor-export.md @@ -12,3 +12,7 @@ side. `BINDING_CONTRACT_VERSION` bumps 35 → 36 for the new record and field. Also corrects the 0.17.0 changelog's claim that group state written before that release ("pre-v35") no longer loads: it does. Sessions written by v0.16.0 restore and keep messaging under this engine, pinned by fixtures. + +`SessionError.Code.misroutedFrame` now has disposition `.discardFrame` (was `.callerBug`): an ill-timed +side-band re-send is normal traffic, and the peer re-sends until answered, so dropping it is lossless. +This shifts app-side handling and any analytics bucketed by disposition. diff --git a/Sources/TwoMLSPQ/SessionErrorBridge.swift b/Sources/TwoMLSPQ/SessionErrorBridge.swift index cf38188..48ebf81 100644 --- a/Sources/TwoMLSPQ/SessionErrorBridge.swift +++ b/Sources/TwoMLSPQ/SessionErrorBridge.swift @@ -26,9 +26,10 @@ enum PQErrorSurface { case pqOperation /// The side-band entry point. `SessionNotReady` here is an ill-timed /// frame (v18 narrowed `DuplicateSideBand` to steps PROVABLY done, so - /// merely ill-timed re-sends still surface as `SessionNotReady`) — - /// mapped to `.misroutedFrame`, whose disposition is `.callerBug` (see - /// `SessionError.Code.disposition`). + /// merely ill-timed re-sends still surface as `SessionNotReady`) — mapped + /// to `.misroutedFrame` so its disposition stays a frame-level discard + /// rather than a caller bug; retention means the peer re-sends until + /// answered, so discarding is lossless. case ingest case receive case decodeHeader diff --git a/Sources/TwoMLSPQTypes/SessionError.swift b/Sources/TwoMLSPQTypes/SessionError.swift index 899982f..ba8660d 100644 --- a/Sources/TwoMLSPQTypes/SessionError.swift +++ b/Sources/TwoMLSPQTypes/SessionError.swift @@ -162,8 +162,8 @@ public struct SessionError: Error, Sendable { /// A different welcome on a live session — a mis-route or unexpected re-invite (same-welcome /// re-deliveries are idempotent, not this). case unexpectedWelcome - /// A side-band frame reached `processIncoming`/`forwarded`, or vice versa — a routing bug - /// at the call site. + /// A frame reached the wrong entry point for its kind, or a side-band frame arrived out of + /// turn (an ill-timed re-send). Drop it: the peer keeps re-sending until answered. case misroutedFrame /// An operation was driven out of turn / order (encrypt before prepare, begin off-turn, …). case sequenceViolation @@ -214,7 +214,7 @@ public struct SessionError: Error, Sendable { return .retryLater case .staleFrame, .duplicateWelcome, .duplicateSideBand, .unopenableFrame, .malformedFrame, .bootstrapKpMismatch, - .attachmentComponentUnavailable: + .attachmentComponentUnavailable, .misroutedFrame: // A.3 KP′ not matching the signed commitment: drop the bad frame, the session is // 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. @@ -244,7 +244,7 @@ public struct SessionError: Error, Sendable { // (un-enveloped, or the creator does not match the admitted key) is refused // exactly like any other bad establishment — tear down, do not adopt. return .rejectEstablishment - case .misroutedFrame, .sequenceViolation, .sessionNotEstablished, + case .sequenceViolation, .sessionNotEstablished, .invalidClientId, .proposalRejected, .unsupportedCipherSuite, .missingWelcome, .sinkAlreadyInstalled, .establishmentEnvelopeConflict, .notImplemented: diff --git a/Tests/TwoMLSPQTests/ErrorContractTests.swift b/Tests/TwoMLSPQTests/ErrorContractTests.swift index f0e9feb..42fac8c 100644 --- a/Tests/TwoMLSPQTests/ErrorContractTests.swift +++ b/Tests/TwoMLSPQTests/ErrorContractTests.swift @@ -47,7 +47,7 @@ struct ErrorContractTests { (.apqInfoMismatch, .rejectEstablishment), (.appBindingMismatch, .rejectEstablishment), (.unexpectedWelcome, .rejectEstablishment), - (.misroutedFrame, .callerBug), + (.misroutedFrame, .discardFrame), (.sequenceViolation, .callerBug), (.sessionNotEstablished, .callerBug), (.invalidClientId, .callerBug), From fdfd1041b760c064ce104645813abf167c07e7df Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 23:31:24 -0700 Subject: [PATCH 03/16] Clarify when born-dedicated acceptors become exportable Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md index a7cd3aa..7c28f78 100644 --- a/.changeset/born-dedicated-acceptor-export.md +++ b/.changeset/born-dedicated-acceptor-export.md @@ -9,6 +9,10 @@ recv-PQ leaf a born-dedicated acceptor's session never catches up now exports as `pqLeafCustody` field, so the app's migrator can seat it correctly on the migrated side. `BINDING_CONTRACT_VERSION` bumps 35 → 36 for the new record and field. +That catch-up happens only once the peer folds the acceptor's catch-up offer. A caller +that folds only offers introducing a new client never folds it, so such born-dedicated +acceptors remain refused until a later release carries unconverged custody. + Also corrects the 0.17.0 changelog's claim that group state written before that release ("pre-v35") no longer loads: it does. Sessions written by v0.16.0 restore and keep messaging under this engine, pinned by fixtures. From 1ec1e3be6d82d889ba5a12e3037048030b8b6952 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 11:49:28 -0700 Subject: [PATCH 04/16] Export every reachable session state for migration The migration export no longer refuses unsettled state. It carries per-group signing keys (with reservations for groups not yet created), rotation candidates, a bounded own-offer window with detached leaf secrets, and deployed-engine flags, and errors only on corrupt data. Pins mls-rs germ-integration for the pending-update exports. Co-Authored-By: Claude Opus 5.5 --- Sources/TwoMLSPQ/PQSession.swift | 12 +- Sources/TwoMLSPQBinding/two_mls_pq.swift | 1262 ++++++- rust/Cargo.toml | 10 +- rust/fuzz/Cargo.toml | 2 +- rust/two-mls-pq/Cargo.toml | 4 + rust/two-mls-pq/src/lib.rs | 17 +- rust/two-mls-pq/src/providers.rs | 14 + rust/two-mls-pq/src/session/migration.rs | 1717 ++++++++-- rust/two-mls-pq/src/session/tests.rs | 3968 ++++++++++++++++++++-- 9 files changed, 6289 insertions(+), 717 deletions(-) diff --git a/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 6e6b607..b2db3f0 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -264,13 +264,11 @@ public typealias SideBandSealing = TwoMLSPQTypes.SideBandSealing // `TwoMlsPqSession.migrationExport()`. The consumer lives in `TwoMLSPQMigrate` // (`SessionMigrator`), mapping onto twomlspq-swift's `SessionMigration.mintArchive`. // No wire, API, or error-variant change beyond the new symbols. -// v36: the session migration export now admits a born-dedicated acceptor once its -// establishment envelope has installed and its recv-classical leaf has caught up, and -// an acceptor still holding a parked return welcome. One new record, -// `SessionMigrationPqLeafCustody`, and one field (`pqLeafCustody`) appended to -// `SessionMigrationExport` — its memberwise init gains a parameter — mapped by -// `SessionMigrator` onto twomlspq-swift's `MigratedRecvLeafPrincipal`. No wire or -// error-variant change. +// v36: the session migration export admits every reachable deployed session instead of +// refusing unsettled state, erroring only on corrupt or impossible data. New records +// carry per-group leaf keys, the rotation candidate, the own-offer window, and +// deployed-engine flags, appended to `SessionMigrationExport`'s memberwise init. No +// wire or error-variant change. private let expectedBindingContract: UInt64 = 36 enum TwoMLSPQBindingContract { diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index 8c1139a..7c142b5 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -2222,21 +2222,15 @@ public protocol TwoMlsPqSessionProtocol: AnyObject, Sendable { /** * Export this session as the migration payload for the twomlspq-swift - * session mint: every group half as a format-2 snapshot plus the session - * metadata `SessionMigration.mintArchive` mints a native `SessionArchive` - * from. + * session mint: every group half as a format-2 snapshot plus session + * metadata `SessionMigration.mintArchive` mints a native + * `SessionArchive` from. See the module note for the totality rule and + * what still refuses. * - * Admits only an established session — including one with a parked - * side-band leg, an in-flight PQ round, or an owed bind, all carried so - * the round completes after migration — see the module note for the - * refused states (`SessionNotReady`; `ArchiveInvalid` for torn or - * unrecoverable state; `Mls` when a group half refuses its own export, - * e.g. a pending commit). - * - * Emits PLAINTEXT SECRET material — the caller seals (the `ArchiveSink` - * contract). PQ secret material is exported in the CryptoKit 96-byte - * representation, correct only under the `cryptokit` provider build; - * under `awslc` the length guards fail the export as `ArchiveInvalid`. + * Emits plaintext secret material — the caller seals (the + * `ArchiveSink` contract). PQ secrets use the CryptoKit 96-byte + * representation; under `awslc` the length guards fail as + * `ArchiveInvalid`. */ func migrationExport() throws -> SessionMigrationExport @@ -3201,21 +3195,15 @@ open func shouldListenOn()throws -> ListenChannels { /** * Export this session as the migration payload for the twomlspq-swift - * session mint: every group half as a format-2 snapshot plus the session - * metadata `SessionMigration.mintArchive` mints a native `SessionArchive` - * from. - * - * Admits only an established session — including one with a parked - * side-band leg, an in-flight PQ round, or an owed bind, all carried so - * the round completes after migration — see the module note for the - * refused states (`SessionNotReady`; `ArchiveInvalid` for torn or - * unrecoverable state; `Mls` when a group half refuses its own export, - * e.g. a pending commit). + * session mint: every group half as a format-2 snapshot plus session + * metadata `SessionMigration.mintArchive` mints a native + * `SessionArchive` from. See the module note for the totality rule and + * what still refuses. * - * Emits PLAINTEXT SECRET material — the caller seals (the `ArchiveSink` - * contract). PQ secret material is exported in the CryptoKit 96-byte - * representation, correct only under the `cryptokit` provider build; - * under `awslc` the length guards fail the export as `ArchiveInvalid`. + * Emits plaintext secret material — the caller seals (the + * `ArchiveSink` contract). PQ secrets use the CryptoKit 96-byte + * representation; under `awslc` the length guards fail as + * `ArchiveInvalid`. */ open func migrationExport()throws -> SessionMigrationExport { return try FfiConverterTypeSessionMigrationExport_lift(try rustCallWithError(FfiConverterTypeTwoMlsPqError_lift) { @@ -5322,6 +5310,87 @@ public func FfiConverterTypeSessionMigrationCombinerKp_lower(_ value: SessionMig } +/** + * Deployed-only carry state with no confirmed native slot yet — kept + * separate so a later release can add support without another core-shape + * FFI break. `Some` whenever any part of it is non-empty or true. + */ +public struct SessionMigrationDeployedState: Equatable, Hashable { + /** + * Recv-classical's own-offer window — see `SessionMigrationOwnOfferWindow` and + * `own_offer_window`'s docs. `None` when there is no recv group, or nothing + * outstanding to carry. + */ + public var ownOffers: SessionMigrationOwnOfferWindow? + /** + * Carried rather than refused — classical messaging is unaffected by a PQ + * side-band wedge. + */ + public var pqWedged: SessionMigrationPqWedgeKind? + public var noCustody: SessionMigrationNoCustody + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Recv-classical's own-offer window — see `SessionMigrationOwnOfferWindow` and + * `own_offer_window`'s docs. `None` when there is no recv group, or nothing + * outstanding to carry. + */ownOffers: SessionMigrationOwnOfferWindow?, + /** + * Carried rather than refused — classical messaging is unaffected by a PQ + * side-band wedge. + */pqWedged: SessionMigrationPqWedgeKind?, noCustody: SessionMigrationNoCustody) { + self.ownOffers = ownOffers + self.pqWedged = pqWedged + self.noCustody = noCustody + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationDeployedState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationDeployedState: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationDeployedState { + return + try SessionMigrationDeployedState( + ownOffers: FfiConverterOptionTypeSessionMigrationOwnOfferWindow.read(from: &buf), + pqWedged: FfiConverterOptionTypeSessionMigrationPqWedgeKind.read(from: &buf), + noCustody: FfiConverterTypeSessionMigrationNoCustody.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationDeployedState, into buf: inout [UInt8]) { + FfiConverterOptionTypeSessionMigrationOwnOfferWindow.write(value.ownOffers, into: &buf) + FfiConverterOptionTypeSessionMigrationPqWedgeKind.write(value.pqWedged, into: &buf) + FfiConverterTypeSessionMigrationNoCustody.write(value.noCustody, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationDeployedState_lift(_ buf: RustBuffer) throws -> SessionMigrationDeployedState { + return try FfiConverterTypeSessionMigrationDeployedState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationDeployedState_lower(_ value: SessionMigrationDeployedState) -> RustBuffer { + return FfiConverterTypeSessionMigrationDeployedState.lower(value) +} + + /** * A digested proposal (offered / queued) in the native * `DigestedProposalArchive` shape. @@ -5463,9 +5532,8 @@ public struct SessionMigrationExport: Equatable, Hashable { public var authTheirs: SessionMigrationPartySequence public var sendGroup: SessionMigrationGroupHalf /** - * `Some` on every export the gates admit (see the module note) — the - * field stays `Option` because the native shape allows a - * pre-establishment initiator even though this export refuses one. + * `None` exactly for a pre-establishment initiator — the one state + * with no recv group at all. `Some` otherwise. */ public var recvGroup: SessionMigrationGroupHalf? public var currentStaple: Data @@ -5502,10 +5570,36 @@ public struct SessionMigrationExport: Equatable, Hashable { */ public var owesEstablishmentEnvelope: Bool /** - * `Some` exactly when the recv-PQ leaf still presents a key other than the - * identity's — a born-dedicated acceptor's uncaught-up PQ leaf. + * `Some` exactly when the recv-PQ leaf still presents a key other than + * the identity's — a born-dedicated acceptor's uncaught-up PQ leaf. Kept + * for back-compat with narrower, history-window-checked gating; + * `leaf_keys` is authoritative and never gates the export. */ public var pqLeafCustody: SessionMigrationPqLeafCustody? + /** + * Every own leaf's resolved signing custody — replaces the + * identity-equality gate and `pq_leaf_custody`'s narrow special case. + * This shape may still change before the Swift mapper consumes it. + */ + public var leafKeys: SessionMigrationLeafKeys + /** + * The newest staged candidate — `None` when there is none, or when its + * id equals `auth.mine`'s current one (a same-id candidate never + * exports here; see `recv_classical_pending`). Its key equals + * `leaf_keys.{send,recv}_classical.pending`'s entry for the same target. + */ + public var rotationCandidate: SessionMigrationRotationCandidate? + /** + * The host's app-layer welcome riding a pre-establishment initiator's + * envelope. `None` on every established session; exported only for a + * pre-join initiator, and only when non-empty. + */ + public var initialAppPayload: Data? + /** + * Deployed-only carry state (own-offer window, `pq_wedged`, per-half no-custody) — + * `Some` whenever any of it is non-empty or true. See `SessionMigrationDeployedState`. + */ + public var deployedState: SessionMigrationDeployedState? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -5516,9 +5610,8 @@ public struct SessionMigrationExport: Equatable, Hashable { * `initiate` never does. */initiated: Bool, identity: SessionMigrationIdentity, authMine: SessionMigrationPartySequence, authTheirs: SessionMigrationPartySequence, sendGroup: SessionMigrationGroupHalf, /** - * `Some` on every export the gates admit (see the module note) — the - * field stays `Option` because the native shape allows a - * pre-establishment initiator even though this export refuses one. + * `None` exactly for a pre-establishment initiator — the one state + * with no recv group at all. `Some` otherwise. */recvGroup: SessionMigrationGroupHalf?, currentStaple: Data, pendingProposal: SessionMigrationProposal?, /** * The native model retains EVERY Upd(self) staged this recv epoch; the @@ -5529,9 +5622,31 @@ public struct SessionMigrationExport: Equatable, Hashable { * `requires_establishment_envelope` under its native name. */owesEstablishmentEnvelope: Bool, /** - * `Some` exactly when the recv-PQ leaf still presents a key other than the - * identity's — a born-dedicated acceptor's uncaught-up PQ leaf. - */pqLeafCustody: SessionMigrationPqLeafCustody?) { + * `Some` exactly when the recv-PQ leaf still presents a key other than + * the identity's — a born-dedicated acceptor's uncaught-up PQ leaf. Kept + * for back-compat with narrower, history-window-checked gating; + * `leaf_keys` is authoritative and never gates the export. + */pqLeafCustody: SessionMigrationPqLeafCustody?, + /** + * Every own leaf's resolved signing custody — replaces the + * identity-equality gate and `pq_leaf_custody`'s narrow special case. + * This shape may still change before the Swift mapper consumes it. + */leafKeys: SessionMigrationLeafKeys, + /** + * The newest staged candidate — `None` when there is none, or when its + * id equals `auth.mine`'s current one (a same-id candidate never + * exports here; see `recv_classical_pending`). Its key equals + * `leaf_keys.{send,recv}_classical.pending`'s entry for the same target. + */rotationCandidate: SessionMigrationRotationCandidate?, + /** + * The host's app-layer welcome riding a pre-establishment initiator's + * envelope. `None` on every established session; exported only for a + * pre-join initiator, and only when non-empty. + */initialAppPayload: Data?, + /** + * Deployed-only carry state (own-offer window, `pq_wedged`, per-half no-custody) — + * `Some` whenever any of it is non-empty or true. See `SessionMigrationDeployedState`. + */deployedState: SessionMigrationDeployedState?) { self.stateSeq = stateSeq self.initiated = initiated self.identity = identity @@ -5565,6 +5680,10 @@ public struct SessionMigrationExport: Equatable, Hashable { self.initialTheirKp = initialTheirKp self.owesEstablishmentEnvelope = owesEstablishmentEnvelope self.pqLeafCustody = pqLeafCustody + self.leafKeys = leafKeys + self.rotationCandidate = rotationCandidate + self.initialAppPayload = initialAppPayload + self.deployedState = deployedState } @@ -5615,7 +5734,11 @@ public struct FfiConverterTypeSessionMigrationExport: FfiConverterRustBuffer { recvAttachmentLedger: FfiConverterSequenceTypeSessionMigrationEpochEntry.read(from: &buf), initialTheirKp: FfiConverterOptionTypeSessionMigrationCombinerKp.read(from: &buf), owesEstablishmentEnvelope: FfiConverterBool.read(from: &buf), - pqLeafCustody: FfiConverterOptionTypeSessionMigrationPqLeafCustody.read(from: &buf) + pqLeafCustody: FfiConverterOptionTypeSessionMigrationPqLeafCustody.read(from: &buf), + leafKeys: FfiConverterTypeSessionMigrationLeafKeys.read(from: &buf), + rotationCandidate: FfiConverterOptionTypeSessionMigrationRotationCandidate.read(from: &buf), + initialAppPayload: FfiConverterOptionData.read(from: &buf), + deployedState: FfiConverterOptionTypeSessionMigrationDeployedState.read(from: &buf) ) } @@ -5653,6 +5776,10 @@ public struct FfiConverterTypeSessionMigrationExport: FfiConverterRustBuffer { FfiConverterOptionTypeSessionMigrationCombinerKp.write(value.initialTheirKp, into: &buf) FfiConverterBool.write(value.owesEstablishmentEnvelope, into: &buf) FfiConverterOptionTypeSessionMigrationPqLeafCustody.write(value.pqLeafCustody, into: &buf) + FfiConverterTypeSessionMigrationLeafKeys.write(value.leafKeys, into: &buf) + FfiConverterOptionTypeSessionMigrationRotationCandidate.write(value.rotationCandidate, into: &buf) + FfiConverterOptionData.write(value.initialAppPayload, into: &buf) + FfiConverterOptionTypeSessionMigrationDeployedState.write(value.deployedState, into: &buf) } } @@ -5732,6 +5859,66 @@ public func FfiConverterTypeSessionMigrationGroupHalf_lower(_ value: SessionMigr } +/** + * One group's full key custody: `current` is `None` only in the no-custody + * case (nothing derives to what the leaf presents or will present) — the + * half is unsignable and the mint must never try. `pending` is deduplicated + * by target, not by signing key. + */ +public struct SessionMigrationGroupKeys: Equatable, Hashable { + public var current: SessionMigrationKeyPair? + public var pending: [SessionMigrationPendingLeafKey] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(current: SessionMigrationKeyPair?, pending: [SessionMigrationPendingLeafKey]) { + self.current = current + self.pending = pending + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationGroupKeys: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationGroupKeys: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationGroupKeys { + return + try SessionMigrationGroupKeys( + current: FfiConverterOptionTypeSessionMigrationKeyPair.read(from: &buf), + pending: FfiConverterSequenceTypeSessionMigrationPendingLeafKey.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationGroupKeys, into buf: inout [UInt8]) { + FfiConverterOptionTypeSessionMigrationKeyPair.write(value.current, into: &buf) + FfiConverterSequenceTypeSessionMigrationPendingLeafKey.write(value.pending, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationGroupKeys_lift(_ buf: RustBuffer) throws -> SessionMigrationGroupKeys { + return try FfiConverterTypeSessionMigrationGroupKeys.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationGroupKeys_lower(_ value: SessionMigrationGroupKeys) -> RustBuffer { + return FfiConverterTypeSessionMigrationGroupKeys.lower(value) +} + + /** * The session's signing identity — the Rust-side image of twomlspq-swift's * `MigratedSessionIdentity`. Byte conventions match the invitation export: @@ -5747,8 +5934,10 @@ public func FfiConverterTypeSessionMigrationGroupHalf_lower(_ value: SessionMigr * session's signing keys (the identity key packages are dormant in an * established session — every post-establishment flow keys off the group * snapshots or the session-owned bootstrap KP). `classical_init_secret_key` - * is always `None`: the mint admits one only for a pre-establishment - * initiator, which this export refuses wholesale. + * is `Some` only for a pre-establishment initiator: the mint takes an init + * secret there, and `identity_kp` on that path is the RETAINED return key + * package (minted before `createTwoMLSGroup`/`setInitialAppPayload`), not a + * fresh one. `None` on every established session. */ public struct SessionMigrationIdentity: Equatable, Hashable { public var clientId: Data @@ -5837,19 +6026,18 @@ public func FfiConverterTypeSessionMigrationIdentity_lower(_ value: SessionMigra /** - * A PQ commit awaiting its classical bind (see `SessionInner::owed_bind`). + * One own leaf's resolved custody pair: the secret behind whatever key it + * currently presents, plus that secret's derived public half (cross-check echo). */ -public struct SessionMigrationOwedBind: Equatable, Hashable { - public var pqCommit: Data - public var tEpoch: UInt64 - public var pqEpoch: UInt64 +public struct SessionMigrationKeyPair: Equatable, Hashable { + public var signingKey: Data + public var signatureKey: Data // Default memberwise initializers are never public by default, so we // declare one manually. - public init(pqCommit: Data, tEpoch: UInt64, pqEpoch: UInt64) { - self.pqCommit = pqCommit - self.tEpoch = tEpoch - self.pqEpoch = pqEpoch + public init(signingKey: Data, signatureKey: Data) { + self.signingKey = signingKey + self.signatureKey = signatureKey } @@ -5858,26 +6046,24 @@ public struct SessionMigrationOwedBind: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationOwedBind: Sendable {} +extension SessionMigrationKeyPair: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationOwedBind: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwedBind { +public struct FfiConverterTypeSessionMigrationKeyPair: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationKeyPair { return - try SessionMigrationOwedBind( - pqCommit: FfiConverterData.read(from: &buf), - tEpoch: FfiConverterUInt64.read(from: &buf), - pqEpoch: FfiConverterUInt64.read(from: &buf) + try SessionMigrationKeyPair( + signingKey: FfiConverterData.read(from: &buf), + signatureKey: FfiConverterData.read(from: &buf) ) } - public static func write(_ value: SessionMigrationOwedBind, into buf: inout [UInt8]) { - FfiConverterData.write(value.pqCommit, into: &buf) - FfiConverterUInt64.write(value.tEpoch, into: &buf) - FfiConverterUInt64.write(value.pqEpoch, into: &buf) + public static func write(_ value: SessionMigrationKeyPair, into buf: inout [UInt8]) { + FfiConverterData.write(value.signingKey, into: &buf) + FfiConverterData.write(value.signatureKey, into: &buf) } } @@ -5885,32 +6071,36 @@ public struct FfiConverterTypeSessionMigrationOwedBind: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationOwedBind_lift(_ buf: RustBuffer) throws -> SessionMigrationOwedBind { - return try FfiConverterTypeSessionMigrationOwedBind.lift(buf) +public func FfiConverterTypeSessionMigrationKeyPair_lift(_ buf: RustBuffer) throws -> SessionMigrationKeyPair { + return try FfiConverterTypeSessionMigrationKeyPair.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationOwedBind_lower(_ value: SessionMigrationOwedBind) -> RustBuffer { - return FfiConverterTypeSessionMigrationOwedBind.lower(value) +public func FfiConverterTypeSessionMigrationKeyPair_lower(_ value: SessionMigrationKeyPair) -> RustBuffer { + return FfiConverterTypeSessionMigrationKeyPair.lower(value) } /** - * One party's AS credential sequence (see `apq::authentication::PartySequence`). + * Every own leaf's resolved custody. All four groups are always present: a + * group that doesn't exist yet carries a reservation in `current` (the key + * it will present once created or joined) with `pending` empty. */ -public struct SessionMigrationPartySequence: Equatable, Hashable { - public var history: [Data] - public var authorizedNext: [Data] - public var pinned: [Data] +public struct SessionMigrationLeafKeys: Equatable, Hashable { + public var sendClassical: SessionMigrationGroupKeys + public var recvClassical: SessionMigrationGroupKeys + public var sendPq: SessionMigrationGroupKeys + public var recvPq: SessionMigrationGroupKeys // Default memberwise initializers are never public by default, so we // declare one manually. - public init(history: [Data], authorizedNext: [Data], pinned: [Data]) { - self.history = history - self.authorizedNext = authorizedNext - self.pinned = pinned + public init(sendClassical: SessionMigrationGroupKeys, recvClassical: SessionMigrationGroupKeys, sendPq: SessionMigrationGroupKeys, recvPq: SessionMigrationGroupKeys) { + self.sendClassical = sendClassical + self.recvClassical = recvClassical + self.sendPq = sendPq + self.recvPq = recvPq } @@ -5919,26 +6109,28 @@ public struct SessionMigrationPartySequence: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationPartySequence: Sendable {} +extension SessionMigrationLeafKeys: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationPartySequence: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPartySequence { +public struct FfiConverterTypeSessionMigrationLeafKeys: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationLeafKeys { return - try SessionMigrationPartySequence( - history: FfiConverterSequenceData.read(from: &buf), - authorizedNext: FfiConverterSequenceData.read(from: &buf), - pinned: FfiConverterSequenceData.read(from: &buf) + try SessionMigrationLeafKeys( + sendClassical: FfiConverterTypeSessionMigrationGroupKeys.read(from: &buf), + recvClassical: FfiConverterTypeSessionMigrationGroupKeys.read(from: &buf), + sendPq: FfiConverterTypeSessionMigrationGroupKeys.read(from: &buf), + recvPq: FfiConverterTypeSessionMigrationGroupKeys.read(from: &buf) ) } - public static func write(_ value: SessionMigrationPartySequence, into buf: inout [UInt8]) { - FfiConverterSequenceData.write(value.history, into: &buf) - FfiConverterSequenceData.write(value.authorizedNext, into: &buf) - FfiConverterSequenceData.write(value.pinned, into: &buf) + public static func write(_ value: SessionMigrationLeafKeys, into buf: inout [UInt8]) { + FfiConverterTypeSessionMigrationGroupKeys.write(value.sendClassical, into: &buf) + FfiConverterTypeSessionMigrationGroupKeys.write(value.recvClassical, into: &buf) + FfiConverterTypeSessionMigrationGroupKeys.write(value.sendPq, into: &buf) + FfiConverterTypeSessionMigrationGroupKeys.write(value.recvPq, into: &buf) } } @@ -5946,38 +6138,37 @@ public struct FfiConverterTypeSessionMigrationPartySequence: FfiConverterRustBuf #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationPartySequence_lift(_ buf: RustBuffer) throws -> SessionMigrationPartySequence { - return try FfiConverterTypeSessionMigrationPartySequence.lift(buf) +public func FfiConverterTypeSessionMigrationLeafKeys_lift(_ buf: RustBuffer) throws -> SessionMigrationLeafKeys { + return try FfiConverterTypeSessionMigrationLeafKeys.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationPartySequence_lower(_ value: SessionMigrationPartySequence) -> RustBuffer { - return FfiConverterTypeSessionMigrationPartySequence.lower(value) +public func FfiConverterTypeSessionMigrationLeafKeys_lower(_ value: SessionMigrationLeafKeys) -> RustBuffer { + return FfiConverterTypeSessionMigrationLeafKeys.lower(value) } /** - * Custody over the PQ signing key that one of this session's own PQ leaves still - * presents in place of the identity's — today, a born-dedicated acceptor's recv-PQ - * leaf, which keeps presenting the INVITATION identity's key because nothing in - * Rust ever catches it up (see the module note). Only the PQ pair: every classical - * own leaf must already present the identity's key, and mls-rs drops the old - * classical signer at that catch-up, so no classical half is ever left to custody. - * A follow-up reuses this for a lagging send-PQ leaf after a rotation. + * Per-half "no secret exists anywhere for what this leaf presents" — the + * no-custody case (see the module doc). Confirmed reachable on `recv_pq`, a + * join-time gap, not corruption. The mint must never sign with that half; + * it should surface a heal-needed condition instead. */ -public struct SessionMigrationPqLeafCustody: Equatable, Hashable { - public var clientId: Data - public var pqSigningKey: Data - public var pqSignatureKey: Data +public struct SessionMigrationNoCustody: Equatable, Hashable { + public var sendClassical: Bool + public var sendPq: Bool + public var recvClassical: Bool + public var recvPq: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(clientId: Data, pqSigningKey: Data, pqSignatureKey: Data) { - self.clientId = clientId - self.pqSigningKey = pqSigningKey - self.pqSignatureKey = pqSignatureKey + public init(sendClassical: Bool, sendPq: Bool, recvClassical: Bool, recvPq: Bool) { + self.sendClassical = sendClassical + self.sendPq = sendPq + self.recvClassical = recvClassical + self.recvPq = recvPq } @@ -5986,26 +6177,28 @@ public struct SessionMigrationPqLeafCustody: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationPqLeafCustody: Sendable {} +extension SessionMigrationNoCustody: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationPqLeafCustody: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPqLeafCustody { +public struct FfiConverterTypeSessionMigrationNoCustody: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationNoCustody { return - try SessionMigrationPqLeafCustody( - clientId: FfiConverterData.read(from: &buf), - pqSigningKey: FfiConverterData.read(from: &buf), - pqSignatureKey: FfiConverterData.read(from: &buf) + try SessionMigrationNoCustody( + sendClassical: FfiConverterBool.read(from: &buf), + sendPq: FfiConverterBool.read(from: &buf), + recvClassical: FfiConverterBool.read(from: &buf), + recvPq: FfiConverterBool.read(from: &buf) ) } - public static func write(_ value: SessionMigrationPqLeafCustody, into buf: inout [UInt8]) { - FfiConverterData.write(value.clientId, into: &buf) - FfiConverterData.write(value.pqSigningKey, into: &buf) - FfiConverterData.write(value.pqSignatureKey, into: &buf) + public static func write(_ value: SessionMigrationNoCustody, into buf: inout [UInt8]) { + FfiConverterBool.write(value.sendClassical, into: &buf) + FfiConverterBool.write(value.sendPq, into: &buf) + FfiConverterBool.write(value.recvClassical, into: &buf) + FfiConverterBool.write(value.recvPq, into: &buf) } } @@ -6013,34 +6206,32 @@ public struct FfiConverterTypeSessionMigrationPqLeafCustody: FfiConverterRustBuf #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationPqLeafCustody_lift(_ buf: RustBuffer) throws -> SessionMigrationPqLeafCustody { - return try FfiConverterTypeSessionMigrationPqLeafCustody.lift(buf) +public func FfiConverterTypeSessionMigrationNoCustody_lift(_ buf: RustBuffer) throws -> SessionMigrationNoCustody { + return try FfiConverterTypeSessionMigrationNoCustody.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationPqLeafCustody_lower(_ value: SessionMigrationPqLeafCustody) -> RustBuffer { - return FfiConverterTypeSessionMigrationPqLeafCustody.lower(value) +public func FfiConverterTypeSessionMigrationNoCustody_lower(_ value: SessionMigrationNoCustody) -> RustBuffer { + return FfiConverterTypeSessionMigrationNoCustody.lower(value) } /** - * The staged Upd(self) awaiting the peer's fold: `pending_proposal_hash` + - * `pending_proposal_message` combined into the native `PendingProposal` - * shape. + * A PQ commit awaiting its classical bind (see `SessionInner::owed_bind`). */ -public struct SessionMigrationProposal: Equatable, Hashable { - public var proposing: Data - public var message: Data - public var hash: Data +public struct SessionMigrationOwedBind: Equatable, Hashable { + public var pqCommit: Data + public var tEpoch: UInt64 + public var pqEpoch: UInt64 // Default memberwise initializers are never public by default, so we // declare one manually. - public init(proposing: Data, message: Data, hash: Data) { - self.proposing = proposing - self.message = message - self.hash = hash + public init(pqCommit: Data, tEpoch: UInt64, pqEpoch: UInt64) { + self.pqCommit = pqCommit + self.tEpoch = tEpoch + self.pqEpoch = pqEpoch } @@ -6049,26 +6240,26 @@ public struct SessionMigrationProposal: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationProposal: Sendable {} +extension SessionMigrationOwedBind: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationProposal: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationProposal { +public struct FfiConverterTypeSessionMigrationOwedBind: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwedBind { return - try SessionMigrationProposal( - proposing: FfiConverterData.read(from: &buf), - message: FfiConverterData.read(from: &buf), - hash: FfiConverterData.read(from: &buf) + try SessionMigrationOwedBind( + pqCommit: FfiConverterData.read(from: &buf), + tEpoch: FfiConverterUInt64.read(from: &buf), + pqEpoch: FfiConverterUInt64.read(from: &buf) ) } - public static func write(_ value: SessionMigrationProposal, into buf: inout [UInt8]) { - FfiConverterData.write(value.proposing, into: &buf) - FfiConverterData.write(value.message, into: &buf) - FfiConverterData.write(value.hash, into: &buf) + public static func write(_ value: SessionMigrationOwedBind, into buf: inout [UInt8]) { + FfiConverterData.write(value.pqCommit, into: &buf) + FfiConverterUInt64.write(value.tEpoch, into: &buf) + FfiConverterUInt64.write(value.pqEpoch, into: &buf) } } @@ -6076,35 +6267,60 @@ public struct FfiConverterTypeSessionMigrationProposal: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationProposal_lift(_ buf: RustBuffer) throws -> SessionMigrationProposal { - return try FfiConverterTypeSessionMigrationProposal.lift(buf) +public func FfiConverterTypeSessionMigrationOwedBind_lift(_ buf: RustBuffer) throws -> SessionMigrationOwedBind { + return try FfiConverterTypeSessionMigrationOwedBind.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationProposal_lower(_ value: SessionMigrationProposal) -> RustBuffer { - return FfiConverterTypeSessionMigrationProposal.lower(value) +public func FfiConverterTypeSessionMigrationOwedBind_lower(_ value: SessionMigrationOwedBind) -> RustBuffer { + return FfiConverterTypeSessionMigrationOwedBind.lower(value) } /** - * One cross-party PSK ledger entry. `component_id` is `u32` on the Rust side; - * the Swift mapper narrows it (checked) to the native `UInt16`. + * One own Update proposal still outstanding in recv-classical's proposal + * cache — carried so a peer's later by-reference fold of an older own offer + * still resolves after migration, since mls-rs never retains the signed + * message bytes. Epoch, group id and sender leaf index are the same for + * every offer in a window, so they're hoisted onto + * `SessionMigrationOwnOfferWindow` instead of repeated per entry. + * + * The entry a `prepare_to_encrypt` has framed is never a window member (its + * HPKE pair already rides the snapshot, and the framed copy wins at apply); + * at rest, nothing is framed, so every window entry is `Placement::Detached` + * and `leaf_secret` is always present. */ -public struct SessionMigrationPskEntry: Equatable, Hashable { - public var epoch: UInt64 - public var componentId: UInt32 - public var pskId: Data - public var psk: Data +public struct SessionMigrationOwnOffer: Equatable, Hashable { + public var proposalRef: Data + /** + * MLS-encoded `Proposal`, always an Update — non-Update entries are + * filtered out. + */ + public var proposal: Data + /** + * The proposed leaf's HPKE private key, carried alongside this entry + * rather than inside the snapshot. Always populated — a selected entry + * with no matching secret is corrupt data (`ArchiveInvalid`). + */ + public var leafSecret: Data // Default memberwise initializers are never public by default, so we // declare one manually. - public init(epoch: UInt64, componentId: UInt32, pskId: Data, psk: Data) { - self.epoch = epoch - self.componentId = componentId - self.pskId = pskId - self.psk = psk + public init(proposalRef: Data, + /** + * MLS-encoded `Proposal`, always an Update — non-Update entries are + * filtered out. + */proposal: Data, + /** + * The proposed leaf's HPKE private key, carried alongside this entry + * rather than inside the snapshot. Always populated — a selected entry + * with no matching secret is corrupt data (`ArchiveInvalid`). + */leafSecret: Data) { + self.proposalRef = proposalRef + self.proposal = proposal + self.leafSecret = leafSecret } @@ -6113,28 +6329,413 @@ public struct SessionMigrationPskEntry: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationPskEntry: Sendable {} +extension SessionMigrationOwnOffer: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationPskEntry: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPskEntry { +public struct FfiConverterTypeSessionMigrationOwnOffer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwnOffer { return - try SessionMigrationPskEntry( - epoch: FfiConverterUInt64.read(from: &buf), - componentId: FfiConverterUInt32.read(from: &buf), - pskId: FfiConverterData.read(from: &buf), - psk: FfiConverterData.read(from: &buf) + try SessionMigrationOwnOffer( + proposalRef: FfiConverterData.read(from: &buf), + proposal: FfiConverterData.read(from: &buf), + leafSecret: FfiConverterData.read(from: &buf) ) } - public static func write(_ value: SessionMigrationPskEntry, into buf: inout [UInt8]) { - FfiConverterUInt64.write(value.epoch, into: &buf) - FfiConverterUInt32.write(value.componentId, into: &buf) - FfiConverterData.write(value.pskId, into: &buf) - FfiConverterData.write(value.psk, into: &buf) + public static func write(_ value: SessionMigrationOwnOffer, into buf: inout [UInt8]) { + FfiConverterData.write(value.proposalRef, into: &buf) + FfiConverterData.write(value.proposal, into: &buf) + FfiConverterData.write(value.leafSecret, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationOwnOffer_lift(_ buf: RustBuffer) throws -> SessionMigrationOwnOffer { + return try FfiConverterTypeSessionMigrationOwnOffer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationOwnOffer_lower(_ value: SessionMigrationOwnOffer) -> RustBuffer { + return FfiConverterTypeSessionMigrationOwnOffer.lower(value) +} + + +/** + * Recv-classical's own-offer window — see `own_offer_window`'s doc for + * selection, banding and the cap. `offers` starts at band 2 + * (presentation-changing), then band 3 (refreshes, deterministic + * `proposal_ref` order); past the cap, which refreshes drop is arbitrary. + */ +public struct SessionMigrationOwnOfferWindow: Equatable, Hashable { + public var epoch: UInt64 + public var groupId: Data + public var senderLeafIndex: UInt32 + public var offers: [SessionMigrationOwnOffer] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(epoch: UInt64, groupId: Data, senderLeafIndex: UInt32, offers: [SessionMigrationOwnOffer]) { + self.epoch = epoch + self.groupId = groupId + self.senderLeafIndex = senderLeafIndex + self.offers = offers + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationOwnOfferWindow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationOwnOfferWindow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwnOfferWindow { + return + try SessionMigrationOwnOfferWindow( + epoch: FfiConverterUInt64.read(from: &buf), + groupId: FfiConverterData.read(from: &buf), + senderLeafIndex: FfiConverterUInt32.read(from: &buf), + offers: FfiConverterSequenceTypeSessionMigrationOwnOffer.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationOwnOfferWindow, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.epoch, into: &buf) + FfiConverterData.write(value.groupId, into: &buf) + FfiConverterUInt32.write(value.senderLeafIndex, into: &buf) + FfiConverterSequenceTypeSessionMigrationOwnOffer.write(value.offers, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationOwnOfferWindow_lift(_ buf: RustBuffer) throws -> SessionMigrationOwnOfferWindow { + return try FfiConverterTypeSessionMigrationOwnOfferWindow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationOwnOfferWindow_lower(_ value: SessionMigrationOwnOfferWindow) -> RustBuffer { + return FfiConverterTypeSessionMigrationOwnOfferWindow.lower(value) +} + + +/** + * One party's AS credential sequence (see `apq::authentication::PartySequence`). + */ +public struct SessionMigrationPartySequence: Equatable, Hashable { + public var history: [Data] + public var authorizedNext: [Data] + public var pinned: [Data] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(history: [Data], authorizedNext: [Data], pinned: [Data]) { + self.history = history + self.authorizedNext = authorizedNext + self.pinned = pinned + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationPartySequence: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPartySequence: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPartySequence { + return + try SessionMigrationPartySequence( + history: FfiConverterSequenceData.read(from: &buf), + authorizedNext: FfiConverterSequenceData.read(from: &buf), + pinned: FfiConverterSequenceData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationPartySequence, into buf: inout [UInt8]) { + FfiConverterSequenceData.write(value.history, into: &buf) + FfiConverterSequenceData.write(value.authorizedNext, into: &buf) + FfiConverterSequenceData.write(value.pinned, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPartySequence_lift(_ buf: RustBuffer) throws -> SessionMigrationPartySequence { + return try FfiConverterTypeSessionMigrationPartySequence.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPartySequence_lower(_ value: SessionMigrationPartySequence) -> RustBuffer { + return FfiConverterTypeSessionMigrationPartySequence.lower(value) +} + + +/** + * One identity a half may still commit to once a peer's commit lands: a + * pending self-Update, a staged rotation candidate, or the generalized + * catch-up entry (`target` = `auth.mine`'s current id). + */ +public struct SessionMigrationPendingLeafKey: Equatable, Hashable { + /** + * Non-empty; unique within its group's `pending`. + */ + public var target: Data + public var key: SessionMigrationKeyPair + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Non-empty; unique within its group's `pending`. + */target: Data, key: SessionMigrationKeyPair) { + self.target = target + self.key = key + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationPendingLeafKey: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPendingLeafKey: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPendingLeafKey { + return + try SessionMigrationPendingLeafKey( + target: FfiConverterData.read(from: &buf), + key: FfiConverterTypeSessionMigrationKeyPair.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationPendingLeafKey, into buf: inout [UInt8]) { + FfiConverterData.write(value.target, into: &buf) + FfiConverterTypeSessionMigrationKeyPair.write(value.key, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPendingLeafKey_lift(_ buf: RustBuffer) throws -> SessionMigrationPendingLeafKey { + return try FfiConverterTypeSessionMigrationPendingLeafKey.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPendingLeafKey_lower(_ value: SessionMigrationPendingLeafKey) -> RustBuffer { + return FfiConverterTypeSessionMigrationPendingLeafKey.lower(value) +} + + +/** + * Custody over the PQ signing key an own PQ leaf still presents in place of + * the identity's — a born-dedicated acceptor's uncaught-up recv-PQ leaf. PQ + * only: mls-rs drops the old classical signer at catch-up, so no classical + * half is ever left to custody. + */ +public struct SessionMigrationPqLeafCustody: Equatable, Hashable { + public var clientId: Data + public var pqSigningKey: Data + public var pqSignatureKey: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(clientId: Data, pqSigningKey: Data, pqSignatureKey: Data) { + self.clientId = clientId + self.pqSigningKey = pqSigningKey + self.pqSignatureKey = pqSignatureKey + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationPqLeafCustody: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPqLeafCustody: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPqLeafCustody { + return + try SessionMigrationPqLeafCustody( + clientId: FfiConverterData.read(from: &buf), + pqSigningKey: FfiConverterData.read(from: &buf), + pqSignatureKey: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationPqLeafCustody, into buf: inout [UInt8]) { + FfiConverterData.write(value.clientId, into: &buf) + FfiConverterData.write(value.pqSigningKey, into: &buf) + FfiConverterData.write(value.pqSignatureKey, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqLeafCustody_lift(_ buf: RustBuffer) throws -> SessionMigrationPqLeafCustody { + return try FfiConverterTypeSessionMigrationPqLeafCustody.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqLeafCustody_lower(_ value: SessionMigrationPqLeafCustody) -> RustBuffer { + return FfiConverterTypeSessionMigrationPqLeafCustody.lower(value) +} + + +/** + * The staged Upd(self) awaiting the peer's fold: `pending_proposal_hash` + + * `pending_proposal_message` combined into the native `PendingProposal` + * shape. + */ +public struct SessionMigrationProposal: Equatable, Hashable { + public var proposing: Data + public var message: Data + public var hash: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(proposing: Data, message: Data, hash: Data) { + self.proposing = proposing + self.message = message + self.hash = hash + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationProposal: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationProposal: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationProposal { + return + try SessionMigrationProposal( + proposing: FfiConverterData.read(from: &buf), + message: FfiConverterData.read(from: &buf), + hash: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationProposal, into buf: inout [UInt8]) { + FfiConverterData.write(value.proposing, into: &buf) + FfiConverterData.write(value.message, into: &buf) + FfiConverterData.write(value.hash, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationProposal_lift(_ buf: RustBuffer) throws -> SessionMigrationProposal { + return try FfiConverterTypeSessionMigrationProposal.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationProposal_lower(_ value: SessionMigrationProposal) -> RustBuffer { + return FfiConverterTypeSessionMigrationProposal.lower(value) +} + + +/** + * One cross-party PSK ledger entry. `component_id` is `u32` on the Rust side; + * the Swift mapper narrows it (checked) to the native `UInt16`. + */ +public struct SessionMigrationPskEntry: Equatable, Hashable { + public var epoch: UInt64 + public var componentId: UInt32 + public var pskId: Data + public var psk: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(epoch: UInt64, componentId: UInt32, pskId: Data, psk: Data) { + self.epoch = epoch + self.componentId = componentId + self.pskId = pskId + self.psk = psk + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationPskEntry: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPskEntry: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPskEntry { + return + try SessionMigrationPskEntry( + epoch: FfiConverterUInt64.read(from: &buf), + componentId: FfiConverterUInt32.read(from: &buf), + pskId: FfiConverterData.read(from: &buf), + psk: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationPskEntry, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.epoch, into: &buf) + FfiConverterUInt32.write(value.componentId, into: &buf) + FfiConverterData.write(value.pskId, into: &buf) + FfiConverterData.write(value.psk, into: &buf) } } @@ -6154,6 +6755,75 @@ public func FfiConverterTypeSessionMigrationPskEntry_lower(_ value: SessionMigra } +/** + * The most recently staged rotation candidate, classical only. Its + * `signing_key`/`signature_key` must equal the same candidate's `pending` + * entry in both classical sets. `None` when the newest candidate's id + * equals `auth.mine`'s current one — a same-id candidate is a self-catch-up + * mechanism, not a rotation target (see `recv_classical_pending`). + */ +public struct SessionMigrationRotationCandidate: Equatable, Hashable { + public var targetClientId: Data + public var signingKey: Data + public var signatureKey: Data + public var proposedAtRecvEpoch: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(targetClientId: Data, signingKey: Data, signatureKey: Data, proposedAtRecvEpoch: UInt64) { + self.targetClientId = targetClientId + self.signingKey = signingKey + self.signatureKey = signatureKey + self.proposedAtRecvEpoch = proposedAtRecvEpoch + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationRotationCandidate: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationRotationCandidate: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationRotationCandidate { + return + try SessionMigrationRotationCandidate( + targetClientId: FfiConverterData.read(from: &buf), + signingKey: FfiConverterData.read(from: &buf), + signatureKey: FfiConverterData.read(from: &buf), + proposedAtRecvEpoch: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationRotationCandidate, into buf: inout [UInt8]) { + FfiConverterData.write(value.targetClientId, into: &buf) + FfiConverterData.write(value.signingKey, into: &buf) + FfiConverterData.write(value.signatureKey, into: &buf) + FfiConverterUInt64.write(value.proposedAtRecvEpoch, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationRotationCandidate_lift(_ buf: RustBuffer) throws -> SessionMigrationRotationCandidate { + return try FfiConverterTypeSessionMigrationRotationCandidate.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationRotationCandidate_lower(_ value: SessionMigrationRotationCandidate) -> RustBuffer { + return FfiConverterTypeSessionMigrationRotationCandidate.lower(value) +} + + /** * One `stagedUpdates` entry: the digest + message of a staged Upd(self). */ @@ -6779,6 +7449,84 @@ public func FfiConverterTypeSessionMigrationPqInflight_lower(_ value: SessionMig } +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * `deployed_state.pq_wedged`'s kind, mapping 1:1 from `PqWedge` (see that type's doc for + * the recovery/diagnosis split). + */ + +public enum SessionMigrationPqWedgeKind: Equatable, Hashable { + + case bootstrap + case ratchet + case rekey + + + + + +} + +#if compiler(>=6) +extension SessionMigrationPqWedgeKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationPqWedgeKind: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationPqWedgeKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationPqWedgeKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .bootstrap + + case 2: return .ratchet + + case 3: return .rekey + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: SessionMigrationPqWedgeKind, into buf: inout [UInt8]) { + switch value { + + + case .bootstrap: + writeInt(&buf, Int32(1)) + + + case .ratchet: + writeInt(&buf, Int32(2)) + + + case .rekey: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqWedgeKind_lift(_ buf: RustBuffer) throws -> SessionMigrationPqWedgeKind { + return try FfiConverterTypeSessionMigrationPqWedgeKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPqWedgeKind_lower(_ value: SessionMigrationPqWedgeKind) -> RustBuffer { + return FfiConverterTypeSessionMigrationPqWedgeKind.lower(value) +} + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. /** @@ -7718,6 +8466,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationCombinerKp: FfiConverte } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationDeployedState: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationDeployedState? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationDeployedState.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationDeployedState.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7766,6 +8538,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationGroupHalf: FfiConverter } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationKeyPair: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationKeyPair? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationKeyPair.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationKeyPair.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7790,6 +8586,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationOwedBind: FfiConverterR } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationOwnOfferWindow: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationOwnOfferWindow? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationOwnOfferWindow.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationOwnOfferWindow.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7838,6 +8658,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationProposal: FfiConverterR } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationRotationCandidate: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationRotationCandidate? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationRotationCandidate.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationRotationCandidate.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7886,6 +8730,30 @@ fileprivate struct FfiConverterOptionTypeSessionMigrationPqInflight: FfiConverte } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeSessionMigrationPqWedgeKind: FfiConverterRustBuffer { + typealias SwiftType = SessionMigrationPqWedgeKind? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSessionMigrationPqWedgeKind.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSessionMigrationPqWedgeKind.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7986,6 +8854,56 @@ fileprivate struct FfiConverterSequenceTypeSessionMigrationEpochEntry: FfiConver } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeSessionMigrationOwnOffer: FfiConverterRustBuffer { + typealias SwiftType = [SessionMigrationOwnOffer] + + public static func write(_ value: [SessionMigrationOwnOffer], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeSessionMigrationOwnOffer.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [SessionMigrationOwnOffer] { + let len: Int32 = try readInt(&buf) + var seq = [SessionMigrationOwnOffer]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeSessionMigrationOwnOffer.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeSessionMigrationPendingLeafKey: FfiConverterRustBuffer { + typealias SwiftType = [SessionMigrationPendingLeafKey] + + public static func write(_ value: [SessionMigrationPendingLeafKey], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeSessionMigrationPendingLeafKey.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [SessionMigrationPendingLeafKey] { + let len: Int32 = try readInt(&buf) + var seq = [SessionMigrationPendingLeafKey]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeSessionMigrationPendingLeafKey.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -8363,7 +9281,7 @@ private let initializationResult: InitializationResult = { if (uniffi_two_mls_pq_checksum_method_twomlspqsession_should_listen_on() != 34726) { return InitializationResult.apiChecksumMismatch } - if (uniffi_two_mls_pq_checksum_method_twomlspqsession_migration_export() != 59429) { + if (uniffi_two_mls_pq_checksum_method_twomlspqsession_migration_export() != 39391) { return InitializationResult.apiChecksumMismatch } if (uniffi_two_mls_pq_checksum_method_twomlspqsession_my_pq_turn() != 12380) { diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 14831df..2f2be3e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,18 +39,18 @@ mls-rs = { version = "0.55", features = [ "safe_extensions", "swift_export", ] } -mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } -mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4", features = [ +mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } +mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597", features = [ "post-quantum", ] } -mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4", features = [ +mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597", features = [ "post-quantum", ] } -mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } +mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } sha2 = "0.10" thiserror = "1" uniffi = "0.31" zeroize = { version = "1", features = ["derive"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml index a7d0faf..ab53c8f 100644 --- a/rust/fuzz/Cargo.toml +++ b/rust/fuzz/Cargo.toml @@ -20,7 +20,7 @@ libfuzzer-sys = "0.4" two-mls-pq = { path = "../two-mls-pq", default-features = false, features = ["awslc"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "fcd822c5e0018cb8cf8d3e9e8289d560d0c8a9f4" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } [[bin]] bench = false diff --git a/rust/two-mls-pq/Cargo.toml b/rust/two-mls-pq/Cargo.toml index ed09784..696401d 100644 --- a/rust/two-mls-pq/Cargo.toml +++ b/rust/two-mls-pq/Cargo.toml @@ -56,6 +56,10 @@ criterion = { version = "0.8", default-features = false, features = [ "html_reports", "plotters", ] } +# Test-only: parses the swift-export CBOR snapshot directly, the same technique the +# mls-rs fork's own `export_for_swift_placing_pending` tests use, to verify recv-classical's +# snapshot carries only the placement's Snapshot-placed pending_updates keys. +ciborium = "0.2" [[bench]] harness = false diff --git a/rust/two-mls-pq/src/lib.rs b/rust/two-mls-pq/src/lib.rs index eeca943..e640cdb 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -500,15 +500,14 @@ pub fn version() -> String { // through v0.15.0 (archive layout 3)) still restores here, pinned by the // `TwoMLSPQMigrateTests` legacy-row fixtures. // -// v36: the session migration export now admits a born-dedicated acceptor once its -// establishment envelope has installed and its recv-classical leaf has caught up, and an -// acceptor still holding a parked return welcome (dropped from the export rather than -// refused). One new Record, `SessionMigrationPqLeafCustody`, and one field appended to -// `SessionMigrationExport` — a stale binding/binary pair would mis-read the export buffer. -// No wire, archive, or error-variant change. The mls-rs pin moves to the fork's -// `germ-integration` head (fcd822c), adding a read-only accessor for a group's current -// signing secret (gated behind the same `swift_export` feature as v35's pin) — no -// serialized struct changed. +// v36: the session migration export admits every reachable deployed session (pre- +// establishment, born-dedicated, staged rotation, lagging or wedged PQ rounds) instead +// of refusing unsettled state, erroring only on corrupt or impossible data. New Records +// carry per-group leaf keys, the rotation candidate, the own-offer window, and +// deployed-engine flags. Fields are appended to `SessionMigrationExport`, so a stale +// binding/binary pair would mis-read the export buffer; no wire, archive, or +// error-variant change. The mls-rs pin moves to the fork's integration head for the +// pending-signer, own-proposal, and pending-placement exports this needs. const BINDING_CONTRACT_VERSION: u64 = 36; /// See `BINDING_CONTRACT_VERSION`. Exported so the Swift layer can verify the diff --git a/rust/two-mls-pq/src/providers.rs b/rust/two-mls-pq/src/providers.rs index 78c0262..de3128a 100644 --- a/rust/two-mls-pq/src/providers.rs +++ b/rust/two-mls-pq/src/providers.rs @@ -103,6 +103,20 @@ pub(crate) fn pq_envelope_suite( .ok_or(TwoMlsPqError::Mls) } +/// The suite provider backing classical-half signature-key derivation for the migration +/// export's per-half custody search — the `classical` facet of the +/// declared suite, served by the pinned CLASSICAL provider. Mirrors `pq_envelope_suite` +/// for the other family: both exist because "the EXPECTED half's provider" means a +/// DIFFERENT provider object per half, even though both ultimately run Ed25519 math. +pub(crate) fn classical_envelope_suite( +) -> Result> +{ + use mls_rs::CryptoProvider; + classical() + .cipher_suite_provider(APQ_SUITE.classical) + .ok_or(TwoMlsPqError::Mls) +} + /// The suite provider backing the header-encryption AEAD — the `header_aead` facet of /// the declared suite (`TwoMlsSuite::CURRENT.header_aead()`, the classical half). /// **Only the suite's AEAD and CSPRNG are used** diff --git a/rust/two-mls-pq/src/session/migration.rs b/rust/two-mls-pq/src/session/migration.rs index 5b00957..eb25187 100644 --- a/rust/two-mls-pq/src/session/migration.rs +++ b/rust/two-mls-pq/src/session/migration.rs @@ -2,80 +2,149 @@ //! session-level analogue of `TwoMlsPqInvitation::migration_export`. Emits //! everything twomlspq-swift's `SessionMigration.mintArchive` needs to mint a //! native `SessionArchive`: each group half as a swift-mls format-2 snapshot -//! (mls-rs's `Group::export_for_swift`, slice A) plus the combiner/session -//! metadata as flat records. +//! (mls-rs's `Group::export_for_swift_with_pending_signers`, slice A) plus the +//! combiner/session metadata as flat records. //! -//! Admits an established session — an established initiator or acceptor need -//! not be quiescent first: a parked side-band leg, an in-flight PQ round -//! (`pq_inflight`), and an owed classical bind (`owed_bind`) are all carried -//! in the export rather than required to settle before migrating, so the -//! round in progress completes normally on whichever engine finishes it: +//! Admits every reachable deployed session: an established initiator or +//! acceptor need not be quiescent first, and no reachable state is refused +//! merely for not having converged. A parked side-band leg, an in-flight +//! (even mis-signed) PQ round (`pq_inflight`), an owed classical bind +//! (`owed_bind`), a wedged PQ side-band, mid-rotation state (up to +//! `CANDIDATE_WINDOW` staged candidates), and a lagging own leaf on any half +//! are all carried rather than required to settle first: //! -//! * one still holding a parked return welcome (an acceptor's -//! `pending_outbound`) — the export drops that parked copy rather than -//! refusing it. The same welcome rides `current_staple` until the -//! acceptor's own first send-group commit, and the native session -//! re-staples `currentStaple` the same way, so nothing is stranded; -//! * a born-dedicated acceptor, once its establishment envelope has -//! installed AND its recv-classical leaf has caught up to the dedicated -//! identity. Its recv-PQ leaf, which nothing in Rust ever catches up, -//! exports as `pq_leaf_custody` instead of refusing (see the custody gate -//! below). +//! * a parked return welcome (acceptor's `pending_outbound`, or an +//! initiator's parked §A.1 envelope): the export drops the parked copy +//! rather than refusing it. It still rides `current_staple` until this +//! party's first send-group commit, matching how the native session +//! re-staples `currentStaple`, so nothing is stranded; +//! * a pre-establishment initiator (`recv_group: None`): carries +//! `initial_their_kp`, `initial_app_payload`, the retained return KP's +//! init secret, and the pre-committed A.3 bootstrap KP secret/commitment. +//! `leaf_keys.recv_classical`/`recv_pq` carry reservations, not live +//! custody; +//! * a born-dedicated acceptor at any point from receive through +//! convergence: `owes_establishment_envelope` reports whether the signed +//! delegation has landed, and `leaf_keys` resolves custody for whichever +//! identity each leaf currently presents (invitation pre-convergence, +//! dedicated after); +//! * every own leaf's signing key is resolved by a key-custody search, not +//! an identity-equality check: the leaf's presented key is derive-matched +//! against a candidate pool built from the identity's own keys, every +//! half's current signer (`signer_for_swift_export`), staged rotation +//! candidates, and pending self-Update signers. This is what lets a +//! post-rotation leaf-lag or mid-rotation desync still export — some pool +//! candidate derives to whatever is presented. Emitted as +//! `leaf_keys..current`; +//! * `leaf_keys` requires all four groups (`send_classical`, +//! `recv_classical`, `send_pq`, `recv_pq`), never `None`: a group that +//! doesn't exist yet carries a reservation in `current` (the key it will +//! present once created or joined) with empty `pending` — see +//! `reservation`; +//! * generalized catch-up: any own leaf whose presented credential lags +//! `auth.mine`'s current one gets a synthesized `pending[mine.current]` +//! entry carrying the identity's current key of that half's kind — see +//! `catch_up_pending_entry` and `fold_in_catch_up`; +//! * every staged rotation candidate rides `pending[candidate id]` in both +//! `send_classical` and `recv_classical`, keyed by the same classical key +//! — see `send_classical_pending`/`recv_classical_pending`. A candidate +//! whose id equals `auth.mine`'s current one can leave two +//! differently-keyed offers outstanding for that one target (the +//! identity's own self-catch-up, and the same-id candidate's +//! re-proposal), and only one key can ride `pending[mine.current]`. +//! `recv_classical_pending` resolves it in priority order: (a) an entry a +//! `prepare_to_encrypt` has already framed, if it targets `mine.current` +//! — a framed entry can never be dropped; (b) else the identity's key, if +//! a real identity-signed offer is outstanding; (c) else the same-id +//! candidate's own (pinned, not scanned) key; (d) else the synthesized +//! catch-up, if the leaf still lags. `own_offer_window` applies the same +//! priority to its own window so the two never disagree. `pending` +//! entries are deduplicated by target, not by signing key, since these +//! two mechanisms can otherwise leave two different keys claiming one +//! target; +//! * the no-custody case: a leaf whose secret exists nowhere in the +//! candidate pool is carried as unsignable +//! (`deployed_state.no_custody.`) rather than erred. Confirmed +//! reachable on `recv_pq` (see +//! `test_migration_export_carries_no_custody_from_an_unchecked_join_signer`): +//! send-PQ's signer at `initiate` time gets bound into recv-PQ at the A.3 +//! join, then a later A.5 own-leaf catch-up rotates send-PQ on, leaving +//! recv-PQ presenting a key nothing in the pool can derive. Decrypting +//! needs only the shared secret, not the signer, so this stays +//! receive-only unless send-PQ independently lags again and a later +//! catch-up tries to sign with the missing key. Native must never sign +//! with such a leaf; it should surface a heal-needed condition instead; +//! * recv-classical's own-offer window: outstanding own Update proposals a +//! peer might still fold by reference, carried up to `OWN_OFFER_WINDOW` +//! in `deployed_state.own_offers` (`None` when there is no recv group or +//! nothing outstanding) — see that constant's and `own_offer_window`'s +//! docs for the cap and the accepted residual risk past it. Epoch, group +//! id and sender leaf index are hoisted onto the window; order is +//! meaningful — selection order, presentation-changing offers before +//! refreshes, deterministic `proposal_ref` order otherwise. An entry is +//! excluded from the window only while a `prepare_to_encrypt` has +//! already framed it (its HPKE pair rides the snapshot via +//! `Placement::Snapshot` instead, and the framed copy wins at apply). At +//! rest, recency is unrecoverable, so every outstanding own offer is a +//! window candidate with a `Placement::Detached`, always-populated +//! `leaf_secret`; +//! * a migrated stale `pending_proposal` (see `own_offer_window`'s doc on +//! how one goes stale) blocks the PQ side-band from auto-opening a new +//! round until the next send — this matches native, which never +//! proactively clears a stale pending proposal on an epoch advance +//! either; //! -//! Refuses (rather than mis-maps) the states the native archive cannot -//! represent: +//! `leaf_keys`, `initial_app_payload`, and `deployed_state` may still change +//! shape before the Swift mapper consumes them. The legacy `pq_leaf_custody` +//! field stays populated with its original, narrower (history-window-checked, +//! born-dedicated-only) gating for back-compat, but its own failure never +//! blocks the export; only `leaf_keys` is authoritative. //! -//! * pre-establishment initiator (no recv group yet): the parked §A.1 -//! envelope and app payload have no native slots, so the migrated session -//! could never complete establishment; -//! * a born-dedicated acceptor pre-install, or installed but pre-convergence: -//! until the peer folds the classical catch-up Upd, the recv-classical -//! leaf still presents the invitation identity's key, and mls-rs replaces -//! that leaf's only signer exactly when the catch-up commit lands — before -//! then there is nothing native could sign that leaf with; -//! * mid-rotation (`staged_candidates` / `deferred_candidate`): the Rust -//! model holds up to `CANDIDATE_WINDOW` full successor identities where the -//! native one holds a single rotation candidate; -//! * post-rotation leaf-lag: every group half's own leaf must present the -//! identity's per-half signing key, or (recv-PQ only) resolve through the -//! custody gate — anything else refuses. Applies to a born-dedicated -//! session too, for every half EXCEPT its already-custodied recv-PQ leaf -//! (e.g. a lagging send-PQ leaf after a rotation, which has no custody -//! slot of its own yet); -//! * a wedged or bind-broken side-band: the native archive carries no wedge -//! verdict, so the migrated session would report healthy and deadlock. +//! What still refuses is corrupt or genuinely impossible data, never a +//! reachable one: //! -//! These refusals return `SessionNotReady` — retryable in principle, once the -//! session advances past the blocking state. One retry ceiling: custody also -//! requires the leaf's id still be within the `auth.mine` history window (8 -//! entries) — past enough further rotations without ever migrating, that -//! stops being true and the refusal becomes permanent. Rare in practice, but -//! not retryable when it happens. `ArchiveInvalid` means something else -//! entirely: the stored state is corrupt and retrying will not help — e.g. a -//! stored group whose cipher suite no longer matches the session's expected -//! suite (checked before any half's signer touches a provider, since deriving -//! a key through the wrong suite's provider is unrecoverable rather than a -//! clean failure), or a custodied PQ signer that doesn't derive to its -//! presented key. +//! * a stored group whose cipher suite disagrees with the session's own +//! declared suite (checked before any signer touches a provider, since +//! deriving through the wrong suite is unrecoverable, not a clean +//! failure) — `ArchiveInvalid`; +//! * `bind_apply_broken` (a torn receive path): in-memory only, since a +//! session restored from its persisted row is built by the converter +//! from those rows alone, which never recorded this flag. Still +//! reachable on the current process, and the native archive carries no +//! torn-receive verdict — `Mls` (retryable: reload from the persisted +//! row and export that instead); +//! * `initial_return_kp` set on a pre-establishment initiator: never +//! reachable from this product's own wrapper (only `setInitialAppPayload` +//! is ever called), so a live value is corrupt, not merely unusual — +//! `ArchiveInvalid`; +//! * a torn `pending_proposal` (a hash with no message, or vice versa), an +//! unrecoverable PQ round secret representation, or any other +//! structurally impossible combination this module already guards — +//! `ArchiveInvalid`. //! -//! A pending mls-rs commit or a signer-rotating pending self-Update fails -//! inside `export_for_swift` itself. +//! A pending mls-rs commit still fails inside +//! `export_for_swift_with_pending_signers` itself, surfaced as `Mls` — the +//! one case a group half refuses its own export outright (an in-flight local +//! commit has no meaningful snapshot to take). Key-package generation failure +//! is likewise `Mls`: environmental and retryable, never `ArchiveInvalid`. //! -//! Emits PLAINTEXT SECRET material (group snapshots, signing keys, HPKE +//! Emits plaintext secret material (group snapshots, signing keys, HPKE //! secrets, any mid-round KEM material) — the caller seals; this inherits the //! `ArchiveSink` contract, exactly as the invitation export does. No `Debug` //! on any record: a derived impl would print plaintext key material. +use std::sync::Arc; + use mls_rs::mls_rs_codec::{MlsDecode, MlsEncode}; use mls_rs::{CipherSuiteProvider, MlsMessage}; use zeroize::Zeroizing; -use crate::key_package_store::{CombinerGroup, KeyPackageSecret, SyntheticKeyPackageStore}; +use crate::key_package_store::{KeyPackageSecret, SyntheticKeyPackageStore}; use crate::{Result, TwoMlsPqError}; use super::frames::PQ_REKEY_UPD_TAG; -use super::pq_ops::PqInflight; -use super::{SessionInner, TwoMlsPqSession}; +use super::pq_ops::{PqInflight, PqWedge}; +use super::{SessionInner, TwoMlsPqPrincipal, TwoMlsPqSession}; /// One Combiner group half-pair: each present half's swift-mls format-2 /// snapshot bytes (`Group::export_for_swift` output — plaintext secret @@ -101,8 +170,10 @@ pub struct SessionMigrationGroupHalf { /// session's signing keys (the identity key packages are dormant in an /// established session — every post-establishment flow keys off the group /// snapshots or the session-owned bootstrap KP). `classical_init_secret_key` -/// is always `None`: the mint admits one only for a pre-establishment -/// initiator, which this export refuses wholesale. +/// is `Some` only for a pre-establishment initiator: the mint takes an init +/// secret there, and `identity_kp` on that path is the RETAINED return key +/// package (minted before `createTwoMLSGroup`/`setInitialAppPayload`), not a +/// fresh one. `None` on every established session. #[derive(Clone, uniffi::Record)] pub struct SessionMigrationIdentity { pub client_id: Vec, @@ -195,13 +266,10 @@ pub struct SessionMigrationBootstrapKp { pub key_package: Vec, } -/// Custody over the PQ signing key that one of this session's own PQ leaves still -/// presents in place of the identity's — today, a born-dedicated acceptor's recv-PQ -/// leaf, which keeps presenting the INVITATION identity's key because nothing in -/// Rust ever catches it up (see the module note). Only the PQ pair: every classical -/// own leaf must already present the identity's key, and mls-rs drops the old -/// classical signer at that catch-up, so no classical half is ever left to custody. -/// A follow-up reuses this for a lagging send-PQ leaf after a rotation. +/// Custody over the PQ signing key an own PQ leaf still presents in place of +/// the identity's — a born-dedicated acceptor's uncaught-up recv-PQ leaf. PQ +/// only: mls-rs drops the old classical signer at catch-up, so no classical +/// half is ever left to custody. #[derive(Clone, uniffi::Record)] pub struct SessionMigrationPqLeafCustody { pub client_id: Vec, @@ -209,6 +277,129 @@ pub struct SessionMigrationPqLeafCustody { pub pq_signature_key: Vec, } +/// One own leaf's resolved custody pair: the secret behind whatever key it +/// currently presents, plus that secret's derived public half (cross-check echo). +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationKeyPair { + pub signing_key: Vec, + pub signature_key: Vec, +} + +/// One identity a half may still commit to once a peer's commit lands: a +/// pending self-Update, a staged rotation candidate, or the generalized +/// catch-up entry (`target` = `auth.mine`'s current id). +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationPendingLeafKey { + /// Non-empty; unique within its group's `pending`. + pub target: Vec, + pub key: SessionMigrationKeyPair, +} + +/// One group's full key custody: `current` is `None` only in the no-custody +/// case (nothing derives to what the leaf presents or will present) — the +/// half is unsignable and the mint must never try. `pending` is deduplicated +/// by target, not by signing key. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationGroupKeys { + pub current: Option, + pub pending: Vec, +} + +/// Every own leaf's resolved custody. All four groups are always present: a +/// group that doesn't exist yet carries a reservation in `current` (the key +/// it will present once created or joined) with `pending` empty. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationLeafKeys { + pub send_classical: SessionMigrationGroupKeys, + pub recv_classical: SessionMigrationGroupKeys, + pub send_pq: SessionMigrationGroupKeys, + pub recv_pq: SessionMigrationGroupKeys, +} + +/// The most recently staged rotation candidate, classical only. Its +/// `signing_key`/`signature_key` must equal the same candidate's `pending` +/// entry in both classical sets. `None` when the newest candidate's id +/// equals `auth.mine`'s current one — a same-id candidate is a self-catch-up +/// mechanism, not a rotation target (see `recv_classical_pending`). +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationRotationCandidate { + pub target_client_id: Vec, + pub signing_key: Vec, + pub signature_key: Vec, + pub proposed_at_recv_epoch: u64, +} + +/// One own Update proposal still outstanding in recv-classical's proposal +/// cache — carried so a peer's later by-reference fold of an older own offer +/// still resolves after migration, since mls-rs never retains the signed +/// message bytes. Epoch, group id and sender leaf index are the same for +/// every offer in a window, so they're hoisted onto +/// `SessionMigrationOwnOfferWindow` instead of repeated per entry. +/// +/// The entry a `prepare_to_encrypt` has framed is never a window member (its +/// HPKE pair already rides the snapshot, and the framed copy wins at apply); +/// at rest, nothing is framed, so every window entry is `Placement::Detached` +/// and `leaf_secret` is always present. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationOwnOffer { + pub proposal_ref: Vec, + /// MLS-encoded `Proposal`, always an Update — non-Update entries are + /// filtered out. + pub proposal: Vec, + /// The proposed leaf's HPKE private key, carried alongside this entry + /// rather than inside the snapshot. Always populated — a selected entry + /// with no matching secret is corrupt data (`ArchiveInvalid`). + pub leaf_secret: Vec, +} + +/// Recv-classical's own-offer window — see `own_offer_window`'s doc for +/// selection, banding and the cap. `offers` starts at band 2 +/// (presentation-changing), then band 3 (refreshes, deterministic +/// `proposal_ref` order); past the cap, which refreshes drop is arbitrary. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationOwnOfferWindow { + pub epoch: u64, + pub group_id: Vec, + pub sender_leaf_index: u32, + pub offers: Vec, +} + +/// Per-half "no secret exists anywhere for what this leaf presents" — the +/// no-custody case (see the module doc). Confirmed reachable on `recv_pq`, a +/// join-time gap, not corruption. The mint must never sign with that half; +/// it should surface a heal-needed condition instead. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationNoCustody { + pub send_classical: bool, + pub send_pq: bool, + pub recv_classical: bool, + pub recv_pq: bool, +} + +/// `deployed_state.pq_wedged`'s kind, mapping 1:1 from `PqWedge` (see that type's doc for +/// the recovery/diagnosis split). +#[derive(Clone, uniffi::Enum)] +pub enum SessionMigrationPqWedgeKind { + Bootstrap, + Ratchet, + Rekey, +} + +/// Deployed-only carry state with no confirmed native slot yet — kept +/// separate so a later release can add support without another core-shape +/// FFI break. `Some` whenever any part of it is non-empty or true. +#[derive(Clone, uniffi::Record)] +pub struct SessionMigrationDeployedState { + /// Recv-classical's own-offer window — see `SessionMigrationOwnOfferWindow` and + /// `own_offer_window`'s docs. `None` when there is no recv group, or nothing + /// outstanding to carry. + pub own_offers: Option, + /// Carried rather than refused — classical messaging is unaffected by a PQ + /// side-band wedge. + pub pq_wedged: Option, + pub no_custody: SessionMigrationNoCustody, +} + /// The archivable `PqInflight` round state in the native `MigratedPQInflight` /// shape: the A.4 variants carry the round's KEM material, `RekeyInitiated` /// the leg-1 Upd' (lifted out of the retained side-band frame, whose `0x1B` @@ -249,9 +440,8 @@ pub struct SessionMigrationExport { pub auth_mine: SessionMigrationPartySequence, pub auth_theirs: SessionMigrationPartySequence, pub send_group: SessionMigrationGroupHalf, - /// `Some` on every export the gates admit (see the module note) — the - /// field stays `Option` because the native shape allows a - /// pre-establishment initiator even though this export refuses one. + /// `None` exactly for a pre-establishment initiator — the one state + /// with no recv group at all. `Some` otherwise. pub recv_group: Option, pub current_staple: Vec, pub pending_proposal: Option, @@ -282,9 +472,27 @@ pub struct SessionMigrationExport { pub initial_their_kp: Option, /// `requires_establishment_envelope` under its native name. pub owes_establishment_envelope: bool, - /// `Some` exactly when the recv-PQ leaf still presents a key other than the - /// identity's — a born-dedicated acceptor's uncaught-up PQ leaf. + /// `Some` exactly when the recv-PQ leaf still presents a key other than + /// the identity's — a born-dedicated acceptor's uncaught-up PQ leaf. Kept + /// for back-compat with narrower, history-window-checked gating; + /// `leaf_keys` is authoritative and never gates the export. pub pq_leaf_custody: Option, + /// Every own leaf's resolved signing custody — replaces the + /// identity-equality gate and `pq_leaf_custody`'s narrow special case. + /// This shape may still change before the Swift mapper consumes it. + pub leaf_keys: SessionMigrationLeafKeys, + /// The newest staged candidate — `None` when there is none, or when its + /// id equals `auth.mine`'s current one (a same-id candidate never + /// exports here; see `recv_classical_pending`). Its key equals + /// `leaf_keys.{send,recv}_classical.pending`'s entry for the same target. + pub rotation_candidate: Option, + /// The host's app-layer welcome riding a pre-establishment initiator's + /// envelope. `None` on every established session; exported only for a + /// pre-join initiator, and only when non-empty. + pub initial_app_payload: Option>, + /// Deployed-only carry state (own-offer window, `pq_wedged`, per-half no-custody) — + /// `Some` whenever any of it is non-empty or true. See `SessionMigrationDeployedState`. + pub deployed_state: Option, } /// The stored Ed25519 secrets use the mls-rs provider convention — the @@ -316,21 +524,48 @@ fn decode_checked_kp(kpd_bytes: &[u8], client_id: &[u8]) -> Result bool { + !kp_bytes.is_empty() && payload.windows(kp_bytes.len()).any(|w| w == kp_bytes) +} + /// Pick the half's identity key package: a retained store entry whose /// credential binds `client_id`, or — the normal case for an established /// session, whose key packages were consumed by their joins — a freshly /// minted one, captured out of the store again so the export leaves no /// residue (single-homed, mirroring the `initiate` bootstrap-KP capture). +/// +/// `prefer_within` (the pre-establishment initiator's `initial_app_payload`) +/// breaks a tie among several retained entries binding `client_id`: the one +/// whose bytes occur inside it is the KP the peer will actually use. Not +/// shown reachable today, so an unresolved tie falls back to +/// first-in-storage-order. fn identity_kp( store: &SyntheticKeyPackageStore, client_id: &[u8], + prefer_within: Option<&[u8]>, generate: impl FnOnce() -> Result>, ) -> Result { - for (id, kpd) in store.all_entries() { - if decode_checked_kp(&kpd.key_package_bytes, client_id).is_ok() { - return Ok((id, kpd)); + let bound: Vec = store + .all_entries() + .into_iter() + .filter(|(_, kpd)| decode_checked_kp(&kpd.key_package_bytes, client_id).is_ok()) + .collect(); + if let Some(payload) = prefer_within { + if bound.len() > 1 { + if let Some(preferred) = bound + .iter() + .find(|(_, kpd)| payload_contains_kp(payload, &kpd.key_package_bytes)) + { + return Ok(preferred.clone()); + } } } + if let Some(first) = bound.into_iter().next() { + return Ok(first); + } let (generated, captured) = store.capture(generate); generated?; let mut captured = captured.into_iter(); @@ -353,30 +588,6 @@ fn bare_kp(framed: &[u8]) -> Result> { .map_err(|_| TwoMlsPqError::ArchiveInvalid) } -/// One group half-pair → format-2 snapshots. Flushes each half first (the -/// same discipline `export_state` follows) so the export sees the state a -/// persistence push would write. `export_for_swift` hard-errors on a pending -/// commit or a signer-rotating pending self-Update — the export inherits -/// those refusals. -fn export_group_half(group: &mut CombinerGroup) -> Result { - group - .classical - .write_to_storage() - .map_err(|_| TwoMlsPqError::Mls)?; - let classical = group - .classical - .export_for_swift() - .map_err(|_| TwoMlsPqError::Mls)?; - let pq = match group.pq.as_mut() { - Some(pq) => { - pq.write_to_storage().map_err(|_| TwoMlsPqError::Mls)?; - Some(pq.export_for_swift().map_err(|_| TwoMlsPqError::Mls)?) - } - None => None, - }; - Ok(SessionMigrationGroupHalf { classical, pq }) -} - /// The live PQ round state → the export enum. `Responding` requires its /// retained wire CT (a v2-restored round's is unrecoverable — fail rather /// than mint a round that can never re-wrap); `RekeyInitiated` lifts the @@ -422,8 +633,8 @@ fn export_pq_inflight(inner: &SessionInner) -> Result( group: &mls_rs::Group, ) -> Result> { @@ -435,11 +646,11 @@ pub(super) fn own_signature_key( .to_vec()) } -/// A PQ half's own leaf, once it no longer presents the identity's key: builds custody -/// over its signer, provided the leaf's own credential is one of THIS session's own past -/// identities (`auth.mine` history, not the peer's) and the signer derives to the -/// presented key. Derivation runs through the EXPECTED suite's provider (never one chosen -/// by the group's own stored suite — the caller checks that separately, first). +/// A PQ half's own leaf that no longer presents the identity's key: builds +/// custody over its signer, provided the leaf's credential is one of this +/// session's own past identities and the signer derives to the presented +/// key. Derivation runs through the expected suite's provider, never the +/// group's own stored suite — the caller checks that separately, first. pub(super) fn leaf_pq_custody( group: &crate::key_package_store::PqMlsGroup, presented: &[u8], @@ -465,61 +676,648 @@ pub(super) fn leaf_pq_custody( }) } +/// One custody-search step: normalize a candidate secret to bare Ed25519 +/// (32 B) and derive its public key through `provider`. `None` for anything +/// that isn't a plausible Ed25519 secret, or the provider refuses. Normalize +/// before deriving: the cryptokit bridge traps on input shorter than 32 +/// bytes, so this turns a mismatched-length candidate into a clean "no +/// match" instead of a process abort. +fn candidate_public_key( + provider: &impl CipherSuiteProvider, + secret_bytes: &[u8], +) -> Option<(Vec, Vec)> { + let bare = bare_ed25519(secret_bytes).ok()?; + let sk = mls_rs::crypto::SignatureSecretKey::new(bare.clone()); + let pk = provider.signature_key_derive_public(&sk).ok()?; + Some((bare, pk.as_bytes().to_vec())) +} + +/// Search `pool` for the secret behind `presented`, deriving each candidate +/// through `provider` — this half's own expected suite provider. First +/// match wins; under well-formed data at most one secret can derive to a +/// given key. `None` (not an error) means the no-custody case. +fn find_custody( + provider: &impl CipherSuiteProvider, + presented: &[u8], + pool: &[Vec], +) -> Option> { + pool.iter().find_map(|candidate| { + let (bare, derived) = candidate_public_key(provider, candidate)?; + (derived == presented).then_some(bare) + }) +} + +/// The `SigningIdentity`'s ClientId (Basic credential) — every credential this crate ever +/// mints is Basic, so anything else is corrupt/impossible. +fn signing_identity_client_id(identity: &mls_rs::identity::SigningIdentity) -> Result> { + identity + .credential + .as_basic() + .map(|basic| basic.identifier.clone()) + .ok_or(TwoMlsPqError::ArchiveInvalid) +} + +/// One half's `pending_updates` signers, mapped onto +/// `SessionMigrationPendingLeafKey` — dropping any orphan +/// (`signing_identity == None`): the proposal cache was cleared while the +/// pending secret survived, so no peer can ever commit it by reference. +fn pending_leaf_keys( + provider: &impl CipherSuiteProvider, + signers: &[mls_rs::group::SwiftExportPendingSigner], +) -> Result> { + // A born-dedicated acceptor left unfolded can re-propose the same signer + // ~10^5 times. Dedupe by normalized secret bytes before deriving — an + // ML-DSA derive is the expensive step — to bound cost by distinct keys. + let mut seen = std::collections::HashSet::new(); + signers + .iter() + .filter_map(|signer| { + let identity = signer.signing_identity.as_ref()?; + let secret_bytes = signer.signer.as_bytes(); + let dedupe_key = bare_ed25519(secret_bytes).unwrap_or_else(|_| secret_bytes.to_vec()); + if !seen.insert(dedupe_key) { + return None; + } + Some((|| { + let target = signing_identity_client_id(identity)?; + let (signing_key, signature_key) = candidate_public_key(provider, secret_bytes) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + Ok(SessionMigrationPendingLeafKey { + target, + key: SessionMigrationKeyPair { + signing_key, + signature_key, + }, + }) + })()) + }) + .collect() +} + +/// `pending_leaf_keys`'s counterpart for recv-classical's `Detached`-placed +/// entries. A `Detached` entry with no signer (a same-identity refresh) is +/// dropped, same as a signer-less `Snapshot` entry: only a leaf that rotates +/// its signing identity is a candidate a peer could ever commit by +/// reference. +fn pending_leaf_keys_from_detached( + provider: &impl CipherSuiteProvider, + detached: &[mls_rs::group::SwiftExportDetachedPending], +) -> Result> { + let mut seen = std::collections::HashSet::new(); + detached + .iter() + .filter_map(|entry| { + let signer = entry.signer.as_ref()?; + let identity = entry.signing_identity.as_ref()?; + let secret_bytes = signer.as_bytes(); + let dedupe_key = bare_ed25519(secret_bytes).unwrap_or_else(|_| secret_bytes.to_vec()); + if !seen.insert(dedupe_key) { + return None; + } + Some((|| { + let target = signing_identity_client_id(identity)?; + let (signing_key, signature_key) = candidate_public_key(provider, secret_bytes) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + Ok(SessionMigrationPendingLeafKey { + target, + key: SessionMigrationKeyPair { + signing_key, + signature_key, + }, + }) + })()) + }) + .collect() +} + +/// The leaf HPKE public key an MLS Update proposal names — the join key between an +/// `own_offer_window` entry's bare-encoded `proposal` and the `leaf_public_key` +/// `export_for_swift_placing_pending`'s `place` callback and `Detached` entries use. +pub(super) fn update_leaf_public_key(proposal_bytes: &[u8]) -> Option> { + let proposal = mls_rs::group::proposal::Proposal::mls_decode(&mut &proposal_bytes[..]).ok()?; + let mls_rs::group::proposal::Proposal::Update(update) = proposal else { + return None; + }; + Some(update.hpke_public_key().as_ref().to_vec()) +} + +/// The (credential id, signature key) an MLS Update proposal names — what `own_offer_ +/// window`'s same-id filter and `recv_classical_pending`'s resolution both key their +/// per-target grouping on, and what native's own check 6 compares a window offer +/// against. `None` if the bytes don't decode as an Update, or the credential isn't +/// Basic (this crate never proposes anything else). +pub(super) fn decoded_update_target(proposal_bytes: &[u8]) -> Option<(Vec, Vec)> { + let proposal = mls_rs::group::proposal::Proposal::mls_decode(&mut &proposal_bytes[..]).ok()?; + let mls_rs::group::proposal::Proposal::Update(update) = proposal else { + return None; + }; + let identity = update.signing_identity(); + let client_id = identity.credential.as_basic()?.identifier.clone(); + Some((client_id, identity.signature_key.as_bytes().to_vec())) +} + +/// The leaf HPKE public key of the session's single staged Update +/// (`staged_updates`), if still present in recv-classical's own-proposal +/// cache. Test-only: an independent cross-check for `own_offer_window`'s +/// `framed_leaf_key`. Gated on `cryptokit`, not just `#[cfg(test)]`, since +/// every call site is a migration test requiring the CryptoKit +/// representation. +#[cfg(all(test, feature = "cryptokit"))] +pub(super) fn staged_update_leaf_key( + recv_classical: &mls_rs::Group, + latest_own_offer: Option<&[u8]>, + provider: &impl CipherSuiteProvider, +) -> Result>> { + let Some(latest) = latest_own_offer else { + return Ok(None); + }; + let Ok(latest_hash) = provider.hash(latest) else { + return Ok(None); + }; + for entry in recv_classical + .own_proposals_for_swift_export() + .map_err(map_swift_export_err)? + { + if entry.message_hash == latest_hash { + return Ok(update_leaf_public_key(&entry.proposal)); + } + } + Ok(None) +} + +/// Dedupe `pending` entries by `key.signing_key`: a leaf re-proposing the +/// same target every frame collapses to one entry, including a real mls-rs +/// signer entry against a generalized catch-up entry for the same target +/// when the two carry the identical key. Stable — first occurrence wins. +/// Does not resolve the same-id-candidate case, where two different keys +/// compete for the same target — see `recv_classical_pending`. +fn dedupe_pending(pending: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + pending.retain(|entry| seen.insert(entry.key.signing_key.clone())); +} + +/// Dedupe a custody candidate pool by normalized secret bytes, before any +/// search: `find_custody` derives every pool entry through each half's +/// provider in turn, so an undeduped pool with ~10^5 copies of one secret +/// would run that many redundant (ML-DSA, for PQ) derives instead of one. +fn dedupe_secret_bytes(pool: &mut Vec>) { + let mut seen = std::collections::HashSet::new(); + pool.retain(|candidate| { + let key = bare_ed25519(candidate).unwrap_or_else(|_| candidate.clone()); + seen.insert(key) + }); +} + +/// One own leaf's full custody resolution: the presented key searched +/// against `pool` through `provider`, plus this half's own pending signers. +/// Staged-candidate entries are appended by the caller, since those are +/// session-global state this function can't see. +fn resolve_leaf_key( + group: &mls_rs::Group, + provider: &impl CipherSuiteProvider, + pool: &[Vec], + pending_signers: &[mls_rs::group::SwiftExportPendingSigner], +) -> Result<(SessionMigrationGroupKeys, bool)> { + let presented = own_signature_key(group)?; + let current = + find_custody(provider, &presented, pool).map(|signing_key| SessionMigrationKeyPair { + signing_key, + signature_key: presented, + }); + let no_custody = current.is_none(); + let mut pending = pending_leaf_keys(provider, pending_signers)?; + dedupe_pending(&mut pending); + Ok((SessionMigrationGroupKeys { current, pending }, no_custody)) +} + +/// A group that doesn't exist yet still carries a reservation key in +/// `current` (the key it will present once created or joined), with +/// `pending` empty. `current: None` is the reservation's own no-custody +/// case, since native's `validateLeafKeys` requires `current` to be `nil` +/// exactly when the group's role is in `noCustody`. +fn reservation(current: Option) -> (SessionMigrationGroupKeys, bool) { + let no_custody = current.is_none(); + ( + SessionMigrationGroupKeys { + current, + pending: Vec::new(), + }, + no_custody, + ) +} + +/// The generalized catch-up: `Some(pending[mine_current] = identity_pair)` +/// when `group`'s presented credential (not raw key) is behind +/// `mine_current`. `None` when the leaf already presents it. +fn catch_up_pending_entry( + group: &mls_rs::Group, + mine_current: &[u8], + identity_pair: &SessionMigrationKeyPair, +) -> Result> { + let own_credential = apq::sender_client_id(group, group.current_member_index()) + .map_err(|_| TwoMlsPqError::Mls)?; + if own_credential == mine_current { + return Ok(None); + } + Ok(Some(SessionMigrationPendingLeafKey { + target: mine_current.to_vec(), + key: identity_pair.clone(), + })) +} + +/// Folds a generalized catch-up entry into a half's real pending — PQ +/// halves only (classical uses `send_classical_pending`/ +/// `recv_classical_pending`, which resolve the same-id ambiguity this +/// simpler fold cannot). A real entry already targeting the catch-up's +/// target is superseded by it. +fn fold_in_catch_up( + mut real_pending: Vec, + catch_up: Option, +) -> Vec { + if let Some(catch_up) = catch_up { + real_pending.retain(|p| p.target != catch_up.target); + real_pending.push(catch_up); + } + dedupe_pending(&mut real_pending); + real_pending +} + +/// `send_classical`'s `pending`: the identity's own catch-up key at +/// `mine_current` (if the leaf lags), plus every staged candidate other +/// than `mine_current`. send_classical has no by-reference fold or window, +/// so a same-id candidate never gets its own entry here — only the +/// identity's; see `recv_classical_pending` for why recv_classical differs. +fn send_classical_pending( + catch_up: Option, + candidates: &[Arc], + classical_provider: &impl CipherSuiteProvider, + mine_current: &[u8], +) -> Result> { + let mut pending: Vec = catch_up.into_iter().collect(); + for candidate in candidates { + let target = candidate.client_id().bytes; + if target == mine_current { + continue; + } + let (signing_key, signature_key) = candidate_public_key( + classical_provider, + candidate.combiner().classical_signing_key(), + ) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + pending.push(SessionMigrationPendingLeafKey { + target, + key: SessionMigrationKeyPair { + signing_key, + signature_key, + }, + }); + } + dedupe_pending(&mut pending); + Ok(pending) +} + +/// `recv_classical`'s `pending`, grouped by target from `framed` (see the +/// module doc), `real_pending` (this group's own outstanding offers), and +/// each staged candidate's synthesized entry. Every target other than +/// `mine_current` is unambiguous. +/// +/// `mine_current` can have two real, differently-keyed offers at once — the +/// identity's self-catch-up and a same-id candidate's re-proposal — and only +/// one key can ever occupy `pending[mine_current]`. See the module doc for +/// the priority order, which `own_offer_window` mirrors so the two never +/// disagree. `same_id_candidate_key` is pinned to the staged candidate's own +/// derived key rather than scanned off `real_pending`/`candidates`, since +/// their iteration order is unspecified and not guaranteed stable across the +/// two functions. +#[allow(clippy::too_many_arguments)] +fn recv_classical_pending( + real_pending: Vec, + framed: Option<&SessionMigrationPendingLeafKey>, + candidates: &[Arc], + classical_provider: &impl CipherSuiteProvider, + mine_current: &[u8], + identity_pair: &SessionMigrationKeyPair, + same_id_candidate_key: Option<&SessionMigrationKeyPair>, + catch_up: Option, +) -> Result> { + let mut by_target: std::collections::HashMap, SessionMigrationKeyPair> = + std::collections::HashMap::new(); + let mut framed_mine_current: Option = None; + if let Some(entry) = framed { + if entry.target == mine_current { + framed_mine_current = Some(entry.key.clone()); + } else { + by_target + .entry(entry.target.clone()) + .or_insert_with(|| entry.key.clone()); + } + } + let mut mine_current_identity = false; + let mut mine_current_candidate = false; + for entry in real_pending { + if entry.target == mine_current { + if entry.key.signing_key == identity_pair.signing_key { + mine_current_identity = true; + } else { + mine_current_candidate = true; + } + } else { + by_target.entry(entry.target).or_insert(entry.key); + } + } + for candidate in candidates { + let target = candidate.client_id().bytes; + let (signing_key, signature_key) = candidate_public_key( + classical_provider, + candidate.combiner().classical_signing_key(), + ) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + let key = SessionMigrationKeyPair { + signing_key, + signature_key, + }; + if target == mine_current { + if key.signing_key == identity_pair.signing_key { + mine_current_identity = true; + } else { + mine_current_candidate = true; + } + } else { + by_target.entry(target).or_insert(key); + } + } + let mine_current_key = framed_mine_current + .or_else(|| mine_current_identity.then(|| identity_pair.clone())) + .or_else(|| { + mine_current_candidate + .then_some(same_id_candidate_key.cloned()) + .flatten() + }) + .or_else(|| catch_up.map(|c| c.key)); + if let Some(key) = mine_current_key { + by_target.insert(mine_current.to_vec(), key); + } + let mut pending: Vec = by_target + .into_iter() + .map(|(target, key)| SessionMigrationPendingLeafKey { target, key }) + .collect(); + pending.sort_by(|a, b| a.target.cmp(&b.target)); + Ok(pending) +} + +/// KP′'s presented PQ signature key, decoded WITHOUT the credential-id check +/// `decode_checked_kp` applies: a card initiator that rotated while stalled at A.3 still +/// has KP′ present the OLD (pre-rotation) identity, so it need not bind the CURRENT +/// identity's client id. +fn kp_signature_key(kp_bytes: &[u8]) -> Result> { + let kp = mls_rs::KeyPackage::mls_decode(&mut &kp_bytes[..]) + .map_err(|_| TwoMlsPqError::ArchiveInvalid)?; + Ok(kp.signing_identity().signature_key.as_bytes().to_vec()) +} + +/// Maps one swift_export failure. `SwiftExportPendingCommitUnsupported` is +/// unreachable for this crate — every Rust commit is built and applied in +/// one call — but stays `Mls` (retryable) in case it ever occurs. Every +/// other failure is corrupt/impossible data — `ArchiveInvalid`. +fn map_swift_export_err(err: mls_rs::client::MlsError) -> TwoMlsPqError { + match err { + mls_rs::client::MlsError::SwiftExportPendingCommitUnsupported => TwoMlsPqError::Mls, + _ => TwoMlsPqError::ArchiveInvalid, + } +} + +/// One `Group::export_for_swift_with_pending_signers()` call, flushed to +/// storage first so the export sees what a persistence push would write. +/// Inherits the pending-commit refusal, surfaced as `Mls` — see +/// `map_swift_export_err`. +fn export_mls_half( + group: &mut mls_rs::Group, +) -> Result<(Vec, Vec)> { + group.write_to_storage().map_err(|_| TwoMlsPqError::Mls)?; + group + .export_for_swift_with_pending_signers() + .map_err(map_swift_export_err) +} + +/// The own-offer window's cap: a fixed bound on carried state, not a +/// measurement of real depth. Trades session stability (a peer folding an +/// offer this window dropped permanently wedges that session) for app +/// stability (bounded export size). One carried offer is on the order of a +/// few hundred bytes (see `test_own_offer_window_entry_size_estimate`), so +/// the cap's worst case (band 2 full) is on the order of tens of MB. +pub(super) const OWN_OFFER_WINDOW: usize = 102_400; + +/// Recv-classical's own-offer window: Update proposals from the own-proposal +/// cache, excluding the entry `staged_updates`/`pending_proposal` frame while +/// a `prepare_to_encrypt` is outstanding (its HPKE pair already rides the +/// snapshot, so a second copy here would duplicate it). At rest, nothing is +/// excluded this way. What remains is capped at `cap`, in two bands: band 2 +/// (presentation-changing — proposed identity differs from what the leaf +/// currently presents) first, then band 3 (same-identity refreshes), each in +/// deterministic `proposal_ref` order. Only band 3 truncates in practice, +/// since a peer can only ever fold a band-2 offer — but band 2 alone can +/// exceed `cap` (e.g. a born-dedicated acceptor left unfolded, re-proposing +/// catch-up ~10^5 times); that is an accepted residual risk (a peer folding +/// an offer this window already dropped permanently wedges that one +/// session), not a hazard this ordering introduces. +/// +/// `cap` is a parameter so tests can exercise banding/truncation against a +/// small window; production always passes `OWN_OFFER_WINDOW`. +/// +/// The framed entry is identified by re-hashing `latest_own_offer` (the +/// session's `pending_proposal_message`) the same way the proposal cache's +/// own key is computed — `own_proposals_for_swift_export` has no other +/// ordering or sequence signal to find it by. At rest `latest_own_offer` is +/// `None`, so nothing is excluded on that basis. +/// +/// `mine_current`/`identity_key`/`same_id_candidate_key` resolve the one +/// target that can have two real, differently-keyed offers outstanding at +/// once — a same-id staged candidate alongside the identity's own +/// self-catch-up (see `recv_classical_pending`'s doc for the priority order, +/// which this mirrors so the window and `pending[mine_current]` never +/// disagree). Only a presentation-changing (band 2) entry, never a same-key +/// refresh, may set the identity- or candidate-offer flags — conflating the +/// two is exactly how the window and `pending[mine_current]` could disagree, +/// since `recv_classical_pending` only ever counts a signer-carrying entry +/// toward its own resolution. A same-key refresh always survives regardless +/// of that resolution (native check 6): its key always matches what the leaf +/// presents. +/// +/// Returns the window offers and, piggybacked on the same scan, the framed +/// entry's own leaf key — `migration_export` uses this to avoid a second +/// pass over the same (potentially ~10^5-entry) cache. +pub(super) fn own_offer_window( + recv_classical: &mls_rs::Group, + latest_own_offer: Option<&[u8]>, + provider: &impl CipherSuiteProvider, + cap: usize, + mine_current: &[u8], + identity_key: &[u8], + same_id_candidate_key: Option<&SessionMigrationKeyPair>, +) -> Result<(Vec, Option>)> { + let presented_key = own_signature_key(recv_classical)?; + let presented_client_id = + apq::sender_client_id(recv_classical, recv_classical.current_member_index()) + .map_err(|_| TwoMlsPqError::Mls)?; + let latest_hash: Option> = latest_own_offer.and_then(|bytes| provider.hash(bytes).ok()); + + let mut band2 = Vec::new(); + let mut band3 = Vec::new(); + let mut mine_current_identity_offer = false; + let mut mine_current_candidate_offer = false; + let mut framed_leaf_key = None; + let mut framed_mine_current_key: Option> = None; + + for entry in recv_classical + .own_proposals_for_swift_export() + .map_err(map_swift_export_err)? + { + let Some((proposed_client_id, proposed_key)) = decoded_update_target(&entry.proposal) + else { + continue; + }; + // A same-key, same-id entry is a plain refresh (`propose_update` with + // no signer, changing nothing the leaf presents). `recv_classical_pending` + // only counts a signer-carrying entry toward its own identity/candidate + // resolution, so a refresh must never set these flags either — + // otherwise the window and `pending[mine_current]` can disagree. + let presentation_changes = + proposed_key != presented_key || proposed_client_id != presented_client_id; + if latest_hash.as_deref() == Some(entry.message_hash.as_slice()) { + // The framed entry never joins the window, but if it presentation- + // changes and targets `mine_current`, its key still decides + // `pending[mine_current]` below — it can never be dropped. + framed_leaf_key = update_leaf_public_key(&entry.proposal); + if presentation_changes && proposed_client_id == mine_current { + framed_mine_current_key = Some(proposed_key); + } + continue; + } + if presentation_changes && proposed_client_id == mine_current { + if proposed_key == identity_key { + mine_current_identity_offer = true; + } else { + mine_current_candidate_offer = true; + } + } + let mapped = SessionMigrationOwnOffer { + proposal_ref: entry.proposal_ref, + proposal: entry.proposal, + // Filled in by the caller from the placement export's `Detached` + // list; empty here is a safe "not yet filled" sentinel, since a + // real HPKE secret is never actually empty. + leaf_secret: Vec::new(), + }; + let entry = (Some(proposed_client_id), proposed_key, mapped); + if presentation_changes { + band2.push(entry); + } else { + band3.push(entry); + } + } + + // Mirrors `recv_classical_pending`'s priority: the framed entry wins if + // it targets `mine_current`; else the identity, if outstanding; else the + // same-id candidate's pinned key; else `None`. + let mine_current_pending_key: Option> = framed_mine_current_key.or_else(|| { + if mine_current_identity_offer { + Some(identity_key.to_vec()) + } else if mine_current_candidate_offer { + same_id_candidate_key.map(|k| k.signature_key.clone()) + } else { + None + } + }); + // Native check 6: an offer survives if its key matches what the leaf + // currently presents (a refresh, regardless of `mine_current_pending_key`), + // or matches `pending[id]` for whatever id it targets. + let keep = |client_id: &Option>, key: &[u8]| { + if key == presented_key.as_slice() { + return true; + } + match client_id { + Some(id) if id.as_slice() == mine_current => { + mine_current_pending_key.as_deref() == Some(key) + } + _ => true, + } + }; + let mut band2: Vec = band2 + .into_iter() + .filter(|(id, key, _)| keep(id, key)) + .map(|(_, _, offer)| offer) + .collect(); + let mut band3: Vec = band3 + .into_iter() + .filter(|(id, key, _)| keep(id, key)) + .map(|(_, _, offer)| offer) + .collect(); + band2.sort_by(|a, b| a.proposal_ref.cmp(&b.proposal_ref)); + band3.sort_by(|a, b| a.proposal_ref.cmp(&b.proposal_ref)); + + let mut out = band2; + out.extend(band3); + let mut seen = std::collections::HashSet::new(); + out.retain(|e| seen.insert(e.proposal_ref.clone())); + out.truncate(cap); + Ok((out, framed_leaf_key)) +} + #[uniffi::export] impl TwoMlsPqSession { /// Export this session as the migration payload for the twomlspq-swift - /// session mint: every group half as a format-2 snapshot plus the session - /// metadata `SessionMigration.mintArchive` mints a native `SessionArchive` - /// from. - /// - /// Admits only an established session — including one with a parked - /// side-band leg, an in-flight PQ round, or an owed bind, all carried so - /// the round completes after migration — see the module note for the - /// refused states (`SessionNotReady`; `ArchiveInvalid` for torn or - /// unrecoverable state; `Mls` when a group half refuses its own export, - /// e.g. a pending commit). + /// session mint: every group half as a format-2 snapshot plus session + /// metadata `SessionMigration.mintArchive` mints a native + /// `SessionArchive` from. See the module note for the totality rule and + /// what still refuses. /// - /// Emits PLAINTEXT SECRET material — the caller seals (the `ArchiveSink` - /// contract). PQ secret material is exported in the CryptoKit 96-byte - /// representation, correct only under the `cryptokit` provider build; - /// under `awslc` the length guards fail the export as `ArchiveInvalid`. + /// Emits plaintext secret material — the caller seals (the + /// `ArchiveSink` contract). PQ secrets use the CryptoKit 96-byte + /// representation; under `awslc` the length guards fail as + /// `ArchiveInvalid`. pub fn migration_export(&self) -> Result { let mut inner = self.lock(); let initiated = inner.expected_bootstrap_kp_commitment.is_none(); - // The unmigratable states (module note): pre-establishment, mid-rotation, a - // born-dedicated session not yet installed, or a torn side-band. An - // acceptor's parked return welcome is fine — dropped below, not refused - // here, because the app never drains it and the same welcome still rides - // `current_staple` — so only an initiator's park blocks the export - // (already implied by `recv_group.is_none()`; kept as belt-and-braces). - if inner.recv_group.is_none() - || (inner.pending_outbound.is_some() && initiated) - || inner.initial_app_payload.is_some() - || inner.initial_return_kp.is_some() - || !inner.staged_candidates.is_empty() - || inner.deferred_candidate.is_some() - || (inner.requires_establishment_envelope && inner.establishment_envelope.is_none()) - || inner.pq_wedged.is_some() - || inner.bind_apply_broken - { - return Err(TwoMlsPqError::SessionNotReady); + // `bind_apply_broken` is in-memory only: a restored session's + // converter never sets it from persisted rows, so it can only be + // live on the current process. Not corrupt — reload and export that + // row instead, so `Mls` (retryable), not `ArchiveInvalid`. + if inner.bind_apply_broken { + return Err(TwoMlsPqError::Mls); + } + // `initial_return_kp` is never set by this product's own wrapper + // (only `setInitialAppPayload` is ever called) — impossible, not + // merely unreached, so a live value here is corrupt. + if inner.recv_group.is_none() && inner.initial_return_kp.is_some() { + return Err(TwoMlsPqError::ArchiveInvalid); + } + // `initial_app_payload` is exported only for a pre-join initiator, + // and must be non-empty there (native's mint enforces the same + // rule) — the wrapper always attaches a real payload before + // establishment and clears it at the cutover, so either violation + // is corruption, caught here rather than at the mint. + match ( + inner.recv_group.is_none(), + inner.initial_app_payload.as_ref(), + ) { + (true, Some(payload)) if payload.is_empty() => { + return Err(TwoMlsPqError::ArchiveInvalid) + } + (false, Some(_)) => return Err(TwoMlsPqError::ArchiveInvalid), + _ => {} } - // SUITE CHECK FIRST, before any crypto: a stored group half whose cipher - // suite disagrees with the session's own declared suite is corrupt — and - // must never reach a provider it doesn't belong to (see the module note). + // Suite check first, before any crypto: a stored group half whose + // cipher suite disagrees with the session's declared suite must + // never reach a provider it doesn't belong to. `recv_group` missing + // is the legitimate pre-establishment case, simply skipped. let expected_suite = inner.client.combiner().cipher_suite(); - for half in [ - inner - .send_group - .as_ref() - .ok_or(TwoMlsPqError::SessionNotReady)?, - inner - .recv_group - .as_ref() - .ok_or(TwoMlsPqError::SessionNotReady)?, - ] { + let send_ref = inner + .send_group + .as_ref() + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + for half in std::iter::once(send_ref).chain(inner.recv_group.as_ref()) { let pq_ok = half .pq .as_ref() @@ -530,15 +1328,22 @@ impl TwoMlsPqSession { } // The identity: signing keys from the session client, key packages - // picked (or freshly minted) per half — see `identity_kp`. - let client = inner.client.combiner(); + // picked (or minted) per half — see `identity_kp`. An owned `Arc` + // clone so `client` outlives the later mutable passes below. + let client_principal = std::sync::Arc::clone(&inner.client); + let client = client_principal.combiner(); let client_id = client.client_id().to_vec(); - let (_, classical_kpd) = identity_kp(client.classical_kp_store(), &client_id, || { - client - .generate_classical_key_package() - .map_err(|_| TwoMlsPqError::Mls) - })?; - let (_, pq_kpd) = identity_kp(client.pq_kp_store(), &client_id, || { + let (_, classical_kpd) = identity_kp( + client.classical_kp_store(), + &client_id, + inner.initial_app_payload.as_deref(), + || { + client + .generate_classical_key_package() + .map_err(|_| TwoMlsPqError::Mls) + }, + )?; + let (_, pq_kpd) = identity_kp(client.pq_kp_store(), &client_id, None, || { client .generate_pq_key_package() .map_err(|_| TwoMlsPqError::Mls) @@ -556,6 +1361,19 @@ impl TwoMlsPqSession { return Err(TwoMlsPqError::ArchiveInvalid); } } + // A pre-establishment initiator's `identity_kp` picks the retained + // return key package, never a freshly minted one, so its init + // secret is real and joinable — supply it. `None` + // post-establishment: an established session's KP mints fresh, with + // no return-channel meaning. + let classical_init_secret_key = if inner.recv_group.is_none() { + if classical_kpd.init_key.len() != 32 { + return Err(TwoMlsPqError::ArchiveInvalid); + } + Some(classical_kpd.init_key.to_vec()) + } else { + None + }; let identity = SessionMigrationIdentity { client_id, signing_key: bare_ed25519(client.classical_signing_key())?, @@ -567,42 +1385,378 @@ impl TwoMlsPqSession { pq_signing_key: bare_ed25519(client.pq_signing_key())?, pq_signature_key: pq_kp.signing_identity().signature_key.as_bytes().to_vec(), classical_leaf_secret_key: classical_kpd.leaf_node_key.to_vec(), - // Always None: the mint admits a classical init secret only for a - // pre-establishment initiator, which this export refuses. - classical_init_secret_key: None, + classical_init_secret_key, pq_leaf_secret_key: pq_kpd.leaf_node_key.to_vec(), classical_key_package: classical_kpd.key_package_bytes.clone(), pq_key_package: pq_kpd.key_package_bytes.clone(), }; - // The custody gate: every group half's own leaf must present the - // identity's per-half signing key, except a born-dedicated acceptor's - // recv-PQ leaf, which nothing in Rust ever catches up (module note) — - // captured as `pq_leaf_custody` instead of matched. The mint's custody - // arms resolve a presented key against identity ∪ rotationCandidate ∪ - // recvLeafPrincipal; anything else admitted here would fail the mint as - // `archiveInvalid` (corruption semantics on a healthy session), so this - // refuses as `SessionNotReady` first — before the export ever surfaces - // an `Mls` error for merely not having converged yet. - let send = inner - .send_group + // `auth.mine`'s canonical current identity — the target of the generalized + // catch-up and the same-id candidate resolution below. Every session is seeded + // with an id at construction, so an empty `mine` here is corrupt, not merely + // unusual. Computed early (immutable `with_auth` read) because the own-offer + // window's same-id filter needs it before the mutable export pass. + let mine_current = inner + .with_auth(|core| core.mine.current().map(<[u8]>::to_vec)) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + let identity_classical_pair = SessionMigrationKeyPair { + signing_key: identity.signing_key.clone(), + signature_key: identity.signature_key.clone(), + }; + let identity_pq_pair = SessionMigrationKeyPair { + signing_key: identity.pq_signing_key.clone(), + signature_key: identity.pq_signature_key.clone(), + }; + + // The own-offer window and its leaf-key sets are computed first, from + // immutable reads, before any mutable export pass: `place` (below) + // needs both already built. + let classical_provider = crate::providers::classical_envelope_suite()?; + let pq_provider = crate::providers::pq_envelope_suite()?; + // The staged same-id candidate's own public key, if any — pinned here + // once rather than left for `own_offer_window` and + // `recv_classical_pending` to each scan for it independently, so the + // two can never disagree. + let same_id_candidate_key = inner + .staged_candidates + .iter() + .find(|c| c.client_id().bytes == mine_current) + .map(|c| { + let (signing_key, signature_key) = + candidate_public_key(&classical_provider, c.combiner().classical_signing_key()) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + Ok::<_, TwoMlsPqError>(SessionMigrationKeyPair { + signing_key, + signature_key, + }) + }) + .transpose()?; + let latest_own_offer = inner + .pending_proposal_message .as_ref() - .ok_or(TwoMlsPqError::SessionNotReady)?; - let recv = inner + .map(|(_, m)| m.as_slice()); + // `own_offer_window` returns the framed entry's leaf key alongside the + // window in one pass, avoiding a second scan; `staged_update_leaf_key` + // stays for tests to cross-check independently. + let (mut own_offer_window_entries, framed_leaf_key) = match inner.recv_group.as_ref() { + Some(recv) => own_offer_window( + &recv.classical, + latest_own_offer, + &classical_provider, + OWN_OFFER_WINDOW, + &mine_current, + &identity_classical_pair.signature_key, + same_id_candidate_key.as_ref(), + )?, + None => (Vec::new(), None), + }; + let entry_leaf_keys: Vec>> = own_offer_window_entries + .iter() + .map(|o| update_leaf_public_key(&o.proposal)) + .collect(); + let window_leaf_key_set: std::collections::HashSet> = + entry_leaf_keys.iter().flatten().cloned().collect(); + + // Flush + export every existing group half — mutable pass first, so + // every later step works from owned bytes and a settled borrow of + // `inner`. + let send_classical = { + let g = inner + .send_group + .as_mut() + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + export_mls_half(&mut g.classical)? + }; + let send_pq = { + let g = inner + .send_group + .as_mut() + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + g.pq.as_mut().map(export_mls_half).transpose()? + }; + // recv-classical only: `export_for_swift_placing_pending` instead of + // `export_for_swift_with_pending_signers`. `place` sorts each entry by + // leaf HPKE key: the framed entry stays in the snapshot (`Snapshot`), + // every other window-carried entry is pulled out (`Detached`), + // everything else is dropped (`Omit`). + let recv_classical = match inner.recv_group.as_mut() { + Some(g) => { + g.classical + .write_to_storage() + .map_err(|_| TwoMlsPqError::Mls)?; + let place = |pk: &[u8]| -> mls_rs::group::SwiftExportPendingPlacement { + use mls_rs::group::SwiftExportPendingPlacement as Placement; + if framed_leaf_key.as_deref() == Some(pk) { + Placement::Snapshot + } else if window_leaf_key_set.contains(pk) { + Placement::Detached + } else { + Placement::Omit + } + }; + let (bytes, signers, detached) = g + .classical + .export_for_swift_placing_pending(place) + .map_err(map_swift_export_err)?; + Some((bytes, signers, detached)) + } + None => None, + }; + // Fill in each carried window offer's leaf secret from the matching + // `Detached` entry. Every remaining entry is `Detached`-placed by + // construction, so a miss here means either an undecodable proposal + // (impossible) or the placement export's own list disagreeing with + // what it was asked to detach — corrupt either way. + if let Some((_, _, detached)) = recv_classical.as_ref() { + let secret_by_leaf: std::collections::HashMap<&[u8], &[u8]> = detached + .iter() + .map(|d| (d.leaf_public_key.as_slice(), d.secret.as_slice())) + .collect(); + for (entry, leaf_key) in own_offer_window_entries + .iter_mut() + .zip(entry_leaf_keys.iter()) + { + entry.leaf_secret = leaf_key + .as_deref() + .and_then(|k| secret_by_leaf.get(k)) + .map(|s| s.to_vec()) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + } + } + let recv_pq = inner .recv_group + .as_mut() + .and_then(|g| g.pq.as_mut()) + .map(export_mls_half) + .transpose()?; + + // The custody search: per-half key custody by presented key. + // `send_ref`/`recv_ref` are a fresh immutable borrow — the mutable + // pass above has already ended. + let send_ref = inner + .send_group .as_ref() - .ok_or(TwoMlsPqError::SessionNotReady)?; - if own_signature_key(&send.classical)? != identity.signature_key - || own_signature_key(&recv.classical)? != identity.signature_key - { - return Err(TwoMlsPqError::SessionNotReady); + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + let recv_ref = inner.recv_group.as_ref(); + + // The candidate pool: the identity's own keys, every half's current + // signer, every staged candidate's keys, and every pending-update + // signer — searched per half through that half's expected provider, + // never the group's own stored suite. + let mut pool: Vec> = vec![ + client.classical_signing_key().to_vec(), + client.pq_signing_key().to_vec(), + send_ref + .classical + .signer_for_swift_export() + .as_bytes() + .to_vec(), + ]; + if let Some(pq) = send_ref.pq.as_ref() { + pool.push(pq.signer_for_swift_export().as_bytes().to_vec()); } - if let Some(pq) = send.pq.as_ref() { - if own_signature_key(pq)? != identity.pq_signature_key { - return Err(TwoMlsPqError::SessionNotReady); + if let Some(recv) = recv_ref { + pool.push(recv.classical.signer_for_swift_export().as_bytes().to_vec()); + if let Some(pq) = recv.pq.as_ref() { + pool.push(pq.signer_for_swift_export().as_bytes().to_vec()); } } - let pq_leaf_custody = match recv.pq.as_ref() { + for candidate in inner.staged_candidates.iter() { + pool.push(candidate.combiner().classical_signing_key().to_vec()); + pool.push(candidate.combiner().pq_signing_key().to_vec()); + } + for pending in [ + Some(&send_classical.1), + send_pq.as_ref().map(|(_, p)| p), + recv_classical.as_ref().map(|(_, p, _)| p), + recv_pq.as_ref().map(|(_, p)| p), + ] + .into_iter() + .flatten() + { + for signer in pending { + pool.push(signer.signer.as_bytes().to_vec()); + } + } + // A born-dedicated acceptor left unfolded can push ~10^5 pending + // signers, virtually all one key. Dedupe by normalized secret before + // any search, so this doesn't turn into O(N) expensive (ML-DSA) + // derives instead of O(1). + dedupe_secret_bytes(&mut pool); + + let send_classical_catch_up = + catch_up_pending_entry(&send_ref.classical, &mine_current, &identity_classical_pair)?; + let recv_classical_catch_up = match recv_ref { + Some(recv) => { + catch_up_pending_entry(&recv.classical, &mine_current, &identity_classical_pair)? + } + None => None, + }; + + // send_classical always exists. Resolve custody, then build `pending` + // from the generalized catch-up plus every other staged candidate — + // see `send_classical_pending`. + let (send_classical_leaf, send_classical_no_custody) = { + let (leaf, no_custody) = resolve_leaf_key( + &send_ref.classical, + &classical_provider, + &pool, + &send_classical.1, + )?; + let pending = send_classical_pending( + send_classical_catch_up.clone(), + &inner.staged_candidates, + &classical_provider, + &mine_current, + )?; + ( + SessionMigrationGroupKeys { + current: leaf.current, + pending, + }, + no_custody, + ) + }; + + // send_pq: the reservation for a pre-A.3 acceptor (the identity's + // current PQ key), otherwise resolved plus the generalized catch-up + // (no candidates: rotation is classical-only). + let (send_pq_leaf, send_pq_no_custody) = match send_ref.pq.as_ref().zip(send_pq.as_ref()) { + Some((group, (_, pending))) => { + let (leaf, no_custody) = resolve_leaf_key(group, &pq_provider, &pool, pending)?; + let catch_up = catch_up_pending_entry(group, &mine_current, &identity_pq_pair)?; + let pending = fold_in_catch_up(leaf.pending, catch_up); + ( + SessionMigrationGroupKeys { + current: leaf.current, + pending, + }, + no_custody, + ) + } + None => reservation(Some(identity_pq_pair.clone())), + }; + + // recv_classical: the reservation for a pre-join initiator (the + // retained return KeyPackage's key — the same key `identity_kp` + // already picked), otherwise resolved plus Detached signers folded + // by `recv_classical_pending`. + let (recv_classical_leaf, recv_classical_no_custody) = match recv_ref + .map(|recv| &recv.classical) + .zip(recv_classical.as_ref()) + { + Some((group, (_, snapshot_signers, detached))) => { + let (leaf, no_custody) = + resolve_leaf_key(group, &classical_provider, &pool, snapshot_signers)?; + // `leaf.pending` is the one `Snapshot`-placed (framed) entry, + // if any. Kept separate from the `Detached` (window-surviving) + // entries so `recv_classical_pending` can apply its + // framed-wins priority explicitly. + let framed = leaf.pending.first().cloned(); + let mut real_pending = + pending_leaf_keys_from_detached(&classical_provider, detached)?; + dedupe_pending(&mut real_pending); + let pending = recv_classical_pending( + real_pending, + framed.as_ref(), + &inner.staged_candidates, + &classical_provider, + &mine_current, + &identity_classical_pair, + same_id_candidate_key.as_ref(), + recv_classical_catch_up.clone(), + )?; + ( + SessionMigrationGroupKeys { + current: leaf.current, + pending, + }, + no_custody, + ) + } + None => reservation(Some(identity_classical_pair.clone())), + }; + + // recv_pq: the reservation for a pre-A.3 initiator — KP′'s presented + // key, resolved by the same custody search as every other half (it + // may still name a pre-rotation identity, see the module doc). + // Otherwise resolved plus the generalized catch-up. + let (recv_pq_leaf, recv_pq_no_custody) = match recv_ref + .and_then(|recv| recv.pq.as_ref()) + .zip(recv_pq.as_ref()) + { + Some((group, (_, pending))) => { + let (leaf, no_custody) = resolve_leaf_key(group, &pq_provider, &pool, pending)?; + let catch_up = catch_up_pending_entry(group, &mine_current, &identity_pq_pair)?; + let pending = fold_in_catch_up(leaf.pending, catch_up); + ( + SessionMigrationGroupKeys { + current: leaf.current, + pending, + }, + no_custody, + ) + } + None => { + // Every reachable pre-A.3-initiator state has + // `bootstrap_kp_secret` (the twin-field invariant on + // `SessionInner`). + let secret = inner + .bootstrap_kp_secret + .as_ref() + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + let presented = kp_signature_key(&secret.1.key_package_bytes)?; + // A reservation's `current` must never be the no-custody case: + // native's check 3 requires `no_custody` to name only existing + // groups. This lookup should always resolve (KP′'s key is + // minted from the same identity pair this pool carries), but + // fails loudly rather than silently mis-deriving + // `no_custody.recv_pq` should that invariant ever break. + let signing_key = find_custody(&pq_provider, &presented, &pool) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + reservation(Some(SessionMigrationKeyPair { + signing_key, + signature_key: presented, + })) + } + }; + + let leaf_keys = SessionMigrationLeafKeys { + send_classical: send_classical_leaf, + recv_classical: recv_classical_leaf, + send_pq: send_pq_leaf, + recv_pq: recv_pq_leaf, + }; + + // The rotation candidate: the newest staged candidate — `None` when + // its id equals `mine_current` (a self-catch-up mechanism, not a + // rotation target to report; its key still rides `pending` + // regardless). `staged_candidates` non-empty implies `recv_group` + // exists, so the current epoch is always available here. + let rotation_candidate = match inner.staged_candidates.last() { + Some(candidate) if candidate.client_id().bytes != mine_current => { + let (signing_key, signature_key) = candidate_public_key( + &classical_provider, + candidate.combiner().classical_signing_key(), + ) + .ok_or(TwoMlsPqError::ArchiveInvalid)?; + Some(SessionMigrationRotationCandidate { + target_client_id: candidate.client_id().bytes, + signing_key, + signature_key, + proposed_at_recv_epoch: recv_ref + .map(|recv| recv.classical.current_epoch()) + .ok_or(TwoMlsPqError::ArchiveInvalid)?, + }) + } + _ => None, + }; + + // `pq_leaf_custody` (back-compat): narrow, history-window-checked + // gating, best-effort — swallowed to `None` rather than propagated, + // since `leaf_keys` is authoritative and must never be blocked by + // this field's stricter rules. + let pq_leaf_custody = match recv_ref.and_then(|recv| recv.pq.as_ref()) { None => None, Some(pq) => { let presented = own_signature_key(pq)?; @@ -610,31 +1764,58 @@ impl TwoMlsPqSession { None } else if inner.requires_establishment_envelope { let auth_mine_history = inner.with_auth(|core| core.mine.to_parts().0); - Some(leaf_pq_custody( - pq, - &presented, - &identity.client_id, - &auth_mine_history, - )?) + leaf_pq_custody(pq, &presented, &identity.client_id, &auth_mine_history).ok() } else { - // The existing post-rotation leaf-lag refusal for a - // non-dedicated session: no custody slot to fall back to. - return Err(TwoMlsPqError::SessionNotReady); + None } } }; - let send_group = export_group_half( - inner - .send_group - .as_mut() - .ok_or(TwoMlsPqError::SessionNotReady)?, - )?; - let recv_group = inner - .recv_group - .as_mut() - .map(export_group_half) - .transpose()?; + // The deployed-only carry: `Some` whenever any part of it is + // non-empty or true. `own_offer_window_entries` was computed early + // and had its `leaf_secret`s filled in already — not recomputed here. + let pq_wedged = inner.pq_wedged.map(|w| match w { + PqWedge::Bootstrap => SessionMigrationPqWedgeKind::Bootstrap, + PqWedge::Ratchet => SessionMigrationPqWedgeKind::Ratchet, + PqWedge::Rekey => SessionMigrationPqWedgeKind::Rekey, + }); + let own_offers = recv_ref + .filter(|_| !own_offer_window_entries.is_empty()) + .map(|recv| SessionMigrationOwnOfferWindow { + epoch: recv.classical.current_epoch(), + group_id: recv.classical.group_id().to_vec(), + sender_leaf_index: recv.classical.current_member_index(), + offers: own_offer_window_entries, + }); + let deployed_state = if pq_wedged.is_some() + || send_classical_no_custody + || send_pq_no_custody + || recv_classical_no_custody + || recv_pq_no_custody + || own_offers.is_some() + { + Some(SessionMigrationDeployedState { + own_offers, + pq_wedged, + no_custody: SessionMigrationNoCustody { + send_classical: send_classical_no_custody, + send_pq: send_pq_no_custody, + recv_classical: recv_classical_no_custody, + recv_pq: recv_pq_no_custody, + }, + }) + } else { + None + }; + + let send_group = SessionMigrationGroupHalf { + classical: send_classical.0, + pq: send_pq.map(|(bytes, _)| bytes), + }; + let recv_group = recv_classical.map(|(classical, _, _)| SessionMigrationGroupHalf { + classical, + pq: recv_pq.map(|(bytes, _)| bytes), + }); let (auth_mine, auth_theirs) = inner.with_auth(|core| { let seq = |s: &apq::authentication::PartySequence| { @@ -648,13 +1829,12 @@ impl TwoMlsPqSession { (seq(&core.mine), seq(&core.theirs)) }); - // The staged Upd(self) pair is set together on every established-session - // path. Hash-without-message is the §A.1 pre-establishment marker - // (prepare_pre_establishment), which the establishment cutover does NOT - // clear: a prepare-then-never-encrypt initiator can carry it across the - // cutover, and the next `prepare_to_encrypt` overwrites both — so past - // the gates it is stale dead state, dropped rather than exported (the - // native side has no hash-only marker). Message-without-hash is torn. + // The staged Upd(self) pair is set together on every + // established-session path. Hash-without-message is the §A.1 + // pre-establishment marker: a prepare-then-never-encrypt initiator + // can carry it across the establishment cutover (which doesn't clear + // it) as stale dead state, dropped rather than exported. + // Message-without-hash is torn. let pending_proposal = match ( &inner.pending_proposal_hash, &inner.pending_proposal_message, @@ -788,11 +1968,17 @@ impl TwoMlsPqSession { }) }) .transpose()?, - // Always false on an admitted export — the pre-install latch above - // refuses otherwise — kept for native-shape parity. + // A born-dedicated acceptor pre-install can leave this `true` on + // an admitted export — the mint installs the envelope when it + // lands, with custody already resolved from the real I_c/I_pq + // signers (leaf_keys, above). owes_establishment_envelope: inner.requires_establishment_envelope && inner.establishment_envelope.is_none(), pq_leaf_custody, + leaf_keys, + rotation_candidate, + initial_app_payload: inner.initial_app_payload.clone(), + deployed_state, }) } } @@ -838,26 +2024,119 @@ mod tests { assert!(assert_some!(bob_export.recv_group).pq.is_some()); } - /// A pre-establishment initiator (no recv group; the parked §A.1 envelope - /// has no native slot) is refused, not mis-mapped. + /// A pre-establishment initiator with no app payload set still exports: + /// `identity_kp` mints a fresh classical KP, so `classical_init_secret_key` + /// is still populated from it, even though this app never takes this + /// exact path (see the payload-attached test below for the one it does). + #[cfg(feature = "cryptokit")] + #[test] + fn test_migration_export_pre_establishment_bare_initiator() { + let alice = make_client(); + let bob = make_client(); + let bob_kp = make_combiner_kp(&bob); + let session = assert_ok!(super::TwoMlsPqSession::initiate(alice, bob_kp, None)); + let export = assert_ok!(session.migration_export()); + assert!(export.recv_group.is_none()); + assert!(!export.send_group.classical.is_empty()); + assert!(export.send_group.pq.is_some()); + assert!(export.initial_app_payload.is_none()); + assert_some!(export.initial_their_kp.as_ref()); + assert_eq!( + export + .identity + .classical_init_secret_key + .as_ref() + .map(Vec::len), + Some(32) + ); + assert!(export.bootstrap_kp_secret.is_some()); + assert!(export.expected_bootstrap_kp_commitment.is_none()); + // Reservations: recv_classical/recv_pq don't exist yet, but still carry + // a reservation key with empty pending. + assert!(export.leaf_keys.recv_classical.current.is_some()); + assert!(export.leaf_keys.recv_classical.pending.is_empty()); + assert!(export.leaf_keys.recv_pq.current.is_some()); + assert!(export.leaf_keys.recv_pq.pending.is_empty()); + assert!(export.leaf_keys.send_classical.current.is_some()); + } + + /// The real app's shape: `PQSession.swift` mints and retains a return KP + /// before attaching the app payload. Proves `identity_kp` picks that + /// retained KP rather than minting a fresh one, by round-tripping the + /// exact KP bytes. + #[cfg(feature = "cryptokit")] + #[test] + fn test_migration_export_pre_establishment_initiator_with_payload() { + let alice = make_client(); + let bob = make_client(); + let bob_kp = make_combiner_kp(&bob); + let session = assert_ok!(super::TwoMlsPqSession::initiate( + std::sync::Arc::clone(&alice), + bob_kp, + None + )); + // Mirrors `PQSession.swift`'s `createTwoMLSGroup`: mint (and retain) the return + // KP BEFORE attaching the payload. + let return_kp = + assert_ok!(alice.generate_key_package(crate::MlsCipherSuite::x25519_chacha())); + assert_ok!(session.set_initial_app_payload(b"host-signed-establishment".to_vec())); + // A parked pre-establishment app message: every pre-establishment + // `encrypt` re-staples the payload onto a fresh §A.1 envelope. Proves + // the session can still send before establishment with the payload + // attached. + assert_ok!(session.prepare_to_encrypt(None)); + let framed = assert_ok!(session.encrypt(b"hello-before-establishment".to_vec())); + assert!(!framed.cipher_text.is_empty()); + + let export = assert_ok!(session.migration_export()); + assert!(export.recv_group.is_none()); + assert_eq!( + export.initial_app_payload.as_deref(), + Some(b"host-signed-establishment".as_slice()) + ); + // `identity_kp` picked the RETAINED return KP, not a fresh mint — compare bare + // forms (`export.identity.classical_key_package` is bare; `return_kp` is the + // published, MLSMessage-framed form `generate_key_package` returns). + assert_eq!( + export.identity.classical_key_package, + assert_ok!(super::bare_kp(&return_kp)) + ); + assert_eq!( + export + .identity + .classical_init_secret_key + .as_ref() + .map(Vec::len), + Some(32) + ); + assert!(export.bootstrap_kp_secret.is_some()); + assert!(!assert_some!(export.initial_their_kp.as_ref()) + .classical + .is_empty()); + } + + /// `initial_return_kp` is never set by this repo's own wrapper, so it's + /// treated as impossible: setting it directly must fail the export as + /// `ArchiveInvalid`. #[cfg(feature = "cryptokit")] #[test] - fn test_migration_export_refuses_pre_establishment() { + fn test_migration_export_rejects_initial_return_kp_as_impossible() { let alice = make_client(); let bob = make_client(); let bob_kp = make_combiner_kp(&bob); let session = assert_ok!(super::TwoMlsPqSession::initiate(alice, bob_kp, None)); + assert_ok!(session.set_initial_return_key_package(b"bare-classical-kp".to_vec())); assert!(matches!( session.migration_export(), - Err(TwoMlsPqError::SessionNotReady) + Err(TwoMlsPqError::ArchiveInvalid) )); } - /// A fresh acceptor (recv group joined, its send-PQ half still deferred) exports fine - /// with its parked return welcome still UNDRAINED: the app never drains an - /// acceptor's `pending_outbound`, so refusing it would mean no acceptor session ever - /// migrates. The export drops the parked copy; the same welcome still rides - /// `current_staple` until this acceptor's own first send-group commit. + /// A fresh acceptor exports fine with its parked return welcome still + /// undrained: the app never drains `pending_outbound`, so refusing it + /// would mean no acceptor session ever migrates. The export drops the + /// parked copy; it still rides `current_staple` until this acceptor's + /// first send-group commit. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_acceptor_pre_bootstrap() { diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index 540e29c..4fd83cc 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -939,12 +939,12 @@ fn test_sent_candidate_not_evicted() { let (alice, bob) = establish_confirmed_sessions(); let mut ids = Vec::new(); let mut first_frame = None; - // Stage super::CANDIDATE_WINDOW + 2 candidates. Only the first super::CANDIDATE_WINDOW are in + // Stage crate::session::CANDIDATE_WINDOW + 2 candidates. Only the first crate::session::CANDIDATE_WINDOW are in // flight (proposable); the rest defer. - for i in 0..(super::CANDIDATE_WINDOW + 2) { + for i in 0..(crate::session::CANDIDATE_WINDOW + 2) { let id = make_client().client_id(); assert_ok!(alice.stage_rotation(id.bytes.clone())); - if i < super::CANDIDATE_WINDOW { + if i < crate::session::CANDIDATE_WINDOW { assert_ok!(alice.prepare_to_encrypt(Some(id.clone()))); let frame = assert_ok!(alice.encrypt(format!("cand{i}").into_bytes())); if i == 0 { @@ -975,7 +975,7 @@ fn test_deferred_candidate_promoted_next_round() { let (alice, bob) = establish_confirmed_sessions(); // Fill the window and defer one more. let mut ids = Vec::new(); - for _ in 0..(super::CANDIDATE_WINDOW + 1) { + for _ in 0..(crate::session::CANDIDATE_WINDOW + 1) { ids.push(make_client().client_id()); } for id in &ids { @@ -1043,7 +1043,7 @@ fn test_archive_round_trips_deferred_candidate() { let (alice, bob) = establish_confirmed_sessions(); // Fill the window and defer one more. let mut ids = Vec::new(); - for _ in 0..(super::CANDIDATE_WINDOW + 1) { + for _ in 0..(crate::session::CANDIDATE_WINDOW + 1) { ids.push(make_client().client_id()); } for id in &ids { @@ -4519,7 +4519,7 @@ fn test_prepare_to_encrypt_lazily_stages_unstaged_candidate() { fn test_lazy_prepare_respects_candidate_window() { let (alice, _bob) = establish_confirmed_sessions(); // Propose CANDIDATE_WINDOW distinct candidates, none canonicalized — the pool fills. - for i in 0..super::CANDIDATE_WINDOW { + for i in 0..crate::session::CANDIDATE_WINDOW { let id = make_client().client_id(); assert_ok!(alice.prepare_to_encrypt(Some(id))); assert_ok!(alice.encrypt(format!("cand{i}").into_bytes())); @@ -8502,11 +8502,8 @@ fn test_v3_archive_restores_with_empty_attachment_ledgers() { message_round(&restored, &alice, b"after-v3-restore"); } -/// A born-dedicated acceptor's `migration_export` at the moment its recv-classical leaf -/// catches up (before its own first send-group commit, so the staple is still the §26 -/// handoff) and again once the full lifecycle (A.3 bootstrap, bind, an A.4 round each way) -/// has completed — including once more after an archive/restore of Bob, proving the -/// custodied signer survives the ordinary (non-swift) archive round-trip. +/// A born-dedicated acceptor exports at recv-classical catch-up (staple still the §26 +/// handoff) and after the full lifecycle, including across an ordinary archive/restore. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_born_dedicated_converged() { @@ -8558,8 +8555,6 @@ fn test_migration_export_born_dedicated_converged() { let res = assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); let alice_upd = assert_some!(res.proposal); - // Converged, but Bob has not yet committed into his OWN send group: the staple is - // still the §26 handoff frame installed at `install_mock_envelope`. assert_eq!( bob_s.lock().current_staple.first(), Some(&super::frames::ESTABLISHMENT_HANDOFF_TAG), @@ -8573,14 +8568,12 @@ fn test_migration_export_born_dedicated_converged() { invitation_identity ); - // And in Bob's direction: his send group commits Alice's Upd. assert_ok!(bob_s.queue_proposal(alice_upd.digest)); let prep = assert_ok!(bob_s.prepare_to_encrypt(None)); assert!(prep.did_commit); let enc = assert_ok!(bob_s.encrypt(b"full-b".to_vec())); assert_some!(assert_ok!(alice_s.process_incoming(enc.cipher_text))); - // A.3 bootstrap + bind, then an A.4 round each way. let kp = assert_ok!(alice_s.pq_bootstrap_begin(None)); assert_ok!(bob_s.pq_bootstrap_respond(kp)); let welcome = assert_some!(bob_s.pq_take_pending_outbound()); @@ -8596,7 +8589,7 @@ fn test_migration_export_born_dedicated_converged() { assert!(!export.owes_establishment_envelope); let custody = assert_some!(export.pq_leaf_custody); assert_eq!(custody.client_id, invitation_identity); - // The pq pair derives (the same cross-check the mint performs). + // Same derive cross-check the mint performs. let pq_cs = assert_some!( crate::providers::pq().cipher_suite_provider(crate::providers::pq_cipher_suite()) ); @@ -8604,47 +8597,70 @@ fn test_migration_export_born_dedicated_converged() { let derived = assert_ok!(pq_cs.signature_key_derive_public(&signer)); assert_eq!(derived.as_bytes(), custody.pq_signature_key); - // The export also succeeds after an archive→restore of Bob — the restored group's - // signer is not swift-export-only state, so it survives the ordinary archive. + // The signer is not swift-export-only state, so it survives the ordinary archive. let archive = assert_ok!(bob_s.archive()); let restored = assert_ok!(TwoMlsPqSession::from_archive(archive)); let export = assert_ok!(restored.migration_export()); + let legacy_custody = assert_some!(export.pq_leaf_custody); + assert_eq!(legacy_custody.client_id, invitation_identity); + let recv_pq_current = assert_some!(export.leaf_keys.recv_pq.current); assert_eq!( - assert_some!(export.pq_leaf_custody).client_id, - invitation_identity + recv_pq_current.signature_key, + legacy_custody.pq_signature_key ); - // The pre-install latch is what the gate actually checks, not an incidental side - // effect of having gone through installation: erase Bob's installed envelope (as if - // it had never landed) and the export must refuse again, even though every OTHER - // convergence condition still holds. + // With the envelope erased (as if it never landed), the export still succeeds and + // reports it via `owes_establishment_envelope`. bob_s.lock().establishment_envelope = None; - assert!(matches!( - bob_s.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + let pre_install_export = assert_ok!(bob_s.migration_export()); + assert!(pre_install_export.owes_establishment_envelope); } -/// A born-dedicated acceptor pre-install: the establishment envelope has not landed yet, so -/// the export refuses rather than mis-mapping the missing delegation. +/// A born-dedicated acceptor before its establishment envelope lands exports with +/// `owes_establishment_envelope` and custody for both recv halves: Bob joined both under the +/// invitation identity, so the custody search's self-match is the real I_c/I_pq signer. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_born_dedicated_pre_install() { +fn test_migration_export_carries_born_dedicated_pre_install() { let d = crate::test_utils::born_dedicated_pending(); - assert!(matches!( - d.bob.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + let (presented_classical, presented_pq) = { + let inner = d.bob.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + ( + assert_ok!(crate::session::migration::own_signature_key( + &recv.classical + )), + assert_ok!(crate::session::migration::own_signature_key( + recv.pq.as_ref().unwrap() + )), + ) + }; + let export = assert_ok!(d.bob.migration_export()); + assert!(export.owes_establishment_envelope); + // Both halves still present the invitation identity's keys, not the dedicated one's. + assert_ne!(presented_classical, export.identity.signature_key); + assert_ne!(presented_pq, export.identity.pq_signature_key); + + let recv_classical = export.leaf_keys.recv_classical; + let recv_classical_current = assert_some!(recv_classical.current); + assert_eq!(recv_classical_current.signature_key, presented_classical); + + let recv_pq = export.leaf_keys.recv_pq; + let recv_pq_current = assert_some!(recv_pq.current); + assert_eq!(recv_pq_current.signature_key, presented_pq); + + let deployed = export.deployed_state; + if let Some(deployed) = deployed { + assert!(!deployed.no_custody.recv_classical); + assert!(!deployed.no_custody.recv_pq); + } } -/// A born-dedicated acceptor, installed and sent, but whose catch-up Upd the peer has only -/// PROCESSED (paused → approved) and not yet folded: Bob's recv-classical leaf still -/// presents the invitation identity's key, and mls-rs still holds a signer-carrying pending -/// update for it — the custody gate must catch this as `SessionNotReady` before -/// `export_for_swift` ever runs and surfaces it as `Mls`. +/// A born-dedicated acceptor whose catch-up Upd the peer processed but hasn't folded exports +/// mls-rs's signer-carrying pending update in `recv_classical.pending`, targeting the dedicated id. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_born_dedicated_pre_convergence() { +fn test_migration_export_carries_born_dedicated_pre_convergence() { let d = crate::test_utils::born_dedicated_pending(); let envelope = crate::test_utils::install_mock_envelope(&d.bob); assert_ok!(d.bob.prepare_to_encrypt(None)); @@ -8655,39 +8671,60 @@ fn test_migration_export_refuses_born_dedicated_pre_convergence() { &envelope, &d.dedicated, )); - assert!(matches!( - d.bob.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + let export = assert_ok!(d.bob.migration_export()); + assert!(!export.owes_establishment_envelope); + let recv_classical = export.leaf_keys.recv_classical; + // mine.current is the dedicated id here, so the real signer and the generalized + // catch-up are the same key and must collapse to one entry. + let catch_up = recv_classical + .pending + .iter() + .find(|p| p.target == d.dedicated) + .expect("recv_classical.pending must carry the dedicated-id catch-up"); + assert_eq!( + catch_up.key.signature_key, export.identity.signature_key, + "the catch-up entry carries the identity's OWN (dedicated) classical key" + ); + assert_eq!( + recv_classical + .pending + .iter() + .filter(|p| p.target == d.dedicated) + .count(), + 1, + "the real signer and the synthetic catch-up entry must collapse to exactly one" + ); } -/// A staged (in-flight, unfolded) rotation candidate blocks the export. +/// A staged, unfolded rotation candidate is carried as a `recv_classical.pending` entry +/// keyed by the candidate's client id. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_staged_rotation_candidate() { +fn test_migration_export_carries_staged_rotation_candidate() { let (alice, _bob) = establish_confirmed_sessions(); let new_id = make_client().client_id(); - assert_ok!(alice.prepare_to_encrypt(Some(new_id))); - assert!(matches!( - alice.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + assert_ok!(alice.prepare_to_encrypt(Some(new_id.clone()))); + let export = assert_ok!(alice.migration_export()); + let recv_classical = export.leaf_keys.recv_classical; + let pending = recv_classical + .pending + .iter() + .find(|p| p.target == new_id.bytes); + assert_some!(pending); } -/// A staged candidate that is never committed stays in `staged_candidates` indefinitely — -/// canonicalization only prunes the set when a candidate itself is the thing committed, not -/// on any unrelated commit — so a later PLAIN round that the peer folds instead still -/// leaves the export refused. +/// A never-committed staged candidate stays in `staged_candidates` (only committing the +/// candidate itself prunes it), so it is still carried after the peer folds a later plain round. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_superseded_staged_candidate() { +fn test_migration_export_carries_superseded_staged_candidate() { let (alice, bob) = establish_confirmed_sessions(); let new_id = make_client().client_id(); - assert_ok!(alice.prepare_to_encrypt(Some(new_id))); + assert_ok!(alice.prepare_to_encrypt(Some(new_id.clone()))); let rotation = assert_ok!(alice.encrypt(b"rotate".to_vec())); assert_some!(assert_ok!(bob.process_incoming(rotation.cipher_text))); - // Bob never queues/commits the candidate; a later PLAIN round supersedes it instead. + // Bob never commits the candidate — he folds a later plain round instead. assert_ok!(alice.prepare_to_encrypt(None)); let plain = assert_ok!(alice.encrypt(b"plain".to_vec())); let got = assert_some!(assert_ok!(bob.process_incoming(plain.cipher_text))); @@ -8697,65 +8734,73 @@ fn test_migration_export_refuses_superseded_staged_candidate() { let staple = assert_ok!(bob.encrypt(b"fold-plain".to_vec())); assert_some!(assert_ok!(alice.process_incoming(staple.cipher_text))); - assert!(matches!( - alice.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + let export = assert_ok!(alice.migration_export()); + let recv_classical = export.leaf_keys.recv_classical; + assert!(recv_classical + .pending + .iter() + .any(|p| p.target == new_id.bytes)); } -/// Post-rotation leaf lag: the peer's commit canonicalizes the new identity, but the -/// rotated party's own send-group leaf lags until its own next commit. The custody gate -/// must refuse this as `SessionNotReady` — pinning that the gate runs BEFORE -/// `export_group_half`, so a lagging leaf never reaches `export_for_swift` and surfaces as -/// the unrelated `Mls` error instead. +/// After a rotation the send-classical leaf presents the old key until its own next commit: +/// `current` resolves via the leaf's own signer (differs from `identity`, never `no_custody`), +/// and `pending` carries the catch-up at `mine.current` with the identity's own key. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_post_rotation_leaf_lag() { +fn test_migration_export_carries_post_rotation_leaf_lag() { let (alice, bob) = establish_confirmed_sessions(); let new_alice = make_client().client_id(); - rotate_round(&alice, &bob, new_alice); - assert!(matches!( - alice.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + rotate_round(&alice, &bob, new_alice.clone()); + let export = assert_ok!(alice.migration_export()); + let send_classical = export.leaf_keys.send_classical; + let current = assert_some!(send_classical.current); + assert_ne!( + current.signature_key, export.identity.signature_key, + "send-classical genuinely still lags the (already-rotated) identity" + ); + let catch_up = send_classical + .pending + .iter() + .find(|p| p.target == new_alice.bytes) + .expect("send_classical.pending must carry the generalized catch-up at mine.current"); + assert_eq!( + catch_up.key.signature_key, export.identity.signature_key, + "the catch-up entry carries the identity's OWN (current) classical key" + ); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.send_classical); + } } -/// A rotation's catch-up A.5 mid-round (Upd' sent, Commit' produced but not yet applied): -/// the rotated party's send-PQ leaf still lags, same as the plain post-rotation case, and -/// must refuse as `SessionNotReady` even with a genuinely in-flight round. +/// Mid-A.5 on a rotated session (Upd' sent, Commit' produced but unapplied): `pq_inflight` +/// is `RekeyInitiated` and the lagging send-PQ leaf still resolves custody. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_mid_a5_round_on_rotated_session() { +fn test_migration_export_carries_mid_a5_round_on_rotated_session() { let (alice, bob) = establish_full(); - // Flip the turn so Bob is the non-turn-holder rekey initiator (`rekey_to_commit`'s - // precondition), then rotate him and drive the A.5 up to — but not through — his apply. + // `rekey_to_commit` requires Bob to be the non-turn-holder initiator. ratchet_round(&bob, &alice, b"flip"); let new_bob_id = make_client().client_id(); let _responder_commit = rekey_to_commit(&bob, &alice, new_bob_id); + let export = assert_ok!(bob.migration_export()); assert!(matches!( - bob.migration_export(), - Err(TwoMlsPqError::SessionNotReady) + export.pq_inflight, + Some(super::migration::SessionMigrationPqInflight::RekeyInitiated { .. }) )); + let send_pq = export.leaf_keys.send_pq; + assert_some!(send_pq.current); } -/// A ROTATED party (Bob) at rest — no A.5 in flight, nothing owed — whose credential -/// handoff has converged everywhere except his own send-PQ leaf: `rekey_round`'s Upd' -/// moves his recv-PQ mirror (the proposal replaces the proposer) and its own trailing -/// discharge catches up his send-classical/recv-classical, but his send-PQ leaf moves -/// only on a round HE responds to (`own_pq_leaf_signature_keys`'s doc), which hasn't -/// happened. The export must still refuse this settled state (`SessionNotReady`) — only -/// recv.pq has a custody slot; send.pq has none. Named descriptively because a follow-up -/// (W2d) is expected to extend custody to this exact shape (a lagging send-PQ leaf after -/// a rotation). +/// A rotated party at rest, converged everywhere except send-PQ (which moves only on a round +/// it responds to): `send_pq.current` resolves the old PQ key via its own signer. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_send_pq_lag_after_rotation_converges_everything_else() { +fn test_migration_export_carries_send_pq_lag_after_rotation_converges_everything_else() { let (alice, bob) = establish_full(); - // Flip the turn so Bob is the non-turn-holder — `rekey_round`'s precondition for - // driving his own catch-up A.5 as its initiator. + // `rekey_round` requires Bob to be the non-turn-holder initiator. ratchet_round(&bob, &alice, b"flip"); let new_bob_id = make_client().client_id(); - rekey_round(&bob, &alice, new_bob_id); + rekey_round(&bob, &alice, new_bob_id.clone()); assert!(bob.lock().owed_bind.is_none(), "settled, not mid-bind"); assert!(bob.lock().pq_inflight.is_none(), "no A.5 in flight"); @@ -8774,261 +8819,3576 @@ fn test_migration_export_refuses_send_pq_lag_after_rotation_converges_everything "send.pq should still lag — the state under test" ); - assert!(matches!( - bob.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + let export = assert_ok!(bob.migration_export()); + let send_pq_leaf = export.leaf_keys.send_pq; + let current = assert_some!(send_pq_leaf.current); + assert_eq!(current.signature_key, send_pq); + let catch_up = send_pq_leaf + .pending + .iter() + .find(|p| p.target == new_bob_id.bytes) + .expect("send_pq.pending must carry the generalized catch-up at mine.current"); + assert_eq!( + catch_up.key.signature_key, export.identity.pq_signature_key, + "the catch-up entry carries the identity's OWN (current) PQ key" + ); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.send_pq); + } } -/// `leaf_pq_custody`'s history-membership check in isolation: an empty `auth_mine` -/// history can never contain the leaf's own credential, whatever it is, so custody must -/// refuse — even though the leaf's id here genuinely differs from `identity_client_id` -/// (the OTHER check this call could otherwise trip instead, which would mask this one). +/// Two rotations (id0 -> id1 with its A.5, then id1 -> id2 purely classical) leave three live +/// PQ keys: id0 in send_pq.current (moves only when responding to a peer A.5), id1 in +/// recv_pq.current, and id2 only as each PQ group's catch-up `pending[id2]`. #[cfg(feature = "cryptokit")] #[test] -fn test_leaf_pq_custody_refuses_when_history_is_empty() { - let (_alice, bob) = establish_full(); - let inner = bob.lock(); - let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); - let presented = assert_ok!(super::migration::own_signature_key(recv_pq)); - let other_identity = crate::test_utils::test_client_id(); - assert!(matches!( - super::migration::leaf_pq_custody(recv_pq, &presented, &other_identity, &[]), - Err(TwoMlsPqError::SessionNotReady) - )); +fn test_migration_export_two_rotations_carry_three_live_pq_keys() { + let (alice, bob) = establish_full(); + let (send_pq_id0, _recv_pq_id0) = own_pq_leaf_signature_keys(&bob); + + // `rekey_round` requires Bob to be the non-turn-holder. + ratchet_round(&bob, &alice, b"flip1"); + let id1 = make_client().client_id(); + rekey_round(&bob, &alice, id1.clone()); + let (send_pq_after_a5, recv_pq_id1) = own_pq_leaf_signature_keys(&bob); + assert_eq!( + send_pq_after_a5, send_pq_id0, + "send_pq must still lag id0 — it never responded to a peer-opened A.5" + ); + assert_ne!( + recv_pq_id1, send_pq_id0, + "recv_pq must have converged to id1" + ); + + let id2 = make_client().client_id(); + rotate_round(&bob, &alice, id2.clone()); + + let export = assert_ok!(bob.migration_export()); + assert_eq!( + export.identity.pq_signature_key, + { + let inner = bob.lock(); + inner + .client + .combiner() + .pq_signature_keypair() + .1 + .as_bytes() + .to_vec() + }, + "sanity: the identity now reflects id2" + ); + + let send_pq = export.leaf_keys.send_pq; + assert_eq!(assert_some!(send_pq.current).signature_key, send_pq_id0); + let send_catch_up = send_pq + .pending + .iter() + .find(|p| p.target == id2.bytes) + .expect("send_pq.pending must carry pending[id2]"); + assert_eq!( + send_catch_up.key.signature_key, + export.identity.pq_signature_key + ); + + let recv_pq = export.leaf_keys.recv_pq; + assert_eq!(assert_some!(recv_pq.current).signature_key, recv_pq_id1); + let recv_catch_up = recv_pq + .pending + .iter() + .find(|p| p.target == id2.bytes) + .expect("recv_pq.pending must carry pending[id2]"); + assert_eq!( + recv_catch_up.key.signature_key, + export.identity.pq_signature_key + ); + + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.send_pq); + assert!(!deployed.no_custody.recv_pq); + } } -/// `leaf_pq_custody`'s identity-mismatch check in isolation: the leaf's own credential -/// equalling `identity_client_id` refuses even though the history genuinely DOES contain -/// it (the OTHER check this call could otherwise trip instead) — a leaf cannot be both -/// "the identity itself" and "an identity needing custody" at once. +/// A pre-A.3 acceptor (send.pq deferred) reserves `send_pq.current` as the identity's current +/// PQ key — what `pq_bootstrap_respond` will found with — with empty `pending`. #[cfg(feature = "cryptokit")] #[test] -fn test_leaf_pq_custody_refuses_when_leaf_is_the_identity() { - let (_alice, bob) = establish_full(); - let inner = bob.lock(); - let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); - let presented = assert_ok!(super::migration::own_signature_key(recv_pq)); - let own_client_id = assert_ok!(apq::sender_client_id( - recv_pq, - recv_pq.current_member_index() - )); - assert!(matches!( - super::migration::leaf_pq_custody( - recv_pq, - &presented, - &own_client_id, - std::slice::from_ref(&own_client_id) - ), - Err(TwoMlsPqError::SessionNotReady) - )); +fn test_migration_export_send_pq_reservation_for_pre_a3_acceptor() { + let (_alice, bob) = establish_confirmed_sessions(); + assert!( + bob.lock().send_group.as_ref().unwrap().pq.is_none(), + "sanity: bob's send-PQ is still deferred pre-A.3" + ); + let export = assert_ok!(bob.migration_export()); + let send_pq = export.leaf_keys.send_pq; + let current = assert_some!(send_pq.current); + assert_eq!(current.signature_key, export.identity.pq_signature_key); + assert!(send_pq.pending.is_empty()); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.send_pq); + } } -/// A wedged PQ side-band blocks the export: the native archive carries no wedge verdict, -/// so a migrated session would report healthy and deadlock instead. +/// A pre-A.3 initiator (no recv.pq yet) reserves `recv_pq.current` as KP′'s key — absent a +/// rotation, the identity's current PQ key — with empty `pending`. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_pq_wedged() { +fn test_migration_export_recv_pq_reservation_for_pre_a3_initiator() { let (alice, _bob) = establish_confirmed_sessions(); - alice.lock().pq_wedged = Some(super::pq_ops::PqWedge::Bootstrap); - assert!(matches!( - alice.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); + assert!( + alice.lock().recv_group.as_ref().unwrap().pq.is_none(), + "sanity: alice's recv-PQ does not exist yet pre-A.3" + ); + let export = assert_ok!(alice.migration_export()); + let recv_pq = export.leaf_keys.recv_pq; + let current = assert_some!(recv_pq.current); + assert_eq!(current.signature_key, export.identity.pq_signature_key); + assert!(recv_pq.pending.is_empty()); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.recv_pq); + } } -/// A torn bind application blocks the export for the same reason a wedge does — no native -/// slot represents it, and the migrated session must not silently drop the tear. +/// A pre-A.3 initiator that rotates classically leaves KP′ presenting id0's PQ key, so +/// `recv_pq.current` must be id0's (found via send-PQ's unrotated signer), not id1's. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_refuses_bind_apply_broken() { - let (alice, _bob) = establish_confirmed_sessions(); - alice.lock().bind_apply_broken = true; - assert!(matches!( - alice.migration_export(), - Err(TwoMlsPqError::SessionNotReady) - )); +fn test_migration_export_recv_pq_reservation_finds_pre_rotation_key() { + let (alice, bob) = establish_confirmed_sessions(); + assert!(alice.lock().recv_group.as_ref().unwrap().pq.is_none()); + let id0_pq_public = alice + .lock() + .client + .combiner() + .pq_signature_keypair() + .1 + .as_bytes() + .to_vec(); + + let id1 = make_client().client_id(); + rotate_round(&alice, &bob, id1); + assert!( + alice.lock().recv_group.as_ref().unwrap().pq.is_none(), + "still pre-A.3 after a purely classical rotation" + ); + + let export = assert_ok!(alice.migration_export()); + assert_ne!( + export.identity.pq_signature_key, id0_pq_public, + "sanity: the identity's PQ key really did move to id1's" + ); + let recv_pq = export.leaf_keys.recv_pq; + let current = assert_some!(recv_pq.current); + assert_eq!( + current.signature_key, id0_pq_public, + "the reservation must present KP′'s (pre-rotation) key, found via send-PQ's \ + own still-unrotated signer" + ); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.recv_pq); + } } -/// The custody derive check catches a corrupted signer: locate the born-dedicated -/// acceptor's ACTUAL recv-PQ signer bytes inside his own archive (a unique 64-byte hit — -/// the cryptokit raw‖public form `signer_for_swift_export` returns), flip one bit, and -/// restore. The presented public key (read off the group's leaf, untouched by the -/// corruption) no longer matches what the corrupted signer derives to, so the export -/// must refuse as `ArchiveInvalid`, not silently custody an unusable key. +/// With several retained classical KPs (unreachable via this crate's wrapper, which mints one), +/// `identity_kp` must pick the one `initial_app_payload` names, not the first stored. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_rejects_corrupted_custody_signer() { - use crate::key_packages::TwoMlsPqInvitation; - +fn test_identity_kp_prefers_the_retained_kp_named_in_the_initial_app_payload() { let alice = make_client(); let bob = make_client(); - let alice_kp = make_classical_kp(&alice); - let bob_inv = assert_ok!(TwoMlsPqInvitation::restore(assert_ok!( - bob.generate_invitation(true) - ))); + let bob_inv = assert_ok!(crate::key_packages::TwoMlsPqInvitation::restore( + assert_ok!(bob.generate_invitation(true)) + )); let bob_kp = bob_inv.combiner_key_package(); let alice_s = assert_ok!(TwoMlsPqSession::initiate(Arc::clone(&alice), bob_kp, None)); - let opened = assert_ok!(bob_inv.open_establishment(assert_some!(alice_s.pending_outbound()))); - let dedicated = crate::test_utils::test_client_id(); - let bob_s = assert_ok!(bob_inv.receive( - assert_some!(opened.welcome), - alice_kp, - commitment_of(&alice_s), - b"tok".to_vec(), - Some(dedicated.clone()), - None, - None, - )); - let envelope = crate::test_utils::install_mock_envelope(&bob_s); - assert_ok!(bob_s.prepare_to_encrypt(None)); - let enc = assert_ok!(bob_s.encrypt(b"confirm-b".to_vec())); - let res = assert_some!(crate::test_utils::approve_establishment( - &alice_s, - enc.cipher_text, - &envelope, - &dedicated - )); - let bob_upd = assert_some!(res.proposal); - assert_ok!(alice_s.prepare_to_encrypt(None)); - let enc = assert_ok!(alice_s.encrypt(b"confirm-a".to_vec())); - assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); - // Alice folds Bob's catch-up Upd — his recv-classical leaf converges, and his - // recv-PQ leaf's custody becomes exportable (still presenting the invitation key). - assert_ok!(alice_s.queue_proposal(bob_upd.digest)); - let prep = assert_ok!(alice_s.prepare_to_encrypt(None)); - assert!(prep.did_commit); - let enc = assert_ok!(alice_s.encrypt(b"full-a".to_vec())); - assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); - assert_some!(assert_ok!(bob_s.migration_export()).pq_leaf_custody); - let signer_bytes = { - let inner = bob_s.lock(); - inner - .recv_group - .as_ref() - .unwrap() - .pq - .as_ref() - .unwrap() - .signer_for_swift_export() - .as_bytes() - .to_vec() - }; - assert_eq!(signer_bytes.len(), 64, "cryptokit raw‖public form"); + // `identity_kp` compares the store's bare `key_package_bytes`, not + // `generate_classical_key_package`'s MLSMessage-framed return value. + let combiner = alice.combiner(); + let (first_result, first_captured) = combiner + .classical_kp_store() + .capture(|| combiner.generate_classical_key_package()); + assert_ok!(first_result); + let first_kp_bytes = first_captured + .into_iter() + .next() + .expect("the first generate must insert one entry") + .1 + .key_package_bytes; + let (second_result, second_captured) = combiner + .classical_kp_store() + .capture(|| combiner.generate_classical_key_package()); + assert_ok!(second_result); + let second_kp_bytes = second_captured + .into_iter() + .next() + .expect("the second generate must insert one entry") + .1 + .key_package_bytes; + assert_ne!( + first_kp_bytes, second_kp_bytes, + "sanity: two distinct KPs were actually minted" + ); + assert_eq!( + combiner.classical_kp_store().all_entries().len(), + 2, + "sanity: the store now retains both" + ); - let mut archive = assert_ok!(bob_s.archive()).bytes; - let hits: Vec<_> = archive - .windows(signer_bytes.len()) - .enumerate() - .filter(|(_, w)| *w == signer_bytes.as_slice()) - .map(|(i, _)| i) - .collect(); - assert_eq!(hits.len(), 1, "expected a unique hit for the signer bytes"); - archive[hits[0]] ^= 0xFF; + assert_ok!(alice_s.set_initial_app_payload(second_kp_bytes.clone())); - let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { - bytes: archive - })); - assert!(matches!( - restored.migration_export(), - Err(TwoMlsPqError::ArchiveInvalid) - )); + let export = assert_ok!(alice_s.migration_export()); + assert_eq!( + export.identity.classical_key_package, second_kp_bytes, + "identity_kp must pick the retained KP the payload names, not the first in \ + storage order" + ); } -/// A restored session whose CLASSICAL group's stored `GroupContext.cipher_suite` disagrees -/// with the session's declared classical suite must refuse `migration_export` rather than -/// restore silently and let a later crypto operation derive a key through the wrong suite's -/// provider — unrecoverable in the native CryptoKit bridge (see the module note). The -/// classical provider recognizes several suites, so `Group::load` itself does not catch -/// this: build the corrupted row by locating the GroupContext via its own (unique) group id -/// and flipping the suite byte, the way a torn or bit-rotted archive would look. +/// Re-proposing `mine.current` while send-classical lags mints a real same-id candidate K′: +/// `send_classical.pending[mine.current]` stays the identity's catch-up key (no own offers), +/// `recv_classical`'s is K′, and it never exports as `rotation_candidate`. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_rejects_corrupted_classical_suite() { - let (_alice, bob) = establish_confirmed_sessions(); - let send_gid = bob - .lock() - .send_group - .as_ref() - .unwrap() - .classical - .group_id() - .to_vec(); - let mut archive = assert_ok!(bob.archive()).bytes; +fn test_migration_export_same_id_candidate_send_classical_also_lagging() { + let (alice, bob) = establish_confirmed_sessions(); + let id1 = make_client().client_id(); + rotate_round(&alice, &bob, id1.clone()); + assert!( + alice.lock().staged_candidates.is_empty(), + "sanity: the winning candidate is pruned once canonicalized" + ); - let mut pattern = vec![0x00u8, 0x01]; - pattern.extend_from_slice(&u16::from(mls_rs::CipherSuite::CURVE25519_CHACHA).to_be_bytes()); - pattern.push(send_gid.len() as u8); - pattern.extend_from_slice(&send_gid); - // The group id can appear more than once (e.g. a creator's own cached creation - // record) — empirically, the byte-for-byte earliest match is always the live - // GroupContext `Group::load` actually reads; a later one, if any, is inert. - let pos = archive - .windows(pattern.len()) - .position(|w| w == pattern.as_slice()) - .unwrap(); - // 0x0003 (CURVE25519_CHACHA) -> 0x0002 (P256_AES128): still a suite the classical - // provider recognizes, so `Group::load` does not itself catch the corruption. - archive[pos + 3] = 0x02; + // Left unfolded so it stays staged alongside send_classical's lagging catch-up. + assert_ok!(alice.prepare_to_encrypt(Some(id1.clone()))); + assert_ok!(alice.encrypt(b"same-id".to_vec())); + let candidate_key = { + let inner = alice.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; - let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { - bytes: archive - })); + let export = assert_ok!(alice.migration_export()); + let send_entry = export + .leaf_keys + .send_classical + .pending + .iter() + .find(|p| p.target == id1.bytes) + .expect("send_classical must still carry pending[mine.current]"); assert_eq!( - restored - .lock() - .send_group - .as_ref() - .unwrap() - .classical - .cipher_suite(), - mls_rs::CipherSuite::new(2), - "the corrupted suite really does restore — the hazard the suite check guards against" + send_entry.key.signature_key, export.identity.signature_key, + "send_classical has no own offers: only the identity's catch-up key can occupy \ + mine.current" + ); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == id1.bytes) + .expect("recv_classical must carry the candidate's own real offer at mine.current"); + assert_eq!( + recv_entry.key.signature_key, candidate_key, + "recv_classical's ONLY real offer at mine.current is the candidate's own K′" + ); + assert_ne!( + recv_entry.key.signature_key, send_entry.key.signature_key, + "send and recv legitimately disagree at mine.current here" + ); + assert!( + export.rotation_candidate.is_none(), + "a same-id candidate must never export as rotation_candidate" ); - assert!(matches!( - restored.migration_export(), - Err(TwoMlsPqError::ArchiveInvalid) - )); } -/// The PQ-half analogue does NOT reproduce the same hazard in this build: unlike the -/// classical provider (which recognizes several suites), `CryptoKitMlKemProvider` — the PQ -/// half's sole crypto provider — recognizes EXACTLY ML-KEM-768, so `Group::load` itself -/// already refuses any other stored suite value for a PQ group before the suite check in -/// `migration_export` ever runs. There is no PQ-half suite corruption that "restores and -/// lies" the way the classical half's does; this pins that boundary instead of asserting a -/// scenario that cannot occur under this build's single-PQ-suite provider. +/// With nothing lagging, a same-id candidate K′ still rides `recv_classical.pending` (never as +/// `rotation_candidate`), and `send_classical` has no entry at all. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_pq_suite_corruption_fails_at_restore() { - let (_alice, bob) = establish_full(); - let send_gid = bob +fn test_migration_export_same_id_candidate_with_nothing_else_lagging() { + let (alice, _bob) = establish_confirmed_sessions(); + let own_id = alice.lock().client.client_id(); + + assert_ok!(alice.prepare_to_encrypt(Some(own_id.clone()))); + assert_ok!(alice.encrypt(b"same-id-fresh".to_vec())); + let candidate_key = { + let inner = alice.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + let export = assert_ok!(alice.migration_export()); + assert!( + export + .leaf_keys + .send_classical + .pending + .iter() + .all(|p| p.target != own_id.bytes), + "send_classical isn't lagging and never carries a same-id candidate's own key" + ); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == own_id.bytes) + .expect("recv_classical must carry the candidate's own real offer at mine.current"); + assert_eq!(recv_entry.key.signature_key, candidate_key); + assert!( + export.rotation_candidate.is_none(), + "a same-id candidate must never export as rotation_candidate" + ); + + // The "confirm-a" round also left a cached same-key refresh outstanding. A refresh + // must not count as an identity offer, or the window would drop K′ (see + // `own_offer_window`). + let window = assert_some!(assert_some!(export.deployed_state).own_offers); + assert!( + window + .offers + .iter() + .any(|o| super::migration::decoded_update_target(&o.proposal) + .is_some_and(|(_, key)| key == candidate_key)), + "the same-id candidate's real K′ offer must ride the window, matching \ + pending[mine.current]" + ); +} + +/// With real recv_classical offers under both the identity's key and a same-id K′, the +/// identity wins `pending[mine.current]` and the K′ offer is dropped from the window. Uses a +/// born-dedicated acceptor because an identity-signed offer needs a leaf genuinely behind +/// `mine.current` (a same-identity refresh carries no signer). +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_recv_classical_prefers_identity_when_both_real_offers_outstanding() { + let d = crate::test_utils::born_dedicated_pending(); + let (alice, bob) = (d.alice, d.bob); + let envelope = crate::test_utils::install_mock_envelope(&bob); + + // Contract 26's mandatory delegation handoff — must itself name the dedicated id. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(b"handoff".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + + // Recv-classical still presents the invitation identity, so this plain refresh is a + // real credential change: a signer-carrying entry under the identity's own key. + assert_ok!(bob.prepare_to_encrypt(None)); + let identity_offer_bytes = bob .lock() - .send_group + .pending_proposal_message .as_ref() .unwrap() - .pq + .1 + .clone(); + assert_ok!(bob.encrypt(b"identity-catchup".to_vec())); + let identity_ref = { + let inner = bob.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let cs = assert_ok!(crate::providers::classical_envelope_suite()); + let hash = assert_ok!(mls_rs::CipherSuiteProvider::hash( + &cs, + &identity_offer_bytes + )); + assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .find(|e| e.message_hash == hash) + .expect("the identity-signed catch-up must still be cached") + .proposal_ref + }; + + // Encrypting the same-id K′ offer clears `pending_proposal_message`, so neither offer + // is framed at export — both are window candidates. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + assert_ok!(bob.encrypt(b"same-id-candidate".to_vec())); + let candidate_key = { + let inner = bob.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + let export = assert_ok!(bob.migration_export()); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == d.dedicated) + .expect("recv_classical must carry mine.current"); + assert_eq!( + recv_entry.key.signature_key, export.identity.signature_key, + "the identity's key wins when both a real identity offer and a same-id \ + candidate's are outstanding" + ); + + let window = assert_some!(assert_some!(export.deployed_state).own_offers); + assert!( + window.offers.iter().any(|o| o.proposal_ref == identity_ref), + "the identity-signed offer must still ride the window" + ); + for offer in &window.offers { + let (_, key) = assert_some!(super::migration::decoded_update_target(&offer.proposal)); + assert!( + key != candidate_key || offer.proposal_ref == identity_ref, + "no window offer may carry the same-id candidate's own losing key" + ); + } +} + +/// A framed identity catch-up over an at-rest same-id K′ offer wins `pending[mine.current]`, +/// so the K′ offer must leave the window too — native check 6 rejects a window offer for +/// `mine.current` that doesn't match `pending[mine.current]`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_framed_identity_catchup_drops_the_real_same_id_candidate_offer() { + let d = crate::test_utils::born_dedicated_pending(); + let (alice, bob) = (d.alice, d.bob); + let envelope = crate::test_utils::install_mock_envelope(&bob); + + // The mandatory handoff stages the same-id candidate K′; alice never folds it. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(b"handoff".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + let candidate_key = { + let inner = bob.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + // Recv-classical still presents the invitation identity, so this refresh is a real + // identity-signed catch-up — left framed (no `encrypt`). + assert_ok!(bob.prepare_to_encrypt(None)); + + let export = assert_ok!(bob.migration_export()); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == d.dedicated) + .expect("recv_classical must carry mine.current"); + assert_eq!( + recv_entry.key.signature_key, export.identity.signature_key, + "the framed identity-signed catch-up wins mine.current's pending entry" + ); + + if let Some(window) = export + .deployed_state .as_ref() - .unwrap() - .group_id() - .to_vec(); - let mut archive = assert_ok!(bob.archive()).bytes; + .and_then(|d| d.own_offers.as_ref()) + { + for offer in &window.offers { + let (_, key) = assert_some!(super::migration::decoded_update_target(&offer.proposal)); + assert_ne!( + key, candidate_key, + "the real same-id candidate offer must be dropped once the framed \ + identity catch-up wins mine.current" + ); + } + } +} - let pq_suite = crate::providers::pq_cipher_suite(); - let mut pattern = vec![0x00u8, 0x01]; - pattern.extend_from_slice(&u16::from(pq_suite).to_be_bytes()); - pattern.push(send_gid.len() as u8); - pattern.extend_from_slice(&send_gid); - // As above: take the earliest match — the live GroupContext `Group::load` reads. - let pos = archive - .windows(pattern.len()) - .position(|w| w == pattern.as_slice()) - .unwrap(); - archive[pos + 3] ^= 0x01; +/// A framed same-id K′ re-proposal wins `pending[mine.current]` over an at-rest identity +/// catch-up: the framed entry rides the snapshot and can never be dropped, so check 6 +/// requires `pending[mine.current]` to match it. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_framed_same_id_candidate_wins_over_a_real_identity_catchup() { + // Born-dedicated, not a plain rotation: bob's `self.client` is the dedicated id from + // `receive` while recv-classical presents the invitation identity, so a plain refresh + // is already a real catch-up. A rotated party's `self.client` only swaps once the peer + // commits, so `None` there is a same-credential no-op. + let d = crate::test_utils::born_dedicated_pending(); + let (alice, bob) = (d.alice, d.bob); + let envelope = crate::test_utils::install_mock_envelope(&bob); + + // The mandatory handoff stages the same-id candidate K′; alice never folds it. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(b"handoff".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + let candidate_key = { + let inner = bob.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + // A real identity-signed catch-up, left at rest. + assert_ok!(bob.prepare_to_encrypt(None)); + let identity_enc = assert_ok!(bob.encrypt(b"identity-catchup".to_vec())); + assert_ok!(alice.process_incoming(identity_enc.cipher_text)); + + // `admit_candidate` reuses the staged candidate (same K′); this re-proposal stays framed. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + + let export = assert_ok!(bob.migration_export()); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == d.dedicated) + .expect("recv_classical must carry mine.current"); + assert_eq!( + recv_entry.key.signature_key, candidate_key, + "the framed same-id candidate offer wins mine.current's pending entry, even \ + over a real identity-signed catch-up" + ); + assert_ne!( + recv_entry.key.signature_key, export.identity.signature_key, + "confirms the identity's key did NOT win here" + ); + + // Check 6: the framed entry (never optional) must match `pending[mine.current]`. + let staged = export + .staged_updates + .first() + .expect("the framed proposal rides staged_updates"); + let cs = assert_ok!(crate::providers::classical_envelope_suite()); + let hash = assert_ok!(mls_rs::CipherSuiteProvider::hash(&cs, &staged.message)); + let cache_entry = { + let inner = bob.lock(); + assert_ok!(inner + .recv_group + .as_ref() + .unwrap() + .classical + .own_proposals_for_swift_export()) + .into_iter() + .find(|e| e.message_hash == hash) + .expect("the framed proposal must still be cached") + }; + let (staged_id, staged_key) = assert_some!(super::migration::decoded_update_target( + &cache_entry.proposal + )); + assert_eq!(staged_id, d.dedicated); + assert_eq!(staged_key, candidate_key); +} + +/// A framed entry left stale by a peer commit (e.g. the app died between prepare and encrypt; +/// `pending_proposal_message` isn't epoch-scoped but mls-rs's cache is) exports harmlessly: +/// no snapshot placement, no effect on `pending`. Folding offer-1 canonicalizes id1, clearing +/// `staged_candidates` (id2 included) with no lag left, so `pending` ends up empty. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_a_stale_framed_entry_harmlessly() { + let (alice, bob) = establish_confirmed_sessions(); + + let id1 = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(id1.clone()))); + let enc1 = assert_ok!(alice.encrypt(b"offer-1".to_vec())); + let got1 = assert_some!(assert_ok!(bob.process_incoming(enc1.cipher_text))); + let offer1 = assert_some!(got1.proposal); + + // Offer-2 stays framed (no `encrypt`) — this is what goes stale. + let id2 = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(id2.clone()))); + + assert_ok!(bob.queue_proposal(offer1.digest)); + let prepared = assert_ok!(bob.prepare_to_encrypt(None)); + assert!(prepared.did_commit, "bob's fold of offer-1 must commit"); + let commit_frame = assert_ok!(bob.encrypt(b"canonicalize".to_vec())); + + assert_ok!(alice.process_incoming(commit_frame.cipher_text)); + + let export = assert_ok!(alice.migration_export()); + + // The stale bytes must still be carried so native's check-6 tolerance (an + // epoch-checked decode failure is skipped, not rejected) sees them. + assert!( + export.pending_proposal.is_some() || !export.staged_updates.is_empty(), + "the stale prepare must still be carried as bookkeeping, not silently dropped" + ); + + assert!( + export.leaf_keys.recv_classical.pending.is_empty(), + "recv_classical must carry no pending entry once id1 fully canonicalizes and \ + id2's own candidacy is abandoned with it" + ); + assert!( + export + .leaf_keys + .recv_classical + .pending + .iter() + .all(|p| p.target != id2.bytes), + "the stale offer-2 entry must not surface as a pending target either way" + ); + + // Never live in this epoch's cache, so it can't be a detached window member either. + if let Some(window) = export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()) + { + for offer in &window.offers { + let (id, _) = assert_some!(super::migration::decoded_update_target(&offer.proposal)); + assert_ne!( + id, id2.bytes, + "the stale offer-2 entry must not ride the window either" + ); + } + } +} + +/// A signerless plain refresh at rest must not count as the identity offer: K′ still wins +/// `pending[mine.current]`, so its offer must stay in the window (else a restored native +/// session hits a terminal `ownOfferUnavailable` when the peer folds it). +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_window_keeps_same_id_candidate_offers_past_a_plain_refresh_at_rest() { + let (alice, bob) = establish_confirmed_sessions(); + let own_id = alice.lock().client.client_id(); + + assert_ok!(alice.prepare_to_encrypt(Some(own_id.clone()))); + let enc = assert_ok!(alice.encrypt(b"same-id-k-prime".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(enc.cipher_text))); + let candidate_key = { + let inner = alice.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + // The leaf isn't lagging, so this refresh is cached but carries no signer. + assert_ok!(alice.prepare_to_encrypt(None)); + let enc = assert_ok!(alice.encrypt(b"plain-refresh".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(enc.cipher_text))); + + let export = assert_ok!(alice.migration_export()); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == own_id.bytes) + .expect("recv_classical must carry the candidate's own real offer at mine.current"); + assert_eq!( + recv_entry.key.signature_key, candidate_key, + "the same-id candidate's K′ must still win mine.current — the refresh carries \ + no signer and cannot compete" + ); + + let window = assert_some!(assert_some!(export.deployed_state).own_offers); + assert!( + window + .offers + .iter() + .any(|o| super::migration::decoded_update_target(&o.proposal) + .is_some_and(|(_, key)| key == candidate_key)), + "the K′ offer must ride the window, matching pending[mine.current] above — a \ + refresh at rest must never evict it" + ); +} + +/// As above with the plain refresh left framed: a refresh must not set +/// `own_offer_window`'s `framed_mine_current_key` either. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_window_keeps_same_id_candidate_offers_past_a_framed_plain_refresh() { + let (alice, bob) = establish_confirmed_sessions(); + let own_id = alice.lock().client.client_id(); + + assert_ok!(alice.prepare_to_encrypt(Some(own_id.clone()))); + let enc = assert_ok!(alice.encrypt(b"same-id-k-prime".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(enc.cipher_text))); + let candidate_key = { + let inner = alice.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the same-id candidate is staged"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + // Signerless refresh (leaf isn't lagging), left framed. + assert_ok!(alice.prepare_to_encrypt(None)); + + let export = assert_ok!(alice.migration_export()); + let recv_entry = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == own_id.bytes) + .expect("recv_classical must carry the candidate's own real offer at mine.current"); + assert_eq!( + recv_entry.key.signature_key, candidate_key, + "the same-id candidate's K′ must still win mine.current — the FRAMED refresh \ + carries no signer and cannot compete either" + ); + let window = assert_some!(assert_some!(export.deployed_state).own_offers); + assert!( + window + .offers + .iter() + .any(|o| super::migration::decoded_update_target(&o.proposal) + .is_some_and(|(_, key)| key == candidate_key)), + "the K′ offer must ride the window, matching pending[mine.current] above — a \ + FRAMED refresh must never evict it either" + ); +} + +/// `leaf_pq_custody` refuses on an empty `auth_mine` history. The leaf id differs from +/// `identity_client_id` so the identity-mismatch check can't mask this one. +#[cfg(feature = "cryptokit")] +#[test] +fn test_leaf_pq_custody_refuses_when_history_is_empty() { + let (_alice, bob) = establish_full(); + let inner = bob.lock(); + let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); + let presented = assert_ok!(crate::session::migration::own_signature_key(recv_pq)); + let other_identity = crate::test_utils::test_client_id(); assert!(matches!( - TwoMlsPqSession::from_archive(crate::Archive { bytes: archive }), - Err(TwoMlsPqError::Mls) + super::migration::leaf_pq_custody(recv_pq, &presented, &other_identity, &[]), + Err(TwoMlsPqError::SessionNotReady) )); } + +/// `leaf_pq_custody` refuses a leaf whose credential is `identity_client_id`. The history +/// does contain it, so the history-membership check can't mask this one. +#[cfg(feature = "cryptokit")] +#[test] +fn test_leaf_pq_custody_refuses_when_leaf_is_the_identity() { + let (_alice, bob) = establish_full(); + let inner = bob.lock(); + let recv_pq = inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap(); + let presented = assert_ok!(crate::session::migration::own_signature_key(recv_pq)); + let own_client_id = assert_ok!(apq::sender_client_id( + recv_pq, + recv_pq.current_member_index() + )); + assert!(matches!( + super::migration::leaf_pq_custody( + recv_pq, + &presented, + &own_client_id, + std::slice::from_ref(&own_client_id) + ), + Err(TwoMlsPqError::SessionNotReady) + )); +} + +/// A wedged PQ side-band is carried via `deployed_state.pq_wedged`, not refused — classical +/// messaging still works. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_pq_wedged() { + let (alice, _bob) = establish_confirmed_sessions(); + alice.lock().pq_wedged = Some(super::pq_ops::PqWedge::Bootstrap); + let export = assert_ok!(alice.migration_export()); + let deployed = assert_some!(export.deployed_state); + assert!(matches!( + deployed.pq_wedged, + Some(super::migration::SessionMigrationPqWedgeKind::Bootstrap) + )); +} + +/// A torn bind application refuses (the native archive has no torn-receive verdict, so it +/// would deadlock) with retryable `Mls`: `bind_apply_broken` is in-memory only and heals on +/// reload, so the reloaded row can be exported instead. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_refuses_bind_apply_broken() { + let (alice, _bob) = establish_confirmed_sessions(); + alice.lock().bind_apply_broken = true; + assert!(matches!(alice.migration_export(), Err(TwoMlsPqError::Mls))); +} + +/// A corrupted custody signer with no other copy is indistinguishable from a no-custody +/// desync, so the export succeeds with `no_custody.recv_pq` set and legacy +/// `pq_leaf_custody` `None`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_no_custody_on_corrupted_signer() { + use crate::key_packages::TwoMlsPqInvitation; + + let alice = make_client(); + let bob = make_client(); + let alice_kp = make_classical_kp(&alice); + let bob_inv = assert_ok!(TwoMlsPqInvitation::restore(assert_ok!( + bob.generate_invitation(true) + ))); + let bob_kp = bob_inv.combiner_key_package(); + let alice_s = assert_ok!(TwoMlsPqSession::initiate(Arc::clone(&alice), bob_kp, None)); + let opened = assert_ok!(bob_inv.open_establishment(assert_some!(alice_s.pending_outbound()))); + let dedicated = crate::test_utils::test_client_id(); + let bob_s = assert_ok!(bob_inv.receive( + assert_some!(opened.welcome), + alice_kp, + commitment_of(&alice_s), + b"tok".to_vec(), + Some(dedicated.clone()), + None, + None, + )); + let envelope = crate::test_utils::install_mock_envelope(&bob_s); + assert_ok!(bob_s.prepare_to_encrypt(None)); + let enc = assert_ok!(bob_s.encrypt(b"confirm-b".to_vec())); + let res = assert_some!(crate::test_utils::approve_establishment( + &alice_s, + enc.cipher_text, + &envelope, + &dedicated + )); + let bob_upd = assert_some!(res.proposal); + assert_ok!(alice_s.prepare_to_encrypt(None)); + let enc = assert_ok!(alice_s.encrypt(b"confirm-a".to_vec())); + assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + // Folding Bob's catch-up makes his recv-PQ custody exportable (still the invitation key). + assert_ok!(alice_s.queue_proposal(bob_upd.digest)); + let prep = assert_ok!(alice_s.prepare_to_encrypt(None)); + assert!(prep.did_commit); + let enc = assert_ok!(alice_s.encrypt(b"full-a".to_vec())); + assert_some!(assert_ok!(bob_s.process_incoming(enc.cipher_text))); + assert_some!(assert_ok!(bob_s.migration_export()).pq_leaf_custody); + + let signer_bytes = { + let inner = bob_s.lock(); + inner + .recv_group + .as_ref() + .unwrap() + .pq + .as_ref() + .unwrap() + .signer_for_swift_export() + .as_bytes() + .to_vec() + }; + assert_eq!(signer_bytes.len(), 64, "cryptokit raw‖public form"); + + let mut archive = assert_ok!(bob_s.archive()).bytes; + let hits: Vec<_> = archive + .windows(signer_bytes.len()) + .enumerate() + .filter(|(_, w)| *w == signer_bytes.as_slice()) + .map(|(i, _)| i) + .collect(); + assert_eq!(hits.len(), 1, "expected a unique hit for the signer bytes"); + archive[hits[0]] ^= 0xFF; + + let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { + bytes: archive + })); + let export = assert_ok!(restored.migration_export()); + assert!(export.pq_leaf_custody.is_none()); + let recv_pq = export.leaf_keys.recv_pq; + assert!(recv_pq.current.is_none()); + assert!(assert_some!(export.deployed_state).no_custody.recv_pq); +} + +/// Every staged candidate (a full `CANDIDATE_WINDOW`) rides both classical `pending` sets with +/// the identical key; the newest is also `rotation_candidate`, pinned to the current recv epoch. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_multiple_staged_candidates() { + let (alice, _bob) = establish_confirmed_sessions(); + let mut ids = Vec::new(); + for i in 0..crate::session::CANDIDATE_WINDOW { + let id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(id.clone()))); + assert_ok!(alice.encrypt(format!("cand{i}").into_bytes())); + ids.push(id); + } + let export = assert_ok!(alice.migration_export()); + let recv_classical = &export.leaf_keys.recv_classical; + let send_classical = &export.leaf_keys.send_classical; + let recv_epoch = { + let inner = alice.lock(); + inner.recv_group.as_ref().unwrap().classical.current_epoch() + }; + for id in &ids { + let recv_entry = recv_classical + .pending + .iter() + .find(|p| p.target == id.bytes) + .expect("candidate missing from recv_classical"); + let send_entry = send_classical + .pending + .iter() + .find(|p| p.target == id.bytes) + .expect("candidate missing from send_classical"); + assert_eq!( + recv_entry.key.signing_key, send_entry.key.signing_key, + "candidate {:?}'s key must be identical in both classical sets", + id.bytes + ); + } + let newest = ids.last().unwrap(); + let rotation_candidate = assert_some!(export.rotation_candidate); + assert_eq!(rotation_candidate.target_client_id, newest.bytes); + let newest_pending = recv_classical + .pending + .iter() + .find(|p| p.target == newest.bytes) + .unwrap(); + assert_eq!( + rotation_candidate.signing_key, + newest_pending.key.signing_key + ); + assert_eq!(rotation_candidate.proposed_at_recv_epoch, recv_epoch); +} + +/// A `deferred_candidate` (parked while `staged_candidates` is full) is dropped on export — +/// the app re-proposes its persisted `nextProposal` natively — while the live window is carried. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_drops_deferred_candidate() { + let (alice, _bob) = establish_confirmed_sessions(); + let mut ids = Vec::new(); + for i in 0..crate::session::CANDIDATE_WINDOW { + let id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(id.clone()))); + assert_ok!(alice.encrypt(format!("cand{i}").into_bytes())); + ids.push(id); + } + // The window is full: staging (not proposing) one more parks it as deferred. + let deferred = make_client().client_id(); + assert_ok!(alice.stage_rotation(deferred.bytes.clone())); + assert_eq!( + alice.lock().deferred_candidate.as_deref(), + Some(deferred.bytes.as_slice()) + ); + + let export = assert_ok!(alice.migration_export()); + let recv_classical = export.leaf_keys.recv_classical; + assert!( + !recv_classical + .pending + .iter() + .any(|p| p.target == deferred.bytes), + "the deferred candidate must not ride the export" + ); + for id in &ids { + assert!(recv_classical.pending.iter().any(|p| p.target == id.bytes)); + } +} + +/// Rotating D -> N before the A.3 bind leaves recv-PQ presenting D_pq while its signer derives +/// N_pq, so the self-heal A.5 Upd' is rejected and PQ stays `RekeyInitiated` forever. The +/// export carries it: `recv_pq.current` resolves D_pq via send-PQ's signer (a leaf-local +/// desync, not `no_custody`), and classical messaging still works. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_rotate_before_bind_mis_signed_rekey() { + let (alice, bob) = establish_confirmed_sessions(); + + let (_d_pq_secret, d_pq_public) = { + let inner = alice.lock(); + inner.client.combiner().pq_signature_keypair() + }; + + let new_alice = make_client().client_id(); + rotate_round(&alice, &bob, new_alice); + + // `pq_bootstrap_bind` joins recv-PQ under KP' (D) with the ambient signer already N. + let kp = assert_ok!(alice.pq_bootstrap_begin(None)); + assert_ok!(bob.pq_bootstrap_respond(kp)); + let welcome = assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + assert_ok!(alice.pq_bootstrap_bind(welcome)); + discharge_bind(&alice, &bob, b"bootstrap-bind"); + + let recv_presented = { + let inner = alice.lock(); + assert_ok!(crate::session::migration::own_signature_key( + inner.recv_group.as_ref().unwrap().pq.as_ref().unwrap() + )) + }; + assert_eq!( + recv_presented, + d_pq_public.as_bytes(), + "recv-PQ's own leaf presents the pre-rotation identity D (from KP')" + ); + + // Bob's rotate-proposing send incidentally stages a plain A.4 EK; draining it hands + // alice the PQ turn. + let new_bob = make_client().client_id(); + rotate_round(&bob, &alice, new_bob); + assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + ratchet_round(&bob, &alice, b"drain-bob-incidental-a4"); + assert!(alice.my_pq_turn()); + + // With send-PQ lagging, alice's next send self-stages an A.5 Upd' signed by the + // N-deriving signer, which bob verifies against D_pq and rejects. + let self_heal_upd = open_rekey(&alice, &bob); + assert_err!( + bob.pq_rekey_respond(self_heal_upd), + TwoMlsPqError::DecryptionFailed + ); + assert!(matches!( + alice.lock().pq_inflight, + Some(super::pq_ops::PqInflight::RekeyInitiated) + )); + + let export = assert_ok!(alice.migration_export()); + assert!(matches!( + export.pq_inflight, + Some(super::migration::SessionMigrationPqInflight::RekeyInitiated { .. }) + )); + let recv_pq = export.leaf_keys.recv_pq; + let current = assert_some!(recv_pq.current); + assert_eq!(current.signature_key, d_pq_public.as_bytes()); + if let Some(deployed) = export.deployed_state { + assert!(!deployed.no_custody.recv_pq); + } + + assert_ok!(alice.prepare_to_encrypt(None)); + let f = assert_ok!(alice.encrypt(b"post-orphan alice->bob".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(f.cipher_text))); +} + +/// A restored classical group whose stored suite disagrees with the session's must refuse +/// `migration_export` — a wrong-suite derive is unrecoverable in the native bridge, and +/// `Group::load` accepts it because the classical provider recognizes several suites. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_rejects_corrupted_classical_suite() { + let (_alice, bob) = establish_confirmed_sessions(); + let send_gid = bob + .lock() + .send_group + .as_ref() + .unwrap() + .classical + .group_id() + .to_vec(); + let mut archive = assert_ok!(bob.archive()).bytes; + + let mut pattern = vec![0x00u8, 0x01]; + pattern.extend_from_slice(&u16::from(mls_rs::CipherSuite::CURVE25519_CHACHA).to_be_bytes()); + pattern.push(send_gid.len() as u8); + pattern.extend_from_slice(&send_gid); + // The group id can recur (e.g. a creator's cached creation record); the earliest + // match is the live GroupContext `Group::load` reads. + let pos = archive + .windows(pattern.len()) + .position(|w| w == pattern.as_slice()) + .unwrap(); + // 0x0003 (CURVE25519_CHACHA) -> 0x0002 (P256_AES128), still a recognized suite. + archive[pos + 3] = 0x02; + + let restored = assert_ok!(TwoMlsPqSession::from_archive(crate::Archive { + bytes: archive + })); + assert_eq!( + restored + .lock() + .send_group + .as_ref() + .unwrap() + .classical + .cipher_suite(), + mls_rs::CipherSuite::new(2), + "the corrupted suite really does restore — the hazard the suite check guards against" + ); + assert!(matches!( + restored.migration_export(), + Err(TwoMlsPqError::ArchiveInvalid) + )); +} + +/// The PQ provider recognizes only ML-KEM-768, so a corrupted PQ suite already fails at +/// `Group::load` — unlike the classical half, it cannot restore and lie. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_pq_suite_corruption_fails_at_restore() { + let (_alice, bob) = establish_full(); + let send_gid = bob + .lock() + .send_group + .as_ref() + .unwrap() + .pq + .as_ref() + .unwrap() + .group_id() + .to_vec(); + let mut archive = assert_ok!(bob.archive()).bytes; + + let pq_suite = crate::providers::pq_cipher_suite(); + let mut pattern = vec![0x00u8, 0x01]; + pattern.extend_from_slice(&u16::from(pq_suite).to_be_bytes()); + pattern.push(send_gid.len() as u8); + pattern.extend_from_slice(&send_gid); + // As above: take the earliest match — the live GroupContext `Group::load` reads. + let pos = archive + .windows(pattern.len()) + .position(|w| w == pattern.as_slice()) + .unwrap(); + archive[pos + 3] ^= 0x01; + + assert!(matches!( + TwoMlsPqSession::from_archive(crate::Archive { bytes: archive }), + Err(TwoMlsPqError::Mls) + )); +} + +/// After full reverse delivery Bob's `offered_proposal` (overwritten on every receive) is the +/// oldest of `DEPTH` unfolded offers, and it still folds. There is no protocol depth bound; +/// `OWN_OFFER_WINDOW` is a policy margin, not derived from this. +#[cfg(feature = "cryptokit")] +#[test] +fn test_reverse_delivery_folds_a_64_generation_old_offer() { + const DEPTH: usize = 64; + + // Not `_confirmed`: recv-classical must start with zero own-proposals for an exact count. + let (alice, bob) = establish_sessions(); + let mut frames = Vec::with_capacity(DEPTH); + for i in 0..DEPTH { + assert_ok!(alice.prepare_to_encrypt(None)); + let enc = assert_ok!(alice.encrypt(format!("frame{i}").into_bytes())); + frames.push(enc.cipher_text); + } + + let export = assert_ok!(alice.migration_export()); + let deployed = assert_some!(export.deployed_state); + assert_eq!(assert_some!(deployed.own_offers).offers.len(), DEPTH); + + let mut last_offered = None; + for frame in frames.into_iter().rev() { + let got = assert_some!(assert_ok!(bob.process_incoming(frame))); + last_offered = got.proposal.or(last_offered); + } + let offered = assert_some!(last_offered); + assert_ok!(bob.queue_proposal(offered.digest)); + let prepared = assert_ok!(bob.prepare_to_encrypt(None)); + assert!( + prepared.did_commit, + "a DEPTH-generation-old offer still folds" + ); +} + +/// Pins `OWN_OFFER_WINDOW`'s value; the edge/priority tests below exercise the same logic +/// at a small `TEST_CAP` rather than with 102,400-entry fixtures. +#[test] +fn test_own_offer_window_constant_is_102_400() { + assert_eq!(super::migration::OWN_OFFER_WINDOW, 102_400); +} + +/// Runs `own_offer_window` on a session's live cache at an explicit `cap` (the export always +/// uses `OWN_OFFER_WINDOW`), so banding/truncation can be tested at a small scale. +#[cfg(feature = "cryptokit")] +fn own_offer_window_for_test( + session: &Arc, + cap: usize, +) -> Vec { + let classical_provider = assert_ok!(crate::providers::classical_envelope_suite()); + let inner = session.lock(); + let latest_own_offer = inner + .pending_proposal_message + .as_ref() + .map(|(_, m)| m.as_slice()); + let recv = inner.recv_group.as_ref().unwrap(); + let mine_current = inner + .with_auth(|core| core.mine.current().map(<[u8]>::to_vec)) + .expect("auth.mine is always seeded"); + let identity_key = inner.client.combiner().classical_signature_keypair().1; + let (offers, _framed_leaf_key) = assert_ok!(super::migration::own_offer_window( + &recv.classical, + latest_own_offer, + &classical_provider, + cap, + &mine_current, + identity_key.as_bytes(), + None, + )); + offers +} + +/// Exactly `TEST_CAP` own-proposals all survive a window capped at `TEST_CAP`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_carries_at_the_edge() { + const TEST_CAP: usize = 16; + let (alice, _bob) = establish_sessions(); + for i in 0..TEST_CAP { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("frame{i}").into_bytes())); + } + let window = own_offer_window_for_test(&alice, TEST_CAP); + assert_eq!(window.len(), TEST_CAP); +} + +/// One own-proposal past `TEST_CAP` (the accepted wedge risk) drops exactly one generation — +/// the one the deterministic `proposal_ref`-sorted selection excludes. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_drops_past_the_edge() { + const TEST_CAP: usize = 16; + let (alice, _bob) = establish_sessions(); + for i in 0..(TEST_CAP + 1) { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("frame{i}").into_bytes())); + } + + // Expected survivors, computed independently: `proposal_ref`-sorted, capped. + let all_refs: Vec> = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .map(|e| e.proposal_ref) + .collect() + }; + assert_eq!(all_refs.len(), TEST_CAP + 1); + let mut sorted = all_refs.clone(); + sorted.sort(); + let expected: std::collections::BTreeSet> = sorted.into_iter().take(TEST_CAP).collect(); + let dropped = all_refs + .into_iter() + .find(|r| !expected.contains(r)) + .expect("with one more entry than the cap, exactly one must be excluded"); + + let window = own_offer_window_for_test(&alice, TEST_CAP); + assert_eq!(window.len(), TEST_CAP); + let carried: std::collections::BTreeSet> = + window.iter().map(|e| e.proposal_ref.clone()).collect(); + assert_eq!(carried, expected); + assert!( + !carried.contains(&dropped), + "the past-edge generation must be DROPPED, not carried" + ); +} + +/// Past the cap with many refreshes plus a rotation and its re-proposals (the born-dedicated +/// catch-up shape), every presentation-changing offer survives; only refreshes drop. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_keeps_every_handoff_past_the_edge() { + use mls_rs::mls_rs_codec::MlsDecode; + const TEST_CAP: usize = 16; + // Enough refreshes that a naive ref-sort would drop a handoff (asserted below). + const REFRESHES: usize = TEST_CAP * 4; + const HANDOFFS: usize = 4; // the rotation proposal + 3 re-proposals + + let (alice, bob) = establish_sessions(); + let mut frames = Vec::with_capacity(REFRESHES + HANDOFFS); + for i in 0..REFRESHES { + assert_ok!(alice.prepare_to_encrypt(None)); + frames.push(assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())).cipher_text); + } + let new_id = make_client().client_id(); + for i in 0..HANDOFFS { + assert_ok!(alice.prepare_to_encrypt(Some(new_id.clone()))); + frames.push(assert_ok!(alice.encrypt(format!("handoff{i}").into_bytes())).cipher_text); + } + + let handoff_refs: std::collections::BTreeSet> = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .filter_map(|e| { + let proposal = + mls_rs::group::proposal::Proposal::mls_decode(&mut &e.proposal[..]).ok()?; + let mls_rs::group::proposal::Proposal::Update(update) = proposal else { + return None; + }; + let is_handoff = update + .signing_identity() + .credential + .as_basic() + .is_some_and(|b| b.identifier == new_id.bytes); + is_handoff.then_some(e.proposal_ref) + }) + .collect() + }; + assert_eq!( + handoff_refs.len(), + HANDOFFS, + "expected exactly HANDOFFS distinct handoff entries" + ); + + // Precondition: a naive ref-sort-then-truncate must lose a handoff, so the priority + // band is load-bearing here. If this fails, raise `REFRESHES`. + let naive_kept: std::collections::BTreeSet> = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let mut refs: Vec> = assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .map(|e| e.proposal_ref) + .collect(); + refs.sort(); + refs.into_iter().take(TEST_CAP).collect() + }; + assert!( + !handoff_refs.iter().all(|r| naive_kept.contains(r)), + "precondition failed: every handoff ref happened to survive a naive sort anyway — \ + raise REFRESHES so this test actually exercises the priority band" + ); + + let window = own_offer_window_for_test(&alice, TEST_CAP); + assert_eq!(window.len(), TEST_CAP); + let carried: std::collections::BTreeSet> = + window.iter().map(|e| e.proposal_ref.clone()).collect(); + assert!( + handoff_refs.is_subset(&carried), + "every handoff-type offer must survive truncation" + ); + assert_eq!( + carried.len() - handoff_refs.len(), + TEST_CAP - HANDOFFS, + "the remainder of the window is refreshes only" + ); + + // `offered.digest` (sha256 of the MLSMessage) matches a cache entry's `message_hash` + // because this classical suite's hash is sha256. + let mut last_offered = None; + for frame in frames { + let got = assert_some!(assert_ok!(bob.process_incoming(frame))); + last_offered = got.proposal.or(last_offered); + } + let offered = assert_some!(last_offered); + assert_eq!( + offered.proposing.bytes, new_id.bytes, + "bob's last offer is the final handoff" + ); + let would_fold_ref = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .find(|e| e.message_hash == offered.digest) + .map(|e| e.proposal_ref) + }; + let would_fold_ref = assert_some!(would_fold_ref); + assert!( + carried.contains(&would_fold_ref), + "the offer bob would actually fold must be in the carried window" + ); +} + +/// While a prepare is outstanding, the latest offer (`pending_proposal_message`) is never in +/// the window: it is the framed, snapshot-placed entry whose HPKE pair rides the snapshot. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_excludes_the_latest_while_a_prepare_is_outstanding() { + use mls_rs::CipherSuiteProvider; + const TEST_CAP: usize = 16; + let (alice, _bob) = establish_sessions(); + for i in 0..(TEST_CAP + 64) { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("base{i}").into_bytes())); + } + // The latest offer, left framed. + assert_ok!(alice.prepare_to_encrypt(None)); + + let classical_cs = assert_ok!(crate::providers::classical_envelope_suite()); + let (latest_ref, latest_bytes) = { + let inner = alice.lock(); + let latest_bytes = inner.pending_proposal_message.as_ref().unwrap().1.clone(); + let recv = inner.recv_group.as_ref().unwrap(); + let hash = assert_ok!(classical_cs.hash(&latest_bytes)); + let proposal_ref = assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .find(|e| e.message_hash == hash) + .expect("the latest offer must still be cached") + .proposal_ref; + (proposal_ref, latest_bytes) + }; + + let window = own_offer_window_for_test(&alice, TEST_CAP); + assert!( + window.iter().all(|o| o.proposal_ref != latest_ref), + "the framed/latest offer must never appear in the window" + ); + + // `staged_update_leaf_key` (the lookup `place`'s Snapshot routing uses) resolves it. + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + assert_some!(assert_ok!(super::migration::staged_update_leaf_key( + &recv.classical, + Some(&latest_bytes), + &classical_cs, + ))); +} + +/// At rest, `encrypt` has consumed `pending_proposal_message`, so nothing identifies a +/// "latest" offer: nothing is framed and the just-sent offer rides the window with its secret. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_carries_the_just_sent_offer_at_rest() { + use mls_rs::CipherSuiteProvider; + let (alice, _bob) = establish_sessions(); + assert_ok!(alice.prepare_to_encrypt(None)); + let sent_bytes = alice + .lock() + .pending_proposal_message + .as_ref() + .unwrap() + .1 + .clone(); + assert_ok!(alice.encrypt(b"sent".to_vec())); + assert!( + alice.lock().pending_proposal_message.is_none(), + "sanity: encrypt must consume the pending proposal" + ); + + let classical_cs = assert_ok!(crate::providers::classical_envelope_suite()); + let (sent_ref, ground_truth) = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let hash = assert_ok!(classical_cs.hash(&sent_bytes)); + let sent_ref = assert_ok!(recv.classical.own_proposals_for_swift_export()) + .into_iter() + .find(|e| e.message_hash == hash) + .expect("the just-sent offer must still be cached") + .proposal_ref; + let (_, _, detached) = assert_ok!(recv.classical.export_for_swift_placing_pending(|_| { + mls_rs::group::SwiftExportPendingPlacement::Detached + })); + let ground_truth: std::collections::HashMap, Vec> = detached + .into_iter() + .map(|d| (d.leaf_public_key, d.secret.to_vec())) + .collect(); + (sent_ref, ground_truth) + }; + + let export = assert_ok!(alice.migration_export()); + assert!( + export.staged_updates.is_empty(), + "at rest, nothing frames as the latest own offer" + ); + assert!(export.pending_proposal.is_none()); + + let deployed = assert_some!(export.deployed_state); + let own_offers = assert_some!(deployed.own_offers); + let sent_entry = own_offers + .offers + .iter() + .find(|o| o.proposal_ref == sent_ref) + .expect("the just-sent offer must be carried in the window at rest"); + let leaf_key = assert_some!(super::migration::update_leaf_public_key( + &sent_entry.proposal + )); + assert_eq!( + Some(&sent_entry.leaf_secret), + ground_truth.get(&leaf_key), + "the just-sent offer's carried secret must match its own leaf key's secret" + ); +} + +/// Measures an own-offer entry's carried size (ref + encoded Update; sender index, epoch and +/// group id are hoisted onto the window), the figure `OWN_OFFER_WINDOW`'s doc estimates from. +/// The range catches drift without pinning mls-rs's exact encoding. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_entry_size_estimate() { + let (alice, _bob) = establish_sessions(); + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(b"measure".to_vec())); + + let window = own_offer_window_for_test(&alice, 16); + let entry = window + .first() + .expect("at least one own-proposal must be cached"); + let measured = entry.proposal_ref.len() + entry.proposal.len(); + assert!( + (100..2000).contains(&measured), + "measured own-proposal entry size {measured} bytes is outside the expected \ + range — update OWN_OFFER_WINDOW's doc comment with the new per-entry estimate \ + and worst-case cap size" + ); + println!( + "own-offer-window entry size: {measured} bytes measured; worst case at cap {} \ + is ~{} MB", + super::migration::OWN_OFFER_WINDOW, + entry_bytes_to_mb(measured, super::migration::OWN_OFFER_WINDOW) + ); +} + +#[cfg(feature = "cryptokit")] +fn entry_bytes_to_mb(entry_bytes: usize, cap: usize) -> f64 { + (entry_bytes * cap) as f64 / 1_000_000.0 +} + +/// Asserts window order by position: index 0 is band 2 (presentation-changing, proposing +/// `new_id`) and every band-2 entry precedes every band-3 refresh. The framed band-1 entry +/// is excluded from the window since its HPKE pair rides the snapshot. +#[cfg(feature = "cryptokit")] +fn assert_own_offer_window_order( + alice: &Arc, + new_id: &crate::ClientId, + cap: usize, +) { + use mls_rs::mls_rs_codec::MlsDecode; + + let window = own_offer_window_for_test(alice, cap); + assert!(!window.is_empty()); + + let is_band2 = |proposal: &[u8]| -> bool { + let Ok(proposal) = mls_rs::group::proposal::Proposal::mls_decode(&mut &proposal[..]) else { + return false; + }; + let mls_rs::group::proposal::Proposal::Update(update) = proposal else { + return false; + }; + update + .signing_identity() + .credential + .as_basic() + .is_some_and(|b| b.identifier == new_id.bytes) + }; + assert!( + is_band2(&window[0].proposal), + "index 0 must be a band-2 (presentation-changing) entry, not a refresh" + ); + + let mut last_band2_index = None; + let mut first_band3_index = None; + for (i, entry) in window.iter().enumerate() { + if is_band2(&entry.proposal) { + last_band2_index = Some(i); + } else if first_band3_index.is_none() { + first_band3_index = Some(i); + } + } + if let (Some(last2), Some(first3)) = (last_band2_index, first_band3_index) { + assert!( + last2 < first3, + "every band-2 entry ({last2} last seen) must precede every surviving \ + band-3 entry ({first3} first seen)" + ); + } +} + +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_order_band2_then_band3() { + const TEST_CAP: usize = 32; + let (alice, _bob) = establish_sessions(); + for i in 0..4 { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())); + } + let new_id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(new_id.clone()))); + assert_ok!(alice.encrypt(b"rotate".to_vec())); + for i in 4..8 { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())); + } + + assert_own_offer_window_order(&alice, &new_id, TEST_CAP); +} + +/// The band order still holds when a tiny cap truncates most band-3 refreshes. +#[cfg(feature = "cryptokit")] +#[test] +fn test_own_offer_window_order_holds_past_the_edge() { + const TEST_CAP: usize = 3; + let (alice, _bob) = establish_sessions(); + for i in 0..8 { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())); + } + let new_id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(new_id.clone()))); + assert_ok!(alice.encrypt(b"rotate".to_vec())); + for i in 8..16 { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())); + } + + let window = own_offer_window_for_test(&alice, TEST_CAP); + assert_eq!(window.len(), TEST_CAP, "truncated to the tiny cap"); + assert_own_offer_window_order(&alice, &new_id, TEST_CAP); +} + +/// Public keys of a format-2 CBOR snapshot's first membership's `pending_updates` +/// (spec/snapshot.md §4.1.2 key 1), or empty if that key is absent. +#[cfg(feature = "cryptokit")] +fn snapshot_recv_pending_update_keys(snapshot_bytes: &[u8]) -> Vec> { + use ciborium::Value; + fn find(map: &[(Value, Value)], k: u64) -> Option<&Value> { + map.iter() + .find(|(key, _)| key == &Value::from(k)) + .map(|(_, v)| v) + } + let value: Value = ciborium::from_reader(snapshot_bytes).expect("valid CBOR snapshot"); + let top = value.into_map().expect("top level is a map"); + let memberships = find(&top, 2) + .expect("memberships (key 2) present") + .as_map() + .expect("memberships is a map"); + let membership = memberships[0] + .1 + .as_map() + .expect("membership entry is a map"); + let Some(pending) = find(membership, 1) else { + return Vec::new(); + }; + let pending = pending.as_map().expect("pending_updates is a map"); + pending + .iter() + .map(|(_, entry)| { + let entry = entry.as_map().expect("PendingUpdateEntry is a map"); + find(entry, 0) + .expect("public_key (key 0) present") + .as_bytes() + .expect("public_key is bytes") + .clone() + }) + .collect() +} + +/// Recv-classical's snapshot carries only the framed entry; every other own-proposal is +/// placed `Detached` or `Omit`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_placement_snapshot_pending_holds_only_the_framed_key() { + let (alice, _bob) = establish_sessions(); + for i in 0..5 { + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(format!("refresh{i}").into_bytes())); + } + // Left framed. + assert_ok!(alice.prepare_to_encrypt(None)); + + let framed_key = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let latest = inner + .pending_proposal_message + .as_ref() + .map(|(_, m)| m.as_slice()); + let classical_provider = assert_ok!(crate::providers::classical_envelope_suite()); + assert_some!(assert_ok!(super::migration::staged_update_leaf_key( + &recv.classical, + latest, + &classical_provider, + ))) + }; + + let export = assert_ok!(alice.migration_export()); + let recv_group = assert_some!(export.recv_group); + let snapshot_keys = snapshot_recv_pending_update_keys(&recv_group.classical); + assert_eq!( + snapshot_keys, + vec![framed_key], + "the snapshot must carry exactly the one framed key, not the other refreshes" + ); +} + +/// Each window offer's `leaf_secret` is its own leaf key's secret, per an independent +/// `export_for_swift_placing_pending(|_| Detached)` call; the framed entry is never in the window. +#[cfg(feature = "cryptokit")] +#[test] +fn test_placement_window_offers_carry_matching_leaf_secrets() { + let (alice, _bob) = establish_sessions(); + let new_id = make_client().client_id(); + assert_ok!(alice.prepare_to_encrypt(Some(new_id))); + assert_ok!(alice.encrypt(b"rotate".to_vec())); + assert_ok!(alice.prepare_to_encrypt(None)); + assert_ok!(alice.encrypt(b"refresh".to_vec())); + // Left framed. + assert_ok!(alice.prepare_to_encrypt(None)); + + let (ground_truth, framed_key) = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let (_, _, detached) = assert_ok!(recv.classical.export_for_swift_placing_pending(|_| { + mls_rs::group::SwiftExportPendingPlacement::Detached + })); + let ground_truth: std::collections::HashMap, Vec> = detached + .into_iter() + .map(|d| (d.leaf_public_key, d.secret.to_vec())) + .collect(); + let latest = inner.pending_proposal_message.as_ref().unwrap().1.clone(); + let classical_provider = assert_ok!(crate::providers::classical_envelope_suite()); + let framed_key = assert_some!(assert_ok!(super::migration::staged_update_leaf_key( + &recv.classical, + Some(&latest), + &classical_provider, + ))); + (ground_truth, framed_key) + }; + + let export = assert_ok!(alice.migration_export()); + let deployed = assert_some!(export.deployed_state); + let own_offers = assert_some!(deployed.own_offers); + assert!( + own_offers.offers.len() >= 2, + "need the rotation and the refresh entries in the window" + ); + + for entry in &own_offers.offers { + let leaf_key = assert_some!(super::migration::update_leaf_public_key(&entry.proposal)); + assert_ne!( + leaf_key, framed_key, + "the framed entry's ref must never appear in the window" + ); + assert_eq!( + Some(&entry.leaf_secret), + ground_truth.get(&leaf_key), + "a window offer's secret must match ITS OWN leaf key's secret" + ); + } +} + +/// A leaf key placed `Omit` (cache entry cleared, secret still in `pending_updates`) appears in +/// neither snapshot nor window. A plain refresh is used because a rotation target would still +/// reach `recv_classical.pending` via `staged_candidates`. +#[cfg(feature = "cryptokit")] +#[test] +fn test_placement_omit_key_appears_nowhere() { + let (alice, _bob) = establish_sessions(); + assert_ok!(alice.prepare_to_encrypt(None)); + + let orphan_key = { + let inner = alice.lock(); + let recv = inner.recv_group.as_ref().unwrap(); + let latest = inner + .pending_proposal_message + .as_ref() + .map(|(_, m)| m.as_slice()); + let classical_provider = assert_ok!(crate::providers::classical_envelope_suite()); + assert_some!(assert_ok!(super::migration::staged_update_leaf_key( + &recv.classical, + latest, + &classical_provider, + ))) + }; + // Clearing the cache directly (bypassing this crate's bookkeeping) leaves the key + // unfindable by both `own_offer_window` and `staged_update_leaf_key`. + { + let mut inner = alice.lock(); + inner + .recv_group + .as_mut() + .unwrap() + .classical + .clear_proposal_cache(); + } + + let export = assert_ok!(alice.migration_export()); + + let window_leaf_keys: Vec> = export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()) + .map(|w| { + w.offers + .iter() + .filter_map(|o| super::migration::update_leaf_public_key(&o.proposal)) + .collect() + }) + .unwrap_or_default(); + assert!( + !window_leaf_keys.contains(&orphan_key), + "the orphaned key must not appear in the window" + ); + let recv_group = assert_some!(export.recv_group); + let snapshot_keys = snapshot_recv_pending_update_keys(&recv_group.classical); + assert!( + !snapshot_keys.contains(&orphan_key), + "the orphaned key must not appear in the snapshot" + ); +} + +/// Recv-PQ still exports via `export_for_swift_with_pending_signers`, so a parked, uncommitted +/// A.5 Upd′ survives as a `recv_pq` pending entry. +#[cfg(feature = "cryptokit")] +#[test] +fn test_placement_parked_a5_upd_survives_in_recv_pq() { + let (alice, bob) = establish_full(); + let new_alice = make_client().client_id(); + rotate_round(&alice, &bob, new_alice.clone()); + let ek = assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + assert_ok!(alice.pq_ratchet_respond(ek)); + let ct = assert_some!(alice.pq_take_pending_outbound()); + assert_ok!(bob.pq_ratchet_bind(ct)); + discharge_bind(&bob, &alice, b"deferral"); + + let upd = open_rekey(&alice, &bob); + // The Upd' announces alice's identity, so it parks in HER recv-PQ (the group bob + // commits), uncommitted until the round's closing ack. + assert_eq!( + assert_ok!(bob.pq_rekey_respond(upd)), + Some(new_alice.clone()) + ); + + let export = assert_ok!(alice.migration_export()); + let recv_pq = export.leaf_keys.recv_pq; + assert!( + !recv_pq.pending.is_empty(), + "the parked A.5 Upd' must survive as a recv-PQ pending entry" + ); + assert!( + recv_pq.pending.iter().any(|p| p.target == new_alice.bytes), + "the parked entry must name the announced identity" + ); +} + +/// A parked A.5 Upd' whose target later leaves `auth.mine.history` is exported as-is: PQ +/// pending may target a historical id, and Rust never re-mints it. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_parked_a5_upd_with_historical_target_past_the_window() { + let (alice, bob) = establish_full(); + let new_alice = make_client().client_id(); + rotate_round(&alice, &bob, new_alice.clone()); + let ek = assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + assert_ok!(alice.pq_ratchet_respond(ek)); + let ct = assert_some!(alice.pq_take_pending_outbound()); + assert_ok!(bob.pq_ratchet_bind(ct)); + discharge_bind(&bob, &alice, b"deferral"); + + let upd = open_rekey(&alice, &bob); + assert_eq!( + assert_ok!(bob.pq_rekey_respond(upd)), + Some(new_alice.clone()) + ); + + for _ in 0..(apq::authentication::CREDENTIAL_HISTORY_WINDOW + 1) { + let next = make_client().client_id(); + rotate_round(&alice, &bob, next); + } + assert!( + !alice + .lock() + .with_auth(|core| core.mine.contains(&new_alice.bytes)), + "sanity: new_alice must actually be evicted from history by now" + ); + + let export = assert_ok!(alice.migration_export()); + let recv_pq = export.leaf_keys.recv_pq; + let historical = recv_pq + .pending + .iter() + .find(|p| p.target == new_alice.bytes) + .expect("the parked entry must survive, target unchanged, past the history window"); + assert!(!historical.key.signature_key.is_empty()); +} + +/// A leaf whose secret exists nowhere in the custody pool is carried as `no_custody.`, +/// reproduced deterministically on `recv_pq`. +/// +/// `pq_bootstrap_bind` joins recv-PQ via `Group::join_with`, which installs the client's signer +/// (D, same as send-PQ's from `initiate`) without checking the tree's credential for the leaf. +/// Alice's later `pq_rekey_respond` catch-up moves send-PQ's signer to her rotated identity, +/// dropping the last copy of D. Had D also left `CREDENTIAL_HISTORY_WINDOW` by join time, the +/// join would refuse loudly with `Mls` instead. Reaching this needs two bob rotations and one +/// alice rotation before her A.3 bind (1-for-1 doesn't), then one bob send to auto-stage his +/// leaf-lag A.5. +#[cfg(feature = "cryptokit")] +#[test] +fn test_migration_export_carries_no_custody_from_an_unchecked_join_signer() { + let (alice, bob) = establish_confirmed_sessions(); + + // D: send-PQ's signer from `initiate`, unchanged until the handoff below. + let d_signing_key = assert_ok!(alice.migration_export()) + .leaf_keys + .send_pq + .current + .expect("send_pq exists from `initiate`") + .signing_key; + + let kp = assert_ok!(alice.pq_bootstrap_begin(None)); + assert_ok!(bob.pq_bootstrap_respond(kp)); + let new_bob1 = make_client().client_id(); + rotate_round(&bob, &alice, new_bob1); + let new_alice1 = make_client().client_id(); + rotate_round(&alice, &bob, new_alice1); + let new_bob_extra = make_client().client_id(); + rotate_round(&bob, &alice, new_bob_extra); + + let welcome = assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + assert_ok!(alice.pq_bootstrap_bind(welcome)); + discharge_bind(&alice, &bob, b"bind-discharge"); + + // The unchecked join installed D on recv_pq too. + let export = assert_ok!(alice.migration_export()); + let recv_pq_at_bind = export + .leaf_keys + .recv_pq + .current + .as_ref() + .expect("recv_pq resolves right after the bind"); + assert_eq!( + recv_pq_at_bind.signing_key, d_signing_key, + "recv_pq's join-installed signer must still be D right after the bind" + ); + let send_pq_at_bind = export + .leaf_keys + .send_pq + .current + .as_ref() + .expect("send_pq resolves right after the bind"); + assert_eq!( + send_pq_at_bind.signing_key, d_signing_key, + "send_pq's signer is still D right after the bind — untouched since `initiate`" + ); + + // Bob's lagging send-PQ auto-stages an A.5; alice's `pq_rekey_respond` then moves her + // send-PQ signer off D. + assert_ok!(bob.prepare_to_encrypt(None)); + let enc = assert_ok!(bob.encrypt(b"post-bind".to_vec())); + assert_ok!(alice.process_incoming(enc.cipher_text)); + for _ in 0..8 { + let mut delivered = false; + for (from, to) in [(&alice, &bob), (&bob, &alice)] { + let Some(leg) = from.pq_pending_outbound(SideBandSealing::Fresh) else { + continue; + }; + let Ok(Some(opened)) = to.open_incoming(leg.clone()) else { + continue; + }; + let crate::session::OpenedFrameKind::PqSideBand { kind } = opened.kind else { + continue; + }; + let _: crate::Result<()> = match kind { + crate::session::PqFrameKind::BootstrapKeyPackage => to.pq_bootstrap_respond(leg), + crate::session::PqFrameKind::BootstrapWelcome => to.pq_bootstrap_bind(leg), + crate::session::PqFrameKind::RatchetEphemeralKey => to.pq_ratchet_respond(leg), + crate::session::PqFrameKind::RatchetCiphertext => to.pq_ratchet_bind(leg), + crate::session::PqFrameKind::RekeyUpdate => to.pq_rekey_respond(leg).map(|_| ()), + crate::session::PqFrameKind::RekeyCommit => to.pq_rekey_apply(leg), + }; + delivered = true; + } + if !delivered { + break; + } + } + + let export = assert_ok!(alice.migration_export()); + let deployed = assert_some!(export.deployed_state); + assert!( + deployed.no_custody.recv_pq, + "expected recv_pq no_custody once the handoff drops the last copy of D" + ); + assert!(!deployed.no_custody.send_classical); + assert!(!deployed.no_custody.send_pq); + assert!(!deployed.no_custody.recv_classical); + assert!(export.leaf_keys.recv_pq.current.is_none()); + let send_pq_after = export + .leaf_keys + .send_pq + .current + .as_ref() + .expect("send_pq still resolves after the handoff"); + assert_ne!( + send_pq_after.signing_key, d_signing_key, + "the handoff must have moved send_pq's signer away from D" + ); + assert!(export.leaf_keys.send_classical.current.is_some()); +} + +/// Seeded, bounded random walk over app-legal operations on a Rust pair, asserting invariants +/// on both sides every step (`assert_invariants`). `process_incoming` must never fail; any +/// other op's failure must match a named expected refusal (`Coverage::record`). +/// +/// Modes: CARD (routine traffic, PQ only via the leaf-lag auto-driver), ANCHOR (plus a full +/// side-band drain every step), MIXED (each side-band leg attempted with probability 1/2 — +/// intermittent delivery and out-of-turn resends), CARD-A3-STALLED (A.3 registered, side-band +/// never touched) and the A3-LATE family (stalled, then binds Welcome′; see `Mode`). +/// Separate walks start from each born-dedicated state. Excluded because the app never calls +/// them: `pq_take_pending_outbound`, `stage_rotation`. +#[cfg(feature = "cryptokit")] +mod totality_random_walk { + use super::*; + use mls_rs::CipherSuiteProvider; + + /// splitmix64: deterministic per seed without a `rand` dependency. + struct Rng(u64); + impl Rng { + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + } + /// Uniform in `0..bound` (bound > 0). + fn below(&mut self, bound: u32) -> u32 { + (self.next_u64() % u64::from(bound)) as u32 + } + } + + /// One op's named, legitimate refusal kind and the predicate matching it. + type RefusalPredicate = (&'static str, fn(&TwoMlsPqError) -> bool); + + /// isNewClient: the host queues an offer only when `proposing != sender`. Bind + /// discharges are the one place a same-identity offer is folded (they skip this filter). + fn handoff_fold_if_legal( + receiver: &Arc, + offered: &crate::QueuedRemoteProposal, + ) { + if offered.proposing.bytes != offered.sender.bytes { + let _ = receiver.queue_proposal(offered.digest.clone()); + } + } + + /// Outcome ledger: `attempted`/`ok` count ops (not steps), `refusals` counts each + /// expected-refusal kind seen. Unexpected failures panic in `record` instead. + #[derive(Default)] + struct Coverage { + attempted: u32, + ok: u32, + refusals: std::collections::BTreeMap<&'static str, u32>, + // Success-only op counters. + sends: u32, + rotations: u32, + restores: u32, + reorders: u32, + legs_delivered: u32, + saw_rekey_initiated: bool, + saw_owed_bind: bool, + /// A3-LATE modes: a rotation of alice's identity committed before the Welcome′ bind, + /// so KP′ names her old identity. Under A3-LATE that identity is usually also out of + /// history, so the bind refuses (`rotate_before_bind_desync`); under A3-LATE-EARLY it + /// more often joins with a mismatched signer (`no_custody.recv_pq`). + saw_rotate_before_bind: bool, + /// A3-LATE-EARLY: an export actually carried `no_custody.recv_pq` (beyond the + /// precondition `saw_rotate_before_bind` records). + saw_recv_pq_no_custody: bool, + } + + impl Coverage { + /// An `Err` matching `expected` is counted as that named refusal and returns `None`; + /// any other `Err` panics with full context. + fn record( + &mut self, + op: &str, + label: &str, + seed: u64, + step: usize, + result: crate::Result, + expected: &[RefusalPredicate], + ) -> Option { + self.attempted += 1; + match result { + Ok(v) => { + self.ok += 1; + Some(v) + } + Err(e) => match expected.iter().find(|(_, matches)| matches(&e)) { + Some((name, _)) => { + *self.refusals.entry(name).or_insert(0) += 1; + None + } + None => unreachable!( + "{label} seed {seed} step {step}: unexpected {op} failure: {e:?}" + ), + }, + } + } + + fn merge(&mut self, other: Coverage) { + self.attempted += other.attempted; + self.ok += other.ok; + for (k, v) in other.refusals { + *self.refusals.entry(k).or_insert(0) += v; + } + self.sends += other.sends; + self.rotations += other.rotations; + self.restores += other.restores; + self.reorders += other.reorders; + self.legs_delivered += other.legs_delivered; + self.saw_rekey_initiated |= other.saw_rekey_initiated; + self.saw_owed_bind |= other.saw_owed_bind; + self.saw_rotate_before_bind |= other.saw_rotate_before_bind; + self.saw_recv_pq_no_custody |= other.saw_recv_pq_no_custody; + } + } + + /// Delivers a frame; `process_incoming` has no legitimate refusal here since every + /// frame is one the pair itself generated. + fn deliver( + to: &Arc, + label: &str, + seed: u64, + step: usize, + frame: Vec, + ) -> Option { + let result = to.process_incoming(frame); + assert!( + result.is_ok(), + "{label} seed {seed} step {step}: process_incoming must never fail: {:?}", + result.as_ref().err() + ); + result.unwrap().and_then(|got| got.proposal) + } + + /// Sends `from` -> `to` under the handoff fold policy; returns whether prepare, encrypt + /// and delivery all succeeded. A rotating send's only expected refusal is + /// `SessionNotReady` with a full `CANDIDATE_WINDOW`; a plain send has none. + fn step_send( + cov: &mut Coverage, + ctx: (&str, u64, usize), + from: &Arc, + to: &Arc, + payload: &[u8], + rotate: bool, + ) -> bool { + let (label, seed, step) = ctx; + let proposing = if rotate { + // Occasionally propose `mine.current()` itself: `admit_candidate` still mints + // a fresh key, a reachable same-id candidate. Keyed on `step`, not `rng`. + if step % 9 == 0 { + from.lock() + .with_auth(|core| core.mine.current().map(<[u8]>::to_vec)) + .map(|bytes| crate::ClientId { bytes }) + } else { + Some(make_client().client_id()) + } + } else { + None + }; + let rotate_expected: &[RefusalPredicate] = + &[("candidate_window_full", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::SessionNotReady) + })]; + let expected: &[RefusalPredicate] = if rotate { rotate_expected } else { &[] }; + let op = if rotate { + "rotate_prepare" + } else { + "plain_prepare" + }; + let Some(_) = cov.record( + op, + label, + seed, + step, + from.prepare_to_encrypt(proposing), + expected, + ) else { + return false; + }; + let Some(enc) = cov.record( + "encrypt", + label, + seed, + step, + from.encrypt(payload.to_vec()), + &[], + ) else { + return false; + }; + if let Some(offered) = deliver(to, label, seed, step, enc.cipher_text) { + handoff_fold_if_legal(to, &offered); + } + if rotate { + cov.rotations += 1; + } else { + cov.sends += 1; + } + true + } + + /// Two plain sends delivered in reverse. mls-rs tolerates bounded out-of-order + /// application messages within an epoch, and neither send proposes, so the epoch holds. + fn step_send_reordered( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + from: &Arc, + to: &Arc, + tag: &str, + ) -> bool { + let Some(_) = cov.record( + "reorder_prepare", + label, + seed, + step, + from.prepare_to_encrypt(None), + &[], + ) else { + return false; + }; + let Some(first) = cov.record( + "reorder_encrypt", + label, + seed, + step, + from.encrypt(format!("{tag}-1").into_bytes()), + &[], + ) else { + return false; + }; + let Some(_) = cov.record( + "reorder_prepare", + label, + seed, + step, + from.prepare_to_encrypt(None), + &[], + ) else { + return false; + }; + let Some(second) = cov.record( + "reorder_encrypt", + label, + seed, + step, + from.encrypt(format!("{tag}-2").into_bytes()), + &[], + ) else { + return false; + }; + for frame in [second.cipher_text, first.cipher_text] { + if let Some(offered) = deliver(to, label, seed, step, frame) { + handoff_fold_if_legal(to, &offered); + } + } + cov.reorders += 1; + true + } + + /// Archives and restores `party`; neither call has a legitimate refusal. + fn step_restore( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + party: Arc, + ) -> Arc { + let Some(archive) = cov.record("archive", label, seed, step, party.archive(), &[]) else { + return party; + }; + let Some(restored) = cov.record( + "restore", + label, + seed, + step, + TwoMlsPqSession::from_archive(archive), + &[], + ) else { + return party; + }; + cov.restores += 1; + restored + } + + /// The key recv-classical currently presents (the ambient signer rotate-before-bind is + /// about), or `None` with no recv group yet. + fn own_recv_classical_key(session: &Arc) -> Option> { + let inner = session.lock(); + inner + .recv_group + .as_ref() + .map(|r| assert_ok!(crate::session::migration::own_signature_key(&r.classical))) + } + + /// The deployed card shape: A.3 registered on both sides (KP′ and Welcome′ parked), nothing + /// delivered. Uses `pq_bootstrap_begin`/`respond` instead of the app's + /// `pq_bootstrap_envelope`, which registers the same round over a different transport. + fn establish_a3_stalled() -> (Arc, Arc) { + let (alice, bob) = establish_confirmed_sessions(); + let kp = assert_ok!(alice.pq_bootstrap_begin(None)); + assert_ok!(bob.pq_bootstrap_respond(kp)); + (alice, bob) + } + + /// Peeks one pending side-band leg from `from` and dispatches it to `to`'s accept call, as + /// a host does; returns whether the accept succeeded. Classifying the leg must not fail. + /// Expected refusals: `DuplicateSideBand` (stale or out-of-turn resend), `SessionNotReady` + /// (round moved on), `CredentialRejected` (A.5 identity not yet canonical on `to`, + /// retryable) and, for `RekeyUpdate` only, `DecryptionFailed` (see below). + /// + /// `wedged` (A3-LATE after a rotate-before-bind desync) also expects `Mls`, since the + /// wedged round re-surfaces the join refusal; everywhere else `Mls` stays unexpected. + fn deliver_pending_side_band( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + from: &Arc, + to: &Arc, + wedged: bool, + ) -> bool { + let Some(leg) = from.pq_pending_outbound(SideBandSealing::Fresh) else { + return false; + }; + let opened = to.open_incoming(leg.clone()); + assert!( + opened.is_ok(), + "{label} seed {seed} step {step}: open_incoming must classify a frame we just \ + peeked: {:?}", + opened.as_ref().err() + ); + let Some(opened) = opened.unwrap() else { + unreachable!("{label} seed {seed} step {step}: a peeked side-band leg must classify"); + }; + let crate::session::OpenedFrameKind::PqSideBand { kind } = opened.kind else { + unreachable!( + "{label} seed {seed} step {step}: pq_pending_outbound produced a \ + non-side-band frame" + ); + }; + let mut side_band_expected: Vec = vec![ + ("duplicate_side_band", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::DuplicateSideBand) + }), + ("side_band_not_ready", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::SessionNotReady) + }), + ("credential_not_yet_canonical", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::CredentialRejected) + }), + ]; + if wedged { + side_band_expected.push(("rotate_before_bind_desync", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::Mls) + })); + } + // `pq_rekey_respond` maps any failure validating the peer's Upd' to retryable + // `DecryptionFailed` (a credential lag the peer's retry heals). Scoped to + // `RekeyUpdate` so a genuine decrypt failure on another leg kind still fails. + if matches!(kind, crate::session::PqFrameKind::RekeyUpdate) { + side_band_expected.push(("rekey_update_credential_lag", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::DecryptionFailed) + })); + } + let result: crate::Result<()> = match kind { + crate::session::PqFrameKind::BootstrapKeyPackage => to.pq_bootstrap_respond(leg), + crate::session::PqFrameKind::BootstrapWelcome => to.pq_bootstrap_bind(leg), + crate::session::PqFrameKind::RatchetEphemeralKey => to.pq_ratchet_respond(leg), + crate::session::PqFrameKind::RatchetCiphertext => to.pq_ratchet_bind(leg), + crate::session::PqFrameKind::RekeyUpdate => to.pq_rekey_respond(leg).map(|_| ()), + crate::session::PqFrameKind::RekeyCommit => to.pq_rekey_apply(leg), + }; + cov.record( + "side_band_accept", + label, + seed, + step, + result, + &side_band_expected, + ) + .is_some() + } + + /// ANCHOR (and A3-LATE post-bind): drains pending legs both ways until neither side has + /// any (bounded). A completed round only sets `owed_bind` — its closing bind rides the + /// next classical frame's staple. + fn drain_side_band( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + alice: &Arc, + bob: &Arc, + wedged: bool, + ) -> u32 { + let mut delivered = 0; + for _ in 0..8 { + let a = deliver_pending_side_band(cov, label, seed, step, alice, bob, wedged); + let b = deliver_pending_side_band(cov, label, seed, step, bob, alice, wedged); + if !a && !b { + break; + } + delivered += u32::from(a) + u32::from(b); + } + delivered + } + + /// MIXED: each direction's leg is attempted once with probability 1/2, not drained. A + /// withheld leg stays pending (peek doesn't consume it) and may land after its round moved + /// on, which is why `duplicate_side_band` shows up here but rarely under ANCHOR. + fn drain_side_band_mixed( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + rng: &mut Rng, + alice: &Arc, + bob: &Arc, + ) -> u32 { + let mut delivered = 0; + if rng.below(2) == 0 && deliver_pending_side_band(cov, label, seed, step, alice, bob, false) + { + delivered += 1; + } + if rng.below(2) == 0 && deliver_pending_side_band(cov, label, seed, step, bob, alice, false) + { + delivered += 1; + } + delivered + } + + /// A3-LATE's transition: binds bob's parked Welcome′ on alice mid-walk and discharges the + /// owed bind, as `establish_full` does at setup. + /// + /// Expected refusal: `Mls` from the bind itself when a rotation of alice's identity landed + /// first. KP′ still names her old identity, now evicted from `PartySequence::history`, so + /// `validate_member` rejects the join (`UnknownIdentity`, mapped to `Mls`) — the loud + /// sibling of the quiet `no_custody` case, where the identity is still in history and the + /// join succeeds with a mismatched signer. The round stays open and every retry hits the + /// same refusal; the walk continues as ANCHOR so the export is exercised in that state. + fn perform_a3_late_bind( + cov: &mut Coverage, + label: &str, + seed: u64, + step: usize, + alice: &Arc, + bob: &Arc, + ) -> bool { + let welcome = assert_some!(bob.pq_pending_outbound(SideBandSealing::Fresh)); + let expected: &[RefusalPredicate] = + &[("rotate_before_bind_desync", |e: &TwoMlsPqError| { + matches!(e, TwoMlsPqError::Mls) + })]; + let Some(()) = cov.record( + "a3_late_bind", + label, + seed, + step, + alice.pq_bootstrap_bind(welcome), + expected, + ) else { + return false; + }; + discharge_bind(alice, bob, b"a3-late-bind-discharge"); + true + } + + /// Invariants checked on both sides every step; returns whether `no_custody.recv_pq` was seen: + /// - each half with a live group has `current` equal to the key its leaf presents, except + /// `recv_pq` under `allow_recv_pq_no_custody` (the unchecked-join case); + /// - `pending` targets are non-empty and unique; classical ones are valid successors; + /// - `no_custody` is false everywhere except that same `recv_pq` case; + /// - the latest own offer, while still cached, is framed and never in the window; + /// - native check 6 and its converse hold for the window. + fn assert_invariants( + label: &str, + seed: u64, + step: usize, + session: &Arc, + allow_recv_pq_no_custody: bool, + ) -> bool { + let export = session.migration_export(); + assert!( + export.is_ok(), + "{label} seed {seed} step {step}: export failed: {:?}", + export.as_ref().err() + ); + let export = export.unwrap(); + + // `mine.current`, for the classical target-membership rule below and for + // locating the same-id-candidate target. + let mine_current = session + .lock() + .with_auth(|core| core.mine.current().map(<[u8]>::to_vec)) + .expect("auth.mine is always seeded"); + + let check_half = |name: &str, + presented: Option>, + leaf: &crate::session::migration::SessionMigrationGroupKeys, + allow_no_custody: bool, + classical: bool| { + let presented_is_none = presented.is_none(); + match (presented, leaf.current.as_ref()) { + (Some(presented), Some(current)) => { + assert_eq!( + current.signature_key, presented, + "{label} seed {seed} step {step}: {name}'s current key doesn't \ + match what the leaf presents" + ); + } + (None, Some(_)) => { + // The reservation case: no live group to compare against. + } + (_, None) => { + if !allow_no_custody { + // Unexpected no-custody: dump live state for diagnosis. + let inner = session.lock(); + let inflight = match &inner.pq_inflight { + None => "none", + Some(crate::session::PqInflight::Initiating(_)) => "initiating", + Some(crate::session::PqInflight::BootstrapInitiated) => { + "bootstrap_initiated" + } + Some(crate::session::PqInflight::BootstrapResponded) => { + "bootstrap_responded" + } + Some(crate::session::PqInflight::RekeyInitiated) => "rekey_initiated", + Some(crate::session::PqInflight::RekeyResponded) => "rekey_responded", + Some(crate::session::PqInflight::Responding { .. }) => "responding", + }; + eprintln!( + "{name} no-custody diagnostic: pending_count={} \ + pq_inflight={inflight} owed_bind={} staged_candidates={} \ + pq_wedged={}", + leaf.pending.len(), + inner.owed_bind.is_some(), + inner.staged_candidates.len(), + inner.pq_wedged.is_some(), + ); + } + assert!( + allow_no_custody, + "{label} seed {seed} step {step}: {name} has no custody" + ); + } + } + if presented_is_none { + assert!( + leaf.pending.is_empty(), + "{label} seed {seed} step {step}: {name}'s reservation has non-empty \ + pending" + ); + } + let mut seen = std::collections::HashSet::new(); + for p in &leaf.pending { + assert!( + !p.target.is_empty(), + "{label} seed {seed} step {step}: {name}'s pending has an empty target" + ); + assert!( + seen.insert(p.target.clone()), + "{label} seed {seed} step {step}: {name}'s pending has a duplicate target" + ); + // Classical targets must be in {mine.current} ∪ authorized_next, which + // `valid_successor` reduces to when `mine_current` is the newest history + // element. PQ pending may target a historical id. + if classical { + let authorized = session + .lock() + .with_auth(|core| core.mine.valid_successor(&mine_current, &p.target)); + assert!( + authorized, + "{label} seed {seed} step {step}: {name}'s pending target is \ + neither mine.current nor authorized_next" + ); + } + } + }; + + let (send_classical, send_pq, recv_classical, recv_pq, latest_offer) = { + let inner = session.lock(); + let send = inner.send_group.as_ref().expect("send_group always exists"); + let recv = inner.recv_group.as_ref(); + ( + assert_ok!(crate::session::migration::own_signature_key( + &send.classical + )), + send.pq + .as_ref() + .map(|g| assert_ok!(crate::session::migration::own_signature_key(g))), + recv.map(|r| { + assert_ok!(crate::session::migration::own_signature_key(&r.classical)) + }), + recv.and_then(|r| r.pq.as_ref()) + .map(|g| assert_ok!(crate::session::migration::own_signature_key(g))), + inner + .pending_proposal_message + .as_ref() + .map(|(_, m)| m.clone()), + ) + }; + check_half( + "send_classical", + Some(send_classical), + &export.leaf_keys.send_classical, + false, + true, + ); + check_half("send_pq", send_pq, &export.leaf_keys.send_pq, false, false); + check_half( + "recv_classical", + recv_classical, + &export.leaf_keys.recv_classical, + false, + true, + ); + check_half( + "recv_pq", + recv_pq, + &export.leaf_keys.recv_pq, + allow_recv_pq_no_custody, + false, + ); + + // Every target but `mine.current` has the same key in both classical sets; there, + // recv_classical may carry a same-id K′ while send_classical carries the identity's. + for candidate_target in [ + &export.leaf_keys.send_classical, + &export.leaf_keys.recv_classical, + ] + .iter() + .flat_map(|g| g.pending.iter().map(|p| p.target.clone())) + .collect::>() + { + if candidate_target == mine_current { + continue; + } + let send_key = export + .leaf_keys + .send_classical + .pending + .iter() + .find(|p| p.target == candidate_target) + .map(|p| &p.key); + let recv_key = export + .leaf_keys + .recv_classical + .pending + .iter() + .find(|p| p.target == candidate_target) + .map(|p| &p.key); + if let (Some(send_key), Some(recv_key)) = (send_key, recv_key) { + assert_eq!( + send_key.signing_key, recv_key.signing_key, + "{label} seed {seed} step {step}: target {candidate_target:?} has \ + different keys in send_classical vs recv_classical" + ); + } + } + // `rotation_candidate` (never a same-id candidate) matches its pending entries. + if let Some(candidate) = &export.rotation_candidate { + assert_ne!( + candidate.target_client_id, mine_current, + "{label} seed {seed} step {step}: a same-id candidate must never export \ + as rotation_candidate" + ); + let matches = [ + &export.leaf_keys.send_classical, + &export.leaf_keys.recv_classical, + ] + .iter() + .flat_map(|g| g.pending.iter()) + .filter(|p| p.target == candidate.target_client_id) + .all(|p| p.key.signing_key == candidate.signing_key); + assert!( + matches, + "{label} seed {seed} step {step}: rotation_candidate's key disagrees with \ + its own pending entry" + ); + } + + let mut observed_recv_pq_no_custody = false; + if let Some(deployed) = &export.deployed_state { + assert!( + !deployed.no_custody.send_classical, + "{label} seed {seed} step {step}: send_classical no_custody" + ); + assert!( + !deployed.no_custody.send_pq, + "{label} seed {seed} step {step}: send_pq no_custody" + ); + assert!( + !deployed.no_custody.recv_classical, + "{label} seed {seed} step {step}: recv_classical no_custody" + ); + assert!( + allow_recv_pq_no_custody || !deployed.no_custody.recv_pq, + "{label} seed {seed} step {step}: recv_pq no_custody (not allowed at this step)" + ); + observed_recv_pq_no_custody = deployed.no_custody.recv_pq; + } + + if let Some(latest) = latest_offer.as_ref() { + let inner = session.lock(); + if let Some(recv) = inner.recv_group.as_ref() { + if let (Ok(cs), Ok(cache)) = ( + crate::providers::classical_envelope_suite(), + recv.classical.own_proposals_for_swift_export(), + ) { + if let Ok(hash) = cs.hash(latest) { + if let Some(entry) = cache.iter().find(|e| e.message_hash == hash) { + // The latest own offer is framed: its HPKE pair rides the + // snapshot, not the window. + let in_window = export.deployed_state.as_ref().is_some_and(|d| { + d.own_offers.as_ref().is_some_and(|w| { + w.offers + .iter() + .any(|o| o.proposal_ref == entry.proposal_ref) + }) + }); + assert!( + !in_window, + "{label} seed {seed} step {step}: the latest own offer must \ + be framed, not carried in the window" + ); + let framed = crate::session::migration::staged_update_leaf_key( + &recv.classical, + Some(latest), + &cs, + ) + .ok() + .flatten() + .is_some(); + assert!( + framed, + "{label} seed {seed} step {step}: the latest own offer must \ + resolve as the framed (Snapshot-placed) entry" + ); + } + } + } + } + } + + // Native's check 6: every window offer, staged update and pending proposal names + // recv_classical's `current` or `pending[its id]`, as a peer's by-reference fold needs. + let recv_classical_keys = &export.leaf_keys.recv_classical; + let names_current_or_pending = |id: &[u8], key: &[u8]| -> bool { + recv_classical_keys + .current + .as_ref() + .is_some_and(|c| c.signature_key == key) + || recv_classical_keys + .pending + .iter() + .any(|p| p.target == id && p.key.signature_key == key) + }; + // Window offers are bare cache entries, but `staged_updates`/`pending_proposal` carry + // framed MLSMessage bytes, so recover the bare proposal by hash from the same cache, + // as `own_offer_window` does. `None` (undecodable, or stale past an epoch advance) is + // skipped, mirroring native check 6's tolerance of an own Update failing epoch checks. + let decode_framed_update = |framed: &[u8]| -> Option<(Vec, Vec)> { + let cs = crate::providers::classical_envelope_suite().ok()?; + let hash = mls_rs::CipherSuiteProvider::hash(&cs, framed).ok()?; + let inner = session.lock(); + let recv = inner.recv_group.as_ref()?; + let cache = recv.classical.own_proposals_for_swift_export().ok()?; + let entry = cache.into_iter().find(|e| e.message_hash == hash)?; + crate::session::migration::decoded_update_target(&entry.proposal) + }; + if let Some(window) = export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()) + { + for offer in &window.offers { + let (id, key) = crate::session::migration::decoded_update_target(&offer.proposal) + .expect("a window offer must decode as an Update"); + assert!( + names_current_or_pending(&id, &key), + "{label} seed {seed} step {step}: a window offer names neither \ + recv_classical's current nor its own pending entry" + ); + } + } + for staged in &export.staged_updates { + let Some((id, key)) = decode_framed_update(&staged.message) else { + continue; + }; + assert!( + names_current_or_pending(&id, &key), + "{label} seed {seed} step {step}: a staged update names neither \ + recv_classical's current nor its own pending entry" + ); + } + if let Some(pending_proposal) = &export.pending_proposal { + if let Some((id, key)) = decode_framed_update(&pending_proposal.message) { + assert!( + names_current_or_pending(&id, &key), + "{label} seed {seed} step {step}: pending_proposal names neither \ + recv_classical's current nor its own pending entry" + ); + } + } + + // Converse of check 6: below the cap, a presentation-changing offer matching + // `pending[mine.current]` must ride the window. The framed entry (matched by hash) + // doesn't count — it rides the snapshot. + if let Some(mine_current_pending) = recv_classical_keys + .pending + .iter() + .find(|p| p.target == mine_current) + { + let presented_key = recv_classical_keys + .current + .as_ref() + .map(|c| &c.signature_key); + if presented_key != Some(&mine_current_pending.key.signature_key) { + let window = export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()); + let at_cap = window + .is_some_and(|w| w.offers.len() >= crate::session::migration::OWN_OFFER_WINDOW); + if !at_cap { + let framed_hash = latest_offer.as_ref().and_then(|bytes| { + crate::providers::classical_envelope_suite() + .ok() + .and_then(|cs| mls_rs::CipherSuiteProvider::hash(&cs, bytes).ok()) + }); + let has_real_entry = { + let inner = session.lock(); + inner.recv_group.as_ref().is_some_and(|recv| { + recv.classical + .own_proposals_for_swift_export() + .map(|cache| { + cache.iter().any(|entry| { + framed_hash.as_deref() + != Some(entry.message_hash.as_slice()) + && crate::session::migration::decoded_update_target( + &entry.proposal, + ) + .is_some_and(|(id, key)| { + id == mine_current + && key == mine_current_pending.key.signature_key + }) + }) + }) + .unwrap_or(false) + }) + }; + if has_real_entry { + let in_window = window.is_some_and(|w| { + w.offers.iter().any(|o| { + crate::session::migration::decoded_update_target(&o.proposal) + .is_some_and(|(id, key)| { + id == mine_current + && key == mine_current_pending.key.signature_key + }) + }) + }); + assert!( + in_window, + "{label} seed {seed} step {step}: a presentation-changing \ + offer matching pending[mine.current] must ride the window \ + (it is not past the cap)" + ); + } + } + } + } + + observed_recv_pq_no_custody + } + + #[derive(Clone, Copy)] + enum Mode { + Card, + Anchor, + Mixed, + /// The deployed card shape (A.3 registered, nothing delivered), never delivered. + CardA3Stalled, + /// A.3-stalled, binding in the walk's latter half so an earlier rotation reliably + /// lands first, producing the loud `rotate_before_bind_desync`. Then ANCHOR. + A3Late, + /// A.3-stalled, binding in `[5, 5 + steps/8)` so the bind races an incidental A.5 on + /// the just-bound recv-PQ, producing the quiet `no_custody.recv_pq`. Then ANCHOR. + A3LateEarly, + /// A.3-stalled, binding before the loop so the post-bind ANCHOR path provably runs on + /// a clean bind rather than being vacuously skipped by A3-LATE always wedging. + A3LateClean, + } + + /// A.3-stalled start, bind, then ANCHOR; only the bind timing differs. + fn is_a3_late_family(mode: Mode) -> bool { + matches!(mode, Mode::A3Late | Mode::A3LateEarly | Mode::A3LateClean) + } + + fn run_walk(label: &str, seed: u64, steps: usize, mode: Mode) -> Coverage { + let mut rng = Rng(seed); + let (mut alice, mut bob) = if matches!(mode, Mode::Card | Mode::Anchor | Mode::Mixed) { + establish_full() + } else { + establish_a3_stalled() + }; + // `is_fully_established` needs A.3 bound on that side: stalled modes leave alice + // unbound, but bob's halves are live from `pq_bootstrap_respond` alone. + if matches!(mode, Mode::Card | Mode::Anchor | Mode::Mixed) { + assert!(alice.is_fully_established()); + } else { + assert!(!alice.is_fully_established()); + } + assert!(bob.is_fully_established()); + let mut cov = Coverage::default(); + + let half = (steps / 2).max(1); + let bind_step = match mode { + Mode::A3Late => Some(half + rng.below(half as u32) as usize), + Mode::A3LateEarly => Some(5 + rng.below((steps / 8).max(1) as u32) as usize), + _ => None, + }; + // Snapshot alice's recv-classical identity at registration to detect a landed + // rotation at bind time. + let alice_recv_key_at_registration = own_recv_classical_key(&alice); + let mut post_bind = false; + // Set when the scheduled bind fails after a rotation: the round is then permanently + // wedged and every later delivery re-surfaces `Mls`. + let mut wedged = false; + + if matches!(mode, Mode::A3LateClean) { + let bind_ok = perform_a3_late_bind(&mut cov, label, seed, 0, &alice, &bob); + assert!( + bind_ok, + "{label} seed {seed}: a3-late-clean's bind failed with nothing preceding \ + it to desync — the post-bind anchor path this mode exists to exercise \ + never ran" + ); + post_bind = true; + } + + for step in 0..steps { + let bound = if matches!(mode, Mode::Anchor | Mode::Mixed) || post_bind { + 7 + } else { + 6 + }; + match rng.below(bound) { + 0 => { + step_send(&mut cov, (label, seed, step), &alice, &bob, b"a->b", false); + } + 1 => { + step_send(&mut cov, (label, seed, step), &bob, &alice, b"b->a", false); + } + 2 => { + step_send( + &mut cov, + (label, seed, step), + &alice, + &bob, + b"a->b rotate", + true, + ); + } + 3 => { + step_send( + &mut cov, + (label, seed, step), + &bob, + &alice, + b"b->a rotate", + true, + ); + } + 4 => { + alice = step_restore(&mut cov, label, seed, step, alice); + } + 5 => { + bob = step_restore(&mut cov, label, seed, step, bob); + } + 6 => { + step_send_reordered(&mut cov, label, seed, step, &alice, &bob, "a->b-reorder"); + } + _ => unreachable!("below(bound) is < bound"), + } + + if matches!(mode, Mode::A3Late | Mode::A3LateEarly) && !post_bind { + let target = + bind_step.expect("A3Late/A3LateEarly always schedule a bind_step at setup"); + if step == target { + let rotated = own_recv_classical_key(&alice) != alice_recv_key_at_registration; + cov.saw_rotate_before_bind |= rotated; + let bind_ok = perform_a3_late_bind(&mut cov, label, seed, step, &alice, &bob); + // `rotate_before_bind_desync` must not become a catch-all for unrelated + // `Mls` failures. + assert!( + bind_ok || rotated, + "{label} seed {seed} step {step}: a3_late_bind failed WITHOUT a \ + preceding rotation — not the documented rotate-before-bind desync" + ); + wedged = !bind_ok; + post_bind = true; + } + } + + // CARD, CARD-A3-STALLED and pre-bind A3-late modes never drive the side-band. + if matches!(mode, Mode::Anchor) || (is_a3_late_family(mode) && post_bind) { + cov.legs_delivered += + drain_side_band(&mut cov, label, seed, step, &alice, &bob, wedged); + } else if matches!(mode, Mode::Mixed) { + cov.legs_delivered += + drain_side_band_mixed(&mut cov, label, seed, step, &mut rng, &alice, &bob); + } + + for s in [&alice, &bob] { + let inner = s.lock(); + cov.saw_rekey_initiated |= matches!( + inner.pq_inflight, + Some(crate::session::PqInflight::RekeyInitiated) + ); + cov.saw_owed_bind |= inner.owed_bind.is_some(); + } + + // Only a bound A3-LATE-EARLY after a rotation can reach `no_custody.recv_pq`; + // even A3-LATE's loud desync must still resolve every half's custody. + let allow_recv_pq_no_custody = + matches!(mode, Mode::A3LateEarly) && post_bind && cov.saw_rotate_before_bind; + + // Occasionally check invariants mid-prepare: `step_send` always encrypts, so the + // framed-entry paths are otherwise unexercised. `Some(mine.current())` covers a + // framed same-id candidate. + // + // Cleared by field reset, not `encrypt`: `encrypt` also drives PQ auto-staging, + // which the strict per-step side-band drain doesn't expect (it induced + // `DecryptionFailed`). Left set, the prepare would go stale at the next epoch + // advance, since `pending_proposal_message` isn't epoch-scoped. + // + // Gated on both sides being PQ-quiescent: even a bare prepare landing mid-round + // induced the same `DecryptionFailed` under ANCHOR/MIXED. + let pq_quiescent = |s: &Arc| { + let inner = s.lock(); + inner.pq_inflight.is_none() && inner.pending_side_band.is_none() + }; + if rng.below(20) == 0 && pq_quiescent(&alice) && pq_quiescent(&bob) { + let party = if rng.below(2) == 0 { &alice } else { &bob }; + let proposing = match rng.below(3) { + 0 => None, + 1 => Some(make_client().client_id()), + _ => party + .lock() + .with_auth(|core| core.mine.current().map(<[u8]>::to_vec)) + .map(|bytes| crate::ClientId { bytes }), + }; + if party.prepare_to_encrypt(proposing).is_ok() { + cov.saw_recv_pq_no_custody |= + assert_invariants(label, seed, step, party, allow_recv_pq_no_custody); + let mut inner = party.lock(); + inner.pending_proposal_message = None; + inner.pending_proposal_hash = None; + } + } + + cov.saw_recv_pq_no_custody |= + assert_invariants(label, seed, step, &alice, allow_recv_pq_no_custody); + cov.saw_recv_pq_no_custody |= + assert_invariants(label, seed, step, &bob, allow_recv_pq_no_custody); + } + cov + } + + /// Which born-dedicated state a born-dedicated walk starts from. + enum BornDedicatedStart { + /// The establishment envelope has not landed yet. + PreInstall, + /// Installed and sent, but the peer hasn't folded the catch-up Upd yet. + InstalledUnfolded, + /// Starts pre-install; the walk installs partway through instead of at setup. + InstallMidWalk, + } + + /// Contract 26: bob's first frame after the delegation installs carries the handoff and + /// pauses alice's `process_incoming` until `approve_establishment` re-feeds it. Until then + /// neither side can exchange traffic: bob's emission refuses + /// (`EstablishmentEnvelopeRequired`) and alice's frames are undecryptable by bob. + fn complete_installation( + cov: &mut Coverage, + ctx: (&str, u64, usize), + alice: &Arc, + bob: &Arc, + envelope: &[u8], + dedicated: &[u8], + ) { + let (label, seed, step) = ctx; + let Some(_) = cov.record( + "bd_install_prepare", + label, + seed, + step, + bob.prepare_to_encrypt(None), + &[], + ) else { + return; + }; + let Some(enc) = cov.record( + "bd_install_encrypt", + label, + seed, + step, + bob.encrypt(b"catch-up".to_vec()), + &[], + ) else { + return; + }; + let approved = + crate::test_utils::approve_establishment(alice, enc.cipher_text, envelope, dedicated); + assert!( + approved.is_some(), + "{label} seed {seed} step {step}: bob's install-completing frame did not pause \ + as expected" + ); + } + + /// The card step set on both parties of a born-dedicated pair, plus a one-time install for + /// `InstallMidWalk`. The side-band is never driven — pre-A.3 the pair is never + /// `is_fully_established` — so this covers the classical/custody side. + fn run_born_dedicated_walk( + label: &str, + seed: u64, + steps: usize, + start: BornDedicatedStart, + ) -> Coverage { + let mut rng = Rng(seed); + let d = crate::test_utils::born_dedicated_pending(); + let (mut alice, mut bob) = (d.alice, d.bob); + let dedicated = d.dedicated; + let mut installed = false; + let mut cov = Coverage::default(); + + // `InstallMidWalk` must stay pre-install here so its pre-install steps run. + if matches!(start, BornDedicatedStart::InstalledUnfolded) { + let envelope = crate::test_utils::install_mock_envelope(&bob); + installed = true; + complete_installation( + &mut cov, + (label, seed, 0), + &alice, + &bob, + &envelope, + &dedicated, + ); + + // One more plain frame alice receives but never folds — the "unfolded" state. + if bob.prepare_to_encrypt(None).is_ok() { + if let Ok(enc) = bob.encrypt(b"catch-up".to_vec()) { + let _ = alice.process_incoming(enc.cipher_text); + } + } + } + + for step in 0..steps { + if !installed + && matches!(start, BornDedicatedStart::InstallMidWalk) + && step == steps / 3 + { + let envelope = crate::test_utils::install_mock_envelope(&bob); + installed = true; + complete_installation( + &mut cov, + (label, seed, step), + &alice, + &bob, + &envelope, + &dedicated, + ); + } + let bound = if installed { 7 } else { 4 }; + match rng.below(bound) { + 0 => { + // Pre-install, bob cannot emit (Contract 26) — a no-op step. + if installed { + step_send( + &mut cov, + (label, seed, step), + &bob, + &alice, + b"bob->alice", + false, + ); + } + } + 1 => { + // Pre-install, bob can't decrypt alice's frames either. + if installed { + step_send( + &mut cov, + (label, seed, step), + &alice, + &bob, + b"alice->bob", + false, + ); + } + } + 2 => { + bob = step_restore(&mut cov, label, seed, step, bob); + } + 3 => { + alice = step_restore(&mut cov, label, seed, step, alice); + } + 4 => { + if installed { + step_send( + &mut cov, + (label, seed, step), + &bob, + &alice, + b"bob->alice rotate", + true, + ); + } + } + 5 => { + if installed { + step_send_reordered( + &mut cov, + label, + seed, + step, + &bob, + &alice, + "bob-reorder", + ); + } + } + 6 => { + if installed { + step_send( + &mut cov, + (label, seed, step), + &alice, + &bob, + b"alice->bob rotate", + true, + ); + } + } + _ => unreachable!("below(bound) is < bound"), + } + + let _ = assert_invariants(label, seed, step, &alice, false); + let _ = assert_invariants(label, seed, step, &bob, false); + } + cov + } + + fn print_outcome_table(mode_name: &str, total: &Coverage) { + println!( + "{mode_name}: sends={} rotations={} restores={} reorders={} legs_delivered={} \ + saw_rekey_initiated={} saw_owed_bind={} saw_rotate_before_bind={} \ + saw_recv_pq_no_custody={}", + total.sends, + total.rotations, + total.restores, + total.reorders, + total.legs_delivered, + total.saw_rekey_initiated, + total.saw_owed_bind, + total.saw_rotate_before_bind, + total.saw_recv_pq_no_custody, + ); + println!( + "{mode_name}: attempted={} ok={} refusals={:?}", + total.attempted, total.ok, total.refusals + ); + } + + #[test] + fn test_totality_random_walk() { + const SEEDS: [u64; 6] = [1, 2, 3, 42, 1337, 0xC0FFEE]; + const STEPS: usize = 300; + for (mode_name, mode) in [ + ("card", Mode::Card), + ("anchor", Mode::Anchor), + ("mixed", Mode::Mixed), + ("card-a3-stalled", Mode::CardA3Stalled), + ("a3-late", Mode::A3Late), + ("a3-late-early", Mode::A3LateEarly), + ("a3-late-clean", Mode::A3LateClean), + ] { + let mut total = Coverage::default(); + for seed in SEEDS { + total.merge(run_walk(mode_name, seed, STEPS, mode)); + } + print_outcome_table(mode_name, &total); + // ANCHOR/MIXED drive the side-band, so zero legs or owed binds means the driver + // is a no-op. + if matches!(mode, Mode::Anchor | Mode::Mixed) { + assert!( + total.legs_delivered > 0, + "{mode_name}: no side-band legs were ever delivered" + ); + assert!(total.saw_owed_bind, "{mode_name}: no bind was ever owed"); + } + if matches!(mode, Mode::CardA3Stalled) { + assert_eq!( + total.legs_delivered, 0, + "card-a3-stalled: a leg was delivered, but this mode must never touch \ + the side-band" + ); + } + if matches!(mode, Mode::A3Late) { + // The late bind reliably wedges, so `legs_delivered` staying 0 is expected. + assert!( + total.saw_rotate_before_bind, + "a3-late: the rotate-before-bind precondition was never observed across \ + any seed — tune the bind-step schedule or seeds" + ); + } + if matches!(mode, Mode::A3LateEarly) { + // `saw_recv_pq_no_custody` is reported but not asserted: it is + // seed/schedule-sensitive, and + // `test_migration_export_carries_no_custody_from_an_unchecked_join_signer` + // pins that state deterministically. + assert!( + total.saw_rotate_before_bind, + "a3-late-early: the rotate-before-bind precondition was never observed \ + across any seed — tune the bind-step schedule or seeds" + ); + } + if matches!(mode, Mode::A3LateClean) { + assert!( + total.legs_delivered > 0, + "a3-late-clean: no side-band legs were ever delivered post-bind" + ); + assert!( + total.saw_rekey_initiated, + "a3-late-clean: rekey was never initiated post-bind" + ); + assert!( + total.saw_owed_bind, + "a3-late-clean: no bind was ever owed post-bind" + ); + } + } + const BD_SEEDS: [u64; 3] = [7, 77, 777]; + const BD_STEPS: usize = 60; + for (start_name, start) in [ + ("bd-pre-install", BornDedicatedStart::PreInstall), + ( + "bd-installed-unfolded", + BornDedicatedStart::InstalledUnfolded, + ), + ("bd-install-mid-walk", BornDedicatedStart::InstallMidWalk), + ] { + let mut total = Coverage::default(); + for seed in BD_SEEDS { + let cov = match start { + BornDedicatedStart::PreInstall => run_born_dedicated_walk( + start_name, + seed, + BD_STEPS, + BornDedicatedStart::PreInstall, + ), + BornDedicatedStart::InstalledUnfolded => run_born_dedicated_walk( + start_name, + seed, + BD_STEPS, + BornDedicatedStart::InstalledUnfolded, + ), + BornDedicatedStart::InstallMidWalk => run_born_dedicated_walk( + start_name, + seed, + BD_STEPS, + BornDedicatedStart::InstallMidWalk, + ), + }; + total.merge(cov); + } + print_outcome_table(start_name, &total); + } + } +} + +/// A never-converging born-dedicated acceptor re-proposes its dedicated id N times, never +/// folded. Each `Some(dedicated)` reuses the one staged same-id candidate (idempotent +/// `admit_candidate`), so custody resolves and `pending` collapses to one K′ entry, with +/// derivation bounded by distinct keys rather than N. +#[cfg(feature = "cryptokit")] +#[test] +fn test_born_dedicated_unfolded_catchup_pending_signers_collapse() { + let d = crate::test_utils::born_dedicated_pending(); + let (alice, bob) = (d.alice, d.bob); + let envelope = crate::test_utils::install_mock_envelope(&bob); + + // Contract 26's mandatory handoff authorizes bob's birth identity, not a new one. + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(b"handoff".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + let candidate_key = { + let inner = bob.lock(); + let candidate = inner + .staged_candidates + .last() + .expect("the handoff stages bob's own same-id candidate"); + candidate + .combiner() + .classical_signature_keypair() + .1 + .as_bytes() + .to_vec() + }; + + const N: usize = 2_000; + for i in 0..N { + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(format!("catchup{i}").into_bytes())); + assert_ok!(alice.process_incoming(enc.cipher_text)); + } + + let export = assert_ok!(bob.migration_export()); + let recv_classical = export.leaf_keys.recv_classical; + assert!( + recv_classical.current.is_some(), + "custody must still resolve after N re-proposals of the same identity" + ); + // It carries K′, not the identity's key: no plain `None` refresh is ever sent, so + // there is no real identity-signed offer. + let for_dedicated: Vec<_> = recv_classical + .pending + .iter() + .filter(|p| p.target == d.dedicated) + .collect(); + assert_eq!( + for_dedicated.len(), + 1, + "N re-proposals of the SAME identity target must collapse to exactly one pending \ + entry for that target, not one per frame" + ); + assert_eq!( + for_dedicated[0].key.signature_key, candidate_key, + "the surviving entry is the reused same-id candidate's own key, not the identity's" + ); +} + +/// Release-mode scale measurement at N ~ 110,000 catch-up sends, reporting export wall time +/// and byte sizes (snapshot, own-offer window, pending pairs). Run with `cargo test --release +/// -p two-mls-pq --features cryptokit -- --ignored +/// test_born_dedicated_unfolded_catchup_scale_measurement`. +/// +/// Measured: build ~30s, export ~0.8s, snapshot_bytes=8,770, window_entries=102,400 (capped), +/// window_bytes=30,003,200, pending_entries=1, pending_pair_bytes=128. +#[cfg(feature = "cryptokit")] +#[test] +#[ignore = "release-mode scale measurement; run explicitly, see doc comment"] +fn test_born_dedicated_unfolded_catchup_scale_measurement() { + let d = crate::test_utils::born_dedicated_pending(); + let (alice, bob) = (d.alice, d.bob); + let envelope = crate::test_utils::install_mock_envelope(&bob); + + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(b"handoff".to_vec())); + assert_some!(crate::test_utils::approve_establishment( + &alice, + enc.cipher_text, + &envelope, + &d.dedicated, + )); + + const N: usize = 110_000; + let build_start = std::time::Instant::now(); + for i in 0..N { + assert_ok!(bob.prepare_to_encrypt(Some(crate::ClientId { + bytes: d.dedicated.clone() + }))); + let enc = assert_ok!(bob.encrypt(format!("catchup{i}").into_bytes())); + assert_ok!(alice.process_incoming(enc.cipher_text)); + } + let build_elapsed = build_start.elapsed(); + + let export_start = std::time::Instant::now(); + let export = assert_ok!(bob.migration_export()); + let export_elapsed = export_start.elapsed(); + + let snapshot_bytes = export.send_group.classical.len() + + export.send_group.pq.as_ref().map_or(0, Vec::len) + + export + .recv_group + .as_ref() + .map(|g| g.classical.len() + g.pq.as_ref().map_or(0, Vec::len)) + .unwrap_or(0); + + let recv_classical = export.leaf_keys.recv_classical; + assert_eq!(recv_classical.pending.len(), 1); + + let window_bytes: usize = export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()) + .map(|w| { + w.group_id.len() + + w.offers + .iter() + .map(|o| o.proposal_ref.len() + o.proposal.len()) + .sum::() + }) + .unwrap_or(0); + let pending_pair_bytes: usize = recv_classical + .pending + .iter() + .map(|p| p.key.signing_key.len() + p.key.signature_key.len() + p.target.len()) + .sum(); + + println!( + "N={N}: build={build_elapsed:?} export={export_elapsed:?} snapshot_bytes={snapshot_bytes} \ + window_entries={} window_bytes={window_bytes} pending_entries={} \ + pending_pair_bytes={pending_pair_bytes}", + export + .deployed_state + .as_ref() + .and_then(|d| d.own_offers.as_ref()) + .map(|w| w.offers.len()) + .unwrap_or(0), + recv_classical.pending.len(), + ); +} From d058f6449695504e1d1397e07559075270debb90 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 11:49:28 -0700 Subject: [PATCH 05/16] Map the migration export onto the native deployed-state inputs SessionMigrator.mint returns the session archive and, when present, the minted own-offer window. Pins twomlspq-swift main until the release that ships these inputs. Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 30 +- Package.resolved | 13 +- Package.swift | 11 +- Sources/TwoMLSPQMigrate/SessionMigrator.swift | 180 ++++- .../BornDedicatedMigrationTests.swift | 173 +++-- .../DeployedStateMigrationTests.swift | 666 ++++++++++++++++++ .../LegacyRowFixtureTests.swift | 282 +++----- .../MintCoverageTests.swift | 444 ++++++++++++ .../NonDedicatedEarlyExportTests.swift | 19 +- .../RustSessionTestHelpers.swift | 59 +- .../SessionMigrationTests.swift | 19 +- 11 files changed, 1583 insertions(+), 313 deletions(-) create mode 100644 Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift create mode 100644 Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md index 7c28f78..f5cc31f 100644 --- a/.changeset/born-dedicated-acceptor-export.md +++ b/.changeset/born-dedicated-acceptor-export.md @@ -2,21 +2,21 @@ "@germ-network/two-mls-pq": minor --- -`TwoMlsPqSession.migrationExport()` now admits a born-dedicated acceptor, once its -establishment envelope has installed and its recv-classical leaf has caught up, and an -acceptor still holding its parked return welcome (previously refused on both). The -recv-PQ leaf a born-dedicated acceptor's session never catches up now exports as a new -`pqLeafCustody` field, so the app's migrator can seat it correctly on the migrated -side. `BINDING_CONTRACT_VERSION` bumps 35 → 36 for the new record and field. +`TwoMlsPqSession.migrationExport()` now exports every reachable session state instead of +refusing unsettled ones: pre-establishment initiators, born-dedicated acceptors at any point, +staged rotation candidates, lagging leaves, and parked or wedged PQ rounds. It fails only on +corrupt data (`ArchiveInvalid`). The export carries per-group signing keys (`leafKeys`), the +rotation candidate, an own-offer window with its leaf secrets, and deployed-engine flags +(`deployedState`). `BINDING_CONTRACT_VERSION` bumps 35 → 36. -That catch-up happens only once the peer folds the acceptor's catch-up offer. A caller -that folds only offers introducing a new client never folds it, so such born-dedicated -acceptors remain refused until a later release carries unconverged custody. +`SessionMigrator.mintArchive(kind:from:classicalProvider:pqProvider:)` is removed. +`SessionMigrator.mint(kind:from:classicalProvider:pqProvider:)` returns a `MintResult`: the +session archive plus, when present, the minted own-offer window, which the caller must persist +before the archive. -Also corrects the 0.17.0 changelog's claim that group state written before that -release ("pre-v35") no longer loads: it does. Sessions written by v0.16.0 restore and -keep messaging under this engine, pinned by fixtures. +`SessionError.Code.misroutedFrame` now has disposition `.discardFrame` (was `.callerBug`): an +ill-timed side-band re-send is normal traffic. This shifts app-side handling and any analytics +bucketed by disposition. -`SessionError.Code.misroutedFrame` now has disposition `.discardFrame` (was `.callerBug`): an ill-timed -side-band re-send is normal traffic, and the peer re-sends until answered, so dropping it is lossless. -This shifts app-side handling and any analytics bucketed by disposition. +Also corrects the 0.17.0 changelog's claim that pre-v35 group state no longer loads: it does, +and sessions written by v0.15.0 and v0.16.0 restore and migrate, pinned by fixtures. diff --git a/Package.resolved b/Package.resolved index cc56e16..87eb37d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dce512909c4b52bf34b936932f4d92ceae94a75e0c9b9bcb7656b8ac58317eef", + "originHash" : "edf0e74cf570a8377a208b7e2f7a6391adb99400e884eefecf482599915296d6", "pins" : [ { "identity" : "atprototypes", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/swift-mls.git", "state" : { - "revision" : "4dd46ab97af684652f8ceb7ef418d6273eb04543", - "version" : "0.1.4" + "revision" : "b809eb1ef46b4e908efd689a5ecfc959c3a88035", + "version" : "0.1.6" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/swift-secret-bytes.git", "state" : { - "revision" : "8cadb9c25f855f6c1db4985c97a28af4dcf391dc", - "version" : "0.5.0" + "revision" : "4f7d36ba92cc594bd5487d9053c39b05fe9e0105", + "version" : "0.7.1" } }, { @@ -105,8 +105,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/twomlspq-swift.git", "state" : { - "revision" : "6d9b1fdfaa2bc9fa91cfcc3ea30945f06c4324cc", - "version" : "0.2.1" + "revision" : "dd62668945e85bdfcb26c2c1653cc1c4082c61ef" } } ], diff --git a/Package.swift b/Package.swift index 786ec83..053456c 100644 --- a/Package.swift +++ b/Package.swift @@ -84,13 +84,11 @@ let package = Package( // maps the Rust migration exports onto. 0.1.1 is the first tag carrying R2 // (SessionMigration + the ML-KEM `hpkeSecretKeySize`); its transitive deps // (swift-mls, swift-secret-bytes, swift-crypto, GermConvenience) resolve - // automatically. - // Temporary revision pin to twomlspq-swift's swift-crypto-5 branch - // (germ-network/twomlspq-swift#60); replace with the released version - // once it cuts. + // automatically. Pinned to unreleased main for the deployed-state migration + // inputs; move to the release that ships them before merging. .package( url: "https://github.com/germ-network/twomlspq-swift.git", - from: "0.2.1" + revision: "dd62668945e85bdfcb26c2c1653cc1c4082c61ef" ), // Declared directly (not just transitively through twomlspq-swift) because // the migrate targets import their products. Library deps stay ranged @@ -99,9 +97,6 @@ let package = Package( // conflicts against a consumer's own tighter pin; twomlspq-swift's own // swift-mls requirement is ranged the same way (the 0.1.1 floor carries // the C0 `Nsk` length check). Exactness belongs to the app-level repo. - // Temporary revision pin to swift-mls's swift-crypto-5 branch - // (germ-network/swift-mls#103); replace with the released version once - // it cuts. .package( url: "https://github.com/germ-network/swift-mls.git", from: "0.1.4" diff --git a/Sources/TwoMLSPQMigrate/SessionMigrator.swift b/Sources/TwoMLSPQMigrate/SessionMigrator.swift index fba3db3..5829597 100644 --- a/Sources/TwoMLSPQMigrate/SessionMigrator.swift +++ b/Sources/TwoMLSPQMigrate/SessionMigrator.swift @@ -19,6 +19,10 @@ import TwoMLSPQSession // ingress restores each snapshot with its half's `CipherSuiteProvider`. // Everything cross-module is qualified — both packages define // `BlobKind`/`ClientID`-shaped names. +// +// `leafKeys` is always mapped and supplied, so the mint never falls back to +// its identity-keyed conversion. `deployedState` mints into its own blob via +// `mint(kind:from:...)`, never folded into the session archive. /// Migrates a legacy Rust session to a native, unsealed session /// `SecretArchive` — the app seals before persisting (this inherits the Rust @@ -26,27 +30,48 @@ import TwoMLSPQSession /// the caller owns its sealing). @available(iOS 26, macOS 26, *) public enum SessionMigrator { - /// Map a raw FFI session migration export onto twomlspq-swift's - /// `MigratedSession` and mint the archive. Mint a `.checkpoint` for the - /// full state (PQ trees inline); a `.core` pairs with it in the app's - /// reconcile slots exactly as the native return cadence's blobs do (a - /// core alone is never restorable — the mint skips its trial restore). + /// The minted session archive, plus the own-offer window's own blob and + /// id when the export carried one — paired so a caller can't drop it. + public struct MintResult: Sendable { + public let archive: SecretArchive + public let ownOfferWindow: TwoMLSPQSession.MintedOwnOfferWindow? + + public init( + archive: SecretArchive, + ownOfferWindow: TwoMLSPQSession.MintedOwnOfferWindow? + ) { + self.archive = archive + self.ownOfferWindow = ownOfferWindow + } + } + + /// Maps a raw FFI session migration export onto twomlspq-swift's inputs + /// and mints. `.checkpoint` carries the full state inline; `.core` pairs + /// with it in the app's reconcile slots (never restorable alone). An + /// own-offer window, if the export carries one, mints into its own blob + /// from the same parts, so the two ids always agree. /// - /// - Throws: `TwoMLSError.archiveInvalid` (from the mint, the checked - /// `componentId` narrowing, or a duplicate key in an epoch-keyed export - /// list) if any exported part fails its cross-check — the Rust export, - /// not this mapping, is the suspect. - public static func mintArchive( + /// - Throws: `TwoMLSError.archiveInvalid` if any exported part fails its + /// cross-check — the Rust export, not this mapping, is the suspect. + public static func mint( kind: TwoMLSPQSession.BlobKind, from export: TwoMLSPQBinding.SessionMigrationExport, classicalProvider: any MLS.CipherSuiteProvider, pqProvider: any MLS.CipherSuiteProvider - ) throws -> SecretArchive { - try TwoMLSPQSession.SessionMigration.mintArchive( - kind: kind, - parts: migratedSession(export), - classicalProvider: classicalProvider, - pqProvider: pqProvider) + ) throws -> MintResult { + let parts = try migratedSession(export) + let deployed = try migratedDeployedState(export.deployedState) + let archive = try TwoMLSPQSession.SessionMigration.mintArchive( + kind: kind, parts: parts, classicalProvider: classicalProvider, + pqProvider: pqProvider, deployedState: deployed) + let window: TwoMLSPQSession.MintedOwnOfferWindow? + if let ownOffers = deployed?.ownOffers { + window = try TwoMLSPQSession.SessionMigration.mintOwnOfferWindow( + ownOffers, parts: parts, classicalProvider: classicalProvider) + } else { + window = nil + } + return MintResult(archive: archive, ownOfferWindow: window) } /// Byte-map the whole export. Every field is verbatim from the export — @@ -95,6 +120,7 @@ public enum SessionMigrator { digest: $0.digest, message: $0.message) }, sendCrossPSKLedger: pskLedger(export.sendCrossPskLedger), + rotationCandidate: try export.rotationCandidate.map(rotationCandidate), spawnToken: export.spawnToken, listenRendezvous: epochMap(export.listenRendezvous), recvHeaderKeys: epochMap(export.recvHeaderKeys), @@ -107,26 +133,112 @@ public enum SessionMigrator { recvLeafPrincipal: try export.pqLeafCustody.map { try recvLeafPrincipal($0, identity: export.identity) }, - owesEstablishmentEnvelope: export.owesEstablishmentEnvelope) + owesEstablishmentEnvelope: export.owesEstablishmentEnvelope, + leafKeys: try leafKeys(export.leafKeys), + initialAppPayload: export.initialAppPayload) } - /// The Rust session no longer holds the invitation's classical signer — - /// mls-rs drops it at the recv-classical catch-up this export requires — - /// so the classical slot carries the identity's own pair instead. That is - /// exactly what the converged recv-classical leaf presents. - /// - /// This makes native's classical custody arm for `recvLeafPrincipal` - /// (`classicalSigningKey(presenting:)`) unreachable for a session this - /// mapper produces: that arm is gated on `recvLeafPrincipal.signatureKey` - /// matching the presented key, but this mapper sets it to - /// `identity.signatureKey` — the SAME value the identity arm above it - /// already matches first. The recv-leaf catch-up arms, which read the - /// classical custody key directly, never fire either: they require the - /// recv-classical leaf to still lag the canonical identity and to present - /// `clientID` (the invitation id), and this export requires that leaf to - /// have converged. Only the PQ half of this mixed record is ever read back - /// out (`pqSigningKey(presenting:)`'s `recvLeafPrincipal` arm, where - /// `pqSignatureKey` is the genuinely different custodied key). + /// Always mapped and supplied — the mint never takes its identity-keyed + /// fallback for a session this mapper produces. + private static func leafKeys( + _ keys: TwoMLSPQBinding.SessionMigrationLeafKeys + ) throws -> TwoMLSPQSession.MigratedLeafKeys { + try TwoMLSPQSession.MigratedLeafKeys( + sendClassical: groupKeys(keys.sendClassical), + recvClassical: groupKeys(keys.recvClassical), + sendPQ: groupKeys(keys.sendPq), + recvPQ: groupKeys(keys.recvPq)) + } + + private static func groupKeys( + _ keys: TwoMLSPQBinding.SessionMigrationGroupKeys + ) throws -> TwoMLSPQSession.MigratedGroupKeys { + try TwoMLSPQSession.MigratedGroupKeys( + current: keys.current.map(leafKey), + pending: keys.pending.map(pendingLeafKey)) + } + + private static func leafKey( + _ key: TwoMLSPQBinding.SessionMigrationKeyPair + ) throws -> TwoMLSPQSession.MigratedLeafKey { + try TwoMLSPQSession.MigratedLeafKey( + signingKey: SecretBytes(bytes: key.signingKey), + signatureKey: key.signatureKey) + } + + private static func pendingLeafKey( + _ entry: TwoMLSPQBinding.SessionMigrationPendingLeafKey + ) throws -> TwoMLSPQSession.MigratedPendingLeafKey { + try TwoMLSPQSession.MigratedPendingLeafKey( + target: entry.target, key: leafKey(entry.key)) + } + + private static func rotationCandidate( + _ candidate: TwoMLSPQBinding.SessionMigrationRotationCandidate + ) throws -> TwoMLSPQSession.MigratedRotationCandidate { + try TwoMLSPQSession.MigratedRotationCandidate( + clientID: candidate.targetClientId, + signingKey: SecretBytes(bytes: candidate.signingKey), + signatureKey: candidate.signatureKey, + proposedAtRecvEpoch: candidate.proposedAtRecvEpoch) + } + + /// `nil` in, `nil` out — the Rust side only populates this when + /// something in it is non-empty or true. + private static func migratedDeployedState( + _ state: TwoMLSPQBinding.SessionMigrationDeployedState? + ) throws -> TwoMLSPQSession.MigratedDeployedState? { + guard let state else { return nil } + return TwoMLSPQSession.MigratedDeployedState( + ownOffers: try state.ownOffers.map(ownOfferWindow), + pqWedged: state.pqWedged.map(pqWedge), + noCustody: noCustody(state.noCustody)) + } + + private static func ownOfferWindow( + _ window: TwoMLSPQBinding.SessionMigrationOwnOfferWindow + ) throws -> TwoMLSPQSession.MigratedOwnOfferWindow { + TwoMLSPQSession.MigratedOwnOfferWindow( + epoch: window.epoch, groupID: window.groupId, + senderLeafIndex: window.senderLeafIndex, + offers: try window.offers.map(ownOffer)) + } + + private static func ownOffer( + _ offer: TwoMLSPQBinding.SessionMigrationOwnOffer + ) throws -> TwoMLSPQSession.MigratedOwnOffer { + try TwoMLSPQSession.MigratedOwnOffer( + ref: offer.proposalRef, proposal: offer.proposal, + leafSecret: SecretBytes(bytes: offer.leafSecret)) + } + + private static func pqWedge( + _ kind: TwoMLSPQBinding.SessionMigrationPqWedgeKind + ) -> TwoMLSPQSession.MigratedPQWedge { + switch kind { + case .bootstrap: return .bootstrap + case .ratchet: return .ratchet + case .rekey: return .rekey + } + } + + private static func noCustody( + _ flags: TwoMLSPQBinding.SessionMigrationNoCustody + ) -> Set { + var roles: Set = [] + if flags.sendClassical { roles.insert(.sendClassical) } + if flags.sendPq { roles.insert(.sendPQ) } + if flags.recvClassical { roles.insert(.recvClassical) } + if flags.recvPq { roles.insert(.recvPQ) } + return roles + } + + /// The classical slot always carries the identity's own pair, since the + /// export doesn't require convergence and the actual (possibly still + /// pre-convergence) key lives in `leafKeys.recvClassical` instead. Only + /// the PQ half of this record is ever read back — the classical arm it + /// would otherwise feed is already unreachable, gated on the same + /// `identity.signatureKey` the identity arm above it matches first. private static func recvLeafPrincipal( _ custody: TwoMLSPQBinding.SessionMigrationPqLeafCustody, identity: TwoMLSPQBinding.SessionMigrationIdentity diff --git a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift index 51c4448..c116b06 100644 --- a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift +++ b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift @@ -8,16 +8,15 @@ import TwoMLSPQMigrate import TwoMLSPQSession import XCTest -// Contract 26 (born-dedicated) migration: a REAL Rust born-dedicated pair, driven through -// classical convergence (folding the acceptor's catch-up Upd) and the A.3 bootstrap, then -// the ACCEPTOR's `migrationExport()` -// is minted into a native archive and shown to keep messaging (and committing) with the Rust -// peer, in both directions, and to survive a native re-archive/restore. Companion to +// Contract 26 (born-dedicated) migration: drives a REAL Rust born-dedicated pair through +// classical convergence and the A.3 bootstrap, mints the ACCEPTOR's `migrationExport()` into +// a native archive, and confirms it keeps messaging and committing with the Rust peer, in +// both directions, and survives a native re-archive/restore. Companion to // `SessionMigrationTests` (the non-dedicated differential); see `RustSessionTestHelpers` for // the shared FFI establishment scaffolding. // -// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI -// record types are module-qualified throughout. +// `two_mls_pq` type names collide with this package's wrapper names, so FFI record types are +// module-qualified throughout. @available(macOS 26, iOS 26, *) final class BornDedicatedMigrationTests: XCTestCase { @@ -35,19 +34,18 @@ final class BornDedicatedMigrationTests: XCTestCase { XCTAssertFalse(export.owesEstablishmentEnvelope) XCTAssertFalse(export.initiated) - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) - // A message alice -> bob and bob -> alice. try aliceSays(pair.alice, "alice-to-native-bob", to: &nativeBob) try bobSays(&nativeBob, "native-bob-to-alice", to: pair.alice) - // One committing round EACH way. - // (i) alice folds native bob's Upd. + // One committing round each way. (i) alice folds native bob's Upd. _ = try nativeBob.prepareToEncrypt() let bobUpdFrame = try nativeBob.encrypt(Data("bob-upd".utf8)) let aliceOffered = try XCTUnwrap( @@ -84,14 +82,10 @@ final class BornDedicatedMigrationTests: XCTestCase { XCTAssertEqual( aliceGotCommit.applicationMessage?.appMessageData, Data("bob-commit".utf8)) - // A message each way, again — `makeSessionArchive` is `internal` (unreachable - // here), so re-archive via the return-cadence `StateUpdate` `encrypt()` already - // carries (`EncryptResult.update` — the pending-advance state a caller needs to - // persist rides every ordinary send, never a separate opt-in step), paired with - // the ORIGINAL `.checkpoint` mint. `encrypt` never touches a PQ tree, so this - // stays `.core` — - // exactly the app's own two-slot persistence shape, and nothing moved the PQ - // trees since the mint, so the checkpoint pairs with it cleanly. + // Re-archive by pairing the original `.checkpoint` mint with the `.core` `StateUpdate` + // that `encrypt()` returns on every send (`makeSessionArchive` itself is `internal`, + // unreachable from here). `encrypt` never touches the PQ tree, so that checkpoint + // still applies unchanged. try aliceSays(pair.alice, "alice-to-native-bob-2", to: &nativeBob) let latestCore = try bobSaysCapturingCore( &nativeBob, "native-bob-to-alice-2", to: pair.alice) @@ -106,19 +100,19 @@ final class BornDedicatedMigrationTests: XCTestCase { // MARK: - Exercise the exported PQ custody key via a mechanical A.5 - /// A.2/A.4 never sign in recv.pq, so a mechanical A.5 (native bob as initiator) is the - /// only round that ever uses the custodied PQ signing key: `pqRekeyBegin` proposes a - /// plain (non-rotating) self-Update signed with it into `recvGroup.pq`. + /// A.2/A.4 never sign in recv.pq, so this mechanical A.5 round (native bob as initiator) + /// is the only path that exercises the custodied PQ signing key, via `pqRekeyBegin`'s + /// plain self-Update into `recvGroup.pq`. func testMigratedAcceptorSignsWithCustodiedPQKeyDuringA5Rekey() throws { - // The AT-DISCHARGE point, not `bornDedicatedSessionPair`: nothing has sent since - // the A.3 bind discharge, so nothing has auto-staged, and `pqRekeyBegin`'s clean - // slate precondition holds without needing to drain anything (a drain would - // discharge a full round and pass the PQ turn away, defeating the point). + // Must start at the AT-DISCHARGE point, not `bornDedicatedSessionPair`: nothing has + // sent since the A.3 bind discharge, so `pqRekeyBegin`'s clean-slate precondition + // holds without draining anything (a drain would pass the PQ turn away). let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() let export = try pair.bob.migrationExport() - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -156,24 +150,24 @@ final class BornDedicatedMigrationTests: XCTestCase { aliceGotCommit.applicationMessage?.appMessageData, Data("a5-bind-commit".utf8)) - // A message each way — the round closed cleanly. try aliceSays(pair.alice, "post-a5-alice", to: &nativeBob) try bobSays(&nativeBob, "post-a5-bob", to: pair.alice) } // MARK: Mutations - func testNilPQLeafCustodyThrowsArchiveInvalid() throws { + /// Nulling `pqLeafCustody` doesn't break the mint: `leafKeys` is authoritative for key + /// resolution and independently carries the acceptor's recv-PQ custody. Contrast + /// `testFlippedPQLeafCustodySigningKeyThrowsArchiveInvalid`, where a present but wrong + /// `pqLeafCustody` still fails its own derive-check. + func testNilPQLeafCustodyNoLongerBreaksTheMint() throws { let pair = try RustSessionTestHelpers.bornDedicatedSessionPair() var export = try pair.bob.migrationExport() export.pqLeafCustody = nil - XCTAssertThrowsError( - try SessionMigrator.mintArchive( + XCTAssertNoThrow( + try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) - ) { error in - XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .archiveInvalid) - } + classicalProvider: classicalProvider, pqProvider: pqProvider)) } func testFlippedPQLeafCustodySigningKeyThrowsArchiveInvalid() throws { @@ -181,7 +175,7 @@ final class BornDedicatedMigrationTests: XCTestCase { var export = try pair.bob.migrationExport() export.pqLeafCustody?.pqSigningKey[0] ^= 0xFF XCTAssertThrowsError( - try SessionMigrator.mintArchive( + try SessionMigrator.mint( kind: .checkpoint, from: export, classicalProvider: classicalProvider, pqProvider: pqProvider) ) { error in @@ -189,20 +183,101 @@ final class BornDedicatedMigrationTests: XCTestCase { } } - // MARK: Negatives — refused before convergence + // MARK: Pre-convergence: now mints and drives a native session too + // + // A born-dedicated acceptor that hasn't installed its establishment envelope, or has + // installed but not yet converged, still exports cleanly (`owesEstablishmentEnvelope` + // reports the pre-install case; the custody search resolves whatever each leaf currently + // presents). These tests mint, restore natively, and keep driving the same protocol + // steps the live Rust pair would take next. - func testPreInstallBobExportThrowsSessionNotReady() throws { + func testPreInstallBobExportNowSucceeds() throws { let pair = try RustSessionTestHelpers.bornDedicatedPending() - XCTAssertThrowsError(try pair.bob.migrationExport()) { error in - XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + let export = try pair.bob.migrationExport() + XCTAssertTrue(export.owesEstablishmentEnvelope) + + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Bob owed his contract-26 handoff pre-migration; native enforces the same + // non-emittable gate, so install it before anything can send. + let signedEnvelope = Data("bd-signed-establishment-delegation".utf8) + _ = try nativeBob.installEstablishmentEnvelope(signedEnvelope) + + // Native bob's first frame carries the `0x0B` handoff staple, so Rust alice pauses + // on it; approve it out of band (mirroring `installMockEstablishmentEnvelope`) and + // she joins in the same call. + _ = try nativeBob.prepareToEncrypt() + let firstFrame = try nativeBob.encrypt(Data("bob-first".utf8)) + let alicePaused = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: firstFrame.frame)) + let pending = try XCTUnwrap(alicePaused.pendingEstablishment) + XCTAssertEqual(pending.envelope, signedEnvelope) + let aliceResumed = try pair.alice.processIncomingApproved( + ciphertext: firstFrame.frame, + approvedEnvelopeDigest: Data(SHA256.hash(data: pending.envelope)), + approvedWelcomeDigest: Data(SHA256.hash(data: pending.welcome)), + expectedCreator: pair.dedicatedId) + XCTAssertEqual( + aliceResumed?.applicationMessage?.appMessageData, Data("bob-first".utf8)) + + _ = try pair.alice.prepareToEncrypt(proposing: nil) + let aliceFrame = try pair.alice.encrypt(appMessage: Data("alice-1".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return } + XCTAssertEqual(bobDecrypted.applicationMessage, Data("alice-1".utf8)) } - func testInstalledButUnfoldedBobExportThrowsSessionNotReady() throws { - let (pair, _) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() - XCTAssertThrowsError(try pair.bob.migrationExport()) { error in - XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) + func testInstalledButUnfoldedBobExportNowSucceeds() throws { + let (pair, bobUpd) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() + let export = try pair.bob.migrationExport() + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Alice folds bob's still-outstanding catch-up Upd (the recv-classical lag + // `leafKeys.recvClassical.pending[dedicatedId]` carried across the mint), + // converging her copy to his dedicated identity. Bob's install-then-confirm frame is + // his first-ever send but lands as a window entry rather than framed, so retry with + // the minted window blob on `.ownOfferWindowRequired`. + try pair.alice.queueProposal(digest: bobUpd.digest) + let alicePrepared = try pair.alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared.didCommit, "alice's fold of bob's catch-up Upd should commit") + let aliceCommitFrame = try pair.alice.encrypt(appMessage: Data("alice-commit".utf8)) + let bobOpened: TwoMLSPQSession.IncomingResult + do { + bobOpened = try nativeBob.processIncoming(aliceCommitFrame.cipherText) + } catch TwoMLSPQSession.TwoMLSError.ownOfferWindowRequired { + let window = try XCTUnwrap( + minted.ownOfferWindow, + "the mint must have returned a window if native demands one") + bobOpened = try nativeBob.processIncoming( + aliceCommitFrame.cipherText, ownOfferWindow: window.archive) } + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + XCTAssertEqual(bobDecrypted.applicationMessage, Data("alice-commit".utf8)) + XCTAssertTrue( + bobDecrypted.didApplyRemoteCommit, + "native bob should see alice's fold of his catch-up applied") + + try aliceSays(pair.alice, "post-convergence-alice", to: &nativeBob) + try bobSays(&nativeBob, "post-convergence-bob", to: pair.alice) } // MARK: - Native <-> Rust one-frame helpers (mirrors `RustSessionTestHelpers`) @@ -228,9 +303,9 @@ final class BornDedicatedMigrationTests: XCTestCase { _ = try bobSaysCapturingCore(&nativeBob, text, to: alice) } - /// Like `bobSays`, but returns the `EncryptResult`'s own `.core`-kind `StateUpdate` - /// archive — the only public way to pull a fresh archive out of a live - /// `TwoMLSSession` (`makeSessionArchive` is `internal`). + /// Like `bobSays`, but also returns the `EncryptResult`'s `.core` `StateUpdate` archive — + /// the only public way to pull a fresh archive out of a live session (`makeSessionArchive` + /// is `internal`). @discardableResult private func bobSaysCapturingCore( _ nativeBob: inout TwoMLSPQSession.TwoMLSSession, _ text: String, diff --git a/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift new file mode 100644 index 0000000..b45ed94 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift @@ -0,0 +1,666 @@ +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// `leafKeys`/`deployedState` cross-engine cases the mapper's core round-trip suites don't +// otherwise cover: a rotate-before-bind mis-signed A.5 wedge, a two-rotation session, and the +// own-offer window end to end. Companion to `SessionMigrationTests` and +// `RotatedRekeyHealTests`; see `RustSessionTestHelpers` for the shared FFI scaffolding. +// +// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI +// record types are module-qualified throughout. + +@available(macOS 26, iOS 26, *) +final class DeployedStateMigrationTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + // MARK: - Rotate-before-bind: a mis-signed A.5 parks, then drops at import + + /// Alice rotates D -> N before her A.3 bind, so her self-driven A.5 Upd' is mis-signed + /// — framed with N against a leaf the peer's tree still has on record as D. Export must + /// carry the parked round; native import drops it and resumes with a plain ratchet. + func testRotateBeforeBindWedgeDropsAtImportAndSelfDrivesAPlainRatchet() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rbb-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("rbb-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + let heldKp: Data + switch try bobInvitation.openInitial(blob: kpEnvelope) { + case .bootstrapKp(let frame): heldKp = frame + case let other: + throw NSError( + domain: "rbb", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope, got \(other)" + ]) + } + let welcome: Data + switch try bobInvitation.openInitial(blob: replyEnvelope) { + case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) + case let other: + throw NSError( + domain: "rbb", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope, got \(other)" + ]) + } + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("rbb-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + // Bob's leg 2: his send-PQ tree (Group_B.pq) is founded HERE, from alice's KP + // frozen at `initiate()` — the presentation that never gets revised. + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + let dPQSignatureKey = try aliceSession.migrationExport().identity.pqSignatureKey + + let newAliceId = TwoMLSPQBinding.ClientId(bytes: Data("rbb-alice-rotated".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: newAliceId) + let rotateFrame = try aliceSession.encrypt(appMessage: Data("rotate".utf8)) + let bobRotateOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: rotateFrame.cipherText)) + let rotateOffered = try XCTUnwrap(bobRotateOpened.proposal) + try bobSession.queueProposal(digest: rotateOffered.digest) + let bobFold = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold.didCommit) + let canonicalizeFrame = try bobSession.encrypt( + appMessage: Data("canonicalize".utf8)) + let aliceCanonOpened = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: canonicalizeFrame.cipherText)) + let remoteCommit = try XCTUnwrap(aliceCanonOpened.remoteCommit) + XCTAssertEqual(remoteCommit.newRecipient, newAliceId) + + // A.3 leg 3 binds against alice's CURRENT (rotated) principal, while bob's + // send-PQ tree still carries her original KP's presentation. + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + + // Bob (turn holder, nothing of HIS lags) opens a plain A.4; draining it hands + // the turn to alice, whose leaf already lags the pre-bind rotation. + _ = try bobSession.prepareToEncrypt(proposing: nil) + let bobOpener = try bobSession.encrypt(appMessage: Data("post-a3-opener".utf8)) + _ = try aliceSession.processIncoming(ciphertext: bobOpener.cipherText) + let incidentalEk = try XCTUnwrap(bobSession.pqPendingOutbound(sealing: .fresh)) + try aliceSession.pqRatchetRespond(ekMsg: incidentalEk) + let incidentalCt = try XCTUnwrap(aliceSession.pqTakePendingOutbound()) + try bobSession.pqRatchetBind(ctMsg: incidentalCt) + try RustSessionTestHelpers.committingRound(binder: bobSession, peer: aliceSession) + XCTAssertTrue(aliceSession.myPqTurn()) + + // Self-stages the A.5 Upd' — mis-signed with N against a leaf still presenting D. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let opener = try aliceSession.encrypt(appMessage: Data("open-a5".utf8)) + _ = try bobSession.processIncoming(ciphertext: opener.cipherText) + + // Export alice: CARRIED, not refused. + let export = try aliceSession.migrationExport() + guard case .rekeyInitiated = export.pqInflight else { + XCTFail( + "expected a parked rekey-initiated round, got " + + "\(String(describing: export.pqInflight))") + return + } + let recvPQCurrent = try XCTUnwrap(export.leafKeys.recvPq.current) + XCTAssertEqual( + recvPQCurrent.signatureKey, dPQSignatureKey, + "recv-PQ's own leaf still presents the pre-rotation identity D, not N") + + // Recv-classical still has un-folded self-proposals from the rotate/canonicalize/ + // opener sends, so an own-offer window may ride the export too — irrelevant here, + // so mint via the window-tolerant entry point and take only the archive. + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // The parked Upd' is DROPPED at import: nothing outstanding to resend. + XCTAssertNil( + nativeAlice.pqPendingOutbound(), + "no pending side-band should survive import") + + // Once the PQ turn is alice's, her next send opens a plain A.4 (not an A.5), + // classified by the Rust peer as the ratchet EK leg, under the carried key. + XCTAssertTrue(nativeAlice.myPQTurn) + _ = try nativeAlice.prepareToEncrypt() + let followUp = try nativeAlice.encrypt(Data("post-restore-a4".utf8)) + let bobOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: followUp.frame)) + XCTAssertEqual( + bobOpened.applicationMessage?.appMessageData, Data("post-restore-a4".utf8)) + let ekLeg = try XCTUnwrap(nativeAlice.pqPendingOutbound()) + let classified = try bobSession.openIncoming(blob: ekLeg) + XCTAssertEqual(classified?.kind, .pqSideBand(kind: .ratchetEphemeralKey)) + XCTAssertNoThrow(try bobSession.pqRatchetRespond(ekMsg: ekLeg)) + } + + // MARK: - Two rotations: each PQ group's `current` is its own presented key + + /// Alice rotates id0 -> id1 through a completed A.5 (recv-PQ converges to id1; send-PQ, + /// never a round's RESPONDER here, still lags id0), then id1 -> id2 purely classically + /// with no A.5 at all — each PQ group's `current` must equal the key it's actually + /// presenting. + func testTwoRotationsEachPQGroupCurrentIsItsPresentedKey() throws { + let (aliceSession, bobSession) = try establishFullPair() + + // id0: alice's PQ key before any rotation — the key her send-PQ leaf will keep + // presenting throughout (it never responds to a peer-opened round). + let id0PQSignatureKey = try aliceSession.migrationExport().identity.pqSignatureKey + + // Post-`establishFullPair`, bob holds the PQ turn — alice is already the + // non-turn-holder `rekey_round` requires, so no extra flip is needed. + XCTAssertFalse(aliceSession.myPqTurn()) + + // --- Rotation 1: id0 -> id1, driven all the way through a completed A.5. --- + let id1 = TwoMLSPQBinding.ClientId(bytes: Data("two-rot-id1".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: id1) + let rotate1Frame = try aliceSession.encrypt(appMessage: Data("rotate1".utf8)) + let bobRotate1Opened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: rotate1Frame.cipherText)) + let rotate1Offered = try XCTUnwrap(bobRotate1Opened.proposal) + try bobSession.queueProposal(digest: rotate1Offered.digest) + let bobFold1 = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold1.didCommit) + XCTAssertEqual(bobFold1.committedRemoteClientId, id1) + let canonicalize1Frame = try bobSession.encrypt( + appMessage: Data("canonicalize1".utf8)) + let aliceCanon1Opened = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: canonicalize1Frame.cipherText)) + XCTAssertEqual(aliceCanon1Opened.remoteCommit?.newRecipient, id1) + + // Bob (turn holder) incidentally auto-stages a plain A.4 on that canonicalize + // send — the one-round catch-up deferral; drain it to pass the turn to alice. + let incidentalEk = try XCTUnwrap(bobSession.pqPendingOutbound(sealing: .fresh)) + try aliceSession.pqRatchetRespond(ekMsg: incidentalEk) + let incidentalCt = try XCTUnwrap(aliceSession.pqTakePendingOutbound()) + try bobSession.pqRatchetBind(ctMsg: incidentalCt) + try RustSessionTestHelpers.committingRound(binder: bobSession, peer: aliceSession) + XCTAssertTrue(aliceSession.myPqTurn()) + + // Alice (turn holder, leaf lagging) self-stages the A.5 Upd' announcing id1. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let rekeyOpener = try aliceSession.encrypt(appMessage: Data("open-a5".utf8)) + _ = try bobSession.processIncoming(ciphertext: rekeyOpener.cipherText) + let upd = try XCTUnwrap(aliceSession.pqPendingOutbound(sealing: .stable)) + let classified = try bobSession.openIncoming(blob: upd) + XCTAssertEqual(classified?.kind, .pqSideBand(kind: .rekeyUpdate)) + let rotatedTo = try bobSession.pqRekeyRespond(updMsg: upd) + XCTAssertEqual(rotatedTo, id1) + let commitPrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + try aliceSession.pqRekeyApply(msg: commitPrime) + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + + // --- Rotation 2: id1 -> id2, purely classical — no A.5 for it at all. --- + let id2 = TwoMLSPQBinding.ClientId(bytes: Data("two-rot-id2".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: id2) + let rotate2Frame = try aliceSession.encrypt(appMessage: Data("rotate2".utf8)) + let bobRotate2Opened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: rotate2Frame.cipherText)) + let rotate2Offered = try XCTUnwrap(bobRotate2Opened.proposal) + try bobSession.queueProposal(digest: rotate2Offered.digest) + let bobFold2 = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold2.didCommit) + XCTAssertEqual(bobFold2.committedRemoteClientId, id2) + let canonicalize2Frame = try bobSession.encrypt( + appMessage: Data("canonicalize2".utf8)) + let aliceCanon2Opened = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: canonicalize2Frame.cipherText)) + XCTAssertEqual(aliceCanon2Opened.remoteCommit?.newRecipient, id2) + + let export = try aliceSession.migrationExport() + + // send-PQ still lags id0 — it was never the RESPONDER of any round. + let sendPQ = export.leafKeys.sendPq + let sendCurrent = try XCTUnwrap(sendPQ.current) + XCTAssertEqual(sendCurrent.signatureKey, id0PQSignatureKey) + let sendCatchUp = try XCTUnwrap(sendPQ.pending.first { $0.target == id2.bytes }) + XCTAssertEqual(sendCatchUp.key.signatureKey, export.identity.pqSignatureKey) + + // recv-PQ converged to id1 via the completed A.5, but now also lags id2. + let recvPQ = export.leafKeys.recvPq + let recvCurrent = try XCTUnwrap(recvPQ.current) + XCTAssertNotEqual( + recvCurrent.signatureKey, id0PQSignatureKey, + "recv-PQ must have converged off id0 via the completed A.5") + let recvCatchUp = try XCTUnwrap(recvPQ.pending.first { $0.target == id2.bytes }) + XCTAssertEqual(recvCatchUp.key.signatureKey, export.identity.pqSignatureKey) + + // The mint's own `validateLeafKeys` independently re-checks that every EXISTING + // group's `current` equals what its leaf actually presents, so a successful + // restore is itself the cross-engine proof. + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Messaging works both ways. + _ = try bobSession.prepareToEncrypt(proposing: nil) + let bobFrame = try bobSession.encrypt( + appMessage: Data("bob-to-migrated-alice".utf8)) + let opened = try nativeAlice.processIncoming(bobFrame.cipherText) + guard case .decrypted(let decrypted) = opened else { + XCTFail("expected a decrypted application frame, got \(opened)") + return + } + XCTAssertEqual(decrypted.applicationMessage, Data("bob-to-migrated-alice".utf8)) + + _ = try nativeAlice.prepareToEncrypt() + let reply = try nativeAlice.encrypt(Data("migrated-alice-to-bob".utf8)) + let bobGot = try XCTUnwrap(bobSession.processIncoming(ciphertext: reply.frame)) + XCTAssertEqual( + bobGot.applicationMessage?.appMessageData, + Data("migrated-alice-to-bob".utf8)) + } + + // MARK: - The own-offer window end to end + + /// Alice re-proposes the same rotation candidate three times, never letting bob fold + /// any of them, then migrates at rest (nothing framed, so recency is unrecoverable — + /// all three offers are window candidates, plus one baseline same-identity refresh + /// `establishFullPair` already leaves cached). Bob folds the OLDEST offer, out of + /// order relative to alice's current: native import must resolve it via the window + /// blob, not the framed entry. + func testOwnOfferWindowLetsBobFoldAnOlderOfferAfterAliceMigrates() throws { + let (aliceSession, bobSession) = try establishFullPair() + let candidate = TwoMLSPQBinding.ClientId(bytes: Data("window-candidate".utf8)) + + // Offer #1: delivered to bob now (he holds it "offered", unqueued) — this is the + // one he folds later, out of order. + _ = try aliceSession.prepareToEncrypt(proposing: candidate) + let frame1 = try aliceSession.encrypt(appMessage: Data("re-propose-1".utf8)) + let bobOffer1 = try XCTUnwrap( + bobSession.processIncoming(ciphertext: frame1.cipherText)) + let offer1 = try XCTUnwrap(bobOffer1.proposal) + + // Offer #2 and #3 (the eventual framed/latest one): alice moves on locally: bob + // never sees either. + _ = try aliceSession.prepareToEncrypt(proposing: candidate) + _ = try aliceSession.encrypt(appMessage: Data("re-propose-2".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: candidate) + _ = try aliceSession.encrypt(appMessage: Data("re-propose-3".utf8)) + + // At rest nothing is framed, so the window carries all three offers plus the + // baseline same-identity refresh `establishFullPair` already left cached (4 total). + let export = try aliceSession.migrationExport() + let window = try XCTUnwrap( + export.deployedState?.ownOffers, "expected an outstanding own-offer window") + XCTAssertEqual(window.offers.count, 4) + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let mintedWindow = try XCTUnwrap( + minted.ownOfferWindow, "the mint must return the window blob alongside it") + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Rust bob folds offer #1 — an OLDER offer, delivered out of order relative to + // alice's own current (#3). + try bobSession.queueProposal(digest: offer1.digest) + let bobPrepared = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "bob's fold of the older offer should commit") + let foldFrame = try bobSession.encrypt(appMessage: Data("fold-older".utf8)) + + // Native alice's `processIncoming` without the blob throws `.ownOfferWindowRequired` + // — her framed `stagedUpdates` only carries offer #3, not the ref bob's commit + // names. Retryable: a separate copy proves nothing changed. + var nativeAliceRetry = nativeAlice + XCTAssertThrowsError(try nativeAliceRetry.processIncoming(foldFrame.cipherText)) { + error in + XCTAssertEqual( + error as? TwoMLSPQSession.TwoMLSError, .ownOfferWindowRequired) + } + + // The retry with the blob succeeds. + let result = try nativeAlice.processIncoming( + foldFrame.cipherText, ownOfferWindow: mintedWindow.archive) + guard case .decrypted(let decrypted) = result else { + XCTFail("expected a decrypted application frame, got \(result)") + return + } + XCTAssertEqual(decrypted.applicationMessage, Data("fold-older".utf8)) + XCTAssertTrue(decrypted.didApplyRemoteCommit) + } + + /// A real same-id candidate (K′) offer must survive the export window alongside a + /// plain same-key refresh `establishFullPair` already leaves cached — proving it's + /// genuinely in the window, not just in `pending[mine.current]` where the refresh + /// could otherwise have evicted it. + func testOwnOfferWindowKeepsSameIdCandidateOfferPastAPlainRefresh() throws { + let (aliceSession, bobSession) = try establishFullPair() + let ownId = TwoMLSPQBinding.ClientId(bytes: Data("dsm-alice".utf8)) + + _ = try aliceSession.prepareToEncrypt(proposing: ownId) + let kPrimeFrame = try aliceSession.encrypt(appMessage: Data("same-id-k-prime".utf8)) + let bobOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: kPrimeFrame.cipherText)) + let kPrimeOffer = try XCTUnwrap(bobOpened.proposal) + + let export = try aliceSession.migrationExport() + let recvEntry = try XCTUnwrap( + export.leafKeys.recvClassical.pending.first { $0.target == ownId.bytes }) + let window = try XCTUnwrap( + export.deployedState?.ownOffers, "expected an outstanding own-offer window") + // `proposalRef` (mls-rs's reference) and `QueuedRemoteProposal.digest` (bob's + // independently computed SHA-256 binding) are different identifiers for the same + // proposal and can't be compared directly — the window count plus the downstream + // fold are the real proof; per-offer decode is covered on the Rust side. + XCTAssertEqual( + window.offers.count, 2, + "the K′ offer and establishFullPair's baseline refresh, matching " + + "pending[mine.current] (\(recvEntry.key.signatureKey))") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let mintedWindow = try XCTUnwrap( + minted.ownOfferWindow, "the mint must return the window blob alongside it") + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Rust bob folds the K′ offer. + try bobSession.queueProposal(digest: kPrimeOffer.digest) + let bobPrepared = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "bob's fold of the K′ offer should commit") + XCTAssertEqual(bobPrepared.committedRemoteClientId, ownId) + let foldFrame = try bobSession.encrypt(appMessage: Data("fold-k-prime".utf8)) + + // Native alice's first attempt, no window blob, must throw. + var nativeAliceRetry = nativeAlice + XCTAssertThrowsError(try nativeAliceRetry.processIncoming(foldFrame.cipherText)) { + error in + XCTAssertEqual( + error as? TwoMLSPQSession.TwoMLSError, .ownOfferWindowRequired) + } + + // The retry with the blob succeeds — the K′ offer really is in the window. + let result = try nativeAlice.processIncoming( + foldFrame.cipherText, ownOfferWindow: mintedWindow.archive) + guard case .decrypted(let decrypted) = result else { + XCTFail("expected a decrypted application frame, got \(result)") + return + } + XCTAssertEqual(decrypted.applicationMessage, Data("fold-k-prime".utf8)) + XCTAssertTrue(decrypted.didApplyRemoteCommit) + } + + // MARK: - Same-id candidate coverage + + /// Bob repeatedly re-proposes his own dedicated id with a fresh same-id candidate key, + /// never folded by alice — `recv_classical`'s pending set must collapse every + /// re-proposal of the same target to one entry, not grow with the call count. + func testBornDedicatedRepeatedSameIdCandidateFramesCollapseToOnePendingEntry() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() + + for i in 0..<5 { + _ = try pair.bob.prepareToEncrypt( + proposing: TwoMLSPQBinding.ClientId(bytes: pair.dedicatedId)) + let frame = try pair.bob.encrypt(appMessage: Data("catchup-\(i)".utf8)) + // Left unfolded: alice receives but never queues/commits it. + _ = try pair.alice.processIncoming(ciphertext: frame.cipherText) + } + + let export = try pair.bob.migrationExport() + let forDedicated = export.leafKeys.recvClassical.pending.filter { + $0.target == pair.dedicatedId + } + XCTAssertEqual( + forDedicated.count, 1, + "N re-proposals of the same target must collapse to one pending entry") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + /// Bob has both a real identity-signed catch-up offer and a same-id K′-signed + /// candidate outstanding for his own dedicated id — the identity's own key must win + /// `recv_classical`'s pending entry. + func testBornDedicatedMixedIdentityAndCandidateOffersPreferTheIdentityKey() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() + + // A plain self-refresh: bob's recv-classical leaf still presents the invitation + // identity, so re-proposing his OWN (dedicated) identity is a genuine credential + // change — a real, signer-carrying entry under the identity's own key. Left + // unfolded. + _ = try pair.bob.prepareToEncrypt(proposing: nil) + let identityFrame = try pair.bob.encrypt(appMessage: Data("identity-catchup".utf8)) + _ = try pair.alice.processIncoming(ciphertext: identityFrame.cipherText) + + // A same-id candidate's own re-proposal of the SAME dedicated id, signed with a + // fresh key K′ — coexists with the identity-signed offer above. + _ = try pair.bob.prepareToEncrypt( + proposing: TwoMLSPQBinding.ClientId(bytes: pair.dedicatedId)) + let candidateFrame = try pair.bob.encrypt( + appMessage: Data("same-id-candidate".utf8)) + _ = try pair.alice.processIncoming(ciphertext: candidateFrame.cipherText) + + let export = try pair.bob.migrationExport() + let recvEntry = try XCTUnwrap( + export.leafKeys.recvClassical.pending.first { + $0.target == pair.dedicatedId + }) + XCTAssertEqual( + recvEntry.key.signatureKey, export.identity.signatureKey, + "the identity's key must win when both a real identity offer and a same-id " + + "candidate are outstanding") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + /// A same-id candidate's real K′ offer at rest (the mandatory handoff), then an + /// identity-signed catch-up left FRAMED (`prepareToEncrypt`, not `encrypt`ed) — the + /// framed entry must win `recv_classical`'s pending entry, and the mint must still + /// accept the archive even though the K′ offer stays in the window despite losing here + /// (mint independently re-checks every window offer against current/pending). + func testBornDedicatedFramedIdentityCatchupDropsTheRealSameIdCandidateOffer() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedSameIdHandoffUnfolded() + + // A plain self-refresh: bob's recv-classical leaf still presents the invitation + // identity, so this is a genuine credential change — an identity-signed catch-up, + // left FRAMED. The mandatory handoff already left a real, unfolded same-id + // candidate (K′) offer outstanding for bob's dedicated id. + _ = try pair.bob.prepareToEncrypt(proposing: nil) + + let export = try pair.bob.migrationExport() + let recvEntry = try XCTUnwrap( + export.leafKeys.recvClassical.pending.first { + $0.target == pair.dedicatedId + }) + XCTAssertEqual( + recvEntry.key.signatureKey, export.identity.signatureKey, + "the framed identity-signed catch-up wins mine.current's pending entry") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + /// A real identity-signed catch-up at rest, then the same-id candidate's own + /// re-proposal left FRAMED — the framed entry (it rides the snapshot, not the window) + /// must win `recv_classical`'s pending entry even with a real identity offer also + /// outstanding, and the mint must still accept the archive. + func testBornDedicatedFramedSameIdCandidateWinsOverARealIdentityCatchup() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedSameIdHandoffUnfolded() + + // A plain self-refresh: bob's recv-classical leaf still presents the invitation + // identity, so this is a genuine credential change — a real, identity-signed + // catch-up, left AT REST (encrypted) alongside the mandatory handoff's still-real, + // still-unfolded same-id candidate (K′) offer. + _ = try pair.bob.prepareToEncrypt(proposing: nil) + let identityFrame = try pair.bob.encrypt(appMessage: Data("identity-catchup".utf8)) + _ = try pair.alice.processIncoming(ciphertext: identityFrame.cipherText) + + // The same same-id candidate, re-proposed — reuses the existing staged candidate + // (same key), but this new proposal is left FRAMED (no paired `encrypt`). + _ = try pair.bob.prepareToEncrypt( + proposing: TwoMLSPQBinding.ClientId(bytes: pair.dedicatedId)) + + let export = try pair.bob.migrationExport() + let recvEntry = try XCTUnwrap( + export.leafKeys.recvClassical.pending.first { + $0.target == pair.dedicatedId + }) + XCTAssertNotEqual( + recvEntry.key.signatureKey, export.identity.signatureKey, + "the framed same-id candidate offer must win over a real identity-signed " + + "catch-up, not the identity's key") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + /// A framed entry can go stale without ever being cleared: bob folds and commits + /// offer-1 while alice's offer-2 stays framed, unencrypted, so her recv-classical + /// epoch advances with the stale offer-2 still in pending-proposal bookkeeping. + /// Folding offer-1 also canonicalizes id1 as alice's principal, abandoning id2's + /// candidacy — `recvClassical.pending` ends up empty, yet export, restore, and a + /// subsequent send must all still succeed. + func testStaleFramedEntrySurvivesExportHarmlessly() throws { + let (alice, bob) = try establishFullPair() + + let id1 = TwoMLSPQBinding.ClientId(bytes: Data("stale-id1".utf8)) + _ = try alice.prepareToEncrypt(proposing: id1) + let enc1 = try alice.encrypt(appMessage: Data("offer-1".utf8)) + let offer1 = try XCTUnwrap( + bob.processIncoming(ciphertext: enc1.cipherText)?.proposal) + + let id2 = TwoMLSPQBinding.ClientId(bytes: Data("stale-id2".utf8)) + _ = try alice.prepareToEncrypt(proposing: id2) + + try bob.queueProposal(digest: offer1.digest) + let bobPrepared = try bob.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobPrepared.didCommit, "bob's fold of offer-1 must commit") + let commitFrame = try bob.encrypt(appMessage: Data("canonicalize".utf8)) + + _ = try alice.processIncoming(ciphertext: commitFrame.cipherText) + + let export = try alice.migrationExport() + XCTAssertTrue( + export.leafKeys.recvClassical.pending.isEmpty, + "id1's canonicalization abandons id2's own candidacy along with it") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // A subsequent native send must still work. + _ = try nativeAlice.prepareToEncrypt() + let followUp = try nativeAlice.encrypt(Data("post-restore".utf8)) + let bobGot = try XCTUnwrap(bob.processIncoming(ciphertext: followUp.frame)) + XCTAssertEqual(bobGot.applicationMessage?.appMessageData, Data("post-restore".utf8)) + } + + // MARK: - Shared establishment scaffolding + + /// A non-dedicated pair through full establishment (both PQ halves live) and the + /// A.3 bind discharge, with no trailing sends — bob (the discharge's non-binder) + /// holds the PQ turn at return. + private func establishFullPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("dsm-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("dsm-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + let heldKp: Data + switch try bobInvitation.openInitial(blob: kpEnvelope) { + case .bootstrapKp(let frame): heldKp = frame + case let other: + throw NSError( + domain: "dsm", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope, got \(other)" + ]) + } + let welcome: Data + switch try bobInvitation.openInitial(blob: replyEnvelope) { + case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) + case let other: + throw NSError( + domain: "dsm", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope, got \(other)" + ]) + } + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("dsm-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + + XCTAssertTrue(aliceSession.isFullyEstablished()) + XCTAssertTrue(bobSession.isFullyEstablished()) + return (aliceSession, bobSession) + } +} diff --git a/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift b/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift index 26d5ba8..489956e 100644 --- a/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift +++ b/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift @@ -389,9 +389,10 @@ class LegacyRowFixtureTests: XCTestCase { let freshAlice = try restoreAndVerify(point: point, side: "initiator") let export = try freshAlice.migrationExport() - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -480,16 +481,15 @@ class LegacyRowFixtureTests: XCTestCase { aliceFinalDecrypted.applicationMessage, Data("\(point)-final-bob".utf8)) } - // MARK: - Acceptor export: p1 still refused, p2-p6 now migrate + // MARK: - Acceptor export: p1-p6 now export (the Rust exporter no longer + // refuses an unconverged recv-classical leaf) - /// p1: bob has sent but alice has not yet folded his catch-up Upd — his recv-classical - /// leaf still presents the invitation identity's key, and the custody gate refuses it - /// (`SessionNotReady`, not `Mls`). - func testP1AcceptorExportRefusedSessionNotReady() throws { - let bob = try restoreAndVerify(point: "p1-bob-sent-unfolded", side: "acceptor") - XCTAssertThrowsError(try bob.migrationExport()) { error in - XCTAssertEqual(error as? TwoMLSPQBinding.TwoMlsPqError, .SessionNotReady) - } + /// p1: bob has sent his catch-up Upd but alice hasn't folded it yet, so his + /// recv-classical leaf still presents the invitation identity's key — the custody + /// search resolves this, so the export carries it rather than refusing, same as + /// every other point. + func testP1AcceptorExportNowSucceeds() throws { + try assertAcceptorMigrates(point: "p1-bob-sent-unfolded") } func testP2AcceptorMigratesAndMessagesRustInitiator() throws { @@ -512,27 +512,25 @@ class LegacyRowFixtureTests: XCTestCase { try assertAcceptorMigrates(point: "p6-a3-responded-stalled") } - /// The 0.16.0 ACCEPTOR row at `point` (converged onward) now exports, mints, and - /// restores natively: a message each way with a FRESH Rust restore of the initiator - /// row, then a cross-engine committing round each way — mirrors - /// `assertInitiatorMigrates` with the roles swapped. This is the app's real - /// population: born-dedicated, 0.16-written, never-drained parked welcome. + /// The 0.16.0 ACCEPTOR row at `point` exports, mints, and restores natively; a message + /// each way plus a cross-engine committing round each way against a fresh Rust + /// initiator — mirrors `assertInitiatorMigrates` with the roles swapped. private func assertAcceptorMigrates(point: String) throws { let freshBob = try restoreAndVerify(point: point, side: "acceptor") let export = try freshBob.migrationExport() XCTAssertNotNil( export.pqLeafCustody, "\(point): the acceptor's PQ custody should export") - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) let alice = try restoreAndVerify(point: point, side: "initiator") - // Message each way. _ = try alice.prepareToEncrypt(proposing: nil) let aliceFrame = try alice.encrypt( appMessage: Data("\(point)-alice-to-migrated-bob".utf8)) @@ -551,9 +549,7 @@ class LegacyRowFixtureTests: XCTestCase { aliceGot.applicationMessage?.appMessageData, Data("\(point)-migrated-bob-to-alice".utf8)) - // Cross-engine committing round, native bob as binder: Rust alice offers an Upd, - // native bob queues it and his next `prepareToEncrypt` must commit it; Rust alice - // applies the resulting commit. + // Cross-engine committing round, native bob as binder. _ = try alice.prepareToEncrypt(proposing: nil) let aliceUpdFrame = try alice.encrypt(appMessage: Data("\(point)-alice-upd".utf8)) let bobOpened = try nativeBob.processIncoming(aliceUpdFrame.cipherText) @@ -572,8 +568,7 @@ class LegacyRowFixtureTests: XCTestCase { aliceGotCommit.applicationMessage?.appMessageData, Data("\(point)-bob-commit".utf8)) - // Cross-engine committing round, Rust alice as binder: native bob offers an Upd, - // Rust alice queues + commits it; native bob sees the remote commit applied. + // Cross-engine committing round, Rust alice as binder. _ = try nativeBob.prepareToEncrypt() let bobUpdFrame = try nativeBob.encrypt(Data("\(point)-bob-upd".utf8)) let aliceDecrypted = try XCTUnwrap( @@ -596,7 +591,6 @@ class LegacyRowFixtureTests: XCTestCase { bobCommitDecrypted.didApplyRemoteCommit, "native bob should see the remote commit applied") - // Final message each way. _ = try nativeBob.prepareToEncrypt() let finalFromBob = try nativeBob.encrypt(Data("\(point)-final-bob".utf8)) let aliceGotFinal = try XCTUnwrap( @@ -619,12 +613,10 @@ class LegacyRowFixtureTests: XCTestCase { // MARK: - Field-state healing across engines (the real card shape) - /// p6(i): the acceptor (bob) migrates to native; the initiator (alice) stays Rust. Bob's - /// parked Welcome' response (never shipped before capture) must survive BOTH the export - /// and the native restore — shown by taking it off native bob's OWN side-band peek, - /// exactly as a live native host would to re-send a dropped leg. Delivered to Rust - /// alice, the owed bind then discharges with a cross-engine committing round (native bob - /// as the peer, Rust alice as the binder — `assertAcceptorMigrates`'s own pattern). + /// p6(i): the acceptor (bob) migrates to native, the initiator (alice) stays Rust. Bob's + /// parked Welcome' response must survive export and restore, taken off native bob's own + /// side-band peek — as a live host would to re-send a dropped leg — then discharged with + /// alice as binder. func testP6AcceptorMigratedHealsA3WithRustInitiator() throws { let alice = try restoreAndVerify( point: "p6-a3-responded-stalled", side: "initiator") @@ -635,28 +627,26 @@ class LegacyRowFixtureTests: XCTestCase { export.pendingSideBand, "bob's parked Welcome' should be in the export") XCTAssertNotNil(export.pqInflight, "bob's bootstrap-responded state should export") - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) - // Bob's own send.pq was already founded before capture (he answered A.3 himself), - // and his recv.pq (mirroring alice's Group_A.pq) has been his from the initial - // establishment — so native bob is ALREADY fully established right off the - // restore, before the heal below ever runs. Migration must preserve that. + // Bob answered A.3 himself before capture, so his send.pq and recv.pq are already + // founded — native bob is fully established right off restore, before the heal + // below even runs. Migration must preserve that. XCTAssertTrue( nativeBob.isFullyEstablished, "native bob's own A.3 response should already be complete after restore") - // The native side-band take: bob still holds the parked Welcome' after migration. let welcomePrimeSealed = try XCTUnwrap( nativeBob.pqPendingOutbound(), "native bob should still hold the parked Welcome' after migration") - // Deliver to Rust alice and discharge the owed bind across engines: native bob (the - // peer) offers an Upd, Rust alice (the binder) queues + commits it. + // Discharge across engines: native bob (peer) offers, Rust alice (binder) commits. let opened = try XCTUnwrap(try alice.openIncoming(blob: welcomePrimeSealed)) try alice.pqBootstrapBind(welcomeMsg: opened.frame) @@ -683,14 +673,13 @@ class LegacyRowFixtureTests: XCTestCase { ) XCTAssertNil( nativeBob.pqPendingOutbound(), "native bob should have nothing parked") - // The discharge passes the PQ turn from the binder (alice, who owed the bind) to - // the peer (bob) — same shape as the Rust-only p6 heal test above. + // The discharge passes the PQ turn from the binder (alice) to the peer (bob), same + // as the Rust-only p6 heal above. XCTAssertFalse( alice.myPqTurn(), "the discharge should pass the PQ turn away from alice") XCTAssertTrue( nativeBob.myPQTurn, "the discharge should pass the PQ turn to native bob") - // A message each way. _ = try alice.prepareToEncrypt(proposing: nil) let aliceMsg = try alice.encrypt(appMessage: Data("p6h-alice-msg".utf8)) let bobGotMsg = try nativeBob.processIncoming(aliceMsg.cipherText) @@ -706,7 +695,6 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertEqual( aliceGotMsg.applicationMessage?.appMessageData, Data("p6h-bob-msg".utf8)) - // A committing round each way. _ = try alice.prepareToEncrypt(proposing: nil) let aliceUpdFrame = try alice.encrypt(appMessage: Data("p6h-alice-upd".utf8)) let bobOpened2 = try nativeBob.processIncoming(aliceUpdFrame.cipherText) @@ -748,19 +736,18 @@ class LegacyRowFixtureTests: XCTestCase { "native bob should see the remote commit applied") } - /// p6(ii): the initiator (alice) migrates to native; the acceptor (bob) stays Rust. - /// Bob's parked Welcome' (his own §A.3 response, still on the Rust side) is delivered - /// straight into native alice's `pqBootstrapJoin` — which unseals it itself, same as - /// `processIncoming` — joining Group_B.pq and owing the classical bind in one call. The - /// bind then discharges with native alice as the binder (mirrors `assertInitiatorMigrates`). + /// p6(ii): the initiator (alice) migrates to native, the acceptor (bob) stays Rust. Bob's + /// parked Welcome' is delivered straight into native alice's `pqBootstrapJoin`, joining + /// and owing the bind in one call; the bind then discharges with native alice as binder. func testP6InitiatorMigratedHealsA3WithRustAcceptor() throws { let freshAlice = try restoreAndVerify( point: "p6-a3-responded-stalled", side: "initiator") let export = try freshAlice.migrationExport() - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -770,12 +757,11 @@ class LegacyRowFixtureTests: XCTestCase { bob.pqPendingOutbound(sealing: .fresh), "expected bob's parked A.3 Welcome' response to survive restore") - // Native alice's `pqBootstrapJoin` unseals `inbound` itself (like `processIncoming`) - // — no separate open step, unlike the Rust-side `openIncoming` + `pqBootstrapBind`. + // Native alice's `pqBootstrapJoin` unseals `inbound` itself, unlike the Rust-side + // `openIncoming` + `pqBootstrapBind` two-step. _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) - // Discharge across engines: Rust bob (the peer) offers an Upd, native alice (the - // binder) queues + commits it. + // Discharge across engines: Rust bob (peer) offers, native alice (binder) commits. _ = try bob.prepareToEncrypt(proposing: nil) let bobUpdFrame = try bob.encrypt(appMessage: Data("p6h2-bob-upd".utf8)) let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) @@ -802,14 +788,11 @@ class LegacyRowFixtureTests: XCTestCase { nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") XCTAssertNil( bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") - // The discharge passes the PQ turn from the binder (native alice) to the peer - // (Rust bob). XCTAssertFalse( nativeAlice.myPQTurn, "the discharge should pass the PQ turn away from alice") XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") - // A message each way. _ = try nativeAlice.prepareToEncrypt() let aliceMsg = try nativeAlice.encrypt(Data("p6h2-alice-msg".utf8)) let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) @@ -825,7 +808,6 @@ class LegacyRowFixtureTests: XCTestCase { } XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p6h2-bob-msg".utf8)) - // A committing round each way. _ = try nativeAlice.prepareToEncrypt() let aliceUpdFrame = try nativeAlice.encrypt(Data("p6h2-alice-upd".utf8)) let bobDecrypted = try XCTUnwrap( @@ -862,25 +844,23 @@ class LegacyRowFixtureTests: XCTestCase { Data("p6h2-alice-commit2".utf8)) } - /// p4(iii): the initiator (alice) migrates to native; the acceptor (bob) stays Rust. - /// p4's shape is earlier than p6's — alice's KP' was parked but bob never responded — so - /// native alice's OWN side-band take is what must survive the migration this time, and - /// the round runs its FULL A.3 shape: deliver KP' to Rust bob (`pqBootstrapRespond`), his - /// Welcome' response back to native alice (`pqBootstrapJoin`), then discharge. + /// p4(iii): the initiator (alice) migrates to native, the acceptor (bob) stays Rust. p4 is + /// earlier than p6 — alice's KP' was parked but bob never responded — so the round runs + /// A.3's full shape: KP' to Rust bob, his Welcome' back to native alice, then discharge. func testP4InitiatorMigratedHealsA3WithRustAcceptor() throws { let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") let export = try freshAlice.migrationExport() XCTAssertNotNil( export.pendingSideBand, "alice's parked KP' leg should be in the export") - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) - // The native side-band take: alice still holds her parked KP' after migration. let sealedKp = try XCTUnwrap( nativeAlice.pqPendingOutbound(), "native alice should still hold her parked KP' after migration") @@ -893,8 +873,7 @@ class LegacyRowFixtureTests: XCTestCase { // Native's `pqBootstrapJoin` unseals `inbound` itself. _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) - // Discharge across engines: Rust bob (the peer) offers, native alice (the binder) - // commits. + // Discharge across engines: Rust bob (peer) offers, native alice (binder) commits. _ = try bob.prepareToEncrypt(proposing: nil) let bobUpdFrame = try bob.encrypt(appMessage: Data("p4h-bob-upd".utf8)) let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) @@ -921,14 +900,11 @@ class LegacyRowFixtureTests: XCTestCase { nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") XCTAssertNil( bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") - // The discharge passes the PQ turn from the binder (native alice) to the peer - // (Rust bob). XCTAssertFalse( nativeAlice.myPQTurn, "the discharge should pass the PQ turn away from alice") XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") - // A message each way. _ = try nativeAlice.prepareToEncrypt() let aliceMsg = try nativeAlice.encrypt(Data("p4h-alice-msg".utf8)) let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) @@ -944,7 +920,6 @@ class LegacyRowFixtureTests: XCTestCase { } XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p4h-bob-msg".utf8)) - // A committing round each way. _ = try nativeAlice.prepareToEncrypt() let aliceUpdFrame = try nativeAlice.encrypt(Data("p4h-alice-upd".utf8)) let bobDecrypted = try XCTUnwrap( @@ -980,12 +955,10 @@ class LegacyRowFixtureTests: XCTestCase { Data("p4h-alice-commit2".utf8)) } - /// p5's A.4 heal, cross-engine: unlike p4/p6's A.3, p5's rows are already fully - /// established (both PQ halves live) and mid a ROUTINE A.4 ratchet instead. meta pins - /// the initiator as the turn holder with the parked leg (`legParked: true`), so THAT - /// side migrates to native; the acceptor stays Rust. Native's side-band take returns - /// the parked EK, Rust bob responds with the CT, and native binds it directly - /// (`pqRatchetBind` unseals its own input, like `pqBootstrapJoin`). + /// p5's A.4 heal, cross-engine: unlike p4/p6's A.3, p5 is already fully established and + /// mid a routine A.4 ratchet. Fixture meta pins the initiator as turn holder with the + /// parked leg, so that side migrates to native; native's side-band take returns the + /// parked EK, Rust bob responds with the CT, and native binds it directly. func testP5A4HealMigratesInitiatorToNative() throws { let freshAlice = try restoreAndVerify(point: "p5-a4-stalled", side: "initiator") let export = try freshAlice.migrationExport() @@ -993,9 +966,10 @@ class LegacyRowFixtureTests: XCTestCase { export.pendingSideBand, "alice's parked A.4 EK should be in the export") XCTAssertNotNil(export.pqInflight, "the in-flight A.4 round should export") - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -1010,12 +984,10 @@ class LegacyRowFixtureTests: XCTestCase { try bob.pqRatchetRespond(ekMsg: openedEk.frame) let sealedCt = try XCTUnwrap(bob.pqTakePendingOutbound()) - // Native's `pqRatchetBind` unseals `inbound` itself. The pinned twomlspq-swift - // release (0.2.1) exposes no public PQ-epoch accessor to assert the numeric - // advance directly (unlike Rust's `epochs()`) — `pqRatchetBind` succeeding - // without error already proves the CT decrypted and verified against the - // correct epoch's derived secret, and the round's full discharge plus the - // continued messaging below is the completion proof this test relies on. + // Native's `pqRatchetBind` unseals `inbound` itself. twomlspq-swift 0.2.1 exposes no + // public PQ-epoch accessor to assert the advance directly — a successful bind, plus + // the discharge and continued messaging below, is the completion proof this test + // relies on. _ = try nativeAlice.pqRatchetBind(sealedCt) XCTAssertNil( @@ -1023,8 +995,8 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertNil( bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") - // Discharge the turn holder's own owed bind: the peer (bob) offers, native alice - // (the binder) queues + commits it. + // Discharge the turn holder's owed bind: bob (peer) offers, native alice (binder) + // commits. _ = try bob.prepareToEncrypt(proposing: nil) let bobUpdFrame = try bob.encrypt(appMessage: Data("p5h-bob-upd".utf8)) let aliceOpened = try nativeAlice.processIncoming(bobUpdFrame.cipherText) @@ -1044,13 +1016,11 @@ class LegacyRowFixtureTests: XCTestCase { bobGotCommit.applicationMessage?.appMessageData, Data("p5h-alice-commit".utf8)) - // The discharge passes the PQ turn away from the holder (alice) to the peer (bob). XCTAssertFalse( nativeAlice.myPQTurn, "the discharge should pass the PQ turn away from alice") XCTAssertTrue(bob.myPqTurn(), "the discharge should pass the PQ turn to bob") - // A message each way. _ = try nativeAlice.prepareToEncrypt() let aliceMsg = try nativeAlice.encrypt(Data("p5h-alice-msg".utf8)) let bobGotMsg = try XCTUnwrap(bob.processIncoming(ciphertext: aliceMsg.frame)) @@ -1066,7 +1036,6 @@ class LegacyRowFixtureTests: XCTestCase { } XCTAssertEqual(aliceMsgDecrypted.applicationMessage, Data("p5h-bob-msg".utf8)) - // A committing round each way. _ = try nativeAlice.prepareToEncrypt() let aliceUpdFrame = try nativeAlice.encrypt(Data("p5h-alice-upd".utf8)) let bobDecrypted = try XCTUnwrap( @@ -1102,15 +1071,11 @@ class LegacyRowFixtureTests: XCTestCase { Data("p5h-alice-commit2".utf8)) } - /// An OWED bind — bound (A.3 or A.4) but NOT YET discharged — is itself part of the - /// pending-advance state the export carries, independent of any parked side-band leg - /// (the p6 shape above already exercises `pendingSideBand`/`pqInflight` together; - /// this isolates `owedBind` on a session where NEITHER of those is set). Reaches an - /// owed-bind-but-undischarged state via an ordinary A.4 ratchet round stopped right - /// after the bind (bob, the turn holder, opens with a plain send; alice responds; bob - /// binds the CT — owing the classical bind — with nothing parked on either side once - /// bound). Migrate BOB; native must still discharge the owed bind via an ordinary - /// committing round, and the peer applies it. + /// An owed bind (bound but not yet discharged) is pending-advance state the export + /// carries independent of any parked side-band leg — isolated here on a session where + /// neither `pendingSideBand` nor `pqInflight` is set. Migrate bob after an ordinary A.4 + /// bind with nothing parked; native must still discharge the owed bind on an ordinary + /// committing round. func testOwedBindCarriesAcrossTheExport() throws { let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() _ = try pair.bob.prepareToEncrypt(proposing: nil) @@ -1129,15 +1094,16 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertNil(export.pendingSideBand, "nothing is parked at this point") XCTAssertNil(export.pqInflight, "no A.3/A.4/A.5 round is in flight at this point") - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) - // Discharge across engines: alice (Rust, the peer) offers, native bob (the - // binder, who owes the bind) queues + commits it. + // Discharge across engines: alice (Rust, peer) offers, native bob (binder, who owes + // the bind) commits. _ = try pair.alice.prepareToEncrypt(proposing: nil) let aliceUpdFrame = try pair.alice.encrypt( appMessage: Data("owed-bind-alice-upd".utf8)) @@ -1161,12 +1127,9 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertTrue(nativeBob.isFullyEstablished, "the discharge should not disturb this") } - /// Mutation: blank `owedBind` in the export before minting — the discharge round then - /// runs with nothing to fold in: native bob commits alice's offered Upd as an ORDINARY - /// classical round (a real, successful commit), but with no PQ half riding it, so - /// alice's PQ epoch never advances and the ratchet she already completed on her side - /// is never acknowledged back to her — the round SUCCEEDS at the classical layer - /// while the dropped PQ bind is silently lost rather than surfacing as an error. + /// Mutation: blank `owedBind` before minting. The discharge round then succeeds as an + /// ordinary classical commit with no PQ half riding it — alice's PQ epoch never + /// advances, and the dropped bind is silently lost rather than surfacing as an error. func testBlankedOwedBindLosesTheBindSilently() throws { let pair = try RustSessionTestHelpers.bornDedicatedSessionPairAtDischarge() _ = try pair.bob.prepareToEncrypt(proposing: nil) @@ -1184,9 +1147,10 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertNotNil(export.owedBind) export.owedBind = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -1218,14 +1182,11 @@ class LegacyRowFixtureTests: XCTestCase { } // MARK: - Mutation: blanking the pending-advance state breaks the heal - /// p6, blanking ONLY `pendingSideBand` (bob's parked Welcome'; `pqInflight` intact). - /// A REAL heal attempt, not just a peek: Rust alice still independently holds HER OWN - /// parked KP' (the p6 shape keeps a copy on both sides), so re-send it to native bob — - /// exactly the retry a host would attempt after a dropped delivery. It cannot recover: - /// bob's send.pq is ALREADY founded, so `pqBootstrapRespond`'s idempotent branch needs - /// a retained frame to re-serve, and there is none. Alice can therefore never receive - /// a fresh Welcome' to bind, and never establishes — proving `pendingSideBand` is - /// load-bearing, not `pqPendingOutbound`'s tautological pass-through of it. + /// p6, blanking only `pendingSideBand` (`pqInflight` intact). Alice retries her own + /// parked KP' — the retry a host would attempt after a dropped delivery — but bob's + /// send.pq is already founded, so `pqBootstrapRespond`'s idempotent branch needs a + /// retained frame to re-serve and finds none: alice never receives a fresh Welcome' and + /// never establishes, proving `pendingSideBand` is load-bearing. func testBlankedPendingSideBandBreaksP6AcceptorHeal() throws { let alice = try restoreAndVerify( point: "p6-a3-responded-stalled", side: "initiator") @@ -1234,9 +1195,10 @@ class LegacyRowFixtureTests: XCTestCase { var export = try freshBob.migrationExport() export.pendingSideBand = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -1259,17 +1221,12 @@ class LegacyRowFixtureTests: XCTestCase { ) } - /// The same p6 shape, blanking ONLY `pqInflight` this time (`pendingSideBand` intact). - /// This field is NOT what `pqPendingOutbound()`/`pqBootstrapRespond`'s idempotent - /// branch read (both key off `pendingSideBand` and `sendGroup.pq`, the real group - /// state) — so native bob's own side-band take and re-respond BOTH still work, and - /// alice DOES receive and bind a fresh Welcome'. The heal breaks one step later - /// instead: bob's `applyBind` (`TwoMLSSession+ClassicalCommit.swift`) explicitly - /// switches on `pqInflight`, requiring `.bootstrapResponded`/`.responding`/ - /// `.rekeyResponded` before it will apply an incoming bind commit at all — with - /// `pqInflight` blanked, that switch's `default` arm throws `sessionNotReady` when - /// bob tries to fold alice's discharge commit. So the heal still fails, just later - /// and differently than blanking `pendingSideBand` does. + /// The same p6 shape, blanking only `pqInflight` (`pendingSideBand` intact). This field + /// gates neither `pqPendingOutbound()` nor the idempotent respond branch (both key off + /// `pendingSideBand`/`sendGroup.pq`), so alice still receives and binds a fresh + /// Welcome'. The heal instead breaks at `applyBind`, which switches on `pqInflight` and + /// requires `.bootstrapResponded`/`.responding`/`.rekeyResponded` before applying an + /// incoming bind commit — blanked, its `default` arm throws `sessionNotReady`. func testBlankedPqInflightBreaksP6AcceptorHealAtTheBindApply() throws { let alice = try restoreAndVerify( point: "p6-a3-responded-stalled", side: "initiator") @@ -1278,15 +1235,15 @@ class LegacyRowFixtureTests: XCTestCase { var export = try freshBob.migrationExport() export.pqInflight = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) - // Native bob's own side-band take is UNAFFECTED — it reads `pendingSideBand`, - // left intact here — and alice binds it successfully. + // Reads `pendingSideBand`, left intact — unaffected by the blanked `pqInflight`. let welcomePrimeSealed = try XCTUnwrap( nativeBob.pqPendingOutbound(), "pendingSideBand alone (left untouched here) is what pqPendingOutbound reads" @@ -1294,8 +1251,8 @@ class LegacyRowFixtureTests: XCTestCase { let opened = try XCTUnwrap(try alice.openIncoming(blob: welcomePrimeSealed)) try alice.pqBootstrapBind(welcomeMsg: opened.frame) - // The discharge round: bob offers, alice commits — bob's own APPLY of that - // commit is where the blanked `pqInflight` actually bites. + // bob offers, alice commits — bob's own apply of that commit is where the blanked + // `pqInflight` bites. _ = try nativeBob.prepareToEncrypt() let bobUpd = try nativeBob.encrypt(Data("p6mut-bob-upd".utf8)) let offered = try XCTUnwrap( @@ -1307,32 +1264,28 @@ class LegacyRowFixtureTests: XCTestCase { error in XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .sessionNotReady) } - // Retrying changes nothing — the guard is on bob's own (blanked) state, not - // anything about the frame — so bob is durably stuck on this commit, not just - // racing a transient condition. + // Retrying changes nothing — the guard is on bob's own blanked state, not the + // frame — so bob is durably stuck, not racing a transient condition. XCTAssertThrowsError(try nativeBob.processIncoming(aliceCommit.cipherText)) { error in XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .sessionNotReady) } } - /// The same PAIR of mutations, on the p4 initiator shape — with a genuinely different - /// (and instructive) result. p4 migrates alice BEFORE bob ever responds, so alice is - /// still pre-join: blanking ONLY `pendingSideBand` does NOT break this heal, because - /// native alice's own `pqBootstrapBegin()` re-derives the SAME KP' bytes from her - /// still-intact identity material (never a fresh mint) and simply restarts the round - /// — bob's pinned commitment still matches it. Unlike p6's acceptor, whose send.pq is - /// ALREADY founded and so has no "start over" available to it, the pre-join initiator - /// always does. Documented precisely, per the brief, rather than asserting a failure - /// that does not happen. + /// The same mutation on the p4 initiator shape has the opposite result: alice migrates + /// before bob ever responds, so she is still pre-join. Blanking `pendingSideBand` does + /// not break the heal — native alice's `pqBootstrapBegin()` re-derives the same KP' + /// bytes from her still-intact identity material and simply restarts the round, unlike + /// p6's acceptor whose send.pq is already founded and has no "start over" available. func testBlankedPendingSideBandDoesNotBreakP4InitiatorHeal() throws { let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") var export = try freshAlice.migrationExport() export.pendingSideBand = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -1341,8 +1294,7 @@ class LegacyRowFixtureTests: XCTestCase { nativeAlice.pqPendingOutbound(), "the blanked export leaves nothing for native alice's own side-band take") - // The self-heal: alice is still pre-join and holds the turn, so she can just - // restart the bootstrap — `pqBootstrapBegin` re-derives, it does not re-mint. + // alice restarts the bootstrap — `pqBootstrapBegin` re-derives, it does not re-mint. let restarted = try nativeAlice.pqBootstrapBegin() let bob = try restoreAndVerify(point: "p4-a3-stalled", side: "acceptor") @@ -1374,22 +1326,20 @@ class LegacyRowFixtureTests: XCTestCase { XCTAssertTrue(bob.myPqTurn(), "the discharge passes the turn to bob") } - /// The p4 initiator shape, blanking ONLY `pqInflight`. Also does not break the heal: - /// `pqBootstrapJoin` never reads `pqInflight` (only `bootstrapKPSecret` and - /// `pendingProposal`), and it clears `pqInflight` itself as part of ordinary - /// completion regardless of what the export carried — so the blanked value is - /// overwritten before it could matter. Contrast p6's acceptor, where the PEER - /// (native bob) applying an INCOMING bind is what reads `pqInflight`; here alice is - /// the one who JOINS and then COMMITS the discharge herself, never on the receiving - /// end of that specific gate. + /// The p4 initiator shape, blanking only `pqInflight`. Also harmless: `pqBootstrapJoin` + /// only reads `bootstrapKPSecret`/`pendingProposal`, and clears `pqInflight` itself on + /// ordinary completion regardless of what the export carried. Contrast p6's acceptor, + /// where the peer applying an incoming bind is what reads `pqInflight` — here alice + /// joins and commits the discharge herself, never on the receiving end of that gate. func testBlankedPqInflightDoesNotBreakP4InitiatorHeal() throws { let freshAlice = try restoreAndVerify(point: "p4-a3-stalled", side: "initiator") var export = try freshAlice.migrationExport() export.pqInflight = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) diff --git a/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift new file mode 100644 index 0000000..10c1ab8 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift @@ -0,0 +1,444 @@ +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// A totality sweep, not a regression pin: seven export shapes the round-trip suites +// don't otherwise exercise, pushed through export -> map -> mint -> restore with minimal +// assertions. The point is coverage of native's acceptance, not any field's exact value +// — a native refusal here is a bug to report, not to paper over. +// +// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI +// record types are module-qualified throughout. + +@available(macOS 26, iOS 26, *) +final class MintCoverageTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + // MARK: - 1. Pre-establishment initiator + + /// A bare pre-establishment initiator with an app payload attached. `recvGroup` is nil + /// and both reservations stand in for groups that don't exist yet. Mirrors the Rust + /// `test_migration_export_pre_establishment_initiator_with_payload`. + func testPreEstablishmentInitiatorWithPayloadMintsAndRestores() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-pre-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-pre-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + // Mint-and-retain the return KP before attaching the payload, matching the app's own + // ordering in `PQSession.swift`'s `createTwoMLSGroup`. + _ = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + try aliceSession.setInitialAppPayload(payload: Data("mc-host-signed".utf8)) + + let export = try aliceSession.migrationExport() + XCTAssertNil( + export.recvGroup, "a pre-establishment initiator has no recv group yet") + XCTAssertEqual(export.initialAppPayload, Data("mc-host-signed".utf8)) + XCTAssertEqual(export.identity.classicalInitSecretKey?.count, 32) + XCTAssertNotNil(export.leafKeys.recvClassical.current, "recv-classical reservation") + XCTAssertNotNil(export.leafKeys.recvPq.current, "recv-PQ reservation") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + // MARK: - 2. Card initiator rotated while stalled at A.3 + + /// A pipelined-A.3 (card) initiator who has sent her bootstrap KP and establishment + /// envelope, and processed bob's return welcome, but has not yet called + /// `pqBootstrapBind` — the A.3 round is open and stalled. A classical rotation + /// self-driven in that window (never delivered) must still export and mint cleanly. + func testCardInitiatorRotatedWhileStalledAtA3() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-card-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-card-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + guard + case .bootstrapKp(let heldKp) = try bobInvitation.openInitial( + blob: kpEnvelope) + else { + return XCTFail("expected a bootstrap-KP envelope") + } + guard + case .establishment(let frame) = try bobInvitation.openInitial( + blob: replyEnvelope) + else { + return XCTFail("expected an establishment envelope") + } + let welcome = try XCTUnwrap(frame.welcome) + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("mc-card-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + _ = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + + // Stalled: alice never calls `pqBootstrapBind`; the rotation below is never + // delivered to bob. + let newAliceId = TwoMLSPQBinding.ClientId(bytes: Data("mc-card-alice-rotated".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: newAliceId) + _ = try aliceSession.encrypt(appMessage: Data("mc-rotate".utf8)) + + let export = try aliceSession.migrationExport() + XCTAssertNotNil(export.bootstrapKpSecret, "A.3 is still outstanding") + XCTAssertNotNil(export.rotationCandidate, "the self-driven rotation is staged") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + // MARK: - 3. Four-candidate export + + /// Four distinct classical candidates staged in a row, none ever folded — each rides + /// `send_classical`'s pending set (one entry per candidate other than `mine.current`). + func testFourCandidateExportMintsAndRestores() throws { + let (alice, _) = try establishCardPair() + + for n in 0..<4 { + let candidate = TwoMLSPQBinding.ClientId( + bytes: Data("mc-4cand-\(n)".utf8)) + _ = try alice.prepareToEncrypt(proposing: candidate) + _ = try alice.encrypt(appMessage: Data("mc-4cand-msg-\(n)".utf8)) + } + + let export = try alice.migrationExport() + XCTAssertGreaterThanOrEqual( + export.leafKeys.sendClassical.pending.count, 4, + "all four staged candidates should ride send-classical's pending set") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + // MARK: - 4. A window of more than 64 offers + + /// 70 same-identity refreshes, never folded, exceed the 64-entry sampled trial native + /// applies to the offer window, exercised against real (not synthetic) cache data. + func testOwnOfferWindowOver64EntriesMintsAndRestores() throws { + let (alice, _) = try establishCardPair() + + for n in 0..<70 { + _ = try alice.prepareToEncrypt(proposing: nil) + _ = try alice.encrypt(appMessage: Data("mc-window-\(n)".utf8)) + } + + let export = try alice.migrationExport() + let window = try XCTUnwrap( + export.deployedState?.ownOffers, + "70 unfolded refreshes must produce a window") + XCTAssertGreaterThan(window.offers.count, 64) + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNotNil(minted.ownOfferWindow) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + // MARK: - 5. Mid-prepare export + + /// Exported with a `prepareToEncrypt` outstanding (no paired `encrypt` yet): the + /// latest offer is framed and `Placement::Snapshot`-routed, excluded from the window. + func testMidPrepareExportMintsAndRestores() throws { + let (alice, _) = try establishCardPair() + _ = try alice.prepareToEncrypt(proposing: nil) + + let export = try alice.migrationExport() + XCTAssertFalse( + export.stagedUpdates.isEmpty, "the outstanding prepare stages an Upd") + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertNoThrow( + try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + // MARK: - 6. A `pq_wedged` export + + /// No FFI-reachable way exists to latch `pq_wedged` deliberately — it only sets past a + /// bind's point of no return on a genuine internal failure the public API can't + /// trigger on demand (see the Rust `test_migration_export_carries_pq_wedged`, which + /// pokes the field directly). So a synthetic mutation stands in: native's mint must + /// carry the wedged flag through (`deployedState.pqWedged` is a straight passthrough) + /// rather than reject it. + func testPqWedgedExportMintsAndRestores() throws { + let (alice, _) = try establishCardPair() + var export = try alice.migrationExport() + var deployed = + export.deployedState + ?? TwoMLSPQBinding.SessionMigrationDeployedState( + ownOffers: nil, pqWedged: nil, + noCustody: TwoMLSPQBinding.SessionMigrationNoCustody( + sendClassical: false, sendPq: false, recvClassical: false, + recvPq: false)) + deployed.pqWedged = .bootstrap + export.deployedState = deployed + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let restored = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertTrue(restored.pqSideBandWedged) + } + + // MARK: - 7. The no-custody state + + /// Mirrors the Rust `test_migration_export_carries_no_custody_from_an_unchecked_join_ + /// signer`. Alice's send-PQ signer (installed at `initiate`) is never itself refreshed; + /// three classical rotations run while her recv-PQ leaf (populated only at bob's A.3 + /// join) sits untouched, so bob's post-bind A.5 catch-up moves alice's send-PQ signer + /// while nothing refreshes recv-PQ — it ends up presenting a key the derive pool can no + /// longer produce. A genuine, FFI-reachable no-custody state, unlike the synthetic + /// `pq_wedged` case above. + func testRecvPqNoCustodyExportMintsAndRestores() throws { + let (alice, bob) = try establishConfirmedNonDedicatedPair() + + let kp = try alice.pqBootstrapBegin(rotating: nil) + try bob.pqBootstrapRespond(kpMsg: kp) + + try rotateRound( + party: bob, peer: alice, + newId: TwoMLSPQBinding.ClientId(bytes: Data("mc-nc-bob1".utf8))) + try rotateRound( + party: alice, peer: bob, + newId: TwoMLSPQBinding.ClientId(bytes: Data("mc-nc-alice1".utf8))) + try rotateRound( + party: bob, peer: alice, + newId: TwoMLSPQBinding.ClientId(bytes: Data("mc-nc-bob-extra".utf8))) + + let welcome = try XCTUnwrap(bob.pqPendingOutbound(sealing: .fresh)) + try alice.pqBootstrapBind(welcomeMsg: welcome) + try RustSessionTestHelpers.committingRound(binder: alice, peer: bob) + + // bob's send-PQ leaf lags his twice-rotated identity, staging an A.5 catch-up. + _ = try bob.prepareToEncrypt(proposing: nil) + let postBind = try bob.encrypt(appMessage: Data("post-bind".utf8)) + _ = try alice.processIncoming(ciphertext: postBind.cipherText) + + // Drain remaining side-band legs, dispatched by the opened frame's kind — mirrors + // the Rust test's pump loop, tolerating a per-leg error (a stale/already-applied + // leg later). + for _ in 0..<8 { + var delivered = false + for (from, to) in [(alice, bob), (bob, alice)] { + guard let leg = from.pqPendingOutbound(sealing: .fresh) else { + continue + } + guard let opened = try? to.openIncoming(blob: leg) else { continue } + guard case .pqSideBand(let kind) = opened.kind else { continue } + switch kind { + case .bootstrapKeyPackage: + _ = try? to.pqBootstrapRespond(kpMsg: leg) + case .bootstrapWelcome: _ = try? to.pqBootstrapBind(welcomeMsg: leg) + case .ratchetEphemeralKey: _ = try? to.pqRatchetRespond(ekMsg: leg) + case .ratchetCiphertext: _ = try? to.pqRatchetBind(ctMsg: leg) + case .rekeyUpdate: _ = try? to.pqRekeyRespond(updMsg: leg) + case .rekeyCommit: _ = try? to.pqRekeyApply(msg: leg) + } + delivered = true + } + if !delivered { break } + } + + let export = try alice.migrationExport() + let deployed = try XCTUnwrap(export.deployedState) + XCTAssertTrue( + deployed.noCustody.recvPq, + "expected recv_pq no_custody once the handoff drops the last copy of D") + XCTAssertFalse(deployed.noCustody.sendClassical) + XCTAssertFalse(deployed.noCustody.sendPq) + XCTAssertFalse(deployed.noCustody.recvClassical) + XCTAssertNil(export.leafKeys.recvPq.current) + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let restored = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertTrue(restored.noCustody.contains(.recvPQ)) + } + + // MARK: - Shared establishment scaffolding + + /// A plain pair (no bootstrap-KP piggyback, PQ deferred), joined with one confirm frame + /// each way so both sides satisfy the `peer_confirmed` precondition a later unilateral + /// rotation commit needs. Mirrors the Rust `establish_confirmed_sessions` helper. + private func establishConfirmedNonDedicatedPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-nc-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-nc-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let envelope = try XCTUnwrap(aliceSession.pendingOutbound()) + guard case .establishment(let frame) = try bobInvitation.openInitial(blob: envelope) + else { + throw NSError( + domain: "mc-nc", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope" + ]) + } + let welcome = try XCTUnwrap(frame.welcome) + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("mc-nc-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + let welcomeB = try XCTUnwrap(bobSession.pendingOutbound()) + _ = try aliceSession.processIncoming(ciphertext: welcomeB) + + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let confirmA = try aliceSession.encrypt(appMessage: Data("confirm-a".utf8)) + _ = try bobSession.processIncoming(ciphertext: confirmA.cipherText) + _ = try bobSession.prepareToEncrypt(proposing: nil) + let confirmB = try bobSession.encrypt(appMessage: Data("confirm-b".utf8)) + _ = try aliceSession.processIncoming(ciphertext: confirmB.cipherText) + + return (aliceSession, bobSession) + } + + /// Mirrors the Rust `rotate_round` helper: `party` proposes `newId`, `peer` commits it + /// (the committed credential defines `party`'s next identity), and `party` applies the + /// returning canonicalize frame. + private func rotateRound( + party: TwoMLSPQBinding.TwoMlsPqSession, peer: TwoMLSPQBinding.TwoMlsPqSession, + newId: TwoMLSPQBinding.ClientId + ) throws { + _ = try party.prepareToEncrypt(proposing: newId) + let enc = try party.encrypt(appMessage: Data("rotate".utf8)) + let offered = try XCTUnwrap( + peer.processIncoming(ciphertext: enc.cipherText)?.proposal) + XCTAssertEqual(offered.proposing, newId) + try peer.queueProposal(digest: offered.digest) + let prepared = try peer.prepareToEncrypt(proposing: nil) + XCTAssertTrue(prepared.didCommit) + XCTAssertEqual(prepared.committedRemoteClientId, newId) + let frame = try peer.encrypt(appMessage: Data("canonicalize".utf8)) + let got = try XCTUnwrap(party.processIncoming(ciphertext: frame.cipherText)) + XCTAssertEqual(got.remoteCommit?.newRecipient, newId) + } + + /// A non-dedicated ("card") pair through full establishment, with a trailing message + /// each way so neither side has anything mid-flight at return. Duplicated (not shared) + /// across this package's test files by convention — see + /// `DeployedStateMigrationTests.establishFullPair`. + private func establishCardPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("mc-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("mc-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + guard + case .bootstrapKp(let heldKp) = try bobInvitation.openInitial( + blob: kpEnvelope) + else { + throw NSError( + domain: "mc", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope" + ]) + } + guard + case .establishment(let frame) = try bobInvitation.openInitial( + blob: replyEnvelope) + else { + throw NSError( + domain: "mc", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope" + ]) + } + let welcome = try XCTUnwrap(frame.welcome) + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("mc-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + XCTAssertTrue(aliceSession.isFullyEstablished()) + XCTAssertTrue(bobSession.isFullyEstablished()) + + try RustSessionTestHelpers.rustSay(bobSession, "mc-pq-app", deliverTo: aliceSession) + try RustSessionTestHelpers.rustSay( + aliceSession, "mc-pq-app-2", deliverTo: bobSession) + return (aliceSession, bobSession) + } +} diff --git a/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift b/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift index 0e49fe6..b94ec69 100644 --- a/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift +++ b/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift @@ -32,9 +32,10 @@ final class NonDedicatedEarlyExportTests: XCTestCase { + "parameter, not a staple — he needs it to dedup alice's later re-staples" ) - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -80,19 +81,19 @@ final class NonDedicatedEarlyExportTests: XCTestCase { XCTAssertEqual(bobDecrypted2.applicationMessage, Data("alice-2".utf8)) } - /// Mutation: blank `joinedWelcomeDigest` in the export — the mint still succeeds (the - /// field is optional data, not cross-checked at mint time), but the flow it exists to - /// support breaks downstream: native bob can no longer recognize alice's re-stapled - /// birth welcome as an already-joined repeat, and `processIncoming` throws - /// `.unexpectedWelcome` instead of decrypting. + /// Blanking `joinedWelcomeDigest` doesn't break the mint (the field isn't cross-checked + /// there), but native bob then can't recognize alice's re-stapled birth welcome as an + /// already-joined repeat, and `processIncoming` throws `.unexpectedWelcome` instead of + /// decrypting. func testNilJoinedWelcomeDigestBreaksTheReStapleDedup() throws { let (aliceSession, bobSession) = try establishNonDedicatedPair() var export = try bobSession.migrationExport() export.joinedWelcomeDigest = nil - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) diff --git a/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift b/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift index 1a692d4..07772ce 100644 --- a/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift +++ b/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift @@ -64,11 +64,11 @@ enum RustSessionTestHelpers { } /// A born-dedicated pair PRE-INSTALL, raw FFI: alice initiates to bob's invitation, and - /// bob `receive`s under a DEDICATED client id (≠ the invitation id) — matching the Rust - /// suite's `born_dedicated_pending` (NOT `SessionMigrationTests`' `establishedSessionPair` - /// pipelined-A.3 shape: no parallel bootstrap-KP envelope is read here, matching how - /// `TwoMlsPqInvitation.receive` is actually driven with a dedicated id). Bob owes his - /// establishment envelope; alice still knows him as the invitation identity. + /// bob `receive`s under a DEDICATED client id (≠ the invitation id) — unlike + /// `SessionMigrationTests`' pipelined-A.3 `establishedSessionPair`, no parallel + /// bootstrap-KP envelope is read here, matching how `TwoMlsPqInvitation.receive` is + /// actually driven with a dedicated id. Bob owes his establishment envelope; alice + /// still knows him as the invitation identity. static func bornDedicatedPending( file: StaticString = #filePath, line: UInt = #line ) throws -> BornDedicatedPair { @@ -106,7 +106,7 @@ enum RustSessionTestHelpers { invitationId: invitationId) } - /// Contract 26: install the mock delegation on `pair.bob`, returning its bytes. + /// Install the mock delegation on `pair.bob`, returning its bytes. @discardableResult static func installMockEstablishmentEnvelope(_ pair: BornDedicatedPair) throws -> Data { let signedEnvelope = Data("bd-signed-establishment-delegation".utf8) @@ -139,13 +139,39 @@ enum RustSessionTestHelpers { return (pair, bobUpd) } + /// `bornDedicatedInstalledUnfolded`'s same-id-candidate variant: the mandatory handoff + /// frame proposes `Some(pair.dedicatedId)` (`admit_candidate` mints a fresh K′) instead + /// of `nil` (a plain identity-signed self-refresh) — a real K′ offer outstanding at + /// rest. Neither queues nor commits bob's catch-up Upd. + static func bornDedicatedSameIdHandoffUnfolded( + file: StaticString = #filePath, line: UInt = #line + ) throws -> (pair: BornDedicatedPair, bobUpd: QueuedRemoteProposal) { + let pair = try bornDedicatedPending(file: file, line: line) + let signedEnvelope = try installMockEstablishmentEnvelope(pair) + _ = try pair.bob.prepareToEncrypt( + proposing: TwoMLSPQBinding.ClientId(bytes: pair.dedicatedId)) + let confirmB = try pair.bob.encrypt(appMessage: Data("confirm-b".utf8)) + let paused = try XCTUnwrap( + pair.alice.processIncoming(ciphertext: confirmB.cipherText), file: file, + line: line) + let pending = try XCTUnwrap(paused.pendingEstablishment, file: file, line: line) + XCTAssertEqual(pending.envelope, signedEnvelope, file: file, line: line) + XCTAssertEqual(pending.welcome.first, 0x01, file: file, line: line) + let resumed = try pair.alice.processIncomingApproved( + ciphertext: confirmB.cipherText, + approvedEnvelopeDigest: Data(SHA256.hash(data: pending.envelope)), + approvedWelcomeDigest: Data(SHA256.hash(data: pending.welcome)), + expectedCreator: pair.dedicatedId) + let bobUpd = try XCTUnwrap(resumed?.proposal, file: file, line: line) + return (pair, bobUpd) + } + /// A born-dedicated pair, raw FFI, through classical convergence and the A.3 bootstrap + - /// bind discharge — the shape of the Rust suite's `test_dedicated_principal_full_lifecycle` - /// up to (not including) its trailing message exchange. Bob holds the PQ turn and NOTHING - /// is mid-flight right at return (no message has been sent since the discharge, so - /// nothing has auto-staged — contrast `bornDedicatedSessionPair`, whose trailing sends - /// legitimately do). NEVER calls `bob`'s `pendingOutbound()` — the app never drains an - /// acceptor's parked return welcome, so this pins that shape rather than the drained one. + /// bind discharge, up to (not including) a trailing message exchange. Bob holds the PQ + /// turn and nothing is mid-flight at return — contrast `bornDedicatedSessionPair`, whose + /// trailing sends legitimately auto-stage. Never calls `bob`'s `pendingOutbound()`: the + /// app never drains an acceptor's parked return welcome, so this pins that shape rather + /// than the drained one. static func bornDedicatedSessionPairAtDischarge( file: StaticString = #filePath, line: UInt = #line ) throws -> BornDedicatedPair { @@ -188,11 +214,10 @@ enum RustSessionTestHelpers { } /// `bornDedicatedSessionPairAtDischarge` plus a message each way. Bob's own send here - /// legitimately auto-stages a speculative A.4 EK (mirrors Rust's - /// `maybe_stage_next_round`, which fires on every turn-holder send) — a caller that - /// needs `pqRekeyBegin`'s clean-slate precondition should use the discharge-point - /// helper above instead, not drain this one (draining a completed round passes the PQ - /// turn away, same as any other bind discharge). + /// legitimately auto-stages a speculative A.4 EK (fires on every turn-holder send) — a + /// caller needing `pqRekeyBegin`'s clean-slate precondition should use the + /// discharge-point helper above instead: draining a completed round passes the PQ turn + /// away, same as any other bind discharge. static func bornDedicatedSessionPair( file: StaticString = #filePath, line: UInt = #line ) throws -> BornDedicatedPair { diff --git a/Tests/TwoMLSPQMigrateTests/SessionMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/SessionMigrationTests.swift index 4c5e154..d260506 100644 --- a/Tests/TwoMLSPQMigrateTests/SessionMigrationTests.swift +++ b/Tests/TwoMLSPQMigrateTests/SessionMigrationTests.swift @@ -32,9 +32,10 @@ final class SessionMigrationTests: XCTestCase { let export = try pair.alice.migrationExport() // Migrate alice to the native engine and restore her there. - let archive = try SessionMigrator.mintArchive( + let archive = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: nil, checkpoint: archive, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -67,12 +68,14 @@ final class SessionMigrationTests: XCTestCase { // Mint both kinds from the ONE export (a core omits the PQ trees; the // reconcile splices them from the checkpoint), then restore from the // pair exactly as the app's two-slot persistence would. - let checkpoint = try SessionMigrator.mintArchive( + let checkpoint = try SessionMigrator.mint( kind: .checkpoint, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) - let core = try SessionMigrator.mintArchive( + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + let core = try SessionMigrator.mint( kind: .core, from: export, - classicalProvider: classicalProvider, pqProvider: pqProvider) + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( core: core, checkpoint: checkpoint, classicalProvider: classicalProvider, pqProvider: pqProvider) @@ -105,7 +108,7 @@ final class SessionMigrationTests: XCTestCase { // a bad export/map rather than merely that something decoded. export.identity.signingKey[16] ^= 0xFF XCTAssertThrowsError( - try SessionMigrator.mintArchive( + try SessionMigrator.mint( kind: .checkpoint, from: export, classicalProvider: classicalProvider, pqProvider: pqProvider) ) { error in @@ -118,7 +121,7 @@ final class SessionMigrationTests: XCTestCase { var torn = try pair.alice.migrationExport() torn.sendGroup.classical[16] ^= 0xFF XCTAssertThrowsError( - try SessionMigrator.mintArchive( + try SessionMigrator.mint( kind: .checkpoint, from: torn, classicalProvider: classicalProvider, pqProvider: pqProvider)) } From df5b7c1420b84808f167c3b65be9643ecfe5ea48 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 11:50:16 -0700 Subject: [PATCH 06/16] Add a cross-engine heal proof for a stuck Rust A.5 rekey Co-Authored-By: Claude Opus 5.5 --- .../RotatedRekeyHealTests.swift | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift diff --git a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift new file mode 100644 index 0000000..0bbdee7 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift @@ -0,0 +1,507 @@ +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// A stuck §A.5 rekey, cross-engine: a deployed Rust party (A) rotates its classical +// identity, its lagging send-PQ leaf opens §A.5 to catch up in the peer-committed PQ +// group, and the peer (B) has since migrated to native. Companion to +// `BornDedicatedMigrationTests`'s mechanical A.5 case (native initiates, Rust responds): +// this drives the other direction. Alice's own send-PQ leaf is never moved by any of +// this — only a responder's own Commit' carries its current credential +// (`pq_rekey_respond`'s `set_new_signing_identity`), and nothing here makes alice a +// responder catching herself up — but `leafKeys`'s general catch-up carries that lag +// directly, so she migrates too, at the end. +// +// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI +// record types are module-qualified throughout. + +@available(macOS 26, iOS 26, *) +final class RotatedRekeyHealTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + // MARK: - HEAL: B folds A's rotation, then heals A's stuck A.5 across engines + + func testNativeBHealsRotatedRustAsStuckA5Rekey() throws { + let (alice, bob) = try establishedNonDedicatedPair() + + // Snapshot bob before he learns of alice's rotation, for the stale-history check + // below. + let earlyBobExport = try bob.migrationExport() + // Alice's original PQ key, before rotation swaps her whole identity (classical and + // PQ together) to the new principal's — her send-PQ leaf keeps presenting this key + // for the rest of the test, since nothing ever moves it. + let originalAlicePQSignatureKey = try alice.migrationExport().identity + .pqSignatureKey + + // Alice rotates; bob folds the handoff — lazy staging admits the candidate with no + // separate stage call. + let newAliceId = TwoMLSPQBinding.ClientId(bytes: Data("rrh-alice-rotated".utf8)) + _ = try alice.prepareToEncrypt(proposing: newAliceId) + let rotateFrame = try alice.encrypt(appMessage: Data("rotate".utf8)) + let bobRotateOpened = try XCTUnwrap( + bob.processIncoming(ciphertext: rotateFrame.cipherText)) + let rotateOffered = try XCTUnwrap(bobRotateOpened.proposal) + XCTAssertEqual(rotateOffered.proposing, newAliceId) + try bob.queueProposal(digest: rotateOffered.digest) + let bobFold = try bob.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold.didCommit, "bob's fold of alice's handoff must commit") + XCTAssertEqual(bobFold.committedRemoteClientId, newAliceId) + let canonicalizeFrame = try bob.encrypt(appMessage: Data("canonicalize".utf8)) + let aliceCanonOpened = try XCTUnwrap( + alice.processIncoming(ciphertext: canonicalizeFrame.cipherText)) + let remoteCommit = try XCTUnwrap(aliceCanonOpened.remoteCommit) + XCTAssertEqual(remoteCommit.newRecipient, newAliceId) + + // Bob still held the turn for that canonicalize send, so it incidentally auto-staged + // a plain A.4 — a one-round catch-up that must drain before alice's leaf-lag can + // open A.5. Draining it is what passes the turn to alice. + let incidentalEk = try XCTUnwrap(bob.pqPendingOutbound(sealing: .fresh)) + try alice.pqRatchetRespond(ekMsg: incidentalEk) + let incidentalCt = try XCTUnwrap(alice.pqTakePendingOutbound()) + try bob.pqRatchetBind(ctMsg: incidentalCt) + try RustSessionTestHelpers.committingRound(binder: bob, peer: alice) + XCTAssertTrue(alice.myPqTurn(), "draining the incidental A.4 must pass the turn") + + // Migrate bob to native after he folded the rotation, so his AS history + // (`authTheirs`) already carries alice's handoff. + let lateBobExport = try bob.migrationExport() + let lateArchive = try SessionMigrator.mint( + kind: .checkpoint, from: lateBobExport, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: lateArchive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Drive alice (turn holder, leaf lagging) until her send auto-stages the A.5 Upd' — + // the session self-drives; there is no host-callable "begin". + _ = try alice.prepareToEncrypt(proposing: nil) + let opener = try alice.encrypt(appMessage: Data("open-a5".utf8)) + let bobOpenerResult = try nativeBob.processIncoming(opener.cipherText) + guard case .decrypted(let openerDecrypted) = bobOpenerResult else { + XCTFail("expected a decrypted application frame, got \(bobOpenerResult)") + return + } + XCTAssertEqual(openerDecrypted.applicationMessage, Data("open-a5".utf8)) + + // `migrationExport` refuses mid-round, so read the side-band state instead: native + // bob (the only party who can open a frame sealed to him) classifies it as the + // Upd' leg. + XCTAssertTrue(alice.myPqTurn(), "alice must still hold the turn pre-discharge") + let firstFetch = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) + let classified = try nativeBob.openIncoming(firstFetch) + XCTAssertEqual(classified?.kind, .pqSideBand(.rekeyUpd)) + + // Model the app's resend policy: re-fetch (not reuse) before delivering — `.stable` + // sealing must hand out byte-identical bytes. + let secondFetch = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) + XCTAssertEqual( + firstFetch, secondFetch, + "a resend after a simulated loss must be byte-identical") + + // A native peer whose AS history never saw the handoff refuses the move regardless + // of the frame — splice the pre-rotation `authTheirs` onto the turn-correct export, + // since the literal early snapshot still shows bob holding the turn and would + // refuse before ever reaching the id check. + var unawareExport = lateBobExport + unawareExport.authTheirs = earlyBobExport.authTheirs + let unawareArchive = try SessionMigrator.mint( + kind: .checkpoint, from: unawareExport, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBobUnaware = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: unawareArchive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertThrowsError(try nativeBobUnaware.pqRekeyRespond(secondFetch)) { error in + XCTAssertEqual( + error as? TwoMLSPQSession.TwoMLSError, .rekeyProposalRejected) + } + + // A tampered announced id fails signature verification before the id comparison is + // even reached, since authenticated data is covered by the framing signature. + // Corrupt the OPENED plaintext, not the wire bytes — a tampered seal would just + // fail AEAD open outright, and `pqRekeyRespond`'s `openOrRaw` accepts raw input + // when re-opening as sealed fails. The announced id is encoded before the leaf's + // own credential, so its first occurrence in the plaintext is the field under test. + let openedPlaintext = try XCTUnwrap(classified?.frame) + var corruptedPlaintext = openedPlaintext + let announcedIdRange = try XCTUnwrap( + corruptedPlaintext.firstRange(of: newAliceId.bytes)) + corruptedPlaintext[announcedIdRange.lowerBound] ^= 0xFF + let corruptedArchive = try SessionMigrator.mint( + kind: .checkpoint, from: lateBobExport, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBobForCorrupted = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: corruptedArchive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertThrowsError(try nativeBobForCorrupted.pqRekeyRespond(corruptedPlaintext)) { + error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .decryptionFailed) + } + + // The real, caught-up native bob accepts it and announces the handoff. + let sideBandResult = try nativeBob.pqRekeyRespond(secondFetch) + XCTAssertEqual(sideBandResult.rotatedCredential, newAliceId.bytes) + + // Alice applies the Commit', leaves rekey-initiated, and owes the classical bind. + try alice.pqRekeyApply(msg: sideBandResult.frame) + + // Bind discharge: native bob offers an Upd, alice (the binder) folds + commits. + _ = try nativeBob.prepareToEncrypt() + let bobBindUpd = try nativeBob.encrypt(Data("a5-bind-upd".utf8)) + let aliceBindOpened = try XCTUnwrap( + alice.processIncoming(ciphertext: bobBindUpd.frame)) + let bindOffered = try XCTUnwrap(aliceBindOpened.proposal) + try alice.queueProposal(digest: bindOffered.digest) + let alicePrepared = try alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + alicePrepared.didCommit, "the bind discharge needs a committing round") + let aliceCommitFrame = try alice.encrypt(appMessage: Data("a5-bind-commit".utf8)) + let bobGotCommit = try nativeBob.processIncoming(aliceCommitFrame.cipherText) + guard case .decrypted(let bobCommitDecrypted) = bobGotCommit else { + XCTFail("expected a decrypted application frame, got \(bobGotCommit)") + return + } + XCTAssertEqual(bobCommitDecrypted.applicationMessage, Data("a5-bind-commit".utf8)) + + // Postcondition: alice's PQ leaf presents her new id in native bob's view + // (`rotatedCredential` above). Passing the turn back needs a full round: bob + // (nothing of his lags) opens a plain A.4, and its own discharge hands the turn to + // alice. + _ = try nativeBob.prepareToEncrypt() + let followUpOpener = try nativeBob.encrypt(Data("post-a5-opener".utf8)) + let aliceFollowUpOpened = try alice.processIncoming( + ciphertext: followUpOpener.frame) + XCTAssertEqual( + aliceFollowUpOpened?.applicationMessage?.appMessageData, + Data("post-a5-opener".utf8)) + let followUpEk = try XCTUnwrap(nativeBob.pqPendingOutbound()) + try alice.pqRatchetRespond(ekMsg: followUpEk) + let followUpCt = try XCTUnwrap(alice.pqTakePendingOutbound()) + _ = try nativeBob.pqRatchetBind(followUpCt) + + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceBindUpd2 = try alice.encrypt(appMessage: Data("post-a5-bind-upd".utf8)) + let bobBindOpened2 = try nativeBob.processIncoming(aliceBindUpd2.cipherText) + guard case .decrypted(let bobDecrypted2) = bobBindOpened2 else { + XCTFail("expected a decrypted application frame, got \(bobBindOpened2)") + return + } + _ = try nativeBob.queueProposal(digest: bobDecrypted2.queuedProposal.digest) + let bobPrepared2 = try nativeBob.prepareToEncrypt() + XCTAssertTrue(bobPrepared2.didCommit, "the second bind discharge needs a commit") + let bobCommit2 = try nativeBob.encrypt(Data("post-a5-bind-commit".utf8)) + let aliceGotCommit2 = try XCTUnwrap( + alice.processIncoming(ciphertext: bobCommit2.frame)) + XCTAssertEqual( + aliceGotCommit2.applicationMessage?.appMessageData, + Data("post-a5-bind-commit".utf8)) + + // `send_pq_leaf_lags` checks alice's own send-PQ leaf. A responder's Commit' carries + // its current credential when its own send-PQ leaf lags, but alice is always this + // round's initiator, never a responder catching herself up — so with the turn back, + // her next send auto-stages another A.5, this time a same-id key change: the steady + // state for every rotated Rust peer talking to native. + XCTAssertTrue(alice.myPqTurn()) + _ = try alice.prepareToEncrypt(proposing: nil) + let secondOpener = try alice.encrypt(appMessage: Data("open-a5-again".utf8)) + let bobSecondOpenerResult = try nativeBob.processIncoming(secondOpener.cipherText) + guard case .decrypted(let secondOpenerDecrypted) = bobSecondOpenerResult else { + XCTFail( + "expected a decrypted application frame, got \(bobSecondOpenerResult)" + ) + return + } + XCTAssertEqual( + secondOpenerDecrypted.applicationMessage, Data("open-a5-again".utf8)) + let secondUpd = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) + let secondClassified = try nativeBob.openIncoming(secondUpd) + XCTAssertEqual(secondClassified?.kind, .pqSideBand(.rekeyUpd)) + let secondSideBandResult = try nativeBob.pqRekeyRespond(secondUpd) + XCTAssertNil( + secondSideBandResult.rotatedCredential, + "a same-id catch-up hands off nothing" + ) + try alice.pqRekeyApply(msg: secondSideBandResult.frame) + + _ = try nativeBob.prepareToEncrypt() + let thirdBindUpd = try nativeBob.encrypt(Data("a5-again-bind-upd".utf8)) + let aliceThirdBindOpened = try XCTUnwrap( + alice.processIncoming(ciphertext: thirdBindUpd.frame)) + let thirdBindOffered = try XCTUnwrap(aliceThirdBindOpened.proposal) + try alice.queueProposal(digest: thirdBindOffered.digest) + let aliceThirdPrepared = try alice.prepareToEncrypt(proposing: nil) + XCTAssertTrue( + aliceThirdPrepared.didCommit, + "the repeated A.5's bind discharge needs a commit" + ) + let aliceThirdCommitFrame = try alice.encrypt( + appMessage: Data("a5-again-bind-commit".utf8)) + let bobGotThirdCommit = try nativeBob.processIncoming( + aliceThirdCommitFrame.cipherText) + guard case .decrypted(let bobThirdCommitDecrypted) = bobGotThirdCommit else { + XCTFail("expected a decrypted application frame, got \(bobGotThirdCommit)") + return + } + XCTAssertEqual( + bobThirdCommitDecrypted.applicationMessage, + Data("a5-again-bind-commit".utf8)) + + _ = try alice.prepareToEncrypt(proposing: nil) + let finalAliceFrame = try alice.encrypt(appMessage: Data("post-heal-alice".utf8)) + let finalBobResult = try nativeBob.processIncoming(finalAliceFrame.cipherText) + guard case .decrypted(let finalBobDecrypted) = finalBobResult else { + XCTFail("expected a decrypted application frame, got \(finalBobResult)") + return + } + XCTAssertEqual(finalBobDecrypted.applicationMessage, Data("post-heal-alice".utf8)) + + _ = try nativeBob.prepareToEncrypt() + let finalBobFrame = try nativeBob.encrypt(Data("post-heal-bob".utf8)) + let finalAliceResult = try XCTUnwrap( + alice.processIncoming(ciphertext: finalBobFrame.frame)) + XCTAssertEqual( + finalAliceResult.applicationMessage?.appMessageData, + Data("post-heal-bob".utf8)) + + // Alice's send-PQ leaf never gets bumped by any of this (same reasoning as above) — + // mint her and restore natively too. + let aliceExport = try alice.migrationExport() + XCTAssertEqual( + aliceExport.leafKeys.sendPq.current?.signatureKey, + originalAlicePQSignatureKey, + "alice's send-PQ leaf still presents her ORIGINAL (pre-rotation) PQ key — " + + "nothing in this flow ever moves it") + let aliceArchive = try SessionMigrator.mint( + kind: .checkpoint, from: aliceExport, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + _ = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: aliceArchive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + } + + // MARK: - CHARACTERIZATION: a rotation-before-bind mismatch never heals from the peer + + /// The deployed Rust engine binds A.3 with the current (post-rotation) principal's + /// signing key, but the peer's send-PQ tree — built from the bootstrap KP frozen at + /// `initiate()` — still records the original key for that leaf. The later A.5 Upd' + /// never matches what the peer's tree has on record, and unlike the heal case above, no + /// later classical fold reconciles it — this party heals only by migrating itself. + func testNativeBNeverHealsRotationBeforeA3Bind() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rrh2-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("rrh2-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + let heldKp: Data + switch try bobInvitation.openInitial(blob: kpEnvelope) { + case .bootstrapKp(let frame): heldKp = frame + case let other: + throw NSError( + domain: "rrh2", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope, got \(other)" + ]) + } + let welcome: Data + switch try bobInvitation.openInitial(blob: replyEnvelope) { + case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) + case let other: + throw NSError( + domain: "rrh2", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope, got \(other)" + ]) + } + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("rrh2-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + // Bob's send-PQ tree is built here, from alice's KP frozen at `initiate()` — the + // leaf presentation that never gets revised. + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + // Alice establishes her classical groups off the return welcome, so she can propose + // a rotation before ever closing A.3 with `pqBootstrapBind`. + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + + let newAliceId = TwoMLSPQBinding.ClientId( + bytes: Data("rrh2-alice-rotated".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: newAliceId) + let rotateFrame = try aliceSession.encrypt(appMessage: Data("rotate".utf8)) + let bobRotateOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: rotateFrame.cipherText)) + let rotateOffered = try XCTUnwrap(bobRotateOpened.proposal) + try bobSession.queueProposal(digest: rotateOffered.digest) + let bobFold = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold.didCommit) + let canonicalizeFrame = try bobSession.encrypt( + appMessage: Data("canonicalize".utf8)) + let aliceCanonOpened = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: canonicalizeFrame.cipherText)) + let remoteCommit = try XCTUnwrap(aliceCanonOpened.remoteCommit) + XCTAssertEqual(remoteCommit.newRecipient, newAliceId) + + // Alice now closes A.3 — binding with her current (rotated) principal, while bob's + // send-PQ tree still carries her original KP's presentation for that leaf. + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + + // A.3's own bind discharge: alice (binder) offers the bind riding her commit; bob + // applies the staple and gains the turn. Unlike the heal test, neither side's send + // auto-stages anything here — alice loses her turn inside this same discharge, + // before any auto-stage check runs on her own send. + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + + // Bob (now turn holder, nothing of his lags) opens a plain A.4; draining it hands + // the turn to alice, whose leaf already lags the pre-bind rotation. + _ = try bobSession.prepareToEncrypt(proposing: nil) + let bobOpener = try bobSession.encrypt(appMessage: Data("post-a3-opener".utf8)) + _ = try aliceSession.processIncoming(ciphertext: bobOpener.cipherText) + let incidentalEk = try XCTUnwrap(bobSession.pqPendingOutbound(sealing: .fresh)) + try aliceSession.pqRatchetRespond(ekMsg: incidentalEk) + let incidentalCt = try XCTUnwrap(aliceSession.pqTakePendingOutbound()) + try bobSession.pqRatchetBind(ctMsg: incidentalCt) + try RustSessionTestHelpers.committingRound(binder: bobSession, peer: aliceSession) + XCTAssertTrue(aliceSession.myPqTurn()) + + let bobExport = try bobSession.migrationExport() + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: bobExport, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let opener = try aliceSession.encrypt(appMessage: Data("open-a5".utf8)) + let bobOpenerResult = try nativeBob.processIncoming(opener.cipherText) + guard case .decrypted = bobOpenerResult else { + XCTFail("expected a decrypted application frame, got \(bobOpenerResult)") + return + } + let parkedUpd = try XCTUnwrap(aliceSession.pqPendingOutbound(sealing: .stable)) + + // Respond throws `.decryptionFailed`: the framing signature doesn't verify against + // the leaf key bob's tree has on record (alice signed with her post-rotation key; + // the tree still holds pre-rotation). + XCTAssertThrowsError(try nativeBob.pqRekeyRespond(parkedUpd)) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .decryptionFailed) + } + // Nothing is consumed: a retry throws the identical error. `makeSessionArchive` is + // internal, so "unchanged" is checked behaviorally below — bob keeps messaging + // correctly, which a corrupted state could not survive. + XCTAssertThrowsError(try nativeBob.pqRekeyRespond(parkedUpd)) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .decryptionFailed) + } + + _ = try nativeBob.prepareToEncrypt() + let bobFrame = try nativeBob.encrypt(Data("bob-still-healthy".utf8)) + let aliceGot = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: bobFrame.frame)) + XCTAssertEqual( + aliceGot.applicationMessage?.appMessageData, Data("bob-still-healthy".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let aliceFrame = try aliceSession.encrypt( + appMessage: Data("alice-still-sends".utf8)) + let bobGot = try nativeBob.processIncoming(aliceFrame.cipherText) + guard case .decrypted(let bobDecrypted) = bobGot else { + XCTFail("expected a decrypted application frame, got \(bobGot)") + return + } + XCTAssertEqual(bobDecrypted.applicationMessage, Data("alice-still-sends".utf8)) + + // Alice stays rekey-initiated — her side never saw a Commit', so nothing let her + // leave the round — and the same parked Upd' bytes come back byte-identical. + let stillParked = try XCTUnwrap(aliceSession.pqPendingOutbound(sealing: .stable)) + XCTAssertEqual(parkedUpd, stillParked) + } + + // MARK: - Shared establishment scaffolding + + /// A non-dedicated Rust pair through full establishment and the A.3 bind discharge, with + /// no trailing sends — so nothing has auto-staged and bob (the discharge's non-binder) + /// holds the turn at return. Mirrors `RustSessionTestHelpers. + /// bornDedicatedSessionPairAtDischarge`'s clean-slate shape, which + /// `SessionMigrationTests.establishedSessionPair` doesn't preserve since its trailing + /// sends legitimately auto-stage. + private func establishedNonDedicatedPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rrh-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("rrh-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + let heldKp: Data + switch try bobInvitation.openInitial(blob: kpEnvelope) { + case .bootstrapKp(let frame): heldKp = frame + case let other: + throw NSError( + domain: "rrh", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope, got \(other)" + ]) + } + let welcome: Data + switch try bobInvitation.openInitial(blob: replyEnvelope) { + case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) + case let other: + throw NSError( + domain: "rrh", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope, got \(other)" + ]) + } + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("rrh-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + XCTAssertTrue(aliceSession.isFullyEstablished()) + XCTAssertTrue(bobSession.isFullyEstablished()) + + return (aliceSession, bobSession) + } +} From 51f0b6df834b32da9ec4e2d45547f354d0d71183 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 12:12:22 -0700 Subject: [PATCH 07/16] Pin mls-rs germ-integration 1ce5ae3 Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.toml | 10 +++++----- rust/fuzz/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2f2be3e..b574e00 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,18 +39,18 @@ mls-rs = { version = "0.55", features = [ "safe_extensions", "swift_export", ] } -mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } -mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597", features = [ +mls-rs-core = { version = "0.27", git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62" } +mls-rs-crypto-awslc = { version = "0.25", git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62", features = [ "post-quantum", ] } -mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597", features = [ +mls-rs-crypto-cryptokit = { version = "0.11", git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62", features = [ "post-quantum", ] } -mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } +mls-rs-crypto-traits = { version = "0.22", git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62" } sha2 = "0.10" thiserror = "1" uniffi = "0.31" zeroize = { version = "1", features = ["derive"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62" } diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml index ab53c8f..98ef21c 100644 --- a/rust/fuzz/Cargo.toml +++ b/rust/fuzz/Cargo.toml @@ -20,7 +20,7 @@ libfuzzer-sys = "0.4" two-mls-pq = { path = "../two-mls-pq", default-features = false, features = ["awslc"] } [patch.crates-io] -mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "585091d890b20a8124b738b384681494841b5597" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62" } [[bin]] bench = false From c9836585ba86b30cdb18b961d246e13ba51c7126 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 13:17:06 -0700 Subject: [PATCH 08/16] Export an empty send-PQ for a pre-A.3 acceptor A.3 founding now mints its own fresh key, so native requires the unfounded send-PQ to carry no reservation. Repins twomlspq-swift to 902fead. Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 3 +- Package.resolved | 4 +- Package.swift | 5 +- Sources/TwoMLSPQBinding/two_mls_pq.swift | 3 +- .../MintCoverageTests.swift | 52 ++++++++++++++++++- rust/two-mls-pq/src/session/migration.rs | 30 +++++++---- rust/two-mls-pq/src/session/tests.rs | 11 ++-- 7 files changed, 85 insertions(+), 23 deletions(-) diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md index f5cc31f..6a8a32d 100644 --- a/.changeset/born-dedicated-acceptor-export.md +++ b/.changeset/born-dedicated-acceptor-export.md @@ -7,7 +7,8 @@ refusing unsettled ones: pre-establishment initiators, born-dedicated acceptors staged rotation candidates, lagging leaves, and parked or wedged PQ rounds. It fails only on corrupt data (`ArchiveInvalid`). The export carries per-group signing keys (`leafKeys`), the rotation candidate, an own-offer window with its leaf secrets, and deployed-engine flags -(`deployedState`). `BINDING_CONTRACT_VERSION` bumps 35 → 36. +(`deployedState`). A pre-A.3 acceptor's `leafKeys.sendPq` is empty, since A.3 founding mints +its own key. `BINDING_CONTRACT_VERSION` bumps 35 → 36. `SessionMigrator.mintArchive(kind:from:classicalProvider:pqProvider:)` is removed. `SessionMigrator.mint(kind:from:classicalProvider:pqProvider:)` returns a `MintResult`: the diff --git a/Package.resolved b/Package.resolved index 87eb37d..3cf8ff9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "edf0e74cf570a8377a208b7e2f7a6391adb99400e884eefecf482599915296d6", + "originHash" : "e12d1534d13a45a598bcc07d8a58780236000b103cdf5d641e7e967ca29cdc44", "pins" : [ { "identity" : "atprototypes", @@ -105,7 +105,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/twomlspq-swift.git", "state" : { - "revision" : "dd62668945e85bdfcb26c2c1653cc1c4082c61ef" + "revision" : "902feadc916cdfa36676bd627b1098ef9b804142" } } ], diff --git a/Package.swift b/Package.swift index 053456c..eca5988 100644 --- a/Package.swift +++ b/Package.swift @@ -85,10 +85,11 @@ let package = Package( // (SessionMigration + the ML-KEM `hpkeSecretKeySize`); its transitive deps // (swift-mls, swift-secret-bytes, swift-crypto, GermConvenience) resolve // automatically. Pinned to unreleased main for the deployed-state migration - // inputs; move to the release that ships them before merging. + // inputs and the empty pre-A.3 send-PQ shape; move to the release that ships + // them before merging. .package( url: "https://github.com/germ-network/twomlspq-swift.git", - revision: "dd62668945e85bdfcb26c2c1653cc1c4082c61ef" + revision: "902feadc916cdfa36676bd627b1098ef9b804142" ), // Declared directly (not just transitively through twomlspq-swift) because // the migrate targets import their products. Library deps stay ranged diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index 7c142b5..c6e97d7 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -6086,7 +6086,8 @@ public func FfiConverterTypeSessionMigrationKeyPair_lower(_ value: SessionMigrat /** * Every own leaf's resolved custody. All four groups are always present: a * group that doesn't exist yet carries a reservation in `current` (the key - * it will present once created or joined) with `pending` empty. + * it will present once created or joined) with `pending` empty, except a + * pre-A.3 acceptor's `send_pq`, which is empty (A.3 founding mints its key). */ public struct SessionMigrationLeafKeys: Equatable, Hashable { public var sendClassical: SessionMigrationGroupKeys diff --git a/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift index 10c1ab8..f3883b9 100644 --- a/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift +++ b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift @@ -6,7 +6,7 @@ import TwoMLSPQMigrate import TwoMLSPQSession import XCTest -// A totality sweep, not a regression pin: seven export shapes the round-trip suites +// A totality sweep, not a regression pin: eight export shapes the round-trip suites // don't otherwise exercise, pushed through export -> map -> mint -> restore with minimal // assertions. The point is coverage of native's acceptance, not any field's exact value // — a native refusal here is a bug to report, not to paper over. @@ -307,6 +307,56 @@ final class MintCoverageTests: XCTestCase { XCTAssertTrue(restored.noCustody.contains(.recvPQ)) } + // MARK: - 8. Pre-A.3 acceptor + + /// A confirmed pair before A.3: bob's send-PQ isn't founded yet, so his export carries the + /// canonical empty set, neither a reservation nor no-custody. Native bob then answers A.3 + /// himself on a freshly minted key, and Rust alice binds and discharges. + func testPreA3AcceptorExportsEmptySendPqAndAnswersA3Natively() throws { + let (alice, bob) = try establishConfirmedNonDedicatedPair() + + let export = try bob.migrationExport() + XCTAssertNil(export.leafKeys.sendPq.current) + XCTAssertTrue(export.leafKeys.sendPq.pending.isEmpty) + XCTAssertFalse(export.deployedState?.noCustody.sendPq ?? false) + + let minted = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: minted.archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + XCTAssertFalse(nativeBob.isFullyEstablished) + + let kp = try alice.pqBootstrapBegin(rotating: nil) + let welcomePrime = try nativeBob.pqBootstrapRespond(kp).frame + XCTAssertTrue(nativeBob.isFullyEstablished) + let opened = try XCTUnwrap(try alice.openIncoming(blob: welcomePrime)) + try alice.pqBootstrapBind(welcomeMsg: opened.frame) + + // Discharge the bind: native bob offers, Rust alice commits. + _ = try nativeBob.prepareToEncrypt() + let bobUpd = try nativeBob.encrypt(Data("mc-a3-bob-upd".utf8)) + let offered = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpd.frame)?.proposal) + try alice.queueProposal(digest: offered.digest) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceCommit = try alice.encrypt(appMessage: Data("mc-a3-alice-commit".utf8)) + let bobOpened = try nativeBob.processIncoming(aliceCommit.cipherText) + guard case .decrypted(let bobDecrypted) = bobOpened else { + XCTFail("expected a decrypted application frame, got \(bobOpened)") + return + } + XCTAssertEqual(bobDecrypted.applicationMessage, Data("mc-a3-alice-commit".utf8)) + XCTAssertTrue(alice.isFullyEstablished()) + + _ = try nativeBob.prepareToEncrypt() + let bobMsg = try nativeBob.encrypt(Data("mc-a3-bob-msg".utf8)) + let aliceGot = try XCTUnwrap(alice.processIncoming(ciphertext: bobMsg.frame)) + XCTAssertEqual( + aliceGot.applicationMessage?.appMessageData, Data("mc-a3-bob-msg".utf8)) + } + // MARK: - Shared establishment scaffolding /// A plain pair (no bootstrap-KP piggyback, PQ deferred), joined with one confirm frame diff --git a/rust/two-mls-pq/src/session/migration.rs b/rust/two-mls-pq/src/session/migration.rs index eb25187..02070f2 100644 --- a/rust/two-mls-pq/src/session/migration.rs +++ b/rust/two-mls-pq/src/session/migration.rs @@ -40,7 +40,8 @@ //! `recv_classical`, `send_pq`, `recv_pq`), never `None`: a group that //! doesn't exist yet carries a reservation in `current` (the key it will //! present once created or joined) with empty `pending` — see -//! `reservation`; +//! `reservation` — except a pre-A.3 acceptor's send-PQ, which is empty +//! because A.3 founding mints its own key; //! * generalized catch-up: any own leaf whose presented credential lags //! `auth.mine`'s current one gets a synthesized `pending[mine.current]` //! entry carrying the identity's current key of that half's kind — see @@ -307,7 +308,8 @@ pub struct SessionMigrationGroupKeys { /// Every own leaf's resolved custody. All four groups are always present: a /// group that doesn't exist yet carries a reservation in `current` (the key -/// it will present once created or joined) with `pending` empty. +/// it will present once created or joined) with `pending` empty, except a +/// pre-A.3 acceptor's `send_pq`, which is empty (A.3 founding mints its key). #[derive(Clone, uniffi::Record)] pub struct SessionMigrationLeafKeys { pub send_classical: SessionMigrationGroupKeys, @@ -890,11 +892,11 @@ fn resolve_leaf_key( Ok((SessionMigrationGroupKeys { current, pending }, no_custody)) } -/// A group that doesn't exist yet still carries a reservation key in -/// `current` (the key it will present once created or joined), with -/// `pending` empty. `current: None` is the reservation's own no-custody -/// case, since native's `validateLeafKeys` requires `current` to be `nil` -/// exactly when the group's role is in `noCustody`. +/// A group that doesn't exist yet holds a reservation key in `current` (the +/// key it will present once created or joined), with `pending` empty. +/// `current: None` is the reservation's own no-custody case, since native's +/// `validateLeafKeys` requires `current` to be `nil` exactly when the group's +/// role is in `noCustody`. fn reservation(current: Option) -> (SessionMigrationGroupKeys, bool) { let no_custody = current.is_none(); ( @@ -1618,9 +1620,9 @@ impl TwoMlsPqSession { ) }; - // send_pq: the reservation for a pre-A.3 acceptor (the identity's - // current PQ key), otherwise resolved plus the generalized catch-up - // (no candidates: rotation is classical-only). + // send_pq: empty for a pre-A.3 acceptor (the group isn't founded yet, and + // A.3 founding mints its own key), otherwise resolved plus the generalized + // catch-up (no candidates: rotation is classical-only). let (send_pq_leaf, send_pq_no_custody) = match send_ref.pq.as_ref().zip(send_pq.as_ref()) { Some((group, (_, pending))) => { let (leaf, no_custody) = resolve_leaf_key(group, &pq_provider, &pool, pending)?; @@ -1634,7 +1636,13 @@ impl TwoMlsPqSession { no_custody, ) } - None => reservation(Some(identity_pq_pair.clone())), + None => ( + SessionMigrationGroupKeys { + current: None, + pending: Vec::new(), + }, + false, + ), }; // recv_classical: the reservation for a pre-join initiator (the diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index 4fd83cc..945a154 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -8909,11 +8909,11 @@ fn test_migration_export_two_rotations_carry_three_live_pq_keys() { } } -/// A pre-A.3 acceptor (send.pq deferred) reserves `send_pq.current` as the identity's current -/// PQ key — what `pq_bootstrap_respond` will found with — with empty `pending`. +/// A pre-A.3 acceptor (send.pq deferred) exports an empty `send_pq`, not a no-custody one: the +/// group isn't founded, and the native A.3 founding mints its own key. #[cfg(feature = "cryptokit")] #[test] -fn test_migration_export_send_pq_reservation_for_pre_a3_acceptor() { +fn test_migration_export_send_pq_empty_for_pre_a3_acceptor() { let (_alice, bob) = establish_confirmed_sessions(); assert!( bob.lock().send_group.as_ref().unwrap().pq.is_none(), @@ -8921,8 +8921,7 @@ fn test_migration_export_send_pq_reservation_for_pre_a3_acceptor() { ); let export = assert_ok!(bob.migration_export()); let send_pq = export.leaf_keys.send_pq; - let current = assert_some!(send_pq.current); - assert_eq!(current.signature_key, export.identity.pq_signature_key); + assert!(send_pq.current.is_none()); assert!(send_pq.pending.is_empty()); if let Some(deployed) = export.deployed_state { assert!(!deployed.no_custody.send_pq); @@ -11370,6 +11369,8 @@ mod totality_random_walk { (None, Some(_)) => { // The reservation case: no live group to compare against. } + // A send-PQ group not founded yet (pre-A.3 acceptor) carries an empty set. + (None, None) if name == "send_pq" => {} (_, None) => { if !allow_no_custody { // Unexpected no-custody: dump live state for diagnosis. From 2b0ff7eb8edcdd423df216fc70fda831e6359f86 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 13:52:50 -0700 Subject: [PATCH 09/16] Pin the A.5 legs' credential carry across engines The born-dedicated opener now announces its dedicated id, and a migrated responder's Commit' moves its lagging send-PQ leaf to its rotated id, which Rust applies. Needs native step 6. Co-Authored-By: Claude Opus 5.5 --- .../BornDedicatedMigrationTests.swift | 13 ++-- .../RotatedRekeyHealTests.swift | 68 ++++++++++++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift index c116b06..7930fe6 100644 --- a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift +++ b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift @@ -98,11 +98,12 @@ final class BornDedicatedMigrationTests: XCTestCase { try bobSays(&restoredBob, "restored-bob-to-alice", to: pair.alice) } - // MARK: - Exercise the exported PQ custody key via a mechanical A.5 + // MARK: - Exercise the exported PQ custody key via an A.5 catch-up - /// A.2/A.4 never sign in recv.pq, so this mechanical A.5 round (native bob as initiator) - /// is the only path that exercises the custodied PQ signing key, via `pqRekeyBegin`'s - /// plain self-Update into `recvGroup.pq`. + /// A.2/A.4 never sign in recv.pq, so this A.5 round (native bob as initiator) is the only + /// path that exercises the custodied PQ signing key, via `pqRekeyBegin`'s Update into + /// `recvGroup.pq`. That leaf still presents the invitation id, so the Update also catches + /// it up to bob's dedicated id and announces it. func testMigratedAcceptorSignsWithCustodiedPQKeyDuringA5Rekey() throws { // Must start at the AT-DISCHARGE point, not `bornDedicatedSessionPair`: nothing has // sent since the A.3 bind discharge, so `pqRekeyBegin`'s clean-slate precondition @@ -126,7 +127,9 @@ final class BornDedicatedMigrationTests: XCTestCase { // Rust alice (committer) folds it into an includePath commit on her send-PQ group. let announced = try pair.alice.pqRekeyRespond(updMsg: begin.frame) - XCTAssertNil(announced, "the mechanical rekey carries no credential handoff") + XCTAssertEqual( + announced?.bytes, pair.dedicatedId, + "bob's Upd' announces the dedicated id his recv-PQ leaf catches up to") let commitFrame = try XCTUnwrap(pair.alice.pqTakePendingOutbound()) // Native bob applies alice's Commit' and owes the classical bind. diff --git a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift index 0bbdee7..f92cd7e 100644 --- a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift +++ b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift @@ -3,9 +3,11 @@ import MLSCrypto import TwoMLSPQBinding import TwoMLSPQCrypto import TwoMLSPQMigrate -import TwoMLSPQSession import XCTest +// Testable only to read native own-leaf ids, which have no public accessor. +@testable import TwoMLSPQSession + // A stuck §A.5 rekey, cross-engine: a deployed Rust party (A) rotates its classical // identity, its lagging send-PQ leaf opens §A.5 to catch up in the peer-committed PQ // group, and the peer (B) has since migrated to native. Companion to @@ -439,6 +441,70 @@ final class RotatedRekeyHealTests: XCTestCase { XCTAssertEqual(parkedUpd, stillParked) } + // MARK: - Native responder carries its own rotation onto its send-PQ leaf + + /// Bob migrates, then rotates natively, and Rust alice's fold of that rotation opens her + /// A.5 (her own send-PQ leaf lags her earlier rotation). Native bob's responder Commit' + /// carries his current credential, moving his lagging send-PQ leaf to his new id, and + /// Rust alice's `pq_rekey_apply` accepts a Commit' whose committer path leaf changes id. + func testMigratedNativeResponderCarriesItsRotatedIdToRust() throws { + let (alice, bob) = try establishedNonDedicatedPair() + let aliceRotated = TwoMLSPQBinding.ClientId(bytes: Data("rrh-alice-rotated".utf8)) + _ = try alice.prepareToEncrypt(proposing: aliceRotated) + let rotateFrame = try alice.encrypt(appMessage: Data("rotate".utf8)) + let opened = try XCTUnwrap(bob.processIncoming(ciphertext: rotateFrame.cipherText)) + try bob.queueProposal(digest: try XCTUnwrap(opened.proposal).digest) + XCTAssertTrue(try bob.prepareToEncrypt(proposing: nil).didCommit) + let canonicalize = try bob.encrypt(appMessage: Data("canonicalize".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: canonicalize.cipherText)) + + // Drain the A.4 bob's commit auto-staged, so alice holds the PQ turn at export. + let incidentalEk = try XCTUnwrap(bob.pqPendingOutbound(sealing: .fresh)) + try alice.pqRatchetRespond(ekMsg: incidentalEk) + let incidentalCt = try XCTUnwrap(alice.pqTakePendingOutbound()) + try bob.pqRatchetBind(ctMsg: incidentalCt) + try RustSessionTestHelpers.committingRound(binder: bob, peer: alice) + XCTAssertTrue(alice.myPqTurn()) + + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: try bob.migrationExport(), + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let bobOriginal = nativeBob.myPrincipalState.clientID + + let bobRotated = Data("rrh-bob-rotated".utf8) + _ = try nativeBob.prepareToEncrypt(rotating: bobRotated) + let bobOffer = try nativeBob.encrypt(Data("bob-rotate".utf8)) + let aliceGot = try XCTUnwrap(alice.processIncoming(ciphertext: bobOffer.frame)) + try alice.queueProposal(digest: try XCTUnwrap(aliceGot.proposal).digest) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceFold = try alice.encrypt(appMessage: Data("alice-fold".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceFold.cipherText) else { + return XCTFail("expected native bob to decrypt alice's fold") + } + XCTAssertEqual(nativeBob.myPrincipalState, .sync(bobRotated)) + + // Alice's fold opens her A.5. + let upd = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) + XCTAssertEqual( + try nativeBob.openIncoming(upd)?.kind, .pqSideBand(.rekeyUpd)) + XCTAssertEqual(try sendPQLeafID(of: nativeBob), bobOriginal) + + let response = try nativeBob.pqRekeyRespond(upd) + XCTAssertEqual(response.rotatedCredential, aliceRotated.bytes) + XCTAssertEqual(try sendPQLeafID(of: nativeBob), bobRotated) + try alice.pqRekeyApply(msg: response.frame) + } + + private func sendPQLeafID(of session: TwoMLSPQSession.TwoMLSSession) throws -> Data { + let group = try XCTUnwrap(session.sendGroup?.pq) + return try TwoMLSPQSession.basicIdentifier( + TwoMLSPQSession.TwoMLSSession.ownLeaf(of: group).credential) + } + // MARK: - Shared establishment scaffolding /// A non-dedicated Rust pair through full establishment and the A.3 bind discharge, with From 1ec2aa508313257fcfb5b2b65e8a04740025df3e Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 13:56:52 -0700 Subject: [PATCH 10/16] Pin a rotated native opener's A.5 announce to Rust Native bob rotates c0 to c1, which Rust knows only through history. His own A.5 moves the lagging recv-PQ leaf to c1 and announces it, and the Rust responder accepts. Needs native step 6. Co-Authored-By: Claude Opus 5.5 --- .../RotatedRekeyHealTests.swift | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift index f92cd7e..c541429 100644 --- a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift +++ b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift @@ -499,6 +499,139 @@ final class RotatedRekeyHealTests: XCTestCase { try alice.pqRekeyApply(msg: response.frame) } + // MARK: - Rotated non-dedicated native opener announces its id to a Rust responder + + /// Bob migrates and rotates c0 -> c1 natively. c0 is known to Rust alice only through her + /// history of bob's ids, not an A.3 pin. Bob's own A.5 then moves his lagging recv-PQ leaf + /// c0 -> c1 and announces c1, and Rust alice's responder accepts it. This is the + /// plain-rotation twin of the born-dedicated I -> D announce. + func testMigratedRotatedOpenerAnnouncesItsIdToRustResponder() throws { + let (alice, bob) = try establishedNonDedicatedPair() + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: try bob.migrationExport(), + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + let c0 = nativeBob.myPrincipalState.clientID + XCTAssertEqual(try recvPQLeafID(of: nativeBob), c0) + + let c1 = Data("rrh-bob-c1".utf8) + _ = try nativeBob.prepareToEncrypt(rotating: c1) + let bobOffer = try nativeBob.encrypt(Data("bob-rotate".utf8)) + let aliceGot = try XCTUnwrap(alice.processIncoming(ciphertext: bobOffer.frame)) + try alice.queueProposal(digest: try XCTUnwrap(aliceGot.proposal).digest) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceFold = try alice.encrypt(appMessage: Data("alice-fold".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceFold.cipherText) else { + return XCTFail("expected native bob to decrypt alice's fold") + } + XCTAssertEqual(nativeBob.myPrincipalState, .sync(c1)) + + // Holding the turn, bob auto-staged an A.4 on alice's fold. Drain it and then alice's + // answering A.4, so the turn comes back to bob at a discharge with nothing staged, + // the clean slate `pqRekeyBegin` needs. + try drainNativeA4(from: &nativeBob, to: alice) + try drainRustA4(from: alice, to: &nativeBob) + XCTAssertEqual(try recvPQLeafID(of: nativeBob), c0, "the recv-PQ leaf lags") + + XCTAssertTrue(nativeBob.myPQTurn) + let begin = try nativeBob.pqRekeyBegin() + let announced = try alice.pqRekeyRespond(updMsg: begin.frame) + XCTAssertEqual(announced?.bytes, c1) + let commitFrame = try XCTUnwrap(alice.pqTakePendingOutbound()) + _ = try nativeBob.pqRekeyApply(commitFrame) + // Alice's copy agrees: applying her Commit' checks the tree it confirms, which holds + // bob's c1 leaf. + XCTAssertEqual(try recvPQLeafID(of: nativeBob), c1) + + // Bind discharge: alice offers an Upd, native bob (the binder) folds and commits it. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpd = try alice.encrypt(appMessage: Data("a5-bind-upd".utf8)) + guard + case .decrypted(let bobDecrypted) = try nativeBob.processIncoming( + aliceUpd.cipherText) + else { + return XCTFail("expected native bob to decrypt alice's Upd") + } + _ = try nativeBob.queueProposal(digest: bobDecrypted.queuedProposal.digest) + XCTAssertTrue(try nativeBob.prepareToEncrypt().didCommit) + let bobCommit = try nativeBob.encrypt(Data("a5-bind-commit".utf8)) + let aliceGotCommit = try XCTUnwrap( + alice.processIncoming(ciphertext: bobCommit.frame)) + XCTAssertEqual( + aliceGotCommit.applicationMessage?.appMessageData, + Data("a5-bind-commit".utf8)) + + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceMsg = try alice.encrypt(appMessage: Data("post-a5-alice".utf8)) + guard + case .decrypted(let bobGot) = try nativeBob.processIncoming( + aliceMsg.cipherText) + else { + return XCTFail("expected native bob to decrypt alice's message") + } + XCTAssertEqual(bobGot.applicationMessage, Data("post-a5-alice".utf8)) + } + + /// Native bob's parked A.4: alice answers, bob binds, and alice's Upd folded by bob + /// discharges the bind, passing the turn to alice. + private func drainNativeA4( + from nativeBob: inout TwoMLSPQSession.TwoMLSSession, + to alice: TwoMLSPQBinding.TwoMlsPqSession + ) throws { + let ek = try XCTUnwrap(nativeBob.pqPendingOutbound()) + try alice.pqRatchetRespond(ekMsg: ek) + _ = try nativeBob.pqRatchetBind(try XCTUnwrap(alice.pqTakePendingOutbound())) + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpd = try alice.encrypt(appMessage: Data("a4-bind-upd".utf8)) + guard + case .decrypted(let opened) = try nativeBob.processIncoming( + aliceUpd.cipherText) + else { + return XCTFail("expected native bob to decrypt alice's Upd") + } + _ = try nativeBob.queueProposal(digest: opened.queuedProposal.digest) + XCTAssertTrue(try nativeBob.prepareToEncrypt().didCommit) + let bobCommit = try nativeBob.encrypt(Data("a4-bind-commit".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: bobCommit.frame)) + XCTAssertTrue(alice.myPqTurn()) + } + + /// Alice's A.4 on her next send: native bob answers, alice binds, and bob's Upd folded by + /// alice discharges the bind, passing the turn back to bob. + private func drainRustA4( + from alice: TwoMLSPQBinding.TwoMlsPqSession, + to nativeBob: inout TwoMLSPQSession.TwoMLSSession + ) throws { + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceMsg = try alice.encrypt(appMessage: Data("a4-open".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceMsg.cipherText) else { + return XCTFail("expected native bob to decrypt alice's message") + } + let ek = try XCTUnwrap(alice.pqPendingOutbound(sealing: .fresh)) + let ct = try nativeBob.pqRatchetRespond(ek) + try alice.pqRatchetBind(ctMsg: ct.frame) + _ = try nativeBob.prepareToEncrypt() + let bobUpd = try nativeBob.encrypt(Data("a4-bind-upd".utf8)) + let offered = try XCTUnwrap( + alice.processIncoming(ciphertext: bobUpd.frame)?.proposal) + try alice.queueProposal(digest: offered.digest) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceCommit = try alice.encrypt(appMessage: Data("a4-bind-commit".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceCommit.cipherText) else { + return XCTFail("expected native bob to decrypt alice's discharge") + } + XCTAssertTrue(nativeBob.myPQTurn) + } + + private func recvPQLeafID(of session: TwoMLSPQSession.TwoMLSSession) throws -> Data { + let group = try XCTUnwrap(session.recvGroup?.pq) + return try TwoMLSPQSession.basicIdentifier( + TwoMLSPQSession.TwoMLSSession.ownLeaf(of: group).credential) + } + private func sendPQLeafID(of session: TwoMLSPQSession.TwoMLSSession) throws -> Data { let group = try XCTUnwrap(session.sendGroup?.pq) return try TwoMLSPQSession.basicIdentifier( From 64baacbf45cdf5e82e2099087232e8ace9f315e0 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 15:05:03 -0700 Subject: [PATCH 11/16] Drive the rotated-party heals through the reciprocal A.5 With the reciprocal self-drive, a migrated rotate-before-bind party re-drives its catch-up after the import drop, and a native peer's reciprocal round moves a rotated Rust party's send-PQ leaf. Adds the C2 case: the reciprocal waits until the rotated party's own A.5 lands. Needs native step 7. Co-Authored-By: Claude Opus 5.5 --- .../DeployedStateMigrationTests.swift | 48 +- .../RotatedRekeyHealTests.swift | 409 ++++++++++-------- 2 files changed, 255 insertions(+), 202 deletions(-) diff --git a/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift index b45ed94..f8f74b7 100644 --- a/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift +++ b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift @@ -24,8 +24,9 @@ final class DeployedStateMigrationTests: XCTestCase { /// Alice rotates D -> N before her A.3 bind, so her self-driven A.5 Upd' is mis-signed /// — framed with N against a leaf the peer's tree still has on record as D. Export must - /// carry the parked round; native import drops it and resumes with a plain ratchet. - func testRotateBeforeBindWedgeDropsAtImportAndSelfDrivesAPlainRatchet() throws { + /// carry the parked round. Native import drops it, and alice's next send re-drives the + /// catch-up, signed with D's carried key, which the Rust peer accepts. + func testRotateBeforeBindWedgeDropsAtImportAndSelfDrivesTheCatchUp() throws { let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rbb-alice".utf8)) let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( clientId: Data("rbb-bob".utf8)) @@ -143,19 +144,46 @@ final class DeployedStateMigrationTests: XCTestCase { nativeAlice.pqPendingOutbound(), "no pending side-band should survive import") - // Once the PQ turn is alice's, her next send opens a plain A.4 (not an A.5), - // classified by the Rust peer as the ratchet EK leg, under the carried key. + // Her recv-PQ leaf still presents D, so once the PQ turn is hers, her next send + // re-opens the A.5: the Upd' moves D -> N under D's carried key and announces N. XCTAssertTrue(nativeAlice.myPQTurn) _ = try nativeAlice.prepareToEncrypt() - let followUp = try nativeAlice.encrypt(Data("post-restore-a4".utf8)) + let followUp = try nativeAlice.encrypt(Data("post-restore-a5".utf8)) let bobOpened = try XCTUnwrap( bobSession.processIncoming(ciphertext: followUp.frame)) XCTAssertEqual( - bobOpened.applicationMessage?.appMessageData, Data("post-restore-a4".utf8)) - let ekLeg = try XCTUnwrap(nativeAlice.pqPendingOutbound()) - let classified = try bobSession.openIncoming(blob: ekLeg) - XCTAssertEqual(classified?.kind, .pqSideBand(kind: .ratchetEphemeralKey)) - XCTAssertNoThrow(try bobSession.pqRatchetRespond(ekMsg: ekLeg)) + bobOpened.applicationMessage?.appMessageData, Data("post-restore-a5".utf8)) + let updLeg = try XCTUnwrap(nativeAlice.pqPendingOutbound()) + XCTAssertEqual( + try bobSession.openIncoming(blob: updLeg)?.kind, + .pqSideBand(kind: .rekeyUpdate)) + let announced = try bobSession.pqRekeyRespond(updMsg: updLeg) + XCTAssertEqual(announced?.bytes, newAliceId.bytes) + _ = try nativeAlice.pqRekeyApply(try XCTUnwrap(bobSession.pqTakePendingOutbound())) + + // Bind discharge: bob offers an Upd, native alice (the binder) folds and commits it. + _ = try bobSession.prepareToEncrypt(proposing: nil) + let bobUpd = try bobSession.encrypt(appMessage: Data("heal-bind-upd".utf8)) + guard + case .decrypted(let aliceOpened) = try nativeAlice.processIncoming( + bobUpd.cipherText) + else { + return XCTFail("expected native alice to decrypt bob's Upd") + } + _ = try nativeAlice.queueProposal(digest: aliceOpened.queuedProposal.digest) + XCTAssertTrue(try nativeAlice.prepareToEncrypt().didCommit) + let aliceCommit = try nativeAlice.encrypt(Data("heal-bind-commit".utf8)) + _ = try XCTUnwrap(bobSession.processIncoming(ciphertext: aliceCommit.frame)) + + // Healed: with the turn, bob opens a plain A.4, and native alice answers it. + XCTAssertTrue(bobSession.myPqTurn()) + _ = try bobSession.prepareToEncrypt(proposing: nil) + let postHeal = try bobSession.encrypt(appMessage: Data("post-heal".utf8)) + guard case .decrypted = try nativeAlice.processIncoming(postHeal.cipherText) else { + return XCTFail("expected native alice to decrypt bob's message") + } + let ekLeg = try XCTUnwrap(bobSession.pqPendingOutbound(sealing: .fresh)) + XCTAssertNoThrow(try nativeAlice.pqRatchetRespond(ekLeg)) } // MARK: - Two rotations: each PQ group's `current` is its own presented key diff --git a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift index c541429..7592ed5 100644 --- a/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift +++ b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift @@ -11,13 +11,12 @@ import XCTest // A stuck §A.5 rekey, cross-engine: a deployed Rust party (A) rotates its classical // identity, its lagging send-PQ leaf opens §A.5 to catch up in the peer-committed PQ // group, and the peer (B) has since migrated to native. Companion to -// `BornDedicatedMigrationTests`'s mechanical A.5 case (native initiates, Rust responds): -// this drives the other direction. Alice's own send-PQ leaf is never moved by any of -// this — only a responder's own Commit' carries its current credential -// (`pq_rekey_respond`'s `set_new_signing_identity`), and nothing here makes alice a -// responder catching herself up — but `leafKeys`'s general catch-up carries that lag -// directly, so she migrates too, at the end. -// +// `BornDedicatedMigrationTests`'s A.5 case (native initiates, Rust responds): this +// drives the other direction. Alice's own send-PQ leaf moves only when she answers a +// round: once her own A.5 has landed, native bob's reciprocal A.5 makes her the +// responder, and her Commit' carries her current credential +// (`pq_rekey_respond`'s `set_new_signing_identity`). + // Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI // record types are module-qualified throughout. @@ -36,8 +35,8 @@ final class RotatedRekeyHealTests: XCTestCase { // below. let earlyBobExport = try bob.migrationExport() // Alice's original PQ key, before rotation swaps her whole identity (classical and - // PQ together) to the new principal's — her send-PQ leaf keeps presenting this key - // for the rest of the test, since nothing ever moves it. + // PQ together) to the new principal's. Her send-PQ leaf presents it until bob's + // reciprocal round at the end moves it. let originalAlicePQSignatureKey = try alice.migrationExport().identity .pqSignatureKey @@ -173,121 +172,50 @@ final class RotatedRekeyHealTests: XCTestCase { } XCTAssertEqual(bobCommitDecrypted.applicationMessage, Data("a5-bind-commit".utf8)) - // Postcondition: alice's PQ leaf presents her new id in native bob's view - // (`rotatedCredential` above). Passing the turn back needs a full round: bob - // (nothing of his lags) opens a plain A.4, and its own discharge hands the turn to - // alice. + // Postcondition: alice's recv-PQ leaf presents her new id in native bob's view + // (`rotatedCredential` above). Her send-PQ leaf, in bob's recv-PQ group, still lags, + // and her own A.5 has landed, so bob's next send opens the reciprocal A.5: a + // same-id refresh of his own leaf there. _ = try nativeBob.prepareToEncrypt() - let followUpOpener = try nativeBob.encrypt(Data("post-a5-opener".utf8)) - let aliceFollowUpOpened = try alice.processIncoming( - ciphertext: followUpOpener.frame) - XCTAssertEqual( - aliceFollowUpOpened?.applicationMessage?.appMessageData, - Data("post-a5-opener".utf8)) - let followUpEk = try XCTUnwrap(nativeBob.pqPendingOutbound()) - try alice.pqRatchetRespond(ekMsg: followUpEk) - let followUpCt = try XCTUnwrap(alice.pqTakePendingOutbound()) - _ = try nativeBob.pqRatchetBind(followUpCt) - - _ = try alice.prepareToEncrypt(proposing: nil) - let aliceBindUpd2 = try alice.encrypt(appMessage: Data("post-a5-bind-upd".utf8)) - let bobBindOpened2 = try nativeBob.processIncoming(aliceBindUpd2.cipherText) - guard case .decrypted(let bobDecrypted2) = bobBindOpened2 else { - XCTFail("expected a decrypted application frame, got \(bobBindOpened2)") - return - } - _ = try nativeBob.queueProposal(digest: bobDecrypted2.queuedProposal.digest) - let bobPrepared2 = try nativeBob.prepareToEncrypt() - XCTAssertTrue(bobPrepared2.didCommit, "the second bind discharge needs a commit") - let bobCommit2 = try nativeBob.encrypt(Data("post-a5-bind-commit".utf8)) - let aliceGotCommit2 = try XCTUnwrap( - alice.processIncoming(ciphertext: bobCommit2.frame)) - XCTAssertEqual( - aliceGotCommit2.applicationMessage?.appMessageData, - Data("post-a5-bind-commit".utf8)) - - // `send_pq_leaf_lags` checks alice's own send-PQ leaf. A responder's Commit' carries - // its current credential when its own send-PQ leaf lags, but alice is always this - // round's initiator, never a responder catching herself up — so with the turn back, - // her next send auto-stages another A.5, this time a same-id key change: the steady - // state for every rotated Rust peer talking to native. - XCTAssertTrue(alice.myPqTurn()) - _ = try alice.prepareToEncrypt(proposing: nil) - let secondOpener = try alice.encrypt(appMessage: Data("open-a5-again".utf8)) - let bobSecondOpenerResult = try nativeBob.processIncoming(secondOpener.cipherText) - guard case .decrypted(let secondOpenerDecrypted) = bobSecondOpenerResult else { - XCTFail( - "expected a decrypted application frame, got \(bobSecondOpenerResult)" - ) - return - } + let reciprocalOpener = try nativeBob.encrypt(Data("reciprocal-opener".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: reciprocalOpener.frame)) + let reciprocalUpd = try XCTUnwrap(nativeBob.pqPendingOutbound()) XCTAssertEqual( - secondOpenerDecrypted.applicationMessage, Data("open-a5-again".utf8)) - let secondUpd = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) - let secondClassified = try nativeBob.openIncoming(secondUpd) - XCTAssertEqual(secondClassified?.kind, .pqSideBand(.rekeyUpd)) - let secondSideBandResult = try nativeBob.pqRekeyRespond(secondUpd) + try alice.openIncoming(blob: reciprocalUpd)?.kind, + .pqSideBand(kind: .rekeyUpdate)) XCTAssertNil( - secondSideBandResult.rotatedCredential, - "a same-id catch-up hands off nothing" - ) - try alice.pqRekeyApply(msg: secondSideBandResult.frame) + try alice.pqRekeyRespond(updMsg: reciprocalUpd), + "bob's same-id refresh announces nothing") + _ = try nativeBob.pqRekeyApply(try XCTUnwrap(alice.pqTakePendingOutbound())) - _ = try nativeBob.prepareToEncrypt() - let thirdBindUpd = try nativeBob.encrypt(Data("a5-again-bind-upd".utf8)) - let aliceThirdBindOpened = try XCTUnwrap( - alice.processIncoming(ciphertext: thirdBindUpd.frame)) - let thirdBindOffered = try XCTUnwrap(aliceThirdBindOpened.proposal) - try alice.queueProposal(digest: thirdBindOffered.digest) - let aliceThirdPrepared = try alice.prepareToEncrypt(proposing: nil) - XCTAssertTrue( - aliceThirdPrepared.didCommit, - "the repeated A.5's bind discharge needs a commit" - ) - let aliceThirdCommitFrame = try alice.encrypt( - appMessage: Data("a5-again-bind-commit".utf8)) - let bobGotThirdCommit = try nativeBob.processIncoming( - aliceThirdCommitFrame.cipherText) - guard case .decrypted(let bobThirdCommitDecrypted) = bobGotThirdCommit else { - XCTFail("expected a decrypted application frame, got \(bobGotThirdCommit)") - return + // Bind discharge: alice offers an Upd, native bob (the binder) folds and commits it. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceBindUpd = try alice.encrypt(appMessage: Data("reciprocal-bind-upd".utf8)) + guard + case .decrypted(let bobBindOpened) = try nativeBob.processIncoming( + aliceBindUpd.cipherText) + else { + return XCTFail("expected native bob to decrypt alice's Upd") } - XCTAssertEqual( - bobThirdCommitDecrypted.applicationMessage, - Data("a5-again-bind-commit".utf8)) + _ = try nativeBob.queueProposal(digest: bobBindOpened.queuedProposal.digest) + XCTAssertTrue(try nativeBob.prepareToEncrypt().didCommit) + let bobBindCommit = try nativeBob.encrypt(Data("reciprocal-bind-commit".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: bobBindCommit.frame)) + // Alice's Commit' moved her send-PQ leaf to her rotated id, so with the turn back + // her trigger is quiet: her next send opens a plain A.4. + XCTAssertTrue(alice.myPqTurn()) _ = try alice.prepareToEncrypt(proposing: nil) - let finalAliceFrame = try alice.encrypt(appMessage: Data("post-heal-alice".utf8)) - let finalBobResult = try nativeBob.processIncoming(finalAliceFrame.cipherText) - guard case .decrypted(let finalBobDecrypted) = finalBobResult else { - XCTFail("expected a decrypted application frame, got \(finalBobResult)") - return + let aliceNext = try alice.encrypt(appMessage: Data("post-reciprocal".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceNext.cipherText) else { + return XCTFail("expected native bob to decrypt alice's message") } - XCTAssertEqual(finalBobDecrypted.applicationMessage, Data("post-heal-alice".utf8)) - - _ = try nativeBob.prepareToEncrypt() - let finalBobFrame = try nativeBob.encrypt(Data("post-heal-bob".utf8)) - let finalAliceResult = try XCTUnwrap( - alice.processIncoming(ciphertext: finalBobFrame.frame)) - XCTAssertEqual( - finalAliceResult.applicationMessage?.appMessageData, - Data("post-heal-bob".utf8)) - - // Alice's send-PQ leaf never gets bumped by any of this (same reasoning as above) — - // mint her and restore natively too. - let aliceExport = try alice.migrationExport() - XCTAssertEqual( - aliceExport.leafKeys.sendPq.current?.signatureKey, + let aliceLeg = try XCTUnwrap(alice.pqPendingOutbound(sealing: .stable)) + XCTAssertEqual(try nativeBob.openIncoming(aliceLeg)?.kind, .pqSideBand(.ratchetEK)) + XCTAssertNotEqual( + try alice.migrationExport().leafKeys.sendPq.current?.signatureKey, originalAlicePQSignatureKey, - "alice's send-PQ leaf still presents her ORIGINAL (pre-rotation) PQ key — " - + "nothing in this flow ever moves it") - let aliceArchive = try SessionMigrator.mint( - kind: .checkpoint, from: aliceExport, - classicalProvider: classicalProvider, pqProvider: pqProvider - ).archive - _ = try TwoMLSPQSession.TwoMLSSession.restore( - core: nil, checkpoint: aliceArchive, - classicalProvider: classicalProvider, pqProvider: pqProvider) + "the reciprocal round moved alice's send-PQ leaf off her original key") } // MARK: - CHARACTERIZATION: a rotation-before-bind mismatch never heals from the peer @@ -298,83 +226,7 @@ final class RotatedRekeyHealTests: XCTestCase { /// never matches what the peer's tree has on record, and unlike the heal case above, no /// later classical fold reconciles it — this party heals only by migrating itself. func testNativeBNeverHealsRotationBeforeA3Bind() throws { - let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rrh2-alice".utf8)) - let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( - clientId: Data("rrh2-bob".utf8)) - let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( - archive: bobPrincipal.generateInvitation(lastResort: true)) - - let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( - client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), - appBinding: nil) - let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) - let kpEnvelope = try aliceSession.pqBootstrapEnvelope() - let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) - - let heldKp: Data - switch try bobInvitation.openInitial(blob: kpEnvelope) { - case .bootstrapKp(let frame): heldKp = frame - case let other: - throw NSError( - domain: "rrh2", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: - "expected a bootstrap-KP envelope, got \(other)" - ]) - } - let welcome: Data - switch try bobInvitation.openInitial(blob: replyEnvelope) { - case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) - case let other: - throw NSError( - domain: "rrh2", code: 2, - userInfo: [ - NSLocalizedDescriptionKey: - "expected an establishment envelope, got \(other)" - ]) - } - let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) - let bobSession = try bobInvitation.receive( - welcome: welcome, theirClassicalKeyPackage: aliceKP, - bootstrapKpCommitment: commitment, spawnToken: Data("rrh2-spawn".utf8), - newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) - - // Bob's send-PQ tree is built here, from alice's KP frozen at `initiate()` — the - // leaf presentation that never gets revised. - try bobSession.pqBootstrapRespond(kpMsg: heldKp) - let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) - let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) - - // Alice establishes her classical groups off the return welcome, so she can propose - // a rotation before ever closing A.3 with `pqBootstrapBind`. - _ = try aliceSession.processIncoming(ciphertext: returnWelcome) - - let newAliceId = TwoMLSPQBinding.ClientId( - bytes: Data("rrh2-alice-rotated".utf8)) - _ = try aliceSession.prepareToEncrypt(proposing: newAliceId) - let rotateFrame = try aliceSession.encrypt(appMessage: Data("rotate".utf8)) - let bobRotateOpened = try XCTUnwrap( - bobSession.processIncoming(ciphertext: rotateFrame.cipherText)) - let rotateOffered = try XCTUnwrap(bobRotateOpened.proposal) - try bobSession.queueProposal(digest: rotateOffered.digest) - let bobFold = try bobSession.prepareToEncrypt(proposing: nil) - XCTAssertTrue(bobFold.didCommit) - let canonicalizeFrame = try bobSession.encrypt( - appMessage: Data("canonicalize".utf8)) - let aliceCanonOpened = try XCTUnwrap( - aliceSession.processIncoming(ciphertext: canonicalizeFrame.cipherText)) - let remoteCommit = try XCTUnwrap(aliceCanonOpened.remoteCommit) - XCTAssertEqual(remoteCommit.newRecipient, newAliceId) - - // Alice now closes A.3 — binding with her current (rotated) principal, while bob's - // send-PQ tree still carries her original KP's presentation for that leaf. - try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) - - // A.3's own bind discharge: alice (binder) offers the bind riding her commit; bob - // applies the staple and gains the turn. Unlike the heal test, neither side's send - // auto-stages anything here — alice loses her turn inside this same discharge, - // before any auto-stage check runs on her own send. - try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + let (aliceSession, bobSession, _) = try rotatedBeforeBindPair() // Bob (now turn holder, nothing of his lags) opens a plain A.4; draining it hands // the turn to alice, whose leaf already lags the pre-bind rotation. @@ -441,6 +293,80 @@ final class RotatedRekeyHealTests: XCTestCase { XCTAssertEqual(parkedUpd, stillParked) } + // MARK: - C2: native defers its reciprocal until the rotated party's own A.5 lands + + /// The rotate-before-bind shape above, with bob migrated while he holds the turn. + /// Alice's send-PQ leaf lags, but C2 defers bob's reciprocal A.5 until alice's own A.5 + /// has landed, so his turn opens a plain A.4. Alice's own A.5 is mis-signed and heals + /// only once she migrates. After that, bob's next turn opens the reciprocal, and + /// alice's responder Commit' moves her send-PQ leaf to her rotated id. + func testNativeBDefersItsReciprocalUntilRotatedAlicesOwnA5Lands() throws { + let (aliceSession, bobSession, newAliceId) = try rotatedBeforeBindPair() + XCTAssertTrue(bobSession.myPqTurn()) + var nativeBob = try restoredNatively(bobSession) + + _ = try nativeBob.prepareToEncrypt() + let bobOpener = try nativeBob.encrypt(Data("post-a3-opener".utf8)) + _ = try aliceSession.processIncoming(ciphertext: bobOpener.frame) + XCTAssertEqual( + try aliceSession.openIncoming( + blob: try XCTUnwrap(nativeBob.pqPendingOutbound()))? + .kind, + .pqSideBand(kind: .ratchetEphemeralKey), "C2 defers the reciprocal") + try drainNativeA4(from: &nativeBob, to: aliceSession) + + // Alice's own A.5 is mis-signed, so native bob refuses it. + _ = try aliceSession.prepareToEncrypt(proposing: nil) + let aliceOpener = try aliceSession.encrypt(appMessage: Data("open-a5".utf8)) + _ = try nativeBob.processIncoming(aliceOpener.cipherText) + let parkedUpd = try XCTUnwrap(aliceSession.pqPendingOutbound(sealing: .stable)) + XCTAssertThrowsError(try nativeBob.pqRekeyRespond(parkedUpd)) + + // Alice migrates. Import drops the parked Upd', and her next send re-drives the + // catch-up under her carried key. + var nativeAlice = try restoredNatively(aliceSession) + XCTAssertNil(nativeAlice.pqPendingOutbound()) + _ = try nativeAlice.prepareToEncrypt() + let healOpener = try nativeAlice.encrypt(Data("heal-open".utf8)) + _ = try nativeBob.processIncoming(healOpener.frame) + let heal = try nativeBob.pqRekeyRespond( + try XCTUnwrap(nativeAlice.pqPendingOutbound())) + XCTAssertEqual(heal.rotatedCredential, newAliceId.bytes) + _ = try nativeAlice.pqRekeyApply(heal.frame) + + // Bind discharge: bob offers an Upd, alice (the binder) folds and commits it. + _ = try nativeBob.prepareToEncrypt() + let bobUpd = try nativeBob.encrypt(Data("heal-bind-upd".utf8)) + guard + case .decrypted(let aliceOpened) = try nativeAlice.processIncoming( + bobUpd.frame) + else { + return XCTFail("expected native alice to decrypt bob's Upd") + } + _ = try nativeAlice.queueProposal(digest: aliceOpened.queuedProposal.digest) + XCTAssertTrue(try nativeAlice.prepareToEncrypt().didCommit) + let aliceBindCommit = try nativeAlice.encrypt(Data("heal-bind-commit".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceBindCommit.frame) else { + return XCTFail("expected native bob to decrypt alice's discharge") + } + + // Alice's own A.5 has landed, so bob's turn now opens the reciprocal. + XCTAssertTrue(nativeBob.myPQTurn) + XCTAssertNotEqual(try sendPQLeafID(of: nativeAlice), newAliceId.bytes) + _ = try nativeBob.prepareToEncrypt() + let reciprocalOpener = try nativeBob.encrypt(Data("reciprocal-open".utf8)) + guard case .decrypted = try nativeAlice.processIncoming(reciprocalOpener.frame) + else { + return XCTFail("expected native alice to decrypt bob's message") + } + let reciprocalUpd = try XCTUnwrap(nativeBob.pqPendingOutbound()) + XCTAssertEqual( + try nativeAlice.openIncoming(reciprocalUpd)?.kind, .pqSideBand(.rekeyUpd)) + let reciprocal = try nativeAlice.pqRekeyRespond(reciprocalUpd) + _ = try nativeBob.pqRekeyApply(reciprocal.frame) + XCTAssertEqual(try sendPQLeafID(of: nativeAlice), newAliceId.bytes) + } + // MARK: - Native responder carries its own rotation onto its send-PQ leaf /// Bob migrates, then rotates natively, and Rust alice's fold of that rotation opens her @@ -575,6 +501,93 @@ final class RotatedRekeyHealTests: XCTestCase { XCTAssertEqual(bobGot.applicationMessage, Data("post-a5-alice".utf8)) } + /// A Rust pair where alice rotates before her A.3 bind, returned at A.3's own bind + /// discharge: bob holds the PQ turn, and bob's send-PQ tree still records alice's + /// pre-rotation leaf. + private func rotatedBeforeBindPair() throws -> ( + alice: TwoMLSPQBinding.TwoMlsPqSession, bob: TwoMLSPQBinding.TwoMlsPqSession, + newAliceId: TwoMLSPQBinding.ClientId + ) { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("rrh2-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("rrh2-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let kpEnvelope = try aliceSession.pqBootstrapEnvelope() + let replyEnvelope = try XCTUnwrap(aliceSession.pendingOutbound()) + + let heldKp: Data + switch try bobInvitation.openInitial(blob: kpEnvelope) { + case .bootstrapKp(let frame): heldKp = frame + case let other: + throw NSError( + domain: "rrh2", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected a bootstrap-KP envelope, got \(other)" + ]) + } + let welcome: Data + switch try bobInvitation.openInitial(blob: replyEnvelope) { + case .establishment(let frame): welcome = try XCTUnwrap(frame.welcome) + case let other: + throw NSError( + domain: "rrh2", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "expected an establishment envelope, got \(other)" + ]) + } + let aliceKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + let bobSession = try bobInvitation.receive( + welcome: welcome, theirClassicalKeyPackage: aliceKP, + bootstrapKpCommitment: commitment, spawnToken: Data("rrh2-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + + // Bob's send-PQ tree is built here, from alice's KP frozen at `initiate()` — the + // leaf presentation that never gets revised. + try bobSession.pqBootstrapRespond(kpMsg: heldKp) + let welcomePrime = try XCTUnwrap(bobSession.pqTakePendingOutbound()) + let returnWelcome = try XCTUnwrap(bobSession.pendingOutbound()) + + // Alice establishes her classical groups off the return welcome, so she can propose + // a rotation before ever closing A.3 with `pqBootstrapBind`. + _ = try aliceSession.processIncoming(ciphertext: returnWelcome) + + let newAliceId = TwoMLSPQBinding.ClientId( + bytes: Data("rrh2-alice-rotated".utf8)) + _ = try aliceSession.prepareToEncrypt(proposing: newAliceId) + let rotateFrame = try aliceSession.encrypt(appMessage: Data("rotate".utf8)) + let bobRotateOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: rotateFrame.cipherText)) + let rotateOffered = try XCTUnwrap(bobRotateOpened.proposal) + try bobSession.queueProposal(digest: rotateOffered.digest) + let bobFold = try bobSession.prepareToEncrypt(proposing: nil) + XCTAssertTrue(bobFold.didCommit) + let canonicalizeFrame = try bobSession.encrypt( + appMessage: Data("canonicalize".utf8)) + let aliceCanonOpened = try XCTUnwrap( + aliceSession.processIncoming(ciphertext: canonicalizeFrame.cipherText)) + let remoteCommit = try XCTUnwrap(aliceCanonOpened.remoteCommit) + XCTAssertEqual(remoteCommit.newRecipient, newAliceId) + + // Alice now closes A.3 — binding with her current (rotated) principal, while bob's + // send-PQ tree still carries her original KP's presentation for that leaf. + try aliceSession.pqBootstrapBind(welcomeMsg: welcomePrime) + + // A.3's own bind discharge: alice (binder) offers the bind riding her commit; bob + // applies the staple and gains the turn. Unlike the heal test, neither side's send + // auto-stages anything here — alice loses her turn inside this same discharge, + // before any auto-stage check runs on her own send. + try RustSessionTestHelpers.committingRound(binder: aliceSession, peer: bobSession) + return (aliceSession, bobSession, newAliceId) + } + /// Native bob's parked A.4: alice answers, bob binds, and alice's Upd folded by bob /// discharges the bind, passing the turn to alice. private func drainNativeA4( @@ -632,6 +645,18 @@ final class RotatedRekeyHealTests: XCTestCase { TwoMLSPQSession.TwoMLSSession.ownLeaf(of: group).credential) } + private func restoredNatively( + _ session: TwoMLSPQBinding.TwoMlsPqSession + ) throws -> TwoMLSPQSession.TwoMLSSession { + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: try session.migrationExport(), + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + return try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + } + private func sendPQLeafID(of session: TwoMLSPQSession.TwoMLSSession) throws -> Data { let group = try XCTUnwrap(session.sendGroup?.pq) return try TwoMLSPQSession.basicIdentifier( From 18398fe7fd8ae220915e469a633bb6519c4f0bed Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 15:21:42 -0700 Subject: [PATCH 12/16] Hold a migrated acceptor's catch-up until Rust folds its offer Against a peer that hasn't folded the born-dedicated acceptor's catch-up offer, the acceptor keeps ratcheting A.4. Once the offer is folded, its next turn opens the I to D catch-up, which Rust accepts. Needs native step 7's own-arm gate. Co-Authored-By: Claude Opus 5.5 --- .../BornDedicatedMigrationTests.swift | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift index 7930fe6..ab542cd 100644 --- a/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift +++ b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift @@ -157,6 +157,69 @@ final class BornDedicatedMigrationTests: XCTestCase { try bobSays(&nativeBob, "post-a5-bob", to: pair.alice) } + // MARK: - No catch-up A.5 until the peer folds the catch-up offer + + /// A deployed host that never folds a born-dedicated acceptor's catch-up offers (book + /// anomaly 3) leaves alice knowing bob only by the invitation id, so she would never + /// accept a catch-up A.5 moving I -> D. Native bob keeps ratcheting A.4 until alice folds + /// his catch-up offer. His next turn then opens the catch-up, and alice accepts it, + /// announcing D. + func testMigratedAcceptorRatchetsA4UntilRustFoldsItsCatchUp() throws { + let (pair, _) = try RustSessionTestHelpers.bornDedicatedInstalledUnfolded() + let alice = pair.alice + + // Bob folds alice's Upd, but alice never folds bob's catch-up offer. She discharges + // the A.3 bind with a commit that folds nothing of his. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpd = try alice.encrypt(appMessage: Data("confirm-a".utf8)) + let aliceOffer = try XCTUnwrap( + pair.bob.processIncoming(ciphertext: aliceUpd.cipherText)?.proposal) + try pair.bob.queueProposal(digest: aliceOffer.digest) + XCTAssertTrue(try pair.bob.prepareToEncrypt(proposing: nil).didCommit) + let bobCommit = try pair.bob.encrypt(appMessage: Data("confirm-b".utf8)) + _ = try alice.processIncoming(ciphertext: bobCommit.cipherText) + let kp = try alice.pqBootstrapBegin(rotating: nil) + try pair.bob.pqBootstrapRespond(kpMsg: kp) + try alice.pqBootstrapBind( + welcomeMsg: try XCTUnwrap(pair.bob.pqTakePendingOutbound())) + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let discharge = try alice.encrypt(appMessage: Data("a3-discharge".utf8)) + _ = try pair.bob.processIncoming(ciphertext: discharge.cipherText) + XCTAssertTrue(pair.bob.myPqTurn()) + + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: try pair.bob.migrationExport(), + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // Two full PQ cycles while alice never folds: every turn of bob's opens an A.4. + for cycle in 0..<2 { + let leg = try bobOpensTurn(&nativeBob, to: alice) + XCTAssertEqual( + try alice.openIncoming(blob: leg)?.kind, + .pqSideBand(kind: .ratchetEphemeralKey), + "cycle \(cycle): no catch-up A.5 before alice folds") + try completeBobsA4(leg, &nativeBob, with: alice) + try completeAlicesA4(alice, with: &nativeBob, foldingBobsOffer: false) + } + + // Alice folds bob's catch-up offer at her next discharge, and bob's next turn opens + // the catch-up. + let leg = try bobOpensTurn(&nativeBob, to: alice) + try completeBobsA4(leg, &nativeBob, with: alice) + try completeAlicesA4(alice, with: &nativeBob, foldingBobsOffer: true) + let catchUp = try bobOpensTurn(&nativeBob, to: alice) + XCTAssertEqual( + try alice.openIncoming(blob: catchUp)?.kind, .pqSideBand(kind: .rekeyUpdate) + ) + let announced = try alice.pqRekeyRespond(updMsg: catchUp) + XCTAssertEqual(announced?.bytes, pair.dedicatedId) + _ = try nativeBob.pqRekeyApply(try XCTUnwrap(alice.pqTakePendingOutbound())) + } + // MARK: Mutations /// Nulling `pqLeafCustody` doesn't break the mint: `leafKeys` is authoritative for key @@ -283,6 +346,70 @@ final class BornDedicatedMigrationTests: XCTestCase { try bobSays(&nativeBob, "post-convergence-bob", to: pair.alice) } + // MARK: - PQ round helpers + + /// Native bob sends on his PQ turn and returns the side-band leg that send parked. + private func bobOpensTurn( + _ nativeBob: inout TwoMLSPQSession.TwoMLSSession, + to alice: TwoMLSPQBinding.TwoMlsPqSession + ) throws -> Data { + XCTAssertTrue(nativeBob.myPQTurn) + _ = try nativeBob.prepareToEncrypt() + let frame = try nativeBob.encrypt(Data("bob-turn".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: frame.frame)) + return try XCTUnwrap(nativeBob.pqPendingOutbound()) + } + + /// Bob's A.4: alice answers, bob binds, and alice's Upd folded by bob discharges the + /// bind, passing the turn to alice. + private func completeBobsA4( + _ ek: Data, _ nativeBob: inout TwoMLSPQSession.TwoMLSSession, + with alice: TwoMLSPQBinding.TwoMlsPqSession + ) throws { + try alice.pqRatchetRespond(ekMsg: ek) + _ = try nativeBob.pqRatchetBind(try XCTUnwrap(alice.pqTakePendingOutbound())) + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceUpd = try alice.encrypt(appMessage: Data("a4-bind-upd".utf8)) + guard + case .decrypted(let opened) = try nativeBob.processIncoming( + aliceUpd.cipherText) + else { + return XCTFail("expected native bob to decrypt alice's Upd") + } + _ = try nativeBob.queueProposal(digest: opened.queuedProposal.digest) + XCTAssertTrue(try nativeBob.prepareToEncrypt().didCommit) + let bobCommit = try nativeBob.encrypt(Data("a4-bind-commit".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: bobCommit.frame)) + XCTAssertTrue(alice.myPqTurn()) + } + + /// Alice's A.4 on her next send: native bob answers, alice binds, and her discharge + /// commit passes the turn back to bob. It folds bob's offer only when asked to. + private func completeAlicesA4( + _ alice: TwoMLSPQBinding.TwoMlsPqSession, + with nativeBob: inout TwoMLSPQSession.TwoMLSSession, foldingBobsOffer: Bool + ) throws { + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceMsg = try alice.encrypt(appMessage: Data("a4-open".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceMsg.cipherText) else { + return XCTFail("expected native bob to decrypt alice's message") + } + let ek = try XCTUnwrap(alice.pqPendingOutbound(sealing: .fresh)) + try alice.pqRatchetBind(ctMsg: try nativeBob.pqRatchetRespond(ek).frame) + if foldingBobsOffer { + _ = try nativeBob.prepareToEncrypt() + let bobOffer = try nativeBob.encrypt(Data("catch-up-offer".utf8)) + let offered = try XCTUnwrap( + alice.processIncoming(ciphertext: bobOffer.frame)?.proposal) + try alice.queueProposal(digest: offered.digest) + } + XCTAssertTrue(try alice.prepareToEncrypt(proposing: nil).didCommit) + let aliceCommit = try alice.encrypt(appMessage: Data("a4-bind-commit".utf8)) + guard case .decrypted = try nativeBob.processIncoming(aliceCommit.cipherText) else { + return XCTFail("expected native bob to decrypt alice's discharge") + } + } + // MARK: - Native <-> Rust one-frame helpers (mirrors `RustSessionTestHelpers`) private func aliceSays( From 436c07ef17272969455810c4360f38b85ddb56e0 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 17:25:26 -0700 Subject: [PATCH 13/16] =?UTF-8?q?Pin=20fresh=20=C2=A7A.1=20establishment?= =?UTF-8?q?=20across=20engines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both engines accept each other's MLSMessage-framed welcome halves and return key package: plain, with the parallel A.3 bootstrap, from a migrated pre-join initiator, and through a born-dedicated handoff. Needs the native §A.1 framing fix. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 2 + ...EstablishmentFramingCrossEngineTests.swift | 331 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift diff --git a/Package.swift b/Package.swift index eca5988..88c77c0 100644 --- a/Package.swift +++ b/Package.swift @@ -175,6 +175,8 @@ let package = Package( .product(name: "TwoMLSPQSession", package: "twomlspq-swift"), .product(name: "TwoMLSPQCrypto", package: "twomlspq-swift"), .product(name: "MLSCrypto", package: "swift-mls"), + .product(name: "MLSCodec", package: "swift-mls"), + .product(name: "MLSProfileRFC9420", package: "swift-mls"), ], resources: [.copy("Fixtures")] ), diff --git a/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift new file mode 100644 index 0000000..683e712 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift @@ -0,0 +1,331 @@ +import CryptoKit +import Foundation +import MLSCodec +import MLSCrypto +import MLSProfileRFC9420 +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// Fresh §A.1 establishment across engines, both ways. The reply's welcome halves and its +// return key package are MLSMessages, the form the deployed engine emits and requires, so +// each engine must accept what the other sends: plain, with the parallel A.3 bootstrap, from +// a migrated pre-join initiator, and through a born-dedicated acceptor's handoff. +// +// Suite note: `two_mls_pq` type names collide with this package's wrapper names, so FFI +// record types are module-qualified throughout. + +@available(macOS 26, iOS 26, *) +final class EstablishmentFramingCrossEngineTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + + // MARK: - Plain establishment + + func testNativeInitiatorEstablishesWithRustAcceptor() throws { + let (bobInvitation, their) = try rustInvitation("efx-bob") + var alice = try nativeInitiator("efx-alice", to: their) + let commitment = try alice.bootstrapKPCommitment() + let frame = try establishmentFrame( + bobInvitation.openInitial(blob: try alice.pendingOutbound())) + + // The return key package goes to Rust exactly as native framed it. + let bob = try bobInvitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try XCTUnwrap(frame.returnKeyPackage), + bootstrapKpCommitment: commitment, spawnToken: Data("efx-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + _ = try alice.processIncoming(try XCTUnwrap(alice.openIncoming(bobFirst)).frame) + XCTAssertTrue(alice.isEstablished) + } + + func testRustInitiatorEstablishesWithNativeAcceptor() throws { + let (bobInvitation, rustKP) = try nativeInvitation("efr-bob") + var invitation = bobInvitation + let alice = try rustInitiator("efr-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + let frame = try nativeEstablishmentFrame( + invitation.openInitial(try XCTUnwrap(alice.pendingOutbound()))) + + var bob = try invitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(frame.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("efr-spawn".utf8) + ).session + _ = try bob.prepareToEncrypt() + let bobFirst = try bob.encrypt(Data("b1".utf8)).frame + let got = try XCTUnwrap(alice.processIncoming(ciphertext: bobFirst)) + XCTAssertEqual(got.applicationMessage?.appMessageData, Data("b1".utf8)) + } + + // MARK: - A migrated pre-join initiator + + /// A Rust initiator exported before the acceptor's welcome arrives joins it natively. + /// The export carries the host-shaped app payload a real pre-join initiator holds. + func testMigratedPreJoinInitiatorJoinsRustAcceptorsWelcome() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("efm-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("efm-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let returnKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + try aliceSession.setInitialAppPayload(payload: Data("efm-host-signed".utf8)) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let welcomeA = try XCTUnwrap(aliceSession.initialWelcome()) + + let export = try aliceSession.migrationExport() + XCTAssertNil(export.recvGroup) + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeAlice = try TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + let bob = try bobInvitation.receive( + welcome: welcomeA, theirClassicalKeyPackage: returnKP, + bootstrapKpCommitment: commitment, spawnToken: Data("efm-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + _ = try nativeAlice.processIncoming( + try XCTUnwrap(nativeAlice.openIncoming(bobFirst)).frame) + XCTAssertTrue(nativeAlice.isEstablished) + } + + // MARK: - The parallel A.3 bootstrap + + func testParallelA3CompletesFromNativeInitiatorToRustAcceptor() throws { + let (bobInvitation, their) = try rustInvitation("efpn-bob") + var alice = try nativeInitiator("efpn-alice", to: their) + let commitment = try alice.bootstrapKPCommitment() + let kpEnvelope = try XCTUnwrap(alice.pqBootstrapEnvelope()) + + guard + case .bootstrapKp(let heldKP) = try bobInvitation.openInitial( + blob: kpEnvelope) + else { + return XCTFail("expected the parallel bootstrap KP") + } + let frame = try establishmentFrame( + bobInvitation.openInitial(blob: try alice.pendingOutbound())) + let bob = try bobInvitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try XCTUnwrap(frame.returnKeyPackage), + bootstrapKpCommitment: commitment, spawnToken: Data("efpn-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + XCTAssertNotNil(bobInvitation.bootstrapKpGroupId(kpFrame: heldKP)) + try bob.pqBootstrapRespond(kpMsg: heldKP) + let welcomePrime = try XCTUnwrap(bob.pqPendingOutbound(sealing: .fresh)) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + _ = try alice.processIncoming(try XCTUnwrap(alice.openIncoming(bobFirst)).frame) + XCTAssertTrue(alice.isEstablished) + let opened = try XCTUnwrap(alice.openIncoming(welcomePrime)) + XCTAssertEqual(opened.kind, .pqSideBand(.bootstrapWelcome)) + _ = try alice.pqBootstrapJoin(opened.frame) + XCTAssertTrue(alice.isFullyEstablished) + + // Alice's next frame carries the bind, which completes A.3 on the Rust side. + _ = try alice.prepareToEncrypt() + _ = try bob.processIncoming(ciphertext: try alice.encrypt(Data("a1".utf8)).frame) + XCTAssertTrue(bob.isFullyEstablished()) + } + + func testParallelA3CompletesFromRustInitiatorToNativeAcceptor() throws { + let (bobInvitation, rustKP) = try nativeInvitation("efpr-bob") + var invitation = bobInvitation + let alice = try rustInitiator("efpr-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + let kpEnvelope = try alice.pqBootstrapEnvelope() + + guard case .bootstrapKP(let heldKP) = try invitation.openInitial(kpEnvelope) else { + return XCTFail("expected the parallel bootstrap KP") + } + let frame = try nativeEstablishmentFrame( + invitation.openInitial(try XCTUnwrap(alice.pendingOutbound()))) + var bob = try invitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(frame.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("efpr-spawn".utf8) + ).session + XCTAssertNotNil(invitation.bootstrapKPGroupID(kpFrame: heldKP)) + _ = try bob.pqBootstrapRespond(heldKP) + + _ = try bob.prepareToEncrypt() + let bobFirst = try bob.encrypt(Data("b1".utf8)).frame + let welcomePrime = try XCTUnwrap(bob.pqPendingOutbound()) + _ = try alice.processIncoming(ciphertext: bobFirst) + try alice.pqBootstrapBind(welcomeMsg: welcomePrime) + XCTAssertTrue(alice.isFullyEstablished()) + + // Alice's next frame carries the bind, which completes A.3 on the native side. + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceFirst = try alice.encrypt(appMessage: Data("a1".utf8)).cipherText + _ = try bob.processIncoming(try XCTUnwrap(bob.openIncoming(aliceFirst)).frame) + XCTAssertTrue(bob.isFullyEstablished) + } + + // MARK: - A born-dedicated acceptor's handoff + + /// A Rust acceptor born under a dedicated id staples the `0x0B` handoff on its first + /// frame. The native initiator pauses on it, and once approved, joins the welcome it wraps. + func testRustBornDedicatedHandoffJoinsNativeInitiator() throws { + let (bobInvitation, their) = try rustInvitation("efbn-bob") + var alice = try nativeInitiator("efbn-alice", to: their) + let commitment = try alice.bootstrapKPCommitment() + let frame = try establishmentFrame( + bobInvitation.openInitial(blob: try alice.pendingOutbound())) + let dedicatedID = Data("efbn-bob-dedicated".utf8) + let bob = try bobInvitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try XCTUnwrap(frame.returnKeyPackage), + bootstrapKpCommitment: commitment, spawnToken: Data("efbn-spawn".utf8), + newClientId: dedicatedID, expectedRemote: nil, expectedAppBinding: nil) + let signedEnvelope = Data("efbn-signed-delegation".utf8) + try bob.installEstablishmentEnvelope(envelope: signedEnvelope) + + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + let opened = try XCTUnwrap(alice.openIncoming(bobFirst)) + guard + case .pendingEstablishment(let pending) = try alice.processIncoming( + opened.frame) + else { + return XCTFail("expected the handoff to pause for approval") + } + XCTAssertEqual(pending.envelope, signedEnvelope) + _ = try alice.processIncomingApproved( + opened.frame, + approvedEnvelopeDigest: Data(SHA256.hash(data: pending.envelope)), + approvedWelcomeDigest: Data(SHA256.hash(data: pending.welcome)), + expectedCreator: dedicatedID) + XCTAssertTrue(alice.isEstablished) + } + + /// A native acceptor born under a dedicated id staples the `0x0B` handoff. The Rust + /// initiator pauses on it, and once approved, joins and decrypts the same frame. + func testNativeBornDedicatedHandoffJoinsRustInitiator() throws { + let (bobInvitation, rustKP) = try nativeInvitation("efbr-bob") + var invitation = bobInvitation + let alice = try rustInitiator("efbr-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + let frame = try nativeEstablishmentFrame( + invitation.openInitial(try XCTUnwrap(alice.pendingOutbound()))) + let dedicatedID = Data("efbr-bob-dedicated".utf8) + var bob = try invitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(frame.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("efbr-spawn".utf8), + newClientID: dedicatedID + ).session + let signedEnvelope = Data("efbr-signed-delegation".utf8) + _ = try bob.installEstablishmentEnvelope(signedEnvelope) + + _ = try bob.prepareToEncrypt() + let bobFirst = try bob.encrypt(Data("b1".utf8)).frame + let paused = try XCTUnwrap(alice.processIncoming(ciphertext: bobFirst)) + let pending = try XCTUnwrap(paused.pendingEstablishment) + XCTAssertEqual(pending.envelope, signedEnvelope) + let resumed = try alice.processIncomingApproved( + ciphertext: bobFirst, + approvedEnvelopeDigest: Data(SHA256.hash(data: pending.envelope)), + approvedWelcomeDigest: Data(SHA256.hash(data: pending.welcome)), + expectedCreator: dedicatedID) + XCTAssertEqual(resumed?.applicationMessage?.appMessageData, Data("b1".utf8)) + } + + // MARK: - Scaffolding + + /// A Rust invitation, and its combiner key package as native reads it. + private func rustInvitation( + _ clientID: String + ) throws -> (TwoMLSPQBinding.TwoMlsPqInvitation, TwoMLSPQSession.CombinerKeyPackage) { + let principal = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data(clientID.utf8)) + let invitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: principal.generateInvitation(lastResort: false)) + let their = try XCTUnwrap( + TwoMLSPQSession.CombinerKeyPackage( + publishedBlob: TwoMLSPQBinding.encodeCombinerKeyPackage( + keyPackage: invitation.combinerKeyPackage()))) + return (invitation, their) + } + + /// A native invitation, and its published combiner key package as Rust reads it. + private func nativeInvitation( + _ clientID: String + ) throws -> (TwoMLSPQSession.Invitation, TwoMLSPQBinding.CombinerKeyPackage) { + let principal = try Principal.generate( + clientID: Data(clientID.utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider) + let (invitation, _) = try principal.generateInvitation(lastResort: false) + let published = try XCTUnwrap(invitation.combinerKeyPackage) + let rustKP = try TwoMLSPQBinding.decodeCombinerKeyPackage( + bytes: try published.publishedBlob()) + return (invitation, rustKP) + } + + private func nativeInitiator( + _ clientID: String, to their: TwoMLSPQSession.CombinerKeyPackage + ) throws -> TwoMLSSession { + let principal = try Principal.generate( + clientID: Data(clientID.utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider) + return try TwoMLSSession.initiate(principal: principal, their: their).session + } + + /// A Rust initiator whose host attaches its return key package, as a real host does. + private func rustInitiator( + _ clientID: String, to rustKP: TwoMLSPQBinding.CombinerKeyPackage + ) throws -> TwoMLSPQBinding.TwoMlsPqSession { + let principal = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data(clientID.utf8)) + let session = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: principal, theirKeyPackage: rustKP, appBinding: nil) + try session.setInitialReturnKeyPackage( + keyPackage: try principal.generateKeyPackage(suite: .init(value: 0x0003))) + return session + } + + private func establishmentFrame( + _ opened: TwoMLSPQBinding.OpenedInitial + ) throws -> TwoMLSPQBinding.InitialFrame { + guard case .establishment(let frame) = opened else { + throw frameError("expected an establishment envelope, got \(opened)") + } + return frame + } + + private func nativeEstablishmentFrame( + _ opened: TwoMLSPQSession.OpenedInitial + ) throws -> TwoMLSPQSession.InitialFrame { + guard case .establishment(let frame) = opened else { + throw frameError("expected an establishment envelope, got \(opened)") + } + return frame + } + + private func keyPackage(fromMessage bytes: Data) throws -> MLS.RFC9420.KeyPackage { + guard case .keyPackage(let keyPackage) = try MLS.RFC9420.Message(mlsEncoded: bytes) + else { + throw frameError("expected an MLSMessage holding a key package") + } + return keyPackage + } + + private func frameError(_ description: String) -> NSError { + NSError(domain: "efx", code: 1, userInfo: [NSLocalizedDescriptionKey: description]) + } +} From da4bac4024c0f6b75cbe9c1f1bd84102e6397ada Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Thu, 24 Sep 2026 23:40:35 -0700 Subject: [PATCH 14/16] Pin pre-establishment sends and profile advertising across engines Both engines read each other's sends made before establishment, and the deployed engine accepts a key package advertising the correct profile through a full lifecycle both ways, staying deployed-compatible. Co-Authored-By: Claude Opus 5.5 --- ...EstablishmentFramingCrossEngineTests.swift | 128 +++++ .../SessionProfileCrossEngineTests.swift | 497 ++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 Tests/TwoMLSPQMigrateTests/SessionProfileCrossEngineTests.swift diff --git a/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift index 683e712..463a5ea 100644 --- a/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift +++ b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift @@ -103,6 +103,134 @@ final class EstablishmentFramingCrossEngineTests: XCTestCase { XCTAssertTrue(nativeAlice.isEstablished) } + // MARK: - Sends before establishment + + func testNativePreJoinSendsReachRustAcceptor() throws { + let (bobInvitation, their) = try rustInvitation("pxa-bob") + var alice = try nativeInitiator("pxa-alice", to: their) + let commitment = try alice.bootstrapKPCommitment() + _ = try alice.prepareToEncrypt() + let e1 = try alice.encrypt(Data("a1".utf8)).frame + _ = try alice.prepareToEncrypt() + let e2 = try alice.encrypt(Data("a2".utf8)).frame + let f2 = try establishmentFrame(bobInvitation.openInitial(blob: e2)) + let f1 = try establishmentFrame(bobInvitation.openInitial(blob: e1)) + let bob = try bobInvitation.receive( + welcome: try XCTUnwrap(f2.welcome), + theirClassicalKeyPackage: try XCTUnwrap(f2.returnKeyPackage), + bootstrapKpCommitment: commitment, spawnToken: Data("pxa".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + let got2 = try XCTUnwrap( + bob.processIncoming(ciphertext: try XCTUnwrap(f2.stapledMessage))) + XCTAssertEqual(got2.applicationMessage?.appMessageData, Data("a2".utf8)) + let got1 = try XCTUnwrap( + bob.processIncoming(ciphertext: try XCTUnwrap(f1.stapledMessage))) + XCTAssertEqual(got1.applicationMessage?.appMessageData, Data("a1".utf8)) + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + _ = try alice.processIncoming(try XCTUnwrap(alice.openIncoming(bobFirst)).frame) + XCTAssertTrue(alice.isEstablished) + _ = try alice.prepareToEncrypt() + let post = try alice.encrypt(Data("a3".utf8)).frame + let got3 = try XCTUnwrap( + bob.processIncoming( + ciphertext: try XCTUnwrap(bob.openIncoming(blob: post)).frame)) + XCTAssertEqual(got3.applicationMessage?.appMessageData, Data("a3".utf8)) + } + + func testNativePayloadShapeSendReachesRustAcceptor() throws { + let (bobInvitation, their) = try rustInvitation("pxp-bob") + let principal = try Principal.generate( + clientID: Data("pxp-alice".utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider) + let est = try TwoMLSSession.initiate(principal: principal, their: their) + var alice = est.session + _ = try alice.setInitialAppPayload(Data("host-signed".utf8)) + _ = try alice.prepareToEncrypt() + let e = try alice.encrypt(Data("a1".utf8)).frame + let f = try establishmentFrame(bobInvitation.openInitial(blob: e)) + XCTAssertEqual(f.appPayload, Data("host-signed".utf8)) + XCTAssertNil(f.welcome) + let bob = try bobInvitation.receive( + welcome: est.welcome, + theirClassicalKeyPackage: try MLS.RFC9420.Message.keyPackage( + est.returnKeyPackage + ).mlsEncoded(), + bootstrapKpCommitment: try alice.bootstrapKPCommitment(), + spawnToken: Data("pxp".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + let got = try XCTUnwrap( + bob.processIncoming(ciphertext: try XCTUnwrap(f.stapledMessage))) + XCTAssertEqual(got.applicationMessage?.appMessageData, Data("a1".utf8)) + } + + func testRustPreJoinSendReachesNativeAcceptor() throws { + let (bobInvitation, rustKP) = try nativeInvitation("pxr-bob") + var invitation = bobInvitation + let alice = try rustInitiator("pxr-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + _ = try alice.prepareToEncrypt(proposing: nil) + let e1 = try alice.encrypt(appMessage: Data("r1".utf8)).cipherText + let f1 = try nativeEstablishmentFrame(invitation.openInitial(e1)) + XCTAssertEqual(f1.stapledMessage?.first, 0x09) + var bob = try invitation.receive( + welcome: try XCTUnwrap(f1.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(f1.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("pxr".utf8) + ).session + guard + case .preEstablishment(let m) = try bob.processIncoming( + try XCTUnwrap(f1.stapledMessage)) + else { return XCTFail("expected preEstablishment") } + XCTAssertEqual(m.applicationMessage, Data("r1".utf8)) + XCTAssertEqual( + m.authenticatedData, try classicalProvider.hash(try XCTUnwrap(f1.welcome))) + } + + func testMigratedPreJoinInitiatorSendsWithCarriedPayload() throws { + let alice = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data("pxm-alice".utf8)) + let bobPrincipal = try TwoMLSPQBinding.TwoMlsPqPrincipal( + clientId: Data("pxm-bob".utf8)) + let bobInvitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: bobPrincipal.generateInvitation(lastResort: true)) + let aliceSession = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: alice, theirKeyPackage: bobInvitation.combinerKeyPackage(), + appBinding: nil) + let returnKP = try alice.generateKeyPackage(suite: .init(value: 0x0003)) + try aliceSession.setInitialAppPayload(payload: Data("pxm-host-signed".utf8)) + let commitment = try XCTUnwrap(aliceSession.bootstrapKpCommitment()) + let welcomeA = try XCTUnwrap(aliceSession.initialWelcome()) + let export = try aliceSession.migrationExport() + let archive = try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeAlice = try TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + _ = try nativeAlice.prepareToEncrypt() + let e = try nativeAlice.encrypt(Data("m1".utf8)).frame + let f = try establishmentFrame(bobInvitation.openInitial(blob: e)) + XCTAssertEqual(f.appPayload, Data("pxm-host-signed".utf8)) + let bob = try bobInvitation.receive( + welcome: welcomeA, theirClassicalKeyPackage: returnKP, + bootstrapKpCommitment: commitment, spawnToken: Data("pxm-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + let got = try XCTUnwrap( + bob.processIncoming(ciphertext: try XCTUnwrap(f.stapledMessage))) + XCTAssertEqual(got.applicationMessage?.appMessageData, Data("m1".utf8)) + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + guard + case .decrypted(let d) = try nativeAlice.processIncoming( + try XCTUnwrap(nativeAlice.openIncoming(bobFirst)).frame) + else { return XCTFail("expected native alice to decrypt bob's first frame") } + _ = try TwoMLSSession.restore( + core: d.update.kind == .core ? d.update.archive : nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + } + // MARK: - The parallel A.3 bootstrap func testParallelA3CompletesFromNativeInitiatorToRustAcceptor() throws { diff --git a/Tests/TwoMLSPQMigrateTests/SessionProfileCrossEngineTests.swift b/Tests/TwoMLSPQMigrateTests/SessionProfileCrossEngineTests.swift new file mode 100644 index 0000000..08e65e7 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/SessionProfileCrossEngineTests.swift @@ -0,0 +1,497 @@ +import CryptoKit +import Foundation +import MLSCodec +import MLSCrypto +import MLSProfileRFC9420 +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import XCTest + +// Testable only to read native groups and leaves, which have no public accessor. +@testable import TwoMLSPQSession + +// A native client advertises the correct session profile on its classical key-package +// leaf. The deployed engine advertises none, so every session with it stays +// deployed-compatible: nothing is recorded, the A.5 announce reaches Rust, and the +// reciprocal A.5 waits for Rust's own. Each direction runs the return welcome, the +// parallel A.3, A.4 both ways, and A.5 rounds opened by both engines. + +@available(macOS 26, iOS 26, *) +final class SessionProfileCrossEngineTests: XCTestCase { + private let classicalProvider = SwiftCryptoProvider().cipherSuiteProvider( + for: .curve25519ChaCha)! + private let pqProvider = MLKEM768CipherSuiteProvider() + private let correctType = MLS.RFC9420.ExtensionType(rawValue: 0xF0A3) + + // MARK: - Swift initiator -> Rust acceptor + + func testNativeInitiatorRustAcceptorFullLifecycle() throws { + let (bobInvitation, their) = try rustInvitation("pc-n2r-bob") + var alice = try nativeInitiator("pc-n2r-alice", to: their) + let commitment = try alice.bootstrapKPCommitment() + let kpEnvelope = try XCTUnwrap(alice.pqBootstrapEnvelope()) + guard + case .bootstrapKp(let heldKP) = try bobInvitation.openInitial( + blob: kpEnvelope) + else { return XCTFail("expected the parallel bootstrap KP") } + let frame = try establishmentFrame( + bobInvitation.openInitial(blob: try alice.pendingOutbound())) + + // The carrier: the return KP's classical leaf, exactly as Rust received it. + let returnKP = try keyPackage(fromMessage: try XCTUnwrap(frame.returnKeyPackage)) + XCTAssertTrue(returnKP.leafNode.capabilities.extensions.contains(correctType)) + XCTAssertFalse(returnKP.leafNode.capabilities.extensions.isEmpty) + XCTAssertEqual(alice.profile, .deployedCompatible, "Rust's KP does not advertise") + + let bob = try bobInvitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try XCTUnwrap(frame.returnKeyPackage), + bootstrapKpCommitment: commitment, spawnToken: Data("pc-n2r-spawn".utf8), + newClientId: nil, expectedRemote: nil, expectedAppBinding: nil) + try bob.pqBootstrapRespond(kpMsg: heldKP) + let welcomePrime = try XCTUnwrap(bob.pqPendingOutbound(sealing: .fresh)) + _ = try bob.prepareToEncrypt(proposing: nil) + let bobFirst = try bob.encrypt(appMessage: Data("b1".utf8)).cipherText + _ = try alice.processIncoming(try XCTUnwrap(alice.openIncoming(bobFirst)).frame) + XCTAssertTrue(alice.isEstablished) + _ = try alice.pqBootstrapJoin(try XCTUnwrap(alice.openIncoming(welcomePrime)).frame) + _ = try alice.prepareToEncrypt() + let a1 = try alice.encrypt(Data("a1".utf8)).frame + _ = try XCTUnwrap(bob.processIncoming(ciphertext: a1)) + XCTAssertTrue(bob.isFullyEstablished()) + XCTAssertTrue(alice.isFullyEstablished) + + var duo = ProfileDuo(native: alice, rust: bob, test: self) + try runLifecycle( + &duo, nativeRotated: Data("pc-n2r-alice-2".utf8), + rustRotated: Data("pc-n2r-bob-2".utf8)) + } + + // MARK: - Rust initiator -> Swift acceptor + + func testRustInitiatorNativeAcceptorFullLifecycle() throws { + let (bobInvitation, rustKP) = try nativeInvitation("pc-r2n-bob") + var invitation = bobInvitation + // The carrier: the classical half of the native acceptor's published KP. + XCTAssertTrue( + try XCTUnwrap(invitation.combinerKeyPackage).classical.leafNode.capabilities + .extensions.contains(correctType)) + let alice = try rustInitiator("pc-r2n-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + let kpEnvelope = try alice.pqBootstrapEnvelope() + guard case .bootstrapKP(let heldKP) = try invitation.openInitial(kpEnvelope) else { + return XCTFail("expected the parallel bootstrap KP") + } + let frame = try nativeEstablishmentFrame( + invitation.openInitial(try XCTUnwrap(alice.pendingOutbound()))) + var bob = try invitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(frame.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("pc-r2n-spawn".utf8) + ).session + XCTAssertEqual(bob.profile, .deployedCompatible) + _ = try bob.pqBootstrapRespond(heldKP) + _ = try bob.prepareToEncrypt() + let bobFirst = try bob.encrypt(Data("b1".utf8)).frame + let welcomePrime = try XCTUnwrap(bob.pqPendingOutbound()) + _ = try alice.processIncoming(ciphertext: bobFirst) + try alice.pqBootstrapBind(welcomeMsg: welcomePrime) + XCTAssertTrue(alice.isFullyEstablished()) + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceFirst = try alice.encrypt(appMessage: Data("a1".utf8)).cipherText + _ = try bob.processIncoming(try XCTUnwrap(bob.openIncoming(aliceFirst)).frame) + XCTAssertTrue(bob.isFullyEstablished) + + var duo = ProfileDuo(native: bob, rust: alice, test: self) + try runLifecycle( + &duo, nativeRotated: Data("pc-r2n-bob-2".utf8), + rustRotated: Data("pc-r2n-alice-2".utf8)) + } + + // MARK: - The lifecycle pump + + private func runLifecycle(_ duo: inout ProfileDuo, nativeRotated: Data, rustRotated: Data) + throws + { + // A.4 both ways. + for i in 0..<6 { + if i % 2 == 0 { + try duo.nativeSend("warm-n\(i)") + } else { + try duo.rustSend("warm-r\(i)") + } + } + XCTAssertGreaterThan(duo.nativeA4, 0, "native opened an A.4: \(duo.trace)") + XCTAssertGreaterThan(duo.rustA4, 0, "rust opened an A.4: \(duo.trace)") + + // Native rotates; its own A.5 carries the new id onto its recv-PQ leaf (Upd'). + duo.nativeRotate = nativeRotated + var guardCount = 0 + while !duo.announcedToRust.contains(nativeRotated) { + guardCount += 1 + guard guardCount < 20 else { + return XCTFail("native A.5 never landed: \(duo.trace)") + } + if guardCount % 2 == 1 { + try duo.nativeSend("nrot\(guardCount)") + } else { + try duo.rustSend("nrot\(guardCount)") + } + } + + // Rust rotates; Rust opens its A.5 (native answers with Commit'), then native's + // reciprocal A.5 moves Rust's leaf in native's recv-PQ. + duo.rustRotate = rustRotated + let nativeA5Before = duo.nativeA5 + guardCount = 0 + while !(duo.announcedToNative.contains(rustRotated) + && duo.nativeA5 > nativeA5Before) + { + guardCount += 1 + guard guardCount < 30 else { + return XCTFail("rust A.5 / reciprocal never landed: \(duo.trace)") + } + if guardCount % 2 == 1 { + try duo.rustSend("rrot\(guardCount)") + } else { + try duo.nativeSend("rrot\(guardCount)") + } + } + for i in 0..<4 { + if i % 2 == 0 { + try duo.nativeSend("tail-n\(i)") + } else { + try duo.rustSend("tail-r\(i)") + } + } + + // Every native own leaf still advertises the type after all those moves. + let native = duo.native + let groups: [(String, MLS.RFC9420.Group?)] = [ + ("sendClassical", native.sendGroup?.classical), + ("sendPQ", native.sendGroup?.pq), + ("recvClassical", native.recvGroup?.classical), + ("recvPQ", native.recvGroup?.pq), + ] + for (name, group) in groups { + let g = try XCTUnwrap(group, name) + let expected = name.hasSuffix("Classical") + XCTAssertEqual( + try TwoMLSSession.ownLeaf(of: g).capabilities.extensions.contains( + correctType), + expected, name) + XCTAssertEqual( + try SessionProfile.recorded(in: g.context), .deployedCompatible, + name) + } + // C1 reached Rust: its responder returned the announced id. + XCTAssertTrue(duo.announcedToRust.contains(nativeRotated), "\(duo.trace)") + } + + // MARK: - The native turn-holder defers the reciprocal until Rust's own A.5 lands + + /// Fresh session (no migration): Rust rotates, native folds, and native's next + /// turn must open a plain A.4, not the reciprocal A.5, until Rust's own A.5 has + /// landed (C2, deployed-compatible: Rust never advertises). Then the reciprocal opens. + func testNativeTurnHolderDefersReciprocalUntilRustOwnA5Lands() throws { + let (bobInvitation, rustKP) = try nativeInvitation("x3-bob") + var invitation = bobInvitation + let alice = try rustInitiator("x3-alice", to: rustKP) + let commitment = try XCTUnwrap(alice.bootstrapKpCommitment()) + let kpEnvelope = try alice.pqBootstrapEnvelope() + guard case .bootstrapKP(let heldKP) = try invitation.openInitial(kpEnvelope) else { + return XCTFail("expected the parallel bootstrap KP") + } + let frame = try nativeEstablishmentFrame( + invitation.openInitial(try XCTUnwrap(alice.pendingOutbound()))) + var bob = try invitation.receive( + welcome: try XCTUnwrap(frame.welcome), + theirClassicalKeyPackage: try keyPackage( + fromMessage: try XCTUnwrap(frame.returnKeyPackage)), + bootstrapKPCommitment: commitment, spawnToken: Data("x3-spawn".utf8) + ).session + XCTAssertEqual(bob.profile, .deployedCompatible) + _ = try bob.pqBootstrapRespond(heldKP) + _ = try bob.prepareToEncrypt() + let bobFirst = try bob.encrypt(Data("b1".utf8)).frame + let welcomePrime = try XCTUnwrap(bob.pqPendingOutbound()) + _ = try alice.processIncoming(ciphertext: bobFirst) + try alice.pqBootstrapBind(welcomeMsg: welcomePrime) + _ = try alice.prepareToEncrypt(proposing: nil) + let aliceFirst = try alice.encrypt(appMessage: Data("a1".utf8)).cipherText + _ = try bob.processIncoming(try XCTUnwrap(bob.openIncoming(aliceFirst)).frame) + XCTAssertTrue(bob.isFullyEstablished) + + var duo = ProfileDuo(native: bob, rust: alice, test: self) + for i in 0..<4 { + if i % 2 == 0 { + try duo.nativeSend("warm-n\(i)") + } else { + try duo.rustSend("warm-r\(i)") + } + } + let rustRotated = Data("x3-alice-2".utf8) + duo.rustRotate = rustRotated + try duo.rustSend("rot-offer") + let before = duo.trace.count + // native folds the rotation; its send self-drives. Before Rust's own A.5 has + // landed, that must be an A.4, never the reciprocal A.5. + var guardCount = 0 + while !duo.announcedToNative.contains(rustRotated) { + guardCount += 1 + guard guardCount < 30 else { + return XCTFail("rust A.5 never landed: \(duo.trace)") + } + if guardCount % 2 == 1 { + try duo.nativeSend("fold\(guardCount)") + } else { + try duo.rustSend("fold\(guardCount)") + } + XCTAssertFalse( + duo.trace[before...].contains { $0.hasPrefix("n:A5") }, + "native opened the reciprocal before Rust's own A.5 landed: \(duo.trace)" + ) + } + XCTAssertTrue(duo.trace[before...].contains("n:A4"), "\(duo.trace)") + // Rust's own A.5 has landed (its leaf in native's send-PQ presents the new id): + // native's next turn opens the reciprocal. + let nativeA5Before = duo.nativeA5 + guardCount = 0 + while duo.nativeA5 == nativeA5Before { + guardCount += 1 + guard guardCount < 10 else { + return XCTFail("reciprocal never opened: \(duo.trace)") + } + if guardCount % 2 == 1 { + try duo.nativeSend("rec\(guardCount)") + } else { + try duo.rustSend("rec\(guardCount)") + } + } + let recvPQ = try XCTUnwrap(duo.native.recvGroup?.pq) + let rustLeaf = try XCTUnwrap( + recvPQ.tree.nonBlankLeaves().first { $0.index != recvPQ.myLeafIndex }) + XCTAssertEqual( + try basicIdentifier( + try MLS.RFC9420.LeafNode(mlsEncoded: rustLeaf.record.encoded) + .credential), + rustRotated, "\(duo.trace)") + } + + // MARK: - The opt-in reaches the published key package, and Rust preserves it + + func testOptInIsLiveInEmittedKeyPackagesAndRustPreservesIt() throws { + let (invitation, rustKP) = try nativeInvitation("nc-bob") + let published = try XCTUnwrap(invitation.combinerKeyPackage).publishedBlob() + let reparsed = try XCTUnwrap(CombinerKeyPackage(publishedBlob: published)) + XCTAssertTrue( + reparsed.classical.leafNode.capabilities.extensions.contains(correctType)) + XCTAssertFalse(reparsed.pq.leafNode.capabilities.extensions.contains(correctType)) + let roundTripped = try XCTUnwrap( + CombinerKeyPackage( + publishedBlob: TwoMLSPQBinding.encodeCombinerKeyPackage( + keyPackage: rustKP))) + XCTAssertTrue( + roundTripped.classical.leafNode.capabilities.extensions.contains( + correctType)) + XCTAssertEqual(try roundTripped.publishedBlob(), published) + // Default (no opt-in): absent. + let quiet = try Principal.generate( + clientID: Data("nc-quiet".utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider) + XCTAssertFalse( + try XCTUnwrap( + quiet.generateInvitation(lastResort: false).invitation + .combinerKeyPackage + ) + .classical.leafNode.capabilities.extensions.contains(correctType)) + // Rust's own KP never lists it. + let (_, their) = try rustInvitation("nc-rust") + XCTAssertFalse( + their.classical.leafNode.capabilities.extensions.contains(correctType)) + } + + // MARK: - Scaffolding (from EstablishmentFramingCrossEngineTests) + + private func rustInvitation( + _ clientID: String + ) throws -> (TwoMLSPQBinding.TwoMlsPqInvitation, TwoMLSPQSession.CombinerKeyPackage) { + let principal = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data(clientID.utf8)) + let invitation = try TwoMLSPQBinding.TwoMlsPqInvitation.restore( + archive: principal.generateInvitation(lastResort: false)) + let their = try XCTUnwrap( + TwoMLSPQSession.CombinerKeyPackage( + publishedBlob: TwoMLSPQBinding.encodeCombinerKeyPackage( + keyPackage: invitation.combinerKeyPackage()))) + return (invitation, their) + } + + private func nativeInvitation( + _ clientID: String + ) throws -> (TwoMLSPQSession.Invitation, TwoMLSPQBinding.CombinerKeyPackage) { + let principal = try Principal.generate( + clientID: Data(clientID.utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider, advertisesCorrectProfile: true) + let (invitation, _) = try principal.generateInvitation(lastResort: false) + let published = try XCTUnwrap(invitation.combinerKeyPackage) + let rustKP = try TwoMLSPQBinding.decodeCombinerKeyPackage( + bytes: try published.publishedBlob()) + return (invitation, rustKP) + } + + private func nativeInitiator( + _ clientID: String, to their: TwoMLSPQSession.CombinerKeyPackage + ) throws -> TwoMLSSession { + let principal = try Principal.generate( + clientID: Data(clientID.utf8), classicalProvider: classicalProvider, + pqProvider: pqProvider, advertisesCorrectProfile: true) + return try TwoMLSSession.initiate(principal: principal, their: their).session + } + + private func rustInitiator( + _ clientID: String, to rustKP: TwoMLSPQBinding.CombinerKeyPackage + ) throws -> TwoMLSPQBinding.TwoMlsPqSession { + let principal = try TwoMLSPQBinding.TwoMlsPqPrincipal(clientId: Data(clientID.utf8)) + let session = try TwoMLSPQBinding.TwoMlsPqSession.initiate( + client: principal, theirKeyPackage: rustKP, appBinding: nil) + try session.setInitialReturnKeyPackage( + keyPackage: try principal.generateKeyPackage(suite: .init(value: 0x0003))) + return session + } + + private func establishmentFrame( + _ opened: TwoMLSPQBinding.OpenedInitial + ) throws -> TwoMLSPQBinding.InitialFrame { + guard case .establishment(let frame) = opened else { + throw profileTestError("expected an establishment envelope, got \(opened)") + } + return frame + } + + private func nativeEstablishmentFrame( + _ opened: TwoMLSPQSession.OpenedInitial + ) throws -> TwoMLSPQSession.InitialFrame { + guard case .establishment(let frame) = opened else { + throw profileTestError("expected an establishment envelope, got \(opened)") + } + return frame + } + + private func keyPackage(fromMessage bytes: Data) throws -> MLS.RFC9420.KeyPackage { + guard case .keyPackage(let keyPackage) = try MLS.RFC9420.Message(mlsEncoded: bytes) + else { throw profileTestError("expected an MLSMessage holding a key package") } + return keyPackage + } +} + +func profileTestError(_ description: String) -> NSError { + NSError(domain: "profile-x", code: 1, userInfo: [NSLocalizedDescriptionKey: description]) +} + +/// A native session and a Rust session, driven by alternating sends. Every offer is +/// approved, and every PQ leg a send opens is answered and bound at once. +@available(macOS 26, iOS 26, *) +struct ProfileDuo { + var native: TwoMLSSession + let rust: TwoMLSPQBinding.TwoMlsPqSession + let test: XCTestCase + var trace: [String] = [] + var nativeRotate: Data? + var rustRotate: Data? + var nativeA4 = 0, nativeA5 = 0, rustA4 = 0, rustA5 = 0 + var announcedToRust: [Data] = [] + var announcedToNative: [Data] = [] + private var lastRustLeg: Data? + + init(native: TwoMLSSession, rust: TwoMLSPQBinding.TwoMlsPqSession, test: XCTestCase) { + self.native = native + self.rust = rust + self.test = test + } + + mutating func nativeSend(_ text: String) throws { + let prep = try native.prepareToEncrypt(rotating: nativeRotate) + if nativeRotate != nil { trace.append("n:rotate-offer") } + nativeRotate = nil + let frame = try native.encrypt(Data(text.utf8)).frame + let got = try XCTUnwrap(rust.processIncoming(ciphertext: frame)) + XCTAssertEqual(got.applicationMessage?.appMessageData, Data(text.utf8)) + if let offer = got.proposal { + try rust.queueProposal(digest: offer.digest) + } + trace.append("n:send\(prep.didCommit ? "+commit" : "")") + try driveNativeLeg() + } + + mutating func rustSend(_ text: String) throws { + let prep = try rust.prepareToEncrypt( + proposing: rustRotate.map { TwoMLSPQBinding.ClientId(bytes: $0) }) + if rustRotate != nil { trace.append("r:rotate-offer") } + rustRotate = nil + let frame = try rust.encrypt(appMessage: Data(text.utf8)).cipherText + guard case .decrypted(let got) = try native.processIncoming(frame) else { + throw profileTestError("native did not decrypt rust's frame") + } + XCTAssertEqual(got.applicationMessage, Data(text.utf8)) + _ = try native.queueProposal(digest: got.queuedProposal.digest) + trace.append("r:send\(prep.didCommit ? "+commit" : "")") + try driveRustLeg() + } + + private mutating func driveNativeLeg() throws { + switch native.pqInflight { + case .some(.initiating): + let leg = try XCTUnwrap(native.pqPendingOutbound()) + XCTAssertEqual( + try rust.openIncoming(blob: leg)?.kind, + .pqSideBand(kind: .ratchetEphemeralKey)) + try rust.pqRatchetRespond(ekMsg: leg) + _ = try native.pqRatchetBind(try XCTUnwrap(rust.pqTakePendingOutbound())) + nativeA4 += 1 + trace.append("n:A4") + case .some(.rekeyInitiated): + let leg = try XCTUnwrap(native.pqPendingOutbound()) + XCTAssertEqual( + try rust.openIncoming(blob: leg)?.kind, + .pqSideBand(kind: .rekeyUpdate)) + let announced = try rust.pqRekeyRespond(updMsg: leg) + _ = try native.pqRekeyApply(try XCTUnwrap(rust.pqTakePendingOutbound())) + if let announced { announcedToRust.append(announced.bytes) } + nativeA5 += 1 + trace.append( + "n:A5(\(announced.map { String(decoding: $0.bytes, as: UTF8.self) } ?? "-"))" + ) + default: + break + } + } + + private mutating func driveRustLeg() throws { + guard let leg = rust.pqPendingOutbound(sealing: .stable) else { return } + guard let opened = try native.openIncoming(leg) else { + throw profileTestError("native could not open rust's leg") + } + if opened.frame == lastRustLeg { return } + switch opened.kind { + case .pqSideBand(.ratchetEK): + try rust.pqRatchetBind(ctMsg: try native.pqRatchetRespond(leg).frame) + rustA4 += 1 + trace.append("r:A4") + case .pqSideBand(.rekeyUpd): + let result = try native.pqRekeyRespond(leg) + try rust.pqRekeyApply(msg: result.frame) + if let rotated = result.rotatedCredential { + announcedToNative.append(rotated) + } + rustA5 += 1 + trace.append( + "r:A5(\(result.rotatedCredential.map { String(decoding: $0, as: UTF8.self) } ?? "-"))" + ) + default: + throw profileTestError("unexpected rust leg \(opened.kind)") + } + lastRustLeg = opened.frame + } +} From 750a1f50c4e07325c2e9d9e5f2ce1f8191ab81dc Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Fri, 25 Sep 2026 10:32:20 -0700 Subject: [PATCH 15/16] Export send-classical keys with current only Its own next commit mints fresh for whatever id it then presents, so twomlspq-swift 0.3.0 drops supplied pending keys there and restore rejects them. Also documents that a generated KeyPackage is an MLSMessage. Co-Authored-By: Claude Opus 5.5 --- .changeset/born-dedicated-acceptor-export.md | 3 +- Sources/TwoMLSPQBinding/two_mls_pq.swift | 8 +- .../MintCoverageTests.swift | 7 +- rust/two-mls-pq/src/key_packages.rs | 2 +- rust/two-mls-pq/src/session/migration.rs | 66 ++--------- rust/two-mls-pq/src/session/tests.rs | 107 ++++-------------- 6 files changed, 47 insertions(+), 146 deletions(-) diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md index 6a8a32d..0b846b6 100644 --- a/.changeset/born-dedicated-acceptor-export.md +++ b/.changeset/born-dedicated-acceptor-export.md @@ -8,7 +8,8 @@ staged rotation candidates, lagging leaves, and parked or wedged PQ rounds. It f corrupt data (`ArchiveInvalid`). The export carries per-group signing keys (`leafKeys`), the rotation candidate, an own-offer window with its leaf secrets, and deployed-engine flags (`deployedState`). A pre-A.3 acceptor's `leafKeys.sendPq` is empty, since A.3 founding mints -its own key. `BINDING_CONTRACT_VERSION` bumps 35 → 36. +its own key, and `leafKeys.sendClassical` carries `current` only. Minting requires +twomlspq-swift 0.3.0 or later. `BINDING_CONTRACT_VERSION` bumps 35 → 36. `SessionMigrator.mintArchive(kind:from:classicalProvider:pqProvider:)` is removed. `SessionMigrator.mint(kind:from:classicalProvider:pqProvider:)` returns a `MintResult`: the diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index c6e97d7..d39fb66 100644 --- a/Sources/TwoMLSPQBinding/two_mls_pq.swift +++ b/Sources/TwoMLSPQBinding/two_mls_pq.swift @@ -1658,7 +1658,7 @@ public protocol TwoMlsPqPrincipalProtocol: AnyObject, Sendable { /** * Generate a fresh KeyPackage for the given cipher suite. - * Returns MLS-encoded bytes suitable for publication. + * Returns the KeyPackage as an RFC 9420 `MLSMessage` (`mls_key_package`), ready to publish. * The corresponding HPKE private key is retained internally for group joins. */ func generateKeyPackage(suite: MlsCipherSuite) throws -> Data @@ -1790,7 +1790,7 @@ open func generateInvitation(lastResort: Bool)throws -> Data { /** * Generate a fresh KeyPackage for the given cipher suite. - * Returns MLS-encoded bytes suitable for publication. + * Returns the KeyPackage as an RFC 9420 `MLSMessage` (`mls_key_package`), ready to publish. * The corresponding HPKE private key is retained internally for group joins. */ open func generateKeyPackage(suite: MlsCipherSuite)throws -> Data { @@ -6759,7 +6759,7 @@ public func FfiConverterTypeSessionMigrationPskEntry_lower(_ value: SessionMigra /** * The most recently staged rotation candidate, classical only. Its * `signing_key`/`signature_key` must equal the same candidate's `pending` - * entry in both classical sets. `None` when the newest candidate's id + * entry in `recv_classical`. `None` when the newest candidate's id * equals `auth.mine`'s current one — a same-id candidate is a self-catch-up * mechanism, not a rotation target (see `recv_classical_pending`). */ @@ -9186,7 +9186,7 @@ private let initializationResult: InitializationResult = { if (uniffi_two_mls_pq_checksum_method_twomlspqprincipal_generate_invitation() != 5215) { return InitializationResult.apiChecksumMismatch } - if (uniffi_two_mls_pq_checksum_method_twomlspqprincipal_generate_key_package() != 11085) { + if (uniffi_two_mls_pq_checksum_method_twomlspqprincipal_generate_key_package() != 63432) { return InitializationResult.apiChecksumMismatch } if (uniffi_two_mls_pq_checksum_method_twomlspqsession_app_binding() != 59144) { diff --git a/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift index f3883b9..c666070 100644 --- a/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift +++ b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift @@ -125,7 +125,7 @@ final class MintCoverageTests: XCTestCase { // MARK: - 3. Four-candidate export /// Four distinct classical candidates staged in a row, none ever folded — each rides - /// `send_classical`'s pending set (one entry per candidate other than `mine.current`). + /// `recv_classical`'s pending set, and `send_classical`'s stays empty. func testFourCandidateExportMintsAndRestores() throws { let (alice, _) = try establishCardPair() @@ -138,8 +138,9 @@ final class MintCoverageTests: XCTestCase { let export = try alice.migrationExport() XCTAssertGreaterThanOrEqual( - export.leafKeys.sendClassical.pending.count, 4, - "all four staged candidates should ride send-classical's pending set") + export.leafKeys.recvClassical.pending.count, 4, + "all four staged candidates should ride recv-classical's pending set") + XCTAssertTrue(export.leafKeys.sendClassical.pending.isEmpty) let minted = try SessionMigrator.mint( kind: .checkpoint, from: export, diff --git a/rust/two-mls-pq/src/key_packages.rs b/rust/two-mls-pq/src/key_packages.rs index 8871206..9a789fc 100644 --- a/rust/two-mls-pq/src/key_packages.rs +++ b/rust/two-mls-pq/src/key_packages.rs @@ -100,7 +100,7 @@ impl TwoMlsPqPrincipal { } /// Generate a fresh KeyPackage for the given cipher suite. - /// Returns MLS-encoded bytes suitable for publication. + /// Returns the KeyPackage as an RFC 9420 `MLSMessage` (`mls_key_package`), ready to publish. /// The corresponding HPKE private key is retained internally for group joins. pub fn generate_key_package(&self, suite: Arc) -> Result> { match suite.value() { diff --git a/rust/two-mls-pq/src/session/migration.rs b/rust/two-mls-pq/src/session/migration.rs index 02070f2..608de2b 100644 --- a/rust/two-mls-pq/src/session/migration.rs +++ b/rust/two-mls-pq/src/session/migration.rs @@ -45,10 +45,11 @@ //! * generalized catch-up: any own leaf whose presented credential lags //! `auth.mine`'s current one gets a synthesized `pending[mine.current]` //! entry carrying the identity's current key of that half's kind — see -//! `catch_up_pending_entry` and `fold_in_catch_up`; -//! * every staged rotation candidate rides `pending[candidate id]` in both -//! `send_classical` and `recv_classical`, keyed by the same classical key -//! — see `send_classical_pending`/`recv_classical_pending`. A candidate +//! `catch_up_pending_entry` and `fold_in_catch_up`. `send_classical` is the +//! exception: it carries `current` only, since its own next commit mints +//! fresh for whatever id it then presents; +//! * every staged rotation candidate rides `pending[candidate id]` in +//! `recv_classical` — see `recv_classical_pending`. A candidate //! whose id equals `auth.mine`'s current one can leave two //! differently-keyed offers outstanding for that one target (the //! identity's own self-catch-up, and the same-id candidate's @@ -320,7 +321,7 @@ pub struct SessionMigrationLeafKeys { /// The most recently staged rotation candidate, classical only. Its /// `signing_key`/`signature_key` must equal the same candidate's `pending` -/// entry in both classical sets. `None` when the newest candidate's id +/// entry in `recv_classical`. `None` when the newest candidate's id /// equals `auth.mine`'s current one — a same-id candidate is a self-catch-up /// mechanism, not a rotation target (see `recv_classical_pending`). #[derive(Clone, uniffi::Record)] @@ -928,9 +929,8 @@ fn catch_up_pending_entry( } /// Folds a generalized catch-up entry into a half's real pending — PQ -/// halves only (classical uses `send_classical_pending`/ -/// `recv_classical_pending`, which resolve the same-id ambiguity this -/// simpler fold cannot). A real entry already targeting the catch-up's +/// halves only (recv-classical uses `recv_classical_pending`, which resolves +/// the same-id ambiguity this simpler fold cannot). A real entry already targeting the catch-up's /// target is superseded by it. fn fold_in_catch_up( mut real_pending: Vec, @@ -944,40 +944,6 @@ fn fold_in_catch_up( real_pending } -/// `send_classical`'s `pending`: the identity's own catch-up key at -/// `mine_current` (if the leaf lags), plus every staged candidate other -/// than `mine_current`. send_classical has no by-reference fold or window, -/// so a same-id candidate never gets its own entry here — only the -/// identity's; see `recv_classical_pending` for why recv_classical differs. -fn send_classical_pending( - catch_up: Option, - candidates: &[Arc], - classical_provider: &impl CipherSuiteProvider, - mine_current: &[u8], -) -> Result> { - let mut pending: Vec = catch_up.into_iter().collect(); - for candidate in candidates { - let target = candidate.client_id().bytes; - if target == mine_current { - continue; - } - let (signing_key, signature_key) = candidate_public_key( - classical_provider, - candidate.combiner().classical_signing_key(), - ) - .ok_or(TwoMlsPqError::ArchiveInvalid)?; - pending.push(SessionMigrationPendingLeafKey { - target, - key: SessionMigrationKeyPair { - signing_key, - signature_key, - }, - }); - } - dedupe_pending(&mut pending); - Ok(pending) -} - /// `recv_classical`'s `pending`, grouped by target from `framed` (see the /// module doc), `real_pending` (this group's own outstanding offers), and /// each staged candidate's synthesized entry. Every target other than @@ -1586,8 +1552,6 @@ impl TwoMlsPqSession { // derives instead of O(1). dedupe_secret_bytes(&mut pool); - let send_classical_catch_up = - catch_up_pending_entry(&send_ref.classical, &mine_current, &identity_classical_pair)?; let recv_classical_catch_up = match recv_ref { Some(recv) => { catch_up_pending_entry(&recv.classical, &mine_current, &identity_classical_pair)? @@ -1595,9 +1559,9 @@ impl TwoMlsPqSession { None => None, }; - // send_classical always exists. Resolve custody, then build `pending` - // from the generalized catch-up plus every other staged candidate — - // see `send_classical_pending`. + // send_classical always exists, and carries `current` only: its own next + // commit mints a fresh key for whatever id it then presents, so native + // needs no pending key in advance (and restore rejects one). let (send_classical_leaf, send_classical_no_custody) = { let (leaf, no_custody) = resolve_leaf_key( &send_ref.classical, @@ -1605,16 +1569,10 @@ impl TwoMlsPqSession { &pool, &send_classical.1, )?; - let pending = send_classical_pending( - send_classical_catch_up.clone(), - &inner.staged_candidates, - &classical_provider, - &mine_current, - )?; ( SessionMigrationGroupKeys { current: leaf.current, - pending, + pending: Vec::new(), }, no_custody, ) diff --git a/rust/two-mls-pq/src/session/tests.rs b/rust/two-mls-pq/src/session/tests.rs index 945a154..5f3b119 100644 --- a/rust/two-mls-pq/src/session/tests.rs +++ b/rust/two-mls-pq/src/session/tests.rs @@ -8744,7 +8744,7 @@ fn test_migration_export_carries_superseded_staged_candidate() { /// After a rotation the send-classical leaf presents the old key until its own next commit: /// `current` resolves via the leaf's own signer (differs from `identity`, never `no_custody`), -/// and `pending` carries the catch-up at `mine.current` with the identity's own key. +/// and `pending` stays empty, since that commit mints its own fresh key. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_carries_post_rotation_leaf_lag() { @@ -8758,14 +8758,9 @@ fn test_migration_export_carries_post_rotation_leaf_lag() { current.signature_key, export.identity.signature_key, "send-classical genuinely still lags the (already-rotated) identity" ); - let catch_up = send_classical - .pending - .iter() - .find(|p| p.target == new_alice.bytes) - .expect("send_classical.pending must carry the generalized catch-up at mine.current"); - assert_eq!( - catch_up.key.signature_key, export.identity.signature_key, - "the catch-up entry carries the identity's OWN (current) classical key" + assert!( + send_classical.pending.is_empty(), + "send-classical carries no pending key, even while it lags" ); if let Some(deployed) = export.deployed_state { assert!(!deployed.no_custody.send_classical); @@ -9045,8 +9040,8 @@ fn test_identity_kp_prefers_the_retained_kp_named_in_the_initial_app_payload() { } /// Re-proposing `mine.current` while send-classical lags mints a real same-id candidate K′: -/// `send_classical.pending[mine.current]` stays the identity's catch-up key (no own offers), -/// `recv_classical`'s is K′, and it never exports as `rotation_candidate`. +/// `send_classical.pending` stays empty, `recv_classical`'s `pending[mine.current]` is K′, +/// and it never exports as `rotation_candidate`. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_same_id_candidate_send_classical_also_lagging() { @@ -9076,18 +9071,7 @@ fn test_migration_export_same_id_candidate_send_classical_also_lagging() { }; let export = assert_ok!(alice.migration_export()); - let send_entry = export - .leaf_keys - .send_classical - .pending - .iter() - .find(|p| p.target == id1.bytes) - .expect("send_classical must still carry pending[mine.current]"); - assert_eq!( - send_entry.key.signature_key, export.identity.signature_key, - "send_classical has no own offers: only the identity's catch-up key can occupy \ - mine.current" - ); + assert!(export.leaf_keys.send_classical.pending.is_empty()); let recv_entry = export .leaf_keys .recv_classical @@ -9099,10 +9083,6 @@ fn test_migration_export_same_id_candidate_send_classical_also_lagging() { recv_entry.key.signature_key, candidate_key, "recv_classical's ONLY real offer at mine.current is the candidate's own K′" ); - assert_ne!( - recv_entry.key.signature_key, send_entry.key.signature_key, - "send and recv legitimately disagree at mine.current here" - ); assert!( export.rotation_candidate.is_none(), "a same-id candidate must never export as rotation_candidate" @@ -9755,8 +9735,9 @@ fn test_migration_export_no_custody_on_corrupted_signer() { assert!(assert_some!(export.deployed_state).no_custody.recv_pq); } -/// Every staged candidate (a full `CANDIDATE_WINDOW`) rides both classical `pending` sets with -/// the identical key; the newest is also `rotation_candidate`, pinned to the current recv epoch. +/// Every staged candidate (a full `CANDIDATE_WINDOW`) rides recv-classical's `pending`, and +/// send-classical's stays empty; the newest is also `rotation_candidate`, pinned to the +/// current recv epoch. #[cfg(feature = "cryptokit")] #[test] fn test_migration_export_carries_multiple_staged_candidates() { @@ -9770,7 +9751,7 @@ fn test_migration_export_carries_multiple_staged_candidates() { } let export = assert_ok!(alice.migration_export()); let recv_classical = &export.leaf_keys.recv_classical; - let send_classical = &export.leaf_keys.send_classical; + assert!(export.leaf_keys.send_classical.pending.is_empty()); let recv_epoch = { let inner = alice.lock(); inner.recv_group.as_ref().unwrap().classical.current_epoch() @@ -9781,16 +9762,7 @@ fn test_migration_export_carries_multiple_staged_candidates() { .iter() .find(|p| p.target == id.bytes) .expect("candidate missing from recv_classical"); - let send_entry = send_classical - .pending - .iter() - .find(|p| p.target == id.bytes) - .expect("candidate missing from send_classical"); - assert_eq!( - recv_entry.key.signing_key, send_entry.key.signing_key, - "candidate {:?}'s key must be identical in both classical sets", - id.bytes - ); + assert_eq!(recv_entry.target, id.bytes); } let newest = ids.last().unwrap(); let rotation_candidate = assert_some!(export.rotation_candidate); @@ -11482,41 +11454,11 @@ mod totality_random_walk { false, ); - // Every target but `mine.current` has the same key in both classical sets; there, - // recv_classical may carry a same-id K′ while send_classical carries the identity's. - for candidate_target in [ - &export.leaf_keys.send_classical, - &export.leaf_keys.recv_classical, - ] - .iter() - .flat_map(|g| g.pending.iter().map(|p| p.target.clone())) - .collect::>() - { - if candidate_target == mine_current { - continue; - } - let send_key = export - .leaf_keys - .send_classical - .pending - .iter() - .find(|p| p.target == candidate_target) - .map(|p| &p.key); - let recv_key = export - .leaf_keys - .recv_classical - .pending - .iter() - .find(|p| p.target == candidate_target) - .map(|p| &p.key); - if let (Some(send_key), Some(recv_key)) = (send_key, recv_key) { - assert_eq!( - send_key.signing_key, recv_key.signing_key, - "{label} seed {seed} step {step}: target {candidate_target:?} has \ - different keys in send_classical vs recv_classical" - ); - } - } + // send_classical carries `current` only; its own next commit mints fresh. + assert!( + export.leaf_keys.send_classical.pending.is_empty(), + "{label} seed {seed} step {step}: send_classical carries a pending key" + ); // `rotation_candidate` (never a same-id candidate) matches its pending entries. if let Some(candidate) = &export.rotation_candidate { assert_ne!( @@ -11524,14 +11466,13 @@ mod totality_random_walk { "{label} seed {seed} step {step}: a same-id candidate must never export \ as rotation_candidate" ); - let matches = [ - &export.leaf_keys.send_classical, - &export.leaf_keys.recv_classical, - ] - .iter() - .flat_map(|g| g.pending.iter()) - .filter(|p| p.target == candidate.target_client_id) - .all(|p| p.key.signing_key == candidate.signing_key); + let matches = export + .leaf_keys + .recv_classical + .pending + .iter() + .filter(|p| p.target == candidate.target_client_id) + .all(|p| p.key.signing_key == candidate.signing_key); assert!( matches, "{label} seed {seed} step {step}: rotation_candidate's key disagrees with \ From 51308ae2c37740c215d252815b9d0a4c96fadf8a Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Fri, 25 Sep 2026 10:32:20 -0700 Subject: [PATCH 16/16] Pin twomlspq-swift 0.3.0 Co-Authored-By: Claude Opus 5.5 --- Package.resolved | 5 +++-- Package.swift | 16 ++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Package.resolved b/Package.resolved index 3cf8ff9..3081019 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e12d1534d13a45a598bcc07d8a58780236000b103cdf5d641e7e967ca29cdc44", + "originHash" : "79f4e6445544b6aab3db1fc9830d0ee3fda5180ca76de58621ab2f9bc2b008c8", "pins" : [ { "identity" : "atprototypes", @@ -105,7 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/twomlspq-swift.git", "state" : { - "revision" : "902feadc916cdfa36676bd627b1098ef9b804142" + "revision" : "3735f67e9a7cc970af4e5c25aa413e9342d83fc9", + "version" : "0.3.0" } } ], diff --git a/Package.swift b/Package.swift index 88c77c0..ab4dd31 100644 --- a/Package.swift +++ b/Package.swift @@ -78,18 +78,14 @@ let package = Package( url: "https://github.com/germ-network/autonomous-comm-protocol.git", from: "1.13.0" ), - // The native-side migrator dependency (GER-2372): twomlspq-swift carries R1's - // `InvitationMigration.mintArchive` + `MigratedIdentity` and R2's - // `SessionMigration.mintArchive` + `MigratedSession`, which `TwoMLSPQMigrate` - // maps the Rust migration exports onto. 0.1.1 is the first tag carrying R2 - // (SessionMigration + the ML-KEM `hpkeSecretKeySize`); its transitive deps - // (swift-mls, swift-secret-bytes, swift-crypto, GermConvenience) resolve - // automatically. Pinned to unreleased main for the deployed-state migration - // inputs and the empty pre-A.3 send-PQ shape; move to the release that ships - // them before merging. + // The native engine `TwoMLSPQMigrate` mints into (`InvitationMigration`, + // `SessionMigration`). 0.3.0 is the first release that takes the per-group + // signing keys and deployed-state inputs this export carries; its transitive + // deps (swift-mls, swift-secret-bytes, swift-crypto, GermConvenience) resolve + // automatically. .package( url: "https://github.com/germ-network/twomlspq-swift.git", - revision: "902feadc916cdfa36676bd627b1098ef9b804142" + from: "0.3.0" ), // Declared directly (not just transitively through twomlspq-swift) because // the migrate targets import their products. Library deps stay ranged