diff --git a/__tests__/orphanedSeed.test.ts b/__tests__/orphanedSeed.test.ts new file mode 100644 index 00000000..4a11893d --- /dev/null +++ b/__tests__/orphanedSeed.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + */ + +/** + * The onboarding cases, and which of them may touch the seed. + * + * A seed is ORPHANED when the keychain still holds one but this launch had to build + * the database from nothing — the container was wiped and the keychain survived it. + * That is the only case where resuming would derive from a zeroed counter against a + * seed the mint has already seen, and so the only one worth interrupting a user for. + * + * The schema half runs for real: instance.ts is driven through the op-sqlite mock, so + * "was the database built this launch" is decided by the production code path rather + * than by a stub of it. Only the keychain is mocked, because there isn't one under + * jest. + */ + +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +const mockHasWalletKeys = jest.fn() +jest.mock('../src/services/keyChain', () => ({ + KeyChain: {hasWalletKeys: mockHasWalletKeys}, +})) + +/** + * A database that already exists. Only the version row matters — it is the single + * fact instance.ts branches on, and stamping it at the current version means no + * migration runs, which is what a launch on an up-to-date wallet actually does. + */ +const seedExistingDatabase = (db: any) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {_dbVersion} = require('../src/services/db/migrations') + db.exec(`CREATE TABLE dbversion (id INTEGER PRIMARY KEY NOT NULL, version INTEGER, createdAt TEXT)`) + db.exec(`INSERT INTO dbversion (id, version, createdAt) VALUES (1, ${_dbVersion}, '2026-01-01')`) +} + +type Launch = {databaseExists: boolean; keysInKeychain: boolean} + +/** One app launch, up to the point setupRootStore captures the answer. */ +const launch = async ({databaseExists, keysInKeychain}: Launch) => { + jest.resetModules() + mockHasWalletKeys.mockReset() + mockHasWalletKeys.mockResolvedValue(keysInKeychain) + + if (databaseExists) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('@op-engineering/op-sqlite').__seedNextDatabase(seedExistingDatabase) + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {Database} = require('../src/services/db') + Database.getInstance() // what setupRootStore does before capturing + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const orphanedSeed = require('../src/services/orphanedSeed') + await orphanedSeed.captureOrphanedSeed() + + return orphanedSeed +} + +describe('orphaned seed detection — the onboarding cases', () => { + test('a fresh first install: no keys, no database', async () => { + const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false}) + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('an Android reinstall looks exactly like a first install', async () => { + // Not a separate code path, and that IS the finding: the keystore dies with the + // app's uid and allowBackup="false" stops Google Backup restoring a container, so + // Android arrives with neither keys nor schema and needs no special handling. + const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false}) + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('an iOS reinstall: keys survived, the database did not', async () => { + // The one case worth prompting on. iOS keeps keychain items across app deletion. + const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: true}) + + expect(hasOrphanedSeed()).toBe(true) + }) + + test('a TOS re-onboard leaves the seed alone', async () => { + // isOnboarded is flipped back to false to re-show the terms. The wallet, its + // database and its counters are all intact — there is nothing to decide. + const {hasOrphanedSeed} = await launch({databaseExists: true, keysInKeychain: true}) + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('replaying onboarding from the developer screen leaves the seed alone', async () => { + const {hasOrphanedSeed} = await launch({databaseExists: true, keysInKeychain: true}) + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('a factory reset leaves the seed alone, because it takes the keys with it', async () => { + // DeveloperScreen calls removeWalletKeys() alongside cleanAll(), so the next + // launch finds neither — a first install, not an orphan. + const {hasOrphanedSeed} = await launch({databaseExists: false, keysInKeychain: false}) + + expect(hasOrphanedSeed()).toBe(false) + }) +}) + +describe('the snapshot', () => { + test('keys created BY onboarding do not make the wallet look orphaned', async () => { + // The trap this module exists to avoid. On a fresh install the schema IS new, so + // a live check would turn true the instant onboarding saved its keys — offering + // to reset a seed thirty seconds old to anyone who backed out and came back. + const {hasOrphanedSeed, captureOrphanedSeed} = await launch({ + databaseExists: false, + keysInKeychain: false, + }) + + // Onboarding generates and saves keys; the keychain now has some. + mockHasWalletKeys.mockResolvedValue(true) + await captureOrphanedSeed() + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('resolving it clears it, so a fresh start is not re-offered', async () => { + const {hasOrphanedSeed, resolveOrphanedSeed} = await launch({ + databaseExists: false, + keysInKeychain: true, + }) + expect(hasOrphanedSeed()).toBe(true) + + resolveOrphanedSeed() + + expect(hasOrphanedSeed()).toBe(false) + }) + + test('an unreadable keychain does not fail the launch', async () => { + jest.resetModules() + mockHasWalletKeys.mockReset() + mockHasWalletKeys.mockRejectedValue(new Error('keychain unavailable')) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {Database} = require('../src/services/db') + Database.getInstance() + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const orphanedSeed = require('../src/services/orphanedSeed') + + await expect(orphanedSeed.captureOrphanedSeed()).resolves.toBeUndefined() + // Falls back to the behaviour every release until now shipped: resume the seed. + expect(orphanedSeed.hasOrphanedSeed()).toBe(false) + }) +}) diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 9007c372..9ffb34d2 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -768,6 +768,17 @@ "welcomeScreen_terms_agreeTerms": "Terms", "welcomeScreen_terms_agreeConjunction": "and", "welcomeScreen_terms_agreePrivacy": "Privacy Policy", + "orphanedSeed_title": "Wallet keys found", + "orphanedSeed_explainTitle": "Your wallet data is gone, your keys are not", + "orphanedSeed_explainDescription": "This device still holds the keys to a previous Minibits wallet, but none of its data. Removing the app deletes its ecash, history and mints, while iOS keeps its keys. The seed phrase below is the only way to reach any funds that wallet still holds.", + "orphanedSeed_copied": "Seed phrase copied to the clipboard.", + "orphanedSeed_recoverTitle": "Recover this wallet", + "orphanedSeed_recoverDescription": "Restore the ecash, the mints and your minibits.cash address from this seed phrase. Choose this if the previous wallet might still hold funds.", + "orphanedSeed_resetTitle": "Start a new wallet", + "orphanedSeed_resetDescription": "Discard the keys above and create a new wallet. Save the seed phrase first if you may need those funds later.", + "orphanedSeed_resetConfirmTitle": "Discard these keys?", + "orphanedSeed_resetConfirmDescription": "Without the seed phrase above, any ecash the previous wallet holds becomes unreachable. You will also get a new minibits.cash address, and contacts will no longer reach you at the old one.", + "orphanedSeed_resetConfirmButton": "Discard and start fresh", "mintSelector_noTopupSupport": "Top up not supported for this currency", "mintSelector_noPayoutSupport": "Payouts not supported for this currency", "mintSelector_noOnchainPayoutSupport": "This mint does not support Bitcoin payouts", diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index e6244f34..5dba3580 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -17,7 +17,7 @@ import { } from 'mobx-state-tree' import * as Sentry from '@sentry/react-native' import type { RootStore } from '../RootStore' -import { Database, MMKVStorage } from '../../services' +import { captureOrphanedSeed, Database, MMKVStorage } from '../../services' import type { MeltRecoverySeed, InFlightRequestSeed, CounterSeed } from '../../services/db' import type { Mint } from '../Mint' import { log } from '../../services/logService' @@ -93,6 +93,12 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO // hydrateMintsFromDatabase for why that must not be gated on a version. mintsStore.hydrateMintsFromDatabase() + // Did the keychain outlive the wallet? Answered HERE because both halves are + // only true here: the database has just been opened (so "was it built this + // launch" is meaningful), and no screen has rendered yet (so onboarding cannot + // have generated the very keys we are asking about). See services/orphanedSeed. + await captureOrphanedSeed() + if(walletProfileStore.walletId) { Sentry.setUser({ id: walletProfileStore.walletId }) } diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1e58472f..866f8a9f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -10,6 +10,7 @@ import React from "react" import Config from "../config" import { WelcomeScreen, + OrphanedSeedScreen, SeedRecoveryScreen, MintsScreen, SeedRecoveryOptionsScreen, @@ -42,8 +43,10 @@ const RootStack = createNativeStackNavigator({ //contentStyle: {backgroundColor: bgColor} }, screens: { - Welcome: WelcomeScreen, - + Welcome: WelcomeScreen, + // Only ever reached from Welcome, when the keychain outlived the wallet. + OrphanedSeed: OrphanedSeedScreen, + SeedRecovery: SeedRecoveryScreen, ImportBackup: ImportBackupScreen, RecoverWalletAddress: RecoverWalletAddressScreen, diff --git a/src/screens/OrphanedSeedScreen.tsx b/src/screens/OrphanedSeedScreen.tsx new file mode 100644 index 00000000..4bbc7e37 --- /dev/null +++ b/src/screens/OrphanedSeedScreen.tsx @@ -0,0 +1,269 @@ +import React, {useEffect, useState} from 'react' +import {FlatList, TextStyle, View, ViewStyle} from 'react-native' +import {scale} from '@gocodingnow/rn-size-matters' +import Clipboard from '@react-native-clipboard/clipboard' +import {StaticScreenProps, useNavigation} from '@react-navigation/native' +import { + $sizeStyles, + BottomModal, + Button, + Card, + ErrorModal, + InfoModal, + ListItem, + Loading, + Screen, + Text, +} from '../components' +import {translate} from '../i18n' +import {KeyChain, log, resolveOrphanedSeed} from '../services' +import {colors, spacing, useThemeColor} from '../theme' +import AppError from '../utils/AppError' + +type Props = StaticScreenProps + +/** + * The keychain outlived the wallet — offer the user the choice before anything derives. + * + * Reached from WelcomeScreen when `hasOrphanedSeed()` is true, which in practice means + * an iOS reinstall: the container (database, MMKV) went with the app, the keychain did + * not. The seed is therefore intact while every derivation counter is gone, and simply + * resuming would re-derive blinded secrets the mint has already signed. + * + * Two ways out, both safe, and the screen is careful not to make the choice for them: + * + * RECOVER — keep the seed and run the standard recovery, which walks the derivation + * space, moves each counter past what the mint has already seen, and brings the ecash + * back. It also recovers the profile from the seedHash, so the user keeps their + * @minibits.cash address. + * + * START FRESH — discard the seed and generate a new one. Safe by construction (a new + * seed has no history, so counter 0 is correct), but it abandons whatever the old + * seed still holds AND changes the user's identity: the Nostr keypair is derived from + * the mnemonic via NIP-06 and the walletId is regenerated, so the address changes and + * contacts can no longer reach them. That is why the mnemonic is shown and copyable + * BEFORE this is offered, and why it takes a confirmation. + */ +export const OrphanedSeedScreen = function ({route}: Props) { + const navigation = useNavigation() + const headerBg = useThemeColor('header') + const headerTitle = useThemeColor('headerTitle') + + const [mnemonic, setMnemonic] = useState() + const [mnemonicArray, setMnemonicArray] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isResetConfirmVisible, setIsResetConfirmVisible] = useState(false) + const [info, setInfo] = useState('') + const [error, setError] = useState() + + useEffect(() => { + const loadMnemonic = async () => { + try { + const keys = await KeyChain.getWalletKeys() + + // Defensive: the screen is only reachable when keys were found at startup, so + // this means they vanished underneath us. Nothing to decide — let onboarding + // carry on and generate a fresh set. + if (!keys) { + log.warn('[OrphanedSeedScreen]', 'No wallet keys found, returning to onboarding') + resolveOrphanedSeed() + navigation.goBack() + return + } + + setMnemonic(keys.SEED.mnemonic) + setMnemonicArray(keys.SEED.mnemonic.split(/\s+/)) + setIsLoading(false) + } catch (e: any) { + setIsLoading(false) + setError(e) + } + } + loadMnemonic() + }, []) + + const onCopy = function () { + try { + if (!mnemonic) return + Clipboard.setString(mnemonic) + setInfo(translate('orphanedSeed_copied')) + } catch (e: any) { + setInfo(translate('commonCopyFailParam', {param: e.message})) + } + } + + /** Keep the seed. Recovery restores the funds AND advances the counters. */ + const onRecover = function () { + // Deliberately NOT resolved: recovery may be abandoned half way, and a user who + // backs out has decided nothing. Asking again is correct. + navigation.navigate('SeedRecovery' as never) + } + + /** Discard the seed. Onboarding then generates a fresh one and derives from 0 safely. */ + const onStartFresh = async function () { + try { + setIsResetConfirmVisible(false) + setIsLoading(true) + + await KeyChain.removeWalletKeys() + // Clear the startup snapshot, or the freshly generated keys look orphaned too and + // onboarding offers to reset a seed that is seconds old. + resolveOrphanedSeed() + + log.info('[OrphanedSeedScreen]', 'Wallet keys discarded on user confirmation') + + navigation.goBack() + } catch (e: any) { + setIsLoading(false) + setError(e) + } + } + + return ( + + + + + + + } + /> + + {isLoading && } + ( +