diff --git a/.changeset/born-dedicated-acceptor-export.md b/.changeset/born-dedicated-acceptor-export.md new file mode 100644 index 0000000..0b846b6 --- /dev/null +++ b/.changeset/born-dedicated-acceptor-export.md @@ -0,0 +1,24 @@ +--- +"@germ-network/two-mls-pq": minor +--- + +`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`). A pre-A.3 acceptor's `leafKeys.sendPq` is empty, since A.3 founding mints +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 +session archive plus, when present, the minted own-offer window, which the caller must persist +before the archive. + +`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. + +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..3081019 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dce512909c4b52bf34b936932f4d92ceae94a75e0c9b9bcb7656b8ac58317eef", + "originHash" : "79f4e6445544b6aab3db1fc9830d0ee3fda5180ca76de58621ab2f9bc2b008c8", "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,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/germ-network/twomlspq-swift.git", "state" : { - "revision" : "6d9b1fdfaa2bc9fa91cfcc3ea30945f06c4324cc", - "version" : "0.2.1" + "revision" : "3735f67e9a7cc970af4e5c25aa413e9342d83fc9", + "version" : "0.3.0" } } ], diff --git a/Package.swift b/Package.swift index 786ec83..ab4dd31 100644 --- a/Package.swift +++ b/Package.swift @@ -78,19 +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 + // 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. - // Temporary revision pin to twomlspq-swift's swift-crypto-5 branch - // (germ-network/twomlspq-swift#60); replace with the released version - // once it cuts. .package( url: "https://github.com/germ-network/twomlspq-swift.git", - from: "0.2.1" + from: "0.3.0" ), // Declared directly (not just transitively through twomlspq-swift) because // the migrate targets import their products. Library deps stay ranged @@ -99,9 +94,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" @@ -179,6 +171,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/Sources/TwoMLSPQ/PQSession.swift b/Sources/TwoMLSPQ/PQSession.swift index 962f73c..b2db3f0 100644 --- a/Sources/TwoMLSPQ/PQSession.swift +++ b/Sources/TwoMLSPQ/PQSession.swift @@ -264,7 +264,12 @@ 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 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 { static let verified: Void = { diff --git a/Sources/TwoMLSPQBinding/two_mls_pq.swift b/Sources/TwoMLSPQBinding/two_mls_pq.swift index 94ab0bc..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 { @@ -2222,19 +2222,15 @@ 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 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, quiescent session — 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 @@ -3199,19 +3195,15 @@ 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. - * - * Admits only an ESTABLISHED, quiescent session — 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) { @@ -5318,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. @@ -5459,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 @@ -5497,6 +5569,37 @@ 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. 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. @@ -5507,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 @@ -5518,7 +5620,33 @@ 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. 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 @@ -5551,6 +5679,11 @@ public struct SessionMigrationExport: Equatable, Hashable { self.recvAttachmentLedger = recvAttachmentLedger self.initialTheirKp = initialTheirKp self.owesEstablishmentEnvelope = owesEstablishmentEnvelope + self.pqLeafCustody = pqLeafCustody + self.leafKeys = leafKeys + self.rotationCandidate = rotationCandidate + self.initialAppPayload = initialAppPayload + self.deployedState = deployedState } @@ -5600,7 +5733,12 @@ 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), + leafKeys: FfiConverterTypeSessionMigrationLeafKeys.read(from: &buf), + rotationCandidate: FfiConverterOptionTypeSessionMigrationRotationCandidate.read(from: &buf), + initialAppPayload: FfiConverterOptionData.read(from: &buf), + deployedState: FfiConverterOptionTypeSessionMigrationDeployedState.read(from: &buf) ) } @@ -5637,6 +5775,11 @@ 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) + FfiConverterTypeSessionMigrationLeafKeys.write(value.leafKeys, into: &buf) + FfiConverterOptionTypeSessionMigrationRotationCandidate.write(value.rotationCandidate, into: &buf) + FfiConverterOptionData.write(value.initialAppPayload, into: &buf) + FfiConverterOptionTypeSessionMigrationDeployedState.write(value.deployedState, into: &buf) } } @@ -5716,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: @@ -5731,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 @@ -5821,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 } @@ -5842,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) } } @@ -5869,32 +6071,37 @@ 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, except a + * pre-A.3 acceptor's `send_pq`, which is empty (A.3 founding mints its key). */ -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 } @@ -5903,26 +6110,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) } } @@ -5930,34 +6139,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) } /** - * The staged Upd(self) awaiting the peer's fold: `pending_proposal_hash` + - * `pending_proposal_message` combined into the native `PendingProposal` - * shape. + * 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 SessionMigrationProposal: Equatable, Hashable { - public var proposing: Data - public var message: Data - public var hash: 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(proposing: Data, message: Data, hash: Data) { - self.proposing = proposing - self.message = message - self.hash = hash + public init(sendClassical: Bool, sendPq: Bool, recvClassical: Bool, recvPq: Bool) { + self.sendClassical = sendClassical + self.sendPq = sendPq + self.recvClassical = recvClassical + self.recvPq = recvPq } @@ -5966,26 +6178,28 @@ public struct SessionMigrationProposal: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationProposal: Sendable {} +extension SessionMigrationNoCustody: 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 FfiConverterTypeSessionMigrationNoCustody: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationNoCustody { return - try SessionMigrationProposal( - proposing: FfiConverterData.read(from: &buf), - message: FfiConverterData.read(from: &buf), - hash: 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: 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: 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) } } @@ -5993,35 +6207,32 @@ 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 FfiConverterTypeSessionMigrationNoCustody_lift(_ buf: RustBuffer) throws -> SessionMigrationNoCustody { + return try FfiConverterTypeSessionMigrationNoCustody.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationProposal_lower(_ value: SessionMigrationProposal) -> RustBuffer { - return FfiConverterTypeSessionMigrationProposal.lower(value) +public func FfiConverterTypeSessionMigrationNoCustody_lower(_ value: SessionMigrationNoCustody) -> RustBuffer { + return FfiConverterTypeSessionMigrationNoCustody.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`. + * A PQ commit awaiting its classical bind (see `SessionInner::owed_bind`). */ -public struct SessionMigrationPskEntry: Equatable, Hashable { - public var epoch: UInt64 - public var componentId: UInt32 - public var pskId: Data - public var psk: 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(epoch: UInt64, componentId: UInt32, pskId: Data, psk: Data) { - self.epoch = epoch - self.componentId = componentId - self.pskId = pskId - self.psk = psk + public init(pqCommit: Data, tEpoch: UInt64, pqEpoch: UInt64) { + self.pqCommit = pqCommit + self.tEpoch = tEpoch + self.pqEpoch = pqEpoch } @@ -6030,28 +6241,26 @@ public struct SessionMigrationPskEntry: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationPskEntry: Sendable {} +extension SessionMigrationOwedBind: 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 FfiConverterTypeSessionMigrationOwedBind: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwedBind { return - try SessionMigrationPskEntry( - epoch: FfiConverterUInt64.read(from: &buf), - componentId: FfiConverterUInt32.read(from: &buf), - pskId: FfiConverterData.read(from: &buf), - psk: 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: 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: SessionMigrationOwedBind, into buf: inout [UInt8]) { + FfiConverterData.write(value.pqCommit, into: &buf) + FfiConverterUInt64.write(value.tEpoch, into: &buf) + FfiConverterUInt64.write(value.pqEpoch, into: &buf) } } @@ -6059,30 +6268,60 @@ public struct FfiConverterTypeSessionMigrationPskEntry: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeSessionMigrationPskEntry_lift(_ buf: RustBuffer) throws -> SessionMigrationPskEntry { - return try FfiConverterTypeSessionMigrationPskEntry.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 FfiConverterTypeSessionMigrationPskEntry_lower(_ value: SessionMigrationPskEntry) -> RustBuffer { - return FfiConverterTypeSessionMigrationPskEntry.lower(value) +public func FfiConverterTypeSessionMigrationOwedBind_lower(_ value: SessionMigrationOwedBind) -> RustBuffer { + return FfiConverterTypeSessionMigrationOwedBind.lower(value) } /** - * One `stagedUpdates` entry: the digest + message of a staged Upd(self). + * 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 SessionMigrationStagedUpdate: Equatable, Hashable { - public var digest: Data - public var message: 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(digest: Data, message: Data) { - self.digest = digest - self.message = message + 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 } @@ -6091,24 +6330,539 @@ public struct SessionMigrationStagedUpdate: Equatable, Hashable { } #if compiler(>=6) -extension SessionMigrationStagedUpdate: Sendable {} +extension SessionMigrationOwnOffer: Sendable {} #endif #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeSessionMigrationStagedUpdate: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationStagedUpdate { +public struct FfiConverterTypeSessionMigrationOwnOffer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationOwnOffer { return - try SessionMigrationStagedUpdate( - digest: FfiConverterData.read(from: &buf), - message: 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: SessionMigrationStagedUpdate, into buf: inout [UInt8]) { - FfiConverterData.write(value.digest, into: &buf) - FfiConverterData.write(value.message, 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) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPskEntry_lift(_ buf: RustBuffer) throws -> SessionMigrationPskEntry { + return try FfiConverterTypeSessionMigrationPskEntry.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSessionMigrationPskEntry_lower(_ value: SessionMigrationPskEntry) -> RustBuffer { + return FfiConverterTypeSessionMigrationPskEntry.lower(value) +} + + +/** + * The most recently staged rotation candidate, classical only. Its + * `signing_key`/`signature_key` must equal the same candidate's `pending` + * 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`). + */ +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). + */ +public struct SessionMigrationStagedUpdate: Equatable, Hashable { + public var digest: Data + public var message: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(digest: Data, message: Data) { + self.digest = digest + self.message = message + } + + + + +} + +#if compiler(>=6) +extension SessionMigrationStagedUpdate: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSessionMigrationStagedUpdate: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionMigrationStagedUpdate { + return + try SessionMigrationStagedUpdate( + digest: FfiConverterData.read(from: &buf), + message: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: SessionMigrationStagedUpdate, into buf: inout [UInt8]) { + FfiConverterData.write(value.digest, into: &buf) + FfiConverterData.write(value.message, into: &buf) } } @@ -6696,6 +7450,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. /** @@ -7635,6 +8467,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 @@ -7683,6 +8539,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 @@ -7707,6 +8587,54 @@ 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 +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 @@ -7731,6 +8659,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 @@ -7779,6 +8731,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 @@ -7879,6 +8855,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 @@ -8160,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) { @@ -8256,7 +9282,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() != 39391) { 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..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), @@ -104,7 +130,125 @@ public enum SessionMigrator { initialTheirKP: export.initialTheirKp.map { (classical: $0.classical, pq: $0.pq) }, - owesEstablishmentEnvelope: export.owesEstablishmentEnvelope) + recvLeafPrincipal: try export.pqLeafCustody.map { + try recvLeafPrincipal($0, identity: export.identity) + }, + owesEstablishmentEnvelope: export.owesEstablishmentEnvelope, + leafKeys: try leafKeys(export.leafKeys), + initialAppPayload: export.initialAppPayload) + } + + /// 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 + ) 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( 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/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift new file mode 100644 index 0000000..ab542cd --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/BornDedicatedMigrationTests.swift @@ -0,0 +1,451 @@ +import CryptoKit +import Foundation +import MLSCrypto +import SecretBytes +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// 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. +// +// `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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + 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)) + + // 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) + + 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 an A.5 catch-up + + /// 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 + // 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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) + 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. + _ = 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)) + + try aliceSays(pair.alice, "post-a5-alice", to: &nativeBob) + 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 + /// 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 + XCTAssertNoThrow( + try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider)) + } + + func testFlippedPQLeafCustodySigningKeyThrowsArchiveInvalid() throws { + let pair = try RustSessionTestHelpers.bornDedicatedSessionPair() + var export = try pair.bob.migrationExport() + export.pqLeafCustody?.pqSigningKey[0] ^= 0xFF + XCTAssertThrowsError( + try SessionMigrator.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider) + ) { error in + XCTAssertEqual(error as? TwoMLSPQSession.TwoMLSError, .archiveInvalid) + } + } + + // 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 testPreInstallBobExportNowSucceeds() throws { + let pair = try RustSessionTestHelpers.bornDedicatedPending() + 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 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: - 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( + _ 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 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, + 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/DeployedStateMigrationTests.swift b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift new file mode 100644 index 0000000..f8f74b7 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/DeployedStateMigrationTests.swift @@ -0,0 +1,694 @@ +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 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)) + 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") + + // 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-a5".utf8)) + let bobOpened = try XCTUnwrap( + bobSession.processIncoming(ciphertext: followUp.frame)) + XCTAssertEqual( + 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 + + /// 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/EstablishmentFramingCrossEngineTests.swift b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift new file mode 100644 index 0000000..463a5ea --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/EstablishmentFramingCrossEngineTests.swift @@ -0,0 +1,459 @@ +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: - 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 { + 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]) + } +} diff --git a/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift b/Tests/TwoMLSPQMigrateTests/LegacyRowFixtureTests.swift index ca6f983..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,39 +481,902 @@ class LegacyRowFixtureTests: XCTestCase { aliceFinalDecrypted.applicationMessage, Data("\(point)-final-bob".utf8)) } - // MARK: - Acceptor export still refused (every point) + // MARK: - Acceptor export: p1-p6 now export (the Rust exporter no longer + // refuses an unconverged recv-classical leaf) - func testP1AcceptorExportRefusedSessionNotReady() throws { - try assertAcceptorExportRefused(point: "p1-bob-sent-unfolded") + /// 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 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` 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.mint( + kind: .checkpoint, from: export, + 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") + + _ = 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. + _ = 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. + _ = 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") + + _ = 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 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") + 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.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 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") + + let welcomePrimeSealed = try XCTUnwrap( + nativeBob.pqPendingOutbound(), + "native bob should still hold the parked Welcome' after migration") + + // 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) + + _ = 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) 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") + + _ = 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)) + + _ = 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' 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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, unlike the Rust-side + // `openIncoming` + `pqBootstrapBind` two-step. + _ = try nativeAlice.pqBootstrapJoin(sealedWelcomePrime) + + // 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) + 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") + 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") + + _ = 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)) + + _ = 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 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeAlice = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + 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 (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) + 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") + 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") + + _ = 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)) + + _ = 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 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() + 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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. 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( + nativeAlice.pqPendingOutbound(), "native alice should have nothing parked") + XCTAssertNil( + bob.pqPendingOutbound(sealing: .fresh), "bob should have nothing parked") + + // 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) + 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)) + + 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") + + _ = 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)) + + _ = 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 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) + 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + var nativeBob = try TwoMLSPQSession.TwoMLSSession.restore( + core: nil, checkpoint: archive, + classicalProvider: classicalProvider, pqProvider: pqProvider) + + // 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)) + 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` 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) + 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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` (`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") + let freshBob = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "acceptor") + var export = try freshBob.migrationExport() + export.pendingSideBand = nil + + 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) + + 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` (`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") + let freshBob = try restoreAndVerify( + point: "p6-a3-responded-stalled", side: "acceptor") + var export = try freshBob.migrationExport() + export.pqInflight = nil + + 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) + + // Reads `pendingSideBand`, left intact — unaffected by the blanked `pqInflight`. + 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) + + // 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( + 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 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 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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") + + // 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") + 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 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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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/MintCoverageTests.swift b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift new file mode 100644 index 0000000..c666070 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/MintCoverageTests.swift @@ -0,0 +1,495 @@ +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +import TwoMLSPQSession +import XCTest + +// 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. +// +// 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 + /// `recv_classical`'s pending set, and `send_classical`'s stays empty. + 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.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, + 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: - 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 + /// 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 new file mode 100644 index 0000000..b94ec69 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/NonDedicatedEarlyExportTests.swift @@ -0,0 +1,152 @@ +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.mint( + kind: .checkpoint, from: export, + classicalProvider: classicalProvider, pqProvider: pqProvider + ).archive + 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)) + } + + /// 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.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'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/RotatedRekeyHealTests.swift b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift new file mode 100644 index 0000000..7592ed5 --- /dev/null +++ b/Tests/TwoMLSPQMigrateTests/RotatedRekeyHealTests.swift @@ -0,0 +1,731 @@ +import Foundation +import MLSCrypto +import TwoMLSPQBinding +import TwoMLSPQCrypto +import TwoMLSPQMigrate +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 +// `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. + +@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 presents it until bob's + // reciprocal round at the end 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 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 reciprocalOpener = try nativeBob.encrypt(Data("reciprocal-opener".utf8)) + _ = try XCTUnwrap(alice.processIncoming(ciphertext: reciprocalOpener.frame)) + let reciprocalUpd = try XCTUnwrap(nativeBob.pqPendingOutbound()) + XCTAssertEqual( + try alice.openIncoming(blob: reciprocalUpd)?.kind, + .pqSideBand(kind: .rekeyUpdate)) + XCTAssertNil( + try alice.pqRekeyRespond(updMsg: reciprocalUpd), + "bob's same-id refresh announces nothing") + _ = try nativeBob.pqRekeyApply(try XCTUnwrap(alice.pqTakePendingOutbound())) + + // 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") + } + _ = 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 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") + } + 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, + "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 + + /// 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 (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. + _ = 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: - 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 + /// 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) + } + + // 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)) + } + + /// 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( + 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 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( + 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 + /// 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) + } +} diff --git a/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift b/Tests/TwoMLSPQMigrateTests/RustSessionTestHelpers.swift index 9bd873b..07772ce 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,168 @@ 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) — 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 { + 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) + } + + /// 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) + } + + /// `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, 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 { + 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 (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 { + 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/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)) } 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 + } +} 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), diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1e8317a..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 = "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 = "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 = "74917834ccba56ed56399bfa6b679dff2e760e8b", 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 = "74917834ccba56ed56399bfa6b679dff2e760e8b" } +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 = "74917834ccba56ed56399bfa6b679dff2e760e8b" } +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 a44e186..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 = "ec69dc251db66ae6eb117079564814998bb55dec" } +mls-rs = { git = "https://github.com/germ-network/mls-rs", rev = "1ce5ae3181ff90643f6f47d2b5fb63eecf291b62" } [[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/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/lib.rs b/rust/two-mls-pq/src/lib.rs index ce124fd..e640cdb 100644 --- a/rust/two-mls-pq/src/lib.rs +++ b/rust/two-mls-pq/src/lib.rs @@ -499,7 +499,16 @@ 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 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 /// binding it was generated with matches the binary it loaded. 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 7194bcf..608de2b 100644 --- a/rust/two-mls-pq/src/session/migration.rs +++ b/rust/two-mls-pq/src/session/migration.rs @@ -1,49 +1,152 @@ -//! 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 +//! 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_with_pending_signers`, 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 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: //! -//! * 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; -//! * 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); -//! * a wedged or bind-broken side-band: the native archive carries no wedge -//! verdict, so the migrated session would report healthy and deadlock. +//! * 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` — 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 +//! `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 +//! 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; //! -//! A pending mls-rs commit or a signer-rotating pending self-Update fails -//! inside `export_for_swift` itself. +//! `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. //! -//! Emits PLAINTEXT SECRET material (group snapshots, signing keys, HPKE +//! What still refuses is corrupt or genuinely impossible data, never a +//! reachable one: +//! +//! * 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 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 //! 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::MlsMessage; +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 @@ -69,8 +172,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, @@ -163,6 +268,141 @@ pub struct SessionMigrationBootstrapKp { pub key_package: Vec, } +/// 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, + pub pq_signing_key: Vec, + 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, 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, + 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 `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)] +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` @@ -203,9 +443,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, @@ -236,6 +475,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. 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 @@ -267,21 +527,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(); @@ -304,30 +591,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 @@ -373,63 +636,682 @@ 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 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], + 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(), + }) +} + +/// 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 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(); + ( + 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 (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, + 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 +} + +/// `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 (GER-2433 C1): 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 - /// 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, - // born-dedicated (either latch state), or a torn side-band. - if inner.recv_group.is_none() - || inner.pending_outbound.is_some() - || 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.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), + _ => {} } - 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()?; + // 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(); + 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() + .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`. - 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) @@ -447,6 +1329,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())?, @@ -458,55 +1353,435 @@ 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. 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 - } - None => true, - }; - Ok(classical_ok && pq_ok) + // `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(), }; - if !leaf_matches( - inner + 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() + .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_ref() - .ok_or(TwoMlsPqError::SessionNotReady)?, - )? || !inner + .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() - .map(leaf_matches) - .transpose()? - .unwrap_or(false) + .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(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()); + } + } + 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() { - return Err(TwoMlsPqError::SessionNotReady); + 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 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, 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, + &classical_provider, + &pool, + &send_classical.1, + )?; + ( + SessionMigrationGroupKeys { + current: leaf.current, + pending: Vec::new(), + }, + no_custody, + ) + }; + + // 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)?; + 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 => ( + SessionMigrationGroupKeys { + current: None, + pending: Vec::new(), + }, + false, + ), + }; + + // 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)?; + 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); + leaf_pq_custody(pq, &presented, &identity.client_id, &auth_mine_history).ok() + } else { + None + } + } + }; + + // 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| { @@ -520,13 +1795,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, @@ -588,7 +1862,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 +1934,17 @@ impl TwoMlsPqSession { }) }) .transpose()?, - owes_establishment_envelope: inner.requires_establishment_envelope, + // 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, }) } } @@ -706,24 +1990,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, - /// return welcome already drained) exports fine — the native mint's - /// topology gate keys off the RECV pair for a responder. + /// 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() { @@ -751,13 +2130,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..5f3b119 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())); @@ -8501,3 +8501,3836 @@ 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 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() { + 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); + + 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 + ); + + 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))); + + 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); + // Same derive 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 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!( + recv_pq_current.signature_key, + legacy_custody.pq_signature_key + ); + + // 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; + let pre_install_export = assert_ok!(bob_s.migration_export()); + assert!(pre_install_export.owes_establishment_envelope); +} + +/// 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_carries_born_dedicated_pre_install() { + let d = crate::test_utils::born_dedicated_pending(); + 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 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_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)); + 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, + )); + 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, 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_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.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 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_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.clone()))); + let rotation = assert_ok!(alice.encrypt(b"rotate".to_vec())); + assert_some!(assert_ok!(bob.process_incoming(rotation.cipher_text))); + + // 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))); + 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))); + + 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)); +} + +/// 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` stays empty, since that commit mints its own fresh key. +#[cfg(feature = "cryptokit")] +#[test] +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.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" + ); + 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); + } +} + +/// 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_carries_mid_a5_round_on_rotated_session() { + let (alice, bob) = establish_full(); + // `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!( + export.pq_inflight, + Some(super::migration::SessionMigrationPqInflight::RekeyInitiated { .. }) + )); + let send_pq = export.leaf_keys.send_pq; + assert_some!(send_pq.current); +} + +/// 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_carries_send_pq_lag_after_rotation_converges_everything_else() { + let (alice, bob) = establish_full(); + // `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.clone()); + + 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" + ); + + 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); + } +} + +/// 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_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); + } +} + +/// 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_empty_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; + 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); + } +} + +/// 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_recv_pq_reservation_for_pre_a3_initiator() { + let (alice, _bob) = establish_confirmed_sessions(); + 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 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_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); + } +} + +/// 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_identity_kp_prefers_the_retained_kp_named_in_the_initial_app_payload() { + let alice = make_client(); + let bob = make_client(); + 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)); + + // `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" + ); + + assert_ok!(alice_s.set_initial_app_payload(second_kp_bytes.clone())); + + 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" + ); +} + +/// Re-proposing `mine.current` while send-classical lags mints a real same-id candidate K′: +/// `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() { + 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" + ); + + // 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 export = assert_ok!(alice.migration_export()); + assert!(export.leaf_keys.send_classical.pending.is_empty()); + 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!( + export.rotation_candidate.is_none(), + "a same-id candidate must never export as rotation_candidate" + ); +} + +/// 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_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() + .pending_proposal_message + .as_ref() + .unwrap() + .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() + .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" + ); + } + } +} + +/// 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!( + 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 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() { + 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; + 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() + }; + for id in &ids { + let recv_entry = recv_classical + .pending + .iter() + .find(|p| p.target == id.bytes) + .expect("candidate missing from recv_classical"); + assert_eq!(recv_entry.target, 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. + } + // 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. + 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, + ); + + // 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!( + 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 + .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 \ + 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(), + ); +}