diff --git a/src/stores/wallet.lifecycle.test.ts b/src/stores/wallet.lifecycle.test.ts new file mode 100644 index 0000000..b33f3f5 --- /dev/null +++ b/src/stores/wallet.lifecycle.test.ts @@ -0,0 +1,6 @@ +import './wallet.lifecycle.activation.cases'; +import './wallet.lifecycle.teardown.cases'; +import './wallet.lifecycle.lockFailure.cases'; +import './wallet.lifecycle.isolation.cases'; +import './wallet.lifecycle.invalidation.cases'; +import './wallet.lifecycle.backupOwner.cases'; diff --git a/src/stores/wallet.lifecycle.testHarness.ts b/src/stores/wallet.lifecycle.testHarness.ts new file mode 100644 index 0000000..7613923 --- /dev/null +++ b/src/stores/wallet.lifecycle.testHarness.ts @@ -0,0 +1,122 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, vi } from 'vitest'; + +import { encryptSecretParts, linkingPubKeyHex } from '@/lnurlcash/keys'; +import type * as NwcExports from '@/lnurlcash/nwc'; +import type * as NostrBackupExports from '@/lnurlcash/nostrBackup'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { lifecycleMocks } from './wallet.lifecycle.testMocks'; + +export { lifecycleMocks as mocks } from './wallet.lifecycle.testMocks'; + +vi.mock('@/capabilities/biometricUnlock', async () => { + const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); + return { + disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock, + unlockWithBiometrics: vi.fn(), + }; +}); + +vi.mock('@/lnurlcash/nwc', async (importOriginal) => { + const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); + const actual = await importOriginal(); + return { ...actual, startService: lifecycleMocks.startService }; +}); + +vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => { + const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); + const actual = await importOriginal(); + return { ...actual, restoreFromNostr: lifecycleMocks.restoreFromNostr }; +}); + +export const LINKING_KEY = new Uint8Array(32).fill(7); +export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +export const OWNER_ID = linkingPubKeyHex(LINKING_KEY); +export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY); +export const PASSWORD = 'correct horse battery staple'; +export const MINT_KEY = '02' + 'aa'.repeat(32); + +export const encryptedLinkingKeyRecord = async () => { + const parts = await encryptSecretParts( + Array.from(LINKING_KEY, (byte) => byte.toString(16).padStart(2, '0')).join(''), + PASSWORD, + ); + return { enc: true as const, ...parts }; +}; + +type Deferred = { + readonly promise: Promise; + readonly resolve: () => void; +}; + +export const deferred = (): Deferred => { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: () => resolvePromise?.(), + }; +}; + +export const installLegacyOwnerlessResidue = (): void => { + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, + }, + ]), + ); + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + { + clientPubkey: '55'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 1000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + ]), + ); + localStorage.setItem('sattle_nwc_enabled', 'true'); + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify([ + { + server: 'legacy.example', + mintPubkey: MINT_KEY, + addedAt: 1, + locked: false, + }, + ]), + ); +}; + +export const installLegacyEncryptedWallet = async (): Promise => { + localStorage.setItem('sattle_linking_key', JSON.stringify(await encryptedLinkingKeyRecord())); +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + stubLocalStorage(); + setActivePinia(createPinia()); + lifecycleMocks.disableBiometricUnlock.mockResolvedValue(); + lifecycleMocks.restoreFromNostr.mockResolvedValue({ + added: 0, + skipped: 0, + linkingKeyRestored: false, + linkingKeySkipped: false, + trustedMintsAdded: 0, + settingsRestored: false, + found: [], + }); + lifecycleMocks.startService.mockResolvedValue({ stop: vi.fn().mockResolvedValue(undefined) }); +}); diff --git a/src/stores/wallet.lifecycle.testMocks.ts b/src/stores/wallet.lifecycle.testMocks.ts new file mode 100644 index 0000000..0fa789d --- /dev/null +++ b/src/stores/wallet.lifecycle.testMocks.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const lifecycleMocks = { + disableBiometricUnlock: vi.fn<() => Promise>(), + restoreFromNostr: vi.fn(), + startService: vi.fn(), +}; diff --git a/src/stores/walletLifecycle.ts b/src/stores/walletLifecycle.ts new file mode 100644 index 0000000..e0c19eb --- /dev/null +++ b/src/stores/walletLifecycle.ts @@ -0,0 +1,128 @@ +import { + deriveWalletLinkingKey, + ensureSavedKeyOwner, + isValidSeedPhrase, + linkingPubKeyHex, + saveLinkingKey, +} from '@/lnurlcash/keys'; +import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys'; +import { + clearPasskeySlotsForOwner, + clearUnownedPasskeySlots, + PASSKEY_SLOTS_STORAGE_KEY, +} from '@/lnurlcash/storage/passkeySlots'; +import { + clearNwcStorageForOwner, + clearUnownedNwcStorage, + migrateLegacyNwcStorage, +} from '@/lnurlcash/storage/nwcConnections'; +import { withStorageLock } from '@/lnurlcash/storageLock'; +import { + migrateLegacyTrustedMints, + removeTrustedMintsForOwner, + resetTrustedMintsForReplacement, +} from '@/lnurlcash/trustedMints'; + +export type WalletTransitionQueue = { + readonly run: (transition: () => Promise) => Promise; +}; + +type WalletTransitionQueueOptions = { + readonly onStart: () => void; + readonly onError: (error: unknown) => void; +}; + +export const createWalletTransitionQueue = ( + options: WalletTransitionQueueOptions, +): WalletTransitionQueue => { + let tail: Promise = Promise.resolve(); + return { + run: (transition: () => Promise): Promise => { + const execute = async (): Promise => { + options.onStart(); + try { + return await transition(); + } catch (error) { + options.onError(error); + throw error; + } + }; + const operation = tail.then(execute, execute); + tail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + }, + }; +}; + +export class WalletLifecycleError extends Error { + override readonly name = 'WalletLifecycleError'; + + constructor( + readonly transition: string, + cause: unknown, + ) { + const detail = cause instanceof Error ? cause.message : 'Unknown failure.'; + super(`Wallet ${transition} failed: ${detail}`, { cause }); + } +} + +export const stopWalletNwcSession = async (): Promise => { + const { useNwcStore } = await import('./nwc'); + await useNwcStore().stop(); +}; + +export const migrateProvenLegacyOwner = async ( + linkingKey: Uint8Array, + ownerWasMissing: boolean, +): Promise => { + ensureSavedKeyOwner(linkingKey); + if (!ownerWasMissing) return; + await migrateLegacyPasskeySlots(linkingKey); + migrateLegacyNwcStorage(linkingKey); + await migrateLegacyTrustedMints(linkingKey); +}; + +export const clearOwnerAuthorizations = async ( + ownerId: string, + resetRegistry = false, +): Promise => { + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { + clearPasskeySlotsForOwner(ownerId); + }); + clearNwcStorageForOwner(ownerId); + if (resetRegistry) await resetTrustedMintsForReplacement(); + else await removeTrustedMintsForOwner(ownerId); +}; + +export const clearUnownedAuthorizations = async (): Promise => { + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, clearUnownedPasskeySlots); + clearUnownedNwcStorage(); + await resetTrustedMintsForReplacement(); +}; + +export const ownerOf = (linkingKey: Uint8Array): string => linkingPubKeyHex(linkingKey); + +type SeedInstallerOptions = { + readonly prepareInstallation: (ownerId: string) => Promise; + readonly activate: (linkingKey: Uint8Array) => Promise; +}; + +export type SeedInstaller = ( + seedPhrase: string, + password?: string, + restore?: (linkingKey: Uint8Array) => Promise, +) => Promise; + +export const createSeedInstaller = + (options: SeedInstallerOptions): SeedInstaller => + async (seedPhrase, password, restore) => { + if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.'); + const linkingKey = deriveWalletLinkingKey(seedPhrase); + await options.prepareInstallation(ownerOf(linkingKey)); + if (restore) await restore(linkingKey); + await saveLinkingKey(linkingKey, password); + await options.activate(linkingKey); + };