From 2e0f0038f756d935c3674fb85c0d042a946ff9d8 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:56:25 +0200 Subject: [PATCH] feat: invalidate stale wallet tabs --- .../wallet.lifecycle.invalidation.cases.ts | 87 +++++++++++++++++++ src/stores/walletOwnerMonitor.ts | 39 +++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/stores/wallet.lifecycle.invalidation.cases.ts create mode 100644 src/stores/walletOwnerMonitor.ts diff --git a/src/stores/wallet.lifecycle.invalidation.cases.ts b/src/stores/wallet.lifecycle.invalidation.cases.ts new file mode 100644 index 0000000..f3926c9 --- /dev/null +++ b/src/stores/wallet.lifecycle.invalidation.cases.ts @@ -0,0 +1,87 @@ +import { MINT_KEY, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness'; +import { describe, expect, it, vi } from 'vitest'; + +import { savedKeyOwnerId } from '@/lnurlcash/keys'; +import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc'; +import type { NwcConnectionRecord } from '@/lnurlcash/nwc'; +import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; +import { useWalletStore } from './wallet'; + +describe('cross-tab owner invalidation', () => { + it('locks an old tab and rejects trust or NWC writes after replacement', async () => { + // Given an unlocked old-owner tab listening for browser storage events + const events = new EventTarget(); + vi.stubGlobal('window', events); + const wallet = useWalletStore(); + await wallet.create(PASSWORD); + const oldOwner = wallet.pubkey; + if (oldOwner === null) throw new Error('Expected an unlocked old owner.'); + + // When another tab has already recreated the wallet and a delayed event arrives + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }), + ); + events.dispatchEvent( + Object.defineProperties(new Event('storage'), { + key: { value: 'sattle_linking_key' }, + newValue: { value: JSON.stringify({ ownerId: oldOwner }) }, + }), + ); + await vi.waitFor(() => expect(wallet.state).toBe('locked')); + + // Then the stale runtime has no usable key and cannot recreate old-owner state + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow( + /owner/i, + ); + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull(); + writeNwcEnabled(OTHER_OWNER_ID, false); + expect(() => writeNwcEnabled(oldOwner, true)).toThrow(/owner/i); + expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false); + }); + + it('rejects old-owner trust and NWC writes during the markerless forget gap', async () => { + // Given wallet A was active and an old tab retained only its owner identifier + const wallet = useWalletStore(); + await wallet.create(PASSWORD); + const oldOwner = wallet.pubkey; + if (oldOwner === null) throw new Error('Expected an unlocked old owner.'); + const staleConnection: NwcConnectionRecord = { + version: 1, + ownerId: oldOwner, + clientPubkey: '55'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 1000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }; + + // When A is forgotten before successor B is installed + await wallet.forgetWallet(); + expect(savedKeyOwnerId()).toBeNull(); + + // Then no stale normal mutation can recreate A-owned state in the gap + await expect( + addTrustedMint('stale-gap.example', MINT_KEY, { ownerId: oldOwner }), + ).rejects.toThrow(/owner/i); + expect(() => writeNwcConnections(oldOwner, [staleConnection])).toThrow(/owner/i); + expect(() => writeNwcEnabled(oldOwner, true)).toThrow(/owner/i); + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull(); + expect(localStorage.getItem('sattle_nwc_connections')).toBeNull(); + expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull(); + + // When B is later installed + await wallet.create(PASSWORD); + const successorOwner = wallet.pubkey; + if (successorOwner === null) throw new Error('Expected an unlocked successor owner.'); + + // Then B starts clean and can create its own independent trust registry + await expect( + addTrustedMint('successor-gap.example', MINT_KEY, { ownerId: successorOwner }), + ).resolves.toBe('added'); + expect(readTrustedMints(successorOwner).map((mint) => mint.server)).toEqual([ + 'successor-gap.example', + ]); + }); +}); diff --git a/src/stores/walletOwnerMonitor.ts b/src/stores/walletOwnerMonitor.ts new file mode 100644 index 0000000..666f352 --- /dev/null +++ b/src/stores/walletOwnerMonitor.ts @@ -0,0 +1,39 @@ +import { savedKeyOwnerId } from '@/lnurlcash/keys'; +import { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents'; +import type { WalletState } from './walletOwnerFence'; + +type OwnerSnapshot = Readonly<{ + token: number; + state: WalletState; + ownerId: string | null; +}>; + +type WalletOwnerMonitor = Readonly<{ + snapshot: () => OwnerSnapshot; + deactivate: () => Promise; + runTransition: (transition: () => Promise) => Promise; +}>; + +export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) => + onSavedKeyStorageChange(() => { + const expected = monitor.snapshot(); + if ( + expected.state !== 'unlocked' || + expected.ownerId === null || + savedKeyOwnerId() === expected.ownerId + ) { + return; + } + void monitor.runTransition(async () => { + const current = monitor.snapshot(); + if ( + current.token !== expected.token || + current.state !== 'unlocked' || + current.ownerId !== expected.ownerId || + savedKeyOwnerId() === expected.ownerId + ) { + return; + } + await monitor.deactivate(); + }); + });