From e886d7cfacbff119cb9bcb961cfc213882675636 Mon Sep 17 00:00:00 2001 From: Makoto Takemiya Date: Mon, 7 Sep 2026 16:46:43 +0900 Subject: [PATCH 1/2] Recover interrupted first legacy wallet activation --- .../ios-migration-qualification-README.md | 10 +- .../Common/Extensions/SettingsExtension.swift | 26 ++ .../Common/Model/WalletNetworkModel.swift | 168 ++++++++- .../ModulesRedesign/Root/RootInteractor.swift | 283 +++++++++++++++ .../SplashScreen/SplashInteractor.swift | 21 +- .../Scripts/collect-ios-migration-evidence.py | 6 +- .../test-ios-migration-evidence-collector.py | 4 +- .../verify-modernization-dependencies.sh | 12 +- .../WalletModernizationTests.swift | 333 ++++++++++++++++++ 9 files changed, 839 insertions(+), 24 deletions(-) diff --git a/Fixtures/Modernization/ios-migration-qualification-README.md b/Fixtures/Modernization/ios-migration-qualification-README.md index c315143c..52d13af6 100644 --- a/Fixtures/Modernization/ios-migration-qualification-README.md +++ b/Fixtures/Modernization/ios-migration-qualification-README.md @@ -130,12 +130,12 @@ Required identity and aggregate fields: exporter, which continues to export only independently verified database and settings artifacts for support. Existing-target rollback and absent-target withdrawal are both exercised by weakening the actual published inode to `.none` - inside the existing 207-method source suite. + inside the existing 209-method source suite. - A positive `retainedReleaseSnapshotCount`, the reviewed `retainedReleaseSnapshotManifestSha256`, both checked-in Core Data model SHA-256 values, the exact executed `WalletModernizationTests`, `WalletRecoveryCapabilityGateTests`, and `WalletRecoveryExporterTests` method counts, zero failure counts, and a reviewed - `testResultBundleSha256` covering all four suite inventories (207 + 11 + 12 + 3 = 233). + `testResultBundleSha256` covering all four suite inventories (209 + 11 + 12 + 3 = 235). - True parity for account count, selected wallet, preferences, Keychain identity and accessibility, legacy dual-read retention, existing SORA2 identity/signatures, and zero lost accounts. - Missing-store qualification must separately retain and exercise raw selected-account settings, @@ -263,7 +263,7 @@ with six success cohorts fails admission. All affected sources are bound by be recollected and independently reviewed for the current candidate. `SoraPassportMigrationEvidence.xcscheme` is the dedicated Release/physical-device evidence -scheme. Its exact test inventory is 207 `WalletModernizationTests`, 11 +scheme. Its exact test inventory is 209 `WalletModernizationTests`, 11 `WalletRecoveryCapabilityGateTests`, 12 `WalletRecoveryExporterTests`, and three `WalletMigrationRetainedDeviceEvidenceTests`. The last three tests bind their schema-v3 attachments to the installed production bundle identifier, the exact production IPA, the canonical projection @@ -545,7 +545,7 @@ the repository with this fixed layout: - `application/SoraPassport.app`, the exact archive-derived installable clone; - `application/canonical-projection-receipt-v2.json`, the canonical observed projection receipt; - `application/installable-clone-receipt-v1.json`, the protected non-authorizing clone receipt; -- `tests/Migration.xcresult`, containing the exact 233 passing test identifiers and three +- `tests/Migration.xcresult`, containing the exact 235 passing test identifiers and three test-associated reserved JSON attachments; - `snapshots/index.json` and `snapshots/data//{source,migrated}`, containing the retained Core Data/settings bundles. @@ -693,7 +693,7 @@ Keychain aggregate must exactly match successful/failing source counts and the r identity/accessibility assertions, with no credential rewrite or raw values. The device aggregate must exactly match Core Data and interruption counts plus reinstall/upgrade, rollback, low-storage, recovery-export, and process-death/restart assertions. The ZIP summary must exactly match all four -declared suite counts (207 + 11 + 12 + 3 = 233) and zero failure, unexpected-failure, skipped, and +declared suite counts (209 + 11 + 12 + 3 = 235) and zero failure, unexpected-failure, skipped, and expected-failure counters. Independent byte reproduction proves that these public aggregates are the collector's derivation from the pinned raw namespace; producer and reviewer signatures remain necessary authentication and do not replace review of the restricted scenario material. diff --git a/SoraPassport/Common/Extensions/SettingsExtension.swift b/SoraPassport/Common/Extensions/SettingsExtension.swift index e21e1b68..689290bc 100644 --- a/SoraPassport/Common/Extensions/SettingsExtension.swift +++ b/SoraPassport/Common/Extensions/SettingsExtension.swift @@ -147,6 +147,32 @@ struct WalletMigrationRecoveryMarker: Equatable, Codable { ].contains(reason ?? "") } + static let accountCommitInterruptionReason = + "An unfinished wallet account commit blocks signing and wallet changes. Existing wallet material was preserved." + + var isLegacyAccountCommitInterruption: Bool { + isDatabaseInterruption || (required && generation == reasonGeneration && + reason == Self.accountCommitInterruptionReason) + } + + func requireUnchangedForLegacyAccountRecovery(_ settings: SettingsManagerProtocol) throws { + guard (!required || isLegacyAccountCommitInterruption), Self.capture(settings) == self else { + throw WalletNetworkMigrationError.walletRecoveryRequired + } + } + + func clearAfterVerifiedLegacyAccountActivation(_ settings: SettingsManagerProtocol) throws { + try Self.synchronized { + try requireUnchangedForLegacyAccountRecovery(settings) + let generation = UUID().uuidString + let cleared = Self(required: false, reason: nil, generation: generation, reasonGeneration: generation) + Self.publish(cleared, to: settings) + guard Self.capture(settings) == cleared else { + throw WalletNetworkMigrationError.walletRecoveryRequired + } + } + } + func requireUnchanged(_ settings: SettingsManagerProtocol) throws { guard isDatabaseInterruption, Self.capture(settings) == self else { throw WalletNetworkMigrationError.walletRecoveryRequired diff --git a/SoraPassport/Common/Model/WalletNetworkModel.swift b/SoraPassport/Common/Model/WalletNetworkModel.swift index d11bdc31..c2057944 100644 --- a/SoraPassport/Common/Model/WalletNetworkModel.swift +++ b/SoraPassport/Common/Model/WalletNetworkModel.swift @@ -1639,6 +1639,8 @@ struct WalletAccountCommitJournal: Codable, Equatable { var stage: WalletAccountCommitStage let createdAt: Date var updatedAt: Date + // Optional for journals written before restart recovery was supported. + var recoveryMarker: WalletMigrationRecoveryMarker? = nil } /// Non-secret interruption journal for new/imported wallets. An unfinished @@ -1790,6 +1792,49 @@ final class WalletAccountCommitJournalStore { } } + func journalsForLegacyRecovery() throws -> [WalletAccountCommitJournal] { + Self.lock.lock() + defer { Self.lock.unlock() } + return try loadUnlocked() + } + + func requireCurrentForLegacyRecovery(_ journal: WalletAccountCommitJournal) throws { + try withCurrentForLegacyRecovery(journal) {} + } + + func withCurrentForLegacyRecovery( + _ journal: WalletAccountCommitJournal, + _ body: () throws -> T + ) throws -> T { + Self.lock.lock() + defer { Self.lock.unlock() } + let journals = try loadUnlocked() + guard journals.count == 1, Self.journalsMatch(journals[0], journal) else { + throw WalletNetworkMigrationError.snapshotVerificationFailed + } + return try body() + } + + func bindLegacyRecoveryMarker( + _ journal: WalletAccountCommitJournal, + marker: WalletMigrationRecoveryMarker + ) throws -> WalletAccountCommitJournal { + try recoveryGate.requireAuthorizedLifecycleContinuation() + Self.lock.lock() + defer { Self.lock.unlock() } + let journals = try loadUnlocked() + guard journals.count == 1, Self.journalsMatch(journals[0], journal), + journal.expectedExistingWalletIds.isEmpty, + journal.recoveryMarker == nil || journal.recoveryMarker == marker + else { throw WalletNetworkMigrationError.snapshotVerificationFailed } + if journal.recoveryMarker == marker { return journals[0] } + var bound = journals[0] + bound.recoveryMarker = marker + bound.updatedAt = Date() + try writeUnlocked(bound) + return bound + } + func unresolved() throws -> [WalletAccountCommitJournal] { Self.lock.lock() defer { Self.lock.unlock() } @@ -2047,6 +2092,7 @@ final class WalletAccountCommitJournalStore { lhs.expectedExistingWalletIds == rhs.expectedExistingWalletIds && lhs.stage == rhs.stage && + lhs.recoveryMarker == rhs.recoveryMarker && Int64(lhs.createdAt.timeIntervalSince1970) == Int64(rhs.createdAt.timeIntervalSince1970) && Int64(lhs.updatedAt.timeIntervalSince1970) == @@ -3184,18 +3230,21 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable { private let stateLock = NSLock() private var didVerifyMigrationNamespace = false private let migrationRecoveryMarker: WalletMigrationRecoveryMarker? + private let legacyAccountRecoveryMarker: WalletMigrationRecoveryMarker? init( settings: SettingsManagerProtocol, unresolvedMigrationJournal: @escaping () -> Bool, unresolvedWalletCommitJournal: @escaping () throws -> Bool, - migrationRecoveryMarker: WalletMigrationRecoveryMarker? = nil + migrationRecoveryMarker: WalletMigrationRecoveryMarker? = nil, + legacyAccountRecoveryMarker: WalletMigrationRecoveryMarker? = nil ) { self.settings = settings self.unresolvedMigrationJournal = unresolvedMigrationJournal self.unresolvedWalletCommitJournal = unresolvedWalletCommitJournal self.migrationRecoveryMarker = migrationRecoveryMarker + self.legacyAccountRecoveryMarker = legacyAccountRecoveryMarker } func requireMutableWalletAccess() throws { @@ -3240,6 +3289,10 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable { /// own expected in-flight commit journal, but a sticky recovery marker /// still aborts the next write phase. func requireAuthorizedLifecycleContinuation() throws { + if let legacyAccountRecoveryMarker { + try legacyAccountRecoveryMarker.requireUnchangedForLegacyAccountRecovery(settings) + return + } if let migrationRecoveryMarker { // A private startup verifier may read/prove the exact interrupted // attempt while its marker continues to block all ordinary gates. @@ -3251,6 +3304,13 @@ final class WalletRecoveryCapabilityGate: @unchecked Sendable { } } + func requireLegacyAccountRecoveryVerification(pending: Bool = false) throws { + guard let legacyAccountRecoveryMarker, + !pending || legacyAccountRecoveryMarker.required + else { throw WalletNetworkMigrationError.walletRecoveryRequired } + try legacyAccountRecoveryMarker.requireUnchangedForLegacyAccountRecovery(settings) + } + /// A terminal journal write may have reached durable storage even when /// its verification read reports an error. Latch recovery immediately; /// a later successful read is not proof that the multi-store commit was @@ -3796,6 +3856,12 @@ enum NexusKeyDerivation { /// a small atomic pointer write performed only after decoding and equality /// checks pass, so an interrupted upgrade continues to use the old snapshot. final class WalletNetworkStore { + struct LegacyFirstSnapshotEvidence { + fileprivate let fileName: String + fileprivate let data: Data + let snapshot: WalletNetworkSnapshot + } + private struct ActivePointer: Codable { let schemaVersion: Int let fileName: String @@ -3867,6 +3933,67 @@ final class WalletNetworkStore { return try loadUnlocked() } + /// Only the private legacy-account verifier may inspect a first snapshot + /// whose durable write completed before its initial active pointer. + func loadForLegacyFirstActivationRecovery() throws + -> (active: WalletNetworkSnapshot?, staged: LegacyFirstSnapshotEvidence?) { + try recoveryGate.requireLegacyAccountRecoveryVerification() + Self.lock.lock() + defer { Self.lock.unlock() } + let namespace = try validatedNamespaceUnlocked() + if namespace.pointerURL != nil || namespace.snapshotURLs.isEmpty { + return (try loadUnlocked(), nil) + } + guard namespace.snapshotURLs.count == 1, let url = namespace.snapshotURLs.first else { + throw WalletNetworkMigrationError.snapshotVerificationFailed + } + let data = try readBoundedData(at: url, maximumBytes: Self.maximumSnapshotBytes) + let snapshot = try decoder.decode(WalletNetworkSnapshot.self, from: data) + try validate(snapshot) + return (nil, LegacyFirstSnapshotEvidence(fileName: url.lastPathComponent, data: data, snapshot: snapshot)) + } + + /// The caller holds the startup lease and exact bound journal/marker CAS. + /// Publish only a pointer to the already retained, independently proven bytes. + func activateVerifiedLegacyFirstSnapshot( + _ evidence: LegacyFirstSnapshotEvidence, + expected: WalletNetworkSnapshot + ) throws { + try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true) + Self.lock.lock() + defer { Self.lock.unlock() } + let namespace = try validatedNamespaceUnlocked() + guard namespace.pointerURL == nil, namespace.snapshotURLs.count == 1, + let snapshotURL = namespace.snapshotURLs.first, + snapshotURL.lastPathComponent == evidence.fileName, + try readBoundedData(at: snapshotURL, maximumBytes: Self.maximumSnapshotBytes) == evidence.data, + evidence.snapshot.schemaVersion == expected.schemaVersion, + evidence.snapshot.selectedWalletId == expected.selectedWalletId, + evidence.snapshot.wallets == expected.wallets, + evidence.snapshot.accounts == expected.accounts + else { throw WalletNetworkMigrationError.snapshotVerificationFailed } + try validate(expected) + try verifyTopologyAdmission(current: nil, proposed: evidence.snapshot) + let pointer = ActivePointer(schemaVersion: WalletNetworkSnapshot.currentSchemaVersion, + fileName: evidence.fileName, sha256: Self.sha256(evidence.data)) + let pointerData = try encoder.encode(pointer) + guard pointerData.count <= Self.maximumPointerBytes else { + throw WalletNetworkMigrationError.snapshotVerificationFailed + } + try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true) + // An error after atomic publication retains the pointer and snapshot; + // the next restart verifies that active state through the ordinary path. + try DurableFileWriter.write(pointerData, to: directoryURL.appendingPathComponent("active.json"), + fileManager: fileManager, protection: .completeUntilFirstUserAuthentication) + let activatedNamespace = try validatedNamespaceUnlocked() + guard activatedNamespace.pointerURL != nil, activatedNamespace.snapshotURLs.count == 1, + activatedNamespace.snapshotURLs.first?.lastPathComponent == evidence.fileName, + try readBoundedData(at: snapshotURL, maximumBytes: Self.maximumSnapshotBytes) == evidence.data, + let activated = try loadUnlocked(), try snapshotsMatch(activated, evidence.snapshot) + else { throw WalletNetworkMigrationError.snapshotVerificationFailed } + try recoveryGate.requireLegacyAccountRecoveryVerification(pending: true) + } + func stageAndActivate(_ snapshot: WalletNetworkSnapshot) throws { try recoveryGate .requireAuthorizedLifecycleContinuation() @@ -4930,21 +5057,46 @@ final class WalletNetworkModelMigrator { selectedAddress: String?, lifecycleLease: WalletLifecycleLease? = nil ) throws { - try lifecycleCoordinator.withExclusiveAccess( + _ = try lifecycleCoordinator.withExclusiveAccess( using: lifecycleLease ) { try migrateLocked( accounts: accounts, - selectedAddress: selectedAddress + selectedAddress: selectedAddress, + current: try store.load(), + activate: true ) } } + /// Runs the same identity and child-key proofs without publishing a snapshot or settings. + func verifiedSnapshot( + accounts: [AccountItem], + selectedAddress: String?, + lifecycleLease: WalletLifecycleLease? = nil + ) throws -> WalletNetworkSnapshot { + try lifecycleCoordinator.withExclusiveAccess(using: lifecycleLease) { + try migrateLocked(accounts: accounts, selectedAddress: selectedAddress, + current: store.load(), activate: false) + } + } + + /// Independently derives the first snapshot while an orphan remains intact. + /// This entry cannot activate state and is restricted to the startup verifier. + func verifiedFirstLegacySnapshot(accounts: [AccountItem], selectedAddress: String) throws + -> WalletNetworkSnapshot { + try recoveryGate.requireLegacyAccountRecoveryVerification() + return try lifecycleCoordinator.withExclusiveAccess { + try migrateLocked(accounts: accounts, selectedAddress: selectedAddress, current: nil, activate: false) + } + } + private func migrateLocked( accounts: [AccountItem], - selectedAddress: String? - ) throws { - let current = try store.load() + selectedAddress: String?, + current: WalletNetworkSnapshot?, + activate: Bool + ) throws -> WalletNetworkSnapshot { if let current { guard current.schemaVersion == @@ -5201,17 +5353,19 @@ final class WalletNetworkModelMigrator { ) } + guard activate else { return snapshot } if let current, current.schemaVersion == snapshot.schemaVersion, current.selectedWalletId == snapshot.selectedWalletId, current.wallets == snapshot.wallets, current.accounts == snapshot.accounts { settings.walletNetworkStoreVersion = WalletNetworkSnapshot.currentSchemaVersion - return + return snapshot } try store.stageAndActivate(snapshot) settings.walletNetworkStoreVersion = WalletNetworkSnapshot.currentSchemaVersion + return snapshot } private static func wipeSensitive(_ value: inout Data?) { diff --git a/SoraPassport/ModulesRedesign/Root/RootInteractor.swift b/SoraPassport/ModulesRedesign/Root/RootInteractor.swift index 642c90c0..6613c327 100644 --- a/SoraPassport/ModulesRedesign/Root/RootInteractor.swift +++ b/SoraPassport/ModulesRedesign/Root/RootInteractor.swift @@ -29,6 +29,8 @@ // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import Foundation +import CoreData +import RobinHood import CryptoKit import SoraKeystore import IrohaCrypto @@ -283,6 +285,287 @@ enum LegacyWalletUpgradeSecretRetention { } } +/// Resumes only the first account activation backed by the original unsuffixed legacy keys. +/// It never grants unfinished ordinary imports permission to invent or replace wallet secrets. +enum LegacyWalletAccountCommitRecovery { + enum Checkpoint { + case proofVerified, journalBound, coreDataCommitted, networkModelActivated + case selectionCommitted, activated, beforeRecoveryClear + } + + @discardableResult + static func recoverIfNeeded( + storeURL: URL, + modelDirectory: String, + keystore: KeystoreProtocol, + settings: SettingsManagerProtocol, + baseURL: URL? = nil, + lifecycleCoordinator: WalletLifecycleCoordinator = .shared, + recoveryGate: WalletRecoveryCapabilityGate = .shared, + checkpoint: (Checkpoint) throws -> Void = { _ in } + ) throws -> Bool { + let lease = lifecycleCoordinator.acquire() + defer { lease.release() } + var marker = WalletMigrationRecoveryMarker.capture(settings) + var gate = verificationGate(settings: settings, marker: marker) + var journalStore = try WalletAccountCommitJournalStore(baseURL: baseURL, recoveryGate: gate) + let journals = try journalStore.journalsForLegacyRecovery() + let unresolved = journals.filter { $0.stage != .activated } + let candidates = unresolved.isEmpty && marker.required + ? journals.filter { $0.recoveryMarker == marker } : unresolved + guard !candidates.isEmpty else { return false } + guard journals.count == 1, candidates.count == 1, + candidates[0].expectedExistingWalletIds.isEmpty, + !WalletRecoveryMigrationJournalProbe.hasUnresolvedMigration(storeURL: storeURL) + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + var journal = candidates[0] + try marker.requireUnchangedForLegacyAccountRecovery(settings) + if let bound = journal.recoveryMarker { + guard bound == marker else { throw WalletNetworkMigrationError.walletRecoveryRequired } + } + + let originalIdentifiers = Set(try keystore.allKeyIdentifiers()) + var entropy = try keystore.fetchKey(for: KeystoreTag.legacyEntropy.rawValue) + defer { entropy.resetBytes(in: entropy.startIndex ..< entropy.endIndex) } + let entropyDigest = Data(SHA256.hash(data: entropy)) + let displayName = try LegacyWalletUpgradeDisplayNameResolver.resolve(settings: settings, keystore: keystore) + let mnemonic = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: entropy) + guard !entropy.isEmpty, + WalletMnemonicWordPolicy.retainedSoraWordCounts.contains(mnemonic.allWords().count), + try LegacyWalletUpgradePolicy.isCandidate(keystore: keystore, + hasWatchOnlyWallet: settings.hasRetainedWatchOnlyWallet(), snapshot: nil) + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + let operation = AccountOperationFactory(keystore: keystore, recoveryGate: gate) + .prepareAccountOperation(request: AccountCreationRequest(username: displayName, + type: .sora, derivationPath: "", cryptoType: .sr25519), mnemonic: mnemonic) + operation.start() + let prepared = try operation.extractNoCancellableResultData() + defer { prepared.discard() } + let expected = prepared.account + try LegacyWalletUpgradeSecretRetention.consumeWithoutPersisting(prepared, keystore: keystore, + settings: settings, expectedEntropyDigest: entropyDigest, expectedDisplayName: displayName, + recoveryGate: gate) + guard expected.address == journal.walletId else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + let model = try accountModel(modelDirectory: modelDirectory) + var networkStore = try WalletNetworkStore(baseURL: baseURL, recoveryGate: gate) + + func prove(requireAccount: Bool = false, requireSnapshot: Bool = false) throws { + try marker.requireUnchangedForLegacyAccountRecovery(settings) + try journalStore.requireCurrentForLegacyRecovery(journal) + guard Set(try keystore.allKeyIdentifiers()) == originalIdentifiers, + !settings.hasRetainedWatchOnlyWallet(), + try LegacyWalletUpgradeDisplayNameResolver.resolve(settings: settings, keystore: keystore) == displayName + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + var retained = try keystore.fetchKey(for: KeystoreTag.legacyEntropy.rawValue) + defer { retained.resetBytes(in: retained.startIndex ..< retained.endIndex) } + guard Data(SHA256.hash(data: retained)) == entropyDigest else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + try keystore.verifyLegacyIrohaKeyIfPresent(entropy: retained) + try LegacySoraIdentityValidator.validate(address: expected.address, publicKey: expected.publicKeyData, + cryptoType: expected.cryptoType, networkType: expected.networkType, derivationPath: nil, + entropy: retained, rawSeed: nil, secret: nil, recoveryGate: gate) + let accounts = try readAccounts(storeURL: storeURL, model: model) + let stageRequiresAccount = [.coreDataCommitted, .networkModelActivated, .activated].contains(journal.stage) + guard accounts.isEmpty || accounts == [expected], + !(requireAccount || stageRequiresAccount) || accounts == [expected] + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + let networkState = try networkStore.loadForLegacyFirstActivationRecovery() + let snapshot = networkState.active ?? networkState.staged?.snapshot + let stageRequiresSnapshot = [.networkModelActivated, .activated].contains(journal.stage) + guard !(requireSnapshot || stageRequiresSnapshot) || networkState.active != nil, + snapshot == nil || accounts == [expected] + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + let verifier = WalletNetworkModelMigrator(keystore: keystore, store: networkStore, settings: settings, + lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), recoveryGate: gate) + let verified = try networkState.staged != nil + ? verifier.verifiedFirstLegacySnapshot(accounts: [expected], selectedAddress: expected.address) + : verifier.verifiedSnapshot(accounts: [expected], selectedAddress: expected.address) + if let snapshot { + guard snapshot.schemaVersion == verified.schemaVersion, + snapshot.selectedWalletId == verified.selectedWalletId, + snapshot.wallets == verified.wallets, snapshot.accounts == verified.accounts + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + } + let selectionKey = SettingsKey.selectedAccount.rawValue + if settings.allKeys().contains(selectionKey) { + guard accounts == [expected], networkState.active != nil, + settings.value(of: AccountItem.self, for: selectionKey) == expected + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + } else if journal.stage == .activated { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + try marker.requireUnchangedForLegacyAccountRecovery(settings) + try journalStore.requireCurrentForLegacyRecovery(journal) + } + + try prove() + try checkpoint(.proofVerified) + try prove() + // Publish a stable pending marker before resuming any durable stage. Startup's error + // handling then preserves this generation even if this recovery itself is interrupted. + if !marker.required { + try WalletMigrationRecoveryMarker.synchronized { + try marker.requireUnchangedForLegacyAccountRecovery(settings) + settings.setWalletMigrationRecovery(reason: WalletMigrationRecoveryMarker.accountCommitInterruptionReason) + marker = WalletMigrationRecoveryMarker.capture(settings) + guard marker.isLegacyAccountCommitInterruption else { + throw WalletNetworkMigrationError.walletRecoveryRequired + } + } + gate = verificationGate(settings: settings, marker: marker) + journalStore = try WalletAccountCommitJournalStore(baseURL: baseURL, recoveryGate: gate) + networkStore = try WalletNetworkStore(baseURL: baseURL, recoveryGate: gate) + } + journal = try journalStore.bindLegacyRecoveryMarker(journal, marker: marker) + try checkpoint(.journalBound) + try prove() + if journal.stage == .prepared { + journal = try journalStore.advance(journal, to: .secretsPersisted) + } + if journal.stage == .secretsPersisted { + try prove() + if try readAccounts(storeURL: storeURL, model: model).isEmpty { + try insertFirstAccount(expected, storeURL: storeURL, model: model) + } + try checkpoint(.coreDataCommitted) + try prove(requireAccount: true) + journal = try journalStore.advance(journal, to: .coreDataCommitted) + } + if journal.stage == .coreDataCommitted { + try prove(requireAccount: true) + let migrator = WalletNetworkModelMigrator(keystore: keystore, store: networkStore, settings: settings, + lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), recoveryGate: gate) + if let staged = try networkStore.loadForLegacyFirstActivationRecovery().staged { + let verified = try migrator.verifiedFirstLegacySnapshot( + accounts: [expected], selectedAddress: expected.address) + try prove(requireAccount: true) + try journalStore.withCurrentForLegacyRecovery(journal) { + try WalletMigrationRecoveryMarker.synchronized { + try marker.requireUnchangedForLegacyAccountRecovery(settings) + guard journal.recoveryMarker == marker else { + throw WalletNetworkMigrationError.walletRecoveryRequired + } + try networkStore.activateVerifiedLegacyFirstSnapshot(staged, expected: verified) + } + } + } + try migrator.migrate(accounts: [expected], selectedAddress: expected.address) + try checkpoint(.networkModelActivated) + try prove(requireAccount: true, requireSnapshot: true) + journal = try journalStore.advance(journal, to: .networkModelActivated) + } + if journal.stage == .networkModelActivated { + try prove(requireAccount: true, requireSnapshot: true) + if !settings.allKeys().contains(SettingsKey.selectedAccount.rawValue) { + settings.set(value: expected, for: SettingsKey.selectedAccount.rawValue) + } + try checkpoint(.selectionCommitted) + try prove(requireAccount: true, requireSnapshot: true) + guard settings.value(of: AccountItem.self, for: SettingsKey.selectedAccount.rawValue) == expected else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + journal = try journalStore.advance(journal, to: .activated) + try checkpoint(.activated) + } + try prove(requireAccount: true, requireSnapshot: true) + try checkpoint(.beforeRecoveryClear) + try prove(requireAccount: true, requireSnapshot: true) + try marker.clearAfterVerifiedLegacyAccountActivation(settings) + try recoveryGate.requireMutableWalletAccess() + return true + } + + private static func verificationGate(settings: SettingsManagerProtocol, + marker: WalletMigrationRecoveryMarker) -> WalletRecoveryCapabilityGate { + WalletRecoveryCapabilityGate(settings: settings, unresolvedMigrationJournal: { false }, + unresolvedWalletCommitJournal: { false }, legacyAccountRecoveryMarker: marker) + } + + private static func accountModel(modelDirectory: String) throws -> NSManagedObjectModel { + let name = UserStorageVersion.version2.rawValue + guard let url = Bundle.main.url(forResource: name, withExtension: "omo", subdirectory: modelDirectory) + ?? Bundle.main.url(forResource: name, withExtension: "mom", subdirectory: modelDirectory), + let model = NSManagedObjectModel(contentsOf: url) + else { throw UserStorageMigrationError.unavailableModel(name) } + return model + } + + private static func validateStoreFiles(_ url: URL) throws { + let manager = FileManager.default + for path in [url.path, url.path + "-wal", url.path + "-shm", url.path + "-journal"] { + guard manager.fileExists(atPath: path) else { continue } + let attributes = try manager.attributesOfItem(atPath: path) + guard attributes[.type] as? FileAttributeType == .typeRegular, + (attributes[.referenceCount] as? NSNumber)?.intValue == 1 + else { throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed } + } + if !manager.fileExists(atPath: url.path), + ["-wal", "-shm", "-journal"].contains(where: { manager.fileExists(atPath: url.path + $0) }) { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + } + + private static func withContext(storeURL: URL, model: NSManagedObjectModel, writable: Bool, + body: (NSManagedObjectContext) throws -> T) throws -> T { + try validateStoreFiles(storeURL) + if FileManager.default.fileExists(atPath: storeURL.path) { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: storeURL, options: [NSReadOnlyPersistentStoreOption: true]) + guard model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + } + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + var options: [AnyHashable: Any] = [NSMigratePersistentStoresAutomaticallyOption: false, + NSInferMappingModelAutomaticallyOption: false] + if !writable { + options[NSReadOnlyPersistentStoreOption] = true + options[NSSQLitePragmasOption] = ["query_only": "ON"] + } + let store = try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, + at: storeURL, options: options) + defer { try? coordinator.remove(store) } + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + var result: Result! + context.performAndWait { result = Result { try body(context) } } + return try result.get() + } + + private static func accounts(in context: NSManagedObjectContext) throws -> [AccountItem] { + let request = NSFetchRequest(entityName: "CDAccountItem") + request.fetchLimit = 2 + request.returnsObjectsAsFaults = false + return try context.fetch(request).map { entity in + guard (0...255).contains(Int(entity.cryptoType)), (0...255).contains(Int(entity.networkType)) else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + return try AccountItemMapper().transform(entity: entity) + } + } + + private static func readAccounts(storeURL: URL, model: NSManagedObjectModel) throws -> [AccountItem] { + try validateStoreFiles(storeURL) + guard FileManager.default.fileExists(atPath: storeURL.path) else { return [] } + return try withContext(storeURL: storeURL, model: model, writable: false, body: accounts) + } + + private static func insertFirstAccount(_ account: AccountItem, storeURL: URL, + model: NSManagedObjectModel) throws { + try withContext(storeURL: storeURL, model: model, writable: true) { context in + guard try accounts(in: context).isEmpty else { + throw WalletIntegrityError.legacyWalletUpgradeVerificationFailed + } + let entity = CDAccountItem(context: context) + try AccountItemMapper().populate(entity: entity, from: account, using: context) + try context.save() + } + } +} + final class RootInteractor { weak var presenter: RootInteractorOutputProtocol? diff --git a/SoraPassport/ModulesRedesign/SplashScreen/SplashInteractor.swift b/SoraPassport/ModulesRedesign/SplashScreen/SplashInteractor.swift index 7f1fa551..059853aa 100644 --- a/SoraPassport/ModulesRedesign/SplashScreen/SplashInteractor.swift +++ b/SoraPassport/ModulesRedesign/SplashScreen/SplashInteractor.swift @@ -44,8 +44,20 @@ enum WalletStorageStartupOutcome: Equatable { enum WalletStorageStartup { static func run( settings: SettingsManagerProtocol, + accountCommitRecovery: (() throws -> Void)? = nil, migration: () throws -> Void ) -> WalletStorageStartupOutcome { + do { + try accountCommitRecovery?() + } catch { + if !settings.walletMigrationRecoveryRequired { + settings.setWalletMigrationRecovery( + reason: UserStorageMigrationError.privacySafeRecoveryDescription(for: error), + preservingExistingReason: true + ) + } + return .recoveryRequired + } let marker = WalletMigrationRecoveryMarker.capture(settings) guard !marker.required || marker.isDatabaseInterruption else { return .recoveryRequired @@ -201,7 +213,14 @@ final class SplashInteractor: SplashInteractorProtocol { ) //it should not be here, but since we're trying to limit chain sync to the splash screen, we need working settings and have to migrate them because robinhood does not support lightweight migration (yet?) var deferredForLegacyUpgrade = false - let outcome = WalletStorageStartup.run(settings: settings) { + let outcome = WalletStorageStartup.run(settings: settings, accountCommitRecovery: { + try LegacyWalletAccountCommitRecovery.recoverIfNeeded( + storeURL: UserStorageParams.storageURL, + modelDirectory: UserStorageParams.modelDirectory, + keystore: keychain, + settings: self.settings + ) + }) { let unresolvedCommits = try WalletAccountCommitJournalStore().unresolved() guard unresolvedCommits.isEmpty else { diff --git a/SoraPassport/Scripts/collect-ios-migration-evidence.py b/SoraPassport/Scripts/collect-ios-migration-evidence.py index e135faf5..2c5690d2 100644 --- a/SoraPassport/Scripts/collect-ios-migration-evidence.py +++ b/SoraPassport/Scripts/collect-ios-migration-evidence.py @@ -2604,8 +2604,8 @@ def expected_test_identifiers( if not methods or len(methods) != len(set(methods)): fail(f"{suite} source has an invalid test-method inventory") result.update(f"{suite}/{method}()" for method in methods) - if len(result) != 233: - fail("migration test source inventory must contain exactly 233 identifiers") + if len(result) != 235: + fail("migration test source inventory must contain exactly 235 identifiers") return result @@ -2690,7 +2690,7 @@ def inspect_xcresult( exact_keys(tests, {"testPlanConfigurations", "devices", "testNodes"}, "xcresult tests") suite_counts, enumerated_total, observed_tests = count_test_cases(tests["testNodes"]) required_counts = { - "WalletModernizationTests": 207, + "WalletModernizationTests": 209, "WalletRecoveryCapabilityGateTests": 11, "WalletRecoveryExporterTests": 12, "WalletMigrationRetainedDeviceEvidenceTests": 3, diff --git a/SoraPassport/Scripts/test-ios-migration-evidence-collector.py b/SoraPassport/Scripts/test-ios-migration-evidence-collector.py index abc0b527..80367bd1 100644 --- a/SoraPassport/Scripts/test-ios-migration-evidence-collector.py +++ b/SoraPassport/Scripts/test-ios-migration-evidence-collector.py @@ -237,7 +237,7 @@ def test_reviewed_settings_inventory_is_source_derived(self) -> None: def test_point_of_use_source_reads_require_admitted_digest(self) -> None: entries = contract_entry_map() - self.assertEqual(len(COLLECTOR.expected_test_identifiers(entries)), 233) + self.assertEqual(len(COLLECTOR.expected_test_identifiers(entries)), 235) settings = "SoraPassport/Common/Extensions/SettingsExtension.swift" _, byte_count = entries[settings] entries[settings] = ("f" * 64, byte_count) @@ -642,7 +642,7 @@ def test_retained_device_evidence_scheme_and_producers_are_exact(self) -> None: }, ) self.assertEqual( - len(COLLECTOR.expected_test_identifiers(contract_entry_map())), 233 + len(COLLECTOR.expected_test_identifiers(contract_entry_map())), 235 ) def test_collection_job_cannot_promote_or_sign(self) -> None: diff --git a/SoraPassport/Scripts/verify-modernization-dependencies.sh b/SoraPassport/Scripts/verify-modernization-dependencies.sh index d31ceda8..96cd7627 100644 --- a/SoraPassport/Scripts/verify-modernization-dependencies.sh +++ b/SoraPassport/Scripts/verify-modernization-dependencies.sh @@ -5790,11 +5790,11 @@ if [ "${migration_candidate_archive_active}" = "true" ]; then retained_device_evidence_test_count="$( /usr/bin/grep -Ec '^[[:space:]]+func test' "${migration_evidence_tests}" )" - if [ "${modernization_test_count}" != "207" ] || + if [ "${modernization_test_count}" != "209" ] || [ "${recovery_gate_test_count}" != "11" ] || [ "${recovery_export_test_count}" != "12" ] || [ "${retained_device_evidence_test_count}" != "3" ] || - [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 233 ] || + [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 235 ] || ! verify_qualification_contract_unchanged; then echo "error: observed-only candidate archive migration source contract is incomplete or unstable" exit 1 @@ -5987,15 +5987,15 @@ recovery_export_test_count="$( retained_device_evidence_test_count="$( /usr/bin/grep -Ec '^[[:space:]]+func test' "${migration_evidence_tests}" )" -if [ "${modernization_test_count}" != "207" ]; then - echo "error: WalletModernizationTests source must contain exactly 207 test methods" +if [ "${modernization_test_count}" != "209" ]; then + echo "error: WalletModernizationTests source must contain exactly 209 test methods" exit 1 fi if [ "${recovery_gate_test_count}" != "11" ] || [ "${recovery_export_test_count}" != "12" ] || [ "${retained_device_evidence_test_count}" != "3" ] || - [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 233 ]; then - echo "error: retained iOS migration evidence source must contain the exact 233-test inventory" + [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 235 ]; then + echo "error: retained iOS migration evidence source must contain the exact 235-test inventory" exit 1 fi qualified_at_epoch_seconds="$( diff --git a/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift b/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift index 2eab1734..16e00b96 100644 --- a/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift +++ b/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift @@ -8929,6 +8929,339 @@ final class WalletModernizationTests: XCTestCase { XCTAssertFalse(settings.walletMigrationRecoveryRequired) } + func testInterruptedLegacyAccountActivationResumesWithoutChangingKeys() throws { + // The journal may lag a completed durable write. These are separate + // installed states, not exceptions injected into the original process. + let boundaries: [(WalletAccountCommitStage, String, Bool, Bool, Bool)] = [ + (.prepared, "missing", false, false, false), + (.prepared, "empty", false, false, false), + (.secretsPersisted, "empty", false, false, false), + (.secretsPersisted, "account", false, false, false), + (.coreDataCommitted, "account", false, false, false), + (.coreDataCommitted, "account", true, false, true), + (.coreDataCommitted, "account", true, false, false), + (.networkModelActivated, "account", true, false, false), + (.networkModelActivated, "account", true, true, false), + ] + for (entropyBytes, irohaPair) in [(16, false), (20, false), (24, false), + (28, false), (32, false), (20, true)] { + for boundary in boundaries { + for markerKind in ["none", "legacy", "current", "account"] { + try withLegacyActivationFixture(entropyBytes: entropyBytes, + retainsIrohaPrivateKey: irohaPair, stage: boundary.0, + database: boundary.1, networkActive: boundary.2, + selectionPersisted: boundary.3, stagedOnly: boundary.4) { directory, account, keys, settings in + let originals = try Dictionary(uniqueKeysWithValues: keys.allKeyIdentifiers().map { + ($0, try keys.fetchKey(for: $0)) + }) + let retainedSnapshots = boundary.4 ? try legacyActivationNetworkFiles(at: directory) : [:] + let reason = UserStorageMigrationError.privacySafeRecoveryDescription( + for: UserStorageMigrationError.interruptedMigration) + if markerKind == "legacy" { + settings.set(value: true, for: SettingsKey.walletMigrationRecoveryRequired.rawValue) + settings.set(value: reason, for: SettingsKey.walletMigrationRecoveryReason.rawValue) + } else if markerKind == "current" { + settings.setWalletMigrationRecovery(reason: reason) + } else if markerKind == "account" { + settings.setWalletMigrationRecovery(reason: WalletMigrationRecoveryMarker.accountCommitInterruptionReason) + } + try resumeLegacyActivationFixture(at: directory, account: account, + keys: keys, settings: settings) + let firstSnapshot = try WalletNetworkStore(baseURL: directory).load() + let restartedSettings = InMemorySettingsManager() + for key in settings.allKeys() { + restartedSettings.set(anyValue: try XCTUnwrap(settings.anyValue(for: key)), for: key) + } + try resumeLegacyActivationFixture(at: directory, account: account, + keys: keys, settings: restartedSettings) + XCTAssertEqual(try WalletNetworkStore(baseURL: directory).load(), firstSnapshot) + XCTAssertEqual(Set(try keys.allKeyIdentifiers()), Set(originals.keys)) + for (tag, value) in originals { XCTAssertEqual(try keys.fetchKey(for: tag), value) } + for (url, bytes) in retainedSnapshots { XCTAssertEqual(try Data(contentsOf: url), bytes) } + if boundary.4 { + XCTAssertEqual(try legacyActivationNetworkFiles(at: directory).count, retainedSnapshots.count + 1) + } + } + } + } + } + // Stop again during recovery itself, including after terminal journal + // activation but before clearing its bound marker. Each restart reads + // copied durable files and a fresh persisted-settings representation. + try withLegacyActivationFixture(entropyBytes: 20, retainsIrohaPrivateKey: true, + stage: .secretsPersisted, database: "account") { directory, account, keys, settings in + settings.setWalletMigrationRecovery(reason: UserStorageMigrationError.privacySafeRecoveryDescription( + for: UserStorageMigrationError.interruptedMigration)) + var copies: [(URL, InMemorySettingsManager)] = [] + defer { for (url, _) in copies { try? FileManager.default.removeItem(at: url) } } + let originals = try Dictionary(uniqueKeysWithValues: keys.allKeyIdentifiers().map { + ($0, try keys.fetchKey(for: $0)) + }) + let gate = legacyActivationRecoveryGate(at: directory, settings: settings) + _ = try LegacyWalletAccountCommitRecovery.recoverIfNeeded( + storeURL: directory.appendingPathComponent("UserDataModel.sqlite"), + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, settings: settings, + baseURL: directory, lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), + recoveryGate: gate, checkpoint: { _ in + let copy = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.copyItem(at: directory, to: copy) + let copiedSettings = InMemorySettingsManager() + for key in settings.allKeys() { + copiedSettings.set(anyValue: try XCTUnwrap(settings.anyValue(for: key)), for: key) + } + copies.append((copy, copiedSettings)) + }) + XCTAssertEqual(copies.count, 7) + for (copy, copiedSettings) in copies { + try resumeLegacyActivationFixture(at: copy, account: account, keys: keys, settings: copiedSettings) + try resumeLegacyActivationFixture(at: copy, account: account, keys: keys, settings: copiedSettings) + XCTAssertEqual(Set(try keys.allKeyIdentifiers()), Set(originals.keys)) + for (tag, bytes) in originals { XCTAssertEqual(try keys.fetchKey(for: tag), bytes) } + } + } + } + + func testLegacyAccountActivationRecoveryPreservesUnverifiableEvidence() throws { + for condition in ["foreignMarker", "missingKey", "changedEntropy", "changedName", + "changedAccount", "unknownJournal", "extraAccount", "missingCommittedAccount", + "changedStagedSnapshot", "multipleStagedSnapshots", "unknownNetworkEvidence"] { + let stagedOnly = condition.contains("Staged") || condition == "unknownNetworkEvidence" + try withLegacyActivationFixture(stage: .coreDataCommitted, + database: condition == "missingCommittedAccount" ? "empty" : "account", + networkActive: stagedOnly, stagedOnly: stagedOnly) { + directory, account, keys, settings in + let reason = condition == "foreignMarker" ? "Missing retained wallet secret" : + UserStorageMigrationError.privacySafeRecoveryDescription( + for: UserStorageMigrationError.interruptedMigration) + settings.setWalletMigrationRecovery(reason: reason) + if condition == "missingKey" { + try keys.deleteKey(for: KeystoreTag.legacyEntropy.rawValue) + } else if condition == "changedEntropy" { + try keys.saveKey(Data(repeating: 103, count: 16), with: KeystoreTag.legacyEntropy.rawValue) + } else if condition == "changedName" { + try keys.saveKey(Data("Changed retained name".utf8), with: KeystoreTag.legacyUsername.rawValue) + } else if condition == "unknownJournal" { + try Data("Retain this unknown journal evidence".utf8).write(to: directory + .appendingPathComponent("SORA/WalletAccountCommits/unknown-evidence")) + } else if stagedOnly { + let networkDirectory = directory.appendingPathComponent("SORA/WalletNetworks") + let snapshotURL = try XCTUnwrap(legacyActivationNetworkFiles(at: directory).keys.first) + if condition == "changedStagedSnapshot" { + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: snapshotURL)) as? [String: Any]) + var wallets = try XCTUnwrap(json["wallets"] as? [[String: Any]]) + wallets[0]["displayName"] = "Changed staged wallet name" + json["wallets"] = wallets + try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]).write(to: snapshotURL) + } else if condition == "multipleStagedSnapshots" { + try FileManager.default.copyItem(at: snapshotURL, + to: networkDirectory.appendingPathComponent("wallet-network-\(UUID().uuidString).json")) + } else { + try Data("Retain unknown network evidence".utf8).write(to: networkDirectory.appendingPathComponent("unknown-evidence")) + } + } else if condition == "changedAccount" || condition == "extraAccount" { + let url = directory.appendingPathComponent("UserDataModel.sqlite") + let model = try userStorageModel(named: UserStorageVersion.version2.rawValue) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + let store = try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, at: url, options: nil) + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + try context.performAndWait { + let row = try XCTUnwrap(context.fetch(NSFetchRequest(entityName: "CDAccountItem")).first) + if condition == "changedAccount" { + row.setValue("Changed database name", forKey: "username") + } else { + let extra = NSEntityDescription.insertNewObject(forEntityName: "CDAccountItem", into: context) + for name in row.entity.attributesByName.keys { extra.setValue(row.value(forKey: name), forKey: name) } + extra.setValue("unexpected-account", forKey: "identifier") + extra.setValue(false, forKey: "isSelected") + } + try context.save() + } + try coordinator.remove(store) + } + let originals = try Dictionary(uniqueKeysWithValues: keys.allKeyIdentifiers().map { + ($0, try keys.fetchKey(for: $0)) + }) + let marker = WalletMigrationRecoveryMarker.capture(settings) + let storeBytes = try Data(contentsOf: directory.appendingPathComponent("UserDataModel.sqlite")) + let journalDirectory = directory.appendingPathComponent("SORA/WalletAccountCommits") + let journalBytes = try Dictionary(uniqueKeysWithValues: FileManager.default.contentsOfDirectory( + at: journalDirectory, includingPropertiesForKeys: nil).map { ($0, try Data(contentsOf: $0)) }) + let networkBytes = try legacyActivationNetworkFiles(at: directory) + let gate = legacyActivationRecoveryGate(at: directory, settings: settings) + for _ in 0..<2 { + XCTAssertThrowsError(try LegacyWalletAccountCommitRecovery.recoverIfNeeded( + storeURL: directory.appendingPathComponent("UserDataModel.sqlite"), + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, + settings: settings, baseURL: directory, + lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), recoveryGate: gate)) + XCTAssertEqual(WalletMigrationRecoveryMarker.capture(settings), marker) + XCTAssertEqual(try Data(contentsOf: directory.appendingPathComponent("UserDataModel.sqlite")), storeBytes) + for (url, bytes) in journalBytes { XCTAssertEqual(try Data(contentsOf: url), bytes) } + XCTAssertEqual(try legacyActivationNetworkFiles(at: directory), networkBytes) + XCTAssertEqual(Set(try keys.allKeyIdentifiers()), Set(originals.keys)) + for (tag, bytes) in originals { XCTAssertEqual(try keys.fetchKey(for: tag), bytes) } + } + } + } + // A later integrity check repeating the same text is still a new + // recovery generation. The earlier journal must not clear it. + try withLegacyActivationFixture(stage: .coreDataCommitted, database: "account") { + directory, account, keys, settings in + let reason = UserStorageMigrationError.privacySafeRecoveryDescription( + for: UserStorageMigrationError.interruptedMigration) + settings.setWalletMigrationRecovery(reason: reason) + let originalMarker = WalletMigrationRecoveryMarker.capture(settings) + let gate = legacyActivationRecoveryGate(at: directory, settings: settings) + XCTAssertThrowsError(try LegacyWalletAccountCommitRecovery.recoverIfNeeded( + storeURL: directory.appendingPathComponent("UserDataModel.sqlite"), + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, + settings: settings, baseURL: directory, + lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), recoveryGate: gate, + checkpoint: { phase in + if phase == .journalBound { settings.setWalletMigrationRecovery(reason: reason) } + })) + let laterMarker = WalletMigrationRecoveryMarker.capture(settings) + XCTAssertNotEqual(laterMarker, originalMarker) + XCTAssertTrue(laterMarker.required) + XCTAssertThrowsError(try LegacyWalletAccountCommitRecovery.recoverIfNeeded( + storeURL: directory.appendingPathComponent("UserDataModel.sqlite"), + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, + settings: settings, baseURL: directory, + lifecycleCoordinator: WalletLifecycleCoordinator(recoveryGate: gate), recoveryGate: gate)) + XCTAssertEqual(WalletMigrationRecoveryMarker.capture(settings), laterMarker) + try assertStoredAccounts([account], at: directory.appendingPathComponent("UserDataModel.sqlite"), + model: userStorageModel(named: UserStorageVersion.version2.rawValue)) + } + } + + private func withLegacyActivationFixture( + entropyBytes: Int = 16, retainsIrohaPrivateKey: Bool = false, + stage: WalletAccountCommitStage, database: String, + networkActive: Bool = false, selectionPersisted: Bool = false, stagedOnly: Bool = false, + _ body: (URL, AccountItem, InMemoryKeychain, InMemorySettingsManager) throws -> Void + ) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let entropy = Data(repeating: retainsIrohaPrivateKey ? 0 : UInt8(entropyBytes), count: entropyBytes) + let phrase = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: entropy).toString() + let seed = try SeedFactory().deriveSeed(from: phrase, password: "").seed.miniSeed + let pair = try SR25519KeypairFactory().createKeypairFromSeed(seed, chaincodeList: []) + let publicKey = pair.publicKey().rawData() + let address = try SS58AddressFactory().address(fromAccountId: publicKey, type: Chain.sora.addressType()) + let displayName = "Retained legacy wallet" + let account = AccountItem(address: address, cryptoType: .sr25519, + networkType: Chain.sora.addressType(), username: displayName, publicKeyData: publicKey, + settings: AccountSettings(visibleAssetIds: [], orderedAssetIds: []), order: 0, isSelected: true) + let keys = InMemoryKeychain() + try keys.addKey(entropy, with: KeystoreTag.legacyEntropy.rawValue) + try keys.addKey(Data(displayName.utf8), with: KeystoreTag.legacyUsername.rawValue) + if retainsIrohaPrivateKey { + try keys.addKey(try Data(hexStringSSF: + "e6ede78853ee2a5ede2d25f51d624e46270a9cb4d492a95c4742a3ca52f65f84"), with: "privateKey") + } + let settings = InMemorySettingsManager() + let gate = WalletRecoveryCapabilityGate(settings: settings, + unresolvedMigrationJournal: { false }, unresolvedWalletCommitJournal: { false }) + let coordinator = WalletLifecycleCoordinator(recoveryGate: gate) + let journals = try WalletAccountCommitJournalStore(baseURL: directory, recoveryGate: gate) + var journal = try journals.begin(walletId: address, existingWalletIds: []) + if database != "missing" { + // The actual first-account writer persists a settings relationship + // with empty asset arrays; the older-schema fixture helper omits it. + let model = try userStorageModel(named: UserStorageVersion.version2.rawValue) + let databaseCoordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + let persistentStore = try databaseCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, at: directory.appendingPathComponent("UserDataModel.sqlite"), options: nil) + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.persistentStoreCoordinator = databaseCoordinator + try context.performAndWait { + if database == "account" { + try AccountItemMapper().populate(entity: CDAccountItem(context: context), from: account, using: context) + } + try context.save() + } + try databaseCoordinator.remove(persistentStore) + } + if networkActive { + try WalletNetworkModelMigrator(keystore: keys, + store: WalletNetworkStore(baseURL: directory, recoveryGate: gate), settings: settings, + lifecycleCoordinator: coordinator, recoveryGate: gate).migrate(accounts: [account], selectedAddress: address) + if stagedOnly { + // Reproduce the persisted state at afterNetworkStaging: the + // exact immutable snapshot exists, but its pointer was not written. + try FileManager.default.removeItem(at: directory.appendingPathComponent("SORA/WalletNetworks/active.json")) + settings.removeValue(for: SettingsKey.walletNetworkStoreVersion.rawValue) + } + } + if selectionPersisted { settings.set(value: account, for: SettingsKey.selectedAccount.rawValue) } + if stage != .prepared { + for next in [WalletAccountCommitStage.secretsPersisted, .coreDataCommitted, .networkModelActivated, .activated] { + journal = try journals.advance(journal, to: next) + if next == stage { break } + } + } + try body(directory, account, keys, settings) + } + + private func legacyActivationNetworkFiles(at directory: URL) throws -> [URL: Data] { + let networkDirectory = directory.appendingPathComponent("SORA/WalletNetworks") + guard FileManager.default.fileExists(atPath: networkDirectory.path) else { return [:] } + return try Dictionary(uniqueKeysWithValues: FileManager.default.contentsOfDirectory( + at: networkDirectory, includingPropertiesForKeys: nil).map { ($0, try Data(contentsOf: $0)) }) + } + + private func legacyActivationRecoveryGate( + at directory: URL, settings: InMemorySettingsManager + ) -> WalletRecoveryCapabilityGate { + let storeURL = directory.appendingPathComponent("UserDataModel.sqlite") + return WalletRecoveryCapabilityGate(settings: settings, + unresolvedMigrationJournal: { WalletRecoveryMigrationJournalProbe.hasUnresolvedMigration(storeURL: storeURL) }, + unresolvedWalletCommitJournal: { try !WalletAccountCommitJournalStore(baseURL: directory).unresolved().isEmpty }) + } + + private func resumeLegacyActivationFixture( + at directory: URL, account: AccountItem, keys: InMemoryKeychain, settings: InMemorySettingsManager + ) throws { + let storeURL = directory.appendingPathComponent("UserDataModel.sqlite") + let gate = legacyActivationRecoveryGate(at: directory, settings: settings) + let coordinator = WalletLifecycleCoordinator(recoveryGate: gate) + XCTAssertEqual(WalletStorageStartup.run(settings: settings, accountCommitRecovery: { + _ = try LegacyWalletAccountCommitRecovery.recoverIfNeeded(storeURL: storeURL, + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, settings: settings, + baseURL: directory, lifecycleCoordinator: coordinator, recoveryGate: gate) + }) { + let migrator = UserStorageMigrator(targetVersion: .version2, storeURL: storeURL, + modelDirectory: UserStorageParams.modelDirectory, keystore: keys, settings: settings, + fileManager: .default, recoveryGate: gate, availableCapacity: { _ in Int64.max }, + loadWalletNetworkSnapshot: { try WalletNetworkStore(baseURL: directory).load() }) + try migrator.migrateAtStartup(lifecycleCoordinator: coordinator, + hasUnresolvedAccountCommit: { try !WalletAccountCommitJournalStore(baseURL: directory).unresolved().isEmpty }) + }, .ready) + try gate.requireMutableWalletAccess() + try assertStoredAccounts([account], at: storeURL, model: userStorageModel(named: UserStorageVersion.version2.rawValue)) + XCTAssertEqual(settings.value(of: AccountItem.self, for: SettingsKey.selectedAccount.rawValue), account) + let snapshot = try XCTUnwrap(WalletNetworkStore(baseURL: directory).load()) + XCTAssertEqual(snapshot.selectedWalletId, account.address) + XCTAssertEqual(snapshot.wallets.count, 1) + let entropy = try XCTUnwrap(keys.fetchEntropyForAddress(account.address, activeSnapshot: snapshot, recoveryGate: gate)) + let expectedNetworks: Set = [16, 32].contains(entropy.count) + ? NexusNetworkConfiguration.admittedWalletNetworkIds : [.sora2] + XCTAssertEqual(Set(snapshot.accounts.map(\.networkId)), expectedNetworks) + XCTAssertEqual(snapshot.accounts.first { $0.networkId == .sora2 }?.publicKey, account.publicKeyData) + let phrase = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: entropy).toString() + let seed = try SeedFactory().deriveSeed(from: phrase, password: "").seed.miniSeed + let pair = try SR25519KeypairFactory().createKeypairFromSeed(seed, chaincodeList: []) + let publicKey = try SNPublicKey(rawData: account.publicKeyData) + let payload = Data("recovered legacy activation signing check".utf8) + let signature = try SNSigner(keypair: SNKeypair( + privateKey: SNPrivateKey(rawData: pair.privateKey().rawData()), publicKey: publicKey)).sign(payload) + XCTAssertTrue(SNSignatureVerifier().verify(signature, forOriginalData: payload, using: publicKey)) + XCTAssertFalse(settings.walletMigrationRecoveryRequired) + } + func testInterruptedCoreDataMigrationResumesWithLegacyAndCurrentRecoveryMarkers() throws { for version in UserStorageVersion.allCases { try withInterruptedDatabaseFixture(version: version) { root, snapshots, accounts, keys in From ab0f3310fa9339c5716f09663ed53553d8ad3347 Mon Sep 17 00:00:00 2001 From: Makoto Takemiya Date: Mon, 7 Sep 2026 16:48:54 +0900 Subject: [PATCH 2/2] Prepare internal TestFlight build 2026090704 --- .../test-ios-internal-testflight-upload.py | 24 +++++++++---------- .../Scripts/upload-ios-internal-testflight.sh | 10 ++++---- ...verify-ios-internal-testflight-delivery.py | 2 +- .../verify-modernization-dependencies.sh | 16 ++++++------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/SoraPassport/Scripts/test-ios-internal-testflight-upload.py b/SoraPassport/Scripts/test-ios-internal-testflight-upload.py index 2da827b9..4f55e8c6 100644 --- a/SoraPassport/Scripts/test-ios-internal-testflight-upload.py +++ b/SoraPassport/Scripts/test-ios-internal-testflight-upload.py @@ -60,7 +60,7 @@ def capability_environment() -> dict[str, str]: { "SORA_IOS_INTERNAL_TESTFLIGHT_UPLOAD_MODE": "sora-ios-internal-testflight-upload-v1", "SORA_IOS_INTERNAL_TESTFLIGHT_UPLOAD_ACTION": "archive", - "SORA_IOS_INTERNAL_TESTFLIGHT_BUILD_NUMBER": "2026090703", + "SORA_IOS_INTERNAL_TESTFLIGHT_BUILD_NUMBER": "2026090704", "SORA_IOS_INTERNAL_TESTFLIGHT_SOURCE_REVISION": revision, "SORA_IOS_INTERNAL_TESTFLIGHT_EXPORT_OPTIONS_SHA256": hashlib.sha256( EXPORT_OPTIONS.read_bytes() @@ -77,7 +77,7 @@ def capability_environment() -> dict[str, str]: "DEVELOPMENT_TEAM": "YLWWUD25VZ", "CODE_SIGN_IDENTITY": "iPhone Developer", "CODE_SIGN_STYLE": "Automatic", - "CURRENT_PROJECT_VERSION": "2026090703", + "CURRENT_PROJECT_VERSION": "2026090704", "PROVISIONING_PROFILE_SPECIFIER": "", "CODE_SIGN_ENTITLEMENTS": "SoraPassport/SoraPassport.entitlements", "INFOPLIST_FILE": "SoraPassport/Info.plist", @@ -126,9 +126,9 @@ def test_wrapper_binds_source_and_stays_non_authorizing(self) -> None: for marker in ( 'status --porcelain=v1 --untracked-files=normal', "rev-parse '@{upstream}'", - 'reviewed_base_revision="e1c27842f4ee9e286bf9270cd7bd60d0352ed735"', - 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090703"', - 'reviewed_build_number="2026090703"', + 'reviewed_base_revision="e886d7cfacbff119cb9bcb961cfc213882675636"', + 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090704"', + 'reviewed_build_number="2026090704"', 'reviewed_signing_certificate_sha1="84AB95335BE14CAE9B050A353910F86FF2F9539B"', 'reviewed_signing_certificate_sha256="d830d54bce8e583089f2ed8cf927fc12b60c9d591e560ffe6f5d2a71c91317fb"', 'reviewed_archive_signing_certificate_sha1="1F57A04EB10B3665696663CDA0DBD893CF7FE886"', @@ -173,8 +173,8 @@ def test_wrapper_binds_source_and_stays_non_authorizing(self) -> None: for marker in ( 'rev-parse HEAD 2>/dev/null)" != "${internal_testflight_source_revision}"', "rev-parse '@{upstream}' 2>/dev/null", - "origin/codex/ios-wallet-upgrade-testflight-2026090703", - "e1c27842f4ee9e286bf9270cd7bd60d0352ed735", + "origin/codex/ios-wallet-upgrade-testflight-2026090704", + "e886d7cfacbff119cb9bcb961cfc213882675636", "SORA_IOS_INTERNAL_TESTFLIGHT_BUILD_NUMBER", "CURRENT_PROJECT_VERSION", "PROVISIONING_PROFILE_SPECIFIER", @@ -276,7 +276,7 @@ def test_delivery_verifier_binds_exact_success_profile_and_options(self) -> None "ApplicationProperties": { "CFBundleIdentifier": "co.jp.soramitsu.sora", "CFBundleShortVersionString": "3.8.7", - "CFBundleVersion": "2026090703", + "CFBundleVersion": "2026090704", "SigningIdentity": "Apple Development: Makoto Takemiya (6A4BK72ZFV)", "Team": "YLWWUD25VZ", }, @@ -292,7 +292,7 @@ def test_delivery_verifier_binds_exact_success_profile_and_options(self) -> None "task": "distribute", "teamID": "YLWWUD25VZ", "uploadDestination": "App Store", - "uploadedBuildNumber": "2026090703", + "uploadedBuildNumber": "2026090704", "uploadEvent": uploaded, } ], @@ -338,7 +338,7 @@ def test_delivery_verifier_binds_exact_success_profile_and_options(self) -> None xcodebuild_log, reviewed_profile, receipt_path, - "2026090703", + "2026090704", expected_profile_sha256=reviewed_profile_sha256, ) self.assertEqual(delivery_id, "12345678-1234-4234-8234-123456789abc") @@ -355,7 +355,7 @@ def test_delivery_verifier_binds_exact_success_profile_and_options(self) -> None xcodebuild_log, reviewed_profile, root / "rejected.json", - "2026090703", + "2026090704", expected_profile_sha256=reviewed_profile_sha256, ) @@ -452,7 +452,7 @@ def test_wrapper_rejects_unreviewed_build_before_xcode(self) -> None: "--build-number", "2026081602", "--app-store-build-lower-bound", - "2026090702", + "2026090703", "--derived-data-path", "/private/tmp/never-created-DerivedData", "--archive-path", diff --git a/SoraPassport/Scripts/upload-ios-internal-testflight.sh b/SoraPassport/Scripts/upload-ios-internal-testflight.sh index aff28a6f..98dec1ad 100755 --- a/SoraPassport/Scripts/upload-ios-internal-testflight.sh +++ b/SoraPassport/Scripts/upload-ios-internal-testflight.sh @@ -16,10 +16,10 @@ export_options="${root}/SoraPassport/Configs/ios-internal-testflight-export-opti source_contract_tool="${root}/SoraPassport/Scripts/ios-migration-qualification-contract.py" delivery_verifier="${root}/SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py" mode="sora-ios-internal-testflight-upload-v1" -reviewed_base_revision="e1c27842f4ee9e286bf9270cd7bd60d0352ed735" -reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090703" -reviewed_build_number="2026090703" -reviewed_lower_bound="2026090702" +reviewed_base_revision="e886d7cfacbff119cb9bcb961cfc213882675636" +reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090704" +reviewed_build_number="2026090704" +reviewed_lower_bound="2026090703" reviewed_marketing_version="3.8.7" reviewed_bundle_identifier="co.jp.soramitsu.sora" reviewed_team_id="YLWWUD25VZ" @@ -172,7 +172,7 @@ upstream_revision="$(/usr/bin/git -C "${root}" rev-parse '@{upstream}' 2>/dev/nu upstream_name="$(/usr/bin/git -C "${root}" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || fail "internal TestFlight upstream name cannot be resolved" [ "${upstream_name}" = "${reviewed_upstream}" ] || - fail "internal TestFlight source must be pushed to origin/codex/ios-wallet-upgrade-testflight-2026090703" + fail "internal TestFlight source must be pushed to origin/codex/ios-wallet-upgrade-testflight-2026090704" parent_revision="$(/usr/bin/git -C "${root}" rev-parse HEAD^ 2>/dev/null)" || fail "internal TestFlight source parent cannot be resolved" [ "${parent_revision}" = "${reviewed_base_revision}" ] || diff --git a/SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py b/SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py index bbc23917..5dbafc87 100644 --- a/SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py +++ b/SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py @@ -19,7 +19,7 @@ SCOPE = "sora-ios-xcode-apple-upload-receipt-v1" -BUILD_NUMBER = "2026090703" +BUILD_NUMBER = "2026090704" MARKETING_VERSION = "3.8.7" BUNDLE_IDENTIFIER = "co.jp.soramitsu.sora" TEAM_ID = "YLWWUD25VZ" diff --git a/SoraPassport/Scripts/verify-modernization-dependencies.sh b/SoraPassport/Scripts/verify-modernization-dependencies.sh index 96cd7627..832ac80a 100644 --- a/SoraPassport/Scripts/verify-modernization-dependencies.sh +++ b/SoraPassport/Scripts/verify-modernization-dependencies.sh @@ -711,7 +711,7 @@ if [ -n "${internal_testflight_mode}" ]; then [ "${CODE_SIGN_STYLE:-}" != "Automatic" ] || [ "${CODE_SIGN_IDENTITY:-}" != "iPhone Developer" ] || [ -n "${PROVISIONING_PROFILE_SPECIFIER:-}" ] || - [ "${internal_testflight_build_number}" != "2026090703" ] || + [ "${internal_testflight_build_number}" != "2026090704" ] || [ "${CURRENT_PROJECT_VERSION:-}" != "${internal_testflight_build_number}" ] || [ "${CODE_SIGN_ENTITLEMENTS:-}" != "SoraPassport/SoraPassport.entitlements" ] || [ "${INFOPLIST_FILE:-}" != "SoraPassport/Info.plist" ] || @@ -747,10 +747,10 @@ if [ -n "${internal_testflight_mode}" ]; then if [ ! -x /usr/bin/git ] || [ "$(/usr/bin/git -C "${root}" rev-parse HEAD 2>/dev/null)" != "${internal_testflight_source_revision}" ] || [ "$(/usr/bin/git -C "${root}" rev-parse '@{upstream}' 2>/dev/null)" != "${internal_testflight_source_revision}" ] || - [ "$(/usr/bin/git -C "${root}" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" != "origin/codex/ios-wallet-upgrade-testflight-2026090703" ] || - [ "$(/usr/bin/git -C "${root}" rev-parse HEAD^ 2>/dev/null)" != "e1c27842f4ee9e286bf9270cd7bd60d0352ed735" ] || - [ "$(/usr/bin/git -C "${root}" rev-list --count "e1c27842f4ee9e286bf9270cd7bd60d0352ed735..${internal_testflight_source_revision}" 2>/dev/null)" != "1" ] || - [ "$(/usr/bin/git -C "${root}" diff --name-only --no-renames "e1c27842f4ee9e286bf9270cd7bd60d0352ed735..${internal_testflight_source_revision}" 2>/dev/null)" != 'SoraPassport/Scripts/test-ios-internal-testflight-upload.py + [ "$(/usr/bin/git -C "${root}" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" != "origin/codex/ios-wallet-upgrade-testflight-2026090704" ] || + [ "$(/usr/bin/git -C "${root}" rev-parse HEAD^ 2>/dev/null)" != "e886d7cfacbff119cb9bcb961cfc213882675636" ] || + [ "$(/usr/bin/git -C "${root}" rev-list --count "e886d7cfacbff119cb9bcb961cfc213882675636..${internal_testflight_source_revision}" 2>/dev/null)" != "1" ] || + [ "$(/usr/bin/git -C "${root}" diff --name-only --no-renames "e886d7cfacbff119cb9bcb961cfc213882675636..${internal_testflight_source_revision}" 2>/dev/null)" != 'SoraPassport/Scripts/test-ios-internal-testflight-upload.py SoraPassport/Scripts/upload-ios-internal-testflight.sh SoraPassport/Scripts/verify-ios-internal-testflight-delivery.py SoraPassport/Scripts/verify-modernization-dependencies.sh' ] || @@ -2390,9 +2390,9 @@ if ! /usr/bin/grep -Fq 'exec /usr/bin/python3 -I -S "${validator}" "$@"' "${migr /usr/bin/grep -Fq 'signingCertificate' "${internal_testflight_export_options}" || /usr/bin/grep -Fq 'provisioningProfiles' "${internal_testflight_export_options}" || ! /usr/bin/grep -Fq 'rev-parse '\''@{upstream}'\''' "${internal_testflight_uploader}" || - ! /usr/bin/grep -Fq 'reviewed_base_revision="e1c27842f4ee9e286bf9270cd7bd60d0352ed735"' "${internal_testflight_uploader}" || - ! /usr/bin/grep -Fq 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090703"' "${internal_testflight_uploader}" || - ! /usr/bin/grep -Fq 'reviewed_build_number="2026090703"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_base_revision="e886d7cfacbff119cb9bcb961cfc213882675636"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026090704"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_build_number="2026090704"' "${internal_testflight_uploader}" || ! /usr/bin/grep -Fq -- '--verify-app-runtime-closure "${archived_app}"' "${internal_testflight_uploader}" || ! /usr/bin/grep -Fq 'verify_app_runtime_dependency_closure' "${internal_testflight_delivery_verifier}" || ! /usr/bin/grep -Fq 'test_runtime_dependency_closure_rejects_missing_framework' "${internal_testflight_harness}" ||