From ec3c07f2c6ffaddbfff8c2983a63214718c519a8 Mon Sep 17 00:00:00 2001 From: Makoto Takemiya Date: Mon, 14 Sep 2026 00:18:34 +0900 Subject: [PATCH 1/2] Preserve cloud recovery copies and verify every backup save --- .../Common/Migration/StorageMigrator.swift | 2 +- .../Storage/SelectedWalletSettings.swift | 237 ++++++++- .../SetupPasswordPresenter.swift | 219 ++++---- .../SetupPasswordViewFactory.swift | 3 +- .../Scripts/collect-ios-migration-evidence.py | 6 +- .../test-ios-migration-evidence-collector.py | 4 +- .../verify-modernization-dependencies.sh | 12 +- .../WalletModernizationTests.swift | 472 +++++++++++++++++- 8 files changed, 836 insertions(+), 119 deletions(-) diff --git a/SoraPassport/Common/Migration/StorageMigrator.swift b/SoraPassport/Common/Migration/StorageMigrator.swift index f524f89e9..8da4987d7 100644 --- a/SoraPassport/Common/Migration/StorageMigrator.swift +++ b/SoraPassport/Common/Migration/StorageMigrator.swift @@ -511,7 +511,7 @@ final class UserStorageMigrator { throw UserStorageMigrationError.accountInventoryMismatch } if let secret { - try WalletCloudBackupRecoveryService.validateSecretEncoding(secret, cryptoType: account.cryptoType) + try WalletCloudBackupRecoveryService.validateSecretEncoding(secret, cryptoType: account.cryptoType, publicKey: account.publicKeyData) } let path = derivationPath.isEmpty ? nil : derivationPath try LegacySoraIdentityValidator.validate(address: account.address, diff --git a/SoraPassport/Common/Storage/SelectedWalletSettings.swift b/SoraPassport/Common/Storage/SelectedWalletSettings.swift index c00ffe229..51f495b96 100644 --- a/SoraPassport/Common/Storage/SelectedWalletSettings.swift +++ b/SoraPassport/Common/Storage/SelectedWalletSettings.swift @@ -1016,6 +1016,226 @@ import SSFUtils import TweetNacl import UIKit +enum WalletCloudBackupWriteError: Error { + case invalidBackup, ambiguous, preservationFailed, verificationFailed, busy + + static func userMessage(for error: Error) -> String { + switch error as? Self { + case .invalidBackup: + return "The wallet backup could not be prepared safely. Existing backups were kept." + case .ambiguous: + return "More than one existing backup matches this wallet. Existing backups were kept." + case .preservationFailed: + return "Google Drive could not preserve the previous backup revision. It was not replaced. Try again later." + case .verificationFailed: + return "The new backup could not be verified. Previous backup revisions were kept. Try again." + case .busy: + return "A backup is already being saved. Wait for it to finish." + default: + return "The backup could not be saved and verified. Existing backups were not deleted. Try again." + } + } +} + +/// Keeps the library's backup encoder and authentication flow, while replacing +/// its final create request with a verified create or preserved-revision update. +final class WalletBackupPreservingGoogleService: GoogleService { + private let base: GoogleService + private let lock = NSLock() + private var saving = false + private static let fileFields = "id,name,size,mimeType,trashed,headRevisionId,version" + + init(base: GoogleService = BaseGoogleService(googleService: GTLRDriveService())) { + self.base = base + } + + func set(authorizer: GTMFetcherAuthorizationProtocol?) { base.set(authorizer: authorizer) } + + func executeQuery(_ query: GTLRQueryProtocol) async throws -> (ticket: GoogleServiceTicket, file: Any?) { + if let list = query as? GTLRDriveQuery_FilesList, + list.spaces == "appDataFolder", list.q == "name = 'backupFolder'" { + let result = try await boundedList("name = 'backupFolder' and mimeType = 'application/vnd.google-apps.folder' and trashed = false") + guard result.files.count <= 1 else { throw WalletCloudBackupWriteError.ambiguous } + for folder in result.files { + guard folder.name == "backupFolder", folder.mimeType == "application/vnd.google-apps.folder", + folder.trashed?.boolValue != true, validID(folder.identifier) else { + throw WalletCloudBackupWriteError.invalidBackup + } + } + let files = GTLRDrive_FileList(); files.files = result.files + return (result.ticket, files) + } + guard let create = query as? GTLRDriveQuery_FilesCreate, + let metadata = create.bodyObject as? GTLRDrive_File else { + throw WalletCloudBackupWriteError.invalidBackup + } + if create.uploadParameters == nil { + guard metadata.name == "backupFolder", metadata.mimeType == "application/vnd.google-apps.folder", + metadata.parents == ["appDataFolder"] else { throw WalletCloudBackupWriteError.invalidBackup } + let result = try await base.executeQuery(create) + guard let folder = result.file as? GTLRDrive_File, validID(folder.identifier) else { + throw WalletCloudBackupWriteError.verificationFailed + } + return result + } + try beginSaving() + defer { endSaving() } + return try await save(create, metadata: metadata) + } + + private func beginSaving() throws { + lock.lock(); defer { lock.unlock() } + guard !saving else { throw WalletCloudBackupWriteError.busy } + saving = true + } + + private func endSaving() { + lock.lock(); defer { lock.unlock() } + saving = false + } + + private func validID(_ value: String?) -> Bool { + guard let value else { return false } + return !value.isEmpty && value.utf8.count <= 1024 && + !value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + + private func boundedList(_ filter: String) async throws -> (ticket: GoogleServiceTicket, files: [GTLRDrive_File]) { + var files: [GTLRDrive_File] = [] + var token: String? + var tokens = Set() + for page in 0..<4 { + try Task.checkCancellation() + let query = GTLRDriveQuery_FilesList.query() + query.spaces = "appDataFolder"; query.q = filter + query.fields = "nextPageToken,incompleteSearch,files(\(Self.fileFields))" + query.pageSize = 100; query.pageToken = token + let result = try await base.executeQuery(query) + guard let list = result.file as? GTLRDrive_FileList, + list.incompleteSearch?.boolValue != true, (list.files?.count ?? 0) <= 100 else { + throw WalletCloudBackupWriteError.invalidBackup + } + files.append(contentsOf: list.files ?? []) + guard files.count <= 1 else { throw WalletCloudBackupWriteError.ambiguous } + token = list.nextPageToken + if token == nil || token == "" { return (result.ticket, files) } + guard page < 3, validID(token), tokens.insert(token!).inserted else { + throw WalletCloudBackupWriteError.invalidBackup + } + } + throw WalletCloudBackupWriteError.invalidBackup + } + + private func validateFile(_ file: GTLRDrive_File, name: String, identifier: String? = nil) throws { + guard validID(file.identifier), identifier == nil || file.identifier == identifier, + file.name == name, file.trashed?.boolValue != true, + file.mimeType == "application/json", validID(file.headRevisionId), + let size = file.size?.int64Value, size > 0, + size <= Int64(WalletCloudBackupRecoveryService.maximumBackupBytes), + let version = file.version?.int64Value, version > 0 else { + throw WalletCloudBackupWriteError.verificationFailed + } + } + + private func metadata(_ identifier: String, name: String) async throws -> GTLRDrive_File { + let query = GTLRDriveQuery_FilesGet.query(withFileId: identifier) + query.fields = Self.fileFields + let result = try await base.executeQuery(query) + guard let file = result.file as? GTLRDrive_File else { throw WalletCloudBackupWriteError.verificationFailed } + try validateFile(file, name: name, identifier: identifier) + return file + } + + private func media(_ query: GTLRQueryProtocol) async throws -> Data { + let result = try await base.executeQuery(query) + guard let data = (result.file as? GTLRDataObject)?.data, !data.isEmpty, + data.count <= WalletCloudBackupRecoveryService.maximumBackupBytes else { + throw WalletCloudBackupWriteError.verificationFailed + } + return data + } + + private func verifyPinnedRevision(file: String, revision: String) async throws { + let query = GTLRDriveQuery_RevisionsGet.query(withFileId: file, revisionId: revision) + query.fields = "id,keepForever" + let result = try await base.executeQuery(query) + guard let retained = result.file as? GTLRDrive_Revision, + retained.identifier == revision, retained.keepForever?.boolValue == true else { + throw WalletCloudBackupWriteError.preservationFailed + } + } + + private func save(_ create: GTLRDriveQuery_FilesCreate, metadata submitted: GTLRDrive_File) + async throws -> (ticket: GoogleServiceTicket, file: Any?) { + guard let payload = create.uploadParameters?.data, !payload.isEmpty, + payload.count <= WalletCloudBackupRecoveryService.maximumBackupBytes, + let envelope = try JSONSerialization.jsonObject(with: payload) as? [String: Any], + let address = envelope["address"] as? String, + (32...128).contains(address.count), + address.allSatisfy({ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".contains($0) }), + submitted.name == "\(address).json", + let parents = submitted.parents, parents.count == 1, validID(parents[0]) else { + throw WalletCloudBackupWriteError.invalidBackup + } + let name = "\(address).json" + let matches = try await boundedList("name = '\(name)' and trashed = false") + let result: (ticket: GoogleServiceTicket, file: Any?) + if let old = matches.files.first { + try validateFile(old, name: name) + let identifier = old.identifier! + let original = try await metadata(identifier, name: name) + guard original.headRevisionId == old.headRevisionId, original.version == old.version else { + throw WalletCloudBackupWriteError.preservationFailed + } + let originalData = try await media(GTLRDriveQuery_FilesGet.queryForMedia(withFileId: identifier)) + guard Int64(originalData.count) == original.size?.int64Value else { + throw WalletCloudBackupWriteError.preservationFailed + } + let revision = original.headRevisionId! + do { + let retained = GTLRDrive_Revision(); retained.keepForever = true + let pin = GTLRDriveQuery_RevisionsUpdate.query(withObject: retained, fileId: identifier, revisionId: revision) + pin.fields = "id,keepForever" + _ = try await base.executeQuery(pin) + try await verifyPinnedRevision(file: identifier, revision: revision) + } catch { throw WalletCloudBackupWriteError.preservationFailed } + // Pinning itself can change the file version. Compare only subsequent + // metadata snapshots, while keeping the original head and bytes fixed. + let afterPin = try await metadata(identifier, name: name) + guard afterPin.headRevisionId == revision else { throw WalletCloudBackupWriteError.preservationFailed } + let preservedData = try await media(GTLRDriveQuery_RevisionsGet.queryForMedia(withFileId: identifier, revisionId: revision)) + guard preservedData == originalData else { throw WalletCloudBackupWriteError.preservationFailed } + let beforeWrite = try await metadata(identifier, name: name) + guard beforeWrite.headRevisionId == revision, beforeWrite.version == afterPin.version else { + throw WalletCloudBackupWriteError.preservationFailed + } + let updateMetadata = GTLRDrive_File() + updateMetadata.descriptionProperty = submitted.descriptionProperty + let update = GTLRDriveQuery_FilesUpdate.query(withObject: updateMetadata, + fileId: identifier, uploadParameters: create.uploadParameters) + update.keepRevisionForever = true + update.fields = Self.fileFields + result = try await base.executeQuery(update) + } else { + create.keepRevisionForever = true + create.fields = Self.fileFields + result = try await base.executeQuery(create) + } + guard let saved = result.file as? GTLRDrive_File else { throw WalletCloudBackupWriteError.verificationFailed } + try validateFile(saved, name: name, identifier: matches.files.first?.identifier) + guard saved.size?.int64Value == Int64(payload.count) else { throw WalletCloudBackupWriteError.verificationFailed } + do { try await verifyPinnedRevision(file: saved.identifier!, revision: saved.headRevisionId!) } + catch { throw WalletCloudBackupWriteError.verificationFailed } + let uploaded = try await media(GTLRDriveQuery_FilesGet.queryForMedia(withFileId: saved.identifier!)) + guard uploaded == payload else { throw WalletCloudBackupWriteError.verificationFailed } + let final = try await metadata(saved.identifier!, name: name) + guard final.headRevisionId == saved.headRevisionId, final.size == saved.size else { + throw WalletCloudBackupWriteError.verificationFailed + } + return result + } +} + enum WalletCloudBackupRecoveryError: Error { case notAuthorized, authorizationCanceled, notFound, ambiguous, invalidBackup, incorrectPassword case identityMismatch, unsupportedBackup, unavailable @@ -1282,9 +1502,10 @@ final class WalletCloudBackupRecoveryService { defer { pkcs.resetBytes(in: pkcs.startIndex.. AccountItem? + private var isSavingBackup = false + private var createdAccountForBackup: AccountItem? + private let lifecycleCoordinator: WalletLifecycleCoordinator + private let recoveryGate: WalletRecoveryCapabilityGate init(account: OpenBackupAccount, cloudStorageService: CloudStorageServiceProtocol, @@ -64,7 +69,10 @@ final class SetupPasswordPresenter: SetupPasswordPresenterProtocol { mnemonic: IRMnemonicProtocol? = nil, entryPoint: EntryPoint, keystore: KeystoreProtocol, - completion: (() -> Void)? = nil) { + completion: (() -> Void)? = nil, + currentAccount: @escaping () -> AccountItem? = { SelectedWalletSettings.shared.currentAccount }, + lifecycleCoordinator: WalletLifecycleCoordinator = .shared, + recoveryGate: WalletRecoveryCapabilityGate = .shared) { self.backupAccount = account self.completion = completion self.createAccountRequest = createAccountRequest @@ -73,6 +81,9 @@ final class SetupPasswordPresenter: SetupPasswordPresenterProtocol { self.entryPoint = entryPoint self.keystore = keystore self.cloudStorageService = cloudStorageService + self.currentAccount = currentAccount + self.lifecycleCoordinator = lifecycleCoordinator + self.recoveryGate = recoveryGate } deinit { @@ -85,23 +96,64 @@ final class SetupPasswordPresenter: SetupPasswordPresenterProtocol { } func backupAccount(with password: String) { - if entryPoint == .profile { - guard let account = SelectedWalletSettings.shared.currentAccount else { return } - - updateBackupedAccount(with: account, password: password) - - view?.showLoading() - updateCloudStorage(with: password) - return - } - - if let createAccountRequest = createAccountRequest, let mnemonic = mnemonic { + Task { @MainActor [weak self] in + guard let self, !self.isSavingBackup else { return } + self.isSavingBackup = true self.view?.showLoading() - createAccountService?.createAccount(request: createAccountRequest, mnemonic: mnemonic) { [weak self] result in - guard let self = self, let result = result, case .success(let account) = result else { return } - - self.updateBackupedAccount(with: account, password: password) - self.updateCloudStorage(with: password) + defer { + self.isSavingBackup = false + self.view?.hideLoading() + } + do { + let account: AccountItem + if self.entryPoint == .profile { + guard let current = self.currentAccount() else { + throw WalletCloudBackupWriteError.invalidBackup + } + guard current.address == self.backupAccount.address else { + throw WalletCloudBackupWriteError.invalidBackup + } + account = current + } else if let created = self.createdAccountForBackup { + account = created + } else { + guard let request = self.createAccountRequest, let mnemonic = self.mnemonic, + let creator = self.createAccountService else { + throw WalletCloudBackupWriteError.invalidBackup + } + let result: Result? = await withCheckedContinuation { continuation in + creator.createAccount(request: request, mnemonic: mnemonic) { result in + continuation.resume(returning: result) + } + } + guard let result else { throw WalletCloudBackupWriteError.invalidBackup } + account = try result.get() + self.createdAccountForBackup = account + } + let lease = try await self.lifecycleCoordinator.acquireForMutableWalletAccessAsync() + do { + self.backupAccount = try self.prepareBackup(with: account, password: password) + lease.release() + } catch { + lease.release() + throw error + } + // The production service returns only after preserving the previous + // revision and reading back the exact newly encoded payload. + try await self.cloudStorageService.saveBackup(account: self.backupAccount, password: password) + var addresses = ApplicationConfig.shared.backupedAccountAddresses + if !addresses.contains(self.backupAccount.address) { addresses.append(self.backupAccount.address) } + ApplicationConfig.shared.backupedAccountAddresses = addresses + if let completion = self.completion { + self.view?.controller.dismiss(animated: true, completion: completion) + } else { + self.wireframe?.showSetupPinCode() + } + } catch { + self.wireframe?.present(message: WalletCloudBackupWriteError.userMessage(for: error), + title: "Backup not saved", + closeAction: R.string.localizable.commonOk(preferredLanguages: .currentLocale), + from: self.view) } } } @@ -124,96 +176,61 @@ final class SetupPasswordPresenter: SetupPasswordPresenterProtocol { return SetupPasswordSection(items: [ .setupPassword(item) ]) } - private func handler(_ result: Result) { - switch result { - case .success: - var backupedAccountAddresses = ApplicationConfig.shared.backupedAccountAddresses - backupedAccountAddresses.append(backupAccount.address) - ApplicationConfig.shared.backupedAccountAddresses = backupedAccountAddresses - - if completion != nil { - view?.controller.dismiss(animated: true, completion: completion) - } else { - wireframe?.showSetupPinCode() - } - case .failure(let error): - wireframe?.present(message: nil, - title: error.localizedDescription, - closeAction: R.string.localizable.commonOk(preferredLanguages: .currentLocale), - from: view) + private func prepareBackup(with account: AccountItem, password: String) throws -> OpenBackupAccount { + try recoveryGate.requireAuthorizedLifecycleContinuation() + guard backupAccount.address == account.address || (entryPoint == .onboarding && backupAccount.address.isEmpty) else { + throw WalletCloudBackupWriteError.invalidBackup } - } - - private func updateBackupedAccount(with account: AccountItem, password: String) { - var backupAccountType: [OpenBackupAccount.BackupAccountType] = [] - - if mnemonic != nil { - backupAccountType.append(.passphrase) + if let crypto = backupAccount.cryptoType, !crypto.isEmpty { + guard crypto.lowercased() == account.cryptoType.typeString.lowercased() || + crypto == String(account.cryptoType.rawValue) else { throw WalletCloudBackupWriteError.invalidBackup } } - - let rawSeed = getRawSeed(from: account) - if rawSeed != nil { - backupAccountType.append(.seed) + let path = try keystore.fetchDeriviationForAddress(account.address) ?? "" + if let suppliedPath = backupAccount.substrateDerivationPath, !suppliedPath.isEmpty, suppliedPath != path { + throw WalletCloudBackupWriteError.invalidBackup } - - _ = try? keystore.fetchSecretKeyForAddress(account.address) - - let substrateJson = getJson(from: account, password: password) - if substrateJson != nil { - backupAccountType.append(.json) + var entropy = try keystore.fetchEntropyForAddress(account.address) + var rawSeed = try keystore.fetchSeedForAddress(account.address) + var secret = try keystore.fetchSecretKeyForAddress(account.address) + defer { + let entropyCount = entropy?.count ?? 0, seedCount = rawSeed?.count ?? 0, secretCount = secret?.count ?? 0 + entropy?.resetBytes(in: 0.. String? { - return try? keystore.fetchSeedForAddress(account.address)?.toHex(includePrefix: true) - } - - private func getJson(from account: AccountItem, password: String) -> String? { - guard let exportData = try? KeystoreExportWrapper(keystore: keystore).export(account: account, password: password) else { return nil } - return String(data: exportData, encoding: .utf8) - } + } extension SetupPasswordPresenter: Localizable { diff --git a/SoraPassport/ModulesRedesign/SetupPassword/SetupPasswordViewFactory.swift b/SoraPassport/ModulesRedesign/SetupPassword/SetupPasswordViewFactory.swift index 1388f9c49..17dd58045 100644 --- a/SoraPassport/ModulesRedesign/SetupPassword/SetupPasswordViewFactory.swift +++ b/SoraPassport/ModulesRedesign/SetupPassword/SetupPasswordViewFactory.swift @@ -42,7 +42,8 @@ final class SetupPasswordViewFactory { entryPoint: EntryPoint, completion: (() -> Void)? = nil) -> SetupPasswordViewProtocol? { let view = SetupPasswordViewController() - let cloudStorageService = CloudStorageService(uiDelegate: view) + let cloudStorageService = CloudStorageService(uiDelegate: view, + googleDriveService: WalletBackupPreservingGoogleService()) let viewModel = SetupPasswordPresenter(account: account, cloudStorageService: cloudStorageService, createAccountRequest: createAccountRequest, diff --git a/SoraPassport/Scripts/collect-ios-migration-evidence.py b/SoraPassport/Scripts/collect-ios-migration-evidence.py index 3fcf4e04e..778750636 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) != 258: - fail("migration test source inventory must contain exactly 258 identifiers") + if len(result) != 262: + fail("migration test source inventory must contain exactly 262 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": 232, + "WalletModernizationTests": 236, "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 045d780dd..2c65a1e2e 100644 --- a/SoraPassport/Scripts/test-ios-migration-evidence-collector.py +++ b/SoraPassport/Scripts/test-ios-migration-evidence-collector.py @@ -238,7 +238,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)), 258) + self.assertEqual(len(COLLECTOR.expected_test_identifiers(entries)), 262) settings = "SoraPassport/Common/Extensions/SettingsExtension.swift" _, byte_count = entries[settings] entries[settings] = ("f" * 64, byte_count) @@ -643,7 +643,7 @@ def test_retained_device_evidence_scheme_and_producers_are_exact(self) -> None: }, ) self.assertEqual( - len(COLLECTOR.expected_test_identifiers(contract_entry_map())), 258 + len(COLLECTOR.expected_test_identifiers(contract_entry_map())), 262 ) 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 d77720608..c9db8a4d4 100644 --- a/SoraPassport/Scripts/verify-modernization-dependencies.sh +++ b/SoraPassport/Scripts/verify-modernization-dependencies.sh @@ -5828,11 +5828,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}" != "232" ] || + if [ "${modernization_test_count}" != "236" ] || [ "${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 258 ] || + [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 262 ] || ! verify_qualification_contract_unchanged; then echo "error: observed-only candidate archive migration source contract is incomplete or unstable" exit 1 @@ -6025,15 +6025,15 @@ recovery_export_test_count="$( retained_device_evidence_test_count="$( /usr/bin/grep -Ec '^[[:space:]]+func test' "${migration_evidence_tests}" )" -if [ "${modernization_test_count}" != "232" ]; then - echo "error: WalletModernizationTests source must contain exactly 232 test methods" +if [ "${modernization_test_count}" != "236" ]; then + echo "error: WalletModernizationTests source must contain exactly 236 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 258 ]; then - echo "error: retained iOS migration evidence source must contain the exact 258-test inventory" + [ "$((modernization_test_count + recovery_gate_test_count + recovery_export_test_count + retained_device_evidence_test_count))" -ne 262 ]; then + echo "error: retained iOS migration evidence source must contain the exact 262-test inventory" exit 1 fi qualified_at_epoch_seconds="$( diff --git a/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift b/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift index 16200a3fb..4a5f70eec 100644 --- a/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift +++ b/SoraPassportTests/Common/Modernization/WalletModernizationTests.swift @@ -31,6 +31,158 @@ private final class RecoveryDriveFixture: GoogleService { } } +private final class PreservedBackupDriveFixture: GoogleService { + let name: String + var head = "original" + var version: Int64 = 1 + var revisions: [String: Data] = [:] + var pinned = Set() + var queries: [GTLRQueryProtocol] = [] + var failure: String? + var folderExists = true + var duplicates = false + var afterPinReads = 0 + init(name: String, original: Data?) { + self.name = name + revisions[head] = original + } + func set(authorizer: GTMFetcherAuthorizationProtocol?) {} + func file() -> GTLRDrive_File { + let value = GTLRDrive_File() + value.identifier = "synthetic-wallet-file"; value.name = name + value.mimeType = "application/json"; value.size = NSNumber(value: revisions[head]?.count ?? 0) + value.headRevisionId = head; value.version = NSNumber(value: version) + return value + } + func media(_ data: Data) -> GTLRDataObject { + let object = GTLRDataObject(); object.data = data; return object + } + func fail(_ phase: String) throws { + if failure == phase { throw NSError(domain: "synthetic-cloud-failure", code: 503) } + } + func executeQuery(_ query: GTLRQueryProtocol) async throws -> (ticket: GoogleServiceTicket, file: Any?) { + queries.append(query) + let result: Any + if let list = query as? GTLRDriveQuery_FilesList { + let files = GTLRDrive_FileList() + if list.q?.contains("backupFolder") == true { + let folder = GTLRDrive_File(); folder.identifier = "synthetic-folder" + folder.name = "backupFolder"; folder.mimeType = "application/vnd.google-apps.folder" + files.files = folderExists ? [folder] : [] + } else { + files.files = revisions[head] == nil ? [] : (duplicates ? [file(), file()] : [file()]) + } + result = files + } else if let pin = query as? GTLRDriveQuery_RevisionsUpdate { + try fail("pin") + let revision = try XCTUnwrap(pin.revisionId) + XCTAssertEqual((pin.bodyObject as? GTLRDrive_Revision)?.keepForever?.boolValue, true) + pinned.insert(revision); version += 1 + let value = GTLRDrive_Revision(); value.identifier = revision; value.keepForever = true + result = value + } else if let get = query as? GTLRDriveQuery_RevisionsGet { + let revision = try XCTUnwrap(get.revisionId) + if get.downloadAsDataObjectType == "media" { + let bytes = try XCTUnwrap(revisions[revision]) + result = media(failure == "preserved-media" ? Data("corrupt".utf8) : bytes) + } else { + let value = GTLRDrive_Revision(); value.identifier = revision + value.keepForever = NSNumber(value: pinned.contains(revision) && !(failure == "pin-verification" && revision == "original")) + result = value + } + } else if let get = query as? GTLRDriveQuery_FilesGet { + if get.downloadAsDataObjectType == "media" { + let bytes = try XCTUnwrap(revisions[head]) + result = media(failure == "new-media" && head != "original" ? Data("corrupt".utf8) : bytes) + } else { + if pinned.contains("original") { + afterPinReads += 1 + if failure == "concurrent-version" && afterPinReads == 2 { version += 1 } + } + result = file() + } + } else if let create = query as? GTLRDriveQuery_FilesCreate { + if let upload = create.uploadParameters { + try fail("create") + XCTAssertTrue(create.keepRevisionForever) + head = "created"; version += 1; revisions[head] = try XCTUnwrap(upload.data) + pinned.insert(head); result = file() + } else { + folderExists = true + let folder = GTLRDrive_File(); folder.identifier = "synthetic-folder"; result = folder + } + } else if let update = query as? GTLRDriveQuery_FilesUpdate { + try fail("update") + XCTAssertTrue(pinned.contains(head), "Existing head must be pinned before replacement") + XCTAssertTrue(update.keepRevisionForever) + XCTAssertEqual(update.fileId, "synthetic-wallet-file") + head = "updated-\(version)"; version += 1 + revisions[head] = try XCTUnwrap(update.uploadParameters?.data) + pinned.insert(head) + try fail("lost-update-response") + result = file() + } else { + XCTFail("Unexpected Drive operation, including deletion") + throw WalletCloudBackupWriteError.invalidBackup + } + return (RecoveryDriveTicket(), result) + } +} + +private final class BackupSavingCloudFixture: CloudStorageServiceProtocol { + var isUserAuthorized = true + var saves: [OpenBackupAccount] = [] + var continuation: CheckedContinuation? + var onSave: (() -> Void)? + func signInIfNeeded() async throws -> CloudStorageAccountState { .authorized } + func getBackupAccounts() async throws -> [OpenBackupAccount] { XCTFail("Saving must not depend on an existing backup"); return [] } + func deleteBackup(account: OpenBackupAccount) async throws { XCTFail("Saving must never delete an existing backup") } + func disconnect() { XCTFail("Saving must not disconnect Google") } + func importBackup(account: OpenBackupAccount, password: String) async throws -> OpenBackupAccount { + XCTFail("Saving must not import a wallet"); return account + } + func saveBackup(account: OpenBackupAccount, password: String) async throws { + saves.append(account) + try await withCheckedThrowingContinuation { + continuation = $0 + onSave?() + } + } + func complete(_ result: Result) { + let pending = continuation; continuation = nil + pending?.resume(with: result) + } +} + +private final class BackupSavingViewFixture: UIViewController, SetupPasswordViewProtocol { + var viewModel: SetupPasswordPresenterProtocol? + var shows = 0 + var hides = 0 + var onHide: (() -> Void)? + func showLoading() { XCTAssertTrue(Thread.isMainThread); shows += 1 } + func hideLoading() { XCTAssertTrue(Thread.isMainThread); hides += 1; onHide?() } +} + +private final class BackupAccountCreatorFixture: CreateAccountServiceProtocol { + let account: AccountItem + var calls = 0 + init(account: AccountItem) { self.account = account } + func createAccount(request: AccountCreationRequest, mnemonic: IRMnemonicProtocol, + completion: @escaping (Result?) -> Void) { + calls += 1; completion(.success(account)) + } +} + +private final class BackupSavingWireframeFixture: SetupPasswordWireframeProtocol { + var activityIndicatorWindow: UIWindow? + var successes = 0 + var failures: [String] = [] + func showSetupPinCode() { XCTAssertTrue(Thread.isMainThread); successes += 1 } + func present(message: String?, title: String?, closeAction: String?, from view: ControllerBackedProtocol?) { + XCTAssertTrue(Thread.isMainThread); failures.append(message ?? "") + } +} + /// Keeps synthetic wallet data in memory while using the production parser for /// Security's mixed wallet/framework attribute result, which InMemoryKeychain /// alone cannot represent. @@ -10939,6 +11091,248 @@ final class WalletModernizationTests: XCTestCase { } } + func testCloudBackupCreateAndReadbackRemainBounded() async throws { + let entropy = Data(repeating: 27, count: 16) + let account = try retainedEntropyAccount(entropy: entropy) + let payload = try cloudRecoveryFixture(account: account, entropy: entropy, + format: "phrase", password: "synthetic-backup-password") + func upload() -> GTLRDriveQuery_FilesCreate { + let file = GTLRDrive_File(); file.name = "\(account.address).json" + file.parents = ["synthetic-folder"] + let parameters = GTLRUploadParameters(data: payload, mimeType: "application/json") + parameters.shouldUploadWithSingleRequest = true + return GTLRDriveQuery_FilesCreate.query(withObject: file, uploadParameters: parameters) + } + let drive = PreservedBackupDriveFixture(name: "\(account.address).json", original: nil) + drive.folderExists = false + let writer = WalletBackupPreservingGoogleService(base: drive) + let folderList = GTLRDriveQuery_FilesList.query() + folderList.spaces = "appDataFolder"; folderList.q = "name = 'backupFolder'" + let folders = try await writer.executeQuery(folderList) + XCTAssertEqual((folders.file as? GTLRDrive_FileList)?.files?.count, 0) + let folder = GTLRDrive_File(); folder.name = "backupFolder" + folder.mimeType = "application/vnd.google-apps.folder"; folder.parents = ["appDataFolder"] + _ = try await writer.executeQuery(GTLRDriveQuery_FilesCreate.query(withObject: folder, uploadParameters: nil)) + _ = try await writer.executeQuery(upload()) + XCTAssertEqual(drive.revisions[drive.head], payload) + XCTAssertTrue(drive.pinned.contains(drive.head)) + XCTAssertEqual(drive.queries.filter { ($0 as? GTLRDriveQuery_FilesCreate)?.uploadParameters != nil }.count, 1) + XCTAssertFalse(drive.queries.contains { $0 is GTLRDriveQuery_FilesDelete || $0 is GTLRDriveQuery_FilesUpdate }) + let list = try XCTUnwrap(drive.queries.compactMap { $0 as? GTLRDriveQuery_FilesList }.last) + XCTAssertEqual(list.q, "name = '\(account.address).json' and trashed = false") + XCTAssertEqual(list.pageSize, 100) + for fault in ["create", "new-media"] { + let failed = PreservedBackupDriveFixture(name: "\(account.address).json", original: nil) + failed.failure = fault + do { _ = try await WalletBackupPreservingGoogleService(base: failed).executeQuery(upload()); XCTFail("Unverified create accepted") } + catch {} + XCTAssertFalse(failed.queries.contains { $0 is GTLRDriveQuery_FilesDelete }) + if fault == "new-media" { + XCTAssertEqual(failed.revisions[failed.head], payload) + XCTAssertTrue(failed.pinned.contains(failed.head), "An uncertain upload must remain recoverable") + } + } + let ambiguous = PreservedBackupDriveFixture(name: "\(account.address).json", original: payload) + ambiguous.duplicates = true + do { _ = try await WalletBackupPreservingGoogleService(base: ambiguous).executeQuery(upload()); XCTFail("Ambiguous backups admitted") } + catch WalletCloudBackupWriteError.ambiguous {} + catch { XCTFail("Ambiguous backup error lost") } + XCTAssertEqual(ambiguous.revisions["original"], payload) + XCTAssertTrue(ambiguous.queries.allSatisfy { $0 is GTLRDriveQuery_FilesList }) + } + + func testCloudBackupReplacementPreservesPriorRevisionAcrossFailures() async throws { + let entropy = Data(repeating: 28, count: 16) + let account = try retainedEntropyAccount(entropy: entropy) + let original = try cloudRecoveryFixture(account: account, entropy: entropy, + format: "phrase", password: "synthetic-original-password") + let replacement = try cloudRecoveryFixture(account: account, entropy: entropy, + format: "combined", password: "synthetic-new-password") + func upload() -> GTLRDriveQuery_FilesCreate { + let file = GTLRDrive_File(); file.name = "\(account.address).json"; file.parents = ["synthetic-folder"] + return GTLRDriveQuery_FilesCreate.query(withObject: file, + uploadParameters: GTLRUploadParameters(data: replacement, mimeType: "application/json")) + } + for fault in ["pin", "pin-verification", "preserved-media", "concurrent-version", "update", "lost-update-response", "new-media"] { + let drive = PreservedBackupDriveFixture(name: "\(account.address).json", original: original) + drive.failure = fault + do { _ = try await WalletBackupPreservingGoogleService(base: drive).executeQuery(upload()); XCTFail("Injected backup fault was accepted") } + catch {} + XCTAssertEqual(drive.revisions["original"], original) + XCTAssertFalse(drive.queries.contains { $0 is GTLRDriveQuery_FilesDelete || $0 is GTLRDriveQuery_RevisionsDelete }) + if fault != "pin" { XCTAssertTrue(drive.pinned.contains("original")) } + if ["pin", "pin-verification", "preserved-media", "concurrent-version"].contains(fault) { + XCTAssertFalse(drive.queries.contains { $0 is GTLRDriveQuery_FilesUpdate }) + XCTAssertEqual(drive.head, "original") + } + // A new adapter represents a restarted save. A lost upload response may + // already have installed the new head; preserve it and verify the retry. + drive.failure = nil + _ = try await WalletBackupPreservingGoogleService(base: drive).executeQuery(upload()) + XCTAssertEqual(drive.revisions["original"], original) + XCTAssertTrue(drive.pinned.contains("original")) + XCTAssertEqual(drive.revisions[drive.head], replacement) + XCTAssertTrue(drive.pinned.contains(drive.head)) + } + } + + @MainActor + func testCloudBackupPresenterRetriesSameCreatedAccountAndMarksOnlyVerifiedSuccess() async throws { + let entropy = Data(repeating: 29, count: 16) + let mnemonic = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: entropy) + let account = try retainedEntropyAccount(entropy: entropy) + let keys = InMemoryKeychain() + try keys.addKey(entropy, with: KeystoreTag.entropyTagForAddress(account.address)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let gate = legacyActivationRecoveryGate(at: directory, settings: InMemorySettingsManager()) + let coordinator = WalletLifecycleCoordinator(recoveryGate: gate) + let creator = BackupAccountCreatorFixture(account: account) + let cloud = BackupSavingCloudFixture(), view = BackupSavingViewFixture(), wireframe = BackupSavingWireframeFixture() + let oldFlags = ApplicationConfig.shared.backupedAccountAddresses + defer { ApplicationConfig.shared.backupedAccountAddresses = oldFlags; cloud.complete(.failure(WalletCloudBackupWriteError.verificationFailed)) } + ApplicationConfig.shared.backupedAccountAddresses = oldFlags.filter { $0 != account.address } + let presenter = SetupPasswordPresenter(account: OpenBackupAccount(address: "", passphrase: mnemonic.toString(), + cryptoType: "sr25519", substrateDerivationPath: ""), cloudStorageService: cloud, + createAccountRequest: AccountCreationRequest(username: "Synthetic backup", type: .sora, + derivationPath: "", cryptoType: .sr25519), createAccountService: creator, mnemonic: mnemonic, + entryPoint: .onboarding, keystore: keys, lifecycleCoordinator: coordinator, recoveryGate: gate) + presenter.view = view; presenter.wireframe = wireframe + let first = expectation(description: "first save pending"), failed = expectation(description: "failed save stops loading") + cloud.onSave = { first.fulfill() }; view.onHide = { failed.fulfill() } + presenter.backupAccount(with: "synthetic-password") + presenter.backupAccount(with: "synthetic-password") + await fulfillment(of: [first], timeout: 10) + XCTAssertEqual(creator.calls, 1); XCTAssertEqual(cloud.saves.count, 1); XCTAssertEqual(view.shows, 1) + XCTAssertFalse(ApplicationConfig.shared.backupedAccountAddresses.contains(account.address)) + cloud.complete(.failure(WalletCloudBackupWriteError.verificationFailed)) + await fulfillment(of: [failed], timeout: 10) + XCTAssertEqual(view.hides, 1); XCTAssertEqual(wireframe.successes, 0); XCTAssertEqual(wireframe.failures.count, 1) + XCTAssertFalse(ApplicationConfig.shared.backupedAccountAddresses.contains(account.address)) + let retry = expectation(description: "same account retry"), saved = expectation(description: "verified save stops loading") + cloud.onSave = { retry.fulfill() }; view.onHide = { saved.fulfill() } + presenter.backupAccount(with: "synthetic-password") + await fulfillment(of: [retry], timeout: 10) + XCTAssertEqual(creator.calls, 1, "Retry must not create another wallet") + XCTAssertEqual(cloud.saves.map(\.address), [account.address, account.address]) + cloud.complete(.success(())) + await fulfillment(of: [saved], timeout: 10) + XCTAssertEqual(view.hides, 2); XCTAssertEqual(wireframe.successes, 1) + XCTAssertTrue(ApplicationConfig.shared.backupedAccountAddresses.contains(account.address)) + XCTAssertEqual(try keys.fetchKey(for: KeystoreTag.entropyTagForAddress(account.address)), entropy) + } + + @MainActor + func testCloudBackupPresenterRejectsIdentityConflictsAndPreservesStoredDerivation() async throws { + let entropy = Data(repeating: 30, count: 16), otherEntropy = Data(repeating: 31, count: 16) + let phrase = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: entropy).toString() + let otherPhrase = try IRMnemonicCreator(language: .english).mnemonic(fromEntropy: otherEntropy).toString() + let path = "//backup-fixture" + let pair = try SR25519KeypairFactory().createKeypairFromSeed(retainedEntropySeed(entropy), + chaincodeList: SubstrateJunctionFactory().parse(path: path).chaincodes) + let account = AccountItem(address: try SS58AddressFactory().address(fromAccountId: pair.publicKey().rawData(), + type: Chain.sora.addressType()), cryptoType: .sr25519, networkType: Chain.sora.addressType(), + username: "Synthetic derived backup", publicKeyData: pair.publicKey().rawData(), + settings: AccountSettings(visibleAssetIds: [], orderedAssetIds: []), order: 0, isSelected: true) + let otherAccount = try retainedEntropyAccount(entropy: otherEntropy) + let keys = InMemoryKeychain() + try keys.addKey(entropy, with: KeystoreTag.entropyTagForAddress(account.address)) + try keys.addKey(Data(path.utf8), with: KeystoreTag.deriviationTagForAddress(account.address)) + let originalKeys = try retainedEntropyKeyBytes(keys) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let gate = legacyActivationRecoveryGate(at: directory, settings: InMemorySettingsManager()) + let coordinator = WalletLifecycleCoordinator(recoveryGate: gate) + let oldFlags = ApplicationConfig.shared.backupedAccountAddresses + defer { ApplicationConfig.shared.backupedAccountAddresses = oldFlags } + for scenario in ["switched-account", "wrong-phrase", "wrong-path", "valid"] { + let cloud = BackupSavingCloudFixture(), view = BackupSavingViewFixture(), wireframe = BackupSavingWireframeFixture() + defer { cloud.complete(.failure(WalletCloudBackupWriteError.verificationFailed)) } + let presenter = SetupPasswordPresenter(account: OpenBackupAccount(address: account.address, + passphrase: scenario == "wrong-phrase" ? otherPhrase : phrase, cryptoType: "sr25519", + substrateDerivationPath: scenario == "wrong-path" ? "//other" : ""), cloudStorageService: cloud, + entryPoint: .profile, keystore: keys, + currentAccount: { scenario == "switched-account" ? otherAccount : account }, + lifecycleCoordinator: coordinator, recoveryGate: gate) + presenter.view = view; presenter.wireframe = wireframe + let ended = expectation(description: "profile backup loading ends") + view.onHide = { ended.fulfill() } + if scenario == "valid" { + let started = expectation(description: "verified profile backup payload") + cloud.onSave = { started.fulfill() } + presenter.backupAccount(with: "synthetic-password") + await fulfillment(of: [started], timeout: 10) + let prepared = try XCTUnwrap(cloud.saves.first) + XCTAssertEqual(prepared.address, account.address) + XCTAssertEqual(prepared.substrateDerivationPath, path) + XCTAssertTrue(prepared.backupAccountType?.contains(.passphrase) == true, + "Profile has no presenter mnemonic but its saved phrase must be importable") + let restoredEntropy = try IRMnemonicCreator(language: .english).mnemonic(fromList: XCTUnwrap(prepared.passphrase)).entropy() + let lease = coordinator.acquire(); defer { lease.release() } + try LegacySoraIdentityValidator.validate(address: account.address, publicKey: account.publicKeyData, + cryptoType: account.cryptoType, networkType: account.networkType, + derivationPath: prepared.substrateDerivationPath, entropy: restoredEntropy, + rawSeed: nil, secret: nil, recoveryGate: gate) + cloud.complete(.success(())) + } else { + presenter.backupAccount(with: "synthetic-password") + } + await fulfillment(of: [ended], timeout: 10) + XCTAssertEqual(cloud.saves.count, scenario == "valid" ? 1 : 0) + XCTAssertEqual(wireframe.successes, scenario == "valid" ? 1 : 0) + XCTAssertEqual(wireframe.failures.count, scenario == "valid" ? 0 : 1) + XCTAssertEqual(view.hides, 1) + XCTAssertEqual(try retainedEntropyKeyBytes(keys), originalKeys) + } + // Released Ed25519 JSON imports retain seed + public key, not only a + // 32-byte seed. Back up those exact bytes and reject either conflicting half. + let edSeed = Data(repeating: 42, count: 32) + let edPair = try Ed25519KeypairFactory().createKeypairFromSeed(edSeed, chaincodeList: []) + let edPublic = edPair.publicKey().rawData() + let edAccount = AccountItem(address: try SS58AddressFactory().address(fromAccountId: edPublic, + type: Chain.sora.addressType()), cryptoType: .ed25519, networkType: Chain.sora.addressType(), + username: "Synthetic retained Ed25519", publicKeyData: edPublic, + settings: account.settings, order: 0, isSelected: true) + for condition in ["valid", "suffix", "seed"] { + var retainedSecret = edSeed + edPublic + if condition == "suffix" { retainedSecret[63] ^= 1 } + if condition == "seed" { retainedSecret[0] ^= 1 } + let retainedKeys = InMemoryKeychain() + try retainedKeys.addKey(retainedSecret, with: KeystoreTag.secretKeyTagForAddress(edAccount.address)) + let original = try retainedEntropyKeyBytes(retainedKeys) + let cloud = BackupSavingCloudFixture(), view = BackupSavingViewFixture(), wireframe = BackupSavingWireframeFixture() + defer { cloud.complete(.failure(WalletCloudBackupWriteError.verificationFailed)) } + let presenter = SetupPasswordPresenter(account: OpenBackupAccount(address: edAccount.address, + cryptoType: "ed25519", substrateDerivationPath: ""), cloudStorageService: cloud, + entryPoint: .profile, keystore: retainedKeys, currentAccount: { edAccount }, + lifecycleCoordinator: coordinator, recoveryGate: gate) + presenter.view = view; presenter.wireframe = wireframe + let ended = expectation(description: "retained Ed25519 backup completes") + view.onHide = { ended.fulfill() } + if condition == "valid" { + let started = expectation(description: "retained Ed25519 backup prepared") + cloud.onSave = { started.fulfill() } + presenter.backupAccount(with: "synthetic-password") + await fulfillment(of: [started], timeout: 10) + let backup = try XCTUnwrap(cloud.saves.first) + let json = try XCTUnwrap(backup.json?.substrateJson).data(using: .utf8)! + let definition = try JSONDecoder().decode(KeystoreDefinition.self, from: json) + let exported = try KeystoreExtractor().extractFromDefinition(definition, password: "synthetic-password") + XCTAssertEqual(exported.secretKeyData, retainedSecret) + XCTAssertEqual(exported.publicKeyData, edPublic) + XCTAssertNil(backup.passphrase) + XCTAssertEqual(backup.backupAccountType, [.json]) + cloud.complete(.success(())) + } else { + presenter.backupAccount(with: "synthetic-password") + } + await fulfillment(of: [ended], timeout: 10) + XCTAssertEqual(cloud.saves.count, condition == "valid" ? 1 : 0) + XCTAssertEqual(wireframe.successes, condition == "valid" ? 1 : 0) + XCTAssertEqual(try retainedEntropyKeyBytes(retainedKeys), original) + } + } + func testCloudRecoveryReadsOnlyExactBoundedExistingBackup() async throws { let account = try retainedEntropyAccount(entropy: Data(repeating: 20, count: 20)) let payload = Data("synthetic encrypted backup bytes".utf8) @@ -11087,6 +11481,46 @@ final class WalletModernizationTests: XCTestCase { } } } + let edSeed = Data(repeating: 43, count: 32) + let edPublic = try Ed25519KeypairFactory().createKeypairFromSeed(edSeed, chaincodeList: []).publicKey().rawData() + let edAccount = AccountItem(address: try SS58AddressFactory().address(fromAccountId: edPublic, + type: Chain.sora.addressType()), cryptoType: .ed25519, networkType: Chain.sora.addressType(), + username: "Retained Ed25519 JSON", publicKeyData: edPublic, + settings: account.settings, order: 0, isSelected: true) + for secret in [edSeed, edSeed + edPublic] { + let payload = try cloudRecoveryEd25519Fixture(account: edAccount, secret: secret, password: password) + for version in UserStorageVersion.allCases { + try withRetainedEntropyDatabase(accounts: [edAccount], version: version, + entropy: entropy, recovering: true) { directory, keys, settings in + try keys.deleteKey(for: KeystoreTag.legacyEntropy.rawValue) + let original = try retainedEntropyKeyBytes(keys) + let marker = WalletMigrationRecoveryMarker.capture(settings) + let coordinator = WalletLifecycleCoordinator(recoveryGate: + legacyActivationRecoveryGate(at: directory, settings: settings)) + let database = missingKeyRecoveryMigrator(directory: directory, keys: keys, settings: settings) + try WalletCloudBackupRecoveryService.restore(data: payload, password: password, + account: edAccount, migrator: database, baseURL: directory, lifecycleCoordinator: coordinator) + var expected = original + expected[KeystoreTag.secretKeyTagForAddress(edAccount.address)] = secret + XCTAssertEqual(try retainedEntropyKeyBytes(keys), expected) + XCTAssertEqual(WalletMigrationRecoveryMarker.capture(settings), marker) + for _ in 0..<2 { + let snapshot = try runRetainedEntropyStartup(directory: directory, keys: keys, + settings: settings, accounts: [edAccount]) + XCTAssertEqual(snapshot.accounts.map(\.networkId), [.sora2]) + let restored = try XCTUnwrap(LegacySoraSecretResolver.resolve(account: edAccount, keystore: keys)) + XCTAssertEqual(restored, secret) + let message = Data("retained Ed25519 JSON recovery proof".utf8) + let signature = try Sora2Ed25519SeedSigner.sign(message, seed: restored) + try Sora2SignatureVerifier.verify(signature: signature, originalData: message, + secretKey: restored, account: edAccount) + XCTAssertThrowsError(try Sora2SignatureVerifier.verify(signature: signature, + originalData: message + Data([1]), secretKey: restored, account: edAccount)) + XCTAssertEqual(try retainedEntropyKeyBytes(keys), expected) + } + } + } + } } func testCloudRecoveryRejectsWrongPasswordConflictsAndMalformedSecretWithoutWrites() throws { @@ -11138,8 +11572,33 @@ final class WalletModernizationTests: XCTestCase { XCTAssertFalse(text.contains(password)) } } + let edSeed = Data(repeating: 44, count: 32) + let edPublic = try Ed25519KeypairFactory().createKeypairFromSeed(edSeed, chaincodeList: []).publicKey().rawData() + let edAccount = AccountItem(address: try SS58AddressFactory().address(fromAccountId: edPublic, + type: Chain.sora.addressType()), cryptoType: .ed25519, networkType: Chain.sora.addressType(), + username: "Retained Ed25519 conflict", publicKeyData: edPublic, + settings: account.settings, order: 0, isSelected: true) + for condition in ["suffix", "seed"] { + var invalid = edSeed + edPublic + invalid[condition == "suffix" ? 63 : 0] ^= 1 + let payload = try cloudRecoveryEd25519Fixture(account: edAccount, secret: invalid, password: password) + try withRetainedEntropyDatabase(accounts: [edAccount], version: .version2, + entropy: entropy, recovering: true) { directory, keys, settings in + try keys.deleteKey(for: KeystoreTag.legacyEntropy.rawValue) + let original = try retainedEntropyKeyBytes(keys) + let marker = WalletMigrationRecoveryMarker.capture(settings) + let coordinator = WalletLifecycleCoordinator(recoveryGate: + legacyActivationRecoveryGate(at: directory, settings: settings)) + let database = missingKeyRecoveryMigrator(directory: directory, keys: keys, settings: settings) + XCTAssertThrowsError(try WalletCloudBackupRecoveryService.restore(data: payload, + password: password, account: edAccount, migrator: database, + baseURL: directory, lifecycleCoordinator: coordinator)) + XCTAssertEqual(try retainedEntropyKeyBytes(keys), original) + XCTAssertEqual(WalletMigrationRecoveryMarker.capture(settings), marker) + } + } XCTAssertThrowsError(try WalletCloudBackupRecoveryService.validateSecretEncoding( - Data(repeating: 255, count: 64), cryptoType: .sr25519)) + Data(repeating: 255, count: 64), cryptoType: .sr25519, publicKey: account.publicKeyData)) } private func cloudRecoveryFixture(account: AccountItem, entropy: Data, format: String, @@ -11170,6 +11629,17 @@ final class WalletModernizationTests: XCTestCase { return try JSONSerialization.data(withJSONObject: fields) } + private func cloudRecoveryEd25519Fixture(account: AccountItem, secret: Data, password: String) throws -> Data { + let definition = try KeystoreBuilder().build(from: KeystoreData(address: account.address, + secretKeyData: secret, publicKeyData: account.publicKeyData, cryptoType: .ed25519), + password: password, isEthereum: false) + let verifier = try XCTUnwrap(EncryptionService().createEncryptedData(with: password, message: account.address)) + let fields: [String: Any] = ["name": "Synthetic Ed25519 backup", "address": account.address, + "cryptoType": "ED25519", "keyVerifier": verifier.hex, "backupAccountType": ["json"], + "json": ["substrateJson": String(decoding: try JSONEncoder().encode(definition), as: UTF8.self)]] + return try JSONSerialization.data(withJSONObject: fields) + } + private func missingKeyRecoveryMigrator(directory: URL, keys: InMemoryKeychain, settings: InMemorySettingsManager) -> UserStorageMigrator { UserStorageMigrator(targetVersion: .version2, storeURL: directory.appendingPathComponent("UserDataModel.sqlite"), From e55774deff4d19818e006597cb65f748613256e0 Mon Sep 17 00:00:00 2001 From: Makoto Takemiya Date: Mon, 14 Sep 2026 00:18:37 +0900 Subject: [PATCH 2/2] Prepare TestFlight build 2026091305 source pins --- .../test-ios-internal-testflight-upload.py | 22 +++++++++---------- .../Scripts/upload-ios-internal-testflight.sh | 8 +++---- ...verify-ios-internal-testflight-delivery.py | 2 +- .../verify-modernization-dependencies.sh | 16 +++++++------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/SoraPassport/Scripts/test-ios-internal-testflight-upload.py b/SoraPassport/Scripts/test-ios-internal-testflight-upload.py index a3b2223c4..0651da604 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": "2026091304", + "SORA_IOS_INTERNAL_TESTFLIGHT_BUILD_NUMBER": "2026091305", "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": "2026091304", + "CURRENT_PROJECT_VERSION": "2026091305", "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="a472679a10beb1cc71468bcf25d7f57ac541c744"', - 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091304"', - 'reviewed_build_number="2026091304"', + 'reviewed_base_revision="ec3c07f2c6ffaddbfff8c2983a63214718c519a8"', + 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091305-r2"', + 'reviewed_build_number="2026091305"', '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-2026091304", - "a472679a10beb1cc71468bcf25d7f57ac541c744", + "origin/codex/ios-wallet-upgrade-testflight-2026091305-r2", + "ec3c07f2c6ffaddbfff8c2983a63214718c519a8", "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": "2026091304", + "CFBundleVersion": "2026091305", "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": "2026091304", + "uploadedBuildNumber": "2026091305", "uploadEvent": uploaded, } ], @@ -338,7 +338,7 @@ def test_delivery_verifier_binds_exact_success_profile_and_options(self) -> None xcodebuild_log, reviewed_profile, receipt_path, - "2026091304", + "2026091305", 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", - "2026091304", + "2026091305", expected_profile_sha256=reviewed_profile_sha256, ) diff --git a/SoraPassport/Scripts/upload-ios-internal-testflight.sh b/SoraPassport/Scripts/upload-ios-internal-testflight.sh index 034e7a132..dee58fc5f 100755 --- a/SoraPassport/Scripts/upload-ios-internal-testflight.sh +++ b/SoraPassport/Scripts/upload-ios-internal-testflight.sh @@ -16,9 +16,9 @@ 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="a472679a10beb1cc71468bcf25d7f57ac541c744" -reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091304" -reviewed_build_number="2026091304" +reviewed_base_revision="ec3c07f2c6ffaddbfff8c2983a63214718c519a8" +reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091305-r2" +reviewed_build_number="2026091305" reviewed_lower_bound="2026091303" reviewed_marketing_version="3.8.7" reviewed_bundle_identifier="co.jp.soramitsu.sora" @@ -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-2026091304" + fail "internal TestFlight source must be pushed to origin/codex/ios-wallet-upgrade-testflight-2026091305-r2" 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 5ecc31c73..66d080b7b 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 = "2026091304" +BUILD_NUMBER = "2026091305" 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 c9db8a4d4..9ae501c6d 100644 --- a/SoraPassport/Scripts/verify-modernization-dependencies.sh +++ b/SoraPassport/Scripts/verify-modernization-dependencies.sh @@ -746,7 +746,7 @@ if [ -n "${internal_testflight_mode}" ]; then [ "${CODE_SIGN_STYLE:-}" != "Automatic" ] || [ "${CODE_SIGN_IDENTITY:-}" != "iPhone Developer" ] || [ -n "${PROVISIONING_PROFILE_SPECIFIER:-}" ] || - [ "${internal_testflight_build_number}" != "2026091304" ] || + [ "${internal_testflight_build_number}" != "2026091305" ] || [ "${CURRENT_PROJECT_VERSION:-}" != "${internal_testflight_build_number}" ] || [ "${CODE_SIGN_ENTITLEMENTS:-}" != "SoraPassport/SoraPassport.entitlements" ] || [ "${INFOPLIST_FILE:-}" != "SoraPassport/Info.plist" ] || @@ -782,10 +782,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-2026091304" ] || - [ "$(/usr/bin/git -C "${root}" rev-parse HEAD^ 2>/dev/null)" != "a472679a10beb1cc71468bcf25d7f57ac541c744" ] || - [ "$(/usr/bin/git -C "${root}" rev-list --count "a472679a10beb1cc71468bcf25d7f57ac541c744..${internal_testflight_source_revision}" 2>/dev/null)" != "1" ] || - [ "$(/usr/bin/git -C "${root}" diff --name-only --no-renames "a472679a10beb1cc71468bcf25d7f57ac541c744..${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-2026091305-r2" ] || + [ "$(/usr/bin/git -C "${root}" rev-parse HEAD^ 2>/dev/null)" != "ec3c07f2c6ffaddbfff8c2983a63214718c519a8" ] || + [ "$(/usr/bin/git -C "${root}" rev-list --count "ec3c07f2c6ffaddbfff8c2983a63214718c519a8..${internal_testflight_source_revision}" 2>/dev/null)" != "1" ] || + [ "$(/usr/bin/git -C "${root}" diff --name-only --no-renames "ec3c07f2c6ffaddbfff8c2983a63214718c519a8..${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' ] || @@ -2425,9 +2425,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="a472679a10beb1cc71468bcf25d7f57ac541c744"' "${internal_testflight_uploader}" || - ! /usr/bin/grep -Fq 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091304"' "${internal_testflight_uploader}" || - ! /usr/bin/grep -Fq 'reviewed_build_number="2026091304"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_base_revision="ec3c07f2c6ffaddbfff8c2983a63214718c519a8"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_upstream="origin/codex/ios-wallet-upgrade-testflight-2026091305-r2"' "${internal_testflight_uploader}" || + ! /usr/bin/grep -Fq 'reviewed_build_number="2026091305"' "${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}" ||