From faa55294f573941f65da838209fdc078edbe27c9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 2 Oct 2026 12:54:41 +0300 Subject: [PATCH 1/2] fix(wallet): don't hold launch on DashSync mnemonics orphaned by an 8.x reset DashSync's Reset (DSChain.unregisterWallet) removes the wallet id from CHAIN_WALLETS_KEY_ and deletes the PIN, but never deletes WALLET_MNEMONIC_KEY_. 8.x loads wallets only from those lists, so it stopped showing such a wallet. The key migrator treated that mnemonic as a wallet on an unknown chain: it never set the done sentinel, and the launch probe counted the mnemonic as pending. A device whose old wallets had all been reset therefore showed "Couldn't move your wallet" on every launch, with no way to create or restore a wallet, and reinstalling did not help because the keychain survives app deletion. With a live wallet next to an orphan the upgrade opened, but done was never set and the migrator re-processed the orphan on every launch. A mnemonic that no chain list names is now orphaned: the migrator skips it, leaves it in the keychain, and marks migration done when nothing else is pending, and the launch probe reports a keychain holding only orphans as absent, so setup is offered at once. That verdict needs every list read and decoded: a list that cannot be read or decoded defers as a failure, and a keychain with no chain list at all keeps the unknown-chain card. The lists are read one item at a time instead of pulling every keychain secret once per wallet. Each migration run past the done check also writes one summary line with counts, never wallet ids, to the app log: the migrator's os_log lines do not reach a diagnostic export. The LEGACY_KEYCHAIN_INVALID fixture now lists its entry on mainnet, so it still reproduces the failure card. Co-Authored-By: Claude Opus 5.5 --- DASHSYNC_KEY_MIGRATION.md | 73 ++++-- .../SwiftDashSDKKeyMigrator.swift | 240 +++++++++++------- .../WalletLifecycleTransitionState.swift | 57 +++++ .../WalletLifecycleTransitionStateTests.swift | 54 ++++ 4 files changed, 318 insertions(+), 106 deletions(-) diff --git a/DASHSYNC_KEY_MIGRATION.md b/DASHSYNC_KEY_MIGRATION.md index ed1a3253fd..91bf6d5648 100644 --- a/DASHSYNC_KEY_MIGRATION.md +++ b/DASHSYNC_KEY_MIGRATION.md @@ -2,7 +2,8 @@ Current contract for importing wallet mnemonics from DashSync-owned keychain entries into the host-owned SwiftDashSDK runtime. Updated 2026-07-11 to include -multi-wallet and active-wallet behavior. +multi-wallet and active-wallet behavior, and 2026-09-30 for mnemonics orphaned +by a DashSync Reset. ## Deployment model and invariant @@ -35,6 +36,13 @@ Only compatibility code may know these old layouts: DashSync wallet IDs are short hash strings. SDK wallet IDs are 32-byte SDK identifiers and are not expected to match. +DashSync loaded wallets only from the per-chain lists. Its Reset +(`DSChain.unregisterWallet`) rewrites the list without the wallet ID (an empty +array for the last wallet) and deletes the PIN, but never deletes the wallet's +`WALLET_MNEMONIC_KEY_*` account. A mnemonic that no list names is therefore a +wallet the user reset in the previous app, which that app no longer showed +either: an **orphaned mnemonic**. + Known chain suffixes: | Network | Genesis short hex | @@ -48,23 +56,41 @@ Known chain suffixes: work on a background queue: 1. If `swiftSDKKeyMigration.v1.done` exists, return. -2. Clear stale legacy defer flags and enumerate every - `WALLET_MNEMONIC_KEY_*` account. -3. If none exist, mark migration done (fresh install or already wiped device). -4. For each DashSync wallet ID not already present in the success ledger: - - resolve mainnet/testnet membership from the frozen chain-wallet lists; - - read and validate the mnemonic; - - sanity-check deterministic seed derivation; - - call `SwiftDashSDKHost.createOrImportWallet` on the main actor; - - persist the DashSync wallet ID in +2. Clear stale legacy defer flags and enumerate the service's accounts + (attributes only). +3. If no `WALLET_MNEMONIC_KEY_*` account exists, mark migration done (fresh + install or already wiped device). +4. Read every `CHAIN_WALLETS_KEY_*` list, one item at a time. If any list + cannot be read or decoded, set `deferredFailure` and stop: a list that + cannot be read might name any wallet. +5. For each DashSync wallet ID not already present in the success ledger, + classify it against the lists (`DashSyncChainWalletLists`): + - orphaned (every list read, none names it): skip it. It stays in the + keychain, neither migrated nor deleted; + - listed only by an unsupported chain, or no chain list exists at all: + unknown chain; + - listed on mainnet or testnet: read and validate the mnemonic, + sanity-check deterministic seed derivation, call + `SwiftDashSDKHost.createOrImportWallet` on the main actor, and persist + the DashSync wallet ID in `swiftSDKKeyMigration.v1.migratedDashSyncWalletIds`. -5. Set the done sentinel only when every discovered wallet either migrated or - was already in the ledger and no wallet has an unknown chain/failure. -6. Notify `SwiftDashSDKWalletRuntime` after the done sentinel is written. +6. Set the done sentinel only when every discovered wallet migrated, was + already in the ledger, or is orphaned, and no wallet has an unknown chain or + failure. +7. Notify `SwiftDashSDKWalletRuntime` after the done sentinel is written. +8. Every run past step 1 writes one summary line with counts, never wallet + IDs, to the app log, so diagnostic exports show the outcome (the + migrator's os_log lines do not reach them). Partial runs are resumable: successfully migrated wallet IDs are not imported again, while failures are retried on a later launch. +The launch decision (`legacyWalletMaterialState()`) applies the same +classification before the run finishes: a keychain holding only orphaned +mnemonics is absent, so setup is offered at once; any listed or unresolved +mnemonic is pending, and the launch hold waits for the migrator; a keychain or +chain-list read failure is unreadable, and the hold fails closed. + ## Multi-wallet behavior Multiple DashSync wallets are supported. The old @@ -84,9 +110,11 @@ than one wallet exists; that previously mirrored or displayed the wrong wallet. | Condition | Behavior | |---|---| -| Unknown/unsupported DashSync chain | Set `swiftSDKKeyMigration.v1.deferredUnknownChain`; leave done unset; retry later. | +| Wallet ID listed only by an unsupported DashSync chain (devnet/regtest/evonet) | Set `swiftSDKKeyMigration.v1.deferredUnknownChain`; leave done unset; retry later. | +| Mnemonics exist but no `CHAIN_WALLETS_KEY_*` list exists at all | Same as an unsupported chain: fail closed. A real Reset keeps its (emptied) list, so no list at all is a layout the migrator cannot vouch for. | +| A chain list cannot be read or decoded | Set `swiftSDKKeyMigration.v1.deferredFailure`; leave done unset; retry later. The launch probe reports the keychain unreadable. | | Mnemonic missing/invalid or host creation fails | Leave done unset; retain successes in the per-wallet ledger; retry later. | -| No old mnemonics | Mark done so SDK runtime startup does not wait indefinitely. | +| No old mnemonics, or only orphaned ones | Mark done so SDK runtime startup does not wait indefinitely. Orphaned mnemonics stay in the keychain. | The runtime treats legacy defer flags as permission to stop waiting, but the migrator remains responsible for clearing stale values and retrying incomplete @@ -121,15 +149,18 @@ that wallet during a partially successful attempt. Every wipe entry point waits for the same explicit result before navigating or creating a replacement wallet. Production recovery removes only the legacy mnemonic accounts matching its single authorized SDK seed; confirmed Delete -All removes every legacy mnemonic account. Both run before SDK deletion, and a +All removes every legacy mnemonic account, orphaned ones included. Both run +before SDK deletion, and a cleanup failure aborts the SDK wipe. Debug reset and screenshot replacement preserve legacy data. The app never deletes the whole `org.dashfoundation.dash` service. Per-wallet Remove deletes matching legacy mnemonic accounts, then the deterministic mainnet and testnet SDK IDs for that seed, with the live network -last. The old `CHAIN_WALLETS_KEY_*` lists may retain harmless orphan IDs: they -contain no seed and the migrator only starts from mnemonic accounts. +last. The old `CHAIN_WALLETS_KEY_*` lists may retain harmless IDs whose +mnemonic is gone: they contain no seed and the migrator only starts from +mnemonic accounts. The reverse, a mnemonic that no list names, is an orphaned +mnemonic (see the frozen contract above). A Wallets-screen Remove may route into the global wipe only after Keychain ground truth proves every stored wallet ID belongs to the same recovery phrase @@ -166,6 +197,10 @@ entry point must collect one of these authorizations before invoking the wiper. - one and multiple DashSync wallets import without selecting the wrong active wallet; - a partial failure resumes without duplicating successful wallets; +- a mnemonic orphaned by a DashSync Reset neither holds the launch nor blocks + the done sentinel, and stays in the keychain; +- an unreadable or undecodable chain list, a keychain without any chain list, + and an unsupported chain all keep the launch hold (fail closed); - mainnet and testnet retain separate active-wallet choices; - create/import/recovery use the same host boundary; - create/import persist and verify the mnemonic before the wallet becomes live; @@ -184,6 +219,8 @@ entry point must collect one of these authorizations before invoking the wiper. ## Source files - `DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift` +- `DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift` + (`DashSyncChainWalletLists`, the launch hold) - `DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift` - `DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletEnvironment.swift` - `DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift` diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index c94dba1da8..a76f408354 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -177,41 +177,93 @@ final class SwiftDashSDKKeyMigrator: NSObject { defaults.removeObject(forKey: deferredUnknownChainKey) defaults.removeObject(forKey: deferredFailureKey) - let mnemonicAccounts: [String] + // The os_log lines below never reach a diagnostic export, so every run + // that gets past the done check also leaves one summary line in the + // app log: counts only, never wallet ids. + let accounts: [String] do { - mnemonicAccounts = try strictlyEnumerateDashSyncMnemonicAccounts() + accounts = try strictlyEnumerateDashSyncAccounts() } catch { defaults.set(true, forKey: deferredFailureKey) logger.error( "🔑 KEYMIG :: DashSync mnemonic enumeration failed: \(String(describing: error), privacy: .public)") + DWLogger.log("🔑 KEYMIG :: legacy keychain unreadable (enumeration failed); migration deferred") return } + let mnemonicAccounts = accounts.filter { $0.hasPrefix(dashSyncMnemonicAccountPrefix) } if mnemonicAccounts.isEmpty { defaults.set("v1", forKey: doneKey) logger.info("🔑 KEYMIG :: no DashSync mnemonics found — fresh install or post-wipe, marking done") + DWLogger.log("🔑 KEYMIG :: no legacy mnemonics; migration complete") return } + // Read the chain lists once, before any verdict: one that cannot be + // read or decoded might name any wallet, so no mnemonic may be judged + // orphaned without all of them. + let chainLists: [String: [String]] + do { + chainLists = try readDashSyncChainWalletLists(from: accounts) + } catch { + defaults.set(true, forKey: deferredFailureKey) + logger.error( + "🔑 KEYMIG :: DashSync chain-wallet lists unreadable: \(String(describing: error), privacy: .public)") + DWLogger.log("🔑 KEYMIG :: \(mnemonicAccounts.count) legacy mnemonic(s), chain-wallet lists unreadable; migration deferred") + return + } + let chainsPresent = chainLists.keys.sorted().joined(separator: ",") + var migrated = Set(defaults.stringArray(forKey: migratedDashSyncWalletIdsKey) ?? []) var hadUnknownChain = false var hadFailure = false var migratedThisRun = 0 + var alreadyMigrated = 0 + var orphaned = 0 + var unsupportedChain = 0 + var unresolved = 0 + var failed = 0 for account in mnemonicAccounts { let walletID = String(account.dropFirst(dashSyncMnemonicAccountPrefix.count)) if migrated.contains(walletID) { + alreadyMigrated += 1 continue } - guard let network = detectNetwork(forWalletID: walletID) else { - logger.warning("🔑 KEYMIG :: \(walletID, privacy: .public) chain unresolved/unsupported") + let network: Network + switch DashSyncChainWalletLists.membership(ofWalletID: walletID, in: chainLists) { + case .orphaned: + // Reset in the previous app, which no longer showed it either. + // Not material to migrate, and never deleted here. + orphaned += 1 + logger.info( + "🔑 KEYMIG :: \(walletID, privacy: .public) not listed by any chain (reset in the previous app) — left in place, not migrated") + continue + case .unresolved: + unresolved += 1 hadUnknownChain = true + logger.error( + "🔑 KEYMIG :: \(walletID, privacy: .public) cannot be mapped: no \(dashSyncChainWalletsKeyPrefix, privacy: .public)* list in the keychain") continue + case let .listed(chains): + if chains.contains(mainnetGenesisShortHex) { + network = .mainnet + } else if chains.contains(testnetGenesisShortHex) { + network = .testnet + } else { + // Unknown chain (devnet/regtest/evonet) — defer in v1. + unsupportedChain += 1 + hadUnknownChain = true + logger.error( + "🔑 KEYMIG :: \(walletID, privacy: .public) belongs to unsupported chain(s) \(chains.sorted().joined(separator: ","), privacy: .public)") + continue + } } guard let mnemonic = readKeychainString(service: dashSyncService, account: account), Mnemonic.validate(mnemonic) else { logger.error("🔑 KEYMIG :: \(walletID, privacy: .public) mnemonic read/validate failed") + failed += 1 hadFailure = true continue } @@ -220,6 +272,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { let seed = try Mnemonic.toSeed(mnemonic: mnemonic) guard seed.count == 64, try Mnemonic.toSeed(mnemonic: mnemonic) == seed else { logger.error("🔑 KEYMIG :: \(walletID, privacy: .public) seed sanity check failed") + failed += 1 hadFailure = true continue } @@ -235,11 +288,18 @@ final class SwiftDashSDKKeyMigrator: NSObject { migratedThisRun += 1 } catch { logger.error("🔑 KEYMIG :: \(walletID, privacy: .public) threw: \(String(describing: error), privacy: .public)") + failed += 1 hadFailure = true } } - if !hadFailure && !hadUnknownChain { + let complete = !hadFailure && !hadUnknownChain + DWLogger.log( + "🔑 KEYMIG :: \(mnemonicAccounts.count) legacy mnemonic(s), chain lists [\(chainsPresent)]: \(migratedThisRun) migrated, " + + "\(alreadyMigrated) already migrated, \(orphaned) orphaned (left in place), " + + "\(unsupportedChain) unsupported chain, \(unresolved) without a chain list, \(failed) failed; " + + (complete ? "migration complete" : "migration incomplete, will retry on next launch")) + if complete { defaults.set("v1", forKey: doneKey) logger.info("🔑 KEYMIG :: migration complete (\(migrated.count, privacy: .public) total, \(migratedThisRun, privacy: .public) this run)") // Notify runtime only after doneKey is set — its wait loop polls for it. @@ -258,17 +318,28 @@ final class SwiftDashSDKKeyMigrator: NSObject { // MARK: - Launch-decision probes /// Whether DashSync wallet material still awaits the one-shot migration. - /// A keychain that cannot be read (for example a background launch on a - /// locked device) is reported as such rather than as "nothing there": - /// the launch hold must not release into setup on a read error, because - /// the upgrading user's wallet may well be behind it. + /// Mnemonics that no chain list names were reset in the previous app and + /// are not material to wait for (`DashSyncChainWalletLists`); a keychain + /// holding only those is `.absent`, so setup is offered at once. A + /// keychain that cannot be read (for example a background launch on a + /// locked device), including a chain list that does not read or decode, + /// is reported as such rather than as "nothing there": the launch hold + /// must not release into setup on a read error, because the upgrading + /// user's wallet may well be behind it. static func legacyWalletMaterialState() -> LegacyWalletMigrationLaunchCoordinator.LegacyMaterialState { guard UserDefaults.standard.string(forKey: doneKey) == nil else { return .absent } do { - return try strictlyEnumerateDashSyncMnemonicAccounts().isEmpty ? .absent : .pending + let accounts = try strictlyEnumerateDashSyncAccounts() + let walletIDs = accounts + .filter { $0.hasPrefix(dashSyncMnemonicAccountPrefix) } + .map { String($0.dropFirst(dashSyncMnemonicAccountPrefix.count)) } + guard !walletIDs.isEmpty else { return .absent } + return DashSyncChainWalletLists.materialState( + mnemonicWalletIDs: walletIDs, + chainLists: try readDashSyncChainWalletLists(from: accounts)) } catch { logger.error( - "🔑 KEYMIG :: DashSync mnemonic enumeration failed during launch probe: \(String(describing: error), privacy: .public)") + "🔑 KEYMIG :: DashSync keychain read failed during launch probe: \(String(describing: error), privacy: .public)") return .unreadable } } @@ -324,6 +395,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { private enum LegacyMnemonicCleanupError: LocalizedError { case keychainRead(OSStatus) + case chainListUndecodable case mnemonicRead case deletionFailed case verificationFailed @@ -472,6 +544,13 @@ final class SwiftDashSDKKeyMigrator: NSObject { } private static func strictlyEnumerateDashSyncMnemonicAccounts() throws -> [String] { + try strictlyEnumerateDashSyncAccounts().filter { $0.hasPrefix(dashSyncMnemonicAccountPrefix) } + } + + /// Every account in DashSync's service, sorted. Attributes only, so it + /// works where a data query would not (the mnemonics are + /// WhenUnlockedThisDeviceOnly). + private static func strictlyEnumerateDashSyncAccounts() throws -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: dashSyncService, @@ -488,82 +567,38 @@ final class SwiftDashSDKKeyMigrator: NSObject { } return items .compactMap { $0[kSecAttrAccount as String] as? String } - .filter { $0.hasPrefix(dashSyncMnemonicAccountPrefix) } .sorted() } - /// Determine which network a wallet ID belongs to by enumerating - /// `CHAIN_WALLETS_KEY_` items, decoding each as an - /// NSKeyedArchiver `NSArray` of wallet IDs, and matching the - /// chain genesis short-hex against our hard-coded mainnet/testnet - /// constants. Returns `nil` for devnet/regtest/evonet (unsupported in v1). - private static func detectNetwork(forWalletID walletID: String) -> Network? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: dashSyncService, - kSecMatchLimit as String: kSecMatchLimitAll, - kSecReturnAttributes as String: true, - kSecReturnData as String: true - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let items = result as? [[String: Any]] else { - // Distinguish "the keychain would not talk to us" from "no chain - // claims this wallet": both used to surface as the same silent nil, - // and the caller's log line ("chain unresolved/unsupported") reads - // as the second while an upgrade on a locked device produces the - // first. `errSecInteractionNotAllowed` (-25308) is that case — this - // query asks for kSecValueData, which the enumeration pass does - // not, so it can fail where enumeration just succeeded. - logger.error( - "🔑 KEYMIG :: chain-wallets keychain query failed, status \(status, privacy: .public)") - return nil - } - - let chainItems = items.filter { - ($0[kSecAttrAccount as String] as? String)? - .hasPrefix(dashSyncChainWalletsKeyPrefix) == true - } - if chainItems.isEmpty { - logger.error( - "🔑 KEYMIG :: no \(dashSyncChainWalletsKeyPrefix, privacy: .public)* items among \(items.count, privacy: .public) keychain item(s) — cannot map any wallet to a chain") - } - - for item in items { - guard let account = item[kSecAttrAccount as String] as? String, - account.hasPrefix(dashSyncChainWalletsKeyPrefix), - let data = item[kSecValueData as String] as? Data else { - continue + /// DashSync's per-chain wallet lists, keyed by chain genesis short hex: + /// every `CHAIN_WALLETS_KEY_*` among `accounts`, read one item at a time. + /// The lists are AfterFirstUnlockThisDeviceOnly; a service-wide data query + /// would also pull every mnemonic secret and fail whenever the device is + /// locked. Throws on any read or decode failure rather than dropping that + /// list: a list that cannot be read might name any wallet, and missing + /// it must never turn a wallet into an orphan. + private static func readDashSyncChainWalletLists(from accounts: [String]) throws -> [String: [String]] { + var lists: [String: [String]] = [:] + for account in accounts where account.hasPrefix(dashSyncChainWalletsKeyPrefix) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: dashSyncService, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + throw LegacyMnemonicCleanupError.keychainRead(status) } - - let chainSuffix = String(account.dropFirst(dashSyncChainWalletsKeyPrefix.count)) - - let allowedClasses: [AnyClass] = [NSArray.self, NSString.self] - guard let unarchived = try? NSKeyedUnarchiver.unarchivedObject( - ofClasses: allowedClasses, from: data), - let walletIDs = unarchived as? [String] else { - continue - } - - if walletIDs.contains(walletID) { - if chainSuffix == mainnetGenesisShortHex { return .mainnet } - if chainSuffix == testnetGenesisShortHex { return .testnet } - // Unknown chain (devnet/regtest/evonet) — defer in v1. - logger.error( - "🔑 KEYMIG :: \(walletID, privacy: .public) belongs to unsupported chain \(chainSuffix, privacy: .public)") - return nil + guard let data = result as? Data, + let walletIDs = DashSyncChainWalletLists.decodeWalletIDs(data) else { + throw LegacyMnemonicCleanupError.chainListUndecodable } + lists[String(account.dropFirst(dashSyncChainWalletsKeyPrefix.count))] = walletIDs } - // The wallet has a mnemonic but no chain list claims it. Name the - // suffixes that were present, so a prefix/format change in the source - // keychain is distinguishable from a genuinely orphaned wallet. - let suffixes = chainItems - .compactMap { ($0[kSecAttrAccount as String] as? String) } - .map { String($0.dropFirst(dashSyncChainWalletsKeyPrefix.count)) } - .joined(separator: ",") - logger.error( - "🔑 KEYMIG :: \(walletID, privacy: .public) not listed by any chain; chains present: [\(suffixes, privacy: .public)]") - return nil + return lists } /// Read a UTF-8 string value from a keychain item. Returns nil on any @@ -580,13 +615,18 @@ final class SwiftDashSDKKeyMigrator: NSObject { /// path can be exercised on a simulator without a real legacy install. /// Two mutually exclusive triggers, each clearing the migration /// sentinel so the migrator re-runs: - /// - `LEGACY_KEYCHAIN_INVALID=1` plants one never-valid mnemonic entry - /// (the perpetually-failing-migration state) and nothing else. + /// - `LEGACY_KEYCHAIN_INVALID=1` plants one never-valid mnemonic entry, + /// listed on mainnet (the perpetually-failing-migration state), and no + /// PIN. /// - `LEGACY_KEYCHAIN_MNEMONIC` = BIP39 phrase to plant, with a legacy /// PIN (`LEGACY_KEYCHAIN_PIN`, default 1111); optional - /// `LEGACY_KEYCHAIN_ORPHAN=1` skips the chain-wallets list (the - /// unknown-chain defer path). This variant also clears the per-wallet - /// ledger and removes any previously planted invalid entry. + /// `LEGACY_KEYCHAIN_ORPHAN=1` deletes the mainnet chain-wallets list + /// instead of writing it. On a keychain with no other list that leaves + /// no list at all: the fail-closed unknown-chain card, with a PIN. It + /// is NOT what a real 8.x Reset leaves (that keeps an emptied list and + /// deletes the PIN; its mnemonic is orphaned and skipped). This variant + /// also clears the per-wallet ledger and removes any previously planted + /// invalid entry. /// Call before `migrateIfNeeded`. @objc static func debugInstallLegacyFixtureIfRequested() { @@ -598,12 +638,36 @@ final class SwiftDashSDKKeyMigrator: NSObject { if env["LEGACY_KEYCHAIN_INVALID"] == "1" { let defaults = UserDefaults.standard defaults.removeObject(forKey: doneKey) + let invalidWalletID = "deadbeefdeadbeef" _ = KeychainStore.set( data: Data("definitely not a valid bip39 phrase".utf8), service: dashSyncService, - account: dashSyncMnemonicAccountPrefix + "deadbeefdeadbeef", + account: dashSyncMnemonicAccountPrefix + invalidWalletID, accessibility: .whenUnlockedThisDeviceOnly) - logger.warning("🔑 KEYMIG :: DEBUG invalid-mnemonic fixture installed") + // List it on mainnet, as DashSync would: an unlisted mnemonic is + // one reset in the previous app and is skipped, never the failure + // this fixture exists to reproduce. Ids already listed stay; a + // list that does not read or decode is left alone. + let listAccount = dashSyncChainWalletsKeyPrefix + mainnetGenesisShortHex + var listedIDs: [String]? + if let accounts = try? strictlyEnumerateDashSyncAccounts() { + listedIDs = accounts.contains(listAccount) + ? (try? readDashSyncChainWalletLists(from: [listAccount]))?[mainnetGenesisShortHex] + : [] + } + if let listedIDs, + let archive = try? NSKeyedArchiver.archivedData( + withRootObject: NSMutableArray( + array: listedIDs.contains(invalidWalletID) ? listedIDs : listedIDs + [invalidWalletID]), + requiringSecureCoding: false) { + _ = KeychainStore.set( + data: archive, + service: dashSyncService, + account: listAccount, + accessibility: .whenUnlockedThisDeviceOnly) + } + logger.warning( + "🔑 KEYMIG :: DEBUG invalid-mnemonic fixture installed (listed on mainnet: \(listedIDs != nil, privacy: .public))") return } guard let phrase = env["LEGACY_KEYCHAIN_MNEMONIC"], !phrase.isEmpty else { return } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift index 4989506440..989c6c8241 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift @@ -498,6 +498,63 @@ final class LegacyWalletMigrationLaunchCoordinator: NSObject { } } +/// What DashSync's own per-chain wallet lists say about its keychain +/// mnemonics. Pure over plain data, so the launch probe and the key migrator +/// reach the same verdict and it is testable without the keychain or the SDK. +/// Deliberately not actor-isolated: the migrator calls it from its queue. +/// +/// DashSync (the 8.x app) instantiated wallets only from the ids in +/// `CHAIN_WALLETS_KEY_`. Its Reset removes the id from that +/// list (rewriting it, empty if need be) and deletes the PIN, but never +/// deletes `WALLET_MNEMONIC_KEY_`. A mnemonic that no list names is +/// therefore a wallet the user reset in the previous app, one 8.x itself no +/// longer showed: nothing to migrate, and it stays in the keychain untouched. +/// That verdict needs every list read and decoded. With no list at all the +/// mnemonics stay unresolved, failing closed: a real Reset keeps its list, +/// so a keychain without any is a layout nothing here can vouch for. +enum DashSyncChainWalletLists { + enum Membership: Equatable { + /// Named by the lists of these chains (genesis short hex). + case listed(chains: Set) + /// Every list was read and none names it: reset in the previous app. + case orphaned + /// No chain list exists at all, so nothing can be told. + case unresolved + } + + /// `lists` maps each chain's genesis short hex to the wallet ids its list + /// names, as read from the keychain. + static func membership(ofWalletID walletID: String, in lists: [String: [String]]) -> Membership { + guard !lists.isEmpty else { return .unresolved } + let chains = Set(lists.compactMap { $0.value.contains(walletID) ? $0.key : nil }) + return chains.isEmpty ? .orphaned : .listed(chains: chains) + } + + /// The launch probe's verdict for mnemonics whose lists were all read: + /// only a keychain of orphaned mnemonics leaves nothing to wait for. + static func materialState( + mnemonicWalletIDs: [String], + chainLists: [String: [String]] + ) -> LegacyWalletMigrationLaunchCoordinator.LegacyMaterialState { + let onlyOrphans = mnemonicWalletIDs.allSatisfy { + membership(ofWalletID: $0, in: chainLists) == .orphaned + } + return onlyOrphans ? .absent : .pending + } + + /// One list decoded the way DashSync wrote it: `NSKeyedArchiver` with + /// `requiringSecureCoding: NO`, an `NSMutableArray` of `NSString` ids + /// (empty after a Reset). Anything else is `nil`, never an empty list, + /// because a list that does not decode might name any wallet. + static func decodeWalletIDs(_ data: Data) -> [String]? { + guard let object = try? NSKeyedUnarchiver.unarchivedObject( + ofClasses: [NSArray.self, NSString.self], from: data) else { + return nil + } + return object as? [String] + } +} + extension Notification.Name { /// Posted (main queue) by `SwiftDashSDKWalletRuntime.handleWalletMaterialChanged` /// whenever persisted wallet material changed — the migrator's success, diff --git a/DashWalletTests/WalletLifecycleTransitionStateTests.swift b/DashWalletTests/WalletLifecycleTransitionStateTests.swift index b7c1ab235c..713f545273 100644 --- a/DashWalletTests/WalletLifecycleTransitionStateTests.swift +++ b/DashWalletTests/WalletLifecycleTransitionStateTests.swift @@ -715,3 +715,57 @@ final class WalletLifecycleTransitionStateTests: XCTestCase { XCTAssertEqual(state.phase, .openingWallet) } } + +/// The verdict the launch probe and the key migrator share: a DashSync +/// mnemonic that no readable chain list names was reset in the previous app +/// and is not material to migrate; anything else keeps the launch hold. +final class DashSyncChainWalletListsTests: XCTestCase { + private typealias Lists = DashSyncChainWalletLists + private let mainnet = "b67a40f" + private let testnet = "2cbcf83" + private let devnet = "1a2b3c4" + + func testAListedWalletReportsTheChainsNamingIt() { + XCTAssertEqual(Lists.membership(ofWalletID: "a1", in: [mainnet: ["a1"], testnet: []]), + .listed(chains: [mainnet])) + XCTAssertEqual(Lists.membership(ofWalletID: "d1", in: [mainnet: [], devnet: ["d1"]]), + .listed(chains: [devnet])) + } + + /// DashSync's Reset rewrites the list without the id, empty for the last + /// wallet, and leaves the mnemonic behind. + func testAMnemonicNoReadableListNamesIsOrphaned() { + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: [mainnet: []]), .orphaned) + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: [mainnet: ["a1"], testnet: ["t1"]]), .orphaned) + } + + func testNoChainListAtAllIsUnresolvedNotOrphaned() { + XCTAssertEqual(Lists.membership(ofWalletID: "x1", in: [:]), .unresolved) + } + + func testOnlyOrphanedMnemonicsReleaseTheLaunch() { + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "r2"], chainLists: [mainnet: []]), .absent) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "a1"], chainLists: [mainnet: ["a1"]]), .pending) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["d1"], chainLists: [devnet: ["d1"]]), .pending) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["x1"], chainLists: [:]), .pending) + } + + /// DashSync archives an `NSMutableArray` with `requiringSecureCoding: NO`. + func testDecodesListsAsDashSyncWroteThem() throws { + let emptied = try NSKeyedArchiver.archivedData(withRootObject: NSMutableArray(), requiringSecureCoding: false) + XCTAssertEqual(Lists.decodeWalletIDs(emptied), []) + let listed = try NSKeyedArchiver.archivedData( + withRootObject: NSMutableArray(array: ["a1", "b2"]), requiringSecureCoding: false) + XCTAssertEqual(Lists.decodeWalletIDs(listed), ["a1", "b2"]) + } + + func testAnUndecodableListIsNilNeverEmpty() throws { + XCTAssertNil(Lists.decodeWalletIDs(Data("not an archive".utf8))) + let numbers = try NSKeyedArchiver.archivedData( + withRootObject: NSMutableArray(array: [1, 2]), requiringSecureCoding: false) + XCTAssertNil(Lists.decodeWalletIDs(numbers)) + let dictionary = try NSKeyedArchiver.archivedData( + withRootObject: NSDictionary(dictionary: ["a1": "b2"]), requiringSecureCoding: false) + XCTAssertNil(Lists.decodeWalletIDs(dictionary)) + } +} From e4483004058657985c87090d662a989adb56dc03 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 2 Oct 2026 20:16:52 +0300 Subject: [PATCH 2/2] fix(wallet): let readable chain lists migrate past an unreadable one Three review follow-ups on the orphaned-mnemonic change. An unreadable or undecodable chain list no longer defers the whole run. It is recorded as unreadable, never as empty, so a wallet that a readable mainnet or testnet list names still migrates, and a mnemonic no readable list names is undetermined rather than orphaned: the run defers as a failure, and the launch probe reports the keychain unreadable only when undetermined mnemonics are all that is left. Before, one bad list (even an unrelated devnet one) held back every wallet, permanently. The migrator header said it was the only file that knows DashSync's keychain layout, but decoding a wallet list moved to DashSyncChainWalletLists. It now says the migrator is the only reader of DashSync's wallet items and points at the pure decoder. The DEBUG fixtures wrote chain lists WhenUnlockedThisDeviceOnly; DashSync wrote them AfterFirstUnlockThisDeviceOnly, so the INVALID fixture would also change a real list's class. Both fixtures now write lists through one helper that matches DashSync's archive format and accessibility. Co-Authored-By: Claude Opus 5.5 --- DASHSYNC_KEY_MIGRATION.md | 30 ++-- .../SwiftDashSDKKeyMigrator.swift | 153 ++++++++++-------- .../WalletLifecycleTransitionState.swift | 58 +++++-- .../WalletLifecycleTransitionStateTests.swift | 46 ++++-- 4 files changed, 178 insertions(+), 109 deletions(-) diff --git a/DASHSYNC_KEY_MIGRATION.md b/DASHSYNC_KEY_MIGRATION.md index 91bf6d5648..2c46087556 100644 --- a/DASHSYNC_KEY_MIGRATION.md +++ b/DASHSYNC_KEY_MIGRATION.md @@ -60,19 +60,21 @@ work on a background queue: (attributes only). 3. If no `WALLET_MNEMONIC_KEY_*` account exists, mark migration done (fresh install or already wiped device). -4. Read every `CHAIN_WALLETS_KEY_*` list, one item at a time. If any list - cannot be read or decoded, set `deferredFailure` and stop: a list that - cannot be read might name any wallet. +4. Read every `CHAIN_WALLETS_KEY_*` list, one item at a time. A list that + cannot be read or decoded is recorded as unreadable, never as empty: it + might name any wallet. 5. For each DashSync wallet ID not already present in the success ledger, classify it against the lists (`DashSyncChainWalletLists`): - orphaned (every list read, none names it): skip it. It stays in the keychain, neither migrated nor deleted; + - named by no readable list while some list is unreadable: undetermined, + a failure for this run; - listed only by an unsupported chain, or no chain list exists at all: unknown chain; - - listed on mainnet or testnet: read and validate the mnemonic, - sanity-check deterministic seed derivation, call - `SwiftDashSDKHost.createOrImportWallet` on the main actor, and persist - the DashSync wallet ID in + - listed on a readable mainnet or testnet list, whatever happened to the + other lists: read and validate the mnemonic, sanity-check deterministic + seed derivation, call `SwiftDashSDKHost.createOrImportWallet` on the main + actor, and persist the DashSync wallet ID in `swiftSDKKeyMigration.v1.migratedDashSyncWalletIds`. 6. Set the done sentinel only when every discovered wallet migrated, was already in the ledger, or is orphaned, and no wallet has an unknown chain or @@ -88,8 +90,9 @@ again, while failures are retried on a later launch. The launch decision (`legacyWalletMaterialState()`) applies the same classification before the run finishes: a keychain holding only orphaned mnemonics is absent, so setup is offered at once; any listed or unresolved -mnemonic is pending, and the launch hold waits for the migrator; a keychain or -chain-list read failure is unreadable, and the hold fails closed. +mnemonic is pending, and the launch hold waits for the migrator; a keychain +that cannot be enumerated, or whose only unsettled mnemonics are undetermined, +is unreadable, and the hold fails closed. ## Multi-wallet behavior @@ -112,7 +115,7 @@ than one wallet exists; that previously mirrored or displayed the wrong wallet. |---|---| | Wallet ID listed only by an unsupported DashSync chain (devnet/regtest/evonet) | Set `swiftSDKKeyMigration.v1.deferredUnknownChain`; leave done unset; retry later. | | Mnemonics exist but no `CHAIN_WALLETS_KEY_*` list exists at all | Same as an unsupported chain: fail closed. A real Reset keeps its (emptied) list, so no list at all is a layout the migrator cannot vouch for. | -| A chain list cannot be read or decoded | Set `swiftSDKKeyMigration.v1.deferredFailure`; leave done unset; retry later. The launch probe reports the keychain unreadable. | +| A chain list cannot be read or decoded | Wallets that a readable mainnet or testnet list names still migrate. Any mnemonic no readable list names is undetermined: set `swiftSDKKeyMigration.v1.deferredFailure`; leave done unset; retry later. With only undetermined mnemonics left, the launch probe reports the keychain unreadable. | | Mnemonic missing/invalid or host creation fails | Leave done unset; retain successes in the per-wallet ledger; retry later. | | No old mnemonics, or only orphaned ones | Mark done so SDK runtime startup does not wait indefinitely. Orphaned mnemonics stay in the keychain. | @@ -199,8 +202,11 @@ entry point must collect one of these authorizations before invoking the wiper. - a partial failure resumes without duplicating successful wallets; - a mnemonic orphaned by a DashSync Reset neither holds the launch nor blocks the done sentinel, and stays in the keychain; -- an unreadable or undecodable chain list, a keychain without any chain list, - and an unsupported chain all keep the launch hold (fail closed); +- an unreadable or undecodable chain list never makes a mnemonic orphaned, yet + does not stop a wallet that a readable mainnet or testnet list names from + migrating; +- a mnemonic that only an unreadable list could name, a keychain without any + chain list, and an unsupported chain all keep the launch hold (fail closed); - mainnet and testnet retain separate active-wallet choices; - create/import/recovery use the same host boundary; - create/import persist and verify the mnemonic before the wallet becomes live; diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift index a76f408354..7decebe335 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKKeyMigrator.swift @@ -20,11 +20,15 @@ // its own wallet state. // 5. No force-unwraps, no `try!`, no `as!`. // -// This file is the ONLY place in dashwallet-ios that knows DashSync's -// keychain layout. The constants below are a frozen contract — once this -// ships, DashSync the *library* can be removed from the binary and the -// migrator will still work, because the keychain items previous app -// versions wrote survive app updates until the user explicitly removes them. +// This file is the ONLY place in dashwallet-ios that reads DashSync's +// wallet keychain items (mnemonics and per-chain wallet lists); `PinStore` +// reads its PIN records in place. The constants below are a frozen +// contract — once this ships, DashSync the *library* can be removed from +// the binary and the migrator will still work, because the keychain items +// previous app versions wrote survive app updates until the user explicitly +// removes them. Decoding one wallet list and judging membership in it is +// `DashSyncChainWalletLists` (WalletLifecycleTransitionState.swift), pure +// over the bytes read here, so the harness can test it without the SDK. // import Foundation @@ -198,20 +202,13 @@ final class SwiftDashSDKKeyMigrator: NSObject { return } - // Read the chain lists once, before any verdict: one that cannot be - // read or decoded might name any wallet, so no mnemonic may be judged - // orphaned without all of them. - let chainLists: [String: [String]] - do { - chainLists = try readDashSyncChainWalletLists(from: accounts) - } catch { - defaults.set(true, forKey: deferredFailureKey) - logger.error( - "🔑 KEYMIG :: DashSync chain-wallet lists unreadable: \(String(describing: error), privacy: .public)") - DWLogger.log("🔑 KEYMIG :: \(mnemonicAccounts.count) legacy mnemonic(s), chain-wallet lists unreadable; migration deferred") - return - } - let chainsPresent = chainLists.keys.sorted().joined(separator: ",") + // Read the chain lists once, before any verdict. A list that cannot be + // read or decoded is recorded as such, never as empty: a wallet that a + // readable list names still migrates, but no mnemonic is judged + // orphaned while a list that might name it is unreadable. + let inventory = readDashSyncChainWalletLists(from: accounts) + let listsPresent = inventory.lists.keys.sorted().joined(separator: ",") + let listsUnreadable = inventory.unreadableChains.sorted().joined(separator: ",") var migrated = Set(defaults.stringArray(forKey: migratedDashSyncWalletIdsKey) ?? []) var hadUnknownChain = false @@ -221,6 +218,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { var orphaned = 0 var unsupportedChain = 0 var unresolved = 0 + var undetermined = 0 var failed = 0 for account in mnemonicAccounts { @@ -231,7 +229,7 @@ final class SwiftDashSDKKeyMigrator: NSObject { } let network: Network - switch DashSyncChainWalletLists.membership(ofWalletID: walletID, in: chainLists) { + switch DashSyncChainWalletLists.membership(ofWalletID: walletID, in: inventory) { case .orphaned: // Reset in the previous app, which no longer showed it either. // Not material to migrate, and never deleted here. @@ -239,6 +237,12 @@ final class SwiftDashSDKKeyMigrator: NSObject { logger.info( "🔑 KEYMIG :: \(walletID, privacy: .public) not listed by any chain (reset in the previous app) — left in place, not migrated") continue + case .undetermined: + undetermined += 1 + hadFailure = true + logger.error( + "🔑 KEYMIG :: \(walletID, privacy: .public) is on no readable chain list, and list(s) [\(listsUnreadable, privacy: .public)] could not be read") + continue case .unresolved: unresolved += 1 hadUnknownChain = true @@ -294,10 +298,14 @@ final class SwiftDashSDKKeyMigrator: NSObject { } let complete = !hadFailure && !hadUnknownChain + let listsSummary = inventory.unreadableChains.isEmpty + ? "[\(listsPresent)]" + : "[\(listsPresent)], unreadable [\(listsUnreadable)]" DWLogger.log( - "🔑 KEYMIG :: \(mnemonicAccounts.count) legacy mnemonic(s), chain lists [\(chainsPresent)]: \(migratedThisRun) migrated, " + "🔑 KEYMIG :: \(mnemonicAccounts.count) legacy mnemonic(s), chain lists \(listsSummary): \(migratedThisRun) migrated, " + "\(alreadyMigrated) already migrated, \(orphaned) orphaned (left in place), " - + "\(unsupportedChain) unsupported chain, \(unresolved) without a chain list, \(failed) failed; " + + "\(unsupportedChain) unsupported chain, \(unresolved) without a chain list, " + + "\(undetermined) on no readable list, \(failed) failed; " + (complete ? "migration complete" : "migration incomplete, will retry on next launch")) if complete { defaults.set("v1", forKey: doneKey) @@ -322,10 +330,11 @@ final class SwiftDashSDKKeyMigrator: NSObject { /// are not material to wait for (`DashSyncChainWalletLists`); a keychain /// holding only those is `.absent`, so setup is offered at once. A /// keychain that cannot be read (for example a background launch on a - /// locked device), including a chain list that does not read or decode, - /// is reported as such rather than as "nothing there": the launch hold - /// must not release into setup on a read error, because the upgrading - /// user's wallet may well be behind it. + /// locked device), or whose only unsettled mnemonics might be named by a + /// chain list that does not read or decode, is reported as such rather + /// than as "nothing there": the launch hold must not release into setup + /// on a read error, because the upgrading user's wallet may well be + /// behind it. static func legacyWalletMaterialState() -> LegacyWalletMigrationLaunchCoordinator.LegacyMaterialState { guard UserDefaults.standard.string(forKey: doneKey) == nil else { return .absent } do { @@ -336,10 +345,10 @@ final class SwiftDashSDKKeyMigrator: NSObject { guard !walletIDs.isEmpty else { return .absent } return DashSyncChainWalletLists.materialState( mnemonicWalletIDs: walletIDs, - chainLists: try readDashSyncChainWalletLists(from: accounts)) + inventory: readDashSyncChainWalletLists(from: accounts)) } catch { logger.error( - "🔑 KEYMIG :: DashSync keychain read failed during launch probe: \(String(describing: error), privacy: .public)") + "🔑 KEYMIG :: DashSync mnemonic enumeration failed during launch probe: \(String(describing: error), privacy: .public)") return .unreadable } } @@ -395,7 +404,6 @@ final class SwiftDashSDKKeyMigrator: NSObject { private enum LegacyMnemonicCleanupError: LocalizedError { case keychainRead(OSStatus) - case chainListUndecodable case mnemonicRead case deletionFailed case verificationFailed @@ -570,16 +578,17 @@ final class SwiftDashSDKKeyMigrator: NSObject { .sorted() } - /// DashSync's per-chain wallet lists, keyed by chain genesis short hex: - /// every `CHAIN_WALLETS_KEY_*` among `accounts`, read one item at a time. - /// The lists are AfterFirstUnlockThisDeviceOnly; a service-wide data query - /// would also pull every mnemonic secret and fail whenever the device is - /// locked. Throws on any read or decode failure rather than dropping that - /// list: a list that cannot be read might name any wallet, and missing - /// it must never turn a wallet into an orphan. - private static func readDashSyncChainWalletLists(from accounts: [String]) throws -> [String: [String]] { - var lists: [String: [String]] = [:] + /// DashSync's per-chain wallet lists: every `CHAIN_WALLETS_KEY_*` among + /// `accounts`, read one item at a time. The lists are + /// AfterFirstUnlockThisDeviceOnly; a service-wide data query would also + /// pull every mnemonic secret and fail whenever the device is locked. A + /// list that cannot be read or decoded goes to `unreadableChains`, never + /// into `lists` as empty: it might name any wallet, and missing it must + /// never turn a wallet into an orphan. + private static func readDashSyncChainWalletLists(from accounts: [String]) -> DashSyncChainWalletLists.Inventory { + var inventory = DashSyncChainWalletLists.Inventory() for account in accounts where account.hasPrefix(dashSyncChainWalletsKeyPrefix) { + let chain = String(account.dropFirst(dashSyncChainWalletsKeyPrefix.count)) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: dashSyncService, @@ -589,16 +598,16 @@ final class SwiftDashSDKKeyMigrator: NSObject { ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { - throw LegacyMnemonicCleanupError.keychainRead(status) - } - guard let data = result as? Data, - let walletIDs = DashSyncChainWalletLists.decodeWalletIDs(data) else { - throw LegacyMnemonicCleanupError.chainListUndecodable + if status == errSecSuccess, let data = result as? Data, + let walletIDs = DashSyncChainWalletLists.decodeWalletIDs(data) { + inventory.lists[chain] = walletIDs + } else { + inventory.unreadableChains.insert(chain) + logger.error( + "🔑 KEYMIG :: chain-wallet list \(chain, privacy: .public) could not be read or decoded, status \(status, privacy: .public)") } - lists[String(account.dropFirst(dashSyncChainWalletsKeyPrefix.count))] = walletIDs } - return lists + return inventory } /// Read a UTF-8 string value from a keychain item. Returns nil on any @@ -648,26 +657,18 @@ final class SwiftDashSDKKeyMigrator: NSObject { // one reset in the previous app and is skipped, never the failure // this fixture exists to reproduce. Ids already listed stay; a // list that does not read or decode is left alone. - let listAccount = dashSyncChainWalletsKeyPrefix + mainnetGenesisShortHex - var listedIDs: [String]? + var listed = false if let accounts = try? strictlyEnumerateDashSyncAccounts() { - listedIDs = accounts.contains(listAccount) - ? (try? readDashSyncChainWalletLists(from: [listAccount]))?[mainnetGenesisShortHex] - : [] - } - if let listedIDs, - let archive = try? NSKeyedArchiver.archivedData( - withRootObject: NSMutableArray( - array: listedIDs.contains(invalidWalletID) ? listedIDs : listedIDs + [invalidWalletID]), - requiringSecureCoding: false) { - _ = KeychainStore.set( - data: archive, - service: dashSyncService, - account: listAccount, - accessibility: .whenUnlockedThisDeviceOnly) + let inventory = readDashSyncChainWalletLists(from: accounts) + if !inventory.unreadableChains.contains(mainnetGenesisShortHex) { + let listedIDs = inventory.lists[mainnetGenesisShortHex] ?? [] + listed = debugWriteChainWalletList( + listedIDs.contains(invalidWalletID) ? listedIDs : listedIDs + [invalidWalletID], + chain: mainnetGenesisShortHex) + } } logger.warning( - "🔑 KEYMIG :: DEBUG invalid-mnemonic fixture installed (listed on mainnet: \(listedIDs != nil, privacy: .public))") + "🔑 KEYMIG :: DEBUG invalid-mnemonic fixture installed (listed on mainnet: \(listed, privacy: .public))") return } guard let phrase = env["LEGACY_KEYCHAIN_MNEMONIC"], !phrase.isEmpty else { return } @@ -702,17 +703,27 @@ final class SwiftDashSDKKeyMigrator: NSObject { data: nil, service: dashSyncService, account: dashSyncChainWalletsKeyPrefix + mainnetGenesisShortHex, - accessibility: .whenUnlockedThisDeviceOnly) - } else if let archive = try? NSKeyedArchiver.archivedData( - withRootObject: [walletID] as NSArray, requiringSecureCoding: false) { - _ = KeychainStore.set( - data: archive, - service: dashSyncService, - account: dashSyncChainWalletsKeyPrefix + mainnetGenesisShortHex, - accessibility: .whenUnlockedThisDeviceOnly) + accessibility: .afterFirstUnlockThisDeviceOnly) + } else { + _ = debugWriteChainWalletList([walletID], chain: mainnetGenesisShortHex) } logger.warning("🔑 KEYMIG :: DEBUG legacy fixture installed (orphan=\(orphan, privacy: .public))") } + + /// Write one chain-wallet list the way DashSync's `setKeychainArray(…, NO)` + /// did: an `NSMutableArray` archived without secure coding, stored + /// AfterFirstUnlockThisDeviceOnly. Replaces any list for that chain. + private static func debugWriteChainWalletList(_ walletIDs: [String], chain: String) -> Bool { + guard let archive = try? NSKeyedArchiver.archivedData( + withRootObject: NSMutableArray(array: walletIDs), requiringSecureCoding: false) else { + return false + } + return KeychainStore.set( + data: archive, + service: dashSyncService, + account: dashSyncChainWalletsKeyPrefix + chain, + accessibility: .afterFirstUnlockThisDeviceOnly) + } #endif } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift index 989c6c8241..77ea7a5012 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/WalletLifecycleTransitionState.swift @@ -501,7 +501,9 @@ final class LegacyWalletMigrationLaunchCoordinator: NSObject { /// What DashSync's own per-chain wallet lists say about its keychain /// mnemonics. Pure over plain data, so the launch probe and the key migrator /// reach the same verdict and it is testable without the keychain or the SDK. -/// Deliberately not actor-isolated: the migrator calls it from its queue. +/// It never touches the keychain: `SwiftDashSDKKeyMigrator`, the only reader +/// of DashSync's wallet items, hands it what it read. Deliberately not +/// actor-isolated: the migrator calls it from its queue. /// /// DashSync (the 8.x app) instantiated wallets only from the ids in /// `CHAIN_WALLETS_KEY_`. Its Reset removes the id from that @@ -509,37 +511,59 @@ final class LegacyWalletMigrationLaunchCoordinator: NSObject { /// deletes `WALLET_MNEMONIC_KEY_`. A mnemonic that no list names is /// therefore a wallet the user reset in the previous app, one 8.x itself no /// longer showed: nothing to migrate, and it stays in the keychain untouched. -/// That verdict needs every list read and decoded. With no list at all the -/// mnemonics stay unresolved, failing closed: a real Reset keeps its list, -/// so a keychain without any is a layout nothing here can vouch for. +/// That verdict needs every list read and decoded; a list that does not is +/// never taken as empty. A wallet a readable list names is listed whatever +/// happened to the other lists. With no list at all the mnemonics stay +/// unresolved, failing closed: a real Reset keeps its list, so a keychain +/// without any is a layout nothing here can vouch for. enum DashSyncChainWalletLists { + /// What the keychain held, as the migrator read it. + struct Inventory: Equatable { + /// Each chain's genesis short hex → the wallet ids its list names, + /// for every list that was read and decoded. + var lists: [String: [String]] = [:] + /// Chains whose list exists but could not be read or decoded. + var unreadableChains: Set = [] + } + enum Membership: Equatable { - /// Named by the lists of these chains (genesis short hex). + /// Named by the readable lists of these chains (genesis short hex). case listed(chains: Set) /// Every list was read and none names it: reset in the previous app. case orphaned + /// No readable list names it, but a list that could not be read might. + case undetermined /// No chain list exists at all, so nothing can be told. case unresolved } - /// `lists` maps each chain's genesis short hex to the wallet ids its list - /// names, as read from the keychain. - static func membership(ofWalletID walletID: String, in lists: [String: [String]]) -> Membership { - guard !lists.isEmpty else { return .unresolved } - let chains = Set(lists.compactMap { $0.value.contains(walletID) ? $0.key : nil }) - return chains.isEmpty ? .orphaned : .listed(chains: chains) + static func membership(ofWalletID walletID: String, in inventory: Inventory) -> Membership { + let chains = Set(inventory.lists.compactMap { $0.value.contains(walletID) ? $0.key : nil }) + if !chains.isEmpty { return .listed(chains: chains) } + if !inventory.unreadableChains.isEmpty { return .undetermined } + return inventory.lists.isEmpty ? .unresolved : .orphaned } - /// The launch probe's verdict for mnemonics whose lists were all read: - /// only a keychain of orphaned mnemonics leaves nothing to wait for. + /// The launch probe's verdict. Only a keychain whose every mnemonic is + /// orphaned leaves nothing to wait for. Any listed or unresolved mnemonic + /// is material the migrator must settle; if all that remains is + /// undetermined, the keychain is unreadable. static func materialState( mnemonicWalletIDs: [String], - chainLists: [String: [String]] + inventory: Inventory ) -> LegacyWalletMigrationLaunchCoordinator.LegacyMaterialState { - let onlyOrphans = mnemonicWalletIDs.allSatisfy { - membership(ofWalletID: $0, in: chainLists) == .orphaned + var undetermined = false + for walletID in mnemonicWalletIDs { + switch membership(ofWalletID: walletID, in: inventory) { + case .orphaned: + continue + case .undetermined: + undetermined = true + case .listed, .unresolved: + return .pending + } } - return onlyOrphans ? .absent : .pending + return undetermined ? .unreadable : .absent } /// One list decoded the way DashSync wrote it: `NSKeyedArchiver` with diff --git a/DashWalletTests/WalletLifecycleTransitionStateTests.swift b/DashWalletTests/WalletLifecycleTransitionStateTests.swift index 713f545273..b5b46d71a3 100644 --- a/DashWalletTests/WalletLifecycleTransitionStateTests.swift +++ b/DashWalletTests/WalletLifecycleTransitionStateTests.swift @@ -725,29 +725,57 @@ final class DashSyncChainWalletListsTests: XCTestCase { private let testnet = "2cbcf83" private let devnet = "1a2b3c4" + private func inventory(_ lists: [String: [String]], unreadable: Set = []) -> Lists.Inventory { + Lists.Inventory(lists: lists, unreadableChains: unreadable) + } + func testAListedWalletReportsTheChainsNamingIt() { - XCTAssertEqual(Lists.membership(ofWalletID: "a1", in: [mainnet: ["a1"], testnet: []]), + XCTAssertEqual(Lists.membership(ofWalletID: "a1", in: inventory([mainnet: ["a1"], testnet: []])), .listed(chains: [mainnet])) - XCTAssertEqual(Lists.membership(ofWalletID: "d1", in: [mainnet: [], devnet: ["d1"]]), + XCTAssertEqual(Lists.membership(ofWalletID: "d1", in: inventory([mainnet: [], devnet: ["d1"]])), .listed(chains: [devnet])) } + /// One list that does not read must not hold back a wallet a readable + /// list names. + func testAReadableListStillNamesAWalletWhenAnotherListIsUnreadable() { + XCTAssertEqual(Lists.membership(ofWalletID: "a1", in: inventory([mainnet: ["a1"]], unreadable: [devnet])), + .listed(chains: [mainnet])) + } + /// DashSync's Reset rewrites the list without the id, empty for the last /// wallet, and leaves the mnemonic behind. func testAMnemonicNoReadableListNamesIsOrphaned() { - XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: [mainnet: []]), .orphaned) - XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: [mainnet: ["a1"], testnet: ["t1"]]), .orphaned) + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: inventory([mainnet: []])), .orphaned) + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: inventory([mainnet: ["a1"], testnet: ["t1"]])), .orphaned) + } + + /// A list that does not read might name the mnemonic, so it is never + /// judged orphaned then. + func testAnUnreadableListLeavesAnUnnamedMnemonicUndetermined() { + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: inventory([mainnet: []], unreadable: [devnet])), .undetermined) + XCTAssertEqual(Lists.membership(ofWalletID: "r1", in: inventory([:], unreadable: [mainnet])), .undetermined) } func testNoChainListAtAllIsUnresolvedNotOrphaned() { - XCTAssertEqual(Lists.membership(ofWalletID: "x1", in: [:]), .unresolved) + XCTAssertEqual(Lists.membership(ofWalletID: "x1", in: inventory([:])), .unresolved) } func testOnlyOrphanedMnemonicsReleaseTheLaunch() { - XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "r2"], chainLists: [mainnet: []]), .absent) - XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "a1"], chainLists: [mainnet: ["a1"]]), .pending) - XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["d1"], chainLists: [devnet: ["d1"]]), .pending) - XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["x1"], chainLists: [:]), .pending) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "r2"], inventory: inventory([mainnet: []])), .absent) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "a1"], inventory: inventory([mainnet: ["a1"]])), .pending) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["d1"], inventory: inventory([devnet: ["d1"]])), .pending) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["x1"], inventory: inventory([:])), .pending) + } + + /// A wallet left to migrate keeps the launch waiting for the migrator; + /// only undetermined leftovers make the keychain unreadable. + func testAnUnreadableListMakesTheLaunchUnreadableUnlessAWalletIsListed() { + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1"], inventory: inventory([mainnet: []], unreadable: [devnet])), + .unreadable) + XCTAssertEqual(Lists.materialState(mnemonicWalletIDs: ["r1", "a1"], + inventory: inventory([mainnet: ["a1"]], unreadable: [devnet])), + .pending) } /// DashSync archives an `NSMutableArray` with `requiringSecureCoding: NO`.