diff --git a/src/stores/wallet.lifecycle.activation.cases.ts b/src/stores/wallet.lifecycle.activation.cases.ts new file mode 100644 index 0000000..e557bc4 --- /dev/null +++ b/src/stores/wallet.lifecycle.activation.cases.ts @@ -0,0 +1,102 @@ +import { + OWNER_ID, + PASSWORD, + deferred, + installLegacyEncryptedWallet, + installLegacyOwnerlessResidue, + mocks, +} from './wallet.lifecycle.testHarness'; +import { describe, expect, it, vi } from 'vitest'; + +import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys'; +import { readNwcConnections } from '@/lnurlcash/nwc'; +import { readPasskeySlots } from '@/lnurlcash/passkeys'; +import { readTrustedMints } from '@/lnurlcash/trustedMints'; +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +describe('serialized wallet activation', () => { + it('migrates a proven legacy owner before NWC can observe unlocked', async () => { + // Given an encrypted legacy wallet with ownerless authorization residue + await installLegacyEncryptedWallet(); + installLegacyOwnerlessResidue(); + const wallet = useWalletStore(); + useNwcStore(); + + // When password proof unlocks the wallet + await wallet.unlock(PASSWORD); + + // Then every legacy namespace belongs to the proven owner before startup + expect(savedKeyOwnerId()).toBe(OWNER_ID); + expect(readPasskeySlots()).toHaveLength(1); + expect(readNwcConnections(OWNER_ID)).toHaveLength(1); + expect(readTrustedMints(OWNER_ID)).toHaveLength(1); + expect(wallet.state).toBe('unlocked'); + await vi.waitFor(() => expect(mocks.startService).toHaveBeenCalledTimes(1)); + }); + + it('serializes a queued create behind an interrupted forget', async () => { + // Given an unlocked wallet whose NWC drain is deferred + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const drain = deferred(); + const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise); + + // When forget and create are requested without waiting between them + const forgetting = wallet.forgetWallet(); + const creating = wallet.create(); + await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled()); + + // Then the successor cannot install until the old owner drain completes, + // and the session keeps its commit capability while the drain runs + expect(savedKeyExists()).toBe(true); + expect(wallet.state).toBe('unlocked'); + drain.resolve(); + await forgetting; + const phrase = await creating; + expect(phrase.split(' ')).toHaveLength(12); + expect(wallet.state).toBe('unlocked'); + }); + + it('drains the active session before file restore reactivation', async () => { + // Given an unlocked wallet whose NWC drain is deferred + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const drain = deferred(); + const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise); + + // When a valid file restore starts + const restoring = wallet.restoreFromBackup({ + type: 'sattle-backup', + version: 1, + createdAt: 1, + bearers: [], + }); + await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled()); + + // Then deactivation waits for the drain before the lifecycle is + // invalidated, and reactivation returns to unlocked afterward + expect(wallet.state).toBe('unlocked'); + drain.resolve(); + await restoring; + expect(wallet.state).toBe('unlocked'); + }); + + it('serializes current-wallet Nostr restore through full reactivation', async () => { + // Given an unlocked wallet and a relay restore result + const wallet = useWalletStore(); + useNwcStore(); + await wallet.create(PASSWORD); + const ownerId = wallet.pubkey; + + // When the active-wallet Nostr restore runs + await wallet.restoreCurrentFromNostr(['wss://relay.example']); + + // Then the same owner is active only after the restore completed + expect(mocks.restoreFromNostr).toHaveBeenCalledTimes(1); + expect(wallet.state).toBe('unlocked'); + expect(wallet.pubkey).toBe(ownerId); + }); +}); diff --git a/src/stores/wallet.lifecycle.backupOwner.cases.ts b/src/stores/wallet.lifecycle.backupOwner.cases.ts new file mode 100644 index 0000000..500871b --- /dev/null +++ b/src/stores/wallet.lifecycle.backupOwner.cases.ts @@ -0,0 +1,57 @@ +import { + MINT_KEY, + OTHER_OWNER_ID, + OWNER_ID, + PASSWORD, + encryptedLinkingKeyRecord, +} from './wallet.lifecycle.testHarness'; +import { describe, expect, it } from 'vitest'; + +import { savedKeyOwnerId } from '@/lnurlcash/keys'; +import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +describe('hostile file backup owner', () => { + it('drops file trust until the restored encrypted key proves its actual owner', async () => { + // Given a fresh device and an encrypted backup whose valid owner claim is foreign + const wallet = useWalletStore(); + useNwcStore(); + const result = await wallet.restoreFromBackup({ + type: 'sattle-backup', + version: 1, + createdAt: 1, + ownerId: OTHER_OWNER_ID, + linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID }, + bearers: [], + trustedMints: [ + { + server: 'file-mint.example', + mintPubkey: MINT_KEY, + addedAt: 1, + locked: true, + pendingMintPubkey: '03' + 'bb'.repeat(32), + }, + ], + }); + const registryBeforeProof: unknown = JSON.parse( + localStorage.getItem('sattle_trusted_mints') ?? 'null', + ); + expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 }); + expect(savedKeyOwnerId()).toBeNull(); + expect(registryBeforeProof).toBeNull(); + + // When password proof activates the restored linking key + await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined(); + + // Then the file claim left no residue and only the derived owner can initialize trust + expect(readTrustedMints(OTHER_OWNER_ID)).toEqual([]); + expect(readTrustedMints(OWNER_ID)).toEqual([]); + await expect( + addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId: OWNER_ID }), + ).resolves.toBe('added'); + expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual( + expect.objectContaining({ ownerId: OWNER_ID }), + ); + }); +}); diff --git a/src/stores/wallet.lifecycle.isolation.cases.ts b/src/stores/wallet.lifecycle.isolation.cases.ts new file mode 100644 index 0000000..30cdc92 --- /dev/null +++ b/src/stores/wallet.lifecycle.isolation.cases.ts @@ -0,0 +1,89 @@ +import { + MINT_KEY, + OTHER_OWNER_ID, + PASSWORD, + installLegacyOwnerlessResidue, +} from './wallet.lifecycle.testHarness'; +import { describe, expect, it } from 'vitest'; + +import { generateSeedPhrase, savedKeyExists } from '@/lnurlcash/keys'; +import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc'; +import { readPasskeySlots } from '@/lnurlcash/passkeys'; +import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +describe('foreign wallet isolation', () => { + it('clears ownerless residue and the old trust tombstone before creating a successor', async () => { + // Given ownerless legacy authorization plus a prior owner's trust tombstone + installLegacyOwnerlessResidue(); + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ version: 1, ownerId: OTHER_OWNER_ID, mints: [] }), + ); + const wallet = useWalletStore(); + useNwcStore(); + + // When a new wallet is installed + await wallet.create(); + + // Then it starts without residue and can initialize its own trust registry + expect(wallet.pubkey).not.toBe(OTHER_OWNER_ID); + expect(readPasskeySlots()).toEqual([]); + expect(readNwcConnections(wallet.pubkey)).toEqual([]); + expect(readNwcEnabled(wallet.pubkey)).toBe(false); + expect(readTrustedMints(wallet.pubkey ?? undefined)).toEqual([]); + await expect( + addTrustedMint('successor.example', MINT_KEY, { ownerId: wallet.pubkey ?? '' }), + ).resolves.toBe('added'); + }); + + it('rejects malformed file restore without changing the installed state', async () => { + // Given an empty installation + const wallet = useWalletStore(); + + // When malformed backup input crosses the serialized restore boundary + const restoring = wallet.restoreFromBackup({ type: 'not-a-wallet' }); + + // Then it fails explicitly and does not install a partial wallet + await expect(restoring).rejects.toThrow(/valid sattle backup/i); + expect(wallet.state).toBe('none'); + expect(savedKeyExists()).toBe(false); + }); + + it('tears down the installed owner before a foreign seed restore', async () => { + // Given an installed wallet with owner-scoped credentials and trust + const wallet = useWalletStore(); + useNwcStore(); + await wallet.create(PASSWORD); + const oldOwner = wallet.pubkey; + if (oldOwner === null) throw new Error('Expected an unlocked owner.'); + 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: oldOwner, + }, + ]), + ); + await addTrustedMint('old.example', MINT_KEY, { ownerId: oldOwner }); + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ version: 1, ownerId: OTHER_OWNER_ID, mints: [] }), + ); + + // When a different seed replaces that installation + await wallet.restoreFromSeed(generateSeedPhrase()); + + // Then old credentials are gone and only the successor is active + expect(wallet.pubkey).not.toBe(oldOwner); + expect(localStorage.getItem('sattle_passkey_slots')).toBeNull(); + expect(readTrustedMints(oldOwner)).toEqual([]); + expect(readTrustedMints(wallet.pubkey ?? undefined)).toEqual([]); + }); +}); diff --git a/src/stores/walletAccess.ts b/src/stores/walletAccess.ts new file mode 100644 index 0000000..17bc85c --- /dev/null +++ b/src/stores/walletAccess.ts @@ -0,0 +1,68 @@ +import { + decryptSavedLinkingKey, + generateSeedPhrase, + getPlainLinkingKey, + savedKeyIsEncrypted, + savedKeyOwnerId, +} from '@/lnurlcash/keys'; +import { unlockWithPasskey } from '@/lnurlcash/passkeys'; +import { unlockWithBiometrics } from '@/capabilities/biometricUnlock'; + +type RunTransition = (transition: () => Promise) => Promise; +type InstallSeed = (seedPhrase: string, password?: string) => Promise; +type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise; + +type WalletAccessOptions = Readonly<{ + runTransition: RunTransition; + installSeed: InstallSeed; + activate: Activate; + canInit: () => boolean; +}>; + +export const createWalletAccess = ({ + runTransition, + installSeed, + activate, + canInit, +}: WalletAccessOptions) => { + const activateSavedKey = async (linkingKey: Uint8Array | null): Promise => { + if (!linkingKey) throw new Error('No wallet on this device.'); + await activate(linkingKey, savedKeyOwnerId() === null); + }; + const create = (password?: string): Promise => + runTransition(async () => { + const phrase = generateSeedPhrase(); + await installSeed(phrase, password); + return phrase; + }); + const restoreFromSeed = (seedPhrase: string, password?: string): Promise => + runTransition(() => installSeed(seedPhrase, password)); + const unlock = (password?: string): Promise => + runTransition(async () => { + const linkingKey = savedKeyIsEncrypted() + ? await decryptSavedLinkingKey(password || '') + : getPlainLinkingKey(); + await activateSavedKey(linkingKey); + }); + const unlockWithPasskeyCredential = (): Promise => + runTransition(async () => { + await activate(await unlockWithPasskey(), false); + }); + const unlockWithBiometric = (): Promise => + runTransition(async () => { + await activateSavedKey(await unlockWithBiometrics()); + }); + const init = (): Promise => + runTransition(async () => { + if (!canInit() || savedKeyIsEncrypted()) return; + await activateSavedKey(getPlainLinkingKey()); + }); + return { + create, + init, + restoreFromSeed, + unlock, + unlockWithBiometric, + unlockWithPasskey: unlockWithPasskeyCredential, + }; +}; diff --git a/src/stores/walletActivation.ts b/src/stores/walletActivation.ts new file mode 100644 index 0000000..af3beb4 --- /dev/null +++ b/src/stores/walletActivation.ts @@ -0,0 +1,23 @@ +import { serverOf } from 'lnurlcash-kit'; + +import { grandfatherTrustedMint } from '@/lnurlcash/trustedMints'; +import type { Bearer } from '@/lnurlcash/types'; + +export const restoreHeldMintTrust = async ( + bearers: readonly Bearer[], + ownerId: string, + onError: (message: string) => void, +): Promise => { + for (const bearer of bearers) { + if (!bearer.mintPubkey) continue; + try { + await grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey, ownerId); + } catch (error) { + onError( + error instanceof Error + ? `Funds loaded, but mint trust could not be restored: ${error.message}` + : 'Funds loaded, but mint trust could not be restored.', + ); + } + } +};