Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 34 additions & 23 deletions DASHSYNC_KEY_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@ multi-wallet and active-wallet behavior.

## Deployment model and invariant

The migrator ships in the same release as the final DashSync removal. It may
read old DashSync state, but it must never mutate or delete the DashSync-owned
keychain service `org.dashfoundation.dash`.
The migrator ships in the same release as the final DashSync removal. Normal
migration treats old DashSync state as read-only. Explicit production
`Wallets -> Remove`, recovery wipe, and confirmed `Delete All` may delete only
the affected `WALLET_MNEMONIC_KEY_*` accounts from the DashSync-owned keychain
service `org.dashfoundation.dash`. They never delete the service, PIN, chain
lists, or unrelated records.

All new writes go to SDK-owned persistence:

- `PlatformWalletManager` / SwiftData for managed-wallet rows;
- SwiftDashSDK `WalletStorage` for mnemonic bytes keyed by SDK wallet ID;
- `WalletEnvironment` UserDefaults entries for the active wallet per network.

DashSync mnemonic entries remain a read-only recovery/rollback source.
DashSync mnemonic entries remain a recovery/rollback source until the user
explicitly removes the corresponding wallet.

## Frozen DashSync keychain contract

Expand Down Expand Up @@ -91,10 +95,10 @@ work.
## Host recovery behavior

If SwiftData wallet rows are missing but SDK-owned mnemonic entries remain,
`SwiftDashSDKHost.recoverPersistedWallet` recreates managed wallets from every
valid stored mnemonic. Wallet creation is idempotent by SDK wallet ID. Because
mnemonics are network-agnostic while wallet IDs can be network-specific, the
host re-stores the mnemonic under the created ID when needed.
`SwiftDashSDKHost.recoverPersistedWallet` recreates only wallets whose stored
ID belongs to the runtime network. It never stores a mainnet mirror from a
testnet ID or vice versa, and rejects a created ID that differs from the
stored source ID.

Create/import derives the deterministic SDK wallet ID, stores and verifies the
SDK-owned mnemonic under that ID, and only then creates the live managed
Expand All @@ -115,8 +119,17 @@ state belonging to a wallet already deleted successfully may be cleared with
that wallet during a partially successful attempt.

Every wipe entry point waits for the same explicit result before navigating or
creating a replacement wallet. The frozen DashSync mnemonic keychain service
`org.dashfoundation.dash` remains read-only and is never deleted by app wipe.
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
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.

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
Expand All @@ -136,20 +149,17 @@ copy that says so (it is not reported as a phrase mismatch). The plain `wipe`
shortcut is allowed only with exactly one stored wallet ID and only while the
active network is mainnet, because the published balance is scoped to the
active wallet/network and cannot prove a mainnet balance while running on
testnet. The explicit strong acceptance phrase overrides both checks on this
path. Forgot-PIN recovery stays non-destructive and may prove ownership with
any one stored wallet phrase.
testnet. The support acknowledgement is accepted only by the separate Support
Wipe path. Forgot-PIN recovery stays non-destructive and may prove ownership
with any one stored wallet phrase.

Every call to `DWSwiftDashSDKWalletWiper` supplies an explicit authorization
reason. Recovery-screen wipes use `.recoveryFlow`; screenshot-triggered wallet
replacement uses `.screenshotReplacement`; and phrase-less global deletion
uses `.confirmedDeleteAll`. The post-reinstall and lock-screen routes obtain a
strict count of distinct stored recovery phrases and, when more than one is
present, name that count and require an explicit Delete All confirmation. A
Keychain inventory error never produces the destructive action. The dev-only
Reset All Wallets item uses the same all-wallet confirmation wording. Any new
wipe entry point must collect one of these authorizations before invoking the
wiper.
reason. Ordinary recovery-screen wipes use `.recoveryFlow`; Support Wipe uses
`.confirmedDeleteAll`; screenshot-triggered replacement uses
`.screenshotReplacement`; and the dev-only reset uses `.debugReset`.
Post-reinstall and lock-screen Delete All warnings deliberately omit an SDK-only
count because the operation may also remove legacy-only seeds. Any new wipe
entry point must collect one of these authorizations before invoking the wiper.

## Acceptance criteria

Expand All @@ -167,7 +177,8 @@ wiper.
any additional wallet ID is stored or while the active network is testnet;
- a successful wipe deletes all SDK-owned mnemonic/managed-wallet/active-wallet
state, while a failed wipe reports failure without an app-side seed deletion;
- no migration code deletes `org.dashfoundation.dash` entries;
- normal migration never deletes legacy entries; explicit production Remove
and Delete All delete only the intended legacy mnemonic accounts;
- both app schemes build and upgrade/multi-wallet/wipe runtime smokes pass.

