From 3acc63e96866bd8c7f690499322f6cc631c0e329 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:56:25 +0200 Subject: [PATCH] fix: clear runtime state after lifecycle failures --- .../wallet.lifecycle.lockFailure.cases.ts | 45 ++++++ src/stores/wallet.lifecycle.teardown.cases.ts | 151 ++++++++++++++++++ src/stores/walletIdle.test.ts | 126 +++++++++++++++ src/stores/walletIdle.ts | 74 +++++++++ 4 files changed, 396 insertions(+) create mode 100644 src/stores/wallet.lifecycle.lockFailure.cases.ts create mode 100644 src/stores/wallet.lifecycle.teardown.cases.ts create mode 100644 src/stores/walletIdle.test.ts create mode 100644 src/stores/walletIdle.ts diff --git a/src/stores/wallet.lifecycle.lockFailure.cases.ts b/src/stores/wallet.lifecycle.lockFailure.cases.ts new file mode 100644 index 0000000..7b0d7de --- /dev/null +++ b/src/stores/wallet.lifecycle.lockFailure.cases.ts @@ -0,0 +1,45 @@ +import { PASSWORD, mocks } from './wallet.lifecycle.testHarness'; +import { afterEach, beforeEach, describe, expect, vi, it } from 'vitest'; + +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +const AUTO_LOCK_MS = 5 * 60 * 1000; + +describe('idle auto-lock failure', () => { + beforeEach(() => { + vi.stubGlobal('window', { + addEventListener: () => undefined, + removeEventListener: () => undefined, + }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears runtime keys and surfaces the error when an idle auto-lock drain fails', async () => { + // Given an encrypted unlocked wallet whose live NWC service rejects shutdown + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const serviceStop = vi.fn().mockRejectedValue(new Error('idle drain failed')); + mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop }); + await nwc.setEnabled(true); + expect(nwc.running).toBe(true); + expect(wallet.state).toBe('unlocked'); + + // When the wallet goes idle past the auto-lock timeout + await vi.advanceTimersByTimeAsync(AUTO_LOCK_MS + 2000); + + // Then the rejected drain still leaves a truthful locked state: no usable + // key material, the failure surfaced, the service handle dropped + expect(wallet.state).toBe('locked'); + expect(wallet.lifecycleError).toMatch(/idle drain failed/i); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(wallet.bearers).toEqual([]); + expect(nwc.running).toBe(false); + expect(serviceStop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/stores/wallet.lifecycle.teardown.cases.ts b/src/stores/wallet.lifecycle.teardown.cases.ts new file mode 100644 index 0000000..5821063 --- /dev/null +++ b/src/stores/wallet.lifecycle.teardown.cases.ts @@ -0,0 +1,151 @@ +import { MINT_KEY, PASSWORD, deferred, mocks } from './wallet.lifecycle.testHarness'; +import { describe, expect, it, vi } from 'vitest'; + +import { savedKeyExists } from '@/lnurlcash/keys'; +import { addTrustedMint } from '@/lnurlcash/trustedMints'; +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +describe('complete owner teardown', () => { + it('clears runtime keys and the NWC service when ordinary lock drain fails', async () => { + // Given an encrypted unlocked wallet whose live NWC service rejects shutdown + const serviceStop = vi.fn().mockRejectedValue(new Error('NWC lock drain failed')); + mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop }); + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + await nwc.setEnabled(true); + expect(nwc.running).toBe(true); + + // When ordinary lock invalidates the session and shutdown rejects + const locking = wallet.lock(); + + // Then failure is truthful while no runtime capability remains usable + await expect(locking).rejects.toThrow('NWC lock drain failed'); + expect(wallet.state).toBe('locked'); + expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(wallet.bearers).toEqual([]); + expect(nwc.running).toBe(false); + expect(serviceStop).toHaveBeenCalledTimes(1); + }); + + it('waits for NWC drain and removes owner state plus idle listeners before none', async () => { + // Given an encrypted wallet with owner data and tracked window listeners + const listeners = new Map>(); + const cleanupOrder: string[] = []; + vi.stubGlobal('window', { + addEventListener: (event: string, listener: EventListenerOrEventListenerObject) => { + const registered = listeners.get(event) ?? new Set(); + registered.add(listener); + listeners.set(event, registered); + }, + removeEventListener: (event: string, listener: EventListenerOrEventListenerObject) => { + cleanupOrder.push(`listener:${event}`); + listeners.get(event)?.delete(listener); + }, + }); + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + await addTrustedMint('owned.example', MINT_KEY, { ownerId }); + 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, + ownerId, + }, + ]), + ); + const drain = deferred(); + const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise); + const removeItem = localStorage.removeItem.bind(localStorage); + localStorage.removeItem = (key: string): void => { + cleanupOrder.push(`storage:${key}`); + removeItem(key); + }; + + // When forget starts while NWC work is still draining + const forgetting = wallet.forgetWallet(); + await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled()); + + // Then completion and destructive storage removal wait for the drain, + // and the session stays commit-capable (fence and keys live) until it + // finishes so accepted NWC work can still reach its durable outcome + expect(savedKeyExists()).toBe(true); + expect(wallet.state).toBe('unlocked'); + drain.resolve(); + await forgetting; + expect(wallet.state).toBe('none'); + expect(savedKeyExists()).toBe(false); + expect(localStorage.getItem('sattle_passkey_slots')).toBeNull(); + expect(localStorage.getItem('sattle_nwc_connections')).toBeNull(); + expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull(); + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull(); + expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true); + expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan( + cleanupOrder.indexOf('storage:sattle_linking_key'), + ); + }); + + it('surfaces biometric deletion failure without reporting completion', async () => { + // Given an unlocked wallet whose secure-storage deletion rejects + const wallet = useWalletStore(); + useNwcStore(); + await wallet.create(PASSWORD); + mocks.disableBiometricUnlock.mockRejectedValue(new Error('secure delete failed')); + + // When forget reaches biometric teardown + const forgetting = wallet.forgetWallet(); + + // Then the caller sees failure and the saved key is not falsely removed + await expect(forgetting).rejects.toThrow('secure delete failed'); + expect(wallet.state).toBe('locked'); + expect(wallet.lifecycleError).toMatch(/secure delete failed/i); + expect(savedKeyExists()).toBe(true); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + }); + + it('surfaces NWC drain failure without removing the saved owner', async () => { + // Given an unlocked wallet whose service drain rejects + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + vi.spyOn(nwc, 'stop').mockRejectedValue(new Error('NWC drain failed')); + + // When forget invalidates the session and requests the drain + const forgetting = wallet.forgetWallet(); + + // Then teardown rejects before deleting owner storage or reporting none + await expect(forgetting).rejects.toThrow('NWC drain failed'); + expect(wallet.state).toBe('locked'); + expect(wallet.lifecycleError).toMatch(/NWC drain failed/i); + expect(savedKeyExists()).toBe(true); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + }); + + it('completes teardown on retry after a transient biometric deletion failure', async () => { + // Given a forget that failed at secure-storage deletion + const wallet = useWalletStore(); + useNwcStore(); + await wallet.create(PASSWORD); + mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed')); + await expect(wallet.forgetWallet()).rejects.toThrow('secure delete failed'); + expect(wallet.state).toBe('locked'); + + // When the holder retries the forget + await wallet.forgetWallet(); + + // Then teardown completes and the error surface clears + expect(wallet.state).toBe('none'); + expect(wallet.lifecycleError).toBe(''); + expect(savedKeyExists()).toBe(false); + }); +}); diff --git a/src/stores/walletIdle.test.ts b/src/stores/walletIdle.test.ts new file mode 100644 index 0000000..f384649 --- /dev/null +++ b/src/stores/walletIdle.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createWalletIdleWatch } from './walletIdle'; + +type ListenerMap = Map>; + +const stubWindowListeners = (): ListenerMap => { + const listeners: ListenerMap = new Map(); + vi.stubGlobal('window', { + addEventListener: (event: string, listener: EventListenerOrEventListenerObject) => { + const registered = listeners.get(event) ?? new Set(); + registered.add(listener); + listeners.set(event, registered); + }, + removeEventListener: (event: string, listener: EventListenerOrEventListenerObject) => { + listeners.get(event)?.delete(listener); + }, + }); + return listeners; +}; + +const fireActivity = (listeners: ListenerMap): void => { + const handler = listeners.get('mousemove')?.values().next().value; + if (typeof handler !== 'function') throw new Error('Expected a registered activity listener.'); + handler(new Event('mousemove')); +}; + +const AUTO_LOCK_MS = 5 * 60 * 1000; +const LOCK_WARNING_MS = 30 * 1000; + +const startWatch = (lock: () => Promise) => { + const listeners = stubWindowListeners(); + let warningSecondsLeft: number | null = null; + const watch = createWalletIdleWatch({ + isEncrypted: () => true, + isUnlocked: () => true, + isLockWarningVisible: () => warningSecondsLeft !== null, + lock, + setWarningSecondsLeft: (seconds) => { + warningSecondsLeft = seconds; + }, + }); + watch.start(); + return { + listeners, + watch, + warningSecondsLeft: () => warningSecondsLeft, + }; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('wallet idle watch', () => { + it('locks on schedule once the warning is up, ignoring passive activity', () => { + // Given an unlocked encrypted wallet idle long enough to show the warning + const lock = vi.fn<() => Promise>().mockResolvedValue(undefined); + const { listeners, warningSecondsLeft } = startWatch(lock); + vi.advanceTimersByTime(AUTO_LOCK_MS - LOCK_WARNING_MS + 1000); + expect(warningSecondsLeft()).toBe(LOCK_WARNING_MS / 1000 - 1); + + // When passive activity arrives while the warning is displayed + fireActivity(listeners); + + // Then the countdown is NOT reset - only an explicit postpone dismisses + // the warning, so the "stay unlocked" affordance cannot vanish under the + // pointer + vi.advanceTimersByTime(LOCK_WARNING_MS - 1000); + expect(lock).toHaveBeenCalledTimes(1); + }); + + it('postpones the auto-lock on activity before any warning', () => { + // Given an unlocked encrypted wallet with regular activity + const lock = vi.fn<() => Promise>().mockResolvedValue(undefined); + const { listeners, warningSecondsLeft } = startWatch(lock); + + // When activity keeps arriving before the warning threshold + vi.advanceTimersByTime(AUTO_LOCK_MS - 60 * 1000); + fireActivity(listeners); + vi.advanceTimersByTime(AUTO_LOCK_MS - 60 * 1000); + + // Then no warning and no lock + expect(warningSecondsLeft()).toBeNull(); + expect(lock).not.toHaveBeenCalled(); + }); + + it('detaches every activity listener and stops ticking on stop', () => { + // Given a running watch + const lock = vi.fn<() => Promise>().mockResolvedValue(undefined); + const { listeners, watch } = startWatch(lock); + expect(listeners.size).toBeGreaterThan(0); + + // When the watch stops + watch.stop(); + + // Then every window listener is detached and the timer is gone + expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true); + vi.advanceTimersByTime(AUTO_LOCK_MS * 2); + expect(lock).not.toHaveBeenCalled(); + }); + + it('surfaces a rejected auto-lock through the lock promise without an unhandled rejection', async () => { + // Given an unlocked encrypted wallet whose lock transition fails (the + // wallet's transition queue records the failure in lifecycleError - the + // idle watch only owes the promise a consumer) + const lock = vi.fn<() => Promise>().mockRejectedValue(new Error('NWC drain failed')); + startWatch(lock); + + // When the idle timeout fires + vi.advanceTimersByTime(AUTO_LOCK_MS + 1000); + await vi.advanceTimersByTimeAsync(0); + + // Then the lock was requested and the watch keeps scheduling (a throw + // escaping the interval callback would kill it) + expect(lock).toHaveBeenCalled(); + const calls = lock.mock.calls.length; + vi.advanceTimersByTime(2000); + expect(lock.mock.calls.length).toBeGreaterThan(calls); + }); +}); diff --git a/src/stores/walletIdle.ts b/src/stores/walletIdle.ts new file mode 100644 index 0000000..d329e33 --- /dev/null +++ b/src/stores/walletIdle.ts @@ -0,0 +1,74 @@ +const AUTO_LOCK_MS = 5 * 60 * 1000; +const LOCK_WARNING_MS = 30 * 1000; +const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const; + +type WalletIdleOptions = { + readonly isEncrypted: () => boolean; + readonly isUnlocked: () => boolean; + // once the warning is up, passive activity is deliberately ignored - only + // postpone() dismisses it, so the "stay unlocked" button can't vanish out + // from under the pointer before the click lands + readonly isLockWarningVisible: () => boolean; + readonly lock: () => Promise; + readonly setWarningSecondsLeft: (seconds: number | null) => void; +}; + +export type WalletIdleWatch = { + readonly start: () => void; + readonly stop: () => void; + readonly postpone: () => void; +}; + +export const createWalletIdleWatch = (options: WalletIdleOptions): WalletIdleWatch => { + let lastActivity = Date.now(); + let timer: ReturnType | null = null; + let activityListener: (() => void) | null = null; + + const stop = (): void => { + if (timer !== null) clearInterval(timer); + timer = null; + if (typeof window !== 'undefined' && activityListener !== null) { + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, activityListener); + } + } + activityListener = null; + options.setWarningSecondsLeft(null); + }; + + const postpone = (): void => { + lastActivity = Date.now(); + options.setWarningSecondsLeft(null); + }; + + const start = (): void => { + stop(); + if (typeof window === 'undefined' || !options.isEncrypted()) return; + lastActivity = Date.now(); + activityListener = () => { + if (options.isUnlocked() && !options.isLockWarningVisible()) { + lastActivity = Date.now(); + } + }; + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, activityListener, { passive: true }); + } + timer = setInterval(() => { + if (!options.isUnlocked()) return; + const elapsed = Date.now() - lastActivity; + if (elapsed >= AUTO_LOCK_MS) { + // lock() routes through the wallet's transition queue, which records + // any rejection in lifecycleError - the failure IS surfaced there; + // this catch only keeps the fire-and-forget promise from becoming + // an unhandled rejection + void options.lock().catch(() => undefined); + return; + } + if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) { + options.setWarningSecondsLeft(Math.ceil((AUTO_LOCK_MS - elapsed) / 1000)); + } + }, 1000); + }; + + return { start, stop, postpone }; +};