diff --git a/e2e/specs/backup-security.spec.ts b/e2e/specs/backup-security.spec.ts index 1966a71..6aed51e 100644 --- a/e2e/specs/backup-security.spec.ts +++ b/e2e/specs/backup-security.spec.ts @@ -79,6 +79,97 @@ test.describe('Security page', () => { // auto-lock: display-only for now await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible(); }); + + test('lists only passkeys owned by the current wallet', async ({ page }) => { + // Given a browser with a PRF-capable authenticator probe and an unlocked wallet + await page.addInitScript(() => { + Object.defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { + isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true), + getClientCapabilities: () => Promise.resolve({ 'extension:prf': true }), + }, + }); + }); + await createFreshWallet(page); + await page.evaluate(() => { + const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}'); + if ( + typeof saved !== 'object' || + saved === null || + !('ownerId' in saved) || + typeof saved.ownerId !== 'string' + ) { + throw new Error('expected saved wallet owner'); + } + const wrap = { + hkdfSalt: '11'.repeat(16), + iv: '22'.repeat(12), + wrappedKey: '33'.repeat(48), + createdAt: 1, + }; + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + ...wrap, + credentialId: '44'.repeat(16), + name: 'Current wallet passkey', + ownerId: saved.ownerId, + version: 1, + }, + { + ...wrap, + credentialId: '55'.repeat(16), + name: 'Foreign wallet passkey', + ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', + version: 1, + }, + ]), + ); + }); + + // When the security management surface reads passkey slots + await page.goto('/#/settings/security'); + + // Then only the current owner's slot is rendered + await expect(page.getByText('Current wallet passkey')).toBeVisible(); + await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0); + }); + + test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => { + // Given an encrypted saved key and passkey slot without owner markers + await page.addInitScript(() => { + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: true, + salt: '11'.repeat(16), + iv: '22'.repeat(12), + ciphertext: '33'.repeat(48), + }), + ); + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + credentialId: '44'.repeat(16), + hkdfSalt: '55'.repeat(16), + iv: '66'.repeat(12), + wrappedKey: '77'.repeat(48), + createdAt: 1, + }, + ]), + ); + }); + + // When the locked wallet renders + await page.goto('/'); + + // Then passkey-first unlock is unavailable until another owner proof + await expect(page.getByText('Wallet locked')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0); + }); }); test.describe('Welcome: restore from nostr', () => { diff --git a/e2e/specs/wallet-lifecycle.spec.ts b/e2e/specs/wallet-lifecycle.spec.ts new file mode 100644 index 0000000..217ee15 --- /dev/null +++ b/e2e/specs/wallet-lifecycle.spec.ts @@ -0,0 +1,242 @@ +import { bytesToHex } from '@noble/hashes/utils.js'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; + +import { test, expect } from '../fixtures'; + +// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet +// with ownerless passkey/NWC/trust residue must migrate to the proven owner +// during password unlock - before the NWC service may start - and forgetting +// the wallet must drain/stop that service and remove every wallet-owned key +// before a successor wallet can be created without any of the old residue. +// +// The legacy encrypted record is produced here with the exact same KDF/wrap +// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the +// owner marker a current build would stamp. + +declare global { + interface Window { + __sattleWalletTest: { state: () => string; forget: () => Promise }; + } +} + +const PASSWORD = 'correct horse battery staple'; +const LINKING_KEY_HEX = '07'.repeat(32); +const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true)); +const MINT_PUBKEY = '02' + 'aa'.repeat(32); + +function hexToBytesLocal(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +const PBKDF2_ITERATIONS = 210_000; + +const legacyEncryptedRecord = async ( + valueHex: string, + password: string, +): Promise<{ salt: string; iv: string; ciphertext: string }> => { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const baseKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveKey'], + ); + const aesKey = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + new TextEncoder().encode(valueHex), + ), + ); + return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) }; +}; + +const WALLET_KEYS = [ + 'sattle_linking_key', + 'sattle_bearers', + 'sattle_activity', + 'sattle_settings', + 'sattle_passkey_slots', + 'sattle_nwc_connections', + 'sattle_nwc_enabled', + 'sattle_trusted_mints', + 'sattle_biometric_wrap', +] as const; + +test.describe('wallet lifecycle', () => { + test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({ + page, + }) => { + // Given a legacy encrypted wallet (no owner marker) plus ownerless residue + const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD); + await page.addInitScript( + ({ storedRecord, mintPubkey }) => { + localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord })); + 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, addedAt: 1, locked: false }]), + ); + }, + { storedRecord: record, mintPubkey: MINT_PUBKEY }, + ); + + await page.goto('/#/'); + + // the unlock screen proves the app boot finished and the dev hooks exist + await expect(page.getByText('Wallet locked')).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => typeof window.__sattleNwcTest)) + .toBe('object'); + + // no real relay traffic once the migrated enabled state starts the service + await page.evaluate(() => { + window.__nwcSubs = []; + window.__sattleNwcTest.setTransport({ + publish: () => Promise.resolve(), + subscribe: () => { + const sub = { + closed: false, + close() { + this.closed = true; + }, + }; + window.__nwcSubs.push(sub); + return sub; + }, + }); + }); + + // When the holder proves the wallet by password + await page.locator('.unlock-card input').fill(PASSWORD); + await page.getByRole('button', { name: 'Unlock', exact: true }).click(); + + // Then the wallet unlocks... + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + + // ...and every legacy namespace was migrated to the proven owner BEFORE + // the NWC service could start (the subscriptions below only exist because + // the migrated owner-scoped enabled record read true) + const migrated = await page.evaluate(() => { + const field = (storageKey: string, name: string): unknown => { + const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); + return typeof value === 'object' && value !== null + ? Object.entries(value).find(([key]) => key === name)?.[1] + : undefined; + }; + const fields = (storageKey: string, name: string): unknown[] => { + const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]'); + if (!Array.isArray(value)) return []; + return value.map((entry: unknown) => + typeof entry === 'object' && entry !== null + ? Object.entries(entry).find(([key]) => key === name)?.[1] + : undefined, + ); + }; + const mints: unknown = field('sattle_trusted_mints', 'mints'); + return { + savedKeyOwner: field('sattle_linking_key', 'ownerId'), + passkeyOwners: fields('sattle_passkey_slots', 'ownerId'), + nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'), + nwcEnabled: field('sattle_nwc_enabled', 'enabled'), + nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'), + trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'), + trustedMintServers: Array.isArray(mints) + ? mints.map((mint: unknown) => + typeof mint === 'object' && mint !== null && 'server' in mint + ? mint.server + : undefined, + ) + : [], + }; + }); + expect(migrated.savedKeyOwner).toBe(OWNER_ID); + expect(migrated.passkeyOwners).toEqual([OWNER_ID]); + expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({ + ownerId: OWNER_ID, + enabled: true, + }); + expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]); + expect(migrated.trustedMintsOwner).toBe(OWNER_ID); + expect(migrated.trustedMintServers).toEqual(['legacy.example']); + await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0); + + // When the wallet is forgotten + await page.evaluate(() => window.__sattleWalletTest.forget()); + + // Then the app lands on the no-wallet screen, the service is drained + // (every subscription closed), and no wallet-owned key remains + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed))) + .toBe(true); + const remaining = await page.evaluate( + (keys) => keys.filter((key) => localStorage.getItem(key) !== null), + [...WALLET_KEYS], + ); + expect(remaining).toEqual([]); + + // When a successor wallet is created + await page.getByRole('button', { name: 'Get started' }).click(); + await page.getByRole('button', { name: 'Create wallet' }).click(); + await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + + // Then it is a different owner with zero adopted residue + const successor = await page.evaluate(() => { + const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null'); + return { + ownerId: + typeof saved === 'object' && saved !== null && 'ownerId' in saved + ? saved.ownerId + : undefined, + residueKeys: [ + 'sattle_passkey_slots', + 'sattle_nwc_connections', + 'sattle_nwc_enabled', + 'sattle_trusted_mints', + ].filter((key) => localStorage.getItem(key) !== null), + }; + }); + expect(successor.ownerId).not.toBe(OWNER_ID); + expect(successor.residueKeys).toEqual([]); + }); +}); diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts index 84b4476..88f9f58 100644 --- a/src/boot/wallet.ts +++ b/src/boot/wallet.ts @@ -3,6 +3,15 @@ import { useWalletStore } from '@/stores/wallet'; import { useNostrBackupStore } from '@/stores/nostrBackup'; import { useNwcStore } from '@/stores/nwc'; +declare global { + interface Window { + __sattleWalletTest?: { + readonly state: () => ReturnType['state']; + readonly forget: () => Promise; + }; + } +} + // Wallet lifecycle bootstrap: reflects whatever is on this device into the // wallet store at app start - a plaintext-stored key unlocks straight away, // a password-encrypted one lands on 'locked' for the unlock screen, and no @@ -17,3 +26,12 @@ export default defineBoot(async () => { // client requests; on lock it stops and drops the key-material closure useNwcStore(); }); + +// dev-only e2e hook: lets a spec observe the wallet state and drive the +// forget transition, which has no UI surface. Never in production builds. +if (import.meta.env.DEV && typeof window !== 'undefined') { + window.__sattleWalletTest = { + state: () => useWalletStore().state, + forget: () => useWalletStore().forgetWallet(), + }; +} diff --git a/src/pages/BackupPage.vue b/src/pages/BackupPage.vue index 2a27217..bcf5d91 100644 --- a/src/pages/BackupPage.vue +++ b/src/pages/BackupPage.vue @@ -172,7 +172,7 @@ const errorMessage = (err: unknown): string => // ---- backup file ---- const downloadBackupFile = () => { - const backup = buildBackup(); + const backup = buildBackup(wallet.pubkey ?? undefined); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json', });