## Source files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,43 @@ final class SwiftDashSDKHost {
}
}

/// Networks represented by a strict SDK-owned Keychain inventory. Used
/// after reinstall to select a sole stored network without manufacturing
/// a network mirror from the same seed.
nonisolated static func persistedSDKWalletNetworks(
in entries: [(walletId: Data, mnemonic: String)]
) throws -> Set<Network> {
try Set(entries.map { entry in
try SwiftDashSDKStoredWalletNetworkResolver.resolve(
walletId: entry.walletId,
mnemonic: entry.mnemonic
).network
})
}

nonisolated static func persistedSDKWalletNetworks() throws -> Set<Network> {
try persistedSDKWalletNetworks(in: strictlyPersistedMnemonics())
}

/// Pure filtering used by reinstall recovery: a manager must only receive
/// entries whose deterministic id belongs to that manager's network.
nonisolated static func recoverablePersistedMnemonics(
_ entries: [(walletId: Data, mnemonic: String)],
for network: Network
) -> [(walletId: Data, mnemonic: String)] {
entries.compactMap { entry in
let mnemonic = Mnemonic.normalizePhrase(entry.mnemonic)
guard Mnemonic.validate(mnemonic),
let resolution = try? SwiftDashSDKStoredWalletNetworkResolver.resolve(
walletId: entry.walletId,
mnemonic: mnemonic),
resolution.network == network else {
return nil
}
return (walletId: entry.walletId, mnemonic: mnemonic)
}
}

/// Strict exact-id read for flows that already selected a concrete wallet.
/// Unlike the legacy active-wallet reader, this never substitutes another
/// Keychain entry when the requested id is missing or unreadable.
Expand Down Expand Up @@ -905,22 +942,19 @@ final class SwiftDashSDKHost {
/// `walletNotFound` retry: re-create wallet rows from the keychain
/// mnemonics (`persistedMnemonics`). One attempt per entry, no retry loop —
/// `createWallet` is idempotent by walletId (`Wallet(mnemonic:network:).id`),
/// so a re-run after a partial failure converges. The mnemonic is re-stored
/// under the created walletId when the stored key was derived for a
/// different network (mnemonics are network-agnostic; ids aren't).
/// so a re-run after a partial failure converges. Network-scoped ids are
/// never replayed through the other network's manager.
/// Wipe race note: the wiper deletes mnemonics before `handleWalletWiped`,
/// so a refresh racing a wipe finds an empty list here and fails the start
/// — and the next refresh's `hasSDKWallet` gate stays closed.
private func recoverPersistedWallet(handles: RuntimeHandles) -> ManagedPlatformWallet? {
let entries = Self.persistedMnemonics()
let inventory = Self.persistedMnemonics()
let entries = Self.recoverablePersistedMnemonics(
inventory,
for: handles.network)
guard !entries.isEmpty else { return nil }

let storage = WalletStorage()
for entry in entries {
guard Mnemonic.validate(entry.mnemonic) else {
Self.logger.error("🪺 HOST :: skipping keychain mnemonic for \(entry.walletId.prefix(4).map { String(format: "%02x", $0) }.joined(), privacy: .public)… — failed validation")
continue
}
do {
let created = try handles.manager.createWallet(
mnemonic: entry.mnemonic,
Expand All @@ -938,15 +972,16 @@ final class SwiftDashSDKHost {
// funds is not.
birthHeight: Self.importedWalletBirthHeight(for: handles.network))
if created.walletId != entry.walletId {
try? storage.storeMnemonic(entry.mnemonic, for: created.walletId)
try? handles.manager.deleteWallet(walletId: created.walletId)
Self.logger.error("🪺 HOST :: recovered wallet id did not match its network-scoped Keychain id; discarded created wallet")
}
} catch {
Self.logger.error("🪺 HOST :: keychain wallet recovery failed for one entry: \(String(describing: error), privacy: .public)")
}
}

guard let resolved = resolveActiveWallet(in: handles.manager, network: handles.network) else { return nil }
Self.logger.info("🪺 HOST :: recovered persisted wallet from keychain mnemonic(s); entries=\(entries.count, privacy: .public)")
Self.logger.info("🪺 HOST :: recovered persisted wallet from keychain mnemonic(s); network=\(handles.network.networkName, privacy: .public) eligible=\(entries.count, privacy: .public) skipped=\(inventory.count - entries.count, privacy: .public)")
return resolved
}

Expand Down
Loading
Loading