diff --git a/AGENTS.md b/AGENTS.md index 43a4dd5..f9f713f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,25 +25,25 @@ e2e/ # Playwright (own AGENTS.md) ## WHERE TO LOOK -| Task | Location | Notes | -|------|----------|-------| -| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state | -| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern | -| Native feature | `src/capabilities/` | never import plugins elsewhere | -| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent | -| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters | +| Task | Location | Notes | +| ------------------- | ------------------------------------------------------ | ---------------------------------------------- | +| Any fund movement | `src/lnurlcash/ops/` | engine returns changesets, never mutates state | +| Add a settings page | `src/pages/` + `router/routes.ts` + SettingsPage group | back-button header pattern | +| Native feature | `src/capabilities/` | never import plugins elsewhere | +| Change wallet state | stores call ops + `addBearers`/`markSpent` | add fresh notes BEFORE marking spent | +| Mint fee math | `src/lnurlcash/fees.ts` | gross vs net direction matters | ## CODE MAP -| Module | Role | -|--------|------| -| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer | -| `lnurlcash/storage/` | AES-GCM bearers+activity, backup (merge entry point), settings | -| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap | -| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key | -| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup | -| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) | -| `stores/wallet.ts` | state none/locked/unlocked; linking key in memory only while unlocked | +| Module | Role | +| -------------------------- | --------------------------------------------------------------------------------------------------------- | +| `lnurlcash/ops.ts` | façade: carve/mint/pay/receiveBearer/transfer | +| `lnurlcash/storage/` | AES-GCM bearers+activity, owner-bound credentials and trusted mints, backup (merge entry point), settings | +| `lnurlcash/keys.ts` | BIP39 + LUD-05 linking key; password wrap | +| `lnurlcash/passkeys.ts` | WebAuthn PRF wrap of the SAME linking key | +| `lnurlcash/nostrBackup.ts` | kind-30078 NIP-44 backup + restore via applyBackup | +| `lnurlcash/nwc/` | NIP-47 wallet service (per-connection budget) | +| `stores/wallet.ts` | serialized owner lifecycle; state none/locked/unlocked; linking key in memory only while unlocked | ## CONVENTIONS @@ -79,8 +79,22 @@ npm run cap:sync # build + cap sync android - NixOS: use the flake dev shell (nodejs_22 + chromium for e2e). `sass-embedded` is aliased to pure-JS `sass` via npm overrides. -- lnurlcash-kit comes from `github:TheCryptoDonkey/lnurlcash-kit` pinned to - a commit — the `prepare` script (our merged PR#1) builds dist on install. +- `lnurlcash-kit@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm + registry packages. Keep their manifest pins, registry tarball URLs, and lock + integrity values intact. +- Credential and authorization records are bound to the canonical owner ID + derived from the linking key. Ordinary writes require an exact match with the + persisted saved-key owner. Ownerless legacy records migrate only after a + proven unlock, never from a restored file claim or passkey-first attempt. +- Wallet create, unlock, lock, restore, and forget transitions are serialized. + Forget locks first, drains NWC, clears runtime keys and owner namespaces, + then removes the saved key only after biometric deletion succeeds. +- Storage events are wakeups, not authoritative payloads. Re-read matching + storage on an event, including `key === null`, before converging state or + invalidating a stale owner tab. Web Locks serialize supported browsers but + do not make another tab's localStorage cache current; trusted mints reconcile + through a durable IndexedDB commit mirror before success. `withStorageLock` + falls back to local execution without a cross-tab guarantee. - tsconfig deliberately relaxed (`exactOptionalPropertyTypes` etc. off) to keep the protocol core untouched; `src/lnurlcash` has an eslint override. - Gitea remote dropped; origin = GitHub. Gitea mirror = pull-mirror on the diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index 6b3acdc..e46a39d 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -40,8 +40,10 @@ interface MockNoteInfoOptions { } interface MockTargetMintOptions { - // the mint's signing pubkey (66 hex chars), advertised in the payRequest - // and note responses + // the mint's signing pubkey (66 hex chars), advertised on the mint-address + // (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest: + // LUD-25 announces it on the withdraw side only, and a too-generous mock + // once masked a real bug mintPubkey: string; // the invoice this mint hands out (and reports back as settled) - keep it // amount-less (decodeBolt11AmountMsat returns null) so the kit skips the @@ -97,24 +99,38 @@ export class MintMocker { ); } - // A mint that never answers its mint-address discovery endpoint with - // anything usable - prepareMint treats that as "no mint-address support" - // and falls back to the plain LNURL-pay guess. - private async mockNoMintAddress(origin: string): Promise { + // The mint-address discovery endpoint (LUD-25): announces the signing key + // and the node stats a real lnurl-mint advertises. `nodeCapacity` is msat + // under its WIRE name (no suffix) - the kit has to map it onto + // nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises. + // The payLink points back at the lnurlp route below, as prepareMint treats + // it as the authoritative place to read the payRequest from. + private async mockMintAddress(options: MockTargetMintOptions, origin: string): Promise { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), async (route: Route) => { - await fulfillJson(route, { status: 'ERROR', reason: 'not supported' }); + await fulfillJson(route, { + tag: 'withdrawRequest', + callback: `${origin}${CALLBACK_PATH}`, + minWithdrawable: 1000, + maxWithdrawable: 100_000_000_000, + mintPubkey: options.mintPubkey, + payLink: `${origin}/.well-known/lnurlp/mint`, + nodeCapacity: 500_000_000, + nodeNumChannels: 4, + nodeNumPeers: 6, + }); }, ); } // Everything the target side of a transfer (or a Lightning receive) - // needs: the payRequest at the standard mint@ address, the invoice - // callback, an immediately-settled verify endpoint revealing the - // preimage, and the note info + rotate the claim then performs. + // needs: the mint-address discovery endpoint carrying the mint metadata, + // the payRequest at the standard mint@ address, the invoice callback, an + // immediately-settled verify endpoint revealing the preimage, and the + // note info + rotate the claim then performs. async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise { - await this.mockNoMintAddress(origin); + await this.mockMintAddress(options, origin); await this.mockNoteInfo( { amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, origin, @@ -129,7 +145,6 @@ export class MintMocker { minSendable: 1000, maxSendable: 100_000_000_000, withdrawLink: `${origin}${NOTE_PATH}`, - mintPubkey: options.mintPubkey, metadata: options.mintFeeMetadata ?? '[]', }); }, 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/nwc.spec.ts b/e2e/specs/nwc.spec.ts index bfa9a1e..e7a77ce 100644 --- a/e2e/specs/nwc.spec.ts +++ b/e2e/specs/nwc.spec.ts @@ -63,9 +63,10 @@ test.describe('NWC settings page', () => { await expect(page.getByText('shown only once', { exact: false })).toBeVisible(); const uri = (await page.locator('.nwc-connection-string').textContent())?.trim(); expect(uri).toBeTruthy(); + if (!uri) throw new Error('Expected the one-time NWC connection string.'); // a well-formed NIP-47 connection string - const url = new URL(uri!); + const url = new URL(uri); expect(url.protocol).toBe('nostr+walletconnect:'); expect(url.host).toMatch(/^[0-9a-f]{64}$/); expect(url.searchParams.get('secret')).toMatch(/^[0-9a-f]{64}$/); diff --git a/e2e/specs/receive.spec.ts b/e2e/specs/receive.spec.ts index ad55851..f5eeee7 100644 --- a/e2e/specs/receive.spec.ts +++ b/e2e/specs/receive.spec.ts @@ -5,6 +5,7 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; import { createFreshWallet } from '../helpers/wallet'; const AMOUNT_MSAT = 21_000; // 21 sats +const MINT_PUBKEY = `02${'aa'.repeat(32)}`; // a syntactically valid bearer note against the mock mint - the k1 is a // fresh random secret, so every test redeems a distinct note @@ -58,4 +59,54 @@ test.describe('Receive bearer note', () => { await page.keyboard.press('Escape'); await expect(page.locator('.balance-card .text-h2')).toHaveText('0'); }); + + test('an already trusted current-owner mint bypasses the first-contact prompt', async ({ + page, + mint, + }) => { + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); + await mint.mockRotateOk(); + await createFreshWallet(page); + const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await redeemNote(page, freshNoteUrl()); + const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' }); + await expect(trustDialog).toBeVisible(); + await trustDialog.getByRole('button', { name: 'Just this once' }).click(); + await expect(trustDialog).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(receiveDialog).toHaveCount(0); + + await redeemNote(page, freshNoteUrl()); + + await expect(receiveDialog.getByText('Received 21 sats')).toBeVisible(); + await expect(trustDialog).toHaveCount(0); + await expect(page.locator('.balance-card .text-h2')).toHaveText('42'); + }); + + test('trust failure after commit keeps received funds and warns against retry', async ({ + page, + mint, + }) => { + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); + await mint.mockRotateOk(); + await createFreshWallet(page); + await page.evaluate(() => { + localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}'); + }); + + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await redeemNote(page, freshNoteUrl()); + + await expect(dialog.getByText('Received 21 sats')).toBeVisible(); + await expect( + page.getByText(/Funds were saved.*receive succeeded.*do not retry/i), + ).toBeVisible(); + await expect(dialog.locator('.q-banner')).toHaveCount(0); + const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' }); + await trustDialog.getByRole('button', { name: 'Just this once' }).click(); + await dialog.getByRole('button', { name: 'Done' }).click(); + await page.reload(); + + await expect(page.locator('.balance-card .text-h2')).toHaveText('21'); + }); }); diff --git a/e2e/specs/trusted-mint-tabs.spec.ts b/e2e/specs/trusted-mint-tabs.spec.ts new file mode 100644 index 0000000..ff2566c --- /dev/null +++ b/e2e/specs/trusted-mint-tabs.spec.ts @@ -0,0 +1,208 @@ +import type { Page, TestInfo } from '@playwright/test'; + +import { test, expect } from '../fixtures'; +import { createFreshWallet } from '../helpers/wallet'; + +const MINT_KEY_A = `02${'aa'.repeat(32)}`; +const MINT_KEY_B = `03${'bb'.repeat(32)}`; +const MINT_KEY_C = `02${'cc'.repeat(32)}`; +const VIEWPORT_WIDTHS = [375, 768, 1280] as const; +const CONCURRENT_ADD_ROUNDS = 10; + +const mintsList = (page: Page) => page.locator('.q-list', { hasText: 'Your mints' }); + +const fillMintForm = async (page: Page, server: string, mintPubkey: string): Promise => { + await page.getByLabel('Server').fill(server); + await page.getByLabel('Signing key (66 hex characters)').fill(mintPubkey); +}; + +const addMint = async (page: Page, server: string, mintPubkey: string): Promise => { + await fillMintForm(page, server, mintPubkey); + await page.getByRole('button', { name: 'Trust this mint' }).click(); +}; + +const ownerId = async (page: Page): Promise => + page.evaluate(() => { + const raw = localStorage.getItem('sattle_linking_key'); + if (raw === null) return null; + const saved: unknown = JSON.parse(raw); + if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null; + return typeof saved.ownerId === 'string' ? saved.ownerId : null; + }); + +const storedMintServers = async (page: Page): Promise => + page.evaluate(() => { + const raw = localStorage.getItem('sattle_trusted_mints'); + if (raw === null) return []; + const registry: unknown = JSON.parse(raw); + if ( + typeof registry !== 'object' || + registry === null || + !('mints' in registry) || + !Array.isArray(registry.mints) + ) { + return []; + } + return registry.mints + .map((mint) => + typeof mint === 'object' && + mint !== null && + 'server' in mint && + typeof mint.server === 'string' + ? mint.server + : '', + ) + .sort(); + }); + +const retainFailureEvidence = async ( + testInfo: TestInfo, + pages: readonly Page[], + consoleMessages: readonly string[], +): Promise => { + for (const [index, page] of pages.entries()) { + if (page.isClosed()) continue; + const path = testInfo.outputPath(`tab-${index + 1}-failure.png`); + await page.screenshot({ path, fullPage: true }); + await testInfo.attach(`tab-${index + 1}-failure`, { path, contentType: 'image/png' }); + } + await testInfo.attach('browser-console', { + body: consoleMessages.join('\n'), + contentType: 'text/plain', + }); +}; + +test.describe('trusted mint tabs', () => { + test('remote updates converge and concurrent additions survive Web Locks', async ({ + page, + }, testInfo) => { + await createFreshWallet(page); + test.skip( + !(await page.evaluate(() => 'locks' in navigator)), + 'Web Locks are unavailable, so this browser provides no concurrent-write guarantee.', + ); + + const remote = await page.context().newPage(); + const consoleMessages: string[] = []; + for (const [name, current] of [ + ['first', page], + ['second', remote], + ] as const) { + current.on('console', (message) => { + consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`); + }); + } + + try { + await Promise.all([page.goto('/#/settings/mints'), remote.goto('/#/settings/mints')]); + await expect(mintsList(page).getByText('No mints yet', { exact: false })).toBeVisible(); + await expect(mintsList(remote).getByText('No mints yet', { exact: false })).toBeVisible(); + + // A real storage event from the second tab updates the first tab without reload. + await addMint(remote, 'remote.example', MINT_KEY_A); + await expect(mintsList(page).getByText('remote.example', { exact: true })).toBeVisible(); + + // Repeated UI races from separate pages must all survive Web Locks. + const expectedServers = ['remote.example']; + for (let round = 1; round <= CONCURRENT_ADD_ROUNDS; round++) { + const firstServer = `first-${round}.example`; + const secondServer = `second-${round}.example`; + expectedServers.push(firstServer, secondServer); + await Promise.all([ + fillMintForm(page, firstServer, MINT_KEY_B), + fillMintForm(remote, secondServer, MINT_KEY_C), + ]); + await Promise.all([ + page.getByRole('button', { name: 'Trust this mint' }).click(), + remote.getByRole('button', { name: 'Trust this mint' }).click(), + ]); + await expect.poll(() => storedMintServers(page)).toEqual([...expectedServers].sort()); + } + + for (const width of VIEWPORT_WIDTHS) { + await Promise.all([ + page.setViewportSize({ width, height: 900 }), + remote.setViewportSize({ width, height: 900 }), + ]); + await expect(mintsList(page).getByText('first-10.example', { exact: true })).toBeVisible(); + await expect(mintsList(page).getByText('second-10.example', { exact: true })).toBeVisible(); + await expect( + mintsList(remote).getByText('first-10.example', { exact: true }), + ).toBeVisible(); + await expect( + mintsList(remote).getByText('second-10.example', { exact: true }), + ).toBeVisible(); + } + expect(await storedMintServers(page)).toEqual([...expectedServers].sort()); + } catch (error) { + await retainFailureEvidence(testInfo, [page, remote], consoleMessages); + throw error; + } finally { + await remote.close(); + } + }); +}); + +test.describe('wallet ownership', () => { + test('a stale owner tab locks and cannot recreate trust or NWC state', async ({ + page, + }, testInfo) => { + await createFreshWallet(page); + const oldOwner = await ownerId(page); + expect(oldOwner).not.toBeNull(); + + const stale = await page.context().newPage(); + const consoleMessages: string[] = []; + for (const [name, current] of [ + ['successor', page], + ['stale', stale], + ] as const) { + current.on('console', (message) => { + consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`); + }); + } + + try { + await stale.goto('/#/settings/mints'); + await expect(mintsList(stale).getByText('No mints yet', { exact: false })).toBeVisible(); + + // The active page forgets owner A, then creates owner B through the rendered onboarding flow. + await page.evaluate(() => window.__sattleWalletTest.forget()); + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); + await stale.goto('/#/'); + await expect(stale.getByText('Wallet locked')).toBeVisible(); + + 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(); + + const successorOwner = await ownerId(page); + expect(successorOwner).not.toBe(oldOwner); + for (const width of VIEWPORT_WIDTHS) { + await stale.setViewportSize({ width, height: 900 }); + await expect(stale.getByText('Wallet locked')).toBeVisible(); + } + + // A stale settings route cannot call owner-bound mutations after invalidation. + await stale.goto('/#/settings/mints'); + await addMint(stale, 'stale.example', MINT_KEY_A); + await expect(stale.locator('.q-banner', { hasText: 'Wallet is locked.' })).toBeVisible(); + await stale.goto('/#/settings/nwc'); + await expect(stale.locator('.q-page', { hasText: 'Unlock your wallet first' })).toBeVisible(); + + const residue = await page.evaluate(() => ({ + trustedMints: localStorage.getItem('sattle_trusted_mints'), + nwcConnections: localStorage.getItem('sattle_nwc_connections'), + nwcEnabled: localStorage.getItem('sattle_nwc_enabled'), + })); + expect(residue).toEqual({ trustedMints: null, nwcConnections: null, nwcEnabled: null }); + } catch (error) { + await retainFailureEvidence(testInfo, [page, stale], consoleMessages); + throw error; + } finally { + await stale.close(); + } + }); +}); 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/package-lock.json b/package-lock.json index 462aa0e..e859c3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76", + "lnurlcash-kit": "0.1.1", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -46,7 +46,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance", + "lnurlcash-conformance": "0.1.1", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", @@ -5961,8 +5961,9 @@ } }, "node_modules/lnurlcash-conformance": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-conformance.git#3d63f8ac3f3b8b87f5b490ff4a55c310afe4e5e2", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz", + "integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==", "dev": true, "license": "MIT", "dependencies": { @@ -5986,9 +5987,9 @@ } }, "node_modules/lnurlcash-kit": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-kit.git#392aeaf6682f23a0e66d28175a9df62802cd1d76", - "integrity": "sha512-xgO7ykIBD5SdGV7M6xyhDJcfgbkvlHxmBmXu2gBs/Cew/RAfH7GRZSCdeVexo6vRGDXhX6IjqqqzfFXrYqkM9A==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz", + "integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==", "license": "MIT", "dependencies": { "@noble/curves": "^2.3.0", diff --git a/package.json b/package.json index 8b678d6..5a68016 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76", + "lnurlcash-kit": "0.1.1", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -57,7 +57,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "github:TheCryptoDonkey/lnurlcash-conformance", + "lnurlcash-conformance": "0.1.1", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", diff --git a/playwright.config.ts b/playwright.config.ts index f4e07da..ebc968a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,8 @@ export default defineConfig({ use: { baseURL: 'http://localhost:9333', - trace: 'on-first-retry', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', serviceWorkers: 'block', launchOptions: { executablePath: process.env.CHROMIUM_PATH || findSystemChromium(), 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/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts new file mode 100644 index 0000000..150777d --- /dev/null +++ b/src/capabilities/biometricUnlock.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils.js'; + +import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys'; +import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; +import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots'; +import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils'; + +const pluginMocks = vi.hoisted(() => ({ + authenticate: vi.fn<() => Promise>(), + secureGet: vi.fn<(key: string) => Promise>(), +})); + +vi.mock('@aparajita/capacitor-biometric-auth', () => ({ + AndroidBiometryStrength: { weak: 'weak' }, + BiometricAuth: { authenticate: pluginMocks.authenticate }, + BiometryError: class BiometryError extends Error {}, + BiometryErrorType: { userCancel: 'userCancel' }, +})); + +vi.mock('@aparajita/capacitor-secure-storage', () => ({ + SecureStorage: { get: pluginMocks.secureGet }, +})); + +vi.mock('./platform', () => ({ isNative: () => true })); + +import { unlockWithBiometrics } from './biometricUnlock'; + +const LINKING_KEY = new Uint8Array(32).fill(7); +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const WRAP_SECRET = new Uint8Array(32).fill(3); + +beforeEach(() => { + vi.clearAllMocks(); + stubLocalStorage(); + pluginMocks.authenticate.mockResolvedValue(); + pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); +}); + +describe('biometric unlock owner proof', () => { + it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => { + // Given an ownerless legacy wallet and credential residue plus a biometric wrap + // whose claimed pubkey does not match the key it unwraps + const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) }; + const legacySlot = { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, + }; + const legacyNwc = [ + { + clientPubkey: '55'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 1000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + ]; + const legacyTrust = [ + { + server: 'legacy.example', + mintPubkey: '02' + 'aa'.repeat(32), + addedAt: 1, + locked: false, + }, + ]; + localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey)); + localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot])); + localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc)); + localStorage.setItem('sattle_nwc_enabled', 'true'); + localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust)); + const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY); + localStorage.setItem( + 'sattle_biometric_wrap', + JSON.stringify({ + ...wrap, + pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), + createdAt: 1, + }), + ); + const before = new Map([ + ['sattle_linking_key', localStorage.getItem('sattle_linking_key')], + ['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')], + ['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')], + ['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')], + ['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')], + ]); + + // When biometric unwrap reaches the pubkey proof check + const attempt = unlockWithBiometrics(); + + // Then no key crosses the capability boundary and no legacy namespace is adopted + await expect(attempt).rejects.toThrow('different wallet'); + expect(savedKeyOwnerId()).toBeNull(); + expect(readPasskeySlots()).toEqual([]); + expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey); + for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value); + }); +}); diff --git a/src/components/receive/ReceiveLightningDialog.vue b/src/components/receive/ReceiveLightningDialog.vue index 73bb011..4851300 100644 --- a/src/components/receive/ReceiveLightningDialog.vue +++ b/src/components/receive/ReceiveLightningDialog.vue @@ -202,19 +202,8 @@ diff --git a/src/components/welcome/WelcomeNostrPanel.vue b/src/components/welcome/WelcomeNostrPanel.vue new file mode 100644 index 0000000..a0ee4a4 --- /dev/null +++ b/src/components/welcome/WelcomeNostrPanel.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/components/welcome/WelcomeSeedPanel.vue b/src/components/welcome/WelcomeSeedPanel.vue new file mode 100644 index 0000000..2605f06 --- /dev/null +++ b/src/components/welcome/WelcomeSeedPanel.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/composables/useManageMintsPage.ts b/src/composables/useManageMintsPage.ts new file mode 100644 index 0000000..867ea70 --- /dev/null +++ b/src/composables/useManageMintsPage.ts @@ -0,0 +1,152 @@ +import { computed, ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { useQuasar } from 'quasar'; +import { + fetchMintAddress, + fetchPayRequest, + lightningAddressUsername, + mintAddressUrl, + resolveMintInput, + serverOf, +} from 'lnurlcash-kit'; + +import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; +import { useMintsStore } from '@/stores/mints'; +import { useWalletStore } from '@/stores/wallet'; + +export const useManageMintsPage = () => { + const router = useRouter(); + const $q = useQuasar(); + const mints = useMintsStore(); + const wallet = useWalletStore(); + const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => { + if (typeof $q.notify === 'function') { + $q.notify({ type, message, position: 'top', timeout: 3000 }); + } + }; + const fingerprint = (pubkey: string): string => + pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey; + const balanceAt = (server: string): string => + (wallet.balanceByMintSats.get(server) ?? 0).toLocaleString(undefined, { + maximumFractionDigits: 3, + }); + const suggestions = computed(() => + mints.PUBLIC_MINTS.filter( + (address) => !mints.mints.some((mint) => mint.server === address.replace(/^@/, '')), + ), + ); + const banner = ref(''); + const addServer = ref(''); + const addPubkey = ref(''); + const discovering = ref(''); + const confirmingRemove = ref(false); + const removeTarget = ref(''); + const trustResult = (result: string, server: string): void => { + if (result === 'rekey-pending') { + toast('warning', `${server} advertised a different signing key - review it above.`); + } else if (result === 'unchanged') { + toast('info', `${server} is already trusted.`); + } else { + toast('positive', `${server} is now trusted.`); + } + }; + const trustManual = async (): Promise => { + banner.value = ''; + try { + const result = await mints.trust(addServer.value, addPubkey.value); + trustResult(result, addServer.value.trim()); + addServer.value = ''; + addPubkey.value = ''; + } catch (error) { + banner.value = error instanceof Error ? error.message : 'Could not trust that mint.'; + } + }; + const trustSuggestion = async (address: string): Promise => { + if (discovering.value) return; + banner.value = ''; + discovering.value = address; + try { + const url = resolveMintInput(address); + if (!url) throw new Error('That mint address cannot be resolved.'); + let nodeInfo = null; + let payUrl = url; + const addressUrl = mintAddressUrl(url); + if (addressUrl) { + try { + nodeInfo = await fetchMintAddress(addressUrl); + payUrl = nodeInfo.payLink; + } catch (error) { + if (!(error instanceof Error)) throw error; + } + } + const info = await fetchPayRequest(payUrl); + const announcedKey = nodeInfo?.nodePubkey ?? info.mintPubkey; + if (!announcedKey) { + throw new Error("This mint didn't announce its signing key - add it manually instead."); + } + const server = serverOf(payUrl); + const result = await mints.trust( + server, + announcedKey, + mintAddressCacheInfo(nodeInfo, lightningAddressUsername(payUrl)), + ); + trustResult(result, server); + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not reach that mint.'; + banner.value = `Could not add ${address}: ${message}`; + } finally { + discovering.value = ''; + } + }; + const askRemove = (server: string): void => { + banner.value = ''; + removeTarget.value = server; + confirmingRemove.value = true; + }; + const doRemove = async (): Promise => { + confirmingRemove.value = false; + try { + await mints.remove(removeTarget.value); + if (mints.defaultMint === removeTarget.value) mints.setDefaultMint(null); + toast('positive', `${removeTarget.value} removed.`); + } catch (error) { + if (!(error instanceof Error)) throw error; + banner.value = `${removeTarget.value} can't be removed while you hold notes from it - move or spend them first.`; + } + }; + const confirmRekey = async (server: string): Promise => { + banner.value = ''; + try { + await mints.confirmRekey(server); + } catch (error) { + banner.value = error instanceof Error ? error.message : 'Could not confirm that signing key.'; + } + }; + const dismissRekey = async (server: string): Promise => { + banner.value = ''; + try { + await mints.dismissRekey(server); + } catch (error) { + banner.value = error instanceof Error ? error.message : 'Could not dismiss that signing key.'; + } + }; + return { + addPubkey, + addServer, + askRemove, + balanceAt, + banner, + confirmingRemove, + confirmRekey, + discovering, + dismissRekey, + doRemove, + fingerprint, + mints, + removeTarget, + router, + suggestions, + trustManual, + trustSuggestion, + }; +}; diff --git a/src/composables/useMintTrustPrompt.ts b/src/composables/useMintTrustPrompt.ts new file mode 100644 index 0000000..bcc33ef --- /dev/null +++ b/src/composables/useMintTrustPrompt.ts @@ -0,0 +1,46 @@ +import { ref } from 'vue'; +import { Notify } from 'quasar'; +import { useMintsStore } from '@/stores/mints'; + +export const useMintTrustPrompt = () => { + const mints = useMintsStore(); + const showTrust = ref(false); + const trustServer = ref(''); + const trustPubkey = ref(''); + const trustNodeAlias = ref(''); + const openTrust = (server: string, pubkey: string, nodeAlias = ''): void => { + trustServer.value = server; + trustPubkey.value = pubkey; + trustNodeAlias.value = nodeAlias; + showTrust.value = true; + }; + const trustMint = async (): Promise => { + try { + await mints.trust(trustServer.value, trustPubkey.value, { + ...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}), + }); + Notify.create({ type: 'positive', message: 'Mint trusted.' }); + } catch (error) { + const caught = error instanceof Error ? error : new Error(String(error)); + Notify.create({ type: 'negative', message: caught.message }); + } finally { + showTrust.value = false; + } + }; + const skipTrust = (): void => { + showTrust.value = false; + Notify.create({ + type: 'warning', + message: + 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.', + }); + }; + return { + openTrust, + showTrust, + skipTrust, + trustMint, + trustNodeAlias, + trustServer, + }; +}; diff --git a/src/composables/useMoveFundsPage.ts b/src/composables/useMoveFundsPage.ts new file mode 100644 index 0000000..c8f0e47 --- /dev/null +++ b/src/composables/useMoveFundsPage.ts @@ -0,0 +1,235 @@ +import { computed, ref, watch } from 'vue'; +import { useRouter } from 'vue-router'; +import { useQuasar } from 'quasar'; +import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit'; +import type { MintFee } from 'lnurlcash-kit'; + +import { transferBetweenMints } from '@/lnurlcash/ops'; +import type { TransferOutcome } from '@/lnurlcash/ops'; +import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees'; +import type { NewBearer } from '@/lnurlcash/types'; +import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units'; +import { useWalletStore } from '@/stores/wallet'; +import { useMintsStore } from '@/stores/mints'; +import { useActivityStore } from '@/stores/activity'; +import { addCommittedBearers, commitCarve } from './walletCarveCommit'; + +type Option = Readonly<{ label: string; value: string }>; +type TransferResult = Readonly<{ + outcome: TransferOutcome; + requestedSats: number; + feeSats: number; + sourceServer: string; + targetServer: string; + claimNoteValueSats?: number; +}>; + +export const useMoveFundsPage = () => { + const router = useRouter(); + const $q = useQuasar(); + const wallet = useWalletStore(); + const mints = useMintsStore(); + const activity = useActivityStore(); + const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => { + if (typeof $q.notify === 'function') { + $q.notify({ type, message, position: 'top', timeout: 3000 }); + } + }; + const warnCommitted = (message: string): void => toast('warning', message); + watch( + () => wallet.state, + (state) => { + if (state !== 'unlocked') void router.replace('/'); + }, + { immediate: true }, + ); + const CUSTOM_TARGET = '__custom__'; + const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT; + const spendableByServerMsat = computed(() => { + const byServer = new Map(); + for (const bearer of wallet.bearers) { + if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url)) + continue; + const server = serverOf(bearer.url); + byServer.set(server, (byServer.get(server) ?? 0) + bearer.amount); + } + return byServer; + }); + const sourceOptions = computed(() => + [...spendableByServerMsat.value.entries()].map(([server, msat]) => ({ + label: `${server} - ${displaySats(msat).toLocaleString()} sats available`, + value: server, + })), + ); + const step = ref<'form' | 'confirm' | 'working' | 'result'>('form'); + const sourceServer = ref(''); + const targetChoice = ref(''); + const customTarget = ref(''); + const amountSats = ref(null); + const inlineError = ref(''); + const stage = ref(''); + const result = ref(null); + const targetOptions = computed(() => { + const options: Option[] = []; + for (const mint of mints.mints) { + if (mint.server === sourceServer.value) continue; + const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`; + options.push({ + label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address, + value: address, + }); + } + options.push({ label: 'Another mint…', value: CUSTOM_TARGET }); + return options; + }); + const targetInput = computed(() => + targetChoice.value === CUSTOM_TARGET ? customTarget.value.trim() : targetChoice.value, + ); + const formFilled = computed( + () => + sourceServer.value !== '' && + targetInput.value !== '' && + Number.isInteger(amountSats.value) && + (amountSats.value ?? 0) >= 1, + ); + const targetFee = ref(null); + let quoteTimer: ReturnType | null = null; + watch(targetInput, (input) => { + targetFee.value = null; + if (quoteTimer) clearTimeout(quoteTimer); + if (input === '') return; + quoteTimer = setTimeout(() => { + void quoteMintFee(input).then((fee) => { + if (targetInput.value === input) targetFee.value = fee; + }); + }, 400); + }); + const targetFeeText = computed(() => + targetFee.value + ? `This mint charges a receive fee (${describeMintFee(targetFee.value)}) - Max already accounts for it.` + : '', + ); + const setMax = (): void => { + const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0; + amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value)); + }; + const proceed = (): void => { + inlineError.value = ''; + const sats = amountSats.value; + if (!sats || !Number.isInteger(sats) || sats < 1) { + inlineError.value = 'Enter how many sats to move.'; + return; + } + const sourceMsat = spendableByServerMsat.value.get(sourceServer.value) ?? 0; + if (satsToMsat(sats) > sourceMsat) { + inlineError.value = `That's more than the ${displaySats(sourceMsat).toLocaleString()} sats spendable at ${sourceServer.value}.`; + return; + } + step.value = 'confirm'; + }; + const move = async (): Promise => { + const sats = amountSats.value; + if (!sats) return; + step.value = 'working'; + stage.value = 'Asking the target mint for an invoice…'; + try { + const ownerFence = wallet.captureOwnerFence(); + const commitContext = { ownerFence, warn: warnCommitted }; + const transfer = await transferBetweenMints( + wallet.bearers, + satsToMsat(sats), + targetInput.value, + { assertOwner: ownerFence }, + ); + stage.value = 'Confirming the result…'; + const carved = await commitCarve(wallet, transfer.carve, commitContext); + if (transfer.rescuedNote) { + await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext); + } + const feeSats = msatToSats(transfer.quote.targetMintFeeMsat); + if (transfer.outcome === 'settled') { + await wallet.markSpent(carved.id, ownerFence); + const claimed = transfer.mintedAtTarget; + if (claimed) { + const notes: NewBearer[] = claimed.possibleCopy + ? [claimed.note, claimed.possibleCopy] + : [claimed.note]; + await addCommittedBearers(wallet, notes, commitContext); + } + await activity.log( + 'transfer', + `Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`, + (error) => warnCommitted(error.message), + ); + toast('positive', `Moved ${sats.toLocaleString()} sats.`); + } else if (transfer.outcome === 'failed-funds-returned') { + await activity.log( + 'transfer', + `A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`, + (error) => warnCommitted(error.message), + ); + } else if (transfer.outcome === 'unknown-still-pending') { + await wallet.markSpent(carved.id, ownerFence); + await activity.log( + 'transfer', + `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, + (error) => warnCommitted(error.message), + ); + } else if (transfer.outcome === 'settled-claim-failed') { + await wallet.markSpent(carved.id, ownerFence); + if (transfer.claimMaterial?.note) { + await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext); + } + await activity.log( + 'transfer', + `${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`, + (error) => warnCommitted(error.message), + ); + } else { + await wallet.markSpent(carved.id, ownerFence); + await activity.log( + 'spent', + `A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`, + (error) => warnCommitted(error.message), + ); + } + const claimNote = transfer.claimMaterial?.note ?? null; + result.value = { + outcome: transfer.outcome, + requestedSats: sats, + feeSats, + sourceServer: transfer.sourceServer, + targetServer: transfer.targetServer, + ...(claimNote ? { claimNoteValueSats: displaySats(claimNote.amount) } : {}), + }; + step.value = 'result'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Something went wrong.'; + inlineError.value = message.startsWith('No mint holds enough') + ? 'Not enough spendable balance at the source mint to cover that move.' + : message; + toast('negative', inlineError.value); + step.value = 'form'; + } + }; + return { + CUSTOM_TARGET, + amountSats, + customTarget, + formFilled, + inlineError, + move, + proceed, + result, + router, + setMax, + sourceOptions, + sourceServer, + stage, + step, + targetChoice, + targetFeeText, + targetInput, + targetOptions, + }; +}; diff --git a/src/composables/usePayInvoiceDialog.ts b/src/composables/usePayInvoiceDialog.ts new file mode 100644 index 0000000..6e1fbb4 --- /dev/null +++ b/src/composables/usePayInvoiceDialog.ts @@ -0,0 +1,238 @@ +import { computed, ref, watch } from 'vue'; +import { useQuasar } from 'quasar'; +import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit'; + +import { readClipboard } from '@/capabilities/clipboard'; +import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; +import type { PayOutcome } from '@/lnurlcash/ops'; +import { msatToSats, satsToMsat } from '@/lnurlcash/units'; +import { useWalletStore } from '@/stores/wallet'; +import { useActivityStore } from '@/stores/activity'; +import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; +import { addCommittedBearers, commitCarve } from './walletCarveCommit'; + +type PayInvoiceProps = Readonly<{ modelValue: boolean; initialInput?: string }>; +type PayInvoiceEmit = { + (event: 'update:modelValue', value: boolean): void; + (event: 'sent'): void; +}; +type TargetKind = 'invoice' | 'address'; +type PendingPayment = Readonly<{ kind: TargetKind; input: string; amountMsat: number }>; +type PaymentResult = Readonly<{ outcome: PayOutcome; amountMsat: number }>; + +export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit) => { + const $q = useQuasar(); + const wallet = useWalletStore(); + const activity = useActivityStore(); + const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => { + if (typeof $q.notify === 'function') { + $q.notify({ type, message, position: 'top', timeout: 3000 }); + } + }; + const warnCommitted = (message: string): void => toast('warning', message); + const show = computed({ + get: () => props.modelValue, + set: (value: boolean) => emit('update:modelValue', value), + }); + const step = ref<'input' | 'confirm' | 'working' | 'result'>('input'); + const input = ref(''); + const addressAmountSats = ref(''); + const showScanner = ref(false); + const inlineError = ref(null); + const stage = ref(''); + const pendingPayment = ref(null); + const result = ref(null); + const formatSats = (sats: number): string => + sats.toLocaleString(undefined, { maximumFractionDigits: 3 }); + const classify = (value: string): TargetKind | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + if (isBolt11Invoice(trimmed)) return 'invoice'; + return resolveLnurlInput(trimmed) === null ? null : 'address'; + }; + const targetKind = computed(() => classify(input.value)); + const truncatedInput = computed(() => { + const payment = pendingPayment.value; + if (!payment) return ''; + if (payment.kind === 'address') return payment.input; + return payment.input.length > 30 + ? `${payment.input.slice(0, 18)}…${payment.input.slice(-8)}` + : payment.input; + }); + const resultAmountSats = computed(() => + result.value ? formatSats(msatToSats(result.value.amountMsat)) : '', + ); + const reset = (): void => { + step.value = 'input'; + input.value = props.initialInput ?? ''; + addressAmountSats.value = ''; + showScanner.value = false; + inlineError.value = null; + stage.value = ''; + pendingPayment.value = null; + result.value = null; + }; + watch( + () => props.modelValue, + (open) => { + if (open) reset(); + }, + ); + const onScan = (text: string): void => { + input.value = text.replace(/^lightning:/i, '').trim(); + showScanner.value = false; + }; + const onScanError = (message: string): void => { + showScanner.value = false; + toast('negative', message); + }; + const paste = async (): Promise => { + try { + const text = await readClipboard(); + if (text) input.value = text.trim(); + } catch (error) { + if (!(error instanceof Error)) throw error; + toast('negative', "Couldn't read the clipboard - paste manually."); + } + }; + const proceed = (): void => { + inlineError.value = null; + const value = input.value.trim(); + if (!value) { + inlineError.value = 'Paste an invoice or a Lightning Address first.'; + return; + } + const kind = classify(value); + if (kind === null) { + inlineError.value = "That doesn't look like a Lightning invoice or address."; + return; + } + let amountMsat: number; + if (kind === 'invoice') { + const decoded = decodeBolt11AmountMsat(value); + if (decoded === null || decoded <= 0) { + inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet."; + return; + } + amountMsat = decoded; + } else { + const sats = Number(addressAmountSats.value); + if (!Number.isInteger(sats) || sats <= 0) { + inlineError.value = 'Enter how many sats to send to this address.'; + return; + } + amountMsat = satsToMsat(sats); + } + if (amountMsat > wallet.balanceMsat) { + inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`; + return; + } + pendingPayment.value = { kind, input: value, amountMsat }; + step.value = 'confirm'; + }; + const friendlyError = (error: unknown): string => { + const message = error instanceof Error ? error.message : 'Something went wrong.'; + return message.startsWith('No mint holds enough') + ? 'Not enough spendable balance to cover that payment.' + : message; + }; + const pay = async (): Promise => { + const payment = pendingPayment.value; + if (!payment) return; + step.value = 'working'; + stage.value = 'Preparing the exact amount and sending the payment…'; + let ownerFence: WalletOwnerFence | undefined; + try { + ownerFence = wallet.captureOwnerFence(); + const commitContext = { ownerFence, warn: warnCommitted }; + const paid = await payWithBearers( + wallet.bearers, + payment.input, + payment.kind === 'address' + ? { amountMsat: payment.amountMsat, assertOwner: ownerFence } + : { assertOwner: ownerFence }, + ); + stage.value = 'Confirming the result…'; + const committed = await commitCarve(wallet, paid.carve, commitContext); + if (paid.rescuedNote) { + await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); + } + const sats = formatSats(msatToSats(paid.amountMsat)); + if (paid.outcome === 'settled') { + await wallet.markSpent(committed.id, ownerFence); + await activity.log('melt', `Paid ${sats} sats over Lightning.`, (error) => + warnCommitted(error.message), + ); + toast('positive', `Paid ${sats} sats.`); + emit('sent'); + } else if (paid.outcome === 'failed-funds-returned') { + await activity.log( + 'transfer', + `A ${sats} sat payment failed - funds are back in your wallet.`, + (error) => warnCommitted(error.message), + ); + toast('warning', 'Payment failed - funds are back in your wallet.'); + } else if (paid.outcome === 'unknown-still-pending') { + await wallet.markSpent(committed.id, ownerFence); + await activity.log( + 'melt', + `Payment of ${sats} sats is still in flight - the note is locked.`, + (error) => warnCommitted(error.message), + ); + emit('sent'); + } else { + await wallet.markSpent(committed.id, ownerFence); + await activity.log('spent', `A ${sats} sat note was already spent at the mint.`, (error) => + warnCommitted(error.message), + ); + } + result.value = { outcome: paid.outcome, amountMsat: paid.amountMsat }; + step.value = 'result'; + } catch (error) { + if (error instanceof UncertainOutcomeError) { + if (!ownerFence) throw error; + await addCommittedBearers(wallet, error.possibleOutputs, { + ownerFence, + warn: warnCommitted, + }); + await activity.log( + 'transfer', + 'A payment preparation could not be confirmed - possible notes stored unverified.', + (activityError) => warnCommitted(activityError.message), + ); + inlineError.value = + "Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile."; + toast('warning', 'Payment preparation uncertain - see the notice in the dialog.'); + } else { + inlineError.value = friendlyError(error); + toast('negative', inlineError.value); + } + step.value = 'input'; + } + }; + const closeResult = (): void => { + show.value = false; + }; + return { + addressAmountSats, + closeResult, + formatSats, + inlineError, + input, + msatToSats, + onScan, + onScanError, + paste, + pay, + pendingPayment, + proceed, + result, + resultAmountSats, + show, + showScanner, + stage, + step, + targetKind, + truncatedInput, + }; +}; diff --git a/src/composables/useReceiveLightningDialog.ts b/src/composables/useReceiveLightningDialog.ts new file mode 100644 index 0000000..a44bdc5 --- /dev/null +++ b/src/composables/useReceiveLightningDialog.ts @@ -0,0 +1,245 @@ +import { computed, ref, watch } from 'vue'; +import { Notify } from 'quasar'; + +import { writeClipboard } from '@/capabilities/clipboard'; +import { prepareMint, claimMintedNote } from '@/lnurlcash/ops'; +import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops'; +import type { NewBearer } from '@/lnurlcash/types'; +import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; +import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; +import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; +import { useMintsStore } from '@/stores/mints'; +import { useActivityStore } from '@/stores/activity'; +import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; +import { useMintTrustPrompt } from './useMintTrustPrompt'; + +type ReceiveLightningProps = Readonly<{ modelValue: boolean }>; +type ReceiveLightningEmit = (event: 'received') => void; +type MintOption = Readonly<{ label: string; value: string }>; + +export const useReceiveLightningDialog = ( + props: ReceiveLightningProps, + emit: ReceiveLightningEmit, +) => { + const wallet = useWalletStore(); + const mints = useMintsStore(); + const activity = useActivityStore(); + const CUSTOM_MINT = '__custom__'; + const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT; + const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Something went wrong.'; + const step = ref<'form' | 'invoice' | 'success'>('form'); + const amountSats = ref(null); + const mintChoice = ref(''); + const customMint = ref(''); + const preparing = ref(false); + const formError = ref(''); + const prepared = ref(null); + const waiting = ref(false); + const claimError = ref(''); + const receivedSats = ref(0); + const receivedServer = ref(''); + const rotationWarning = ref(''); + const trustPrompt = useMintTrustPrompt(); + let claimRun: Promise | null = null; + const mintOptions = computed(() => { + const options: MintOption[] = []; + const seen = new Set(); + for (const mint of mints.mints) { + const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`; + if (seen.has(address)) continue; + seen.add(address); + options.push({ + label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address, + value: address, + }); + } + for (const publicMint of mints.PUBLIC_MINTS) { + if (seen.has(publicMint)) continue; + seen.add(publicMint); + options.push({ label: publicMint, value: publicMint }); + } + options.push({ label: 'Another mint…', value: CUSTOM_MINT }); + return options; + }); + const defaultChoice = (): string => { + const options = mintOptions.value; + if (mints.defaultMint) { + const match = options.find((option) => option.value.endsWith(`@${mints.defaultMint}`)); + if (match) return match.value; + } + const first = options[0]; + return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT; + }; + const formValid = computed(() => { + if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false; + return mintChoice.value === CUSTOM_MINT + ? customMint.value.trim() !== '' + : mintChoice.value !== ''; + }); + const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0)); + const netSats = computed(() => + prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0, + ); + const feeSats = computed(() => grossSats.value - netSats.value); + const onClaimed = async ( + claimed: ClaimedNote, + from: PreparedMint, + ownerFence: WalletOwnerFence, + ): Promise => { + const server = from.server; + const wasTrusted = mints.isTrusted(server); + const notes: NewBearer[] = claimed.possibleCopy + ? [claimed.note, claimed.possibleCopy] + : [claimed.note]; + let trustWarning = ''; + try { + await wallet.addBearers(notes, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + trustWarning = error.message; + } + receivedSats.value = displaySats(claimed.note.amount); + receivedServer.value = server; + rotationWarning.value = claimed.rotationError ?? ''; + await activity.log( + 'mint', + `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`, + (error) => { + trustWarning = error.message; + }, + ); + const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username); + if (nodeInfo) { + try { + await mints.cacheNodeInfo(server, nodeInfo); + } catch (error) { + if (!(error instanceof Error)) throw error; + trustWarning = `Funds were saved, but mint details could not be updated: ${errorMessage(error)}`; + } + } + Notify.create({ + type: 'positive', + message: `Received ${receivedSats.value.toLocaleString()} sats.`, + }); + if (trustWarning) Notify.create({ type: 'warning', message: trustWarning }); + emit('received'); + if (props.modelValue) step.value = 'success'; + if (!wasTrusted && claimed.note.mintPubkey) { + trustPrompt.openTrust(server, claimed.note.mintPubkey, from.nodeInfo?.nodeAlias ?? ''); + } + }; + const beginClaim = (): void => { + if (!prepared.value || claimRun) return; + waiting.value = true; + claimError.value = ''; + const current = prepared.value; + claimRun = (async () => { + try { + const ownerFence = wallet.captureOwnerFence(); + await onClaimed( + await claimMintedNote(current, {}, { assertOwner: ownerFence }), + current, + ownerFence, + ); + } catch (error) { + if (!(error instanceof Error)) throw error; + claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`; + Notify.create({ type: 'negative', message: errorMessage(error) }); + } finally { + waiting.value = false; + } + })(); + }; + const createInvoice = async (): Promise => { + const sats = amountSats.value; + if (!sats || preparing.value) return; + preparing.value = true; + formError.value = ''; + try { + const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; + const next = await prepareMint(input, satsToMsat(sats)); + if (!next.verifyUrl) { + formError.value = + 'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; + return; + } + prepared.value = next; + claimRun = null; + claimError.value = ''; + step.value = 'invoice'; + beginClaim(); + } catch (error) { + if (!(error instanceof Error)) throw error; + formError.value = errorMessage(error); + Notify.create({ type: 'negative', message: formError.value }); + } finally { + preparing.value = false; + } + }; + const copyInvoice = async (): Promise => { + if (!prepared.value) return; + try { + await writeClipboard(prepared.value.invoice); + Notify.create({ type: 'positive', message: 'Invoice copied.' }); + } catch (error) { + if (!(error instanceof Error)) throw error; + Notify.create({ type: 'negative', message: errorMessage(error) }); + } + }; + const retryClaim = (): void => { + claimRun = null; + beginClaim(); + }; + const stopWaiting = (): void => { + waiting.value = false; + }; + const resumeWaiting = (): void => { + if (claimRun) waiting.value = true; + }; + watch( + () => props.modelValue, + (open) => { + if (!open) return; + step.value = 'form'; + amountSats.value = null; + customMint.value = ''; + mintChoice.value = defaultChoice(); + preparing.value = false; + formError.value = ''; + prepared.value = null; + waiting.value = false; + rotationWarning.value = ''; + }, + ); + return { + CUSTOM_MINT, + amountSats, + claimError, + copyInvoice, + createInvoice, + customMint, + feeSats, + formError, + formValid, + grossSats, + mintChoice, + mintOptions, + netSats, + prepared, + preparing, + receivedSats, + receivedServer, + resumeWaiting, + retryClaim, + rotationWarning, + showTrust: trustPrompt.showTrust, + skipTrust: trustPrompt.skipTrust, + step, + stopWaiting, + trustMint: trustPrompt.trustMint, + trustNodeAlias: trustPrompt.trustNodeAlias, + trustServer: trustPrompt.trustServer, + waiting, + }; +}; diff --git a/src/composables/useReceiveTokenDialog.ts b/src/composables/useReceiveTokenDialog.ts new file mode 100644 index 0000000..3f53601 --- /dev/null +++ b/src/composables/useReceiveTokenDialog.ts @@ -0,0 +1,183 @@ +import { computed, ref, watch } from 'vue'; +import { Notify } from 'quasar'; +import { + NoteSpentError, + NoteUnknownError, + PendingNoteError, + isValidNoteInput, +} from 'lnurlcash-kit'; + +import { receiveBearer } from '@/lnurlcash/ops'; +import type { NewBearer } from '@/lnurlcash/types'; +import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; +import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; +import { useMintsStore } from '@/stores/mints'; +import { useActivityStore } from '@/stores/activity'; +import { useMintTrustPrompt } from './useMintTrustPrompt'; + +type ReceiveTokenProps = Readonly<{ modelValue: boolean; initialInput?: string }>; +type ReceiveTokenEmit = { + (event: 'received'): void; +}; +type ReceiveErrorKind = 'spent' | 'unknown' | 'pending' | 'duplicate' | 'invalid' | 'generic' | ''; + +const ERROR_TEXT: Readonly, string>> = { + spent: 'This note has already been spent.', + unknown: "The mint doesn't know this note.", + pending: 'This note is locked mid-payment — try again shortly.', + duplicate: 'This note is already in your wallet.', + invalid: 'Not a valid bearer note.', + generic: '', +}; +const ERROR_ICON: Readonly, string>> = { + spent: 'money_off', + unknown: 'help_outline', + pending: 'hourglass_top', + duplicate: 'content_copy', + invalid: 'error_outline', + generic: 'error_outline', +}; + +export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTokenEmit) => { + const wallet = useWalletStore(); + const mints = useMintsStore(); + const activity = useActivityStore(); + const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT; + const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Something went wrong.'; + const step = ref<'input' | 'success'>('input'); + const input = ref(''); + const scanning = ref(false); + const busy = ref(false); + const errorKind = ref(''); + const errorMessageText = ref(''); + const receivedSats = ref(0); + const receivedServer = ref(''); + const unverifiedNote = ref(false); + const rotationWarning = ref(''); + const trustPrompt = useMintTrustPrompt(); + const errorText = computed(() => + errorKind.value === 'generic' + ? errorMessageText.value + : errorKind.value === '' + ? '' + : ERROR_TEXT[errorKind.value], + ); + const errorIcon = computed(() => + errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value], + ); + const inputValid = computed(() => isValidNoteInput(input.value.trim())); + const clearError = (): void => { + errorKind.value = ''; + errorMessageText.value = ''; + }; + const classifyError = (error: unknown): void => { + let kind: Exclude; + if (error instanceof NoteSpentError) kind = 'spent'; + else if (error instanceof NoteUnknownError) kind = 'unknown'; + else if (error instanceof PendingNoteError) kind = 'pending'; + else if (error instanceof Error && error.message.includes('already in your wallet')) { + kind = 'duplicate'; + } else if (error instanceof Error && error.message.includes('Not an LNURLcash bearer note')) { + kind = 'invalid'; + } else { + kind = 'generic'; + errorMessageText.value = errorMessage(error); + } + errorKind.value = kind; + Notify.create({ + type: 'negative', + message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind], + }); + }; + const receive = async (): Promise => { + const value = input.value.trim(); + if (busy.value || value === '') return; + busy.value = true; + clearError(); + try { + const ownerFence = wallet.captureOwnerFence(); + const claimed = await receiveBearer(value, wallet.bearers, { + assertOwner: ownerFence, + }); + const note = claimed.note; + const server = new URL(note.url).host; + const wasTrusted = mints.isTrusted(server); + const notes: NewBearer[] = claimed.possibleCopy ? [note, claimed.possibleCopy] : [note]; + let trustWarning = ''; + try { + await wallet.addBearers(notes, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + trustWarning = error.message; + } + receivedSats.value = displaySats(note.amount); + receivedServer.value = server; + unverifiedNote.value = !note.verified; + rotationWarning.value = claimed.rotationError ?? ''; + await activity.log( + 'receive', + `Received ${receivedSats.value.toLocaleString()} sats from ${server}.`, + (error) => Notify.create({ type: 'warning', message: error.message }), + ); + Notify.create({ + type: 'positive', + message: `Received ${receivedSats.value.toLocaleString()} sats.`, + }); + if (trustWarning) Notify.create({ type: 'warning', message: trustWarning }); + emit('received'); + scanning.value = false; + step.value = 'success'; + if (!wasTrusted && note.mintPubkey) { + trustPrompt.openTrust(server, note.mintPubkey); + } + } catch (error) { + classifyError(error instanceof Error ? error : new Error(String(error))); + } finally { + busy.value = false; + } + }; + const onScan = (text: string): void => { + input.value = text; + scanning.value = false; + void receive(); + }; + const onScanError = (message: string): void => { + scanning.value = false; + Notify.create({ type: 'negative', message }); + }; + watch( + () => props.modelValue, + (open) => { + if (!open) return; + step.value = 'input'; + input.value = props.initialInput ?? ''; + scanning.value = false; + busy.value = false; + clearError(); + unverifiedNote.value = false; + rotationWarning.value = ''; + }, + ); + return { + busy, + errorIcon, + errorKind, + errorText, + input, + inputValid, + onScan, + onScanError, + receive, + receivedSats, + receivedServer, + rotationWarning, + scanning, + showTrust: trustPrompt.showTrust, + skipTrust: trustPrompt.skipTrust, + step, + trustMint: trustPrompt.trustMint, + trustServer: trustPrompt.trustServer, + unverifiedNote, + }; +}; diff --git a/src/composables/useSendTokenDialog.ts b/src/composables/useSendTokenDialog.ts new file mode 100644 index 0000000..15b8b4b --- /dev/null +++ b/src/composables/useSendTokenDialog.ts @@ -0,0 +1,194 @@ +import { computed, ref, watch } from 'vue'; +import { useQuasar } from 'quasar'; +import { toBech32Lnurl } from 'lnurlcash-kit'; + +import { writeClipboard } from '@/capabilities/clipboard'; +import { canShareText, shareText } from '@/capabilities/share'; +import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops'; +import type { Bearer } from '@/lnurlcash/types'; +import { msatToSats, satsToMsat } from '@/lnurlcash/units'; +import { useWalletStore } from '@/stores/wallet'; +import { useActivityStore } from '@/stores/activity'; +import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; +import { addCommittedBearers, commitCarve } from './walletCarveCommit'; + +type SendTokenProps = Readonly<{ modelValue: boolean }>; +type SendTokenEmit = { + (event: 'update:modelValue', value: boolean): void; + (event: 'sent'): void; +}; + +export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) => { + const $q = useQuasar(); + const wallet = useWalletStore(); + const activity = useActivityStore(); + const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => { + if (typeof $q.notify === 'function') { + $q.notify({ type, message, position: 'top', timeout: 3000 }); + } + }; + const warnCommitted = (message: string): void => toast('warning', message); + const show = computed({ + get: () => props.modelValue, + set: (value: boolean) => emit('update:modelValue', value), + }); + const step = ref<'amount' | 'ready'>('amount'); + const amountSats = ref(''); + const preparing = ref(false); + const removing = ref(false); + const errorMessage = ref(null); + const prepared = ref(null); + const revealed = ref(false); + const formatSats = (sats: number): string => + sats.toLocaleString(undefined, { maximumFractionDigits: 3 }); + const parsedAmount = computed(() => { + const amount = Number(amountSats.value); + return Number.isInteger(amount) && amount > 0 ? amount : null; + }); + const amountError = computed(() => { + if (parsedAmount.value === null) return null; + if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) { + return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`; + } + return null; + }); + const canPrepare = computed( + () => parsedAmount.value !== null && amountError.value === null && !preparing.value, + ); + const noteDisplayValue = computed(() => + prepared.value ? toBech32Lnurl(prepared.value.url) : '', + ); + const canShare = canShareText(); + const reset = (): void => { + step.value = 'amount'; + amountSats.value = ''; + preparing.value = false; + removing.value = false; + errorMessage.value = null; + prepared.value = null; + revealed.value = false; + }; + watch( + () => props.modelValue, + (open) => { + if (open) reset(); + }, + ); + const prepare = async (): Promise => { + const sats = parsedAmount.value; + if (sats === null || amountError.value !== null) return; + preparing.value = true; + errorMessage.value = null; + let ownerFence: WalletOwnerFence | undefined; + try { + ownerFence = wallet.captureOwnerFence(); + const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { + assertOwner: ownerFence, + }); + const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted }); + if (carve.change) { + await activity.log( + 'split', + `Prepared a ${formatSats(sats)} sat note to hand over.`, + (error) => warnCommitted(error.message), + ); + } else if (carve.consumed.length > 1) { + await activity.log( + 'combine', + `Combined notes into a ${formatSats(sats)} sat note.`, + (error) => warnCommitted(error.message), + ); + } + prepared.value = note; + revealed.value = false; + step.value = 'ready'; + } catch (error) { + if (error instanceof UncertainOutcomeError) { + if (!ownerFence) throw error; + await addCommittedBearers(wallet, error.possibleOutputs, { + ownerFence, + warn: warnCommitted, + }); + await activity.log( + 'transfer', + 'A note preparation could not be confirmed - possible notes stored unverified.', + (activityError) => warnCommitted(activityError.message), + ); + errorMessage.value = + "Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile."; + toast('warning', 'Preparation uncertain - see the notice in the dialog.'); + return; + } + const message = error instanceof Error ? error.message : 'Something went wrong.'; + errorMessage.value = message.startsWith('No mint holds enough') + ? 'Not enough spendable balance to cover that amount.' + : message; + toast('negative', errorMessage.value); + } finally { + preparing.value = false; + } + }; + const copyNote = async (): Promise => { + try { + await writeClipboard(noteDisplayValue.value); + toast('positive', 'Note copied to clipboard.'); + } catch (error) { + if (!(error instanceof Error)) throw error; + toast('negative', "Couldn't copy - reveal the note and copy it manually."); + } + }; + const shareNote = async (): Promise => { + try { + await shareText('sattle bearer note', noteDisplayValue.value); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + await copyNote(); + } + }; + const finishRemove = async (): Promise => { + const note = prepared.value; + if (!note) return; + removing.value = true; + try { + await wallet.markSpent(note.id, wallet.captureOwnerFence()); + await activity.log( + 'spent', + `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`, + (error) => warnCommitted(error.message), + ); + toast('positive', 'Removed from your balance.'); + emit('sent'); + show.value = false; + } catch (error) { + toast('negative', error instanceof Error ? error.message : 'Something went wrong.'); + } finally { + removing.value = false; + } + }; + const finishKeep = (): void => { + toast('info', 'Note kept in your wallet.'); + show.value = false; + }; + return { + amountError, + amountSats, + canPrepare, + canShare, + copyNote, + errorMessage, + finishKeep, + finishRemove, + formatSats, + msatToSats, + noteDisplayValue, + prepare, + prepared, + preparing, + removing, + revealed, + shareNote, + show, + step, + wallet, + }; +}; diff --git a/src/composables/walletCarveCommit.test.ts b/src/composables/walletCarveCommit.test.ts new file mode 100644 index 0000000..eef9a71 --- /dev/null +++ b/src/composables/walletCarveCommit.test.ts @@ -0,0 +1,130 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import type { CarveResult } from '@/lnurlcash/ops'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { commitCarve } from './walletCarveCommit'; +import { useWalletStore } from '../stores/wallet'; + +const note = (secret: string): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, +}); + +const failOnBearerWrite = (occurrence: number): void => { + const setItem = localStorage.setItem.bind(localStorage); + let writes = 0; + vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { + if (key === 'sattle_bearers') { + writes += 1; + if (writes === occurrence) throw new Error('bearer storage unavailable'); + } + setItem(key, value); + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('commitCarve', () => { + it('commits the carve additions and spent marks in one bearer write', async () => { + // Given a wallet holding the carve's input note + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + const carve: CarveResult = { + note: note('bb'), + change: note('cc'), + consumed: [input], + }; + const writes = vi.spyOn(localStorage, 'setItem'); + + // When the carve is committed + const committed = await commitCarve(wallet, carve, { + ownerFence, + warn: () => undefined, + }); + + // Then the whole rotation landed as ONE durable write + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(committed.url).toBe(carve.note.url); + expect(wallet.bearers).toHaveLength(3); + expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(3); + expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + }); + + it('survives a failure that would have hit the old split commit second write', async () => { + // Given a wallet holding the carve's input note, with the second + // sattle_bearers write poisoned (the old add-then-markSpent split wrote + // twice; the single-write commit never reaches a second write) + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + failOnBearerWrite(2); + const carve: CarveResult = { + note: note('bb'), + change: note('cc'), + consumed: [input], + }; + + // When the carve is committed + const committed = await commitCarve(wallet, carve, { + ownerFence, + warn: () => undefined, + }); + + // Then the rotation committed completely: additions tracked, input spent + expect(committed.url).toBe(carve.note.url); + expect(wallet.bearers).toHaveLength(3); + expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(3); + expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + }); + + it('leaves no partial carve behind when the commit write itself fails', async () => { + // Given bearer storage that fails the very next write + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + failOnBearerWrite(1); + + // When the carve commit fails + await expect( + commitCarve( + wallet, + { note: note('bb'), change: note('cc'), consumed: [input] }, + { ownerFence, warn: () => undefined }, + ), + ).rejects.toThrow('bearer storage unavailable'); + + // Then nothing moved: not in storage, not in the reactive list + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.spent).toBeUndefined(); + }); +}); diff --git a/src/composables/walletCarveCommit.ts b/src/composables/walletCarveCommit.ts new file mode 100644 index 0000000..ed9b032 --- /dev/null +++ b/src/composables/walletCarveCommit.ts @@ -0,0 +1,67 @@ +import type { CarveResult } from '@/lnurlcash/ops'; +import type { BearerChangeset } from '@/lnurlcash/storage'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { TrustedMintPostCommitError } from '@/stores/wallet'; +import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; + +export type CarveWallet = { + readonly bearers: readonly Bearer[]; + readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise; + readonly applyChangeset: ( + changeset: BearerChangeset, + ownerFence: WalletOwnerFence, + ) => Promise; +}; + +type CarveCommitContext = Readonly<{ + ownerFence: WalletOwnerFence; + warn: (message: string) => void; +}>; + +export const addCommittedBearers = async ( + wallet: CarveWallet, + notes: NewBearer[], + context: CarveCommitContext, +): Promise => { + try { + return await wallet.addBearers(notes, context.ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + context.warn(error.message); + return error.committedBearers; + } +}; + +// A carve is ONE logical rotation: the fresh notes (target + change) and the +// spent marks of the burned inputs must land together or not at all - the +// mint already destroyed the inputs server-side, so a partial commit (added +// but not spent, or vice versa) would strand or double-show money. Hence a +// single changeset through the wallet's one-write boundary, never an +// add-then-markSpent sequence of separate writes. +export const commitCarve = async ( + wallet: CarveWallet, + carve: CarveResult, + context: CarveCommitContext, +): Promise => { + const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url); + const additions: NewBearer[] = []; + if (!existing) additions.push(carve.note); + if (carve.change) additions.push(carve.change); + let added: Bearer[]; + try { + added = await wallet.applyChangeset( + { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + }, + context.ownerFence, + ); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + context.warn(error.message); + added = error.committedBearers; + } + const committed = existing ?? added[0]; + if (!committed) throw new Error('The carved note was not tracked.'); + return committed; +}; diff --git a/src/composables/welcomePassword.ts b/src/composables/welcomePassword.ts new file mode 100644 index 0000000..5d0cd05 --- /dev/null +++ b/src/composables/welcomePassword.ts @@ -0,0 +1,4 @@ +export const MIN_PASSWORD_LENGTH = 8; + +export const passwordValid = (password: string, confirmation: string): boolean => + password === '' || (password.length >= MIN_PASSWORD_LENGTH && password === confirmation); diff --git a/src/lnurlcash/AGENTS.md b/src/lnurlcash/AGENTS.md index 6f6c3e4..eca2b44 100644 --- a/src/lnurlcash/AGENTS.md +++ b/src/lnurlcash/AGENTS.md @@ -22,8 +22,9 @@ lnurlcash-kit directly. ``` ops.ts / ops/ # flows: carve (exact-amount), mint, pay, receiveBearer, # transfer (inter-mint); ops.ts is the façade -storage/ # encrypted bearers + activity log, settings, backup, - # nwcConnections, passkeySlots; storage.ts is the façade +storage/ # encrypted bearers + activity log, owner-bound NWC, + # passkey and trusted-mint records, settings, backup; + # storage.ts is the façade keys.ts # BIP39, LUD-05 linking key derivation, password wrap passkeys.ts + passkeyWrap.ts # WebAuthn PRF wrap (same linking key) nostrBackup.ts + nostr/ # kind-30078 backup, NIP-44 self-encryption @@ -38,8 +39,21 @@ test-utils.ts # mock mint harness used by *.test.ts - Style: NO semicolons, 2-space indent, single quotes, `{braced}` imports without inner spaces — deliberately different from the rest of the app (eslint override); keep the tested core diffable against its lineage. -- Storage: localStorage keys `sattle_*`; strict shape validation on read, - malformed entries dropped; read-modify-write under `withStorageLock`. +- Storage: localStorage keys `sattle_*`; strict shape validation on read. + Credential, NWC, passkey, and trusted-mint records belong to the canonical + saved-key owner. Normal writes require that exact persisted owner; migration + of ownerless legacy records has its own proof-gated API. +- Concurrency: read-modify-write uses `withStorageLock` where Web Locks are + available, but lock handoff is not a localStorage visibility barrier. The + trusted-mint repository reconciles from a durable IndexedDB commit mirror + before one successful localStorage write and before resolving. Its fallback + is local execution only, with no cross-tab serialization guarantee. Storage + events are wakeups, so listeners re-read current storage instead of trusting + `event.newValue`, including on clears. +- Lifecycle: wallet transitions serialize create, restore, unlock, lock, and + forget. Activation completes proven-owner migration before exposing unlocked + state. Forget locks, drains NWC, clears runtime and owner-bound state, then + removes the saved key after biometric deletion succeeds. - Network: kit calls only; injectable transport/options so tests never touch the network. No WebSocket at import time (lazy `import()`). - Every module header comment explains the WHY, including failure models. diff --git a/src/lnurlcash/fees.ts b/src/lnurlcash/fees.ts index fdd318a..a5a16d8 100644 --- a/src/lnurlcash/fees.ts +++ b/src/lnurlcash/fees.ts @@ -22,7 +22,7 @@ import { grossUpForMintFee, mintAddressUrl, resolveMintInput, - serverOf + serverOf, } from 'lnurlcash-kit' import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit' import {ceilMsatToSat, floorMsatToSat} from './units' @@ -55,7 +55,7 @@ export const clearMintFeeQuoteCache = (): void => quoteCache.clear() // reached right now. export const quoteMintFee = async ( mintInput: string, - options: LnurlcashOptions = {} + options: LnurlcashOptions = {}, ): Promise => { const url = resolveMintInput(mintInput) if (!url) return null @@ -69,8 +69,9 @@ export const quoteMintFee = async ( if (addressUrl) { try { payUrl = (await fetchMintAddress(addressUrl, opts)).payLink - } catch { + } catch (error) { // no mint-address support - the plain payRequest guess still works + if (!(error instanceof Error)) throw error } } let fee: MintFee | null diff --git a/src/lnurlcash/jsonParsing.ts b/src/lnurlcash/jsonParsing.ts new file mode 100644 index 0000000..3e888f9 --- /dev/null +++ b/src/lnurlcash/jsonParsing.ts @@ -0,0 +1,20 @@ +export const isJsonObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +export const parseJsonObject = (source: string): Record => { + const parsed: unknown = JSON.parse(source) + if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.') + return parsed +} + +export const parseJsonArray = (source: string): unknown[] => { + const parsed: unknown = JSON.parse(source) + if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.') + return Array.from(parsed, (value: unknown) => value) +} + +export const parseJsonObjectArray = (source: string): Array> => + parseJsonArray(source).map((value) => { + if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.') + return value + }) diff --git a/src/lnurlcash/keys.test.ts b/src/lnurlcash/keys.test.ts new file mode 100644 index 0000000..97f5e3a --- /dev/null +++ b/src/lnurlcash/keys.test.ts @@ -0,0 +1,316 @@ +// Saved linking-key record tests. The baseline describes pin the observable +// behavior of saveLinkingKey/decryptSavedLinkingKey/getPlainLinkingKey/ +// restoreLinkingKeyStored as it existed before the owner marker (they must +// keep passing unchanged); the owner-marker describes cover the ownerId +// field that binds the saved key to its one proven wallet identity. +// Node env: in-memory localStorage stub, native WebCrypto. + +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import { + decryptSavedLinkingKey, + encryptSecretParts, + ensureSavedKeyOwner, + getPlainLinkingKey, + linkingPubKeyHex, + restoreLinkingKeyStored, + savedKeyExists, + savedKeyIsEncrypted, + savedKeyOwnerMatches, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {isWalletOwnerId} from './storage/walletOwner' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +import './keys.version.cases' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const PASSWORD = 'hunter2' + +const STORAGE_KEY = 'sattle_linking_key' + +const readRawRecord = (): Record => { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) throw new Error('expected a saved linking-key record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +// hand-written records in the pre-owner-marker shape - what every wallet +// created before this change has on disk +const saveLegacyPlaintext = (key: Uint8Array): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({enc: false, value: bytesToHex(key)})) +} + +const saveLegacyEncrypted = async ( + key: Uint8Array, + password: string, +): Promise> => { + const parts = await encryptSecretParts(bytesToHex(key), password) + const record: Record = {enc: true, ...parts} + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + return record +} + +describe('baseline: saved-key record behavior', () => { + it('saves a plaintext key and reads it back', async () => { + await saveLinkingKey(LINKING_KEY) + + expect(savedKeyExists()).toBe(true) + expect(savedKeyIsEncrypted()).toBe(false) + expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)}) + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + }) + + it('saves a password-encrypted key and decrypts it with the password', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + + expect(savedKeyIsEncrypted()).toBe(true) + const record = readRawRecord() + expect(record.enc).toBe(true) + expect(record.value).toBeUndefined() + // an encrypted record never reads through the plaintext path + expect(getPlainLinkingKey()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('rejects the wrong password via the GCM auth tag', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow() + }) + + it('throws when asked to decrypt a plaintext record', async () => { + await saveLinkingKey(LINKING_KEY) + await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow( + 'No encrypted linking key saved.', + ) + }) + + it('restores an ownerless record verbatim and reads it back', async () => { + const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) + const record = {enc: true as const, ...parts} + restoreLinkingKeyStored(record) + + expect(readRawRecord()).toEqual(record) + expect(savedKeyIsEncrypted()).toBe(true) + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('drops a malformed stored record instead of trusting it', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({enc: true, salt: 'zz', iv: '00', ciphertext: ''}), + ) + expect(savedKeyExists()).toBe(false) + localStorage.setItem(STORAGE_KEY, 'not json') + expect(savedKeyExists()).toBe(false) + expect(getPlainLinkingKey()).toBeNull() + }) +}) + +describe('owner marker on new writes', () => { + it('matches the canonical owner derived from the saved linking key', async () => { + // Given a newly saved owner-bearing key + await saveLinkingKey(LINKING_KEY) + + // When its freshly derived linking key is compared + const matches = savedKeyOwnerMatches(LINKING_KEY) + + // Then the saved owner matches + expect(matches).toBe(true) + }) + + it('does not match a different linking key', async () => { + // Given a key owned by this wallet + await saveLinkingKey(LINKING_KEY) + + // When a foreign freshly derived linking key is compared + const matches = savedKeyOwnerMatches(OTHER_KEY) + + // Then the foreign key is rejected + expect(matches).toBe(false) + }) + + it('stamps the same canonical owner on plaintext and encrypted saves', async () => { + await saveLinkingKey(LINKING_KEY) + const plainOwner = savedKeyOwnerId() + expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY)) + + await saveLinkingKey(LINKING_KEY, PASSWORD) + expect(savedKeyOwnerId()).toBe(plainOwner) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + }) + + it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => { + await saveLinkingKey(LINKING_KEY) + expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/) + }) +}) + +describe('owner marker on legacy records', () => { + it('reads a legacy record without ownerId as ownerless', async () => { + saveLegacyPlaintext(LINKING_KEY) + expect(savedKeyOwnerId()).toBeNull() + // the record itself stays a fully valid saved key + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + + await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + expect(savedKeyOwnerId()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('rejects malformed owner-bearing records instead of treating them as legacy', () => { + const real = linkingPubKeyHex(LINKING_KEY) + const junk: unknown[] = [ + real.slice(1), // wrong length (65) + real + '00', // wrong length (68) + real.toUpperCase(), // uppercase hex + 'zz' + real.slice(2), // non-hex + '04' + real.slice(2), // not a compressed-pubkey prefix + '02' + 'ff'.repeat(32), // hex of the right length, not a curve point + 42, // wrong type + null, + {pubkey: real}, + '', + ] + for (const ownerId of junk) { + saveLegacyPlaintext(LINKING_KEY) + localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId})) + // the junk marker never reads as an owner... + expect(savedKeyOwnerId()).toBeNull() + // ...or downgrades to an adoptable ownerless legacy record + expect(savedKeyExists()).toBe(false) + expect(getPlainLinkingKey()).toBeNull() + } + }) + + it('stamps the owner after a password unlock without touching the ciphertext', async () => { + // Given a legacy encrypted record with no owner marker + const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + + // When ownership is proven by a successful unlock and then stamped + const linkingKey = await decryptSavedLinkingKey(PASSWORD) + ensureSavedKeyOwner(linkingKey) + + // Then the marker names the proven key and every ciphertext byte is + // preserved + const after = readRawRecord() + expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(after.ciphertext).toBe(before.ciphertext) + expect(after.salt).toBe(before.salt) + expect(after.iv).toBe(before.iv) + }) + + it('stamps a plaintext legacy record after a plaintext unlock', () => { + saveLegacyPlaintext(LINKING_KEY) + const linkingKey = getPlainLinkingKey() + if (linkingKey === null) throw new Error('expected a plaintext key') + ensureSavedKeyOwner(linkingKey) + + expect(readRawRecord()).toEqual({ + enc: false, + value: bytesToHex(LINKING_KEY), + version: 1, + ownerId: linkingPubKeyHex(LINKING_KEY), + }) + }) + + it('is idempotent - stamping twice leaves storage untouched after the first write', async () => { + const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + const linkingKey = await decryptSavedLinkingKey(PASSWORD) + + ensureSavedKeyOwner(linkingKey) + const afterFirst = localStorage.getItem(STORAGE_KEY) + ensureSavedKeyOwner(linkingKey) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst) + expect(readRawRecord().ciphertext).toBe(before.ciphertext) + }) + + it('writes nothing when a new-format record is already correctly stamped', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + const raw = localStorage.getItem(STORAGE_KEY) + ensureSavedKeyOwner(LINKING_KEY) + expect(localStorage.getItem(STORAGE_KEY)).toBe(raw) + }) + + it('refuses to restamp a record owned by a different wallet', async () => { + await saveLinkingKey(OTHER_KEY, PASSWORD) + const before = localStorage.getItem(STORAGE_KEY) + + expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() + // the failed stamp leaves the record - marker included - untouched + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY)) + }) + + it('refuses to stamp a key that contradicts a plaintext record', () => { + saveLegacyPlaintext(OTHER_KEY) + expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() + expect(savedKeyOwnerId()).toBeNull() + }) + + it('does not adopt a record carrying a junk owner marker', () => { + saveLegacyPlaintext(LINKING_KEY) + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({...readRawRecord(), ownerId: 'obviously junk'}), + ) + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyOwnerId()).toBeNull() + expect(savedKeyExists()).toBe(false) + }) + + it('is a no-op when no record is saved at all', () => { + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyExists()).toBe(false) + }) +}) + +describe('owner marker on restore', () => { + it('strips the unproven ownerId a restored record arrives with', async () => { + // a backup file can claim any marker - only a freshly derived key may + // establish ownership, so restore installs the secret parts alone + restoreLinkingKeyStored({ + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(OTHER_KEY), + }) + expect(savedKeyOwnerId()).toBeNull() + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + + // the first proven unlock then establishes the true owner + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + }) + + it('strips a junk ownerId from a restored encrypted record', async () => { + const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) + restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42}) + expect(savedKeyOwnerId()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) +}) + +describe('isWalletOwnerId', () => { + it('accepts exactly what linkingPubKeyHex produces', () => { + expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true) + expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true) + }) + + it('rejects everything else', () => { + const real = linkingPubKeyHex(LINKING_KEY) + expect(isWalletOwnerId(real.toUpperCase())).toBe(false) + expect(isWalletOwnerId(real.slice(0, 64))).toBe(false) + expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false) + expect(isWalletOwnerId(66)).toBe(false) + expect(isWalletOwnerId(undefined)).toBe(false) + expect(isWalletOwnerId(null)).toBe(false) + }) +}) diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index b901df9..604553c 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -1,8 +1,4 @@ -import { - mnemonicToSeedSync, - generateMnemonic, - validateMnemonic -} from '@scure/bip39' +import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39' import {wordlist} from '@scure/bip39/wordlists/english.js' import {HDKey, HARDENED_OFFSET} from '@scure/bip32' import {hmac} from '@noble/hashes/hmac.js' @@ -10,6 +6,20 @@ import {sha256} from '@noble/hashes/sha2.js' import {secp256k1} from '@noble/curves/secp256k1.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import { + parseStoredSecret, + stampStoredSecretOwner, + storedSecretClaimedOwnerId, + storedSecretOwnerId, + stripStoredSecretOwner, + STORED_SECRET_VERSION, + type StoredSecret, +} from './storage/storedSecret' +import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' + +export {isValidStoredSecret} from './storage/storedSecret' +export type {StoredSecret} from './storage/storedSecret' + // The wallet's identity is derived against this fixed domain rather than // window.location.hostname, so the same seed phrase always yields the same // linking key (and thus decrypts the same bearer tokens) no matter where @@ -30,16 +40,12 @@ const readUint32BE = (bytes: Uint8Array, offset: number): number => // LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server - // a seed restored there or here produces the same identity for a given domain -export const deriveLud05LinkingKey = ( - seedPhrase: string, - domain: string -): Uint8Array => { +export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => { const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) const master = HDKey.fromMasterSeed(seed) const hashingKeyNode = master.derive("m/138'/0") - if (!hashingKeyNode.privateKey) - throw new Error('Could not derive hashing key') + if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key') const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain) // path suffix longs are raw BIP32 child indices: whether each level ends up @@ -55,12 +61,9 @@ export const deriveLud05LinkingKey = ( // the HMAC half of the derivation, split out so the LUD-05 test vector // (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it // directly - see keys.test.ts -export const lud05PathSuffix = ( - hashingKey: Uint8Array, - domain: string -): number[] => { +export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => { const material = hmac(sha256, hashingKey, utf8ToBytes(domain)) - return [0, 4, 8, 12].map(i => readUint32BE(material, i)) + return [0, 4, 8, 12].map((i) => readUint32BE(material, i)) } export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => @@ -75,56 +78,21 @@ export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string => // GCM's auth tag doubles as the "wrong password" check on decrypt. const PBKDF2_ITERATIONS = 210_000 -export type StoredSecret = - | {enc: false; value: string} - | {enc: true; salt: string; iv: string; ciphertext: string} - -// strict shape check on a StoredSecret - a plaintext form must be exactly a -// 32-byte hex key, an encrypted form must carry hex salt/iv/ciphertext of -// the sizes encryptSecretParts produces. Guards the backup-restore path -// (storage.ts's applyBackup), where a crafted file would otherwise get an -// arbitrary "linking key" installed verbatim. -export const isValidStoredSecret = ( - stored: unknown -): stored is StoredSecret => { - if (typeof stored !== 'object' || stored === null) return false - const s = stored as Record - if (s.enc === false) { - return typeof s.value === 'string' && /^[0-9a-f]{64}$/i.test(s.value) - } - if (s.enc === true) { - return ( - typeof s.salt === 'string' && - /^[0-9a-f]{32}$/i.test(s.salt) && - typeof s.iv === 'string' && - /^[0-9a-f]{24}$/i.test(s.iv) && - typeof s.ciphertext === 'string' && - s.ciphertext.length > 0 && - s.ciphertext.length % 2 === 0 && - /^[0-9a-f]+$/i.test(s.ciphertext) - ) - } - return false -} - const readSecret = (storageKey: string): StoredSecret | null => { const raw = localStorage.getItem(storageKey) if (!raw) return null try { const parsed: unknown = JSON.parse(raw) - return isValidStoredSecret(parsed) ? parsed : null + return parseStoredSecret(parsed)?.secret ?? null } catch { return null } } -const deriveAesKeyFromPassword = ( - password: string, - salt: Uint8Array -): Promise => +const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => crypto.subtle .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) - .then(baseKey => + .then((baseKey) => crypto.subtle.deriveKey( // the copy pins the TS type to Uint8Array - hexToBytes // returns Uint8Array, which BufferSource rejects @@ -132,13 +100,13 @@ const deriveAesKeyFromPassword = ( name: 'PBKDF2', salt: new Uint8Array(salt), iterations: PBKDF2_ITERATIONS, - hash: 'SHA-256' + hash: 'SHA-256', }, baseKey, {name: 'AES-GCM', length: 256}, false, - ['encrypt', 'decrypt'] - ) + ['encrypt', 'decrypt'], + ), ) export type EncryptedSecretParts = { @@ -149,29 +117,25 @@ export type EncryptedSecretParts = { export const encryptSecretParts = async ( value: string, - password: string + password: string, ): Promise => { const salt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const aesKey = await deriveAesKeyFromPassword(password, salt) const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - {name: 'AES-GCM', iv}, - aesKey, - utf8ToBytes(value) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)), ) return { salt: bytesToHex(salt), iv: bytesToHex(iv), - ciphertext: bytesToHex(ciphertext) + ciphertext: bytesToHex(ciphertext), } } // rejects (WebCrypto's own auth-tag check) if the password is wrong export const decryptSecretParts = async ( parts: EncryptedSecretParts, - password: string + password: string, ): Promise => { const salt = hexToBytes(parts.salt) const iv = hexToBytes(parts.iv) @@ -179,7 +143,7 @@ export const decryptSecretParts = async ( const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv}, aesKey, - hexToBytes(parts.ciphertext) + hexToBytes(parts.ciphertext), ) return new TextDecoder().decode(plaintext) } @@ -188,17 +152,64 @@ export const decryptSecretParts = async ( // it was derived from is shown once at setup and never stored. Everything // else at rest (the bearer tokens) is encrypted with a key derived from it, // so protecting this one record with a password protects the whole wallet. -const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' +// +// The record also carries an ownerId marker: the lowercase compressed +// pubkey hex of the key itself (storage/walletOwner.ts), binding every +// other wallet-owned record (passkeys, NWC, trusted mints) to this exact +// identity. Failure modes of the marker API: +// - new writes always carry the marker derived from the key being saved; +// - a record restored from a backup/relay is installed OWNERLESS - its +// file-carried marker is an unproven claim and is stripped on restore; +// - an ownerless legacy record stays usable but cannot establish ownership; +// malformed or unsupported owner-bearing metadata rejects the whole record; +// - ensureSavedKeyOwner stamps the marker after the caller proved the key +// (successful password/plaintext unlock or matching biometric unwrap), +// preserving ciphertext byte-for-byte; it refuses to restamp a record +// already owned by a different valid owner, and refuses a key that +// contradicts a plaintext record. +export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null -export const savedKeyExists = (): boolean => - readSecret(LINKING_KEY_STORAGE_KEY) !== null - -export const savedKeyIsEncrypted = (): boolean => - readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true +export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true export const getSavedLinkingKeyStored = (): StoredSecret | null => readSecret(LINKING_KEY_STORAGE_KEY) +// the proven owner of the saved key, or null when there is no record or it +// carries no current version-1 marker (ownerless or compatible unversioned) +export const savedKeyOwnerId = (): string | null => { + const stored = readSecret(LINKING_KEY_STORAGE_KEY) + return stored === null ? null : storedSecretOwnerId(stored) +} + +// Compares only against an owner freshly derived from a linking key. An +// ownerless or unversioned saved marker never matches. +export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean => + savedKeyOwnerId() === linkingPubKeyHex(linkingKey) + +// Stamps the owner marker onto the existing record. Call ONLY with the key +// just proven against this record (decryptSavedLinkingKey / a plaintext +// read / a biometric unwrap whose stored pubkey matched) - the marker is +// derived from that key, never from a stored claim. No saved record or an +// already-correct marker: no write. A DIFFERENT valid owner, or a key that +// contradicts a plaintext record, throws and leaves storage untouched. +export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => { + const stored = readSecret(LINKING_KEY_STORAGE_KEY) + if (stored === null) return + const ownerId = linkingPubKeyHex(linkingKey) + if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) { + throw new Error('Proven key does not match the saved wallet key.') + } + const claimedOwnerId = storedSecretClaimedOwnerId(stored) + if (storedSecretOwnerId(stored) === ownerId) return + if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { + throw new Error('Saved wallet key is owned by a different wallet.') + } + localStorage.setItem( + LINKING_KEY_STORAGE_KEY, + JSON.stringify(stampStoredSecretOwner(stored, ownerId)), + ) +} + export const getPlainLinkingKey = (): Uint8Array | null => { const stored = readSecret(LINKING_KEY_STORAGE_KEY) if (stored === null || stored.enc === true) return null @@ -207,30 +218,33 @@ export const getPlainLinkingKey = (): Uint8Array | null => { export const saveLinkingKey = async ( linkingPrivKey: Uint8Array, - password?: string + password?: string, ): Promise => { const hex = bytesToHex(linkingPrivKey) + const ownerId = linkingPubKeyHex(linkingPrivKey) if (!password) { localStorage.setItem( LINKING_KEY_STORAGE_KEY, - JSON.stringify({enc: false, value: hex}) + JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}), ) return } const parts = await encryptSecretParts(hex, password) localStorage.setItem( LINKING_KEY_STORAGE_KEY, - JSON.stringify({enc: true, ...parts}) + JSON.stringify({enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION}), ) } +// installs a record from a backup/relay. Any ownerId it carries is an +// unproven claim by whoever produced that file, so the marker is stripped +// here - the first proven unlock re-establishes it (see the failure-model +// comment above) export const restoreLinkingKeyStored = (stored: StoredSecret): void => { - localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored)) + localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored))) } -export const decryptSavedLinkingKey = async ( - password: string -): Promise => { +export const decryptSavedLinkingKey = async (password: string): Promise => { const stored = readSecret(LINKING_KEY_STORAGE_KEY) if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.') return hexToBytes(await decryptSecretParts(stored, password)) @@ -246,43 +260,32 @@ export const clearSavedLinkingKey = (): void => { // a fresh device and every previously exported ciphertext decrypts again. const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1' -export const deriveBearerAesKey = ( - linkingPrivKey: Uint8Array -): Promise => { - const material = sha256( - new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]) - ) - return crypto.subtle.importKey('raw', material, 'AES-GCM', false, [ - 'encrypt', - 'decrypt' - ]) +export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise => { + const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])) + return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']) } export type EncryptedRecordParts = {iv: string; ciphertext: string} export const encryptRecord = async ( aesKey: CryptoKey, - value: object + value: object, ): Promise => { const iv = crypto.getRandomValues(new Uint8Array(12)) const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - {name: 'AES-GCM', iv}, - aesKey, - utf8ToBytes(JSON.stringify(value)) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))), ) return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)} } -export const decryptRecord = async ( +export const decryptRecord = async ( aesKey: CryptoKey, - parts: EncryptedRecordParts -): Promise => { + parts: EncryptedRecordParts, +): Promise => { const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv: hexToBytes(parts.iv)}, aesKey, - hexToBytes(parts.ciphertext) + hexToBytes(parts.ciphertext), ) - return JSON.parse(new TextDecoder().decode(plaintext)) as T + return JSON.parse(new TextDecoder().decode(plaintext)) } diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts new file mode 100644 index 0000000..fd686f5 --- /dev/null +++ b/src/lnurlcash/keys.version.cases.ts @@ -0,0 +1,102 @@ +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import { + ensureSavedKeyOwner, + getPlainLinkingKey, + isValidStoredSecret, + linkingPubKeyHex, + savedKeyExists, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const STORAGE_KEY = 'sattle_linking_key' + +const readRawRecord = (): Record => { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) throw new Error('expected a saved linking-key record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +describe('saved-key schema version', () => { + it('writes version 1 on current plaintext and encrypted records', async () => { + // Given a linking key saved through each current persistence path + await saveLinkingKey(LINKING_KEY) + const plaintext = readRawRecord() + await saveLinkingKey(LINKING_KEY, 'correct horse') + const encrypted = readRawRecord() + + // When the persisted schema metadata is inspected + // Then both owner-bearing records carry the recognized discriminator + expect(plaintext.version).toBe(1) + expect(encrypted.version).toBe(1) + }) + + it('upgrades an unversioned same-owner record only after key proof', () => { + // Given the valid owner-bearing shape written before schema versioning + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}), + ) + + // When it is read before and then stamped after the plaintext key proves ownership + expect(savedKeyOwnerId()).toBeNull() + const provenKey = getPlainLinkingKey() + if (provenKey === null) throw new Error('expected the compatible plaintext key') + ensureSavedKeyOwner(provenKey) + + // Then it becomes an explicitly versioned current record + expect(readRawRecord()).toEqual({ + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId, + version: 1, + }) + }) + + it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => { + // Given an otherwise valid owner-bearing record with unrecognized metadata + const record = { + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(LINKING_KEY), + version, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + + // When the saved-key boundary parses it + // Then the record is neither usable nor eligible for legacy adoption + expect(isValidStoredSecret(record)).toBe(false) + expect(savedKeyExists()).toBe(false) + expect(savedKeyOwnerId()).toBeNull() + expect(getPlainLinkingKey()).toBeNull() + }) + + it('rejects a foreign current owner at the proven-key stamping boundary', () => { + // Given a versioned record whose owner conflicts with its plaintext key + const record = { + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + version: 1, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + const before = localStorage.getItem(STORAGE_KEY) + + // When the actual key is proven + const stamp = () => ensureSavedKeyOwner(LINKING_KEY) + + // Then the foreign claim fails closed without rewriting storage + expect(stamp).toThrow('different wallet') + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + }) +}) diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts new file mode 100644 index 0000000..fd7c4aa --- /dev/null +++ b/src/lnurlcash/mintContract.test.ts @@ -0,0 +1,165 @@ +// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance +// 0.1.1 artifacts. Two contracts the app's mint discovery relies on: +// the kit must MAP the mint-address wire field `nodeCapacity` onto the +// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the +// typed field read undefined forever), and a payRequest withdraw link is +// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published +// conformance mock mint emits `lnurlw://` by default, so do NOT assume an +// HTTPS default anywhere in the receive path. + +import {afterEach, describe, expect, it} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit' + +import {claimMintedNote, prepareMint} from './ops' +import {mintAddressCacheInfo} from './trustedMints' + +type Mint = Awaited> + +const mints: Mint[] = [] +const mint = async (options: Parameters[0] = {}): Promise => { + const m = await createMockMint(options) + mints.push(m) + return m +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map((m) => m.close())) +}) + +// paying a mint invoice is what brings its note into existence - the mock +// exposes that through its test hook (settle + credit in one step) +const settleLastInvoice = async (m: Mint): Promise => { + const paymentHash = [...m.state.invoices.keys()].at(-1) + if (!paymentHash) throw new Error('no invoice requested yet') + const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) + const invoice = m.state.invoices.get(paymentHash) + if (!invoice) throw new Error('settled invoice vanished from the mock') + return invoice.preimage +} + +const MINT_PUBKEY = `02${'ab'.repeat(32)}` + +// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: +// node stats under their WIRE names - `nodeCapacity` is msat like every +// other amount, named without the suffix on the wire +const mintAddressFixture = { + tag: 'withdrawRequest', + callback: 'https://mint.example/w/cb', + minWithdrawable: 1_000, + maxWithdrawable: 100_000_000, + defaultDescription: 'fixture mint', + payLink: 'https://mint.example/.well-known/lnurlp/mint', + mintPubkey: MINT_PUBKEY, + nodeAlias: 'fixture-mint', + nodeCapacity: 500_000_000, + nodeNumChannels: 4, + nodeNumPeers: 6, +} + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: {'content-type': 'application/json'}, + }) + +// a fetch that serves fixture bodies by URL prefix and 404s everything else, +// so a test drives the real kit HTTP boundary without any network +const fixtureFetch = (routes: ReadonlyArray): typeof fetch => { + const impl: typeof fetch = (input, _init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + for (const [prefix, body] of routes) { + if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body)) + } + return Promise.resolve(new Response('not found', {status: 404})) + } + return impl +} + +describe('mint-address wire contract', () => { + it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => { + const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { + fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), + }) + // renamed fields have to be mapped, not spread: the spread carries the + // wire name through and the typed one reads undefined forever + expect(info.nodePubkey).toBe(MINT_PUBKEY) + expect(info.nodeCapacityMsat).toBe(500_000_000) + expect(info.nodeNumChannels).toBe(4) + expect(info.nodeNumPeers).toBe(6) + }) + + it('carries node stats into the cached trusted-mint display metadata', async () => { + const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { + fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), + }) + const cached = mintAddressCacheInfo(info, 'mint') + expect(cached?.nodeCapacityMsat).toBe(500_000_000) + expect(cached?.nodeNumChannels).toBe(4) + expect(cached?.nodeNumPeers).toBe(6) + }) + + it("surfaces the mock mint's mint-address node stats through prepareMint", async () => { + const m = await mint() + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + // the metadata is advertised at the mint-address endpoint itself - + // the payRequest never carried it + expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) + expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000) + expect(prepared.nodeInfo?.nodeNumChannels).toBe(4) + expect(prepared.nodeInfo?.nodeNumPeers).toBe(6) + const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username) + expect(cached?.nodeCapacityMsat).toBe(500_000_000) + }) +}) + +describe('withdraw-link forms', () => { + it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { + const m = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + // published conformance 0.1.1 emits lnurlw:// by default - NOT https + expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) + + // and the link is fully usable: settle the invoice, claim the note + const preimage = await settleLastInvoice(m) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(m.state.noteState(preimage)).toBe('burned') + }) + + it('accepts an HTTPS withdraw link', async () => { + const fetch = fixtureFetch([ + ['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture], + [ + 'https://mint.example/.well-known/lnurlp/mint', + { + tag: 'payRequest', + callback: 'https://mint.example/pay', + minSendable: 1_000, + maxSendable: 100_000_000_000, + withdrawLink: 'https://mint.example/note', + metadata: '[]', + }, + ], + // amount-less invoice: the kit skips its amount cross-check + ['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}], + ]) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch}) + expect(prepared.withdrawLink).toBe('https://mint.example/note') + // the mint-address payLink is authoritative - the payRequest came from it + expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint') + }) + + it('builds the same note URL from both withdraw-link forms', () => { + const k1 = 'ab'.repeat(32) + expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe( + buildNoteUrl('https://mint.example/note', k1, 21_000), + ) + }) +}) diff --git a/src/lnurlcash/nostr/events.ts b/src/lnurlcash/nostr/events.ts index 7a3b04d..bffeae5 100644 --- a/src/lnurlcash/nostr/events.ts +++ b/src/lnurlcash/nostr/events.ts @@ -43,7 +43,7 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings' export const BACKUP_D_TAGS: Record = { notes: 'notes', mints: 'mints', - settings: 'settings' + settings: 'settings', } // the decrypted payload of each part, without its envelope @@ -62,8 +62,7 @@ export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array => sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)])) // the x-only nostr pubkey identifying this wallet's backup events -export const backupPubkey = (secretKey: Uint8Array): string => - getPublicKey(secretKey) +export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey) // NIP-44 "self-DM": the conversation key between the backup key and its own // pubkey - decryptable by the seed holder and nobody else @@ -76,13 +75,9 @@ const selfConversationKey = (secretKey: Uint8Array): Uint8Array => // per-record bounds still apply on top after decrypt) const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 -export const dTagOf = (event: NostrEvent): string => - event.tags.find(t => t[0] === 'd')?.[1] ?? '' +export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' -const envelopeFor = ( - part: BackupPart, - payload: BackupPartPayload[BackupPart] -): string => { +const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { switch (part) { case 'notes': return JSON.stringify({version: 1, bearers: payload}) @@ -98,26 +93,23 @@ export const buildBackupEvent =

( secretKey: Uint8Array, part: P, payload: BackupPartPayload[P], - createdAt: number = Math.floor(Date.now() / 1000) + createdAt: number = Math.floor(Date.now() / 1000), ): NostrEvent => finalizeEvent( { kind: BACKUP_EVENT_KIND, created_at: createdAt, tags: [['d', BACKUP_D_TAGS[part]]], - content: nip44v2.encrypt( - envelopeFor(part, payload), - selfConversationKey(secretKey) - ) + content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)), }, - secretKey + secretKey, ) // one event per part present in `parts`, all sharing one timestamp export const buildBackupEvents = ( secretKey: Uint8Array, parts: Partial, - createdAt?: number + createdAt?: number, ): NostrEvent[] => { const at = createdAt ?? Math.floor(Date.now() / 1000) const events: NostrEvent[] = [] @@ -138,10 +130,7 @@ export type ParsedBackupEvent = // (record counts, field lengths, pubkey patterns) are enforced by // applyBackup / mergeTrustedMints on the restore path, same as file // backups. -const parsePayload = ( - dTag: BackupPart, - data: unknown -): ParsedBackupEvent | null => { +const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => { if (typeof data !== 'object' || data === null) return null const envelope = data as Record if (envelope.version !== 1) return null @@ -151,10 +140,10 @@ const parsePayload = ( const bearers = envelope.bearers as unknown[] if ( !bearers.every( - r => + (r) => typeof (r as EncryptedBearerRecord)?.id === 'string' && typeof (r as EncryptedBearerRecord)?.iv === 'string' && - typeof (r as EncryptedBearerRecord)?.ciphertext === 'string' + typeof (r as EncryptedBearerRecord)?.ciphertext === 'string', ) ) { return null @@ -166,9 +155,9 @@ const parsePayload = ( const mints = envelope.trustedMints as unknown[] if ( !mints.every( - m => + (m) => typeof (m as TrustedMint)?.server === 'string' && - typeof (m as TrustedMint)?.mintPubkey === 'string' + typeof (m as TrustedMint)?.mintPubkey === 'string', ) ) { return null @@ -180,10 +169,7 @@ const parsePayload = ( return null } const settings = envelope.settings as Record - if ( - settings.defaultMint !== undefined && - typeof settings.defaultMint !== 'string' - ) { + if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') { return null } return {part: 'settings', settings: settings as WalletSettings} @@ -197,7 +183,7 @@ const parsePayload = ( // skip nulls - one junk event must never sink a restore. export const parseBackupEvent = ( secretKey: Uint8Array, - event: NostrEvent + event: NostrEvent, ): ParsedBackupEvent | null => { if (event.kind !== BACKUP_EVENT_KIND) return null if (event.pubkey !== getPublicKey(secretKey)) return null diff --git a/src/lnurlcash/nostr/publisher.ts b/src/lnurlcash/nostr/publisher.ts index 8c0e79b..0fab0e2 100644 --- a/src/lnurlcash/nostr/publisher.ts +++ b/src/lnurlcash/nostr/publisher.ts @@ -22,9 +22,7 @@ export type BackupPublisherOptions = { onError?: (error: unknown) => void } -export const createBackupPublisher = ( - options: BackupPublisherOptions -): BackupPublisher => { +export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => { let timer: ReturnType | null = null let pending: Partial | null = null let running: Promise | null = null @@ -45,7 +43,9 @@ export const createBackupPublisher = ( try { await options.publish(snapshot) } catch (error) { - options.onError?.(error) + options.onError?.( + error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}), + ) } } } @@ -61,7 +61,7 @@ export const createBackupPublisher = ( } return { - schedule: parts => { + schedule: (parts) => { pending = parts clearTimer() timer = setTimeout(() => void fire(), options.delayMs) @@ -70,6 +70,6 @@ export const createBackupPublisher = ( cancel: () => { clearTimer() pending = null - } + }, } } diff --git a/src/lnurlcash/nostr/sync.ts b/src/lnurlcash/nostr/sync.ts index 9a0dc43..67515cb 100644 --- a/src/lnurlcash/nostr/sync.ts +++ b/src/lnurlcash/nostr/sync.ts @@ -10,12 +10,9 @@ import type {RestoreResult} from '../storage/backup' import {applyBackup} from '../storage/backup' +import {linkingPubKeyHex} from '../keys' -import type { - BackupPart, - BackupPartPayload, - NostrEvent -} from './events' +import type {BackupPart, BackupPartPayload, NostrEvent} from './events' import { BACKUP_EVENT_KIND, BACKUP_PARTS, @@ -23,7 +20,7 @@ import { buildBackupEvent, deriveBackupKey, dTagOf, - parseBackupEvent + parseBackupEvent, } from './events' import type {BackupTransport} from './transport' import {defaultTransport} from './transport' @@ -42,20 +39,17 @@ export const publishBackup = async ( secretKey: Uint8Array, parts: Partial, relays: string[], - options: PublishBackupOptions = {} + options: PublishBackupOptions = {}, ): Promise => { const at = options.createdAt ?? Math.floor(Date.now() / 1000) - const present = BACKUP_PARTS.filter(part => parts[part] !== undefined) + const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined) if (present.length === 0) return {published: []} const transport = options.transport ?? (await defaultTransport()) const published: BackupPart[] = [] for (const part of present) { const payload = parts[part] if (payload === undefined) continue - await transport.publish( - relays, - buildBackupEvent(secretKey, part, payload, at) - ) + await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at)) published.push(part) } return {published} @@ -75,12 +69,12 @@ export type FetchBackupOptions = { export const fetchBackup = async ( pubkey: string, relays: string[], - options: FetchBackupOptions + options: FetchBackupOptions, ): Promise> => { const transport = options.transport ?? (await defaultTransport()) const events = await transport.fetch(relays, { kinds: [BACKUP_EVENT_KIND], - authors: [pubkey] + authors: [pubkey], }) const byTag = new Map() for (const event of events) { @@ -130,23 +124,26 @@ export type NostrRestoreResult = RestoreResult & { export const restoreFromNostr = async ( linkingPrivKey: Uint8Array, relays: string[], - options: {transport?: BackupTransport} = {} + options: {transport?: BackupTransport} = {}, ): Promise => { const secretKey = deriveBackupKey(linkingPrivKey) const parts = await fetchBackup(backupPubkey(secretKey), relays, { secretKey, - transport: options.transport - }) - const result = applyBackup({ - type: 'sattle-backup', - version: 1, - createdAt: Date.now(), - bearers: parts.notes ?? [], - trustedMints: parts.mints, - settings: parts.settings + transport: options.transport, }) + const result = await applyBackup( + { + type: 'sattle-backup', + version: 1, + createdAt: Date.now(), + bearers: parts.notes ?? [], + trustedMints: parts.mints, + settings: parts.settings, + }, + linkingPubKeyHex(linkingPrivKey), + ) return { ...result, - found: BACKUP_PARTS.filter(part => parts[part] !== undefined) + found: BACKUP_PARTS.filter((part) => parts[part] !== undefined), } } diff --git a/src/lnurlcash/nostr/transport.ts b/src/lnurlcash/nostr/transport.ts index 95e1870..b0df178 100644 --- a/src/lnurlcash/nostr/transport.ts +++ b/src/lnurlcash/nostr/transport.ts @@ -23,10 +23,10 @@ export const defaultTransport = async (): Promise => { const results = await Promise.allSettled(pool.publish(relays, event)) // one honest relay keeping the event is enough - addressable events // are re-publishable, and the next debounced publish retries anyway - if (!results.some(r => r.status === 'fulfilled')) { + if (!results.some((r) => r.status === 'fulfilled')) { throw new Error('No relay accepted the backup event.') } }, - fetch: (relays, filter) => pool.querySync(relays, filter) + fetch: (relays, filter) => pool.querySync(relays, filter), } } diff --git a/src/lnurlcash/nostrBackup.codec.cases.ts b/src/lnurlcash/nostrBackup.codec.cases.ts new file mode 100644 index 0000000..8d84386 --- /dev/null +++ b/src/lnurlcash/nostrBackup.codec.cases.ts @@ -0,0 +1,191 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr, +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers, +} from './storage' +import type {Bearer} from './types' +import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + (e) => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)), + ), + ), + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('buildBackupEvent / parseBackupEvent', () => { + const secretKey = deriveBackupKey(LINKING_KEY) + + const records = [ + // long unique sentinel id: a short id like 'r1' randomly appears in + // base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext + // assertion below + {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, + ] + const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}] + const settings = {defaultMint: 'mint.example'} + + it('round-trips all three parts through build and parse', () => { + const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) + expect(events).toHaveLength(3) + expect(events.map((e) => e.kind)).toEqual([ + BACKUP_EVENT_KIND, + BACKUP_EVENT_KIND, + BACKUP_EVENT_KIND, + ]) + expect(events.map((e) => e.tags)).toEqual([ + [['d', 'notes']], + [['d', 'mints']], + [['d', 'settings']], + ]) + expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true) + + expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({ + part: 'notes', + bearers: records, + }) + expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ + part: 'mints', + trustedMints: mints, + }) + expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({ + part: 'settings', + settings, + }) + }) + + it('leaves no plaintext in the payload', () => { + const event = buildBackupEvent(secretKey, 'notes', records) + expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') + expect(event.content).not.toContain('ciphertext') + }) + + it('builds events only for the parts present', () => { + const events = buildBackupEvents(secretKey, {settings}, 1000) + expect(events).toHaveLength(1) + expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]) + }) + + it('rejects a payload encrypted for a different key', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull() + }) + + it('rejects the wrong kind', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull() + }) + + it('rejects an unknown d-tag', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull() + }) + + it('rejects a modified ciphertext - the signature no longer matches', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + const tampered = {...event, content: tamperContent(event.content)} + expect(parseBackupEvent(secretKey, tampered)).toBeNull() + }) + + it('rejects an event signed by a different key', () => { + const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings) + expect(parseBackupEvent(secretKey, foreign)).toBeNull() + }) + + it('rejects a validly signed event whose payload is not a backup envelope', () => { + // a same-key event of the right kind and d-tag, but its decrypted + // content is not a version-1 envelope + const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) + const event = finalizeEvent( + { + kind: BACKUP_EVENT_KIND, + created_at: 1000, + tags: [['d', 'settings']], + content: nip44v2.encrypt(JSON.stringify({version: 2, settings: {}}), conversationKey), + }, + secretKey, + ) + expect(parseBackupEvent(secretKey, event)).toBeNull() + }) +}) diff --git a/src/lnurlcash/nostrBackup.keys.cases.ts b/src/lnurlcash/nostrBackup.keys.cases.ts new file mode 100644 index 0000000..916c819 --- /dev/null +++ b/src/lnurlcash/nostrBackup.keys.cases.ts @@ -0,0 +1,112 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr, +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers, +} from './storage' +import type {Bearer} from './types' +import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + (e) => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)), + ), + ), + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('deriveBackupKey', () => { + it('derives a stable key from the linking key', () => { + // pinned: changing the context string or the construction would + // silently orphan every backup ever published - the wallet would + // derive a different pubkey and find nothing to restore + expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe( + 'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9', + ) + }) + + it('derives a different key from a different linking key', () => { + expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe( + bytesToHex(deriveBackupKey(LINKING_KEY)), + ) + }) +}) diff --git a/src/lnurlcash/nostrBackup.publisher.cases.ts b/src/lnurlcash/nostrBackup.publisher.cases.ts new file mode 100644 index 0000000..88bf238 --- /dev/null +++ b/src/lnurlcash/nostrBackup.publisher.cases.ts @@ -0,0 +1,211 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr, +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers, +} from './storage' +import type {Bearer} from './types' +import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + (e) => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)), + ), + ), + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createBackupPublisher', () => { + it('coalesces rapid schedules into a single publish of the latest snapshot', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: (p) => { + published.push(p) + return Promise.resolve() + }, + delayMs: 1000, + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + publisher.schedule({settings: {defaultMint: 'b'}}) + publisher.schedule({settings: {defaultMint: 'c'}}) + await vi.advanceTimersByTimeAsync(999) + expect(published).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + expect(published).toEqual([{settings: {defaultMint: 'c'}}]) + }) + + it('publishes again when a change lands after the quiet window', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: (p) => { + published.push(p) + return Promise.resolve() + }, + delayMs: 1000, + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await vi.advanceTimersByTimeAsync(1000) + publisher.schedule({settings: {defaultMint: 'b'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}]) + }) + + it('publishes a snapshot that lands mid-publish instead of losing it', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + // the publish callback re-schedules on the publisher being created - + // a holder indirection keeps both const + const holder: {publisher?: ReturnType} = {} + const publisher = createBackupPublisher({ + publish: (p) => { + published.push(p) + // a local change lands while the first publish is in flight + if (published.length === 1) { + holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}}) + } + return Promise.resolve() + }, + delayMs: 1000, + }) + holder.publisher = publisher + + publisher.schedule({settings: {defaultMint: 'first'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([ + {settings: {defaultMint: 'first'}}, + {settings: {defaultMint: 'mid-flight'}}, + ]) + }) + + it('flush publishes immediately; cancel drops the pending snapshot', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: (p) => { + published.push(p) + return Promise.resolve() + }, + delayMs: 60_000, + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await publisher.flush() + expect(published).toEqual([{settings: {defaultMint: 'a'}}]) + + publisher.schedule({settings: {defaultMint: 'b'}}) + publisher.cancel() + await vi.advanceTimersByTimeAsync(60_000) + expect(published).toHaveLength(1) + }) + + it('reports a failed publish via onError and retries on the next change', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const errors: unknown[] = [] + let failing = true + const publisher = createBackupPublisher({ + publish: (p) => { + if (failing) return Promise.reject(new Error('relay down')) + published.push(p) + return Promise.resolve() + }, + delayMs: 1000, + onError: (e) => { + errors.push(e) + }, + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([]) + expect(errors).toHaveLength(1) + + failing = false + publisher.schedule({settings: {defaultMint: 'b'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([{settings: {defaultMint: 'b'}}]) + }) +}) diff --git a/src/lnurlcash/nostrBackup.restore.cases.ts b/src/lnurlcash/nostrBackup.restore.cases.ts new file mode 100644 index 0000000..fc394e7 --- /dev/null +++ b/src/lnurlcash/nostrBackup.restore.cases.ts @@ -0,0 +1,223 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr, +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers, +} from './storage' +import type {Bearer} from './types' +import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + (e) => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)), + ), + ), + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('restoreFromNostr', () => { + it('restores notes, mints and settings onto a fresh device through applyBackup', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A: one note, one trusted mint, one setting - all published + await saveLinkingKey(LINKING_KEY) + await persistBearer(aesKey, bearerFixture({id: 'note-a'})) + await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID}) + persistSettings({defaultMint: 'mint.example'}) + await publishBackup( + secretKey, + { + notes: readEncryptedBearers(), + mints: readTrustedMints(OWNER_ID), + settings: loadSettings(), + }, + RELAYS, + {transport}, + ) + + // device B: the same seed on empty storage + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + expect(result.found).toEqual(['notes', 'mints', 'settings']) + expect(result.added).toBe(1) + expect(result.trustedMintsAdded).toBe(1) + expect(result.settingsRestored).toBe(true) + // the linking key is never part of a nostr backup - the seed phrase + // the holder entered is its recovery path + expect(result.linkingKeyRestored).toBe(false) + + // the note decrypts under this device's bearer key - same seed + expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) + expect(loadSettings()).toEqual({defaultMint: 'mint.example'}) + // a file/backup-sourced mint pin stays unconfirmed until a live + // response corroborates it - nostr restore inherits that rule from + // applyBackup unchanged + expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true) + }) + + it('unions records by id and lets a spent copy win after decrypt', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A publishes its store holding the spendable note + await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) + await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + transport, + createdAt: 1000, + }) + + // device B restores, then marks the same note spent under its OWN + // record id, and republishes its full store + stubLocalStorage() + await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) + await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + transport, + createdAt: 2000, + }) + + // device C restores from the final published state + stubLocalStorage() + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + // union by record id: both copies landed + expect(result.added).toBe(2) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['rec-a', 'rec-b']) + + // after decrypt, the note-level merge (same server + k1) collapses + // them, and the spent copy wins even though its record is the newer + // arrival - a restored backup must never resurrect spendable money + const merged = mergeBearers([], await loadBearers(aesKey)) + expect(merged).toHaveLength(1) + expect(requiredValue(merged[0]).id).toBe('rec-b') + expect(requiredValue(merged[0]).spent).toBe(true) + }) + + it('never overwrites local state: records union, settings keep local values', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + await persistBearer(aesKey, bearerFixture({id: 'remote'})) + persistSettings({defaultMint: 'remote.example'}) + await publishBackup( + secretKey, + {notes: readEncryptedBearers(), settings: loadSettings()}, + RELAYS, + {transport, createdAt: 1000}, + ) + + // this device already has its own wallet state + stubLocalStorage() + await persistBearer( + aesKey, + bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}), + ) + persistSettings({defaultMint: 'local.example'}) + + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + expect(result.added).toBe(1) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['local', 'remote']) + expect(result.settingsRestored).toBe(false) + expect(loadSettings()).toEqual({defaultMint: 'local.example'}) + }) + + it('reports nothing found when the relays hold no backup', async () => { + const {transport} = createRecordingTransport() + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + expect(result.found).toEqual([]) + expect(result.added).toBe(0) + }) +}) diff --git a/src/lnurlcash/nostrBackup.test.ts b/src/lnurlcash/nostrBackup.test.ts index 90ea581..735fdfc 100644 --- a/src/lnurlcash/nostrBackup.test.ts +++ b/src/lnurlcash/nostrBackup.test.ts @@ -1,530 +1,5 @@ -// Nostr backup: key derivation stability, event build/parse round-trips, -// tamper rejection, publish/fetch and restore against an in-memory relay -// (the transport is injected - no network), and the debounced publisher. - -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' -import {bytesToHex} from '@noble/hashes/utils.js' -import type {NostrEvent} from 'nostr-tools/core' -import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' -import {v2 as nip44v2} from 'nostr-tools/nip44' -import {buildNoteUrl} from 'lnurlcash-kit' - -import {deriveBearerAesKey} from './keys' -import { - BACKUP_EVENT_KIND, - backupPubkey, - buildBackupEvent, - buildBackupEvents, - createBackupPublisher, - deriveBackupKey, - fetchBackup, - parseBackupEvent, - publishBackup, - restoreFromNostr -} from './nostrBackup' -import type {BackupPartPayload, BackupTransport} from './nostrBackup' -import { - loadBearers, - loadSettings, - mergeBearers, - persistBearer, - persistSettings, - readEncryptedBearers -} from './storage' -import type {Bearer} from './types' -import { - addTrustedMint, - clearTrustedMints, - isMintUnconfirmed, - readTrustedMints -} from './trustedMints' -import {stubLocalStorage} from './test-utils' - -const LINKING_KEY = new Uint8Array(32).fill(7) -const OTHER_KEY = new Uint8Array(32).fill(9) - -const K1_A = 'aa'.repeat(32) -const K1_B = 'bb'.repeat(32) -const MINT_PUBKEY = 'ab'.repeat(33) - -// never connected - the recording transport below stands in for the relays -const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] - -const bearerFixture = (overrides: Partial = {}): Bearer => ({ - id: 'fixture', - url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), - callback: 'https://mint.example/w/cb', - amount: 21_000, - verified: true, - createdAt: 1000, - updatedAt: 1000, - ...overrides -}) - -// an in-memory relay set. It serves EVERY event it ever accepted, older -// addressable copies included - like a relay that never replaces - which -// is exactly the case fetchBackup's client-side latest-pick exists for -const createRecordingTransport = (): { - transport: BackupTransport - events: NostrEvent[] -} => { - const events: NostrEvent[] = [] - const transport: BackupTransport = { - publish: (_relays, event) => { - events.push(event) - return Promise.resolve() - }, - fetch: (_relays, filter) => - Promise.resolve( - events.filter( - e => - (!filter.kinds || filter.kinds.includes(e.kind)) && - (!filter.authors || filter.authors.includes(e.pubkey)) - ) - ) - } - return {transport, events} -} - -// flips the end of a base64 payload to different-but-valid characters -const tamperContent = (content: string): string => - content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') - -beforeEach(() => { - stubLocalStorage() - // the trusted-mint registry caches module-level - reset it alongside - // the storage stub - clearTrustedMints() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('deriveBackupKey', () => { - it('derives a stable key from the linking key', () => { - // pinned: changing the context string or the construction would - // silently orphan every backup ever published - the wallet would - // derive a different pubkey and find nothing to restore - expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe( - 'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9' - ) - }) - - it('derives a different key from a different linking key', () => { - expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe( - bytesToHex(deriveBackupKey(LINKING_KEY)) - ) - }) -}) - -describe('buildBackupEvent / parseBackupEvent', () => { - const secretKey = deriveBackupKey(LINKING_KEY) - - const records = [ - // long unique sentinel id: a short id like 'r1' randomly appears in - // base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext - // assertion below - {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)} - ] - const mints = [ - {server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true} - ] - const settings = {defaultMint: 'mint.example'} - - it('round-trips all three parts through build and parse', () => { - const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) - expect(events).toHaveLength(3) - expect(events.map(e => e.kind)).toEqual([ - BACKUP_EVENT_KIND, - BACKUP_EVENT_KIND, - BACKUP_EVENT_KIND - ]) - expect(events.map(e => e.tags)).toEqual([ - [['d', 'notes']], - [['d', 'mints']], - [['d', 'settings']] - ]) - expect(events.every(e => e.pubkey === backupPubkey(secretKey))).toBe(true) - - expect(parseBackupEvent(secretKey, events[0]!)).toEqual({ - part: 'notes', - bearers: records - }) - expect(parseBackupEvent(secretKey, events[1]!)).toEqual({ - part: 'mints', - trustedMints: mints - }) - expect(parseBackupEvent(secretKey, events[2]!)).toEqual({ - part: 'settings', - settings - }) - }) - - it('leaves no plaintext in the payload', () => { - const event = buildBackupEvent(secretKey, 'notes', records) - expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') - expect(event.content).not.toContain('ciphertext') - }) - - it('builds events only for the parts present', () => { - const events = buildBackupEvents(secretKey, {settings}, 1000) - expect(events).toHaveLength(1) - expect(events[0]!.tags).toEqual([['d', 'settings']]) - }) - - it('rejects a payload encrypted for a different key', () => { - const event = buildBackupEvent(secretKey, 'settings', settings) - expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull() - }) - - it('rejects the wrong kind', () => { - const event = buildBackupEvent(secretKey, 'settings', settings) - expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull() - }) - - it('rejects an unknown d-tag', () => { - const event = buildBackupEvent(secretKey, 'settings', settings) - expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull() - }) - - it('rejects a modified ciphertext - the signature no longer matches', () => { - const event = buildBackupEvent(secretKey, 'settings', settings) - const tampered = {...event, content: tamperContent(event.content)} - expect(parseBackupEvent(secretKey, tampered)).toBeNull() - }) - - it('rejects an event signed by a different key', () => { - const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings) - expect(parseBackupEvent(secretKey, foreign)).toBeNull() - }) - - it('rejects a validly signed event whose payload is not a backup envelope', () => { - // a same-key event of the right kind and d-tag, but its decrypted - // content is not a version-1 envelope - const conversationKey = nip44v2.utils.getConversationKey( - secretKey, - getPublicKey(secretKey) - ) - const event = finalizeEvent( - { - kind: BACKUP_EVENT_KIND, - created_at: 1000, - tags: [['d', 'settings']], - content: nip44v2.encrypt( - JSON.stringify({version: 2, settings: {}}), - conversationKey - ) - }, - secretKey - ) - expect(parseBackupEvent(secretKey, event)).toBeNull() - }) -}) - -describe('publishBackup / fetchBackup', () => { - const secretKey = deriveBackupKey(LINKING_KEY) - - it('publishes every part and fetches them back decrypted', async () => { - const {transport} = createRecordingTransport() - const parts = { - notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], - mints: [ - {server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false} - ], - settings: {defaultMint: 'mint.example'} - } - const published = await publishBackup(secretKey, parts, RELAYS, {transport}) - expect(published.published).toEqual(['notes', 'mints', 'settings']) - - const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { - secretKey, - transport - }) - expect(fetched).toEqual(parts) - }) - - it('publishes nothing when no parts are given', async () => { - const {transport, events} = createRecordingTransport() - const result = await publishBackup(secretKey, {}, RELAYS, {transport}) - expect(result.published).toEqual([]) - expect(events).toEqual([]) - }) - - it('picks the newest event per d-tag when a relay serves stale copies', async () => { - const {transport} = createRecordingTransport() - await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, { - transport, - createdAt: 1000 - }) - await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, { - transport, - createdAt: 2000 - }) - // the recording transport serves BOTH - the newer must win - const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { - secretKey, - transport - }) - expect(parts.settings).toEqual({defaultMint: 'new.example'}) - }) - - it('falls back to an older valid copy when the newest event is tampered', async () => { - const {transport, events} = createRecordingTransport() - await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, { - transport, - createdAt: 1000 - }) - events.push({ - ...events[0]!, - content: tamperContent(events[0]!.content), - created_at: 3000 - }) - const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { - secretKey, - transport - }) - expect(parts.settings).toEqual({defaultMint: 'mint.example'}) - }) -}) - -describe('restoreFromNostr', () => { - it('restores notes, mints and settings onto a fresh device through applyBackup', async () => { - const aesKey = await deriveBearerAesKey(LINKING_KEY) - const secretKey = deriveBackupKey(LINKING_KEY) - const {transport} = createRecordingTransport() - - // device A: one note, one trusted mint, one setting - all published - await persistBearer(aesKey, bearerFixture({id: 'note-a'})) - addTrustedMint('mint.example', MINT_PUBKEY) - persistSettings({defaultMint: 'mint.example'}) - await publishBackup( - secretKey, - { - notes: readEncryptedBearers(), - mints: readTrustedMints(), - settings: loadSettings() - }, - RELAYS, - {transport} - ) - - // device B: the same seed on empty storage - stubLocalStorage() - clearTrustedMints() - const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) - - expect(result.found).toEqual(['notes', 'mints', 'settings']) - expect(result.added).toBe(1) - expect(result.trustedMintsAdded).toBe(1) - expect(result.settingsRestored).toBe(true) - // the linking key is never part of a nostr backup - the seed phrase - // the holder entered is its recovery path - expect(result.linkingKeyRestored).toBe(false) - - // the note decrypts under this device's bearer key - same seed - expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) - expect(loadSettings()).toEqual({defaultMint: 'mint.example'}) - // a file/backup-sourced mint pin stays unconfirmed until a live - // response corroborates it - nostr restore inherits that rule from - // applyBackup unchanged - expect(isMintUnconfirmed('mint.example')).toBe(true) - }) - - it('unions records by id and lets a spent copy win after decrypt', async () => { - const aesKey = await deriveBearerAesKey(LINKING_KEY) - const secretKey = deriveBackupKey(LINKING_KEY) - const {transport} = createRecordingTransport() - - // device A publishes its store holding the spendable note - await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { - transport, - createdAt: 1000 - }) - - // device B restores, then marks the same note spent under its OWN - // record id, and republishes its full store - stubLocalStorage() - clearTrustedMints() - await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) - await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { - transport, - createdAt: 2000 - }) - - // device C restores from the final published state - stubLocalStorage() - clearTrustedMints() - const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) - - // union by record id: both copies landed - expect(result.added).toBe(2) - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['rec-a', 'rec-b']) - - // after decrypt, the note-level merge (same server + k1) collapses - // them, and the spent copy wins even though its record is the newer - // arrival - a restored backup must never resurrect spendable money - const merged = mergeBearers([], await loadBearers(aesKey)) - expect(merged).toHaveLength(1) - expect(merged[0]!.id).toBe('rec-b') - expect(merged[0]!.spent).toBe(true) - }) - - it('never overwrites local state: records union, settings keep local values', async () => { - const aesKey = await deriveBearerAesKey(LINKING_KEY) - const secretKey = deriveBackupKey(LINKING_KEY) - const {transport} = createRecordingTransport() - - await persistBearer(aesKey, bearerFixture({id: 'remote'})) - persistSettings({defaultMint: 'remote.example'}) - await publishBackup( - secretKey, - {notes: readEncryptedBearers(), settings: loadSettings()}, - RELAYS, - {transport, createdAt: 1000} - ) - - // this device already has its own wallet state - stubLocalStorage() - clearTrustedMints() - await persistBearer( - aesKey, - bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) - ) - persistSettings({defaultMint: 'local.example'}) - - const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) - expect(result.added).toBe(1) - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['local', 'remote']) - expect(result.settingsRestored).toBe(false) - expect(loadSettings()).toEqual({defaultMint: 'local.example'}) - }) - - it('reports nothing found when the relays hold no backup', async () => { - const {transport} = createRecordingTransport() - const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) - expect(result.found).toEqual([]) - expect(result.added).toBe(0) - }) -}) - -describe('createBackupPublisher', () => { - it('coalesces rapid schedules into a single publish of the latest snapshot', async () => { - vi.useFakeTimers() - const published: Partial[] = [] - const publisher = createBackupPublisher({ - publish: p => { - published.push(p) - return Promise.resolve() - }, - delayMs: 1000 - }) - - publisher.schedule({settings: {defaultMint: 'a'}}) - publisher.schedule({settings: {defaultMint: 'b'}}) - publisher.schedule({settings: {defaultMint: 'c'}}) - await vi.advanceTimersByTimeAsync(999) - expect(published).toEqual([]) - await vi.advanceTimersByTimeAsync(1) - expect(published).toEqual([{settings: {defaultMint: 'c'}}]) - }) - - it('publishes again when a change lands after the quiet window', async () => { - vi.useFakeTimers() - const published: Partial[] = [] - const publisher = createBackupPublisher({ - publish: p => { - published.push(p) - return Promise.resolve() - }, - delayMs: 1000 - }) - - publisher.schedule({settings: {defaultMint: 'a'}}) - await vi.advanceTimersByTimeAsync(1000) - publisher.schedule({settings: {defaultMint: 'b'}}) - await vi.advanceTimersByTimeAsync(1000) - expect(published).toEqual([ - {settings: {defaultMint: 'a'}}, - {settings: {defaultMint: 'b'}} - ]) - }) - - it('publishes a snapshot that lands mid-publish instead of losing it', async () => { - vi.useFakeTimers() - const published: Partial[] = [] - // the publish callback re-schedules on the publisher being created - - // a holder indirection keeps both const - const holder: {publisher?: ReturnType} = {} - const publisher = createBackupPublisher({ - publish: p => { - published.push(p) - // a local change lands while the first publish is in flight - if (published.length === 1) { - holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}}) - } - return Promise.resolve() - }, - delayMs: 1000 - }) - holder.publisher = publisher - - publisher.schedule({settings: {defaultMint: 'first'}}) - await vi.advanceTimersByTimeAsync(1000) - expect(published).toEqual([ - {settings: {defaultMint: 'first'}}, - {settings: {defaultMint: 'mid-flight'}} - ]) - }) - - it('flush publishes immediately; cancel drops the pending snapshot', async () => { - vi.useFakeTimers() - const published: Partial[] = [] - const publisher = createBackupPublisher({ - publish: p => { - published.push(p) - return Promise.resolve() - }, - delayMs: 60_000 - }) - - publisher.schedule({settings: {defaultMint: 'a'}}) - await publisher.flush() - expect(published).toEqual([{settings: {defaultMint: 'a'}}]) - - publisher.schedule({settings: {defaultMint: 'b'}}) - publisher.cancel() - await vi.advanceTimersByTimeAsync(60_000) - expect(published).toHaveLength(1) - }) - - it('reports a failed publish via onError and retries on the next change', async () => { - vi.useFakeTimers() - const published: Partial[] = [] - const errors: unknown[] = [] - let failing = true - const publisher = createBackupPublisher({ - publish: p => { - if (failing) return Promise.reject(new Error('relay down')) - published.push(p) - return Promise.resolve() - }, - delayMs: 1000, - onError: e => { - errors.push(e) - } - }) - - publisher.schedule({settings: {defaultMint: 'a'}}) - await vi.advanceTimersByTimeAsync(1000) - expect(published).toEqual([]) - expect(errors).toHaveLength(1) - - failing = false - publisher.schedule({settings: {defaultMint: 'b'}}) - await vi.advanceTimersByTimeAsync(1000) - expect(published).toEqual([{settings: {defaultMint: 'b'}}]) - }) -}) +import './nostrBackup.keys.cases' +import './nostrBackup.codec.cases' +import './nostrBackup.transport.cases' +import './nostrBackup.restore.cases' +import './nostrBackup.publisher.cases' diff --git a/src/lnurlcash/nostrBackup.transport.cases.ts b/src/lnurlcash/nostrBackup.transport.cases.ts new file mode 100644 index 0000000..ab3873f --- /dev/null +++ b/src/lnurlcash/nostrBackup.transport.cases.ts @@ -0,0 +1,160 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr, +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers, +} from './storage' +import type {Bearer} from './types' +import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + (e) => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)), + ), + ), + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('publishBackup / fetchBackup', () => { + const secretKey = deriveBackupKey(LINKING_KEY) + + it('publishes every part and fetches them back decrypted', async () => { + const {transport} = createRecordingTransport() + const parts = { + notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], + mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}], + settings: {defaultMint: 'mint.example'}, + } + const published = await publishBackup(secretKey, parts, RELAYS, {transport}) + expect(published.published).toEqual(['notes', 'mints', 'settings']) + + const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport, + }) + expect(fetched).toEqual(parts) + }) + + it('publishes nothing when no parts are given', async () => { + const {transport, events} = createRecordingTransport() + const result = await publishBackup(secretKey, {}, RELAYS, {transport}) + expect(result.published).toEqual([]) + expect(events).toEqual([]) + }) + + it('picks the newest event per d-tag when a relay serves stale copies', async () => { + const {transport} = createRecordingTransport() + await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, { + transport, + createdAt: 1000, + }) + await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, { + transport, + createdAt: 2000, + }) + // the recording transport serves BOTH - the newer must win + const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport, + }) + expect(parts.settings).toEqual({defaultMint: 'new.example'}) + }) + + it('falls back to an older valid copy when the newest event is tampered', async () => { + const {transport, events} = createRecordingTransport() + await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, { + transport, + createdAt: 1000, + }) + const latest = requiredValue(events[0]) + events.push({ + ...latest, + content: tamperContent(latest.content), + created_at: 3000, + }) + const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport, + }) + expect(parts.settings).toEqual({defaultMint: 'mint.example'}) + }) +}) diff --git a/src/lnurlcash/nostrBackup.ts b/src/lnurlcash/nostrBackup.ts index 572efd7..91a783f 100644 --- a/src/lnurlcash/nostrBackup.ts +++ b/src/lnurlcash/nostrBackup.ts @@ -24,14 +24,9 @@ export { backupPubkey, buildBackupEvent, buildBackupEvents, - parseBackupEvent -} from './nostr/events' -export type { - NostrEvent, - BackupPart, - BackupPartPayload, - ParsedBackupEvent + parseBackupEvent, } from './nostr/events' +export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events' export type {BackupTransport, NostrFilter} from './nostr/transport' @@ -40,7 +35,7 @@ export type { PublishBackupOptions, PublishBackupResult, FetchBackupOptions, - NostrRestoreResult + NostrRestoreResult, } from './nostr/sync' export {createBackupPublisher} from './nostr/publisher' diff --git a/src/lnurlcash/nwc.connection.cases.ts b/src/lnurlcash/nwc.connection.cases.ts new file mode 100644 index 0000000..7eee998 --- /dev/null +++ b/src/lnurlcash/nwc.connection.cases.ts @@ -0,0 +1,144 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('connection strings', () => { + it('round-trips build -> parse, including several relays', () => { + const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [ + 'wss://relay-a.example', + 'wss://relay-b.example/path?q=1', + ]) + expect(uri).toBe( + `nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`, + ) + expect(parseConnectionString(uri)).toEqual({ + walletServicePubkey: 'ab'.repeat(32), + clientSecret: 'cd'.repeat(32), + relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'], + }) + }) + + it('rejects strings that are not connection strings', () => { + expect(parseConnectionString('not a uri')).toBeNull() + expect(parseConnectionString('https://example.com')).toBeNull() + // missing secret + expect( + parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`), + ).toBeNull() + // missing relay + expect( + parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`), + ).toBeNull() + // a non-hex pubkey + expect( + parseConnectionString( + 'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32), + ), + ).toBeNull() + }) + + it('createConnection returns a string that parses back to the same connection', () => { + const connection = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 100_000, periodMs: 86_400_000}, + clientSecret: CLIENT_SECRET, + }) + const parsed = parseConnectionString(connection.connectionString) + expect(parsed).toEqual({ + walletServicePubkey: connection.walletServicePubkey, + clientSecret: '11'.repeat(32), + relays: RELAYS, + }) + // the record persisted WITHOUT the client secret - it is handed out + // once, in the connection string, and never stored + const records = readNwcConnections(OWNER_ID) + expect(records).toHaveLength(1) + expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY) + expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)) + }) +}) + +describe('deriveNwcWalletKey', () => { + it('is pinned: derivation changes would silently orphan every connection', () => { + expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( + '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d', + ) + expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( + 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', + ) + }) + + it('derives a distinct key per client and per linking key', () => { + const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)) + expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe( + base, + ) + expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base) + }) + + it('re-derives the same wallet identity from a persisted record after a reinstall', () => { + const first = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 100_000, periodMs: 86_400_000}, + clientSecret: CLIENT_SECRET, + }) + expect(first.walletServicePubkey).toBe( + 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', + ) + }) +}) diff --git a/src/lnurlcash/nwc.fencing.cases.ts b/src/lnurlcash/nwc.fencing.cases.ts new file mode 100644 index 0000000..1f28488 --- /dev/null +++ b/src/lnurlcash/nwc.fencing.cases.ts @@ -0,0 +1,66 @@ +// Stale-owner fencing end to end: a service accepted work while its owner +// was still installed, and a second tab replaced the saved key AFTER the +// fence's last safe point - mid-melt, past the boundary where aborting is +// no longer possible. What must not happen is any stale-owner WRITE: +// no bearer changeset, no budget debit, no success response. + +import {describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import {readNwcConnections} from './nwc' +import { + methodRequest, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + waitFor, +} from './nwc.testProtocol' +import {makeBearer, mint, readResponse, startTestService} from './nwc.testService' + +describe('service: post-boundary stale-owner fencing', () => { + it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => { + // Given a running service whose next mint call coincides with a second + // tab installing its own wallet (the first fetch is the melt itself: + // the exact-match carve performs no mint call of its own) + const m = await mint() + let swapped = false + const swappingFetch: typeof fetch = (input, init) => { + if (!swapped) { + swapped = true + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: bytesToHex(OTHER_LINKING_KEY), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ) + } + return fetch(input, init) + } + const {relay, walletServicePubkey, state, stop} = await startTestService({ + kit: {fetch: swappingFetch}, + }) + state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + // When the pay flows past the irreversible boundary and reaches the + // first owner-bound persistence (the conservative budget debit) + relay.emit(request) + await waitFor(() => state.errors.length > 0) + + // Then the melt genuinely happened (we are past the boundary) ... + expect(swapped).toBe(true) + expect(m.state.noteState('d7'.repeat(32))).toBe('burned') + // ... but nothing under the stale owner moved: no budget debit, no + // bearer changeset, no success response (the failure surfaced through + // onError instead) + expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0) + expect(state.changesets).toEqual([]) + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts new file mode 100644 index 0000000..77b39cb --- /dev/null +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -0,0 +1,206 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredString, requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: make_invoice / lookup_invoice', () => { + it('issues an invoice, settles it in the background, and reports the preimage', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + }) + + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + description: 'nwc test', + expiry: 3600, + }) + expect(made.error).toBeNull() + expect(made.result).toMatchObject({ + type: 'incoming', + state: 'pending', + amount: 21_000, + description: 'nwc test', + created_at: nowSeconds(), + expires_at: nowSeconds() + 3600, + }) + const invoice = requiredValue(made.result).invoice + if (typeof invoice !== 'string') { + throw new TypeError('make_invoice did not return an invoice') + } + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + expect(invoice).toMatch(/^lnbc/) + expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) + + // before settlement the lookup reports the pending invoice + const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(pending.error).toBeNull() + expect(pending.result?.state).toBe('pending') + expect(pending.result?.preimage).toBeUndefined() + + // the "payer" pays the invoice; the background claim settles and + // mints the note + const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleRes.ok).toBe(true) + await waitFor(() => state.changesets.some((c) => c.add.length > 0)) + + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.error).toBeNull() + expect(settled.result?.state).toBe('settled') + expect(settled.result?.settled_at).toBe(nowSeconds()) + const preimage = requiredString(settled.result?.preimage) + expect(preimage).toMatch(/^[0-9a-f]{64}$/) + + // the minted note was claimed AND rotated before settlement was + // recorded: the preimage the client just learned is a burned secret, + // and the wallet's fresh note is the only live one + expect(m.state.noteState(preimage)).toBe('burned') + const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + expect(minted.amount).toBe(21_000) + expect(minted.verified).toBe(true) + expect(noteK1(minted.url)).not.toBe(preimage) + expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding') + await stop() + }) + + it('keeps a paid invoice pending while its bearer commit is deferred', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + + const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(pending.result?.state).toBe('pending') + expect(pending.result?.settled_at).toBeUndefined() + expect(pending.result?.preimage).toBeUndefined() + + commit.resolve() + await waitFor(() => state.changesets.length === 1) + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.result?.state).toBe('settled') + expect(settled.result?.settled_at).toBe(nowSeconds()) + expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/) + await stop() + }) + + it('keeps repeated stops pending until an already-started invoice settlement commits', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + let stopped = false + const firstStop = stop().then(() => { + stopped = true + return state.changesets.length + }) + const repeatedStop = stop() + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + + expect(stopped).toBe(false) + expect(state.changesets).toHaveLength(0) + + commit.resolve() + const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]) + expect(changesetsAtStop).toBe(1) + expect(state.changesets).toHaveLength(1) + }) +}) diff --git a/src/lnurlcash/nwc.invoice-b.cases.ts b/src/lnurlcash/nwc.invoice-b.cases.ts new file mode 100644 index 0000000..115e22a --- /dev/null +++ b/src/lnurlcash/nwc.invoice-b.cases.ts @@ -0,0 +1,241 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: make_invoice / lookup_invoice (continued)', () => { + it('drains a rejected invoice settlement and reports it before stop resolves', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + const commitError = new Error('invoice commit rejected during stop') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + let stopped = false + const stopping = stop().then(() => { + stopped = true + return state.errors.length + }) + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + expect(stopped).toBe(false) + + commit.reject(commitError) + expect(await stopping).toBe(1) + expect(state.errors).toEqual([commitError]) + expect(state.changesets).toHaveLength(0) + await stop() + }) + + it('does not block stop on an invoice whose claim is still polling', async () => { + // an unpaid invoice's claim poll can legally run for minutes (the + // client pays whenever it pays) - stop must interrupt the wait, not + // sit on it; a settlement that already REACHED the commit phase is + // still awaited (see the drain tests above) + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000}, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 5_000, + }) + expect(made.error).toBeNull() + + // nobody pays the invoice; the claim keeps polling. stop must resolve + // promptly regardless (an un-interrupted stop would wait out the + // whole 60s claim budget) + await stop() + expect(state.changesets).toHaveLength(0) + expect(state.errors).toHaveLength(0) + }) + + it('does not start invoice settlement after stop begins during preparation', async () => { + const m = await mint({testHooks: true}) + const prepare = deferred() + let prepareStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + kit: { + fetch: async (input, init) => { + prepareStarted = true + await prepare.promise + return fetch(input, init) + }, + }, + }) + const request = methodRequest(walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + relay.emit(request) + await waitFor(() => prepareStarted) + + let stopped = false + const stopping = stop().then(() => { + stopped = true + }) + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + expect(stopped).toBe(false) + + prepare.resolve() + await stopping + + expect(state.changesets).toHaveLength(0) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') + }) + + it('marks a paid invoice failed when its bearer commit rejects', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + const commitError = new Error('invoice bearer commit failed') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + + const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + commit.reject(commitError) + await waitFor(() => state.errors.length === 1) + + const failed = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(failed.result?.state).toBe('failed') + expect(failed.result?.settled_at).toBeUndefined() + expect(failed.result?.preimage).toBeUndefined() + expect(state.errors).toEqual([commitError]) + await stop() + }) + + it('finds an invoice by its invoice string too', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10}, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 5_000, + }) + const invoice = made.result?.invoice + if (typeof invoice !== 'string') { + throw new TypeError('make_invoice did not return an invoice') + } + const found = await call(relay, walletServicePubkey, 'lookup_invoice', { + invoice: invoice.toUpperCase(), + }) + expect(found.error).toBeNull() + expect(found.result?.payment_hash).toBe(made.result?.payment_hash) + await stop() + }) + + it('answers an unknown invoice with NOT_FOUND', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: 'ab'.repeat(32), + }) + expect(response.error?.code).toBe('NOT_FOUND') + await stop() + }) + + it('answers make_invoice without a default mint with INTERNAL', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: null, + }) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + expect(response.error?.code).toBe('INTERNAL') + await stop() + }) + + it('answers a make_invoice with a bad amount with OTHER', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: -5, + }) + expect(response.error?.code).toBe('OTHER') + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.ownership.cases.ts b/src/lnurlcash/nwc.ownership.cases.ts new file mode 100644 index 0000000..8b1f154 --- /dev/null +++ b/src/lnurlcash/nwc.ownership.cases.ts @@ -0,0 +1,159 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service ownership', () => { + it('subscribes only current-owner records and leaves foreign budgets untouched', async () => { + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 0, + }) + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) + storeForeignConnection(foreign.record) + const relay = createFakeRelay() + + const service = await startService(LINKING_KEY, { + assertCurrentOwner: () => undefined, + getBearers: () => [], + getDefaultMint: () => null, + applyChangeset: () => Promise.resolve(), + transport: relay.transport, + nowSeconds, + }) + + expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) + expect(relay.subscriptionCount()).toBe(1) + expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) + expect(service.connections).not.toContainEqual(foreign) + await service.stop() + }) + + it('ignores foreign records handed in through the records snapshot', async () => { + // the injected-records path bypasses storage, so the service's own + // owner filter is the only boundary here (stale-ownership probe: a + // snapshot from a previous wallet must not be served) + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 0, + }) + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) + const relay = createFakeRelay() + + const service = await startService( + LINKING_KEY, + { + assertCurrentOwner: () => undefined, + getBearers: () => [], + getDefaultMint: () => null, + applyChangeset: () => Promise.resolve(), + transport: relay.transport, + nowSeconds, + }, + [current.record, foreign.record], + ) + + expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) + expect(relay.subscriptionCount()).toBe(1) + // the foreign snapshot record must not be persisted for the new owner + expect(readNwcConnections(OWNER_ID)).toEqual([current.record]) + await service.stop() + }) +}) + +describe('service: info and balance', () => { + it('publishes a kind-13194 info event on startup', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND)) + expect(info.pubkey).toBe(walletServicePubkey) + expect(info.content).toContain('pay_invoice') + expect(info.content).toContain('make_invoice') + expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']) + await stop() + }) + + it('answers get_info with the connection identity and method list', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'get_info', {}) + expect(response.error).toBeNull() + expect(response.result_type).toBe('get_info') + expect(response.result).toMatchObject({ + alias: 'sattle', + pubkey: walletServicePubkey, + methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'], + }) + await stop() + }) + + it('answers get_balance with the spendable total only', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [ + await makeBearer(m, 'aa'.repeat(32), 21_000), + await makeBearer(m, 'bb'.repeat(32), 5_000), + {...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true}, + ] + const response = await call(relay, walletServicePubkey, 'get_balance', {}) + expect(response.error).toBeNull() + expect(response.result).toEqual({balance: 26_000}) + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.pay-a.cases.ts b/src/lnurlcash/nwc.pay-a.cases.ts new file mode 100644 index 0000000..9d60619 --- /dev/null +++ b/src/lnurlcash/nwc.pay-a.cases.ts @@ -0,0 +1,235 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredString, requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: pay_invoice', () => { + it('applies the settled changeset before publishing success', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + expect(response.error).toBeNull() + expect(state.changesets).toHaveLength(1) + expect(state.bearers[0]?.spent).toBe(true) + await stop() + }) + + it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 50_000, + }) + const foreign = foreignConnectionFixture( + {maxMsat: 99_000, periodMs: 86_400_000}, + nowSeconds() * 1000, + ) + storeForeignConnection(foreign.record) + state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error).toBeNull() + expect(requiredString(response.result?.preimage)).toHaveLength(64) + + // the note is gone (melted) and locked spent via the changeset + expect(m.state.noteState('dd'.repeat(32))).toBe('burned') + expect(requiredValue(state.bearers[0]).spent).toBe(true) + + // the spend was recorded against the budget, persisted + expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000) + expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) + await stop() + }) + + it('waits for a deferred bearer commit before publishing payment success', async () => { + const m = await mint() + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + + // a non-awaiting engine publishes within milliseconds of the commit + // call (one local verify re-read + encrypt); this window is far wider + // than that, so an early response here can only mean a missing barrier + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + commit.resolve() + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() + await stop() + }) + + it('reports a rejected bearer commit without publishing payment success', async () => { + const m = await mint() + const commit = deferred() + const commitError = new Error('bearer commit failed') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + commit.reject(commitError) + await waitFor( + () => + state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, + ) + + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + expect(state.errors).toEqual([commitError]) + // the conservative budget debit is persisted separately from bearer + // storage and deliberately NOT rolled back: the budget was debited, + // the bearer was never locked spent + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) + expect(state.bearers[0]?.spent).toBeUndefined() + await stop() + }) + + it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => { + const m = await mint() + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + // stop closes subscriptions; in-flight handlers still finish (their + // changesets hold money) - a repeated stop interrupts nothing twice + let stopped = false + const firstStop = stop().then(() => { + stopped = true + }) + const repeatedStop = stop() + await Promise.resolve() + expect(stopped).toBe(false) + // subscriptions close IMMEDIATELY, before the drain completes + expect(relay.subscriptionCount()).toBe(0) + + const afterStop = methodRequest(walletServicePubkey, 'get_balance', {}) + relay.emitAfterClose(afterStop) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull() + + commit.resolve() + await Promise.all([firstStop, repeatedStop]) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() + expect(state.bearers[0]?.spent).toBe(true) + }) + + it('rejects a stale saved owner before touching the mint', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)] + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: bytesToHex(OTHER_LINKING_KEY), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ) + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor( + () => + state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, + ) + + expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding') + expect(state.changesets).toEqual([]) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.pay-b.cases.ts b/src/lnurlcash/nwc.pay-b.cases.ts new file mode 100644 index 0000000..30f30cf --- /dev/null +++ b/src/lnurlcash/nwc.pay-b.cases.ts @@ -0,0 +1,158 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: pay_invoice (continued)', () => { + it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 20_000, + }) + state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('QUOTA_EXCEEDED') + // nothing moved: the note is untouched, no spend recorded + expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') + expect(requiredValue(state.bearers[0]).spent).toBeUndefined() + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) + await stop() + }) + + it('resets the allowance once the budget period has rolled over', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 21_000, + periodMs: 60_000, + }) + // simulate a fully spent budget from a period that ended long ago + const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]) + writeNwcConnections(OWNER_ID, [ + {...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}}, + ]) + state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error).toBeNull() + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) + await stop() + }) + + it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') + expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') + await stop() + }) + + it('rejects a request amount that mismatches the invoice amount', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + amount: 5_000, + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/match/i) + await stop() + }) + + it('rejects an amount-less invoice instead of guessing', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/amount/i) + await stop() + }) + + it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { + const m = await mint({meltAlwaysFails: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + // a short verify budget: the failed melt is classified by the poll + // running out, and that wait is the test's own clock + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('PAYMENT_FAILED') + + // the funds came back, re-secured: the old secret burned, a fresh one + // tracked unspent via the changeset - and no budget spend recorded + expect(m.state.noteState('01'.repeat(32))).toBe('burned') + const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + expect(returned.spent).toBeUndefined() + expect(returned.amount).toBe(21_000) + expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding') + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.storage.cases.ts b/src/lnurlcash/nwc.storage.cases.ts new file mode 100644 index 0000000..ebe3954 --- /dev/null +++ b/src/lnurlcash/nwc.storage.cases.ts @@ -0,0 +1,173 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('storage validation', () => { + it('drops malformed records instead of throwing', () => { + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + {clientPubkey: 'nope'}, + { + version: 1, + ownerId: OWNER_ID, + clientPubkey: CLIENT_PUBKEY, + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 0, + }, + ]), + ) + expect(readNwcConnections(OWNER_ID)).toHaveLength(1) + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY) + }) + + it('returns nothing for garbage json', () => { + localStorage.setItem('sattle_nwc_connections', '{{{') + expect(readNwcConnections(OWNER_ID)).toEqual([]) + }) + + it('returns only strictly parsed records belonging to the requested owner', () => { + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 10, + }).record + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record + const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') + if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + ...raw, + foreign, + {...current, ownerId: 'malformed'}, + { + clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))), + relays: RELAYS, + budget: {maxMsat: 3000, periodMs: 3000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 30, + }, + ]), + ) + + expect(readNwcConnections(OWNER_ID)).toEqual([current]) + expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]) + }) + + it('adopts ownerless connections and enabled state only after owner proof', async () => { + await saveLinkingKey(LINKING_KEY) + const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}') + if (typeof saved !== 'object' || saved === null) { + throw new TypeError('Expected a saved linking-key record') + } + Reflect.deleteProperty(saved, 'ownerId') + Reflect.deleteProperty(saved, 'version') + localStorage.setItem('sattle_linking_key', JSON.stringify(saved)) + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + { + clientPubkey: CLIENT_PUBKEY, + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 0, + }, + ]), + ) + localStorage.setItem('sattle_nwc_enabled', 'true') + + expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow() + expect(readNwcConnections(OWNER_ID)).toEqual([]) + expect(readNwcEnabled(OWNER_ID)).toBe(false) + + ensureSavedKeyOwner(LINKING_KEY) + expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ + connections: 1, + enabled: true, + }) + expect(readNwcConnections(OWNER_ID)).toHaveLength(1) + expect(readNwcEnabled(OWNER_ID)).toBe(true) + expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ + connections: 0, + enabled: false, + }) + }) + + it('does not expose one wallet enabled state to another owner', () => { + writeNwcEnabled(OWNER_ID, true) + + expect(readNwcEnabled(OWNER_ID)).toBe(true) + expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false) + }) + + it('treats malformed owner-bearing enabled records as disabled', () => { + for (const value of [ + {version: 1, ownerId: 'malformed', enabled: true}, + {version: 2, ownerId: OWNER_ID, enabled: true}, + {version: 1, ownerId: OWNER_ID, enabled: 'true'}, + ]) { + localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value)) + expect(readNwcEnabled(OWNER_ID)).toBe(false) + } + }) +}) diff --git a/src/lnurlcash/nwc.test.ts b/src/lnurlcash/nwc.test.ts index 58cc9c5..b150f5e 100644 --- a/src/lnurlcash/nwc.test.ts +++ b/src/lnurlcash/nwc.test.ts @@ -1,816 +1,9 @@ -// The NWC wallet service end to end: connection strings and key -// derivation, the request/response cycle over an in-memory relay (the -// transport is injected - no network), every method against the -// conformance mock mint, the legacy NIP-04 path, budget enforcement, and -// the error paths. Fund-safety focus: budgets can't be exceeded, stale -// requests never execute, and a settled preimage only ever reveals an -// already-rotated (burned) note secret. - -import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' -import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' -import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' -import {v2 as nip44v2} from 'nostr-tools/nip44' -import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' -import {createMockMint} from 'lnurlcash-conformance/mock-mint' - -import { - NWC_INFO_KIND, - NWC_REQUEST_KIND, - NWC_RESPONSE_KIND, - buildConnectionString, - createConnection, - deriveNwcWalletKey, - parseConnectionString, - readNwcConnections, - startService, - writeNwcConnections -} from './nwc' -import type {NostrEvent, NwcConnectionRecord, NwcTransport} from './nwc' -import type {NostrFilter} from './nwc/transport' -import type {NwcChangeset} from './nwc' -import type {Bearer} from './types' -import {stubLocalStorage} from './test-utils' - -const LINKING_KEY = new Uint8Array(32).fill(7) -const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) -const CLIENT_SECRET = hexToBytes('11'.repeat(32)) -const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET) -const STRANGER_SECRET = hexToBytes('22'.repeat(32)) - -// never connected - the in-memory relay below stands in -const RELAYS = ['wss://relay-a.example'] - -let NOW = 1_800_000_000 -const nowSeconds = (): number => NOW - -const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} - -// an in-memory relay set: subscriptions register, emit delivers to every -// matching one, publish records -const createFakeRelay = (): { - transport: NwcTransport - published: NostrEvent[] - emit: (event: NostrEvent) => void -} => { - const published: NostrEvent[] = [] - const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] - const transport: NwcTransport = { - publish: (_relays, event) => { - published.push(event) - return Promise.resolve() - }, - subscribe: (_relays, filter, onEvent) => { - const sub = {filter, onEvent} - subs.push(sub) - return { - close: () => { - const index = subs.indexOf(sub) - if (index >= 0) subs.splice(index, 1) - } - } - } - } - const emit = (event: NostrEvent): void => { - for (const sub of [...subs]) { - const kindsMatch = - !sub.filter.kinds || sub.filter.kinds.includes(event.kind) - const wanted = sub.filter['#p'] - const pMatch = - !wanted || - event.tags.some(t => t[0] === 'p' && wanted.includes(t[1] ?? '')) - const sinceMatch = - sub.filter.since === undefined || event.created_at >= sub.filter.since - if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event) - } - } - return {transport, published, emit} -} - -type Encryption = 'nip44_v2' | 'nip04' | 'none' - -// a NIP-47 request exactly as a real client would build it, signed by the -// connection's client secret -const clientRequest = ( - walletServicePubkey: string, - content: string, - scheme: Encryption = 'nip44_v2', - createdAt: number = NOW -): NostrEvent => { - const tags: string[][] = [['p', walletServicePubkey]] - if (scheme !== 'none') tags.push(['encryption', scheme]) - return finalizeEvent( - { - kind: NWC_REQUEST_KIND, - created_at: createdAt, - tags, - content: - scheme === 'nip44_v2' - ? nip44v2.encrypt( - content, - nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey) - ) - : nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content) - }, - CLIENT_SECRET - ) -} - -const methodRequest = ( - walletServicePubkey: string, - method: string, - params: Record, - scheme: Encryption = 'nip44_v2', - createdAt?: number -): NostrEvent => - clientRequest( - walletServicePubkey, - JSON.stringify({method, params}), - scheme, - createdAt - ) - -// generous: a failed/never-settling melt is only classified after the -// verify-poll budget (seconds) runs out -const waitFor = async (cond: () => boolean): Promise => { - for (let i = 0; i < 3000 && !cond(); i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - } - expect(cond()).toBe(true) -} - -type NwcResponsePayload = { - result_type: string - error: {code: string; message: string} | null - result: Record | null -} - -const readResponse = ( - published: NostrEvent[], - requestId: string, - scheme: Encryption -): NwcResponsePayload | null => { - const event = published.find( - e => - e.kind === NWC_RESPONSE_KIND && - e.tags.some(t => t[0] === 'e' && t[1] === requestId) - ) - if (!event) return null - const plaintext = - scheme === 'nip44_v2' - ? nip44v2.decrypt( - event.content, - nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey) - ) - : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content) - return JSON.parse(plaintext) as NwcResponsePayload -} - -// drives one full request/response round trip over the fake relay -const call = async ( - relay: ReturnType, - walletServicePubkey: string, - method: string, - params: Record, - scheme: Encryption = 'nip44_v2' -): Promise => { - const request = methodRequest(walletServicePubkey, method, params, scheme) - relay.emit(request) - await waitFor(() => readResponse(relay.published, request.id, scheme) !== null) - return readResponse(relay.published, request.id, scheme)! -} - -type Mint = Awaited> -const mints: Mint[] = [] -const mint = async ( - options: Parameters[0] = {} -): Promise => { - const m = await createMockMint(options) - mints.push(m) - return m -} - -afterEach(async () => { - await Promise.all(mints.splice(0).map(m => m.close())) -}) - -let bearerCounter = 0 -const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise => { - m.state.creditNote(k1, amountMsat) - const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) - const info = await fetchNoteInfo(url) - bearerCounter += 1 - return { - id: `bearer-${bearerCounter}`, - url, - callback: info.callback, - amount: info.maxWithdrawable, - verified: true, - mintPubkey: m.state.pubkey, - createdAt: Date.now(), - updatedAt: Date.now() - } -} - -// the harness around startService: a fake relay, an in-memory "store" -// applying changesets the way the Pinia layer will, and a created -// connection with a pinned client secret -const startTestService = async (options: { - budgetMsat?: number - periodMs?: number - defaultMint?: string | null - linkingKey?: Uint8Array - poll?: typeof FAST_POLL -}): Promise<{ - relay: ReturnType - walletServicePubkey: string - state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} - stop: () => void -}> => { - const budgetMsat = options.budgetMsat ?? 1_000_000_000 - const connection = createConnection(options.linkingKey ?? LINKING_KEY, { - relays: RELAYS, - budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000}, - clientSecret: CLIENT_SECRET, - now: NOW * 1000 - }) - const relay = createFakeRelay() - const state = { - bearers: [] as Bearer[], - changesets: [] as NwcChangeset[], - errors: [] as unknown[] - } - const service = await startService(options.linkingKey ?? LINKING_KEY, { - getBearers: () => state.bearers, - getDefaultMint: () => options.defaultMint ?? null, - applyChangeset: (changeset: NwcChangeset) => { - state.changesets.push(changeset) - for (const id of changeset.markSpent) { - const bearer = state.bearers.find(b => b.id === id) - if (bearer) bearer.spent = true - } - for (const note of changeset.add) { - bearerCounter += 1 - state.bearers.push({ - ...note, - id: `added-${bearerCounter}`, - createdAt: Date.now(), - updatedAt: Date.now() - }) - } - }, - onError: err => { - state.errors.push(err) - }, - transport: relay.transport, - poll: options.poll ?? FAST_POLL, - claimPoll: FAST_POLL, - nowSeconds - }) - return { - relay, - walletServicePubkey: connection.walletServicePubkey, - state, - stop: service.stop - } -} - -beforeEach(() => { - stubLocalStorage() - NOW = 1_800_000_000 -}) - -describe('connection strings', () => { - it('round-trips build -> parse, including several relays', () => { - const uri = buildConnectionString( - 'ab'.repeat(32), - 'cd'.repeat(32), - ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'] - ) - expect(uri).toBe( - `nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}` - ) - expect(parseConnectionString(uri)).toEqual({ - walletServicePubkey: 'ab'.repeat(32), - clientSecret: 'cd'.repeat(32), - relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'] - }) - }) - - it('rejects strings that are not connection strings', () => { - expect(parseConnectionString('not a uri')).toBeNull() - expect(parseConnectionString('https://example.com')).toBeNull() - // missing secret - expect( - parseConnectionString( - `nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example` - ) - ).toBeNull() - // missing relay - expect( - parseConnectionString( - `nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}` - ) - ).toBeNull() - // a non-hex pubkey - expect( - parseConnectionString( - 'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32) - ) - ).toBeNull() - }) - - it('createConnection returns a string that parses back to the same connection', () => { - const connection = createConnection(LINKING_KEY, { - relays: RELAYS, - budget: {maxMsat: 100_000, periodMs: 86_400_000}, - clientSecret: CLIENT_SECRET - }) - const parsed = parseConnectionString(connection.connectionString) - expect(parsed).toEqual({ - walletServicePubkey: connection.walletServicePubkey, - clientSecret: '11'.repeat(32), - relays: RELAYS - }) - // the record persisted WITHOUT the client secret - it is handed out - // once, in the connection string, and never stored - const records = readNwcConnections() - expect(records).toHaveLength(1) - expect(records[0]!.clientPubkey).toBe(CLIENT_PUBKEY) - expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)) - }) -}) - -describe('deriveNwcWalletKey', () => { - it('is pinned: derivation changes would silently orphan every connection', () => { - expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( - '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d' - ) - expect( - getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)) - ).toBe('bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf') - }) - - it('derives a distinct key per client and per linking key', () => { - const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)) - expect( - bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET))) - ).not.toBe(base) - expect( - bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY)) - ).not.toBe(base) - }) - - it('re-derives the same wallet identity from a persisted record after a reinstall', () => { - const first = createConnection(LINKING_KEY, { - relays: RELAYS, - budget: {maxMsat: 100_000, periodMs: 86_400_000}, - clientSecret: CLIENT_SECRET - }) - expect(first.walletServicePubkey).toBe( - 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf' - ) - }) -}) - -describe('storage validation', () => { - it('drops malformed records instead of throwing', () => { - localStorage.setItem( - 'sattle_nwc_connections', - JSON.stringify([ - {clientPubkey: 'nope'}, - { - clientPubkey: CLIENT_PUBKEY, - relays: RELAYS, - budget: {maxMsat: 1000, periodMs: 1000}, - spent: {periodStart: 0, msat: 0}, - createdAt: 0 - } - ]) - ) - expect(readNwcConnections()).toHaveLength(1) - expect(readNwcConnections()[0]!.clientPubkey).toBe(CLIENT_PUBKEY) - }) - - it('returns nothing for garbage json', () => { - localStorage.setItem('sattle_nwc_connections', '{{{') - expect(readNwcConnections()).toEqual([]) - }) -}) - -describe('service: info and balance', () => { - it('publishes a kind-13194 info event on startup', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const info = relay.published.find(e => e.kind === NWC_INFO_KIND) - expect(info).toBeDefined() - expect(info!.pubkey).toBe(walletServicePubkey) - expect(info!.content).toContain('pay_invoice') - expect(info!.content).toContain('make_invoice') - expect(info!.tags).toContainEqual(['encryption', 'nip44_v2 nip04']) - stop() - }) - - it('answers get_info with the connection identity and method list', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'get_info', {}) - expect(response.error).toBeNull() - expect(response.result_type).toBe('get_info') - expect(response.result).toMatchObject({ - alias: 'sattle', - pubkey: walletServicePubkey, - methods: [ - 'get_info', - 'get_balance', - 'make_invoice', - 'pay_invoice', - 'lookup_invoice' - ] - }) - stop() - }) - - it('answers get_balance with the spendable total only', async () => { - const m = await mint() - const {relay, walletServicePubkey, state, stop} = await startTestService({}) - state.bearers = [ - await makeBearer(m, 'aa'.repeat(32), 21_000), - await makeBearer(m, 'bb'.repeat(32), 5_000), - {...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true} - ] - const response = await call(relay, walletServicePubkey, 'get_balance', {}) - expect(response.error).toBeNull() - expect(response.result).toEqual({balance: 26_000}) - stop() - }) -}) - -describe('service: request validation', () => { - it('answers an unknown method with NOT_IMPLEMENTED', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'get_payments', {}) - expect(response.result_type).toBe('get_payments') - expect(response.error?.code).toBe('NOT_IMPLEMENTED') - expect(response.result).toBeNull() - stop() - }) - - it('answers a malformed (non-JSON) request with an error, not a crash', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const request = clientRequest(walletServicePubkey, 'this is not json') - relay.emit(request) - await waitFor( - () => readResponse(relay.published, request.id, 'nip44_v2') !== null - ) - const response = readResponse(relay.published, request.id, 'nip44_v2')! - expect(response.error?.code).toBe('OTHER') - stop() - }) - - it('answers a JSON request without a method with an error', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) - relay.emit(request) - await waitFor( - () => readResponse(relay.published, request.id, 'nip44_v2') !== null - ) - expect(readResponse(relay.published, request.id, 'nip44_v2')!.error?.code).toBe( - 'OTHER' - ) - stop() - }) - - it('ignores a request signed by a stranger key - silently', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const content = nip44v2.encrypt( - JSON.stringify({method: 'get_balance', params: {}}), - nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey) - ) - const forged = finalizeEvent( - { - kind: NWC_REQUEST_KIND, - created_at: NOW, - tags: [['p', walletServicePubkey], ['encryption', 'nip44_v2']], - content - }, - STRANGER_SECRET - ) - relay.emit(forged) - await new Promise(resolve => setTimeout(resolve, 100)) - expect( - relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) - ).toHaveLength(0) - stop() - }) - - it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const content = nip04Encrypt( - CLIENT_SECRET, - walletServicePubkey, - JSON.stringify({method: 'get_balance', params: {}}) - ) - const request = finalizeEvent( - { - kind: NWC_REQUEST_KIND, - created_at: NOW, - tags: [['p', walletServicePubkey], ['encryption', 'nip17']], - content - }, - CLIENT_SECRET - ) - relay.emit(request) - // the error answer goes out in the legacy scheme every client reads - await waitFor( - () => readResponse(relay.published, request.id, 'nip04') !== null - ) - expect( - readResponse(relay.published, request.id, 'nip04')!.error?.code - ).toBe('UNSUPPORTED_ENCRYPTION') - stop() - }) - - it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - for (const scheme of ['none', 'nip04'] as const) { - const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) - relay.emit(request) - await waitFor( - () => readResponse(relay.published, request.id, 'nip04') !== null - ) - const response = readResponse(relay.published, request.id, 'nip04')! - expect(response.error).toBeNull() - expect(response.result).toEqual({balance: 0}) - // the response mirrors the request's scheme - const event = relay.published.find( - e => - e.kind === NWC_RESPONSE_KIND && - e.tags.some(t => t[0] === 'e' && t[1] === request.id) - )! - expect(event.tags).toContainEqual(['encryption', 'nip04']) - } - stop() - }) - - it('drops requests older than the replay window unanswered', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const stale = methodRequest( - walletServicePubkey, - 'get_balance', - {}, - 'nip44_v2', - NOW - 1200 - ) - relay.emit(stale) - await new Promise(resolve => setTimeout(resolve, 100)) - expect( - relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) - ).toHaveLength(0) - stop() - }) - - it('picks up no new requests after stop', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - stop() - const request = methodRequest(walletServicePubkey, 'get_balance', {}) - relay.emit(request) - await new Promise(resolve => setTimeout(resolve, 100)) - expect( - relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) - ).toHaveLength(0) - }) -}) - -describe('service: pay_invoice', () => { - it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { - const m = await mint() - const {relay, walletServicePubkey, state, stop} = await startTestService({ - budgetMsat: 50_000 - }) - state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] - - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz' - }) - expect(response.error).toBeNull() - expect(typeof response.result?.preimage).toBe('string') - expect((response.result?.preimage as string).length).toBe(64) - - // the note is gone (melted) and locked spent via the changeset - expect(m.state.noteState('dd'.repeat(32))).toBe('burned') - expect(state.bearers[0]!.spent).toBe(true) - - // the spend was recorded against the budget, persisted - expect(readNwcConnections()[0]!.spent.msat).toBe(21_000) - stop() - }) - - it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { - const m = await mint() - const {relay, walletServicePubkey, state, stop} = await startTestService({ - budgetMsat: 20_000 - }) - state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)] - - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz' - }) - expect(response.error?.code).toBe('QUOTA_EXCEEDED') - // nothing moved: the note is untouched, no spend recorded - expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') - expect(state.bearers[0]!.spent).toBeUndefined() - expect(readNwcConnections()[0]!.spent.msat).toBe(0) - stop() - }) - - it('resets the allowance once the budget period has rolled over', async () => { - const m = await mint() - const {relay, walletServicePubkey, state, stop} = await startTestService({ - budgetMsat: 21_000, - periodMs: 60_000 - }) - // simulate a fully spent budget from a period that ended long ago - const record: NwcConnectionRecord = readNwcConnections()[0]! - writeNwcConnections([ - {...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}} - ]) - state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] - - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz' - }) - expect(response.error).toBeNull() - expect(readNwcConnections()[0]!.spent.msat).toBe(21_000) - stop() - }) - - it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { - const m = await mint() - const {relay, walletServicePubkey, state, stop} = await startTestService({}) - state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] - - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz' - }) - expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') - expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') - stop() - }) - - it('rejects a request amount that mismatches the invoice amount', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz', - amount: 5_000 - }) - expect(response.error?.code).toBe('OTHER') - expect(response.error?.message).toMatch(/match/i) - stop() - }) - - it('rejects an amount-less invoice instead of guessing', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc1pjqrstuvwxyz' - }) - expect(response.error?.code).toBe('OTHER') - expect(response.error?.message).toMatch(/amount/i) - stop() - }) - - it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { - const m = await mint({meltAlwaysFails: true}) - const {relay, walletServicePubkey, state, stop} = await startTestService({ - // a short verify budget: the failed melt is classified by the poll - // running out, and that wait is the test's own clock - poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} - }) - state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)] - - const response = await call(relay, walletServicePubkey, 'pay_invoice', { - invoice: 'lnbc210n1pjqrstuvwxyz' - }) - expect(response.error?.code).toBe('PAYMENT_FAILED') - - // the funds came back, re-secured: the old secret burned, a fresh one - // tracked unspent via the changeset - and no budget spend recorded - expect(m.state.noteState('01'.repeat(32))).toBe('burned') - const returned = state.bearers.find(b => b.id.startsWith('added-')) - expect(returned).toBeDefined() - expect(returned!.spent).toBeUndefined() - expect(returned!.amount).toBe(21_000) - expect(m.state.noteState(noteK1(returned!.url)!)).toBe('outstanding') - expect(readNwcConnections()[0]!.spent.msat).toBe(0) - stop() - }) -}) - -describe('service: make_invoice / lookup_invoice', () => { - it('issues an invoice, settles it in the background, and reports the preimage', async () => { - const m = await mint({testHooks: true}) - const {relay, walletServicePubkey, state, stop} = await startTestService({ - defaultMint: `mint@127.0.0.1:${m.port}` - }) - - const made = await call(relay, walletServicePubkey, 'make_invoice', { - amount: 21_000, - description: 'nwc test', - expiry: 3600 - }) - expect(made.error).toBeNull() - expect(made.result).toMatchObject({ - type: 'incoming', - state: 'pending', - amount: 21_000, - description: 'nwc test', - created_at: NOW, - expires_at: NOW + 3600 - }) - const invoice = made.result!.invoice as string - const paymentHash = made.result!.payment_hash as string - expect(invoice).toMatch(/^lnbc/) - expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) - - // before settlement the lookup reports the pending invoice - const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { - payment_hash: paymentHash - }) - expect(pending.error).toBeNull() - expect(pending.result?.state).toBe('pending') - expect(pending.result?.preimage).toBeUndefined() - - // the "payer" pays the invoice; the background claim settles and - // mints the note - const settleRes = await fetch( - `${m.url}/_test/settle?payment_hash=${paymentHash}` - ) - expect(settleRes.ok).toBe(true) - await waitFor(() => - state.changesets.some(c => c.add.length > 0) - ) - - const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { - payment_hash: paymentHash - }) - expect(settled.error).toBeNull() - expect(settled.result?.state).toBe('settled') - expect(settled.result?.settled_at).toBe(NOW) - const preimage = settled.result?.preimage as string - expect(preimage).toMatch(/^[0-9a-f]{64}$/) - - // the minted note was claimed AND rotated before settlement was - // recorded: the preimage the client just learned is a burned secret, - // and the wallet's fresh note is the only live one - expect(m.state.noteState(preimage)).toBe('burned') - const minted = state.bearers.find(b => b.id.startsWith('added-'))! - expect(minted.amount).toBe(21_000) - expect(minted.verified).toBe(true) - expect(noteK1(minted.url)).not.toBe(preimage) - expect(m.state.noteState(noteK1(minted.url)!)).toBe('outstanding') - stop() - }) - - it('finds an invoice by its invoice string too', async () => { - const m = await mint({testHooks: true}) - const {relay, walletServicePubkey, stop} = await startTestService({ - defaultMint: `mint@127.0.0.1:${m.port}` - }) - const made = await call(relay, walletServicePubkey, 'make_invoice', { - amount: 5_000 - }) - const found = await call(relay, walletServicePubkey, 'lookup_invoice', { - invoice: (made.result!.invoice as string).toUpperCase() - }) - expect(found.error).toBeNull() - expect(found.result?.payment_hash).toBe(made.result!.payment_hash) - stop() - }) - - it('answers an unknown invoice with NOT_FOUND', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'lookup_invoice', { - payment_hash: 'ab'.repeat(32) - }) - expect(response.error?.code).toBe('NOT_FOUND') - stop() - }) - - it('answers make_invoice without a default mint with INTERNAL', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({ - defaultMint: null - }) - const response = await call(relay, walletServicePubkey, 'make_invoice', { - amount: 21_000 - }) - expect(response.error?.code).toBe('INTERNAL') - stop() - }) - - it('answers a make_invoice with a bad amount with OTHER', async () => { - const {relay, walletServicePubkey, stop} = await startTestService({}) - const response = await call(relay, walletServicePubkey, 'make_invoice', { - amount: -5 - }) - expect(response.error?.code).toBe('OTHER') - stop() - }) -}) +import './nwc.connection.cases' +import './nwc.storage.cases' +import './nwc.ownership.cases' +import './nwc.validation.cases' +import './nwc.pay-a.cases' +import './nwc.pay-b.cases' +import './nwc.fencing.cases' +import './nwc.invoice-a.cases' +import './nwc.invoice-b.cases' diff --git a/src/lnurlcash/nwc.testProtocol.ts b/src/lnurlcash/nwc.testProtocol.ts new file mode 100644 index 0000000..96171e9 --- /dev/null +++ b/src/lnurlcash/nwc.testProtocol.ts @@ -0,0 +1,188 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +export const LINKING_KEY = new Uint8Array(32).fill(7) +export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +export const CLIENT_SECRET = hexToBytes('11'.repeat(32)) +export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET) +export const STRANGER_SECRET = hexToBytes('22'.repeat(32)) +export const OWNER_ID = linkingPubKeyHex(LINKING_KEY) +export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY) + +// never connected - the in-memory relay below stands in +export const RELAYS = ['wss://relay-a.example'] + +let NOW = 1_800_000_000 +export const nowSeconds = (): number => NOW + +export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + +export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) => + connectionInfoOf(OTHER_LINKING_KEY, { + version: 1, + ownerId: OTHER_OWNER_ID, + clientPubkey: getPublicKey(STRANGER_SECRET), + relays: RELAYS, + budget, + spent: {periodStart: now, msat: 0}, + createdAt: now, + }) + +export const storeForeignConnection = (record: NwcConnectionRecord): void => { + const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') + if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') + localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record])) +} + +// an in-memory relay set: subscriptions register, emit delivers to every +// matching one, publish records +export const createFakeRelay = (): { + transport: NwcTransport + published: NostrEvent[] + emit: (event: NostrEvent) => void + emitAfterClose: (event: NostrEvent) => void + subscriptionCount: () => number +} => { + const published: NostrEvent[] = [] + const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] + const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] + const transport: NwcTransport = { + publish: (_relays, event) => { + published.push(event) + return Promise.resolve() + }, + subscribe: (_relays, filter, onEvent) => { + const sub = {filter, onEvent} + subs.push(sub) + allSubs.push(sub) + return { + close: () => { + const index = subs.indexOf(sub) + if (index >= 0) subs.splice(index, 1) + }, + } + }, + } + const deliver = ( + targets: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[], + event: NostrEvent, + ): void => { + for (const sub of [...targets]) { + const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind) + const wanted = sub.filter['#p'] + const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? '')) + const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since + if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event) + } + } + const emit = (event: NostrEvent): void => deliver(subs, event) + const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event) + return { + transport, + published, + emit, + emitAfterClose, + subscriptionCount: () => subs.length, + } +} + +export type Encryption = 'nip44_v2' | 'nip04' | 'none' + +// a NIP-47 request exactly as a real client would build it, signed by the +// connection's client secret +export const clientRequest = ( + walletServicePubkey: string, + content: string, + scheme: Encryption = 'nip44_v2', + createdAt: number = NOW, +): NostrEvent => { + const tags: string[][] = [['p', walletServicePubkey]] + if (scheme !== 'none') tags.push(['encryption', scheme]) + return finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: createdAt, + tags, + content: + scheme === 'nip44_v2' + ? nip44v2.encrypt( + content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey), + ) + : nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content), + }, + CLIENT_SECRET, + ) +} + +export const methodRequest = ( + walletServicePubkey: string, + method: string, + params: Record, + scheme: Encryption = 'nip44_v2', + createdAt?: number, +): NostrEvent => + clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt) + +// generous: a failed/never-settling melt is only classified after the +// verify-poll budget (seconds) runs out +export const waitFor = async (cond: () => boolean): Promise => { + for (let i = 0; i < 3000 && !cond(); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(cond()).toBe(true) +} + +export const deferred = (): { + promise: Promise + resolve: () => void + reject: (reason: Error) => void +} => { + let resolve = (): void => undefined + let reject = (_reason: Error): void => undefined + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return {promise, resolve, reject} +} + +export const resetNow = (): void => { + NOW = 1_800_000_000 +} diff --git a/src/lnurlcash/nwc.testService.ts b/src/lnurlcash/nwc.testService.ts new file mode 100644 index 0000000..4036e0e --- /dev/null +++ b/src/lnurlcash/nwc.testService.ts @@ -0,0 +1,207 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' +import {isJsonObject} from './jsonParsing' + +import { + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OWNER_ID, + RELAYS, + createFakeRelay, + methodRequest, + nowSeconds, + resetNow, + waitFor, +} from './nwc.testProtocol' +import type {Encryption} from './nwc.testProtocol' +export type NwcResponsePayload = { + result_type: string + error: {code: string; message: string} | null + result: Record | null +} + +const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload => + isJsonObject(value) && + typeof value.result_type === 'string' && + (value.error === null || + (isJsonObject(value.error) && + typeof value.error.code === 'string' && + typeof value.error.message === 'string')) && + (value.result === null || isJsonObject(value.result)) + +export const readResponse = ( + published: NostrEvent[], + requestId: string, + scheme: Encryption, +): NwcResponsePayload | null => { + const event = published.find( + (e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId), + ) + if (!event) return null + const plaintext = + scheme === 'nip44_v2' + ? nip44v2.decrypt( + event.content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey), + ) + : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content) + const parsed: unknown = JSON.parse(plaintext) + if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.') + return parsed +} + +// drives one full request/response round trip over the fake relay +export const call = async ( + relay: ReturnType, + walletServicePubkey: string, + method: string, + params: Record, + scheme: Encryption = 'nip44_v2', +): Promise => { + const request = methodRequest(walletServicePubkey, method, params, scheme) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, scheme) !== null) + return requiredValue(readResponse(relay.published, request.id, scheme)) +} + +export type Mint = Awaited> +const mints: Mint[] = [] +export const mint = async (options: Parameters[0] = {}): Promise => { + const m = await createMockMint(options) + mints.push(m) + return m +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map((m) => m.close())) +}) + +let bearerCounter = 0 +export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise => { + m.state.creditNote(k1, amountMsat) + const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) + const info = await fetchNoteInfo(url) + bearerCounter += 1 + return { + id: `bearer-${bearerCounter}`, + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: m.state.pubkey, + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +// the harness around startService: a fake relay, an in-memory "store" +// applying changesets the way the Pinia layer will, and a created +// connection with a pinned client secret +export const startTestService = async (options: { + budgetMsat?: number + periodMs?: number + defaultMint?: string | null + linkingKey?: Uint8Array + poll?: typeof FAST_POLL + claimPoll?: typeof FAST_POLL + kit?: NwcServiceDeps['kit'] + commitChangeset?: (changeset: NwcChangeset) => Promise +}): Promise<{ + relay: ReturnType + walletServicePubkey: string + state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} + stop: () => Promise +}> => { + const budgetMsat = options.budgetMsat ?? 1_000_000_000 + const connection = createConnection(options.linkingKey ?? LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000}, + clientSecret: CLIENT_SECRET, + now: nowSeconds() * 1000, + }) + const relay = createFakeRelay() + const state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} = { + bearers: [], + changesets: [], + errors: [], + } + const service = await startService(options.linkingKey ?? LINKING_KEY, { + assertCurrentOwner: () => undefined, + getBearers: () => state.bearers, + getDefaultMint: () => options.defaultMint ?? null, + applyChangeset: async (changeset: NwcChangeset) => { + await options.commitChangeset?.(changeset) + state.changesets.push(changeset) + for (const id of changeset.markSpent) { + const bearer = state.bearers.find((b) => b.id === id) + if (bearer) bearer.spent = true + } + for (const note of changeset.add) { + bearerCounter += 1 + state.bearers.push({ + ...note, + id: `added-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + } + }, + onError: (err) => { + state.errors.push(err) + }, + transport: relay.transport, + kit: options.kit, + poll: options.poll ?? FAST_POLL, + claimPoll: options.claimPoll ?? FAST_POLL, + nowSeconds, + }) + return { + relay, + walletServicePubkey: connection.walletServicePubkey, + state, + stop: service.stop, + } +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) + resetNow() +}) diff --git a/src/lnurlcash/nwc.ts b/src/lnurlcash/nwc.ts index 850230e..f0b3e67 100644 --- a/src/lnurlcash/nwc.ts +++ b/src/lnurlcash/nwc.ts @@ -37,17 +37,18 @@ // per-connection request queues // Budget/connection persistence lives in storage/nwcConnections.ts. -export type { - NwcBudget, - NwcBudgetSpend, - NwcConnectionRecord -} from './storage/nwcConnections' +export type {NwcBudget, NwcBudgetSpend, NwcConnectionRecord} from './storage/nwcConnections' export { + clearNwcStorageForOwner, + clearUnownedNwcStorage, + migrateLegacyNwcStorage, persistNwcConnection, readNwcConnections, removeNwcConnection, - writeNwcConnections + writeNwcConnections, } from './storage/nwcConnections' +export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled' +export type {NwcLegacyMigrationResult} from './storage/nwcConnections' export { buildConnectionString, @@ -55,13 +56,13 @@ export { createConnection, deriveNwcWalletKey, nwcWalletPubkey, - parseConnectionString + parseConnectionString, } from './nwc/connection' export type { CreateConnectionOptions, CreatedConnection, NwcConnectionInfo, - ParsedConnectionString + ParsedConnectionString, } from './nwc/connection' export { @@ -73,7 +74,7 @@ export { buildResponseEvent, decryptRequest, errResult, - okResult + okResult, } from './nwc/protocol' export type { DecryptedNwcRequest, @@ -82,7 +83,7 @@ export type { NwcErrorCode, NwcMethod, NwcRequest, - NwcResponse + NwcResponse, } from './nwc/protocol' export {defaultNwcTransport} from './nwc/transport' diff --git a/src/lnurlcash/nwc.validation.cases.ts b/src/lnurlcash/nwc.validation.cases.ts new file mode 100644 index 0000000..09fdc54 --- /dev/null +++ b/src/lnurlcash/nwc.validation.cases.ts @@ -0,0 +1,187 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: request validation', () => { + it('answers an unknown method with NOT_IMPLEMENTED', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'get_payments', {}) + expect(response.result_type).toBe('get_payments') + expect(response.error?.code).toBe('NOT_IMPLEMENTED') + expect(response.result).toBeNull() + await stop() + }) + + it('answers a malformed (non-JSON) request with an error, not a crash', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, 'this is not json') + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2')) + expect(response.error?.code).toBe('OTHER') + await stop() + }) + + it('answers a JSON request without a method with an error', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe( + 'OTHER', + ) + await stop() + }) + + it('ignores a request signed by a stranger key - silently', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip44v2.encrypt( + JSON.stringify({method: 'get_balance', params: {}}), + nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey), + ) + const forged = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: nowSeconds(), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip44_v2'], + ], + content, + }, + STRANGER_SECRET, + ) + relay.emit(forged) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + await stop() + }) + + it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip04Encrypt( + CLIENT_SECRET, + walletServicePubkey, + JSON.stringify({method: 'get_balance', params: {}}), + ) + const request = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: nowSeconds(), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip17'], + ], + content, + }, + CLIENT_SECRET, + ) + relay.emit(request) + // the error answer goes out in the legacy scheme every client reads + await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) + expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe( + 'UNSUPPORTED_ENCRYPTION', + ) + await stop() + }) + + it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + for (const scheme of ['none', 'nip04'] as const) { + const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) + const response = requiredValue(readResponse(relay.published, request.id, 'nip04')) + expect(response.error).toBeNull() + expect(response.result).toEqual({balance: 0}) + // the response mirrors the request's scheme + const event = requiredValue( + relay.published.find( + (e) => + e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id), + ), + ) + expect(event.tags).toContainEqual(['encryption', 'nip04']) + } + await stop() + }) + + it('drops requests older than the replay window unanswered', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const stale = methodRequest( + walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + nowSeconds() - 1200, + ) + relay.emit(stale) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + await stop() + }) + + it('picks up no new requests after stop', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + await stop() + const request = methodRequest(walletServicePubkey, 'get_balance', {}) + relay.emitAfterClose(request) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + }) +}) diff --git a/src/lnurlcash/nwc/budget.ts b/src/lnurlcash/nwc/budget.ts index 74320ee..628c970 100644 --- a/src/lnurlcash/nwc/budget.ts +++ b/src/lnurlcash/nwc/budget.ts @@ -8,10 +8,7 @@ import type {NwcConnectionRecord} from '../storage/nwcConnections' import {persistNwcConnection} from '../storage/nwcConnections' -export const budgetRemainingMsat = ( - record: NwcConnectionRecord, - nowMs: number -): number => { +export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => { const {maxMsat, periodMs} = record.budget if (nowMs - record.spent.periodStart >= periodMs) return maxMsat return Math.max(0, maxMsat - record.spent.msat) @@ -20,18 +17,19 @@ export const budgetRemainingMsat = ( // rolls the period when it expired, then adds the spend; persists (the // caller's queue serialized this read-modify-write) export const recordSpend = ( + ownerId: string, record: NwcConnectionRecord, amountMsat: number, - nowMs: number + nowMs: number, ): NwcConnectionRecord => { const expired = nowMs - record.spent.periodStart >= record.budget.periodMs - return persistNwcConnection({ + return persistNwcConnection(ownerId, { ...record, spent: expired ? {periodStart: nowMs, msat: amountMsat} : { periodStart: record.spent.periodStart, - msat: record.spent.msat + amountMsat - } + msat: record.spent.msat + amountMsat, + }, }) } diff --git a/src/lnurlcash/nwc/connection.ts b/src/lnurlcash/nwc/connection.ts index dec0c16..8407637 100644 --- a/src/lnurlcash/nwc/connection.ts +++ b/src/lnurlcash/nwc/connection.ts @@ -24,6 +24,7 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {getPublicKey} from 'nostr-tools/pure' +import {linkingPubKeyHex} from '../keys' import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' import {persistNwcConnection} from '../storage/nwcConnections' @@ -35,16 +36,13 @@ const HEX_64 = /^[0-9a-f]{64}$/i // result is a secp256k1 secret key used ONLY as this connection's // wallet-service identity - it signs and decrypts NIP-47 events for this // one client, nothing else. -export const deriveNwcWalletKey = ( - linkingPrivKey: Uint8Array, - clientPubkey: string -): Uint8Array => +export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array => sha256( new Uint8Array([ ...linkingPrivKey, ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT), - ...hexToBytes(clientPubkey) - ]) + ...hexToBytes(clientPubkey), + ]), ) // the x-only nostr pubkey the client addresses its requests to @@ -60,12 +58,10 @@ export type NwcConnectionInfo = { // wallet-service identity export const connectionInfoOf = ( linkingPrivKey: Uint8Array, - record: NwcConnectionRecord + record: NwcConnectionRecord, ): NwcConnectionInfo => ({ record, - walletServicePubkey: nwcWalletPubkey( - deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) - ) + walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)), }) export type CreatedConnection = NwcConnectionInfo & { @@ -86,20 +82,22 @@ export type CreateConnectionOptions = { // default; the wallet-service key falls out of the derivation above. export const createConnection = ( linkingPrivKey: Uint8Array, - options: CreateConnectionOptions + options: CreateConnectionOptions, ): CreatedConnection => { if (options.relays.length === 0) { throw new Error('A connection needs at least one relay.') } - const clientSecret = - options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) + const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) const clientPubkey = getPublicKey(clientSecret) - const record = persistNwcConnection({ + const ownerId = linkingPubKeyHex(linkingPrivKey) + const record = persistNwcConnection(ownerId, { + version: 1, + ownerId, clientPubkey, relays: options.relays, budget: options.budget, spent: {periodStart: options.now ?? Date.now(), msat: 0}, - createdAt: options.now ?? Date.now() + createdAt: options.now ?? Date.now(), }) const info = connectionInfoOf(linkingPrivKey, record) return { @@ -107,19 +105,17 @@ export const createConnection = ( connectionString: buildConnectionString( info.walletServicePubkey, bytesToHex(clientSecret), - record.relays - ) + record.relays, + ), } } export const buildConnectionString = ( walletServicePubkey: string, clientSecretHex: string, - relays: string[] + relays: string[], ): string => { - const query = relays - .map(relay => `relay=${encodeURIComponent(relay)}`) - .join('&') + const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&') return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` } @@ -132,9 +128,7 @@ export type ParsedConnectionString = { // parses a NIP-47 connection string; returns null for anything that isn't // exactly one (a client-side counterpart of buildConnectionString, here so // the format has a tested inverse) -export const parseConnectionString = ( - uri: string -): ParsedConnectionString | null => { +export const parseConnectionString = (uri: string): ParsedConnectionString | null => { let url: URL try { url = new URL(uri.trim()) @@ -149,9 +143,7 @@ export const parseConnectionString = ( if (!HEX_64.test(walletServicePubkey)) return null const secret = url.searchParams.get('secret') if (!secret || !HEX_64.test(secret)) return null - const relays = url.searchParams - .getAll('relay') - .filter(relay => /^wss?:\/\//.test(relay)) + const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay)) if (relays.length === 0) return null return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} } diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts index 9541cfb..1e5148f 100644 --- a/src/lnurlcash/nwc/context.ts +++ b/src/lnurlcash/nwc/context.ts @@ -30,11 +30,13 @@ export type NwcServiceDeps = { // the mint make_invoice issues invoices against (the wallet's default // mint - NIP-47's make_invoice carries no mint choice) getDefaultMint: () => string | null + assertCurrentOwner: () => void applyChangeset: ( changeset: NwcChangeset, connection: NwcConnectionInfo, - method: NwcMethod - ) => void + method: NwcMethod, + assertOwner: () => void, + ) => Promise transport?: NwcTransport // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions @@ -73,4 +75,13 @@ export type RequestContext = { updateRecord: (record: NwcConnectionRecord) => void invoices: Map nowSeconds: () => number + assertOwner: () => void + // Starts service-owned work only while the service accepts new work. + // Accepted tasks become part of stop's drain before key cleanup. + startBackground: (work: () => Promise) => boolean + // fires when the service stops: long OBSERVATION waits (the invoice + // claim poll) must interrupt themselves on it. Work that has already + // reached a fund-critical commit must NOT consult it - stop awaits + // those tasks through its drain + stopSignal: AbortSignal } diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts index 36651ea..99c06f8 100644 --- a/src/lnurlcash/nwc/dispatch.ts +++ b/src/lnurlcash/nwc/dispatch.ts @@ -9,6 +9,7 @@ import {noteK1, sameInvoice} from 'lnurlcash-kit' import type {Bearer} from '../types' import type {PreparedMint} from '../ops' import {prepareMint} from '../ops' +import {PollAbortedError} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' @@ -19,10 +20,7 @@ import {NWC_METHODS, errResult, okResult} from './protocol' // the same eligibility carve applies - the balance answers "what could // this wallet actually pay with right now" const spendable = (bearer: Bearer): boolean => - !bearer.spent && - bearer.callback !== '' && - !bearer.deviceId && - !!noteK1(bearer.url) + !bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url) const handleGetInfo = (ctx: RequestContext): NwcResponse => okResult('get_info', { @@ -34,7 +32,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse => // block height/hash exists, so those fields are simply absent. network: 'mainnet', methods: [...NWC_METHODS], - notifications: [] + notifications: [], }) const handleGetBalance = (ctx: RequestContext): NwcResponse => @@ -42,20 +40,16 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse => balance: ctx.deps .getBearers() .filter(spendable) - .reduce((sum, b) => sum + b.amount, 0) + .reduce((sum, b) => sum + b.amount, 0), }) const handleMakeInvoice = async ( ctx: RequestContext, - params: Record + params: Record, ): Promise => { const amountMsat = Number(params.amount) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { - return errResult( - 'make_invoice', - 'OTHER', - 'Amount must be a positive whole number of msat.' - ) + return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.') } const mint = ctx.deps.getDefaultMint() if (!mint) { @@ -65,11 +59,7 @@ const handleMakeInvoice = async ( try { prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {}) } catch (err) { - return errResult( - 'make_invoice', - 'INTERNAL', - err instanceof Error ? err.message : String(err) - ) + return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) } const entry: PendingInvoice = { invoice: prepared.invoice, @@ -77,7 +67,7 @@ const handleMakeInvoice = async ( amountMsat: prepared.grossMsat, createdAt: ctx.nowSeconds(), prepared, - state: 'pending' + state: 'pending', } if (typeof params.description === 'string' && params.description) { entry.description = params.description @@ -88,30 +78,29 @@ const handleMakeInvoice = async ( } ctx.invoices.set(entry.paymentHash, entry) // phase two runs in the background; the invoice goes out now and - // lookup_invoice reports the settlement the claim observes - void settleAndClaim(ctx, entry).catch(err => { + // lookup_invoice reports the settlement the service-owned claim observes + const started = ctx.startBackground(() => + settleAndClaim(ctx, entry).catch((err) => { + entry.state = 'failed' + // an interrupted claim poll is normal service teardown (stop + // aborted it), not a background failure worth surfacing + if (err instanceof PollAbortedError) return + ctx.deps.onError?.(err, ctx.connection()) + }), + ) + if (!started) { entry.state = 'failed' - ctx.deps.onError?.(err, ctx.connection()) - }) + return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.') + } return okResult('make_invoice', invoiceResult(entry)) } -const handleLookupInvoice = ( - ctx: RequestContext, - params: Record -): NwcResponse => { - const invoiceParam = - typeof params.invoice === 'string' ? params.invoice : undefined +const handleLookupInvoice = (ctx: RequestContext, params: Record): NwcResponse => { + const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined const hashParam = - typeof params.payment_hash === 'string' - ? params.payment_hash.toLowerCase() - : undefined + typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined if (!invoiceParam && !hashParam) { - return errResult( - 'lookup_invoice', - 'OTHER', - 'Provide an invoice or a payment hash.' - ) + return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.') } let entry = hashParam ? ctx.invoices.get(hashParam) : undefined if (!entry && invoiceParam) { @@ -128,10 +117,7 @@ const handleLookupInvoice = ( return okResult('lookup_invoice', invoiceResult(entry)) } -export const dispatch = async ( - ctx: RequestContext, - request: NwcRequest -): Promise => { +export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise => { switch (request.method) { case 'get_info': return handleGetInfo(ctx) @@ -144,10 +130,6 @@ export const dispatch = async ( case 'lookup_invoice': return handleLookupInvoice(ctx, request.params) default: - return errResult( - request.method, - 'NOT_IMPLEMENTED', - `Unknown method: ${request.method}.` - ) + return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`) } } diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts index 92cd9da..1fc1a5b 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -23,10 +23,10 @@ import type {PollOptions} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' -export const DEFAULT_CLAIM_POLL: Required = { +export const DEFAULT_CLAIM_POLL: Required> = { intervalMs: 2000, intervalCapMs: 10_000, - maxWaitMs: 15 * 60_000 + maxWaitMs: 15 * 60_000, } // LUD-21 verify URLs end in /verify/ (the protocol's verify @@ -41,9 +41,7 @@ export const resolvePaymentHash = (prepared: PreparedMint): string => { } // the NIP-47 transaction object make_invoice and lookup_invoice share -export const invoiceResult = ( - entry: PendingInvoice -): Record => { +export const invoiceResult = (entry: PendingInvoice): Record => { const result: Record = { type: 'incoming', state: entry.state, @@ -51,7 +49,7 @@ export const invoiceResult = ( payment_hash: entry.paymentHash, amount: entry.amountMsat, created_at: entry.createdAt, - metadata: {} + metadata: {}, } if (entry.description) result.description = entry.description if (entry.expiresAt) result.expires_at = entry.expiresAt @@ -64,47 +62,50 @@ export const invoiceResult = ( // the background half of make_invoice: watch the invoice, and once it // settles claim the note (rotating it immediately) and hand the fresh -// bearer to the caller. Settlement is recorded LAST - after the rotate - -// so a preimage lookup can only ever reveal an already-burned secret. +// bearer to the caller. Settlement is recorded LAST - after the rotate and +// bearer commit - so lookup can only reveal durably tracked funds and an +// already-burned secret. // Throws on any failure; the caller marks the entry failed and reports // through deps.onError. -export const settleAndClaim = async ( - ctx: RequestContext, - entry: PendingInvoice -): Promise => { +export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise => { if (!entry.prepared.verifyUrl) { throw new Error( - 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.' + 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', ) } + // the observation half is interruptible (the client may never pay, so + // the poll can legally outlive the service); once settlement is seen, + // everything below - claim, rotate, bearer commit - is fund-critical and + // deliberately ignores the stop signal: stop's drain awaits it const result = await pollVerifyUntilSettled( entry.prepared.verifyUrl, - ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL, - ctx.deps.kit ?? {} + {...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal}, + ctx.deps.kit ?? {}, ) // a settled report only means this wallet's invoice was paid if it's // for the invoice this wallet actually requested if (!sameInvoice(result.pr, entry.prepared.invoice)) { - throw new Error( - "The service's verify response is for a different invoice than requested." - ) + throw new Error("The service's verify response is for a different invoice than requested.") } const preimage = result.preimage if (!preimage || !isPreimage(preimage)) { - throw new Error( - 'The payment settled but the service did not reveal the preimage.' - ) + throw new Error('The payment settled but the service did not reveal the preimage.') } // claimFromPreimage IS claimMintedNote's claim half (poll above is the // other half) - invoked in two steps here because NWC needs the // preimage, which claimMintedNote deliberately discards - const claimed = await claimFromPreimage( - entry.prepared, - preimage, - ctx.deps.kit ?? {} - ) + const claimed = await claimFromPreimage(entry.prepared, preimage, { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + }) const add: NewBearer[] = [claimed.note] if (claimed.possibleCopy) add.push(claimed.possibleCopy) + await ctx.deps.applyChangeset( + {add, markSpent: []}, + ctx.connection(), + 'make_invoice', + ctx.assertOwner, + ) entry.settledAt = ctx.nowSeconds() entry.state = 'settled' if (claimed.rotated) { @@ -112,5 +113,4 @@ export const settleAndClaim = async ( // secret, safe to hand out as the settlement receipt entry.preimage = preimage } - ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice') } diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts index b3d2192..c03cbda 100644 --- a/src/lnurlcash/nwc/pay.ts +++ b/src/lnurlcash/nwc/pay.ts @@ -9,7 +9,7 @@ import { decodeBolt11AmountMsat, fetchInvoiceVerification, isBolt11Invoice, - noteK1 + noteK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -29,12 +29,9 @@ import {errResult, okResult} from './protocol' // rotate) is left for the next refresh to reconcile, exactly as the UI // leaves it - the money itself sits in the re-secured note, which IS // tracked. -export const payChangeset = ( - bearers: Bearer[], - result: PayResult -): NwcChangeset => { +export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { const add: NewBearer[] = [] - const markSpent: string[] = result.carve.consumed.map(b => b.id) + const markSpent: string[] = result.carve.consumed.map((b) => b.id) if (result.carve.change) add.push(result.carve.change) if (result.outcome === 'failed-funds-returned') { add.push(result.carve.note) @@ -44,9 +41,7 @@ export const payChangeset = ( // carve), lock that bearer; a freshly carved note is never added - // it was born spent const carvedK1 = noteK1(result.carve.note.url) - const existing = carvedK1 - ? bearers.find(b => noteK1(b.url) === carvedK1) - : undefined + const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined if (existing) markSpent.push(existing.id) } if (result.rescuedNote) add.push(result.rescuedNote) @@ -55,10 +50,9 @@ export const payChangeset = ( export const handlePayInvoice = async ( ctx: RequestContext, - params: Record + params: Record, ): Promise => { - const invoice = - typeof params.invoice === 'string' ? params.invoice.trim() : '' + const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '' if (!isBolt11Invoice(invoice)) { return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.') } @@ -69,23 +63,17 @@ export const handlePayInvoice = async ( return errResult( 'pay_invoice', 'OTHER', - 'Could not read this invoice\'s amount - amount-less invoices are not supported.' + "Could not read this invoice's amount - amount-less invoices are not supported.", ) } if (params.amount !== undefined && params.amount !== amountMsat) { - return errResult( - 'pay_invoice', - 'OTHER', - 'The request\'s amount does not match the invoice.' - ) + return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.") } - if ( - amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now()) - ) { + if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) { return errResult( 'pay_invoice', 'QUOTA_EXCEEDED', - 'This payment exceeds the connection\'s budget.' + "This payment exceeds the connection's budget.", ) } const bearers = ctx.deps.getBearers() @@ -93,77 +81,100 @@ export const handlePayInvoice = async ( try { result = await payWithBearers(bearers, invoice, { poll: ctx.deps.poll ?? {}, - kit: ctx.deps.kit ?? {} + kit: ctx.deps.kit ?? {}, + assertOwner: ctx.assertOwner, }) } catch (err) { if (err instanceof UncertainOutcomeError) { // the carve's answer was lost and the probe couldn't tell: the // possible outputs carry fresh secrets that may be the only money // left - tracked unverified, never dropped - ctx.deps.applyChangeset( + await ctx.deps.applyChangeset( {add: err.possibleOutputs, markSpent: []}, ctx.connection(), - 'pay_invoice' + 'pay_invoice', + ctx.assertOwner, ) return errResult( 'pay_invoice', 'INTERNAL', - 'The payment preparation could not be confirmed; possible new notes were stored unverified.' + 'The payment preparation could not be confirmed; possible new notes were stored unverified.', ) } const message = err instanceof Error ? err.message : String(err) return errResult( 'pay_invoice', /enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL', - message + message, ) } const spendRecorded = (): void => { - ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now())) + // This conservative budget debit is persisted separately from bearer + // storage. A later bearer commit failure does not roll it back. + const connection = ctx.connection().record + ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now())) } switch (result.outcome) { case 'settled': { spendRecorded() - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) // the receipt NIP-47 clients expect: the melt's own payment // preimage, re-read from the settle proof. A mint that reveals // none yields an empty preimage rather than a fabricated one. let preimage = '' if (result.verifyUrl) { try { - const proof = await fetchInvoiceVerification( - result.verifyUrl, - ctx.deps.kit ?? {} - ) + const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {}) preimage = proof.preimage ?? '' - } catch { + } catch (error) { // the settle proof was already polled inside payWithBearers; // a failed re-read must not flip the outcome + if (!(error instanceof Error)) throw error } } return okResult('pay_invoice', {preimage}) } case 'failed-funds-returned': - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'PAYMENT_FAILED', - 'The payment failed; the funds are back in the wallet.' + 'The payment failed; the funds are back in the wallet.', ) case 'note-already-spent': - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'PAYMENT_FAILED', - 'The note backing this payment was already spent; nothing was paid.' + 'The note backing this payment was already spent; nothing was paid.', ) case 'unknown-still-pending': spendRecorded() - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'OTHER', - 'The payment is still in flight; the note stays locked until it reconciles.' + 'The payment is still in flight; the note stays locked until it reconciles.', ) } } diff --git a/src/lnurlcash/nwc/protocol.ts b/src/lnurlcash/nwc/protocol.ts index 60e4f7e..a7ccaba 100644 --- a/src/lnurlcash/nwc/protocol.ts +++ b/src/lnurlcash/nwc/protocol.ts @@ -29,7 +29,7 @@ export const NWC_METHODS = [ 'get_balance', 'make_invoice', 'pay_invoice', - 'lookup_invoice' + 'lookup_invoice', ] as const export type NwcMethod = (typeof NWC_METHODS)[number] @@ -61,17 +61,13 @@ export type NwcResponse = { export const okResult = (method: string, result: unknown): NwcResponse => ({ result_type: method, error: null, - result + result, }) -export const errResult = ( - method: string, - code: NwcErrorCode, - message: string -): NwcResponse => ({ +export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({ result_type: method, error: {code, message}, - result: null + result: null, }) // the two encryption schemes this service speaks; the scheme of a request @@ -79,16 +75,14 @@ export const errResult = ( // requested by the client") export type NwcEncryption = 'nip44_v2' | 'nip04' -const conversationKey = ( - walletSecretKey: Uint8Array, - clientPubkey: string -): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) +const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array => + nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) export const encryptFor = ( scheme: NwcEncryption, walletSecretKey: Uint8Array, clientPubkey: string, - plaintext: string + plaintext: string, ): string => scheme === 'nip44_v2' ? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey)) @@ -98,14 +92,14 @@ const decryptFrom = ( scheme: NwcEncryption, walletSecretKey: Uint8Array, clientPubkey: string, - content: string + content: string, ): string => scheme === 'nip44_v2' ? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey)) : nip04Decrypt(walletSecretKey, clientPubkey, content) const tagValue = (event: NostrEvent, name: string): string | undefined => - event.tags.find(t => t[0] === name)?.[1] + event.tags.find((t) => t[0] === name)?.[1] // The outcome of validating + decrypting a candidate request event: // - a request to dispatch (encryption scheme carried so the response can @@ -126,7 +120,7 @@ export const decryptRequest = ( walletServicePubkey: string, clientPubkey: string, event: NostrEvent, - nowSeconds: number = Math.floor(Date.now() / 1000) + nowSeconds: number = Math.floor(Date.now() / 1000), ): DecryptedNwcRequest | null => { if (event.kind !== NWC_REQUEST_KIND) return null // only the authorized client may talk to this connection, and the @@ -155,8 +149,8 @@ export const decryptRequest = ( response: errResult( '', 'UNSUPPORTED_ENCRYPTION', - `Unsupported encryption scheme: ${advertised}.` - ) + `Unsupported encryption scheme: ${advertised}.`, + ), } } let plaintext: string @@ -173,7 +167,7 @@ export const decryptRequest = ( return { respond: true, encryption, - response: errResult('', 'OTHER', 'The request is not valid JSON.') + response: errResult('', 'OTHER', 'The request is not valid JSON.'), } } if ( @@ -185,14 +179,11 @@ export const decryptRequest = ( return { respond: true, encryption, - response: errResult('', 'OTHER', 'The request has no method.') + response: errResult('', 'OTHER', 'The request has no method.'), } } const request = data as NwcRequest - const params = - typeof request.params === 'object' && request.params !== null - ? request.params - : {} + const params = typeof request.params === 'object' && request.params !== null ? request.params : {} return {respond: false, request: {method: request.method, params}, encryption} } @@ -204,7 +195,7 @@ export const buildResponseEvent = ( encryption: NwcEncryption, requestEventId: string, response: NwcResponse, - createdAt: number = Math.floor(Date.now() / 1000) + createdAt: number = Math.floor(Date.now() / 1000), ): NostrEvent => finalizeEvent( { @@ -213,29 +204,24 @@ export const buildResponseEvent = ( tags: [ ['p', clientPubkey], ['e', requestEventId], - ['encryption', encryption] + ['encryption', encryption], ], - content: encryptFor( - encryption, - walletSecretKey, - clientPubkey, - JSON.stringify(response) - ) + content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)), }, - walletSecretKey + walletSecretKey, ) // the replaceable info event advertising this service's capabilities export const buildInfoEvent = ( walletSecretKey: Uint8Array, - createdAt: number = Math.floor(Date.now() / 1000) + createdAt: number = Math.floor(Date.now() / 1000), ): NostrEvent => finalizeEvent( { kind: NWC_INFO_KIND, created_at: createdAt, tags: [['encryption', 'nip44_v2 nip04']], - content: NWC_METHODS.join(' ') + content: NWC_METHODS.join(' '), }, - walletSecretKey + walletSecretKey, ) diff --git a/src/lnurlcash/nwc/service.ts b/src/lnurlcash/nwc/service.ts index 55bca53..2a43e72 100644 --- a/src/lnurlcash/nwc/service.ts +++ b/src/lnurlcash/nwc/service.ts @@ -14,26 +14,18 @@ // foreground-only design itself is documented in the nwc.ts façade // header. +import {linkingPubKeyHex} from '../keys' import type {NwcConnectionRecord} from '../storage/nwcConnections' import {readNwcConnections} from '../storage/nwcConnections' +import {assertSavedKeyOwner} from '../storage/currentOwner' import type {NwcConnectionInfo} from './connection' import {deriveNwcWalletKey, nwcWalletPubkey} from './connection' import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context' import {dispatch} from './dispatch' -import type { - NostrEvent, - NwcEncryption, - NwcRequest, - NwcResponse -} from './protocol' -import { - NWC_REQUEST_KIND, - buildInfoEvent, - buildResponseEvent, - decryptRequest -} from './protocol' -import type {NwcSubscription, NwcTransport} from './transport' +import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol' +import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol' +import type {NwcSubscription} from './transport' import {defaultNwcTransport} from './transport' export type {NwcConnectionInfo} @@ -44,7 +36,8 @@ const MAX_REQUEST_AGE_SECONDS = 600 type ConnectionRuntime = { info: NwcConnectionInfo - walletSecret: Uint8Array + // nulled and zeroed only after every tracked handler drains + walletSecret: Uint8Array | null // invoices this connection issued, by payment hash - in-memory only: // pending invoices don't survive a restart (lookup then answers // NOT_FOUND), same as any foreground-only wallet @@ -61,34 +54,38 @@ export type NwcService = { connections: NwcConnectionInfo[] // closes every relay subscription. In-flight handlers still finish - // their changesets hold money - but no new requests are picked up - stop: () => void + stop: () => Promise } export const startService = async ( linkingPrivKey: Uint8Array, deps: NwcServiceDeps, - records: NwcConnectionRecord[] = readNwcConnections() + records?: NwcConnectionRecord[], ): Promise => { const transport = deps.transport ?? (await defaultNwcTransport()) - const nowSeconds = (): number => - deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000) + const ownerId = linkingPubKeyHex(linkingPrivKey) + const ownedRecords = (records ?? readNwcConnections(ownerId)).filter( + (record) => record.ownerId === ownerId, + ) + const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000) const publishResponse = async ( runtime: ConnectionRuntime, + walletSecret: Uint8Array, requestEventId: string, encryption: NwcEncryption, - response: NwcResponse + response: NwcResponse, ): Promise => { await transport.publish( runtime.info.record.relays, buildResponseEvent( - runtime.walletSecret, + walletSecret, runtime.info.record.clientPubkey, encryption, requestEventId, response, - nowSeconds() - ) + nowSeconds(), + ), ) } @@ -97,12 +94,12 @@ export const startService = async ( const dispatchSerialized = ( runtime: ConnectionRuntime, ctx: RequestContext, - request: NwcRequest + request: NwcRequest, ): Promise => { if (request.method !== 'pay_invoice') return dispatch(ctx, request) const run = runtime.queue.then( () => dispatch(ctx, request), - () => dispatch(ctx, request) + () => dispatch(ctx, request), ) runtime.queue = run.catch(() => undefined) return run @@ -111,28 +108,53 @@ export const startService = async ( const handleEvent = async ( runtime: ConnectionRuntime, ctx: RequestContext, - event: NostrEvent + event: NostrEvent, ): Promise => { + const walletSecret = runtime.walletSecret + if (walletSecret === null) return const at = nowSeconds() // replay safety (see the header): too-old requests are dropped if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return const decrypted = decryptRequest( - runtime.walletSecret, + walletSecret, runtime.info.walletServicePubkey, runtime.info.record.clientPubkey, event, - at + at, ) if (decrypted === null) return if (decrypted.respond) { - await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response) + await publishResponse( + runtime, + walletSecret, + event.id, + decrypted.encryption, + decrypted.response, + ) return } const response = await dispatchSerialized(runtime, ctx, decrypted.request) - await publishResponse(runtime, event.id, decrypted.encryption, response) + await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response) } - const runtimes = records.map(record => { + let accepting = true + // interrupts long observation waits (the invoice claim poll) at stop; + // the drain below still awaits tasks that reached a fund-critical commit + const stopController = new AbortController() + const inFlight = new Set>() + const track = (task: Promise): void => { + inFlight.add(task) + void task.then( + () => inFlight.delete(task), + () => inFlight.delete(task), + ) + } + const startBackground = (work: () => Promise): boolean => { + if (!accepting) return false + track(work()) + return true + } + let runtimes = ownedRecords.map((record) => { const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) const runtime: ConnectionRuntime = { info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)}, @@ -141,29 +163,37 @@ export const startService = async ( queue: Promise.resolve(), // replaced below, immediately - the field exists because the // subscription callback closes over the runtime - sub: {close: () => undefined} + sub: {close: () => undefined}, } const ctx: RequestContext = { deps, connection: () => runtime.info, - updateRecord: updated => { + updateRecord: (updated) => { runtime.info = {...runtime.info, record: updated} }, invoices: runtime.invoices, - nowSeconds + nowSeconds, + assertOwner: () => { + deps.assertCurrentOwner() + assertSavedKeyOwner(ownerId) + }, + startBackground, + stopSignal: stopController.signal, } runtime.sub = transport.subscribe( record.relays, { kinds: [NWC_REQUEST_KIND], '#p': [runtime.info.walletServicePubkey], - since: nowSeconds() + since: nowSeconds(), }, - event => { - void handleEvent(runtime, ctx, event).catch(err => + (event) => { + if (!accepting) return + const handler = handleEvent(runtime, ctx, event).catch((err) => { deps.onError?.(err, runtime.info) - ) - } + }) + track(handler) + }, ) return runtime }) @@ -171,20 +201,43 @@ export const startService = async ( // info events: best-effort - a rejected publish must not sink startup; // the client learns capabilities from its first error-free exchange too for (const runtime of runtimes) { + const walletSecret = runtime.walletSecret + if (walletSecret === null) continue try { await transport.publish( runtime.info.record.relays, - buildInfoEvent(runtime.walletSecret, nowSeconds()) + buildInfoEvent(walletSecret, nowSeconds()), + ) + } catch (error) { + deps.onError?.( + error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}), + runtime.info, ) - } catch (err) { - deps.onError?.(err, runtime.info) } } + const connections = runtimes.map((runtime) => runtime.info) + let stopPromise: Promise | null = null + const stop = (): Promise => { + if (stopPromise !== null) return stopPromise + accepting = false + for (const runtime of runtimes) runtime.sub.close() + stopController.abort() + stopPromise = Promise.all([...inFlight]) + .then(() => undefined) + .finally(() => { + for (const runtime of runtimes) { + runtime.walletSecret?.fill(0) + runtime.walletSecret = null + runtime.invoices.clear() + } + runtimes = [] + }) + return stopPromise + } + return { - connections: runtimes.map(r => r.info), - stop: () => { - for (const runtime of runtimes) runtime.sub.close() - } + connections, + stop, } } diff --git a/src/lnurlcash/nwc/transport.ts b/src/lnurlcash/nwc/transport.ts index b218686..673638c 100644 --- a/src/lnurlcash/nwc/transport.ts +++ b/src/lnurlcash/nwc/transport.ts @@ -19,7 +19,7 @@ export type NwcTransport = { subscribe: ( relays: string[], filter: NostrFilter, - onEvent: (event: NostrEvent) => void + onEvent: (event: NostrEvent) => void, ) => NwcSubscription } @@ -30,11 +30,10 @@ export const defaultNwcTransport = async (): Promise => { publish: async (relays, event) => { const results = await Promise.allSettled(pool.publish(relays, event)) // one honest relay accepting is enough - same rule as the backup - if (!results.some(r => r.status === 'fulfilled')) { + if (!results.some((r) => r.status === 'fulfilled')) { throw new Error('No relay accepted the event.') } }, - subscribe: (relays, filter, onEvent) => - pool.subscribeMany(relays, filter, {onevent: onEvent}) + subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}), } } diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts new file mode 100644 index 0000000..bef3e74 --- /dev/null +++ b/src/lnurlcash/ops.carve.cases.ts @@ -0,0 +1,122 @@ +import {describe, expect, it} from 'vitest' +import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' + +import type {Bearer} from './types' +import {UncertainOutcomeError, ensureExactAmount} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' + +describe('ensureExactAmount', () => { + it('returns an already-exact note untouched, burning nothing', async () => { + const instance = await mint() + const k1 = secret('01') + const bearer = await makeBearer(instance, k1, 21_000) + const result = await ensureExactAmount([bearer], 21_000) + expect(noteK1(result.note.url)).toBe(k1) + expect(result.consumed).toEqual([]) + expect(result.change).toBeUndefined() + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('split path: carves an exact note off a larger one, with change', async () => { + const instance = await mint() + const k1 = secret('02') + const bearer = await makeBearer(instance, k1, 21_000) + const result = await ensureExactAmount([bearer], 5_000) + expect(result.note.amount).toBe(5_000) + expect(result.note.verified).toBe(true) + expect(result.change?.amount).toBe(16_000) + expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(instance.state.noteState(k1)).toBe('burned') + const partK1 = requiredValue(noteK1(result.note.url)) + const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) + expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) + }) + + it('merge path: combines notes summing exactly to the target', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('03'), 3_000) + const second = await makeBearer(instance, secret('04'), 4_000) + const result = await ensureExactAmount([first, second], 7_000) + expect(result.note.amount).toBe(7_000) + expect(result.change).toBeUndefined() + expect(result.consumed).toHaveLength(2) + expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned') + expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned') + const mergedK1 = requiredValue(noteK1(result.note.url)) + expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000) + }) + + it('merge+split path: splits the target off several notes in one request', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('05'), 3_000) + const second = await makeBearer(instance, secret('06'), 4_000) + const result = await ensureExactAmount([first, second], 5_000) + expect(result.note.amount).toBe(5_000) + expect(result.change?.amount).toBe(2_000) + expect(result.consumed).toHaveLength(2) + const partK1 = requiredValue(noteK1(result.note.url)) + const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) + expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000) + }) + + it('excludes spent and unverified notes from selection', async () => { + const instance = await mint() + const spentBearer = await makeBearer(instance, secret('07'), 50_000) + const unverified: Bearer = { + ...(await makeBearer(instance, secret('08'), 50_000)), + callback: '', + } + await expect( + ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000), + ).rejects.toThrow(/enough/) + }) + + it('refuses an amount no mint can cover', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('09'), 5_000) + await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/) + }) + + it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => { + const instance = await mint({dropAfterMutation: true}) + const k1 = secret('10') + const bearer = await makeBearer(instance, k1, 21_000) + const result = await ensureExactAmount([bearer], 5_000) + const partK1 = requiredValue(noteK1(result.note.url)) + const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) + expect(partK1).not.toBe(k1) + expect(instance.state.noteState(k1)).toBe('burned') + expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) + expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) + }) + + it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { + const instance = await mint({dropAfterMutation: true}) + const k1 = secret('11') + const bearer = await makeBearer(instance, k1, 21_000) + const probeKillingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) return fetch(input, init) + return Promise.reject(new Error('probe unreachable')) + } + const failure = await ensureExactAmount([bearer], 5_000, { + fetch: probeKillingFetch, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(UncertainOutcomeError) + if (!(failure instanceof UncertainOutcomeError)) throw failure + expect(failure.possibleOutputs).toHaveLength(2) + const first = requiredValue(failure.possibleOutputs[0]) + const second = requiredValue(failure.possibleOutputs[1]) + expect(first.amount).toBe(5_000) + expect(second.amount).toBe(16_000) + expect( + (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable, + ).toBe(5_000) + expect( + (await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable, + ).toBe(16_000) + }) +}) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts new file mode 100644 index 0000000..9649448 --- /dev/null +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -0,0 +1,106 @@ +import {describe, expect, it} from 'vitest' +import { + NoteSpentError, + PendingNoteError, + buildNoteUrl, + fetchNoteInfo, + meltNote, + noteK1, + rotateNote, +} from 'lnurlcash-kit' + +import {claimMintedNote, prepareMint, receiveBearer} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness' + +describe('mint -> claim -> rotate', () => { + it('mints a note from a paid invoice and rotates it immediately', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + expect(prepared.invoice).toMatch(/^lnbc/) + expect(prepared.verifyUrl).toBeTruthy() + expect(prepared.expectedNoteValueMsat).toBe(21_000) + const preimage = await settleLastInvoice(instance) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + expect(instance.state.noteState(preimage)).toBe('burned') + const k1 = requiredValue(noteK1(claimed.note.url)) + expect(k1).not.toBe(preimage) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('grosses the invoice up for an advertised mint fee', async () => { + const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000) + expect(prepared.grossMsat).toBeGreaterThan(100_000) + const preimage = await settleLastInvoice(instance) + const info = await fetchNoteInfo( + buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat), + ) + expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) + expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}), + ).rejects.toThrow(/different invoice/) + }) + + it('times out cleanly when the invoice is never paid', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), + ).rejects.toThrow(/not confirmed/i) + }) +}) + +describe('receiveBearer', () => { + it("verifies an incoming note and rotates it, burning the sender's copy", async () => { + const instance = await mint() + const senderK1 = secret('20') + instance.state.creditNote(senderK1, 21_000) + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), []) + expect(received.rotated).toBe(true) + expect(received.note.amount).toBe(21_000) + expect(received.note.verified).toBe(true) + const newK1 = requiredValue(noteK1(received.note.url)) + expect(newK1).not.toBe(senderK1) + expect(instance.state.noteState(senderK1)).toBe('burned') + expect(instance.state.noteState(newK1)).toBe('outstanding') + }) + + it('refuses a note the wallet already holds', async () => { + const instance = await mint() + const senderK1 = secret('21') + const existing = await makeBearer(instance, senderK1, 21_000) + await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow( + /already/, + ) + }) + + it('surfaces a spent note as definitively spent', async () => { + const instance = await mint() + const k1 = secret('22') + const bearer = await makeBearer(instance, k1, 21_000) + const info = await fetchNoteInfo(bearer.url) + await rotateNote(info.callback, k1) + await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( + NoteSpentError, + ) + }) + + it('surfaces a note locked mid-melt as pending, not as unverified', async () => { + const instance = await mint({meltNeverSettles: true}) + const k1 = secret('23') + const bearer = await makeBearer(instance, k1, 21_000) + await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') + await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( + PendingNoteError, + ) + }) +}) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts new file mode 100644 index 0000000..5c27833 --- /dev/null +++ b/src/lnurlcash/ops.pay.cases.ts @@ -0,0 +1,75 @@ +import {describe, expect, it} from 'vitest' +import {noteK1} from 'lnurlcash-kit' + +import {payWithBearers} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, secret} from './ops.testHarness' + +describe('payWithBearers', () => { + it('pays a bolt11 invoice by melting an exact note (settled)', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('30'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(instance.state.noteState(secret('30'))).toBe('burned') + }) + + it('pays a Lightning Address by requesting an invoice first', async () => { + const payer = await mint() + const payee = await mint() + const bearer = await makeBearer(payer, secret('31'), 21_000) + const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, { + amountMsat: 21_000, + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(result.invoice).toMatch(/^lnbc/) + expect(payer.state.noteState(secret('31'))).toBe('burned') + }) + + it('carves the exact amount out of a larger note before melting', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('32'), 50_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(instance.state.noteState(secret('32'))).toBe('burned') + expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(result.carve.change?.amount).toBe(29_000) + const change = requiredValue(result.carve.change) + expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding') + }) + + it('classifies a failed melt as funds-returned once the note is spendable again', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('33'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('failed-funds-returned') + expect(instance.state.noteState(secret('33'))).toBe('burned') + const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + expect(instance.state.noteState(returnedK1)).toBe('outstanding') + expect(result.carve.note.amount).toBe(21_000) + }) + + it('classifies a never-settling melt as unknown-still-pending', async () => { + const instance = await mint({meltNeverSettles: true}) + const bearer = await makeBearer(instance, secret('34'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('unknown-still-pending') + expect(instance.state.noteState(secret('34'))).toBe('pending') + }) + + it('rejects an amountless or unreadable invoice instead of guessing', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('35'), 21_000) + await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) + await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index d7ec823..e7f160f 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -1,606 +1,4 @@ -// The operations engine against the conformance mock mint - a real HTTP -// server that can be told to misbehave. The happy paths matter, but the -// adversarial modes (dropped mutations, failed melts) are what prove the -// fund-safety invariants: fresh secrets are never lost, and melt outcomes -// are classified by proof, not by hope. - -import {afterEach, describe, expect, it} from 'vitest' -import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' -import {sha256} from '@noble/hashes/sha2.js' -import { - NoteSpentError, - PendingNoteError, - buildNoteUrl, - fetchNoteInfo, - meltNote, - noteK1, - rotateNote -} from 'lnurlcash-kit' - -import type {Bearer} from './types' -import { - UncertainOutcomeError, - claimMintedNote, - ensureExactAmount, - payWithBearers, - prepareMint, - receiveBearer, - transferBetweenMints -} from './ops' - -type Mint = Awaited> - -const mints: Mint[] = [] -const mint = async (options: Parameters[0] = {}): Promise => { - const m = await createMockMint(options) - mints.push(m) - return m -} - -afterEach(async () => { - await Promise.all(mints.splice(0).map(m => m.close())) -}) - -const secret = (seed: string) => - bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) -const noteUrl = (m: Mint, k1: string, amountMsat?: number) => - buildNoteUrl(`${m.url}/w`, k1, amountMsat) - -// a verified, ready-to-spend bearer fixture: funded on the mock mint and -// read back through the informational GET, exactly as a real receive would -// learn its callback and authoritative amount -let fixtureCounter = 0 -const makeBearer = async ( - m: Mint, - k1: string, - amountMsat: number -): Promise => { - m.state.creditNote(k1, amountMsat) - const url = noteUrl(m, k1, amountMsat) - const info = await fetchNoteInfo(url) - fixtureCounter += 1 - return { - id: `fixture-${fixtureCounter}`, - url, - callback: info.callback, - amount: info.maxWithdrawable, - verified: true, - mintPubkey: m.state.pubkey, - createdAt: Date.now(), - updatedAt: Date.now() - } -} - -// paying a mint invoice is what brings its note into existence - the mock -// exposes that through its test hook (settle + credit in one step). -// Returns the paid invoice's preimage, which IS the fresh note's secret. -const settleLastInvoice = async (m: Mint): Promise => { - const paymentHash = [...m.state.invoices.keys()].at(-1)! - const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) - if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) - return m.state.invoices.get(paymentHash)!.preimage -} - -// waits for a mint to have an invoice at all, then settles it - for flows -// that request the invoice deep inside a single awaited call (transfer), -// where the test has to play the arriving payment mid-flight -const settleWhenRequested = async (m: Mint): Promise => { - for (let i = 0; i < 200 && m.state.invoices.size === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - return settleLastInvoice(m) -} - -// the mock burns a melted note 20ms after the melt - a transfer can -// resolve off the TARGET's settlement faster than that, so source-burn -// assertions wait for the mock's own timer instead of racing it -const expectBurned = async (m: Mint, k1: string): Promise => { - for (let i = 0; i < 200 && m.state.noteState(k1) !== 'burned'; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(m.state.noteState(k1)).toBe('burned') -} - -describe('ensureExactAmount', () => { - it('returns an already-exact note untouched, burning nothing', async () => { - const m = await mint() - const k1 = secret('01') - const bearer = await makeBearer(m, k1, 21_000) - - const result = await ensureExactAmount([bearer], 21_000) - expect(noteK1(result.note.url)).toBe(k1) - expect(result.consumed).toEqual([]) - expect(result.change).toBeUndefined() - expect(m.state.noteState(k1)).toBe('outstanding') - }) - - it('split path: carves an exact note off a larger one, with change', async () => { - const m = await mint() - const k1 = secret('02') - const bearer = await makeBearer(m, k1, 21_000) - - const result = await ensureExactAmount([bearer], 5_000) - expect(result.note.amount).toBe(5_000) - expect(result.note.verified).toBe(true) - expect(result.change?.amount).toBe(16_000) - expect(result.consumed.map(b => b.id)).toEqual([bearer.id]) - - // the input is burned; both outputs are live and worth what the result claims - expect(m.state.noteState(k1)).toBe('burned') - const partK1 = noteK1(result.note.url)! - const changeK1 = noteK1(result.change!.url)! - expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) - expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000) - }) - - it('merge path: combines notes summing exactly to the target', async () => { - const m = await mint() - const a = await makeBearer(m, secret('03'), 3_000) - const b = await makeBearer(m, secret('04'), 4_000) - - const result = await ensureExactAmount([a, b], 7_000) - expect(result.note.amount).toBe(7_000) - expect(result.change).toBeUndefined() - expect(result.consumed).toHaveLength(2) - - expect(m.state.noteState(noteK1(a.url)!)).toBe('burned') - expect(m.state.noteState(noteK1(b.url)!)).toBe('burned') - const mergedK1 = noteK1(result.note.url)! - expect((await fetchNoteInfo(noteUrl(m, mergedK1))).maxWithdrawable).toBe(7_000) - }) - - it('merge+split path: splits the target off several notes in one request', async () => { - const m = await mint() - const a = await makeBearer(m, secret('05'), 3_000) - const b = await makeBearer(m, secret('06'), 4_000) - - const result = await ensureExactAmount([a, b], 5_000) - expect(result.note.amount).toBe(5_000) - expect(result.change?.amount).toBe(2_000) - expect(result.consumed).toHaveLength(2) - - const partK1 = noteK1(result.note.url)! - const changeK1 = noteK1(result.change!.url)! - expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) - expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(2_000) - }) - - it('excludes spent and unverified notes from selection', async () => { - const m = await mint() - const spentBearer = await makeBearer(m, secret('07'), 50_000) - const unverified: Bearer = { - ...(await makeBearer(m, secret('08'), 50_000)), - callback: '' - } - await expect( - ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000) - ).rejects.toThrow(/enough/) - }) - - it('refuses an amount no mint can cover', async () => { - const m = await mint() - const bearer = await makeBearer(m, secret('09'), 5_000) - await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/) - }) - - it('rescues the fresh secrets when a split\'s answer is lost (probe: gone)', async () => { - const m = await mint({dropAfterMutation: true}) - const k1 = secret('10') - const bearer = await makeBearer(m, k1, 21_000) - - // the split's response never arrives - but the mutation landed, so the - // probe resolves the ambiguity and the carried secrets are adopted - const result = await ensureExactAmount([bearer], 5_000) - const partK1 = noteK1(result.note.url)! - const changeK1 = noteK1(result.change!.url)! - expect(partK1).not.toBe(k1) - expect(m.state.noteState(k1)).toBe('burned') - expect((await fetchNoteInfo(noteUrl(m, partK1))).maxWithdrawable).toBe(5_000) - expect((await fetchNoteInfo(noteUrl(m, changeK1))).maxWithdrawable).toBe(16_000) - }) - - it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { - const m = await mint({dropAfterMutation: true}) - const k1 = secret('11') - const bearer = await makeBearer(m, k1, 21_000) - - // mutations go to the mint (and land, dropped); every informational GET - // fails, so the probe cannot resolve the ambiguity either - const probeKillingFetch: typeof fetch = (input, init) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.href - : input.url - if (url.includes('/w/cb')) return fetch(input, init) - return Promise.reject(new Error('probe unreachable')) - } - const err = await ensureExactAmount([bearer], 5_000, { - fetch: probeKillingFetch - }).catch((e: unknown) => e) - expect(err).toBeInstanceOf(UncertainOutcomeError) - const outputs = (err as UncertainOutcomeError).possibleOutputs - expect(outputs).toHaveLength(2) - // both possible outputs carry their fresh secrets, at the expected - // amounts - if the split landed, these are the only money left - expect(outputs[0]!.amount).toBe(5_000) - expect(outputs[1]!.amount).toBe(16_000) - expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[0]!.url)!))).maxWithdrawable).toBe(5_000) - expect((await fetchNoteInfo(noteUrl(m, noteK1(outputs[1]!.url)!))).maxWithdrawable).toBe(16_000) - }) -}) - -describe('mint -> claim -> rotate', () => { - it('mints a note from a paid invoice and rotates it immediately', async () => { - const m = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) - expect(prepared.invoice).toMatch(/^lnbc/) - expect(prepared.verifyUrl).toBeTruthy() - expect(prepared.expectedNoteValueMsat).toBe(21_000) - - const preimage = await settleLastInvoice(m) - - const claimed = await claimMintedNote(prepared, { - intervalMs: 10, - intervalCapMs: 50, - maxWaitMs: 5_000 - }) - expect(claimed.rotated).toBe(true) - expect(claimed.note.amount).toBe(21_000) - expect(claimed.note.verified).toBe(true) - - // the preimage IS the initial note secret - after the rotate, that - // secret (which the mint necessarily saw) is worthless, and the - // wallet's fresh secret is the only live note - expect(m.state.noteState(preimage)).toBe('burned') - const k1 = noteK1(claimed.note.url)! - expect(k1).not.toBe(preimage) - expect(m.state.noteState(k1)).toBe('outstanding') - }) - - it('grosses the invoice up for an advertised mint fee', async () => { - const m = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 100_000) - expect(prepared.grossMsat).toBeGreaterThan(100_000) - - const preimage = await settleLastInvoice(m) - // the service's fee math is authoritative - the credited note nets - // roughly what was asked for (within fee-rounding slack), never more - // than the gross - const info = await fetchNoteInfo( - buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat) - ) - expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) - expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) - - // this mock regenerates the proof's pr from the NET amount rather than - // echoing the stored invoice, so the strict same-invoice guard in - // claimMintedNote correctly refuses to bind it - the guard working as - // designed against a mismatched proof - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}) - ).rejects.toThrow(/different invoice/) - }) - - it('times out cleanly when the invoice is never paid', async () => { - const m = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}) - ).rejects.toThrow(/not confirmed/i) - }) -}) - -describe('receiveBearer', () => { - it('verifies an incoming note and rotates it, burning the sender\'s copy', async () => { - const m = await mint() - // the "sender" hands over this URL - they know its secret - const senderK1 = secret('20') - m.state.creditNote(senderK1, 21_000) - - const received = await receiveBearer(noteUrl(m, senderK1, 21_000), []) - expect(received.rotated).toBe(true) - expect(received.note.amount).toBe(21_000) - expect(received.note.verified).toBe(true) - - const newK1 = noteK1(received.note.url)! - expect(newK1).not.toBe(senderK1) - expect(m.state.noteState(senderK1)).toBe('burned') - expect(m.state.noteState(newK1)).toBe('outstanding') - }) - - it('refuses a note the wallet already holds', async () => { - const m = await mint() - const senderK1 = secret('21') - const existing = await makeBearer(m, senderK1, 21_000) - await expect( - receiveBearer(noteUrl(m, senderK1, 21_000), [existing]) - ).rejects.toThrow(/already/) - }) - - it('surfaces a spent note as definitively spent', async () => { - const m = await mint() - const k1 = secret('22') - const bearer = await makeBearer(m, k1, 21_000) - // burn it server-side (a rotate by the "other" copy of the wallet) - const info = await fetchNoteInfo(bearer.url) - await rotateNote(info.callback, k1) - - await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf( - NoteSpentError - ) - }) - - it('surfaces a note locked mid-melt as pending, not as unverified', async () => { - const m = await mint({meltNeverSettles: true}) - const k1 = secret('23') - const bearer = await makeBearer(m, k1, 21_000) - await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') - - await expect(receiveBearer(noteUrl(m, k1, 21_000), [])).rejects.toBeInstanceOf( - PendingNoteError - ) - }) -}) - -describe('payWithBearers', () => { - it('pays a bolt11 invoice by melting an exact note (settled)', async () => { - const m = await mint() - const bearer = await makeBearer(m, secret('30'), 21_000) - - const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { - poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} - }) - expect(result.outcome).toBe('settled') - expect(m.state.noteState(secret('30'))).toBe('burned') - }) - - it('pays a Lightning Address by requesting an invoice first', async () => { - const payer = await mint() - const payee = await mint() - const bearer = await makeBearer(payer, secret('31'), 21_000) - - const result = await payWithBearers( - [bearer], - `mint@127.0.0.1:${payee.port}`, - {amountMsat: 21_000, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}} - ) - expect(result.outcome).toBe('settled') - expect(result.invoice).toMatch(/^lnbc/) - expect(payer.state.noteState(secret('31'))).toBe('burned') - }) - - it('carves the exact amount out of a larger note before melting', async () => { - const m = await mint() - const bearer = await makeBearer(m, secret('32'), 50_000) - - const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { - poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} - }) - expect(result.outcome).toBe('settled') - // the split happened: input burned, the 21000 sat note melted, and the - // change note is tracked for the wallet to keep - expect(m.state.noteState(secret('32'))).toBe('burned') - expect(result.carve.consumed.map(b => b.id)).toEqual([bearer.id]) - expect(result.carve.change?.amount).toBe(29_000) - expect(m.state.noteState(noteK1(result.carve.change!.url)!)).toBe('outstanding') - }) - - it('classifies a failed melt as funds-returned once the note is spendable again', async () => { - const m = await mint({meltAlwaysFails: true}) - const bearer = await makeBearer(m, secret('33'), 21_000) - - const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { - poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} - }) - expect(result.outcome).toBe('failed-funds-returned') - // the mint restored the note, and the classification rotate re-secured - // it (the melt had put its k1 on the wire): the old secret is burned, - // the fresh one in the result is outstanding at the full amount - expect(m.state.noteState(secret('33'))).toBe('burned') - const returnedK1 = noteK1(result.carve.note.url)! - expect(m.state.noteState(returnedK1)).toBe('outstanding') - expect(result.carve.note.amount).toBe(21_000) - }) - - it('classifies a never-settling melt as unknown-still-pending', async () => { - const m = await mint({meltNeverSettles: true}) - const bearer = await makeBearer(m, secret('34'), 21_000) - - const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { - poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} - }) - expect(result.outcome).toBe('unknown-still-pending') - expect(m.state.noteState(secret('34'))).toBe('pending') - }) - - it('rejects an amountless or unreadable invoice instead of guessing', async () => { - const m = await mint() - const bearer = await makeBearer(m, secret('35'), 21_000) - await expect( - payWithBearers([bearer], 'lnbc1pjqrstuvwxyz') - ).rejects.toThrow(/amount/) - await expect( - payWithBearers([bearer], 'not-an-invoice') - ).rejects.toThrow(/not a valid/i) - }) -}) - -describe('transferBetweenMints', () => { - const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} - - it('moves value to another mint: melt at source, claim + rotate at target', async () => { - const source = await mint() - const target = await mint({testHooks: true}) - const k1 = secret('40') - const bearer = await makeBearer(source, k1, 21_000) - - const pending = transferBetweenMints( - [bearer], - 21_000, - `mint@127.0.0.1:${target.port}`, - {poll: fastPoll} - ) - // the transfer is now waiting on the target invoice settling - the - // mock mints can't actually pay each other, so the settle hook plays - // the melt's payment arriving - const preimage = await settleWhenRequested(target) - const result = await pending - - expect(result.outcome).toBe('settled') - expect(result.invoice).toMatch(/^lnbc/) - expect(result.quote).toEqual({ - requestedMsat: 21_000, - grossMsat: 21_000, - targetMintFeeMsat: 0, - sourceMeltFeeReserveMsat: 0 - }) - expect(result.sourceServer).not.toBe(result.targetServer) - await expectBurned(source, k1) - - const claimed = result.mintedAtTarget! - expect(claimed.rotated).toBe(true) - expect(claimed.note.amount).toBe(21_000) - expect(claimed.note.verified).toBe(true) - // the preimage is the secret the target mint necessarily saw - after - // the rotate it is worthless there, and the wallet's fresh secret is - // the only live note - expect(target.state.noteState(preimage)).toBe('burned') - const newK1 = noteK1(claimed.note.url)! - expect(newK1).not.toBe(preimage) - expect(target.state.noteState(newK1)).toBe('outstanding') - }) - - it('refuses an amount no source mint can cover', async () => { - const source = await mint() - const target = await mint() - const k1 = secret('41') - const bearer = await makeBearer(source, k1, 5_000) - - await expect( - transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`) - ).rejects.toThrow(/enough/) - expect(source.state.noteState(k1)).toBe('outstanding') - }) - - it('rejects a transfer onto the mint the notes are already on', async () => { - const m = await mint() - const k1 = secret('42') - const bearer = await makeBearer(m, k1, 21_000) - - await expect( - transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${m.port}`) - ).rejects.toThrow(/different target/) - expect(m.state.noteState(k1)).toBe('outstanding') - }) - - it('moves nothing when the target mint is unreachable', async () => { - const source = await mint() - // not via the mint() helper - a dead server stays out of afterEach - const dead = await createMockMint() - const deadAddress = `mint@127.0.0.1:${dead.port}` - await dead.close() - const k1 = secret('43') - // a note LARGER than the transfer amount, so a premature carve would - // show up here as a burn - const bearer = await makeBearer(source, k1, 50_000) - - await expect( - transferBetweenMints([bearer], 21_000, deadAddress) - ).rejects.toThrow() - expect(source.state.noteState(k1)).toBe('outstanding') - }) - - it('recovers from a melt whose answer was lost once the target invoice settles', async () => { - // unconfirmedMutation: the melt's response confirms nothing, so the - // melt's outcome is uncertain - the target invoice settling is the - // transfer's ground truth - const source = await mint({unconfirmedMutation: true}) - const target = await mint({testHooks: true}) - const k1 = secret('44') - const bearer = await makeBearer(source, k1, 21_000) - - const pending = transferBetweenMints( - [bearer], - 21_000, - `mint@127.0.0.1:${target.port}`, - {poll: fastPoll} - ) - await settleWhenRequested(target) - const result = await pending - - expect(result.outcome).toBe('settled') - // the melt had landed despite its lost answer - the source note is - // gone, and the target note came out the other end - await expectBurned(source, k1) - expect(result.mintedAtTarget?.note.amount).toBe(21_000) - expect(result.mintedAtTarget?.rotated).toBe(true) - }) - - it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => { - // echoWrongK1: the target settles the invoice and reveals the - // preimage, but its informational GET then breaks the claim - const source = await mint() - const target = await mint({testHooks: true, echoWrongK1: true}) - const k1 = secret('45') - const bearer = await makeBearer(source, k1, 21_000) - - const pending = transferBetweenMints( - [bearer], - 21_000, - `mint@127.0.0.1:${target.port}`, - {poll: fastPoll} - ) - const preimage = await settleWhenRequested(target) - const result = await pending - - expect(result.outcome).toBe('settled-claim-failed') - await expectBurned(source, k1) - // the preimage IS the note secret - surfaced unverified, not lost - const note = result.claimMaterial?.note - expect(note).toBeDefined() - expect(noteK1(note!.url)).toBe(preimage) - expect(note!.verified).toBe(false) - expect(note!.amount).toBe(21_000) - expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`) - }) - - it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { - const source = await mint() - const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000}) - const k1 = secret('46') - // covers the requested net exactly - but not the grossed-up invoice - const bearer = await makeBearer(source, k1, 100_000) - - await expect( - transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`) - ).rejects.toThrow(/enough/) - expect(source.state.noteState(k1)).toBe('outstanding') - }) - - it('restores the source note, re-secured, when the melt fails', async () => { - const source = await mint({meltAlwaysFails: true}) - const target = await mint({testHooks: true}) - const k1 = secret('47') - const bearer = await makeBearer(source, k1, 21_000) - - const result = await transferBetweenMints( - [bearer], - 21_000, - `mint@127.0.0.1:${target.port}`, - {poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}} - ) - expect(result.outcome).toBe('failed-funds-returned') - expect(result.mintedAtTarget).toBeUndefined() - // the classification rotate re-secured the note (the melt had put its - // k1 on the wire): the old secret is burned, the fresh one in the - // result is outstanding at the full amount - expect(source.state.noteState(k1)).toBe('burned') - const returnedK1 = noteK1(result.carve.note.url)! - expect(returnedK1).not.toBe(k1) - expect(source.state.noteState(returnedK1)).toBe('outstanding') - expect(result.carve.note.amount).toBe(21_000) - }) -}) +import './ops.carve.cases' +import './ops.mint-receive.cases' +import './ops.pay.cases' +import './ops.transfer.cases' diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts new file mode 100644 index 0000000..98b1166 --- /dev/null +++ b/src/lnurlcash/ops.testHarness.ts @@ -0,0 +1,71 @@ +import {afterEach, expect} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' +import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' + +import type {Bearer} from './types' +import {requiredValue} from './test-utils' + +export type Mint = Awaited> + +const mints: Mint[] = [] + +export const mint = async (options: Parameters[0] = {}): Promise => { + const instance = await createMockMint(options) + mints.push(instance) + return instance +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map((instance) => instance.close())) +}) + +export const secret = (seed: string): string => + bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) + +export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string => + buildNoteUrl(`${instance.url}/w`, k1, amountMsat) + +let fixtureCounter = 0 +export const makeBearer = async ( + instance: Mint, + k1: string, + amountMsat: number, +): Promise => { + instance.state.creditNote(k1, amountMsat) + const url = noteUrl(instance, k1, amountMsat) + const info = await fetchNoteInfo(url) + fixtureCounter += 1 + return { + id: `fixture-${fixtureCounter}`, + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: instance.state.pubkey, + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +export const settleLastInvoice = async (instance: Mint): Promise => { + const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1)) + const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`) + if (!response.ok) throw new Error(`settle hook failed: ${response.status}`) + return requiredValue(instance.state.invoices.get(paymentHash)).preimage +} + +export const settleWhenRequested = async (instance: Mint): Promise => { + for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return settleLastInvoice(instance) +} + +export const expectBurned = async (instance: Mint, k1: string): Promise => { + for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(instance.state.noteState(k1)).toBe('burned') +} diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts new file mode 100644 index 0000000..6cedf05 --- /dev/null +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -0,0 +1,136 @@ +import {describe, expect, it} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {noteK1} from 'lnurlcash-kit' + +import {transferBetweenMints} from './ops' +import {requiredValue} from './test-utils' +import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' + +describe('transferBetweenMints', () => { + const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + + it('moves value to another mint: melt at source, claim + rotate at target', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const k1 = secret('40') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + expect(result.invoice).toMatch(/^lnbc/) + expect(result.quote).toEqual({ + requestedMsat: 21_000, + grossMsat: 21_000, + targetMintFeeMsat: 0, + sourceMeltFeeReserveMsat: 0, + }) + expect(result.sourceServer).not.toBe(result.targetServer) + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + expect(target.state.noteState(preimage)).toBe('burned') + const newK1 = requiredValue(noteK1(claimed.note.url)) + expect(newK1).not.toBe(preimage) + expect(target.state.noteState(newK1)).toBe('outstanding') + }) + + it('refuses an amount no source mint can cover', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('41') + const bearer = await makeBearer(source, k1, 5_000) + await expect( + transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), + ).rejects.toThrow(/enough/) + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('rejects a transfer onto the mint the notes are already on', async () => { + const instance = await mint() + const k1 = secret('42') + const bearer = await makeBearer(instance, k1, 21_000) + await expect( + transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`), + ).rejects.toThrow(/different target/) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('moves nothing when the target mint is unreachable', async () => { + const source = await mint() + const dead = await createMockMint() + const deadAddress = `mint@127.0.0.1:${dead.port}` + await dead.close() + const k1 = secret('43') + const bearer = await makeBearer(source, k1, 50_000) + await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow() + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('recovers from a melt whose answer was lost once the target invoice settles', async () => { + const source = await mint({unconfirmedMutation: true}) + const target = await mint({testHooks: true}) + const k1 = secret('44') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + await expectBurned(source, k1) + expect(result.mintedAtTarget?.note.amount).toBe(21_000) + expect(result.mintedAtTarget?.rotated).toBe(true) + }) + + it('surfaces the claimable preimage note when the claim fails after a settled melt', async () => { + const source = await mint() + const target = await mint({testHooks: true, echoWrongK1: true}) + const k1 = secret('45') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled-claim-failed') + await expectBurned(source, k1) + const note = requiredValue(result.claimMaterial?.note) + expect(noteK1(note.url)).toBe(preimage) + expect(note.verified).toBe(false) + expect(note.amount).toBe(21_000) + expect(result.claimMaterial?.withdrawLink).toContain(`${target.port}`) + }) + + it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { + const source = await mint() + const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000}) + const k1 = secret('46') + const bearer = await makeBearer(source, k1, 100_000) + await expect( + transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`), + ).rejects.toThrow(/enough/) + expect(source.state.noteState(k1)).toBe('outstanding') + }) + + it('restores the source note, re-secured, when the melt fails', async () => { + const source = await mint({meltAlwaysFails: true}) + const target = await mint({testHooks: true}) + const k1 = secret('47') + const bearer = await makeBearer(source, k1, 21_000) + const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('failed-funds-returned') + expect(result.mintedAtTarget).toBeUndefined() + expect(source.state.noteState(k1)).toBe('burned') + const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + expect(returnedK1).not.toBe(k1) + expect(source.state.noteState(returnedK1)).toBe('outstanding') + expect(result.carve.note.amount).toBe(21_000) + }) +}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index b0d800a..1efaa5d 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -41,5 +41,5 @@ export type { TransferOptions, TransferOutcome, TransferQuote, - TransferResult + TransferResult, } from './ops/transfer' diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index fb4429d..780cd30 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -11,11 +11,11 @@ import { serverOf, settleNote, splitNote, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' -import {UncertainOutcomeError} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, UncertainOutcomeError} from './shared' // the changeset stores apply after a mutation: `note`/`change` BEFORE // `consumed` - the mint call already burned every consumed input @@ -49,13 +49,13 @@ export type CarveResult = { export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } const eligible = bearers.filter( - b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url) + (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), ) // per-server greedy pick: smallest notes first until the target is // covered (an exact single-note match short-circuits - no mutation at @@ -68,19 +68,17 @@ export const ensureExactAmount = async ( let pick: Bearer[] | null = null for (const group of byServer.values()) { const sorted = [...group].sort((a, b) => a.amount - b.amount) - const exact = sorted.find(b => b.amount === amountMsat) + const exact = sorted.find((b) => b.amount === amountMsat) const candidate = exact ? [exact] : accumulate(sorted, amountMsat) if (!candidate) continue if (!pick || better(candidate, pick, amountMsat)) pick = candidate } if (!pick) { - throw new Error( - 'No mint holds enough verified, unspent balance to cover that amount.' - ) + throw new Error('No mint holds enough verified, unspent balance to cover that amount.') } const base = pick[0] const total = pick.reduce((sum, b) => sum + b.amount, 0) - const k1s = pick.map(b => requireNoteK1(b.url)) + const k1s = pick.map((b) => requireNoteK1(b.url)) if (pick.length === 1 && total === amountMsat) { // already exact - hand over the note itself, untouched @@ -90,9 +88,9 @@ export const ensureExactAmount = async ( callback: base.callback, amount: base.amount, verified: base.verified, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, }, - consumed: [] + consumed: [], } } @@ -100,13 +98,14 @@ export const ensureExactAmount = async ( // merge path: many notes, exact sum - merge into one, then settle it // (true value + fresh secret; a failed settle leaves an unverified // note a refresh can repair, not a lost secret) + assertFundOwner(options) const merged = await mergeAmbiguitySafe(base, k1s, total, options) const unverified: NewBearer = { url: withNewK1(base.url, merged.k1, total, merged.signature), callback: base.callback, amount: total, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } // a merge whose answer was lost leaves the service in an unknown // state from here - settling fires another mutation (the rotate @@ -115,27 +114,16 @@ export const ensureExactAmount = async ( // refresh repair. if (merged.rescued) return {note: unverified, consumed: pick} try { - const settled = await settleNote( - base.url, - merged.k1, - total, - merged.signature, - options - ) + const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) return { note: { - url: withNewK1( - base.url, - settled.k1, - settled.amountMsat, - settled.signature - ), + url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), callback: settled.callback, amount: settled.amountMsat, verified: true, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, }, - consumed: pick + consumed: pick, } } catch { return {note: unverified, consumed: pick} @@ -154,6 +142,7 @@ export const ensureExactAmount = async ( // state, so the change is NOT settled (that would fire another mutation // at it, whose own ambiguous failure would strand the rescued secret) let rescued = false + assertFundOwner(options) try { const parts = await splitNote(base.callback, k1s, amountMsat, options) partK1 = parts.k1 @@ -178,16 +167,16 @@ export const ensureExactAmount = async ( callback: base.callback, amount: amountMsat, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, }, { url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), callback: base.callback, amount: total - amountMsat, verified: false, - mintPubkey: base.mintPubkey - } - ] + mintPubkey: base.mintPubkey, + }, + ], ) } // 'gone': the burn landed - the carried secrets are the only money @@ -200,7 +189,7 @@ export const ensureExactAmount = async ( callback: base.callback, amount: amountMsat, verified: partVerified, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } // settleNote: the change may be worth less than total - amount if this // mint charges split fees (LUD-25 deducts them from change, never the @@ -211,7 +200,7 @@ export const ensureExactAmount = async ( callback: base.callback, amount: total - amountMsat, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } if (!rescued) { try { @@ -220,22 +209,18 @@ export const ensureExactAmount = async ( changeK1, total - amountMsat, changeSignature, - options + options, ) change = { - url: withNewK1( - base.url, - settled.k1, - settled.amountMsat, - settled.signature - ), + url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), callback: settled.callback, amount: settled.amountMsat, verified: true, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } - } catch { + } catch (error) { // settle is best-effort - the unverified change above is still tracked + if (!(error instanceof Error)) throw error } } return {note, change, consumed: pick} @@ -270,7 +255,7 @@ const mergeAmbiguitySafe = async ( base: Bearer, k1s: string[], total: number, - options: LnurlcashOptions + options: FundOperationOptions, ): Promise<{k1: string; signature?: string; rescued: boolean}> => { try { const merged = await mergeNotes(base.callback, k1s, options) @@ -288,9 +273,9 @@ const mergeAmbiguitySafe = async ( callback: base.callback, amount: total, verified: false, - mintPubkey: base.mintPubkey - } - ] + mintPubkey: base.mintPubkey, + }, + ], ) } // 'gone': the burn landed - the carried secret is the only money left diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 27a802d..82f558c 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -19,13 +19,14 @@ import { rotateNote, sameInvoice, serverOf, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions, MintAddressInfo} from 'lnurlcash-kit' +import type {MintAddressInfo} from 'lnurlcash-kit' import type {NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type PreparedMint = { invoice: string @@ -52,7 +53,7 @@ export type PreparedMint = { export const prepareMint = async ( mintInput: string, amountMsat: number, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -69,21 +70,20 @@ export const prepareMint = async ( try { nodeInfo = await fetchMintAddress(addressUrl, options) payUrl = nodeInfo.payLink - } catch { + } catch (error) { // no mint-address support here - proceed with just the guess + if (!(error instanceof Error)) throw error } } const info = await fetchPayRequest(payUrl, options) if (!info.withdrawLink) { - throw new Error( - 'This payRequest does not advertise lnurlcash minting (no withdrawLink).' - ) + throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') } const grossMsat = ceilMsatToSat( - info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat + info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat, ) if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { - throw new Error('Amount is outside this mint\'s sendable range.') + throw new Error("Amount is outside this mint's sendable range.") } const invoice = await requestInvoice(info.callback, grossMsat, options) const prepared: PreparedMint = { @@ -95,7 +95,7 @@ export const prepareMint = async ( withdrawLink: info.withdrawLink, server: serverOf(payUrl), username: lightningAddressUsername(payUrl), - nodeInfo + nodeInfo, } if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey return prepared @@ -123,11 +123,11 @@ export type ClaimedNote = { export const claimMintedNote = async ( prepared: PreparedMint, poll: PollOptions = {}, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!prepared.verifyUrl) { throw new Error( - 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.' + 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', ) } const verifyUrl = prepared.verifyUrl @@ -135,15 +135,11 @@ export const claimMintedNote = async ( // a settled report only means this wallet's invoice was paid if it's for // the invoice this wallet actually requested if (!sameInvoice(result.pr, prepared.invoice)) { - throw new Error( - "The service's verify response is for a different invoice than requested." - ) + throw new Error("The service's verify response is for a different invoice than requested.") } const preimage = result.preimage if (!preimage || !isPreimage(preimage)) { - throw new Error( - 'The payment settled but the service did not reveal the preimage.' - ) + throw new Error('The payment settled but the service did not reveal the preimage.') } return claimFromPreimage(prepared, preimage, options) } @@ -168,15 +164,12 @@ export type ClaimTarget = { export const claimFromPreimage = async ( claim: ClaimTarget, preimage: string, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { // declare the invoiced amount (a claim - not yet confirmed) so the note // is self-describing even before the verifying GET below - const declaredUrl = buildNoteUrl( - claim.withdrawLink, - preimage, - claim.expectedNoteValueMsat - ) + const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat) + assertFundOwner(options) // the service's maxWithdrawable is authoritative - SERVICE's own fee // math might not match this wallet's estimate, and the note is worth // exactly maxWithdrawable regardless @@ -186,7 +179,7 @@ export const claimFromPreimage = async ( url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), callback: noteInfo.callback, amount: noteInfo.maxWithdrawable, - verified: true + verified: true, } if (mintPubkey) base.mintPubkey = mintPubkey @@ -196,12 +189,7 @@ export const claimFromPreimage = async ( let rotationError: string | undefined try { const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options) - url = withNewK1( - declaredUrl, - rotatedNote.k1, - noteInfo.maxWithdrawable, - rotatedNote.signature - ) + url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature) } catch (err) { rotated = false if (err instanceof AmbiguousMutationError) { @@ -210,24 +198,16 @@ export const claimFromPreimage = async ( const outcome = await probeBurnedNote(declaredUrl, options) if (outcome === 'gone') { // the burn landed - adopt the fresh secret as the note - url = withNewK1( - declaredUrl, - err.newSecrets[0], - noteInfo.maxWithdrawable - ) + url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable) rotated = true } else if (outcome === 'unknown') { // can't tell: the preimage note is returned either way - the // possible rotated copy goes alongside it, both refreshable possibleCopy = { - url: withNewK1( - declaredUrl, - err.newSecrets[0], - noteInfo.maxWithdrawable - ), + url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable), callback: noteInfo.callback, amount: noteInfo.maxWithdrawable, - verified: false + verified: false, } if (mintPubkey) possibleCopy.mintPubkey = mintPubkey rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index c5fe5e7..c1fd878 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -16,14 +16,15 @@ import { resolveLnurlInput, rotateNote, sameInvoice, - withNewK1 + withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type PayOutcome = | 'settled' @@ -54,6 +55,7 @@ export type PayOptions = { poll?: PollOptions // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions + assertOwner?: () => void } // A melt's resolved promise only means the payment is in flight; the @@ -69,9 +71,9 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}}: PayOptions = {} + {amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {}, ): Promise => { - const options = kit + const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit let invoice: string let amount: number const trimmed = input.trim() @@ -79,7 +81,7 @@ export const payWithBearers = async ( const decoded = decodeBolt11AmountMsat(trimmed) if (decoded === null || decoded <= 0) { throw new Error( - 'Could not read this invoice\'s amount - amount-less invoices are not supported.' + "Could not read this invoice's amount - amount-less invoices are not supported.", ) } invoice = trimmed @@ -97,7 +99,7 @@ export const payWithBearers = async ( } const info = await fetchPayRequest(url, options) if (amountMsat < info.minSendable || amountMsat > info.maxSendable) { - throw new Error('Amount is outside the payee\'s sendable range.') + throw new Error("Amount is outside the payee's sendable range.") } const result = await requestInvoice(info.callback, amountMsat, options) invoice = result.pr @@ -106,6 +108,7 @@ export const payWithBearers = async ( const carve = await ensureExactAmount(bearers, amount, options) const k1 = requireNoteK1(carve.note.url) + if (carve.consumed.length === 0) assertFundOwner(options) let melt: MeltResult try { melt = await meltNote(carve.note.callback, k1, invoice, options) @@ -136,11 +139,7 @@ export const payWithBearers = async ( // either way (a service regenerating synthetic prs in proofs) and is // tolerated. const proofAmount = decodeBolt11AmountMsat(proof.pr) - if ( - !sameInvoice(proof.pr, invoice) && - proofAmount !== null && - proofAmount !== amount - ) { + if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) { return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} } return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} @@ -158,12 +157,12 @@ export const payWithBearers = async ( ...carve, note: { ...carve.note, - url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature) - } + url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), + }, }, invoice, amountMsat: amount, - verifyUrl + verifyUrl, } } catch (err) { if (err instanceof PendingNoteError) { @@ -183,7 +182,7 @@ export const payWithBearers = async ( url: withNewK1(carve.note.url, err.newSecrets[0], amount), callback: carve.note.callback, amount, - verified: false + verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey return { @@ -192,7 +191,7 @@ export const payWithBearers = async ( invoice, amountMsat: amount, verifyUrl, - rescuedNote + rescuedNote, } } return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index c151c61..13cbe87 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -10,12 +10,13 @@ import { NoteUnknownError, PendingNoteError, probeBurnedNote, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' import type {ClaimedNote} from './mint' +import type {FundOperationOptions} from './shared' +import {assertFundOwner} from './shared' // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, @@ -23,13 +24,14 @@ import type {ClaimedNote} from './mint' export const receiveBearer = async ( input: string, existing: Bearer[], - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { return {note, rotated: false} } try { + assertFundOwner(options) const rotatedUrl = await secureReceivedNote(note) return {note: {...note, url: rotatedUrl}, rotated: true} } catch (err) { @@ -49,9 +51,9 @@ export const receiveBearer = async ( return { note: { ...note, - url: withNewK1(note.url, err.newSecrets[0], note.amount) + url: withNewK1(note.url, err.newSecrets[0], note.amount), }, - rotated: true + rotated: true, } } if (outcome === 'unknown') { @@ -59,14 +61,14 @@ export const receiveBearer = async ( url: withNewK1(note.url, err.newSecrets[0], note.amount), callback: note.callback, amount: note.amount, - verified: false + verified: false, } if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { note, rotated: false, possibleCopy, - rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` + rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, } } } @@ -75,7 +77,7 @@ export const receiveBearer = async ( return { note, rotated: false, - rotationError: err instanceof Error ? err.message : String(err) + rotationError: err instanceof Error ? err.message : String(err), } } } diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index 45ba0e3..b93fade 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -19,45 +19,102 @@ export class UncertainOutcomeError extends Error { } } +// the wait was interrupted from outside (service shutdown) - distinct +// from budget exhaustion so the caller can treat it as normal teardown +export class PollAbortedError extends Error { + constructor() { + super('The wait was interrupted by shutdown.') + this.name = 'PollAbortedError' + } +} + +export type FundOperationOptions = LnurlcashOptions & { + readonly assertOwner?: () => void +} + +export const assertFundOwner = (options: FundOperationOptions): void => { + options.assertOwner?.() +} + export type PollOptions = { // first delay between checks (doubles each round up to intervalCapMs) intervalMs?: number intervalCapMs?: number // total budget before giving up maxWaitMs?: number + // aborts the wait promptly (shutdown). Only the WAIT is interruptible: + // callers pass this for work whose observation phase may outlive the + // caller - once pollVerifyUntilSettled has returned, the signal no + // longer reaches anything + signal?: AbortSignal } -const DEFAULT_POLL: Required = { +const DEFAULT_POLL: Required> = { intervalMs: 1000, intervalCapMs: 5000, - maxWaitMs: 120_000 + maxWaitMs: 120_000, } -const sleep = (ms: number): Promise => - new Promise(resolve => setTimeout(resolve, ms)) +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +// a sleep that ends immediately on abort instead of riding out its timer +const abortableSleep = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(new PollAbortedError()) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + if (signal.aborted) { + clearTimeout(timer) + reject(new PollAbortedError()) + return + } + signal.addEventListener('abort', onAbort, {once: true}) + }) // polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // backoff, inside a total time budget. A single failed check isn't fatal - // the next round tries again. Returns the settled VerifyResult; throws on -// budget exhaustion. +// budget exhaustion, or PollAbortedError when the caller's signal fires +// (a hung fetch is interrupted too: the signal is bound into the request). export const pollVerifyUntilSettled = async ( verifyUrl: string, poll: PollOptions, - options: LnurlcashOptions + options: LnurlcashOptions, ): Promise => { - const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll} + const {intervalMs, intervalCapMs, maxWaitMs, signal} = { + ...DEFAULT_POLL, + ...poll, + } + const fetchOptions: LnurlcashOptions = signal + ? { + ...options, + fetch: (input, init) => { + const base = options.fetch ?? globalThis.fetch + return base(input, {...init, signal}) + }, + } + : options const deadline = Date.now() + maxWaitMs let delay = intervalMs let lastError: unknown = null while (Date.now() < deadline) { + if (signal?.aborted) throw new PollAbortedError() try { - const result = await fetchInvoiceVerification(verifyUrl, options) + const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) if (result.settled) return result lastError = null } catch (err) { + // the signal's own AbortError lands here on an interrupted fetch + if (signal?.aborted) throw new PollAbortedError() lastError = err } - await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) + if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal) + else await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) delay = Math.min(delay * 2, intervalCapMs) } if (lastError instanceof Error) { diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index 793976e..db49837 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -24,7 +24,7 @@ import { rotateNote, sameInvoice, serverOf, - withNewK1 + withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -33,7 +33,8 @@ import {ensureExactAmount} from './carve' import type {ClaimedNote} from './mint' import {claimFromPreimage, prepareMint} from './mint' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type TransferOutcome = // the melt settled and the target note was claimed (and rotated) @@ -102,15 +103,16 @@ export type TransferOptions = { poll?: PollOptions // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions + assertOwner?: () => void } export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}}: TransferOptions = {} + {poll = {}, kit = {}, assertOwner}: TransferOptions = {}, ): Promise => { - const options = kit + const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } @@ -120,7 +122,7 @@ export const transferBetweenMints = async ( const prepared = await prepareMint(targetMint, amountMsat, options) if (!prepared.verifyUrl) { throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.' + 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', ) } const verifyUrl = prepared.verifyUrl @@ -129,19 +131,17 @@ export const transferBetweenMints = async ( // goes nowhere (melt pays an invoice; the same mint's invoice just // re-mints into itself, paying fees for nothing) const eligible = bearers.filter( - b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url) + (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), ) - const offTarget = eligible.filter(b => serverOf(b.url) !== targetServer) + const offTarget = eligible.filter((b) => serverOf(b.url) !== targetServer) if (eligible.length > 0 && offTarget.length === 0) { - throw new Error( - 'That\'s the mint these notes are already on - pick a different target.' - ) + throw new Error("That's the mint these notes are already on - pick a different target.") } const quote: TransferQuote = { requestedMsat: amountMsat, grossMsat: prepared.grossMsat, targetMintFeeMsat: prepared.grossMsat - amountMsat, - sourceMeltFeeReserveMsat: 0 + sourceMeltFeeReserveMsat: 0, } // carving burns its inputs server-side, so it happens only once the // target is known good and the invoice exists @@ -151,12 +151,13 @@ export const transferBetweenMints = async ( const claimMaterial: TransferClaimMaterial = { invoice, withdrawLink: prepared.withdrawLink, - expectedNoteValueMsat: prepared.expectedNoteValueMsat + expectedNoteValueMsat: prepared.expectedNoteValueMsat, } // from here on the carve's fresh secrets exist only in this result - the // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) + if (carve.consumed.length === 0) assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) } catch (err) { @@ -197,20 +198,16 @@ export const transferBetweenMints = async ( // the melt settled - the money is now the preimage note at the // target and nowhere else; surface it rather than lose it const note: NewBearer = { - url: buildNoteUrl( - prepared.withdrawLink, - proof.preimage, - prepared.expectedNoteValueMsat - ), + url: buildNoteUrl(prepared.withdrawLink, proof.preimage, prepared.expectedNoteValueMsat), callback: '', amount: prepared.expectedNoteValueMsat, - verified: false + verified: false, } if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey return { ...base, outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note} + claimMaterial: {...claimMaterial, note}, } } } catch { @@ -229,14 +226,9 @@ export const transferBetweenMints = async ( ...carve, note: { ...carve.note, - url: withNewK1( - carve.note.url, - rotated.k1, - carve.note.amount, - rotated.signature - ) - } - } + url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature), + }, + }, } } catch (err) { if (err instanceof PendingNoteError || err instanceof NoteSpentError) { @@ -251,7 +243,7 @@ export const transferBetweenMints = async ( url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), callback: carve.note.callback, amount: carve.note.amount, - verified: false + verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey return {...base, outcome: 'failed-funds-returned', rescuedNote} diff --git a/src/lnurlcash/passkeyOwnership.ts b/src/lnurlcash/passkeyOwnership.ts new file mode 100644 index 0000000..58337e2 --- /dev/null +++ b/src/lnurlcash/passkeyOwnership.ts @@ -0,0 +1,15 @@ +// Legacy passkey slots may be adopted only after another unlock path has +// proven and stamped the saved wallet owner. The linking key is checked +// against that marker before markerless slots are changed under the lock. + +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots' +import {withStorageLock} from './storageLock' + +export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Legacy passkey migration requires a proven owner.') + } + return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId)) +} diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index abdb9c6..c59f1de 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -16,15 +16,11 @@ const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1' export const derivePasskeyWrapKey = async ( prfOutput: Uint8Array, - hkdfSalt: Uint8Array + hkdfSalt: Uint8Array, ): Promise => { - const baseKey = await crypto.subtle.importKey( - 'raw', - new Uint8Array(prfOutput), - 'HKDF', - false, - ['deriveKey'] - ) + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(prfOutput), 'HKDF', false, [ + 'deriveKey', + ]) return crypto.subtle.deriveKey( // the copies pin the TS type to Uint8Array - hexToBytes // returns Uint8Array, which BufferSource rejects @@ -32,33 +28,29 @@ export const derivePasskeyWrapKey = async ( name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(hkdfSalt), - info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)) + info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)), }, baseKey, {name: 'AES-GCM', length: 256}, false, - ['encrypt', 'decrypt'] + ['encrypt', 'decrypt'], ) } export const wrapLinkingKeyWithPrf = async ( prfOutput: Uint8Array, - linkingKey: Uint8Array + linkingKey: Uint8Array, ): Promise => { const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - {name: 'AES-GCM', iv}, - wrapKey, - new Uint8Array(linkingKey) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, new Uint8Array(linkingKey)), ) return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - wrappedKey: bytesToHex(ciphertext) + wrappedKey: bytesToHex(ciphertext), } } @@ -66,16 +58,13 @@ export const wrapLinkingKeyWithPrf = async ( // i.e. a different passkey than the one that created the slot export const unwrapLinkingKeyWithPrf = async ( prfOutput: Uint8Array, - wrap: PasskeyWrap + wrap: PasskeyWrap, ): Promise => { - const wrapKey = await derivePasskeyWrapKey( - prfOutput, - hexToBytes(wrap.hkdfSalt) - ) + const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt)) const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))}, wrapKey, - new Uint8Array(hexToBytes(wrap.wrappedKey)) + new Uint8Array(hexToBytes(wrap.wrappedKey)), ) return new Uint8Array(plaintext) } diff --git a/src/lnurlcash/passkeys.crypto.cases.ts b/src/lnurlcash/passkeys.crypto.cases.ts new file mode 100644 index 0000000..5cbd799 --- /dev/null +++ b/src/lnurlcash/passkeys.crypto.cases.ts @@ -0,0 +1,173 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('pure wrap crypto', () => { + it('round-trips a linking key through a PRF-derived wrap', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + }) + + it('rejects unwrap with a different PRF output', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() + }) + + it('rejects unwrap with a tampered HKDF salt', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + await expect( + unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}), + ).rejects.toThrow() + }) + + it('rejects unwrap with a tampered ciphertext', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const flipped = `${wrap.wrappedKey.slice(0, -2)}${wrap.wrappedKey.endsWith('00') ? '01' : '00'}` + await expect( + unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}), + ).rejects.toThrow() + }) + + it('derives wrap keys deterministically from the same PRF output and salt', async () => { + const salt = new Uint8Array(16).fill(1) + const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt) + const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt) + const record = await encryptRecord(a, {v: 1}) + await expect(decryptRecord(b, record)).resolves.toEqual({v: 1}) + }) +}) diff --git a/src/lnurlcash/passkeys.multiple.cases.ts b/src/lnurlcash/passkeys.multiple.cases.ts new file mode 100644 index 0000000..f948226 --- /dev/null +++ b/src/lnurlcash/passkeys.multiple.cases.ts @@ -0,0 +1,178 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('multiple passkeys', () => { + it('keeps slots independent: each passkey unlocks the same key', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop, + name: 'laptop', + }) + const phoneSlot = await registerPasskey(LINKING_KEY, { + credentials: phone, + name: 'phone', + }) + expect(readPasskeySlots()).toHaveLength(2) + // independent wrap keys: same plaintext, different salts and ciphertexts + expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) + expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey) + + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + }) + + it('removePasskey drops exactly one slot and leaves the rest working', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop, + }) + await registerPasskey(LINKING_KEY, {credentials: phone}) + + await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true) + expect(readPasskeySlots()).toHaveLength(1) + + // the removed passkey no longer matches any offered credential + await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow('cancelled') + // the survivor is unaffected + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + // removing again is a no-op + await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false) + }) +}) diff --git a/src/lnurlcash/passkeys.ownership-a.cases.ts b/src/lnurlcash/passkeys.ownership-a.cases.ts new file mode 100644 index 0000000..e9c1ac5 --- /dev/null +++ b/src/lnurlcash/passkeys.ownership-a.cases.ts @@ -0,0 +1,243 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('slot ownership', () => { + it('binds a new slot to the proven saved wallet owner', async () => { + // Given the saved wallet has a canonical owner marker + const auth = new FakeAuthenticator() + + // When its linking key registers a passkey + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + + // Then the slot carries that same canonical owner + expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('filters foreign, malformed, and unowned slots from reads and availability', async () => { + // Given one valid current-owner slot plus copies with untrusted owners + const auth = new FakeAuthenticator() + const current = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = { + ...current, + credentialId: '11'.repeat(16), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + } + const malformed = { + ...current, + credentialId: '22'.repeat(16), + ownerId: 'not-an-owner', + } + const unowned = { + credentialId: '33'.repeat(16), + hkdfSalt: current.hkdfSalt, + iv: current.iv, + wrappedKey: current.wrappedKey, + createdAt: current.createdAt, + } + writeRawSlots([foreign, malformed, unowned]) + + // When the current wallet asks for its slots + const slots = readPasskeySlots() + + // Then no foreign or unproven slot is exposed + expect(slots).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + }) + + it('does not offer markerless slots for passkey-first unlock', async () => { + // Given a legacy slot and a saved key with no proven owner marker + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + removeSavedOwnerMarker() + + // When passkey unlock is attempted before another proof path + const attempt = unlockWithPasskey({credentials: auth}) + + // Then it fails before asking the authenticator + await expect(attempt).rejects.toThrow('No passkeys') + expect(auth.getCalls).toBe(0) + expect(hasPasskeySlots()).toBe(false) + }) + + it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => { + // Given markerless residue from the old wallet + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + + // When a different wallet is installed with its canonical owner + await saveLinkingKey(OTHER_LINKING_KEY) + + // Then the residue stays unowned and unavailable to the new wallet + expect(readPasskeySlots()).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + expect(readRawSlots()).toEqual(legacy) + }) + + it('adopts legacy slots only after the saved wallet owner is proven', async () => { + // Given a legacy encrypted wallet and its markerless passkey slot + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + await saveLinkingKey(LINKING_KEY, 'correct horse') + removeSavedOwnerMarker() + + // When migration is attempted before and then after password proof + await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') + const provenKey = await decryptSavedLinkingKey('correct horse') + ensureSavedKeyOwner(provenKey) + await migrateLegacyPasskeySlots(provenKey) + + // Then the same slot is stamped once for that proven owner and unlocks + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(readPasskeySlots()).toHaveLength(1) + expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + await expect(unlockWithPasskey({credentials: auth})).resolves.toEqual(LINKING_KEY) + }) +}) diff --git a/src/lnurlcash/passkeys.ownership-b.cases.ts b/src/lnurlcash/passkeys.ownership-b.cases.ts new file mode 100644 index 0000000..6df9811 --- /dev/null +++ b/src/lnurlcash/passkeys.ownership-b.cases.ts @@ -0,0 +1,228 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('slot ownership (continued)', () => { + it('rejects an unwrapped key that does not match the saved proven owner', async () => { + // Given a current-owner slot whose authenticated wrap was replaced with + // a valid wrap of another wallet key + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { + credentials: auth, + }) + const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY) + writeRawSlots([{...slot, ...foreignWrap}]) + + // When the authenticator successfully unwraps that foreign key + const attempt = unlockWithPasskey({credentials: auth}) + + // Then owner validation rejects it before activation can receive it + await expect(attempt).rejects.toThrow('different wallet') + }) + + it('rejects a stale unlock when the saved owner changes during the ceremony', async () => { + // Given an authenticator that replaces the saved wallet before returning + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const stale: PasskeyCredentials = { + create: auth.create, + get: async (options) => { + const credential = await auth.get(options) + await saveLinkingKey(OTHER_LINKING_KEY) + return credential + }, + } + + // When the old wallet's ceremony completes after replacement + const attempt = unlockWithPasskey({credentials: stale}) + + // Then the old linking key is never returned for activation + await expect(attempt).rejects.toThrow('different wallet') + }) + + it('does not adopt a slot carrying a malformed owner marker', async () => { + // Given an otherwise valid slot whose owner claim is malformed + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const malformed = {...slot, ownerId: 'not-an-owner'} + writeRawSlots([malformed]) + + // When the current owner performs the legacy migration + await migrateLegacyPasskeySlots(LINKING_KEY) + + // Then only truly markerless legacy slots are eligible + expect(readPasskeySlots()).toEqual([]) + expect(readRawSlots()).toEqual([malformed]) + }) + + it('cannot remove a foreign-owner slot', async () => { + // Given a slot owned by another wallet remains in shared storage + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)} + writeRawSlots([foreign]) + + // When the current owner asks to remove that credential id + const removed = await removePasskey(slot.credentialId) + + // Then the foreign record is untouched + expect(removed).toBe(false) + expect(readRawSlots()).toEqual([foreign]) + }) + + it('rewraps only current-owner slots and preserves foreign slots', async () => { + // Given current and foreign slots share storage + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = { + ...slot, + credentialId: '44'.repeat(16), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + } + writeRawSlots([slot, foreign]) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { + credentials: auth, + }) + + // When the current wallet rewraps its slots + await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]])) + + // Then the foreign slot did not require output and remains byte-identical + expect(readRawSlots()).toContainEqual(foreign) + }) +}) diff --git a/src/lnurlcash/passkeys.registration.cases.ts b/src/lnurlcash/passkeys.registration.cases.ts new file mode 100644 index 0000000..8e7065d --- /dev/null +++ b/src/lnurlcash/passkeys.registration.cases.ts @@ -0,0 +1,245 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('registration and unlock', () => { + it('registers a passkey and unlocks the same linking key', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, { + credentials: auth, + name: 'laptop', + }) + expect(slot.name).toBe('laptop') + expect(readPasskeySlots()).toEqual([slot]) + expect(hasPasskeySlots()).toBe(true) + + const unwrapped = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + }) + + it('never stores the linking key in the clear', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const raw = localStorage.getItem('sattle_passkey_slots') + expect(raw).toBeTruthy() + expect(raw).not.toContain(bytesToHex(LINKING_KEY)) + }) + + it('yields the same key material unlock(password) yields', async () => { + const linkingKey = crypto.getRandomValues(new Uint8Array(32)) + await saveLinkingKey(linkingKey, 'correct horse') + const auth = new FakeAuthenticator() + await registerPasskey(linkingKey, {credentials: auth}) + + const viaPassword = await decryptSavedLinkingKey('correct horse') + const viaPasskey = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword)) + + // and the practical consequence: a bearer record encrypted after a + // password unlock decrypts after a passkey unlock + const passwordAes = await deriveBearerAesKey(viaPassword) + const record = await encryptRecord(passwordAes, {note: 'still readable'}) + const passkeyAes = await deriveBearerAesKey(viaPasskey) + await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ + note: 'still readable', + }) + }) + + it('falls back to a get ceremony when create only reports prf.enabled', async () => { + const auth = new FakeAuthenticator() + auth.prfResultsOnCreate = false + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + expect(auth.getCalls).toBe(1) + const unwrapped = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId) + }) + + it('refuses registration when the authenticator has no PRF support', async () => { + const auth = new FakeAuthenticator() + auth.supportsPrf = false + await expect(registerPasskey(LINKING_KEY, {credentials: auth})).rejects.toThrow('PRF') + expect(hasPasskeySlots()).toBe(false) + }) + + it('throws on a cancelled registration ceremony', async () => { + const cancelled: PasskeyCredentials = { + create: async () => null, + get: async () => null, + } + await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow( + 'cancelled', + ) + expect(hasPasskeySlots()).toBe(false) + }) + + it('throws before any ceremony when no passkeys are registered', async () => { + const auth = new FakeAuthenticator() + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('No passkeys') + expect(auth.getCalls).toBe(0) + }) + + it('rejects unlock when the passkey returns no PRF secret', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + auth.prfResultsOnGet = false + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('PRF secret') + }) + + it('rejects unlock when the ceremony yields an unregistered credential', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const rogue: PasskeyCredentials = { + create: async () => null, + get: async () => ({ + type: 'public-key', + rawId: crypto.getRandomValues(new Uint8Array(16)), + getClientExtensionResults: () => ({ + prf: {enabled: true, results: {first: new Uint8Array(32)}}, + }), + }), + } + await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow('not registered') + }) + + it('rejects unlock after the authenticator secret changed underneath the slot', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + auth.rotateSecret(slot.credentialId) + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow() + }) +}) diff --git a/src/lnurlcash/passkeys.rewrap.cases.ts b/src/lnurlcash/passkeys.rewrap.cases.ts new file mode 100644 index 0000000..db5e833 --- /dev/null +++ b/src/lnurlcash/passkeys.rewrap.cases.ts @@ -0,0 +1,188 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('rewrap for the current owner', () => { + it('refreshes every current-owner wrap all-or-nothing', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop, + }) + const phoneSlot = await registerPasskey(LINKING_KEY, { + credentials: phone, + }) + + // partial coverage aborts before writing + const partial = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop, + }), + ], + ]) + await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap') + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + + // full coverage refreshes both wraps around the same proven owner key + const fresh = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop, + }), + ], + [ + phoneSlot.credentialId, + await getPasskeyPrfOutput(phoneSlot.credentialId, { + credentials: phone, + }), + ], + ]) + await rewrapAllSlots(LINKING_KEY, fresh) + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + expect(readPasskeySlots()[0]?.wrappedKey).not.toBe(laptopSlot.wrappedKey) + // credential ids and labels survive the re-wrap + expect( + readPasskeySlots() + .map((s) => s.credentialId) + .sort(), + ).toEqual([laptopSlot.credentialId, phoneSlot.credentialId].sort()) + }) +}) diff --git a/src/lnurlcash/passkeys.storage.cases.ts b/src/lnurlcash/passkeys.storage.cases.ts new file mode 100644 index 0000000..3d6f3ca --- /dev/null +++ b/src/lnurlcash/passkeys.storage.cases.ts @@ -0,0 +1,156 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('slot storage hygiene', () => { + it('drops malformed entries instead of throwing', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([...stored, {credentialId: 'zz', hkdfSalt: 1}, 'garbage', null]), + ) + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('treats unparseable storage as empty', () => { + localStorage.setItem('sattle_passkey_slots', '{not json') + expect(readPasskeySlots()).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + }) +}) diff --git a/src/lnurlcash/passkeys.support.cases.ts b/src/lnurlcash/passkeys.support.cases.ts new file mode 100644 index 0000000..4ee6a30 --- /dev/null +++ b/src/lnurlcash/passkeys.support.cases.ts @@ -0,0 +1,180 @@ +// Passkey engine tests. The WebAuthn ceremony is faked by an injected +// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - +// the real extension's exact contract: deterministic per credential+salt, +// unguessable without the authenticator. Everything except a real +// authenticator's touch is covered here. + +import {beforeEach, describe, expect, it} from 'vitest' +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' + +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + hasPasskeySlots, + migrateLegacyPasskeySlots, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// Fake platform authenticator: holds credentials (id -> secret), evaluates +// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks +// found in the wild: PRF unsupported, results only on get, results never. +class FakeAuthenticator implements PasskeyCredentials { + // id typed Uint8Array: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + this.createCalls += 1 + const salt = options?.publicKey?.extensions?.prf?.eval?.first + const id = crypto.getRandomValues(new Uint8Array(16)) + const secret = crypto.getRandomValues(new Uint8Array(32)) + this.held.set(bytesToHex(id), {id, secret}) + return { + type: 'public-key', + rawId: id, + getClientExtensionResults: () => ({ + prf: + this.supportsPrf && salt + ? { + enabled: true, + ...(this.prfResultsOnCreate ? {results: {first: this.prf(secret, salt)}} : {}), + } + : {}, + }), + } + } + + // answers with the first allowed credential it holds, like a real + // authenticator picking among allowCredentials; null when it holds none + get = async (options?: CredentialRequestOptions): Promise => { + this.getCalls += 1 + const pk = options?.publicKey + const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) + const match = allowed.find((hex) => this.held.has(hex)) + const held = match ? this.held.get(match) : undefined + if (!held) return null + const salt = pk?.extensions?.prf?.eval?.first + return { + type: 'public-key', + rawId: held.id, + getClientExtensionResults: () => ({ + prf: + salt && this.prfResultsOnGet + ? {enabled: true, results: {first: this.prf(held.secret, salt)}} + : {}, + }), + } + } + + // simulates the passkey's secret changing underneath a slot (credential + // re-created on the authenticator while the slot stayed behind) + rotateSecret = (credentialId: string): void => { + const held = this.held.get(credentialId) + if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) + } + + private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): void => { + localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) +} + +const removeSavedOwnerMarker = (): void => { + const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + delete stored.ownerId + delete stored.version + localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) +} + +beforeEach(async () => { + stubLocalStorage() + await saveLinkingKey(LINKING_KEY) +}) + +describe('passkeySupported', () => { + it('is false without a PublicKeyCredential probe', async () => { + // node test env has no PublicKeyCredential global: the default lookup + // finds nothing + await expect(passkeySupported()).resolves.toBe(false) + }) + + it('is false without a user-verifying platform authenticator', async () => { + await expect( + passkeySupported({ + isUserVerifyingPlatformAuthenticatorAvailable: async () => false, + getClientCapabilities: async () => ({'extension:prf': true}), + }), + ).resolves.toBe(false) + }) + + it('checks extension:prf when client capabilities are available', async () => { + const platform = { + isUserVerifyingPlatformAuthenticatorAvailable: async () => true, + } + await expect( + passkeySupported({ + ...platform, + getClientCapabilities: async () => ({'extension:prf': true}), + }), + ).resolves.toBe(true) + await expect( + passkeySupported({ + ...platform, + getClientCapabilities: async () => ({'extension:prf': false}), + }), + ).resolves.toBe(false) + }) + + it('is optimistic when capabilities cannot be pre-detected', async () => { + await expect( + passkeySupported({ + isUserVerifyingPlatformAuthenticatorAvailable: async () => true, + }), + ).resolves.toBe(true) + }) +}) diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index 8796981..ab091b1 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,458 +1,9 @@ -// Passkey engine tests. The WebAuthn ceremony is faked by an injected -// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) - -// the real extension's exact contract: deterministic per credential+salt, -// unguessable without the authenticator. Everything except a real -// authenticator's touch is covered here. - -import {beforeEach, describe, expect, it} from 'vitest' -import {hmac} from '@noble/hashes/hmac.js' -import {sha256} from '@noble/hashes/sha2.js' -import {bytesToHex} from '@noble/hashes/utils.js' - -import type {CeremonyCredential, PasskeyCredentials} from './passkeys' -import { - derivePasskeyWrapKey, - getPasskeyPrfOutput, - hasPasskeySlots, - passkeySupported, - readPasskeySlots, - registerPasskey, - removePasskey, - rewrapAllSlots, - unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf -} from './passkeys' -import { - decryptRecord, - decryptSavedLinkingKey, - deriveBearerAesKey, - encryptRecord, - saveLinkingKey -} from './keys' -import {stubLocalStorage} from './test-utils' - -const LINKING_KEY = new Uint8Array(32).fill(7) -const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) -const PRF_OUTPUT = new Uint8Array(32).fill(3) -const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) - -const toBytes = (source: BufferSource): Uint8Array => - source instanceof ArrayBuffer - ? new Uint8Array(source) - : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) - -// Fake platform authenticator: holds credentials (id -> secret), evaluates -// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks -// found in the wild: PRF unsupported, results only on get, results never. -class FakeAuthenticator implements PasskeyCredentials { - // id typed Uint8Array: rawId must satisfy BufferSource - private held = new Map< - string, - {id: Uint8Array; secret: Uint8Array} - >() - supportsPrf = true - prfResultsOnCreate = true - prfResultsOnGet = true - createCalls = 0 - getCalls = 0 - - create = async ( - options?: CredentialCreationOptions - ): Promise => { - this.createCalls += 1 - const salt = options?.publicKey?.extensions?.prf?.eval?.first - const id = crypto.getRandomValues(new Uint8Array(16)) - const secret = crypto.getRandomValues(new Uint8Array(32)) - this.held.set(bytesToHex(id), {id, secret}) - return { - type: 'public-key', - rawId: id, - getClientExtensionResults: () => ({ - prf: - this.supportsPrf && salt - ? { - enabled: true, - ...(this.prfResultsOnCreate - ? {results: {first: this.prf(secret, salt)}} - : {}) - } - : {} - }) - } - } - - // answers with the first allowed credential it holds, like a real - // authenticator picking among allowCredentials; null when it holds none - get = async ( - options?: CredentialRequestOptions - ): Promise => { - this.getCalls += 1 - const pk = options?.publicKey - const allowed = (pk?.allowCredentials ?? []).map(d => - bytesToHex(toBytes(d.id)) - ) - const match = allowed.find(hex => this.held.has(hex)) - const held = match ? this.held.get(match) : undefined - if (!held) return null - const salt = pk?.extensions?.prf?.eval?.first - return { - type: 'public-key', - rawId: held.id, - getClientExtensionResults: () => ({ - prf: - salt && this.prfResultsOnGet - ? {enabled: true, results: {first: this.prf(held.secret, salt)}} - : {} - }) - } - } - - // simulates the passkey's secret changing underneath a slot (credential - // re-created on the authenticator while the slot stayed behind) - rotateSecret = (credentialId: string): void => { - const held = this.held.get(credentialId) - if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) - } - - private prf = ( - secret: Uint8Array, - salt: BufferSource - ): Uint8Array => { - // set into a fresh array: hmac returns Uint8Array, - // which BufferSource rejects - const out = new Uint8Array(32) - out.set(hmac(sha256, secret, toBytes(salt))) - return out - } -} - -beforeEach(() => { - stubLocalStorage() -}) - -describe('pure wrap crypto', () => { - it('round-trips a linking key through a PRF-derived wrap', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) - }) - - it('rejects unwrap with a different PRF output', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - await expect( - unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap) - ).rejects.toThrow() - }) - - it('rejects unwrap with a tampered HKDF salt', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}) - ).rejects.toThrow() - }) - - it('rejects unwrap with a tampered ciphertext', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - const flipped = `${wrap.wrappedKey.slice(0, -2)}${ - wrap.wrappedKey.endsWith('00') ? '01' : '00' - }` - await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}) - ).rejects.toThrow() - }) - - it('derives wrap keys deterministically from the same PRF output and salt', async () => { - const salt = new Uint8Array(16).fill(1) - const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt) - const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt) - const record = await encryptRecord(a, {v: 1}) - await expect(decryptRecord(b, record)).resolves.toEqual({v: 1}) - }) -}) - -describe('registration and unlock', () => { - it('registers a passkey and unlocks the same linking key', async () => { - const auth = new FakeAuthenticator() - const slot = await registerPasskey(LINKING_KEY, { - credentials: auth, - name: 'laptop' - }) - expect(slot.name).toBe('laptop') - expect(readPasskeySlots()).toEqual([slot]) - expect(hasPasskeySlots()).toBe(true) - - const unwrapped = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) - }) - - it('never stores the linking key in the clear', async () => { - const auth = new FakeAuthenticator() - await registerPasskey(LINKING_KEY, {credentials: auth}) - const raw = localStorage.getItem('sattle_passkey_slots') - expect(raw).toBeTruthy() - expect(raw).not.toContain(bytesToHex(LINKING_KEY)) - }) - - it('yields the same key material unlock(password) yields', async () => { - const linkingKey = crypto.getRandomValues(new Uint8Array(32)) - await saveLinkingKey(linkingKey, 'correct horse') - const auth = new FakeAuthenticator() - await registerPasskey(linkingKey, {credentials: auth}) - - const viaPassword = await decryptSavedLinkingKey('correct horse') - const viaPasskey = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword)) - - // and the practical consequence: a bearer record encrypted after a - // password unlock decrypts after a passkey unlock - const passwordAes = await deriveBearerAesKey(viaPassword) - const record = await encryptRecord(passwordAes, {note: 'still readable'}) - const passkeyAes = await deriveBearerAesKey(viaPasskey) - await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ - note: 'still readable' - }) - }) - - it('falls back to a get ceremony when create only reports prf.enabled', async () => { - const auth = new FakeAuthenticator() - auth.prfResultsOnCreate = false - const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) - expect(auth.getCalls).toBe(1) - const unwrapped = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) - expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId) - }) - - it('refuses registration when the authenticator has no PRF support', async () => { - const auth = new FakeAuthenticator() - auth.supportsPrf = false - await expect( - registerPasskey(LINKING_KEY, {credentials: auth}) - ).rejects.toThrow('PRF') - expect(hasPasskeySlots()).toBe(false) - }) - - it('throws on a cancelled registration ceremony', async () => { - const cancelled: PasskeyCredentials = { - create: async () => null, - get: async () => null - } - await expect( - registerPasskey(LINKING_KEY, {credentials: cancelled}) - ).rejects.toThrow('cancelled') - expect(hasPasskeySlots()).toBe(false) - }) - - it('throws before any ceremony when no passkeys are registered', async () => { - const auth = new FakeAuthenticator() - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow( - 'No passkeys' - ) - expect(auth.getCalls).toBe(0) - }) - - it('rejects unlock when the passkey returns no PRF secret', async () => { - const auth = new FakeAuthenticator() - await registerPasskey(LINKING_KEY, {credentials: auth}) - auth.prfResultsOnGet = false - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow( - 'PRF secret' - ) - }) - - it('rejects unlock when the ceremony yields an unregistered credential', async () => { - const auth = new FakeAuthenticator() - await registerPasskey(LINKING_KEY, {credentials: auth}) - const rogue: PasskeyCredentials = { - create: async () => null, - get: async () => ({ - type: 'public-key', - rawId: crypto.getRandomValues(new Uint8Array(16)), - getClientExtensionResults: () => ({ - prf: {enabled: true, results: {first: new Uint8Array(32)}} - }) - }) - } - await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow( - 'not registered' - ) - }) - - it('rejects unlock after the authenticator secret changed underneath the slot', async () => { - const auth = new FakeAuthenticator() - const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) - auth.rotateSecret(slot.credentialId) - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow() - }) -}) - -describe('multiple passkeys', () => { - it('keeps slots independent: each passkey unlocks the same key', async () => { - const laptop = new FakeAuthenticator() - const phone = new FakeAuthenticator() - const laptopSlot = await registerPasskey(LINKING_KEY, { - credentials: laptop, - name: 'laptop' - }) - const phoneSlot = await registerPasskey(LINKING_KEY, { - credentials: phone, - name: 'phone' - }) - expect(readPasskeySlots()).toHaveLength(2) - // independent wrap keys: same plaintext, different salts and ciphertexts - expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) - expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey) - - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( - bytesToHex(LINKING_KEY) - ) - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( - bytesToHex(LINKING_KEY) - ) - }) - - it('removePasskey drops exactly one slot and leaves the rest working', async () => { - const laptop = new FakeAuthenticator() - const phone = new FakeAuthenticator() - const laptopSlot = await registerPasskey(LINKING_KEY, { - credentials: laptop - }) - await registerPasskey(LINKING_KEY, {credentials: phone}) - - await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true) - expect(readPasskeySlots()).toHaveLength(1) - - // the removed passkey no longer matches any offered credential - await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow( - 'cancelled' - ) - // the survivor is unaffected - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( - bytesToHex(LINKING_KEY) - ) - // removing again is a no-op - await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false) - }) -}) - -describe('rewrap on linking-key rotation', () => { - it('re-wraps every slot onto the new key, all-or-nothing', async () => { - const laptop = new FakeAuthenticator() - const phone = new FakeAuthenticator() - const laptopSlot = await registerPasskey(LINKING_KEY, { - credentials: laptop - }) - const phoneSlot = await registerPasskey(LINKING_KEY, { - credentials: phone - }) - - // partial coverage aborts before writing: both slots still unwrap the - // OLD key afterwards - const partial = new Map([ - [ - laptopSlot.credentialId, - await getPasskeyPrfOutput(laptopSlot.credentialId, { - credentials: laptop - }) - ] - ]) - await expect(rewrapAllSlots(OTHER_LINKING_KEY, partial)).rejects.toThrow( - 'partial re-wrap' - ) - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( - bytesToHex(LINKING_KEY) - ) - - // full coverage: both slots now unwrap the NEW key - const fresh = new Map([ - [ - laptopSlot.credentialId, - await getPasskeyPrfOutput(laptopSlot.credentialId, { - credentials: laptop - }) - ], - [ - phoneSlot.credentialId, - await getPasskeyPrfOutput(phoneSlot.credentialId, { - credentials: phone - }) - ] - ]) - await rewrapAllSlots(OTHER_LINKING_KEY, fresh) - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( - bytesToHex(OTHER_LINKING_KEY) - ) - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( - bytesToHex(OTHER_LINKING_KEY) - ) - // credential ids and labels survive the re-wrap - expect(readPasskeySlots().map(s => s.credentialId).sort()).toEqual( - [laptopSlot.credentialId, phoneSlot.credentialId].sort() - ) - }) -}) - -describe('slot storage hygiene', () => { - it('drops malformed entries instead of throwing', async () => { - const auth = new FakeAuthenticator() - const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) - const stored: unknown[] = JSON.parse( - localStorage.getItem('sattle_passkey_slots') ?? '[]' - ) as unknown[] - localStorage.setItem( - 'sattle_passkey_slots', - JSON.stringify([...stored, {credentialId: 'zz', hkdfSalt: 1}, 'garbage', null]) - ) - expect(readPasskeySlots()).toEqual([slot]) - }) - - it('treats unparseable storage as empty', () => { - localStorage.setItem('sattle_passkey_slots', '{not json') - expect(readPasskeySlots()).toEqual([]) - expect(hasPasskeySlots()).toBe(false) - }) -}) - -describe('passkeySupported', () => { - it('is false without a PublicKeyCredential probe', async () => { - // node test env has no PublicKeyCredential global: the default lookup - // finds nothing - await expect(passkeySupported()).resolves.toBe(false) - }) - - it('is false without a user-verifying platform authenticator', async () => { - await expect( - passkeySupported({ - isUserVerifyingPlatformAuthenticatorAvailable: async () => false, - getClientCapabilities: async () => ({'extension:prf': true}) - }) - ).resolves.toBe(false) - }) - - it('checks extension:prf when client capabilities are available', async () => { - const platform = { - isUserVerifyingPlatformAuthenticatorAvailable: async () => true - } - await expect( - passkeySupported({ - ...platform, - getClientCapabilities: async () => ({'extension:prf': true}) - }) - ).resolves.toBe(true) - await expect( - passkeySupported({ - ...platform, - getClientCapabilities: async () => ({'extension:prf': false}) - }) - ).resolves.toBe(false) - }) - - it('is optimistic when capabilities cannot be pre-detected', async () => { - await expect( - passkeySupported({ - isUserVerifyingPlatformAuthenticatorAvailable: async () => true - }) - ).resolves.toBe(true) - }) -}) +import './passkeys.crypto.cases' +import './passkeys.registration.cases' +import './passkeys.ownership-a.cases' +import './passkeys.ownership-b.cases' +import './passkeys.multiple.cases' +import './passkeys.rewrap.cases' +import './passkeys.storage.cases' +import './passkeys.support.cases' +import './passkeys.version.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index a213497..e37270f 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -30,22 +30,21 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' import {withStorageLock} from './storageLock' import type {PasskeySlot} from './storage/passkeySlots' import { PASSKEY_SLOTS_STORAGE_KEY, + PASSKEY_SLOT_VERSION, readPasskeySlots, - writePasskeySlots + writePasskeySlots, } from './storage/passkeySlots' import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap' export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' -export { - derivePasskeyWrapKey, - wrapLinkingKeyWithPrf, - unwrapLinkingKeyWithPrf -} from './passkeyWrap' +export {migrateLegacyPasskeySlots} from './passkeyOwnership' +export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap' // 32 bytes, fixed - the authenticator requires exactly 32 const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) @@ -62,9 +61,7 @@ export type CeremonyCredential = { // the slice of navigator.credentials the ceremonies need export type PasskeyCredentials = { - create( - options?: CredentialCreationOptions - ): Promise + create(options?: CredentialCreationOptions): Promise get(options?: CredentialRequestOptions): Promise } @@ -76,13 +73,11 @@ export type PasskeySupportProbe = { // the one runtime narrow at the browser boundary: navigator.credentials // resolves to the Credential supertype, but a publicKey ceremony always // produces a PublicKeyCredential -const asCeremonyCredential = ( - credential: Credential | null -): CeremonyCredential | null => { - if (!credential || credential.type !== 'public-key') return null - if (!('rawId' in credential)) return null - if (!('getClientExtensionResults' in credential)) return null - return credential as unknown as CeremonyCredential +const asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => { + if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) { + return null + } + return credential } const defaultCredentials = (): PasskeyCredentials => { @@ -91,8 +86,8 @@ const defaultCredentials = (): PasskeyCredentials => { } const container = navigator.credentials return { - create: options => container.create(options).then(asCeremonyCredential), - get: options => container.get(options).then(asCeremonyCredential) + create: (options) => container.create(options).then(asCeremonyCredential), + get: (options) => container.get(options).then(asCeremonyCredential), } } @@ -101,14 +96,8 @@ const defaultCredentials = (): PasskeyCredentials => { // direct pre-flight check on older clients - where getClientCapabilities // exists we can ask for it, elsewhere this returns true optimistically and // registration itself fails with a clear error. -export const passkeySupported = async ( - probe?: PasskeySupportProbe -): Promise => { - const p = - probe ?? - (typeof PublicKeyCredential !== 'undefined' - ? PublicKeyCredential - : undefined) +export const passkeySupported = async (probe?: PasskeySupportProbe): Promise => { + const p = probe ?? (typeof PublicKeyCredential !== 'undefined' ? PublicKeyCredential : undefined) if (!p) return false if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false if (p.getClientCapabilities) { @@ -135,25 +124,21 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => { // rotation export const getPasskeyPrfOutput = async ( credentialId: string, - options: {credentials?: PasskeyCredentials} = {} + options: {credentials?: PasskeyCredentials} = {}, ): Promise => { const credentials = options.credentials ?? defaultCredentials() const assertion = await credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), - allowCredentials: [ - {type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))} - ], + allowCredentials: [{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}], userVerification: 'required', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}, + }, }) if (!assertion) throw new Error('Passkey ceremony was cancelled.') const prfOutput = prfOutputOf(assertion) if (!prfOutput) { - throw new Error( - 'This passkey did not return a PRF secret - it cannot unlock this wallet.' - ) + throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } return prfOutput } @@ -172,8 +157,12 @@ export type RegisterPasskeyOptions = { // those get a follow-up get() against the fresh credential. export const registerPasskey = async ( linkingKey: Uint8Array, - options: RegisterPasskeyOptions = {} + options: RegisterPasskeyOptions = {}, ): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Passkey registration requires the proven saved wallet owner.') + } const credentials = options.credentials ?? defaultCredentials() const credential = await credentials.create({ publicKey: { @@ -184,29 +173,27 @@ export const registerPasskey = async ( // discoverable-credential login is used id: crypto.getRandomValues(new Uint8Array(16)), name: 'sattle wallet', - displayName: 'sattle wallet' + displayName: 'sattle wallet', }, pubKeyCredParams: [ {type: 'public-key', alg: -7}, // ES256 - {type: 'public-key', alg: -257} // RS256 + {type: 'public-key', alg: -257}, // RS256 ], authenticatorSelection: { authenticatorAttachment: options.authenticatorAttachment ?? 'platform', residentKey: 'preferred', - userVerification: 'required' + userVerification: 'required', }, attestation: 'none', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}, + }, }) if (!credential) throw new Error('Passkey registration was cancelled.') const credentialId = bytesToHex(toBytes(credential.rawId)) let prfOutput = prfOutputOf(credential) if (!prfOutput) { if (credential.getClientExtensionResults().prf?.enabled !== true) { - throw new Error( - 'This authenticator does not support the WebAuthn PRF extension.' - ) + throw new Error('This authenticator does not support the WebAuthn PRF extension.') } prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) } @@ -215,14 +202,14 @@ export const registerPasskey = async ( credentialId, ...wrap, createdAt: Date.now(), - ...(options.name !== undefined ? {name: options.name} : {}) + ...(options.name !== undefined ? {name: options.name} : {}), + ownerId, + version: PASSKEY_SLOT_VERSION, } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { - const slots = readPasskeySlots().filter( - s => s.credentialId !== credentialId - ) + const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) slots.push(slot) - writePasskeySlots(slots) + writePasskeySlots(ownerId, slots) }) return slot } @@ -231,85 +218,88 @@ export const registerPasskey = async ( // slot's credential, then unwrap. Yields the exact same linking key // unlock(password) yields - the caller activates the wallet with it. export const unlockWithPasskey = async ( - options: {credentials?: PasskeyCredentials} = {} + options: {credentials?: PasskeyCredentials} = {}, ): Promise => { + const ownerId = savedKeyOwnerId() const slots = readPasskeySlots() - if (slots.length === 0) { + if (ownerId === null || slots.length === 0) { throw new Error('No passkeys registered on this device.') } const credentials = options.credentials ?? defaultCredentials() const assertion = await credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), - allowCredentials: slots.map(slot => ({ + allowCredentials: slots.map((slot) => ({ type: 'public-key', - id: new Uint8Array(hexToBytes(slot.credentialId)) + id: new Uint8Array(hexToBytes(slot.credentialId)), })), userVerification: 'required', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}}, + }, }) if (!assertion) throw new Error('Passkey ceremony was cancelled.') const credentialId = bytesToHex(toBytes(assertion.rawId)) - const slot = slots.find(s => s.credentialId === credentialId) + const slot = slots.find((s) => s.credentialId === credentialId) if (!slot) { throw new Error('The passkey used is not registered with this wallet.') } const prfOutput = prfOutputOf(assertion) if (!prfOutput) { - throw new Error( - 'This passkey did not return a PRF secret - it cannot unlock this wallet.' - ) + throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } - return unwrapLinkingKeyWithPrf(prfOutput, slot) + const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot) + if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('This passkey belongs to a different wallet.') + } + return linkingKey } // Removes the slot only: WebAuthn has no API to delete the credential from // the authenticator - an orphaned passkey simply finds nothing to unwrap. // Returns whether a slot was actually removed. -export const removePasskey = async ( - credentialId: string -): Promise => { +export const removePasskey = async (credentialId: string): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null) return false let removed = false await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { const slots = readPasskeySlots() - const kept = slots.filter(s => s.credentialId !== credentialId) + const kept = slots.filter((s) => s.credentialId !== credentialId) removed = kept.length !== slots.length - if (removed) writePasskeySlots(kept) + if (removed) writePasskeySlots(ownerId, kept) }) return removed } -// Re-wraps every slot around NEW key material - needed on linking-key -// rotation (restoring a different seed while keeping the passkeys). Each -// slot's wrap secret lives only inside its authenticator, so the caller +// Refreshes every current-owner slot around the same proven key material. +// Each slot's wrap secret lives only inside its authenticator, so the caller // must supply a fresh PRF output per credential (one getPasskeyPrfOutput // ceremony each). All-or-nothing: a slot without a PRF output aborts the -// whole re-wrap before anything is written, since a half-rewrapped set -// would keep unlocking the OLD key with the uncovered passkeys. +// whole refresh before anything is written. // // A password change does NOT need this: the password wrap (keys.ts) and the // passkey slots wrap the same linking key independently, so re-encrypting // the stored key under a new password leaves every slot valid. export const rewrapAllSlots = async ( linkingKey: Uint8Array, - prfOutputs: ReadonlyMap + prfOutputs: ReadonlyMap, ): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Passkey re-wrap requires the proven saved wallet owner.') + } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { const slots = readPasskeySlots() const rewrapped: PasskeySlot[] = [] for (const slot of slots) { const prfOutput = prfOutputs.get(slot.credentialId) if (!prfOutput) { - throw new Error( - 'Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.' - ) + throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.') } rewrapped.push({ ...slot, - ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)) + ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)), }) } - writePasskeySlots(rewrapped) + writePasskeySlots(ownerId, rewrapped) }) } diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts new file mode 100644 index 0000000..afd8b0e --- /dev/null +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -0,0 +1,96 @@ +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' +import type {PasskeySlot} from './passkeys' +import {parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const KEY_STORAGE = 'sattle_linking_key' +const SLOT_STORAGE = 'sattle_passkey_slots' + +const SLOT_BASE = { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, +} as const + +const writeRawSlots = (slots: readonly Record[]): void => { + localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots)) +} + +describe('passkey-slot schema version', () => { + beforeEach(() => { + stubLocalStorage() + }) + it('reads a current version 1 slot for the exact saved owner', () => { + // Given a current saved-key marker and passkey record + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 1} + writeRawSlots([slot]) + + // When current-owner slots are read + // Then the recognized version is exposed unchanged + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => { + // Given records written before schema versioning + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}), + ) + writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}]) + + // When migration is attempted before and after the saved key proves ownership + await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') + const provenKey = getPlainLinkingKey() + if (provenKey === null) throw new Error('expected the compatible plaintext key') + ensureSavedKeyOwner(provenKey) + await migrateLegacyPasskeySlots(provenKey) + + // Then both compatible legacy forms become current records + expect(savedKeyOwnerId()).toBe(ownerId) + expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ + {...SLOT_BASE, ownerId, version: 1}, + {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1}, + ]) + }) + + it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => { + // Given an otherwise valid slot carrying unrecognized metadata + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + writeRawSlots([{...SLOT_BASE, ownerId, version}]) + + // When slots are parsed + // Then future or malformed records are unavailable and not downgraded to legacy + expect(readPasskeySlots()).toEqual([]) + }) + + it('hides a foreign version 1 slot from the current owner', () => { + // Given the saved owner and slot owner differ + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}]) + + // When the current wallet reads passkeys + // Then the foreign credential is unavailable + expect(readPasskeySlots()).toEqual([]) + }) +}) diff --git a/src/lnurlcash/receive.ts b/src/lnurlcash/receive.ts index 9efdb95..897cd56 100644 --- a/src/lnurlcash/receive.ts +++ b/src/lnurlcash/receive.ts @@ -8,7 +8,7 @@ import { withNewK1, NoteSpentError, NoteUnknownError, - PendingNoteError + PendingNoteError, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from './types' @@ -17,20 +17,13 @@ import type {Bearer, NewBearer} from './types' // always puts k1 on the wire, so receive.ts's caller should rotate right // after, see secureReceivedNote). Returns the note even when the info fetch // fails - a bearer is better stored unverified than dropped. -export const receiveNote = async ( - input: string, - existing: Bearer[] -): Promise => { +export const receiveNote = async (input: string, existing: Bearer[]): Promise => { const url = resolveNoteInput(input) if (!url) { throw new Error('Not an LNURLcash bearer note (needs a k1).') } const k1 = noteK1(url) - if ( - existing.some( - b => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url) - ) - ) { + if (existing.some((b) => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url))) { throw new Error('This note is already in your wallet.') } try { @@ -40,7 +33,7 @@ export const receiveNote = async ( callback: info.callback, amount: info.maxWithdrawable, verified: true, - mintPubkey: info.mintPubkey + mintPubkey: info.mintPubkey, } } catch (err) { // the service positively told us this k1 is dead, unknown, or locked @@ -62,7 +55,7 @@ export const receiveNote = async ( url, callback: '', amount: noteDeclaredAmount(url) ?? 0, - verified: false + verified: false, } } } diff --git a/src/lnurlcash/storage.backup.test.ts b/src/lnurlcash/storage.backup.test.ts new file mode 100644 index 0000000..1c10fce --- /dev/null +++ b/src/lnurlcash/storage.backup.test.ts @@ -0,0 +1,155 @@ +// Backup restore waits for owner-bound trusted-mint convergence before it +// reports success, while retaining the hostile-file merge policy. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import {applyBackup, buildBackup} from './storage' +import {addTrustedMint, readTrustedMints} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) + +type LockRequest = { + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +class DeferredLocks { + readonly requests: LockRequest[] = [] + + readonly request = (_name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({callback, resolve, reject}) + }) + + async releaseNext(): Promise { + const request = this.requests.shift() + if (!request) throw new Error('Expected a queued lock request.') + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } + } +} + +const backup = (server: string, mintPubkey: string) => ({ + type: 'sattle-backup' as const, + version: 1 as const, + createdAt: 1, + bearers: [], + trustedMints: [ + { + server, + mintPubkey, + addedAt: 1, + locked: true, + pendingMintPubkey: KEY_B, + }, + ], +}) + +const installProvenOwner = (): Promise => saveLinkingKey(LINKING_KEY) + +beforeEach(() => { + vi.unstubAllGlobals() + stubLocalStorage() +}) + +describe('owner-bound backup restore', () => { + it('exports only the active owner trusted-mint registry', async () => { + await installProvenOwner() + await addTrustedMint('backup-mint.example', KEY_A, {ownerId: OWNER_ID}) + + expect(buildBackup(OWNER_ID).trustedMints).toEqual([ + expect.objectContaining({server: 'backup-mint.example', mintPubkey: KEY_A}), + ]) + expect(buildBackup(OWNER_ID).ownerId).toBe(OWNER_ID) + expect(buildBackup().trustedMints).toEqual([]) + }) + + it('drops fresh-device mint trust instead of using a valid file owner marker', async () => { + const result = await applyBackup({ + ...backup('file-mint.example', KEY_A), + ownerId: OWNER_ID, + }) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull() + }) + + it('does not attach file mints to a malformed owner marker', async () => { + const result = await applyBackup({ + ...backup('file-mint.example', KEY_A), + ownerId: 'malformed-owner', + }) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + }) + + it('does not resolve before the trusted-mint merge commits', async () => { + await installProvenOwner() + const locks = new DeferredLocks() + vi.stubGlobal('navigator', {locks}) + + let settled = false + const restoring = applyBackup(backup('backup-mint.example', KEY_A), OWNER_ID).then((result) => { + settled = true + return result + }) + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)) + + expect(settled).toBe(false) + await locks.releaseNext() + + expect((await restoring).trustedMintsAdded).toBe(1) + const restored = readTrustedMints(OWNER_ID) + expect(restored).toEqual([ + expect.objectContaining({ + server: 'backup-mint.example', + mintPubkey: KEY_A, + locked: false, + unconfirmed: true, + }), + ]) + expect(restored[0]?.pendingMintPubkey).toBeUndefined() + }) + + it('keeps a local locked pin and pending rekey authoritative', async () => { + await installProvenOwner() + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ + version: 1, + ownerId: OWNER_ID, + mints: [ + { + server: 'local.example', + mintPubkey: KEY_A, + addedAt: 1, + locked: true, + pendingMintPubkey: KEY_B, + }, + ], + }), + ) + + const result = await applyBackup(backup('local.example', KEY_B), OWNER_ID) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([ + expect.objectContaining({ + mintPubkey: KEY_A, + locked: true, + pendingMintPubkey: KEY_B, + }), + ]) + }) +}) diff --git a/src/lnurlcash/storage.changeset.cases.ts b/src/lnurlcash/storage.changeset.cases.ts new file mode 100644 index 0000000..4ca5fef --- /dev/null +++ b/src/lnurlcash/storage.changeset.cases.ts @@ -0,0 +1,249 @@ +// Imported by storage.test.ts so the focused storage command exercises the +// fund-critical changeset boundary without mixing it into unrelated storage +// round-trip and backup cases. + +import {describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, encryptRecord} from './keys' +import { + applyBearerChangeset, + deleteBearerRecord, + loadBearers, + newBearerId, + persistBearer, + readEncryptedBearers, +} from './storage' +import type {BearerChangeset} from './storage' +import type {Bearer, NewBearer} from './types' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const K1_C = 'cc'.repeat(32) +const K1_D = 'dd'.repeat(32) + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', K1_C, 3_000), + callback: 'https://mint.example/w/cb', + amount: 3_000, + verified: true, + ...overrides, +}) + +describe('baseline: per-record bearer persistence', () => { + it('writes once for every persist or delete call', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(localStorage, 'setItem') + + await persistBearer(key, bearerFixture({id: 'a'})) + await persistBearer(key, bearerFixture({id: 'b'})) + await deleteBearerRecord('a') + + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(3) + }) +}) + +describe('applyBearerChangeset', () => { + it('commits additions and spent replacements with exactly one write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const oldA = bearerFixture({id: 'old-a'}) + const oldB = bearerFixture({ + id: 'old-b', + url: buildNoteUrl('https://mint.example/w', K1_B, 5_000), + amount: 5_000, + }) + await persistBearer(key, oldA) + await persistBearer(key, oldB) + const snapshot = [oldA, oldB] + const changeset: BearerChangeset = { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + amount: 4_000, + }), + ], + markSpent: ['old-a', 'old-b'], + } + const writes = vi.spyOn(localStorage, 'setItem') + + const result = await applyBearerChangeset(key, snapshot, changeset) + + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(1) + expect(result).toHaveLength(4) + expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) + expect(result[0]?.id).not.toBe(result[1]?.id) + expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined]) + expect(changeset.markSpent).toEqual(['old-a', 'old-b']) + expect(changeset.add.some((note) => 'id' in note)).toBe(false) + expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual( + result.map((bearer) => bearer.id).sort(), + ) + const raw = localStorage.getItem('sattle_bearers') ?? '' + expect(raw).not.toContain(K1_C) + expect(raw).not.toContain(K1_D) + }) + + it('writes nothing when the second record encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const writes = vi.spyOn(localStorage, 'setItem') + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + encrypt + .mockResolvedValueOnce(new ArrayBuffer(32)) + .mockRejectedValueOnce(new Error('second encryption failed')) + + try { + await expect( + applyBearerChangeset(key, [], { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + }), + ], + markSpent: [], + }), + ).rejects.toThrow('second encryption failed') + expect(encrypt).toHaveBeenCalledTimes(2) + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(0) + expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) + } finally { + encrypt.mockRestore() + } + }) + + it('rejects a failed storage write without changing persisted state', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const kept = bearerFixture({id: 'kept'}) + await persistBearer(key, kept) + const before = localStorage.getItem('sattle_bearers') + const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + vi.stubGlobal('navigator', {}) + + try { + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + expect(localStorage.getItem('sattle_bearers')).toBe(before) + } finally { + write.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('deduplicates repeated spent ids into one stored replacement', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const bearer = bearerFixture({id: 'duplicate'}) + await persistBearer(key, bearer) + + const result = await applyBearerChangeset(key, [bearer], { + add: [], + markSpent: ['duplicate', 'duplicate'], + }) + + expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1) + expect(result[0]?.spent).toBe(true) + expect((await loadBearers(key))[0]?.spent).toBe(true) + }) + + it('encrypts before locking and preserves a fresh unrelated record', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + const mine = bearerFixture({id: 'mine'}) + const foreign = bearerFixture({ + id: 'fresh-tab', + url: buildNoteUrl('https://mint.example/w', K1_B, 9_000), + }) + await persistBearer(key, mine) + const {id: foreignId, ...foreignPlain} = foreign + const foreignParts = await encryptRecord(other, foreignPlain) + const encrypted = vi.spyOn(crypto.subtle, 'encrypt') + const queue: {release: () => Promise}[] = [] + vi.stubGlobal('navigator', { + locks: { + request: (_name: string, fn: () => unknown): Promise => + new Promise((resolve, reject) => { + queue.push({ + release: async () => { + await Promise.resolve(fn) + .then((callback) => callback()) + .then(resolve, reject) + }, + }) + }), + }, + }) + + try { + const commit = applyBearerChangeset(key, [mine], { + add: [newBearerFixture()], + markSpent: [], + }) + await vi.waitFor(() => expect(queue).toHaveLength(1)) + expect(encrypted).toHaveBeenCalledTimes(1) + localStorage.setItem( + 'sattle_bearers', + JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]), + ) + const pendingLock = queue.at(0) + if (pendingLock === undefined) throw new Error('Expected pending lock') + + await pendingLock.release() + const result = await commit + + expect( + readEncryptedBearers() + .map((record) => record.id) + .sort(), + ).toEqual(['fresh-tab', 'mine', result[0]?.id].sort()) + expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab']) + } finally { + encrypted.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('replaces corrupt JSON with the single committed changeset write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + localStorage.setItem('sattle_bearers', 'not json {{{') + const writes = vi.spyOn(localStorage, 'setItem') + + const result = await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: ['missing'], + }) + + expect(result).toHaveLength(1) + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(1) + expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual( + result.map((bearer) => bearer.id), + ) + }) +}) diff --git a/src/lnurlcash/storage.test.ts b/src/lnurlcash/storage.test.ts index 96fd16e..4f00acd 100644 --- a/src/lnurlcash/storage.test.ts +++ b/src/lnurlcash/storage.test.ts @@ -4,6 +4,7 @@ import {beforeEach, describe, expect, it} from 'vitest' import {buildNoteUrl} from 'lnurlcash-kit' +import './storage.changeset.cases' import type {Bearer} from './types' import {deriveBearerAesKey} from './keys' import { @@ -18,10 +19,10 @@ import { persistActivityEvent, persistBearer, readEncryptedBearers, - MAX_ACTIVITY_ENTRIES + MAX_ACTIVITY_ENTRIES, } from './storage' import {saveLinkingKey} from './keys' -import {stubLocalStorage} from './test-utils' +import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_KEY = new Uint8Array(32).fill(9) @@ -37,7 +38,7 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({ verified: true, createdAt: 1000, updatedAt: 1000, - ...overrides + ...overrides, }) beforeEach(() => { @@ -51,7 +52,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, bearer) // at rest, nothing plaintext leaks: no k1, no amounts - const raw = localStorage.getItem('sattle_bearers')! + const raw = requiredValue(localStorage.getItem('sattle_bearers')) expect(raw).not.toContain(K1_A) expect(raw).not.toContain('21000') @@ -63,12 +64,19 @@ describe('encrypted bearer records', () => { const key = await deriveBearerAesKey(LINKING_KEY) const other = await deriveBearerAesKey(OTHER_KEY) await persistBearer(key, bearerFixture({id: 'mine'})) - await persistBearer(other, bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})) + await persistBearer( + other, + bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}), + ) const loaded = await loadBearers(key) - expect(loaded.map(b => b.id)).toEqual(['mine']) + expect(loaded.map((b) => b.id)).toEqual(['mine']) // the foreign ciphertext is still there, untouched - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['foreign', 'mine']) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['foreign', 'mine']) }) it('overwrites a record when the same id is persisted again', async () => { @@ -79,7 +87,7 @@ describe('encrypted bearer records', () => { const loaded = await loadBearers(key) expect(loaded).toHaveLength(1) - expect(loaded[0]!.spent).toBe(true) + expect(requiredValue(loaded[0]).spent).toBe(true) }) it('deletes a record by id and clears all', async () => { @@ -90,7 +98,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, b) await deleteBearerRecord('a') - expect((await loadBearers(key)).map(x => x.id)).toEqual(['b']) + expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b']) clearAllBearers() expect(readEncryptedBearers()).toEqual([]) @@ -104,7 +112,7 @@ describe('activity log', () => { await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000}) const loaded = await loadActivity(key) - expect(loaded.map(e => e.id)).toEqual(['2', '1']) + expect(loaded.map((e) => e.id)).toEqual(['2', '1']) }) it('caps the log, rolling the oldest entries off', async () => { @@ -114,14 +122,14 @@ describe('activity log', () => { id: `ev-${i}`, kind: 'receive', message: `event ${i}`, - createdAt: i + createdAt: i, }) } const loaded = await loadActivity(key) expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES) // the five oldest rolled off; the newest is first - expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) - expect(loaded.at(-1)!.id).toBe('ev-5') + expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) + expect(requiredValue(loaded.at(-1)).id).toBe('ev-5') }, 30_000) }) @@ -130,7 +138,7 @@ describe('mergeBearers (union by note id, spent-wins)', () => { const a = bearerFixture({id: 'a'}) const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) const merged = mergeBearers([a], [b]) - expect(merged.map(x => x.id).sort()).toEqual(['a', 'b']) + expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']) }) it('lets the spent copy of a note win over a still-spendable one', () => { @@ -141,8 +149,8 @@ describe('mergeBearers (union by note id, spent-wins)', () => { // would resurrect burned money const merged = mergeBearers([spendable], [spent]) expect(merged).toHaveLength(1) - expect(merged[0]!.id).toBe('new-copy') - expect(merged[0]!.spent).toBe(true) + expect(requiredValue(merged[0]).id).toBe('new-copy') + expect(requiredValue(merged[0]).spent).toBe(true) }) it('keeps the newer copy when both agree on spent state', () => { @@ -150,14 +158,14 @@ describe('mergeBearers (union by note id, spent-wins)', () => { const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2}) const merged = mergeBearers([stale], [fresh]) expect(merged).toHaveLength(1) - expect(merged[0]!.id).toBe('fresh') + expect(requiredValue(merged[0]).id).toBe('fresh') }) it('treats the same secret on different servers as different notes', () => { const here = bearerFixture({id: 'here'}) const there = bearerFixture({ id: 'there', - url: buildNoteUrl('https://other.example/w', K1_A, 21_000) + url: buildNoteUrl('https://other.example/w', K1_A, 21_000), }) expect(mergeBearers([here], [there])).toHaveLength(2) }) @@ -191,16 +199,17 @@ describe('backup', () => { ...backup, bearers: [ ...backup.bearers, - {id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)} - ] + {id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, + ], } - const result = applyBackup(incoming) + const result = await applyBackup(incoming) expect(result.added).toBe(1) expect(result.skipped).toBe(1) - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([ - 'existing', - 'from-backup' - ]) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['existing', 'from-backup']) }) it('restores the linking key only onto a device that has none', async () => { @@ -208,33 +217,35 @@ describe('backup', () => { const backup = buildBackup() // same device: a key already exists, so the backup's key is skipped - const here = applyBackup(backup) + const here = await applyBackup(backup) expect(here.linkingKeySkipped).toBe(true) expect(here.linkingKeyRestored).toBe(false) // fresh device: the key installs stubLocalStorage() - const fresh = applyBackup(backup) + const fresh = await applyBackup(backup) expect(fresh.linkingKeyRestored).toBe(true) expect(fresh.linkingKeySkipped).toBe(false) }) - it('rejects a file that is not a sattle backup', () => { - expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow() - expect(() => applyBackup(null)).toThrow() - expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow() + it('rejects a file that is not a sattle backup', async () => { + await expect( + applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}), + ).rejects.toThrow() + await expect(applyBackup(null)).rejects.toThrow() + await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow() }) - it('skips malformed records instead of failing the whole restore', () => { - const result = applyBackup({ + it('skips malformed records instead of failing the whole restore', async () => { + const result = await applyBackup({ type: 'sattle-backup', version: 1, createdAt: 1, bearers: [ {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 42, iv: null, ciphertext: 'xx'}, - 'garbage' - ] + 'garbage', + ], }) expect(result.added).toBe(1) expect(result.skipped).toBe(2) diff --git a/src/lnurlcash/storage.ts b/src/lnurlcash/storage.ts index 5ac33ca..6f53b75 100644 --- a/src/lnurlcash/storage.ts +++ b/src/lnurlcash/storage.ts @@ -5,7 +5,8 @@ // the Pinia mints store and this module's backup both use it. // // Split by concern; this façade re-exports everything: -// storage/bearers.ts - encrypted bearer records + mergeBearers +// storage/bearers.ts - encrypted bearer records, changeset commits, +// mergeBearers // storage/activityLog.ts - the append-only encrypted activity log // storage/settings.ts - plaintext wallet settings // storage/backup.ts - buildBackup / applyBackup @@ -19,10 +20,11 @@ export { loadBearers, persistBearer, deleteBearerRecord, + applyBearerChangeset, clearAllBearers, - mergeBearers + mergeBearers, } from './storage/bearers' -export type {EncryptedBearerRecord} from './storage/bearers' +export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers' export { newActivityId, @@ -30,16 +32,12 @@ export { loadActivity, persistActivityEvent, clearAllActivity, - MAX_ACTIVITY_ENTRIES -} from './storage/activityLog' -export type { - ActivityKind, - ActivityEvent, - EncryptedActivityRecord + MAX_ACTIVITY_ENTRIES, } from './storage/activityLog' +export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog' export {loadSettings, persistSettings, clearSettings} from './storage/settings' export type {WalletSettings} from './storage/settings' -export {buildBackup, applyBackup, MAX_BACKUP_FILE_BYTES} from './storage/backup' +export {buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES} from './storage/backup' export type {BackupFile, RestoreResult} from './storage/backup' diff --git a/src/lnurlcash/storage/activityLog.ts b/src/lnurlcash/storage/activityLog.ts index 4b763e7..139a2c8 100644 --- a/src/lnurlcash/storage/activityLog.ts +++ b/src/lnurlcash/storage/activityLog.ts @@ -2,9 +2,10 @@ // AES-GCM under the same bearer key, append-only, capped so a wallet used // for years doesn't grow localStorage without limit. -import type { EncryptedRecordParts } from '../keys'; -import { encryptRecord, decryptRecord } from '../keys'; -import { withStorageLock } from '../storageLock'; +import type {EncryptedRecordParts} from '../keys' +import {encryptRecord, decryptRecord} from '../keys' +import {isJsonObject} from '../jsonParsing' +import {withStorageLock} from '../storageLock' // `message` is the full human-readable sentence rather than structured // fields the UI reassembles, so the log stays simple to read and to extend @@ -19,56 +20,87 @@ export type ActivityKind = | 'spent' | 'deleted' // a payment or mint initiated by a Nostr Wallet Connect client (M5) - | 'nwc'; + | 'nwc' export type ActivityEvent = { - id: string; - kind: ActivityKind; - message: string; - createdAt: number; -}; + id: string + kind: ActivityKind + message: string + createdAt: number +} -export type EncryptedActivityRecord = { id: string } & EncryptedRecordParts; +export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts -const ACTIVITY_STORAGE_KEY = 'sattle_activity'; +const isActivityKind = (value: unknown): value is ActivityKind => { + switch (value) { + case 'mint': + case 'split': + case 'combine': + case 'melt': + case 'transfer': + case 'receive': + case 'spent': + case 'deleted': + case 'nwc': + return true + default: + return false + } +} + +const isEncryptedActivityRecord = (value: unknown): value is EncryptedActivityRecord => + isJsonObject(value) && + typeof value.id === 'string' && + typeof value.iv === 'string' && + typeof value.ciphertext === 'string' + +const isStoredActivity = (value: unknown): value is Omit => + isJsonObject(value) && + isActivityKind(value.kind) && + typeof value.message === 'string' && + typeof value.createdAt === 'number' + +const ACTIVITY_STORAGE_KEY = 'sattle_activity' // bounds how far back the log ever reaches - the oldest entries simply // roll off once this many are kept -export const MAX_ACTIVITY_ENTRIES = 500; +export const MAX_ACTIVITY_ENTRIES = 500 export const newActivityId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) .map((b) => b.toString(16).padStart(2, '0')) - .join(''); + .join('') export const readEncryptedActivity = (): EncryptedActivityRecord[] => { - const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY); - if (!raw) return []; + const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY) + if (!raw) return [] try { - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter(isEncryptedActivityRecord) : [] } catch { - return []; + return [] } -}; +} const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { - localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)); -}; + localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)) +} // same tolerance as loadBearers - an entry that fails to decrypt with this // key (written by a different seed) is skipped, not destroyed export const loadActivity = async (aesKey: CryptoKey): Promise => { - const events: ActivityEvent[] = []; + const events: ActivityEvent[] = [] for (const record of readEncryptedActivity()) { try { - const event = await decryptRecord>(aesKey, record); - events.push({ ...event, id: record.id }); - } catch { + const event = await decryptRecord(aesKey, record) + if (!isStoredActivity(event)) throw new Error('Malformed encrypted activity record.') + events.push({...event, id: record.id}) + } catch (error) { // undecryptable with this key - leave it in place + if (!(error instanceof Error)) throw error } } - return events.sort((a, b) => b.createdAt - a.createdAt); -}; + return events.sort((a, b) => b.createdAt - a.createdAt) +} // append-only (the log never edits or removes a single entry, only clears // outright - see clearAllActivity) - records are stored oldest-first so @@ -77,15 +109,15 @@ export const persistActivityEvent = async ( aesKey: CryptoKey, event: ActivityEvent, ): Promise => { - const { id, ...plain } = event; - const parts = await encryptRecord(aesKey, plain); + const {id, ...plain} = event + const parts = await encryptRecord(aesKey, plain) await withStorageLock(ACTIVITY_STORAGE_KEY, () => { - const records = readEncryptedActivity(); - records.push({ id, ...parts }); - writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)); - }); -}; + const records = readEncryptedActivity() + records.push({id, ...parts}) + writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)) + }) +} export const clearAllActivity = (): void => { - localStorage.removeItem(ACTIVITY_STORAGE_KEY); -}; + localStorage.removeItem(ACTIVITY_STORAGE_KEY) +} diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts index 89bf71a..c277264 100644 --- a/src/lnurlcash/storage/backup.ts +++ b/src/lnurlcash/storage/backup.ts @@ -10,34 +10,49 @@ import { savedKeyExists, savedKeyIsEncrypted, restoreLinkingKeyStored, - isValidStoredSecret + isValidStoredSecret, } from '../keys' import type {TrustedMint} from '../trustedMints' import {readTrustedMints, mergeTrustedMints} from '../trustedMints' +import {isWalletOwnerId} from './walletOwner' import type {EncryptedBearerRecord} from './bearers' import {readEncryptedBearers, writeEncryptedBearers} from './bearers' import type {WalletSettings} from './settings' import {loadSettings, persistSettings} from './settings' +import {isJsonObject} from '../jsonParsing' export type BackupFile = { type: 'sattle-backup' version: 1 createdAt: number + ownerId?: unknown linkingKey?: StoredSecret bearers: EncryptedBearerRecord[] trustedMints?: TrustedMint[] settings?: WalletSettings } -export const buildBackup = (): BackupFile => { +type ParsedBackupFile = { + type: 'sattle-backup' + version: 1 + createdAt?: unknown + ownerId?: unknown + linkingKey?: unknown + bearers: unknown[] + trustedMints?: unknown + settings?: unknown +} + +export const buildBackup = (ownerId?: string): BackupFile => { const backup: BackupFile = { type: 'sattle-backup', version: 1, createdAt: Date.now(), bearers: readEncryptedBearers(), - trustedMints: readTrustedMints(), - settings: loadSettings() + trustedMints: readTrustedMints(ownerId), + settings: loadSettings(), } + if (isWalletOwnerId(ownerId)) backup.ownerId = ownerId const storedKey = getSavedLinkingKeyStored() if (savedKeyIsEncrypted() && storedKey) { backup.linkingKey = storedKey @@ -73,14 +88,17 @@ export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024 const MAX_BACKUP_RECORDS = 10_000 const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 -const isBackupFile = (data: unknown): data is BackupFile => { - if (typeof data !== 'object' || data === null) return false - const backup = data as Record - return ( - backup.type === 'sattle-backup' && - backup.version === 1 && - Array.isArray(backup.bearers) - ) +const isBackupFile = (data: unknown): data is ParsedBackupFile => + isJsonObject(data) && + data.type === 'sattle-backup' && + data.version === 1 && + Array.isArray(data.bearers) + +export const parseBackupFile = (data: unknown): ParsedBackupFile => { + if (!isBackupFile(data)) { + throw new Error('Not a valid sattle backup file.') + } + return data } // merges a backup into localStorage: bearer records are added by id @@ -94,25 +112,23 @@ const isBackupFile = (data: unknown): data is BackupFile => { // different key. See linkingKeySkipped above. The note-level dedupe (same // note arriving under a different record id, spent-wins) happens after // decrypt, in bearers.ts's mergeBearers. -export const applyBackup = (data: unknown): RestoreResult => { - if (!isBackupFile(data)) { - throw new Error('Not a valid sattle backup file.') - } - const backup = data +export const applyBackup = async (data: unknown, ownerId?: string): Promise => { + const backup = parseBackupFile(data) const existing = readEncryptedBearers() - const existingIds = new Set(existing.map(r => r.id)) + const existingIds = new Set(existing.map((r) => r.id)) if (backup.bearers.length > MAX_BACKUP_RECORDS) { throw new Error( - `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.` + `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`, ) } let added = 0 let skipped = 0 for (const record of backup.bearers) { if ( - typeof record?.id !== 'string' || - typeof record?.iv !== 'string' || - typeof record?.ciphertext !== 'string' || + !isJsonObject(record) || + typeof record.id !== 'string' || + typeof record.iv !== 'string' || + typeof record.ciphertext !== 'string' || record.id.length > MAX_BACKUP_FIELD_LENGTH || record.iv.length > MAX_BACKUP_FIELD_LENGTH || record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH @@ -132,14 +148,14 @@ export const applyBackup = (data: unknown): RestoreResult => { writeEncryptedBearers(existing) } catch { throw new Error( - 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.' + 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', ) } let linkingKeyRestored = false let linkingKeySkipped = false // an invalid key record reads as "no key in this backup", never as skipped - if (backup.linkingKey && isValidStoredSecret(backup.linkingKey)) { + if (isValidStoredSecret(backup.linkingKey)) { if (savedKeyExists()) { linkingKeySkipped = true } else { @@ -148,16 +164,20 @@ export const applyBackup = (data: unknown): RestoreResult => { } } - const trustedMintsAdded = Array.isArray(backup.trustedMints) - ? mergeTrustedMints(backup.trustedMints) - : 0 + // A file-carried owner marker is not identity proof, so it cannot namespace + // imported trust. Fresh file restores drop pins until key proof; active-wallet + // and Nostr restores supply an owner derived from their already-proven key. + const trustedMintsAdded = + ownerId && Array.isArray(backup.trustedMints) + ? await mergeTrustedMints(backup.trustedMints, ownerId) + : 0 // settings merge: fill only fields this device has never set. Flat // optional fields (see settings.ts), so the merge is field by field - // today that is just defaultMint let settingsRestored = false - if (typeof backup.settings === 'object' && backup.settings !== null) { - const incoming = (backup.settings as Record).defaultMint + if (isJsonObject(backup.settings)) { + const incoming = backup.settings.defaultMint const local = loadSettings() if ( local.defaultMint === undefined && @@ -175,6 +195,6 @@ export const applyBackup = (data: unknown): RestoreResult => { linkingKeyRestored, linkingKeySkipped, trustedMintsAdded, - settingsRestored + settingsRestored, } } diff --git a/src/lnurlcash/storage/bearers.baseline.cases.ts b/src/lnurlcash/storage/bearers.baseline.cases.ts new file mode 100644 index 0000000..8631efe --- /dev/null +++ b/src/lnurlcash/storage/bearers.baseline.cases.ts @@ -0,0 +1,29 @@ +import {describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' +import type {Bearer} from '../types' +import {deriveBearerAesKey} from '../keys' +import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' +import {stubLocalStorage} from '../test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const K1 = 'aa'.repeat(32) +const bearerFixture = (): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, +}) +describe('baseline: per-record bearer persistence', () => { + it('persistBearer/deleteBearerRecord perform one write per call', async () => { + const storage = stubLocalStorage() + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(storage, 'setItem') + await persistBearer(key, {...bearerFixture(), id: 'a'}) + await persistBearer(key, {...bearerFixture(), id: 'b'}) + await deleteBearerRecord('a') + expect(writes.mock.calls.filter(([keyName]) => keyName === 'sattle_bearers')).toHaveLength(3) + }) +}) diff --git a/src/lnurlcash/storage/bearers.test.ts b/src/lnurlcash/storage/bearers.test.ts new file mode 100644 index 0000000..91d1886 --- /dev/null +++ b/src/lnurlcash/storage/bearers.test.ts @@ -0,0 +1,305 @@ +// Bearer changeset persistence: applyBearerChangeset (the single-write +// commit primitive) plus a baseline pin of the per-record write behavior it +// replaces at the call sites. Lives next to bearers.ts instead of inside +// ../storage.test.ts to keep both files under the project's module size +// ceiling. Runs in Node against an in-memory localStorage stub; WebCrypto +// (crypto.subtle) is native. + +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' + +import type {Bearer, NewBearer} from '../types' +import {deriveBearerAesKey, encryptRecord} from '../keys' +import { + applyBearerChangeset, + loadBearers, + newBearerId, + persistBearer, + readEncryptedBearers, +} from '../storage' +import type {BearerChangeset} from '../storage' +import {writeEncryptedBearers} from './bearers' +import {requiredValue, stubLocalStorage} from '../test-utils' +import type {LocalStorageStub} from '../test-utils' +import './bearers.baseline.cases' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const K1_C = 'cc'.repeat(32) +const K1_D = 'dd'.repeat(32) + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', K1_C, 3_000), + callback: 'https://mint.example/w/cb', + amount: 3_000, + verified: true, + ...overrides, +}) + +let stub: LocalStorageStub + +beforeEach(() => { + stub = stubLocalStorage() +}) + +const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => + spy.mock.calls.filter(([k]) => k === 'sattle_bearers') + +describe('applyBearerChangeset (single-write changeset commit)', () => { + it('commits additions and spent replacements with exactly one write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const oldA = bearerFixture({id: 'old-a'}) + const oldB = bearerFixture({ + id: 'old-b', + url: buildNoteUrl('https://mint.example/w', K1_B, 5_000), + amount: 5_000, + }) + await persistBearer(key, oldA) + await persistBearer(key, oldB) + + const writes = vi.spyOn(stub, 'setItem') + const result = await applyBearerChangeset(key, [oldA, oldB], { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + amount: 4_000, + }), + ], + markSpent: ['old-a', 'old-b'], + }) + + // the whole changeset is ONE setItem on sattle_bearers + expect(bearerWrites(writes)).toHaveLength(1) + + // the returned next list: additions first, then the snapshot with spent + // marks applied + expect(result).toHaveLength(4) + const addA = requiredValue(result[0]) + const addB = requiredValue(result[1]) + const spentA = requiredValue(result[2]) + const spentB = requiredValue(result[3]) + expect(addA.id).not.toBe(addB.id) + expect(addA.amount).toBe(3_000) + expect(addB.amount).toBe(4_000) + expect(addA.createdAt).toBe(addA.updatedAt) + expect(spentA.id).toBe('old-a') + expect(spentA.spent).toBe(true) + expect(spentA.updatedAt).toBeGreaterThan(1000) + expect(spentB.id).toBe('old-b') + expect(spentB.spent).toBe(true) + + // the source of truth is the reloaded ciphertext, not the return value + const reloaded = await loadBearers(key) + expect(reloaded.map((b) => b.id).sort()).toEqual(result.map((b) => b.id).sort()) + expect(requiredValue(reloaded.find((b) => b.id === 'old-a')).spent).toBe(true) + expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true) + expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined() + // nothing plaintext leaked: the fresh k1s are ciphertext-only at rest + const raw = requiredValue(localStorage.getItem('sattle_bearers')) + expect(raw).not.toContain(K1_C) + expect(raw).not.toContain(K1_D) + }) + + it('never mutates the caller snapshot or the changeset', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const snapshot = [bearerFixture({id: 's1'})] + const changeset: BearerChangeset = { + add: [newBearerFixture()], + markSpent: ['s1'], + } + + await applyBearerChangeset(key, snapshot, changeset) + + expect(requiredValue(snapshot[0]).spent).toBeUndefined() + expect('id' in requiredValue(changeset.add[0])).toBe(false) + expect(changeset.markSpent).toEqual(['s1']) + }) + + it('persists nothing when encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + // a decrypt-only key makes every AES-GCM encrypt call reject + const decryptOnly = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32).fill(3), + 'AES-GCM', + false, + ['decrypt'], + ) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + applyBearerChangeset(decryptOnly, [], { + add: [newBearerFixture()], + markSpent: [], + }), + ).rejects.toThrow() + + expect(bearerWrites(writes)).toHaveLength(0) + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept']) + }) + + it('rejects without a partial write when the storage write itself fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const kept = bearerFixture({id: 'kept'}) + await persistBearer(key, kept) + const before = readEncryptedBearers() + stub.setItem = (): void => { + throw new Error('QuotaExceededError') + } + // run this one through the unlocked fallback on purpose: a quota throw + // is SYNCHRONOUS, and Node 24's real navigator.locks never releases a + // lock whose callback throws synchronously (verified Node quirk - every + // browser releases per the Web Locks spec), which would wedge + // 'sattle_bearers' for the rest of the file. Bonus: one test keeps the + // documented plain-Node fallback path (storageLock.ts) exercised. + vi.stubGlobal('navigator', {}) + try { + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + + // nothing was persisted: the pre-existing record is byte-identical + expect(readEncryptedBearers()).toEqual(before) + } finally { + vi.unstubAllGlobals() + } + }) + + it('upserts changed ids and dedupes repeated markSpent ids', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const stale = bearerFixture({id: 'dup', updatedAt: 1000}) + await persistBearer(key, stale) + + const result = await applyBearerChangeset(key, [stale], { + add: [], + markSpent: ['dup', 'dup'], + }) + + // one record per id, never a duplicate append + expect(readEncryptedBearers().filter((r) => r.id === 'dup')).toHaveLength(1) + const reloaded = await loadBearers(key) + expect(reloaded).toHaveLength(1) + expect(requiredValue(reloaded[0]).spent).toBe(true) + expect(requiredValue(result[0]).spent).toBe(true) + }) + + it('preserves a record another tab commits between the snapshot and the lock', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + const mine = bearerFixture({id: 'mine'}) + await persistBearer(key, mine) + + // a controllable Web Locks fake: lock requests park until the test + // releases them, so a foreign write can interleave deterministically + const queue: {name: string; release: () => Promise}[] = [] + vi.stubGlobal('navigator', { + locks: { + request: (name: string, fn: () => unknown): Promise => + new Promise((resolve, reject) => { + queue.push({ + name, + release: async () => { + try { + resolve(await fn()) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + } + }, + }) + }), + }, + }) + try { + const commit = applyBearerChangeset(key, [mine], { + add: [newBearerFixture()], + markSpent: [], + }) + // encryption happens BEFORE the lock request; wait for it to arrive + await vi.waitFor(() => { + expect(queue).toHaveLength(1) + }) + const queuedLock = requiredValue(queue[0]) + expect(queuedLock.name).toBe('sattle_bearers') + + // while our commit waits on the lock, another tab commits a record we + // cannot even decrypt (written under a different seed's key) + const foreign = bearerFixture({ + id: 'foreign-tab', + url: buildNoteUrl('https://mint.example/w', K1_B, 9_000), + }) + const {id: foreignId, ...foreignPlain} = foreign + const foreignParts = await encryptRecord(other, foreignPlain) + writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]) + + await queuedLock.release() + const result = await commit + + // the foreign ciphertext survived our upsert, untouched + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['foreign-tab', 'mine', requiredValue(result[0]).id].sort()) + expect((await loadBearers(other)).map((b) => b.id)).toEqual(['foreign-tab']) + expect((await loadBearers(key)).map((b) => b.id).sort()).toEqual( + ['mine', requiredValue(result[0]).id].sort(), + ) + } finally { + vi.unstubAllGlobals() + } + }) + + it('treats corrupted stored JSON as an empty record set instead of throwing', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + localStorage.setItem('sattle_bearers', 'not json {{{') + + const result = await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: ['gone'], + }) + + // readEncryptedBearers' long-standing contract: unparseable storage + // reads as [] (malformed entries are dropped, never thrown on) - the + // changeset still commits and its single write replaces the corrupt blob + expect(result).toHaveLength(1) + expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id]) + }) + + it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + await persistBearer(other, bearerFixture({id: 'foreign'})) + const writes = vi.spyOn(stub, 'setItem') + + // 'foreign' is not in the caller's snapshot; deriving its spent copy + // would require decrypting an unrelated record, which this primitive + // never does - so the changeset changes nothing and performs no write + const result = await applyBearerChangeset(key, [], { + add: [], + markSpent: ['foreign'], + }) + + expect(result).toEqual([]) + expect(bearerWrites(writes)).toHaveLength(0) + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign']) + }) +}) diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 87bad1f..8e9cdf4 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -4,7 +4,8 @@ import type {EncryptedRecordParts} from '../keys' import {encryptRecord, decryptRecord} from '../keys' -import type {Bearer} from '../types' +import type {Bearer, NewBearer} from '../types' +import {isJsonObject} from '../jsonParsing' import {noteK1, serverOf} from 'lnurlcash-kit' import {withStorageLock} from '../storageLock' @@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number => export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts +const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord => + isJsonObject(value) && + typeof value.id === 'string' && + typeof value.iv === 'string' && + typeof value.ciphertext === 'string' + +const isStoredBearer = (value: unknown): value is Omit => + isJsonObject(value) && + typeof value.url === 'string' && + typeof value.callback === 'string' && + typeof value.amount === 'number' && + typeof value.verified === 'boolean' && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' && + (value.mintPubkey === undefined || typeof value.mintPubkey === 'string') && + (value.spent === undefined || typeof value.spent === 'boolean') && + (value.sortIndex === undefined || typeof value.sortIndex === 'number') && + (value.label === undefined || typeof value.label === 'string') && + (value.deviceId === undefined || typeof value.deviceId === 'string') + const BEARERS_STORAGE_KEY = 'sattle_bearers' export const newBearerId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) - .map(b => b.toString(16).padStart(2, '0')) + .map((b) => b.toString(16).padStart(2, '0')) .join('') export const readEncryptedBearers = (): EncryptedBearerRecord[] => { @@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => { if (!raw) return [] try { const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed : [] + return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : [] } catch { return [] } } -export const writeEncryptedBearers = ( - records: EncryptedBearerRecord[] -): void => { +export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) } +type BearerCommitOptions = {beforeCommit?: () => void} + // decrypts everything currently stored - a record that fails to decrypt // (e.g. written by a different seed's key) is skipped, not destroyed: it // stays in localStorage untouched and simply doesn't show up @@ -46,10 +67,12 @@ export const loadBearers = async (aesKey: CryptoKey): Promise => { const bearers: Bearer[] = [] for (const record of readEncryptedBearers()) { try { - const bearer = await decryptRecord>(aesKey, record) + const bearer = await decryptRecord(aesKey, record) + if (!isStoredBearer(bearer)) throw new Error('Malformed encrypted bearer record.') bearers.push({...bearer, id: record.id}) - } catch { + } catch (error) { // undecryptable with this key - leave it in place + if (!(error instanceof Error)) throw error } } return bearers.sort((a, b) => b.createdAt - a.createdAt) @@ -57,23 +80,123 @@ export const loadBearers = async (aesKey: CryptoKey): Promise => { export const persistBearer = async ( aesKey: CryptoKey, - bearer: Bearer + bearer: Bearer, + options: BearerCommitOptions = {}, ): Promise => { const {id, ...plain} = bearer const parts = await encryptRecord(aesKey, plain) await withStorageLock(BEARERS_STORAGE_KEY, () => { - const records = readEncryptedBearers().filter(r => r.id !== id) + options.beforeCommit?.() + const records = readEncryptedBearers().filter((r) => r.id !== id) records.push({id, ...parts}) writeEncryptedBearers(records) }) } -export const deleteBearerRecord = async (id: string): Promise => { +export const deleteBearerRecord = async ( + id: string, + options: BearerCommitOptions = {}, +): Promise => { await withStorageLock(BEARERS_STORAGE_KEY, () => { - writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id)) + options.beforeCommit?.() + writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id)) }) } +// The atomic unit of bearer persistence: fresh notes to start tracking plus +// ids of snapshot notes to lock as spent. Born-spent notes (carved and +// melted away in one flow) are deliberately not representable - they were +// never the wallet's money in a trackable state. +export type BearerChangeset = { + add: NewBearer[] + markSpent: string[] + upsert?: Bearer[] + remove?: string[] +} + +// Commits a whole changeset as ONE storage write - the fund-critical +// boundary a caller (NWC service, wallet store) awaits before reporting +// success. The per-record path above (persistBearer in a loop) can die +// halfway through a melt: some records persisted, some not, while the +// caller's reactive state already moved on. Here nothing becomes +// observable until the single locked write lands: +// +// - every changed Bearer value is derived from the caller's snapshot: added +// notes get their id/timestamps assigned HERE (so state and storage can +// never disagree about them), spent marks copy the snapshot's record +// - ALL encryption happens BEFORE the lock is taken - crypto is the slow, +// async part and a storage lock must never be held across it (see +// storageLock.ts); if any record fails to encrypt, no write happens at +// all +// - inside the lock the encrypted records are re-read FRESH, so records +// another tab committed after the caller's snapshot survive the upsert - +// changed ids replace their stored copy, everything else is kept as-is +// (unrelated records are never decrypted or re-encrypted) +// - markSpent ids absent from the snapshot are ignored: deriving them would +// require decrypting a record the caller doesn't hold +// - a changeset that changes nothing performs no write at all +// - caller arrays are never mutated +// - options.beforeCommit runs synchronously INSIDE the lock, immediately +// before the single write: the caller's last-chance fence (the wallet +// store revalidates persisted ownership there). Throwing aborts the +// commit with storage untouched. Four parameters are deliberate here: +// the fence is an orthogonal hook, not changeset data, and grouping it +// into the changeset would let callers persist it by accident. +// +// Returns the next local bearer list (additions first, then the snapshot +// with spent marks applied) only after the write succeeded; on any failure +// the promise rejects and persisted state is untouched. +export const applyBearerChangeset = async ( + aesKey: CryptoKey, + snapshot: Bearer[], + changeset: BearerChangeset, + options: BearerCommitOptions = {}, +): Promise => { + const now = Date.now() + const added: Bearer[] = changeset.add.map((note) => ({ + id: newBearerId(), + ...note, + createdAt: now, + updatedAt: now, + })) + const spentIds = new Set(changeset.markSpent) + const spent = new Map() + for (const bearer of snapshot) { + if (spentIds.has(bearer.id)) { + spent.set(bearer.id, {...bearer, spent: true, updatedAt: now}) + } + } + const upserted = changeset.upsert ?? [] + const removedIds = new Set(changeset.remove ?? []) + const changedById = new Map() + for (const bearer of spent.values()) changedById.set(bearer.id, bearer) + for (const bearer of upserted) changedById.set(bearer.id, bearer) + for (const bearer of added) changedById.set(bearer.id, bearer) + const changed = [...changedById.values()] + if (changed.length === 0 && removedIds.size === 0) return snapshot + const encrypted: EncryptedBearerRecord[] = [] + for (const bearer of changed) { + const {id, ...plain} = bearer + const parts = await encryptRecord(aesKey, plain) + encrypted.push({id, ...parts}) + } + await withStorageLock(BEARERS_STORAGE_KEY, () => { + options.beforeCommit?.() + const changedIds = new Set(encrypted.map((r) => r.id)) + const records = readEncryptedBearers().filter( + (record) => !changedIds.has(record.id) && !removedIds.has(record.id), + ) + records.push(...encrypted) + writeEncryptedBearers(records) + }) + const snapshotIds = new Set(snapshot.map((bearer) => bearer.id)) + const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id)) + const retained = snapshot + .filter((bearer) => !removedIds.has(bearer.id)) + .map((bearer) => changedById.get(bearer.id) ?? bearer) + return [...added, ...inserted, ...retained] +} + // wipes every bearer record from this device outright - unlike forgetting // just the linking key, this is not recoverable by restoring the same seed: // the ciphertexts themselves are gone, so only a previously downloaded @@ -91,10 +214,7 @@ export const clearAllBearers = (): void => { // same spent state, the newer updatedAt wins. This is the merge a restore // (backup file now, nostr later) applies after its records decrypt, and it // is what makes multi-device restores converge instead of duplicate. -export const mergeBearers = ( - current: Bearer[], - incoming: Bearer[] -): Bearer[] => { +export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => { const keyOf = (b: Bearer): string => { const k1 = noteK1(b.url) return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}` diff --git a/src/lnurlcash/storage/currentOwner.ts b/src/lnurlcash/storage/currentOwner.ts new file mode 100644 index 0000000..9b3a64b --- /dev/null +++ b/src/lnurlcash/storage/currentOwner.ts @@ -0,0 +1,20 @@ +// Mutators must not let a still-running old tab overwrite namespaces after a +// successor has installed its saved-key owner marker. Ownerless data may only +// move through explicit migration APIs after their own proof checks succeed. + +import {savedKeyOwnerId} from '../keys' + +export class WalletOwnerMismatchError extends Error { + override readonly name = 'WalletOwnerMismatchError' + constructor() { + super('The active wallet owner no longer matches the saved wallet.') + } +} + +export const savedKeyOwnerAllows = (ownerId: string): boolean => { + return savedKeyOwnerId() === ownerId +} + +export const assertSavedKeyOwner = (ownerId: string): void => { + if (!savedKeyOwnerAllows(ownerId)) throw new WalletOwnerMismatchError() +} diff --git a/src/lnurlcash/storage/nwcConnections.ts b/src/lnurlcash/storage/nwcConnections.ts index 582ac5b..b2b8b5a 100644 --- a/src/lnurlcash/storage/nwcConnections.ts +++ b/src/lnurlcash/storage/nwcConnections.ts @@ -1,28 +1,34 @@ -// NWC connection persistence: one localStorage record holding every NIP-47 -// connection this wallet serves (see nwc.ts). A record is public metadata -// only - the wallet-service key is re-derived from the linking key and the -// client pubkey (nwc/connection.ts's deriveNwcWalletKey), and the CLIENT -// secret is never stored at all (NIP-47: the wallet service should not -// store the secret it generates for the client). The budget spend counter -// lives here so a restart doesn't reset a client's allowance. +// NWC connection persistence. Each record and the service-enabled setting +// belong to one canonical wallet owner, so local residue from another wallet +// is never served, edited, revoked, or charged. Hostile localStorage input is +// parsed before use; ownerless v0 records remain hidden until an already +// proven saved wallet explicitly migrates them. + +import {linkingPubKeyHex, savedKeyOwnerId} from '../keys' +import { + clearNwcEnabledForOwner, + clearUnownedNwcEnabled, + readLegacyNwcEnabled, + writeNwcEnabled, +} from './nwcEnabled' +import {savedKeyOwnerAllows} from './currentOwner' +import {isWalletOwnerId} from './walletOwner' export type NwcBudget = { - // the most this connection may pay per period, msat maxMsat: number - // the period length in milliseconds (e.g. 86_400_000 for daily) periodMs: number } -// spend within the current period; rolls over once periodStart is more -// than budget.periodMs in the past export type NwcBudgetSpend = { periodStart: number msat: number } +const NWC_RECORD_VERSION = 1 + export type NwcConnectionRecord = { - // the authorized client's pubkey (the pubkey of the client secret that - // was handed out in the connection string, once, at creation time) + version: typeof NWC_RECORD_VERSION + ownerId: string clientPubkey: string relays: string[] budget: NwcBudget @@ -30,80 +36,192 @@ export type NwcConnectionRecord = { createdAt: number } +type LegacyNwcConnectionRecord = Omit + +type StoredNwcConnection = + {kind: 'owned'; record: NwcConnectionRecord} | {kind: 'legacy'; record: LegacyNwcConnectionRecord} + const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections' -const HEX_64 = /^[0-9a-f]{64}$/i +const HEX_64 = /^[0-9a-f]{64}$/ -// strict shape check, same spirit as passkeySlots.ts: localStorage content -// is not trustworthy input, so records are validated before use -const isValidNwcConnectionRecord = ( - record: unknown -): record is NwcConnectionRecord => { - if (typeof record !== 'object' || record === null) return false - const r = record as Record - const budget = r.budget as Record | null - const spent = r.spent as Record | null - return ( - typeof r.clientPubkey === 'string' && - HEX_64.test(r.clientPubkey) && - Array.isArray(r.relays) && - r.relays.length > 0 && - r.relays.every( - relay => typeof relay === 'string' && /^wss?:\/\//.test(relay) - ) && - typeof budget === 'object' && - budget !== null && - typeof budget.maxMsat === 'number' && - Number.isInteger(budget.maxMsat) && - budget.maxMsat > 0 && - typeof budget.periodMs === 'number' && - budget.periodMs > 0 && - typeof spent === 'object' && - spent !== null && - typeof spent.periodStart === 'number' && - typeof spent.msat === 'number' && - spent.msat >= 0 && - typeof r.createdAt === 'number' - ) +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const isPositiveInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + +const isNonNegativeInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + +const isRelay = (value: unknown): value is string => { + if (typeof value !== 'string') return false + try { + const url = new URL(value) + return url.protocol === 'wss:' || url.protocol === 'ws:' + } catch { + return false + } } -// malformed entries are dropped, not thrown on - one corrupted record must -// not take the remaining connections down with it -export const readNwcConnections = (): NwcConnectionRecord[] => { +const parseStoredConnection = (value: unknown): StoredNwcConnection | null => { + if (!isRecord(value)) return null + const {clientPubkey, relays, budget, spent, createdAt} = value + if ( + typeof clientPubkey !== 'string' || + !HEX_64.test(clientPubkey) || + !Array.isArray(relays) || + relays.length === 0 || + !relays.every(isRelay) || + !isRecord(budget) || + !isPositiveInteger(budget.maxMsat) || + !isPositiveInteger(budget.periodMs) || + !isRecord(spent) || + !isNonNegativeInteger(spent.periodStart) || + !isNonNegativeInteger(spent.msat) || + !isNonNegativeInteger(createdAt) + ) { + return null + } + const base: LegacyNwcConnectionRecord = { + clientPubkey, + relays, + budget: {maxMsat: budget.maxMsat, periodMs: budget.periodMs}, + spent: {periodStart: spent.periodStart, msat: spent.msat}, + createdAt, + } + if (!Object.hasOwn(value, 'version') && !Object.hasOwn(value, 'ownerId')) { + return {kind: 'legacy', record: base} + } + if (value.version !== NWC_RECORD_VERSION || !isWalletOwnerId(value.ownerId)) { + return null + } + return { + kind: 'owned', + record: {...base, version: NWC_RECORD_VERSION, ownerId: value.ownerId}, + } +} + +const readStoredConnections = (): StoredNwcConnection[] => { const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY) - if (!raw) return [] + if (raw === null) return [] try { const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) - ? parsed.filter(isValidNwcConnectionRecord) - : [] + if (!Array.isArray(parsed)) return [] + return parsed + .map(parseStoredConnection) + .filter((entry): entry is StoredNwcConnection => entry !== null) } catch { return [] } } -export const writeNwcConnections = ( - records: NwcConnectionRecord[] -): void => { - localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records)) +const storedValue = (entry: StoredNwcConnection): NwcConnectionRecord | LegacyNwcConnectionRecord => + entry.record + +export const readNwcConnections = (ownerId: unknown): NwcConnectionRecord[] => { + if (!isWalletOwnerId(ownerId)) return [] + return readStoredConnections() + .filter( + (entry): entry is Extract => + entry.kind === 'owned' && entry.record.ownerId === ownerId, + ) + .map((entry) => entry.record) +} + +export const writeNwcConnections = (ownerId: unknown, records: NwcConnectionRecord[]): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC connections require a valid wallet owner.') + } + const canonical: NwcConnectionRecord[] = [] + for (const record of records) { + const parsed = parseStoredConnection(record) + if (parsed?.kind !== 'owned' || parsed.record.ownerId !== ownerId) { + throw new Error('Refusing to write an invalid or foreign NWC connection.') + } + canonical.push(parsed.record) + } + const preserved = readStoredConnections() + .filter((entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId) + .map(storedValue) + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify([...preserved, ...canonical])) } -// upsert by client pubkey; returns the stored record. Callers serialize -// read-modify-write cycles themselves (the NWC service serializes per -// connection through its request queue) export const persistNwcConnection = ( - record: NwcConnectionRecord + ownerId: unknown, + record: NwcConnectionRecord, ): NwcConnectionRecord => { - const records = readNwcConnections() - const index = records.findIndex(r => r.clientPubkey === record.clientPubkey) + if (!isWalletOwnerId(ownerId) || record.ownerId !== ownerId) { + throw new Error('NWC connection writes require a valid wallet owner.') + } + const records = readNwcConnections(ownerId) + const index = records.findIndex((stored) => stored.clientPubkey === record.clientPubkey) if (index >= 0) records[index] = record else records.push(record) - writeNwcConnections(records) + writeNwcConnections(ownerId, records) return record } -export const removeNwcConnection = (clientPubkey: string): void => { +export const removeNwcConnection = (ownerId: unknown, clientPubkey: string): void => { + if (!isWalletOwnerId(ownerId)) return writeNwcConnections( - readNwcConnections().filter(r => r.clientPubkey !== clientPubkey) + ownerId, + readNwcConnections(ownerId).filter((record) => record.clientPubkey !== clientPubkey), ) } + +export type NwcLegacyMigrationResult = { + connections: number + enabled: boolean +} + +export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => { + const ownerId = linkingPubKeyHex(linkingKey) + if (savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy NWC migration requires a proven saved wallet owner.') + } + + const stored = readStoredConnections() + let connections = 0 + const migrated = stored.map((entry) => { + if (entry.kind === 'owned') return entry.record + connections += 1 + return { + ...entry.record, + version: NWC_RECORD_VERSION, + ownerId, + } satisfies NwcConnectionRecord + }) + if (connections > 0) { + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(migrated)) + } + + const legacyEnabled = readLegacyNwcEnabled() + if (legacyEnabled !== null) writeNwcEnabled(ownerId, legacyEnabled) + return {connections, enabled: legacyEnabled !== null} +} + +const persistStoredConnections = (entries: StoredNwcConnection[]): void => { + if (entries.length === 0) { + localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY) + return + } + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(entries.map(storedValue))) +} + +export const clearNwcStorageForOwner = (ownerId: unknown): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC teardown requires a valid wallet owner.') + } + persistStoredConnections( + readStoredConnections().filter( + (entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId, + ), + ) + clearNwcEnabledForOwner(ownerId) +} + +export const clearUnownedNwcStorage = (): void => { + persistStoredConnections(readStoredConnections().filter((entry) => entry.kind === 'owned')) + clearUnownedNwcEnabled() +} diff --git a/src/lnurlcash/storage/nwcEnabled.ts b/src/lnurlcash/storage/nwcEnabled.ts new file mode 100644 index 0000000..df9b4fe --- /dev/null +++ b/src/lnurlcash/storage/nwcEnabled.ts @@ -0,0 +1,87 @@ +// NWC service-enabled persistence: one owner-bearing record for whether the +// wallet service should run. Split from nwcConnections.ts (size ceiling) - +// the enabled flag and the connection records are independent storage keys +// with the same ownership rules: hostile input is parsed before use, and a +// legacy global 'true'/'false' string counts as ownerless residue until an +// already proven saved wallet migrates it. + +import {isWalletOwnerId} from './walletOwner' +import {savedKeyOwnerAllows} from './currentOwner' + +const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled' +const NWC_ENABLED_VERSION = 1 + +type NwcEnabledRecord = { + version: typeof NWC_ENABLED_VERSION + ownerId: string + enabled: boolean +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const readNwcEnabledRecord = (): NwcEnabledRecord | null => { + const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY) + if (raw === null) return null + try { + const parsed: unknown = JSON.parse(raw) + if ( + !isRecord(parsed) || + parsed.version !== NWC_ENABLED_VERSION || + !isWalletOwnerId(parsed.ownerId) || + typeof parsed.enabled !== 'boolean' + ) { + return null + } + return { + version: NWC_ENABLED_VERSION, + ownerId: parsed.ownerId, + enabled: parsed.enabled, + } + } catch { + return null + } +} + +export const readNwcEnabled = (ownerId: unknown): boolean => { + if (!isWalletOwnerId(ownerId)) return false + const record = readNwcEnabledRecord() + return record?.ownerId === ownerId && record.enabled +} + +export const writeNwcEnabled = (ownerId: unknown, enabled: boolean): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC enabled state requires a valid wallet owner.') + } + localStorage.setItem( + NWC_ENABLED_STORAGE_KEY, + JSON.stringify({version: NWC_ENABLED_VERSION, ownerId, enabled}), + ) +} + +// the pre-owner storage form was a bare 'true'/'false' string - returns it +// when present so legacy migration can re-home the value under the proven +// owner, null for anything else (absent, junk, or an owned envelope) +export const readLegacyNwcEnabled = (): boolean | null => { + const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY) + if (raw === 'true') return true + if (raw === 'false') return false + return null +} + +// teardown of one owner's enabled state - any other owner's record (or a +// legacy string) is left exactly as found +export const clearNwcEnabledForOwner = (ownerId: unknown): void => { + if (!isWalletOwnerId(ownerId)) return + if (readNwcEnabledRecord()?.ownerId === ownerId) { + localStorage.removeItem(NWC_ENABLED_STORAGE_KEY) + } +} + +// install-time residue sweep: only the legacy string form is unowned; an +// owned envelope stays (it is inert for every other owner) +export const clearUnownedNwcEnabled = (): void => { + if (readLegacyNwcEnabled() !== null) { + localStorage.removeItem(NWC_ENABLED_STORAGE_KEY) + } +} diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 4d63c8a..3b43cef 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -1,64 +1,202 @@ -// Passkey-slot persistence: one localStorage record holding every passkey -// wrap of the linking key (see passkeys.ts). Slots are public metadata plus -// AES-GCM wrapped keys - a wrapped blob is useless without the passkey's -// authenticator, so this sits next to the plaintext registries. Read/write -// are exported bare; callers serialize read-modify-write cycles with -// withStorageLock, same convention as bearers.ts. +// Passkey-slot persistence: every new slot is bound to the canonical owner +// of the saved linking key. localStorage remains hostile input, so owner +// markers are parsed strictly and reads expose only slots belonging to the +// currently proven saved owner. Mutations preserve every other record. + +import {savedKeyOwnerId} from '../keys' +import {isJsonObject} from '../jsonParsing' +import {isWalletOwnerId} from './walletOwner' -// the encrypted half of a slot: the linking key under a passkey wrap key export type PasskeyWrap = { - hkdfSalt: string // hex, 16 bytes - per-slot HKDF salt - iv: string // hex, 12 bytes - wrappedKey: string // hex, AES-GCM ciphertext of the 32-byte linking key + readonly hkdfSalt: string + readonly iv: string + readonly wrappedKey: string } +export const PASSKEY_SLOT_VERSION = 1 as const + export type PasskeySlot = PasskeyWrap & { - credentialId: string // hex of the raw WebAuthn credential id - createdAt: number - name?: string // optional holder label ('laptop', 'phone', ...) + readonly credentialId: string + readonly createdAt: number + readonly name?: string + readonly ownerId: string + readonly version: typeof PASSKEY_SLOT_VERSION +} + +type StoredPasskeySlot = PasskeyWrap & { + readonly credentialId: string + readonly createdAt: number + readonly name?: string + readonly ownerId?: unknown + readonly version?: unknown +} + +type ParsedPasskeySlot = { + readonly record: StoredPasskeySlot + readonly claimedOwnerId: string | null + readonly isCurrent: boolean } export const PASSKEY_SLOTS_STORAGE_KEY = 'sattle_passkey_slots' -// strict shape check, same spirit as keys.ts's isValidStoredSecret: -// localStorage content is not trustworthy input (hand-edited, restored -// backups), so slots are validated before use -const isValidPasskeySlot = (slot: unknown): slot is PasskeySlot => { - if (typeof slot !== 'object' || slot === null) return false - const s = slot as Record - return ( - typeof s.credentialId === 'string' && - s.credentialId.length > 0 && - s.credentialId.length % 2 === 0 && - /^[0-9a-f]+$/i.test(s.credentialId) && - typeof s.hkdfSalt === 'string' && - /^[0-9a-f]{32}$/i.test(s.hkdfSalt) && - typeof s.iv === 'string' && - /^[0-9a-f]{24}$/i.test(s.iv) && - typeof s.wrappedKey === 'string' && - s.wrappedKey.length > 0 && - s.wrappedKey.length % 2 === 0 && - /^[0-9a-f]+$/i.test(s.wrappedKey) && - typeof s.createdAt === 'number' && - (s.name === undefined || typeof s.name === 'string') - ) +const SLOT_KEYS: readonly string[] = [ + 'credentialId', + 'hkdfSalt', + 'iv', + 'wrappedKey', + 'createdAt', + 'name', + 'ownerId', + 'version', +] + +const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { + if (!isJsonObject(slot)) return null + if ( + typeof slot.credentialId === 'string' && + slot.credentialId.length > 0 && + slot.credentialId.length % 2 === 0 && + /^[0-9a-f]+$/i.test(slot.credentialId) && + typeof slot.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/i.test(slot.hkdfSalt) && + typeof slot.iv === 'string' && + /^[0-9a-f]{24}$/i.test(slot.iv) && + typeof slot.wrappedKey === 'string' && + slot.wrappedKey.length > 0 && + slot.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/i.test(slot.wrappedKey) && + typeof slot.createdAt === 'number' && + (slot.name === undefined || typeof slot.name === 'string') && + Object.keys(slot).every((key) => SLOT_KEYS.includes(key)) + ) { + const record: StoredPasskeySlot = { + credentialId: slot.credentialId, + hkdfSalt: slot.hkdfSalt, + iv: slot.iv, + wrappedKey: slot.wrappedKey, + createdAt: slot.createdAt, + ...(slot.name !== undefined ? {name: slot.name} : {}), + } + const hasOwner = Object.hasOwn(slot, 'ownerId') + const hasVersion = Object.hasOwn(slot, 'version') + if (!hasOwner && !hasVersion) return {record, claimedOwnerId: null, isCurrent: false} + if (!isWalletOwnerId(slot.ownerId)) return null + if (!hasVersion) { + return { + record: {...record, ownerId: slot.ownerId}, + claimedOwnerId: slot.ownerId, + isCurrent: false, + } + } + if (slot.version !== PASSKEY_SLOT_VERSION) return null + return { + record: {...record, ownerId: slot.ownerId, version: PASSKEY_SLOT_VERSION}, + claimedOwnerId: slot.ownerId, + isCurrent: true, + } + } + return null } -// malformed entries are dropped, not thrown on - one corrupted slot must -// not take the remaining passkeys down with it -export const readPasskeySlots = (): PasskeySlot[] => { +const readStoredPasskeySlots = (): ParsedPasskeySlot[] => { const raw = localStorage.getItem(PASSKEY_SLOTS_STORAGE_KEY) if (!raw) return [] try { const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed.filter(isValidPasskeySlot) : [] + if (!Array.isArray(parsed)) return [] + const slots: ParsedPasskeySlot[] = [] + for (const value of parsed) { + const slot = parseStoredPasskeySlot(value) + if (slot !== null) slots.push(slot) + } + return slots } catch { return [] } } +const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | null => { + if (!stored.isCurrent || stored.claimedOwnerId !== ownerId) { + return null + } + const record = stored.record + return { + credentialId: record.credentialId, + hkdfSalt: record.hkdfSalt, + iv: record.iv, + wrappedKey: record.wrappedKey, + createdAt: record.createdAt, + ...(record.name !== undefined ? {name: record.name} : {}), + ownerId, + version: PASSKEY_SLOT_VERSION, + } +} + +export const readPasskeySlots = (): PasskeySlot[] => { + const ownerId = savedKeyOwnerId() + if (ownerId === null) return [] + return readStoredPasskeySlots() + .map((slot) => asOwnedSlot(slot, ownerId)) + .filter((slot): slot is PasskeySlot => slot !== null) +} + export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 -export const writePasskeySlots = (slots: PasskeySlot[]): void => { +export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Passkey slots require the proven saved wallet owner.') + } + if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { + throw new Error('Refusing to write a passkey slot for a different wallet.') + } + const preserved = readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== ownerId) + .map((slot) => slot.record) + localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify([...preserved, ...slots])) +} + +export const adoptLegacyPasskeySlots = (ownerId: string): number => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy passkey migration requires a proven owner.') + } + const stored = readStoredPasskeySlots() + let adopted = 0 + const migrated = stored.map((slot) => { + if (slot.isCurrent || (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)) { + return slot.record + } + adopted += 1 + return {...slot.record, ownerId, version: PASSKEY_SLOT_VERSION} + }) + if (adopted > 0) { + localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(migrated)) + } + return adopted +} + +const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => { + if (slots.length === 0) { + localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY) + return + } localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots)) } + +export const clearPasskeySlotsForOwner = (ownerId: string): void => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Passkey teardown requires the proven saved wallet owner.') + } + persistStoredPasskeySlots( + readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== ownerId) + .map((slot) => slot.record), + ) +} + +export const clearUnownedPasskeySlots = (): void => { + persistStoredPasskeySlots( + readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== null) + .map((slot) => slot.record), + ) +} diff --git a/src/lnurlcash/storage/settings.ts b/src/lnurlcash/storage/settings.ts index fa44848..ec226c5 100644 --- a/src/lnurlcash/storage/settings.ts +++ b/src/lnurlcash/storage/settings.ts @@ -20,15 +20,12 @@ export const loadSettings = (): WalletSettings => { if (typeof parsed !== 'object' || parsed === null) return {} const s = parsed as Record return { - defaultMint: - typeof s.defaultMint === 'string' ? s.defaultMint : undefined, + defaultMint: typeof s.defaultMint === 'string' ? s.defaultMint : undefined, nostrBackupEnabled: - typeof s.nostrBackupEnabled === 'boolean' - ? s.nostrBackupEnabled - : undefined, + typeof s.nostrBackupEnabled === 'boolean' ? s.nostrBackupEnabled : undefined, nostrBackupRelays: Array.isArray(s.nostrBackupRelays) ? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string') - : undefined + : undefined, } } catch { return {} diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts new file mode 100644 index 0000000..a053f1c --- /dev/null +++ b/src/lnurlcash/storage/storedSecret.ts @@ -0,0 +1,130 @@ +// Saved linking-key records have two safe at-rest forms: ownerless legacy +// records and version-1 owner-bearing records. The briefly shipped +// unversioned owner-bearing shape remains readable only so a proven key can +// upgrade it; it never establishes ownership by itself. Any other metadata +// is rejected rather than downgraded to adoptable legacy data. + +import {isJsonObject} from '../jsonParsing' +import {isWalletOwnerId} from './walletOwner' + +export const STORED_SECRET_VERSION = 1 as const + +type PlainStoredSecret = { + readonly enc: false + readonly value: string + readonly ownerId?: unknown + readonly version?: unknown +} + +type EncryptedStoredSecret = { + readonly enc: true + readonly salt: string + readonly iv: string + readonly ciphertext: string + readonly ownerId?: unknown + readonly version?: unknown +} + +export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret + +type ParsedStoredSecret = { + readonly secret: StoredSecret + readonly claimedOwnerId: string | null + readonly isCurrent: boolean +} + +const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const +const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const + +const hasOnlyKeys = (record: Record, allowed: readonly string[]): boolean => + Object.keys(record).every((key) => allowed.includes(key)) + +export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => { + if (!isJsonObject(stored)) return null + let secret: StoredSecret + if (stored.enc === false) { + if ( + typeof stored.value !== 'string' || + !/^[0-9a-f]{64}$/i.test(stored.value) || + !hasOnlyKeys(stored, PLAIN_KEYS) + ) { + return null + } + secret = {enc: false, value: stored.value} + } else if (stored.enc === true) { + if ( + typeof stored.salt !== 'string' || + !/^[0-9a-f]{32}$/i.test(stored.salt) || + typeof stored.iv !== 'string' || + !/^[0-9a-f]{24}$/i.test(stored.iv) || + typeof stored.ciphertext !== 'string' || + stored.ciphertext.length === 0 || + stored.ciphertext.length % 2 !== 0 || + !/^[0-9a-f]+$/i.test(stored.ciphertext) || + !hasOnlyKeys(stored, ENCRYPTED_KEYS) + ) { + return null + } + secret = { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + } + } else { + return null + } + + const hasOwner = Object.hasOwn(stored, 'ownerId') + const hasVersion = Object.hasOwn(stored, 'version') + if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false} + if (!isWalletOwnerId(stored.ownerId)) return null + if (!hasVersion) { + return { + secret: {...secret, ownerId: stored.ownerId}, + claimedOwnerId: stored.ownerId, + isCurrent: false, + } + } + if (stored.version !== STORED_SECRET_VERSION) return null + return { + secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION}, + claimedOwnerId: stored.ownerId, + isCurrent: true, + } +} + +export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => + parseStoredSecret(stored) !== null + +export const storedSecretOwnerId = (stored: StoredSecret): string | null => { + const parsed = parseStoredSecret(stored) + return parsed?.isCurrent === true ? parsed.claimedOwnerId : null +} + +export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null => + parseStoredSecret(stored)?.claimedOwnerId ?? null + +export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => { + if (stored.enc === false) { + return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION} + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + ownerId, + version: STORED_SECRET_VERSION, + } +} + +export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { + if (stored.enc === false) return {enc: false, value: stored.value} + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + } +} diff --git a/src/lnurlcash/storage/walletOwner.ts b/src/lnurlcash/storage/walletOwner.ts new file mode 100644 index 0000000..d430b60 --- /dev/null +++ b/src/lnurlcash/storage/walletOwner.ts @@ -0,0 +1,36 @@ +// Wallet owner marker. The saved linking-key record (and, in later work, +// every credential-ish record: passkey slots, NWC connections, the +// trusted-mint registry) carries an ownerId binding it to exactly one +// wallet identity, so a restored or foreign wallet can never inherit +// residue from a previous one and a stale tab cannot act for a replaced +// owner. +// +// The canonical ownerId is linkingPubKeyHex(linkingKey): the lowercase +// 66-char compressed secp256k1 pubkey hex of the wallet's LUD-05 linking +// key. It is a PUBLIC value - knowing it proves nothing. That is why a +// marker may only be WRITTEN from a freshly derived or freshly proven key +// (a new save, or ensureSavedKeyOwner after a successful unlock) and never +// copied from a backup file, a credential id, or any other stored claim. +// +// Failure model: localStorage is hand-editable and backups are hostile +// input, so a marker is never trusted on presence alone. Anything that is +// not byte-exactly a valid compressed-pubkey hex - wrong length, wrong +// case, non-hex, off-curve, non-string - is rejected by its owning schema. +// Only a record with no ownership metadata at all is legacy ownerless data; +// malformed or future metadata must never be downgraded into that adoptable +// path. + +import {secp256k1} from '@noble/curves/secp256k1.js' + +// strict shape AND curve check: exactly what linkingPubKeyHex can produce +export const isWalletOwnerId = (ownerId: unknown): ownerId is string => { + if (typeof ownerId !== 'string' || !/^0[23][0-9a-f]{64}$/.test(ownerId)) { + return false + } + try { + secp256k1.Point.fromHex(ownerId) + return true + } catch { + return false + } +} diff --git a/src/lnurlcash/storage/walletOwnerEvents.ts b/src/lnurlcash/storage/walletOwnerEvents.ts new file mode 100644 index 0000000..ba0b366 --- /dev/null +++ b/src/lnurlcash/storage/walletOwnerEvents.ts @@ -0,0 +1,17 @@ +// Storage events only wake an owning store to reread its saved-key marker. +// Their payload may be stale when writes arrive faster than delivery. + +export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' + +// Each subscription binds its own storage listener and removes exactly it on +// unsubscribe: no shared reference counting, so an abandoned subscriber can +// never keep another subscriber's window listener (or runtime) alive. +export const onSavedKeyStorageChange = (listener: () => void): (() => void) => { + const target = typeof window === 'undefined' ? null : window + const onStorage = (event: StorageEvent): void => { + if (event.key !== LINKING_KEY_STORAGE_KEY && event.key !== null) return + listener() + } + target?.addEventListener('storage', onStorage) + return () => target?.removeEventListener('storage', onStorage) +} diff --git a/src/lnurlcash/storageLock.test.ts b/src/lnurlcash/storageLock.test.ts new file mode 100644 index 0000000..a6a9afb --- /dev/null +++ b/src/lnurlcash/storageLock.test.ts @@ -0,0 +1,30 @@ +import {describe, expect, it, vi} from 'vitest' + +import {withStorageLock} from './storageLock' + +describe('withStorageLock fallback', () => { + it('runs unlocked when Web Locks are unavailable without promising serialization', async () => { + vi.stubGlobal('navigator', {}) + const entered: string[] = [] + let releaseFirst: (() => void) | undefined + + try { + const first = withStorageLock('registry', async () => { + entered.push('first') + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + const second = withStorageLock('registry', () => { + entered.push('second') + }) + + await second + expect(entered).toEqual(['first', 'second']) + releaseFirst?.() + await first + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/src/lnurlcash/storageLock.ts b/src/lnurlcash/storageLock.ts index dc96281..16ff91b 100644 --- a/src/lnurlcash/storageLock.ts +++ b/src/lnurlcash/storageLock.ts @@ -3,12 +3,13 @@ // lose each other's records (worst case: a stale tab overwrites a freshly // persisted rotated note after its old k1 was burned). Falls back to // running unlocked where Web Locks is unavailable (plain-Node tests, very -// old browsers). -export const withStorageLock = ( - name: string, - fn: () => T | Promise -): Promise => { +// old browsers). That fallback provides no cross-tab serialization +// guarantee; the promise hop only normalizes synchronous callback errors. +export const withStorageLock = (name: string, fn: () => T | Promise): Promise => { const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined - if (locks) return locks.request(name, fn) - return Promise.resolve(fn()) + if (locks) return locks.request(name, () => Promise.resolve().then(fn)) + return Promise.resolve().then(fn) } + +export const storageLocksAvailable = (): boolean => + typeof navigator !== 'undefined' && navigator.locks !== undefined diff --git a/src/lnurlcash/test-utils.ts b/src/lnurlcash/test-utils.ts index 4e9697f..42a6fb5 100644 --- a/src/lnurlcash/test-utils.ts +++ b/src/lnurlcash/test-utils.ts @@ -2,6 +2,9 @@ // a browser) plus the mock-mint helpers every suite uses. import {vi} from 'vitest' +import {parseJsonArray, parseJsonObject, parseJsonObjectArray} from './jsonParsing' + +export {parseJsonArray, parseJsonObject, parseJsonObjectArray} export class LocalStorageStub { private map = new Map() @@ -26,3 +29,16 @@ export const stubLocalStorage = (): LocalStorageStub => { vi.stubGlobal('localStorage', stub) return stub } + +export const requiredValue = ( + value: T | null | undefined, + message = 'Expected test value to be present', +): T => { + if (value === null || value === undefined) throw new TypeError(message) + return value +} + +export const requiredString = (value: unknown, message = 'Expected a string value'): string => { + if (typeof value !== 'string') throw new TypeError(message) + return value +} diff --git a/src/lnurlcash/trustedMintMerge.ts b/src/lnurlcash/trustedMintMerge.ts new file mode 100644 index 0000000..8909b25 --- /dev/null +++ b/src/lnurlcash/trustedMintMerge.ts @@ -0,0 +1,43 @@ +// Hostile backup merge policy stays separate from live mint transitions: +// local records win, and imported trust starts unlocked and unconfirmed. + +import type {TrustedMint} from './trustedMints' +import {isJsonObject} from './jsonParsing' +import {isValidMintPubkey, type MintTransition} from './trustedMintTransitions' + +export const mergeMints = (mints: TrustedMint[], incoming: unknown[]): MintTransition => { + const knownServers = new Set(mints.map((mint) => mint.server)) + const merged = [...mints] + let added = 0 + for (const mint of incoming) { + if ( + !isJsonObject(mint) || + typeof mint.server !== 'string' || + typeof mint.mintPubkey !== 'string' || + typeof mint.addedAt !== 'number' || + !isValidMintPubkey(mint.mintPubkey) + ) { + continue + } + if (knownServers.has(mint.server)) continue + merged.push({ + server: mint.server, + mintPubkey: mint.mintPubkey.toLowerCase(), + addedAt: mint.addedAt, + locked: false, + unconfirmed: true, + nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined, + nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined, + nodeCapacityMsat: + typeof mint.nodeCapacityMsat === 'number' ? mint.nodeCapacityMsat : undefined, + nodeNumChannels: typeof mint.nodeNumChannels === 'number' ? mint.nodeNumChannels : undefined, + nodeNumPeers: typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined, + username: typeof mint.username === 'string' ? mint.username : undefined, + }) + knownServers.add(mint.server) + added++ + } + return added === 0 + ? {mints, result: 0, changed: false} + : {mints: merged, result: added, changed: true} +} diff --git a/src/lnurlcash/trustedMintTransitions.ts b/src/lnurlcash/trustedMintTransitions.ts new file mode 100644 index 0000000..ef3287f --- /dev/null +++ b/src/lnurlcash/trustedMintTransitions.ts @@ -0,0 +1,211 @@ +// Pure trusted-mint registry transitions. Persistence serializes these +// operations, while this module keeps pinning and backup policy auditable. + +import type {TrustedMint, TrustedMintNodeInfo, TrustKeyResult} from './trustedMints' + +const PUBKEY_PATTERN = /^[0-9a-f]{66}$/ + +export const isValidMintPubkey = (value: string): boolean => + PUBKEY_PATTERN.test(value.toLowerCase()) + +export type MintTransition = { + readonly mints: TrustedMint[] + readonly result: T + readonly changed: boolean +} + +type MintKeyInput = { + readonly server: string + readonly mintPubkey: string +} + +type AddMintInput = MintKeyInput & { + readonly nodeInfo?: TrustedMintNodeInfo +} + +const unchanged = (mints: TrustedMint[], result: T): MintTransition => ({ + mints, + result, + changed: false, +}) + +const changed = (mints: TrustedMint[], result: T): MintTransition => ({ + mints, + result, + changed: true, +}) + +export const lockMint = ( + mints: TrustedMint[], + input: MintKeyInput, +): MintTransition => { + const key = input.mintPubkey.trim().toLowerCase() + if (!input.server || !isValidMintPubkey(key)) { + return unchanged(mints, 'unchanged') + } + const existing = mints.find((mint) => mint.server === input.server) + if (!existing) { + return changed( + [ + ...mints, + { + server: input.server, + mintPubkey: key, + addedAt: Date.now(), + locked: true, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) { + if (existing.locked && !existing.unconfirmed) { + return unchanged(mints, 'unchanged') + } + return changed( + mints.map((mint) => + mint.server === input.server ? {...mint, locked: true, unconfirmed: undefined} : mint, + ), + 'unchanged', + ) + } + if (existing.pendingMintPubkey === key) { + return unchanged(mints, 'rekey-pending') + } + return changed( + mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)), + 'rekey-pending', + ) +} + +export const grandfatherMint = ( + mints: TrustedMint[], + input: MintKeyInput, +): MintTransition => { + const key = input.mintPubkey.trim().toLowerCase() + if (!input.server || !isValidMintPubkey(key)) { + return unchanged(mints, 'unchanged') + } + const existing = mints.find((mint) => mint.server === input.server) + if (!existing) { + return changed( + [ + ...mints, + { + server: input.server, + mintPubkey: key, + addedAt: Date.now(), + locked: false, + unconfirmed: true, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) return unchanged(mints, 'unchanged') + if (existing.pendingMintPubkey === key) { + return unchanged(mints, 'rekey-pending') + } + return changed( + mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)), + 'rekey-pending', + ) +} + +export const addMint = ( + mints: TrustedMint[], + input: AddMintInput, +): MintTransition => { + const server = input.server.trim() + const key = input.mintPubkey.trim().toLowerCase() + if (!server) throw new Error('Enter a server.') + if (!isValidMintPubkey(key)) { + throw new Error('Signing key must be a 33-byte compressed pubkey (66 hex characters).') + } + const existing = mints.find((mint) => mint.server === server) + if (!existing) { + return changed( + [ + ...mints, + { + server, + mintPubkey: key, + addedAt: Date.now(), + locked: false, + ...input.nodeInfo, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) { + return changed( + mints.map((mint) => + mint.server === server ? {...mint, ...input.nodeInfo, unconfirmed: undefined} : mint, + ), + 'unchanged', + ) + } + return changed( + mints.map((mint) => + mint.server === server ? {...mint, pendingMintPubkey: key, ...input.nodeInfo} : mint, + ), + 'rekey-pending', + ) +} + +export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTransition => { + const pending = mints.find((mint) => mint.server === server)?.pendingMintPubkey + if (!pending) return unchanged(mints, undefined) + return changed( + mints.map((mint) => + mint.server === server + ? { + ...mint, + mintPubkey: pending, + pendingMintPubkey: undefined, + unconfirmed: undefined, + } + : mint, + ), + undefined, + ) +} + +export const dismissMintRekey = (mints: TrustedMint[], server: string): MintTransition => { + if (!mints.some((mint) => mint.server === server)) { + return unchanged(mints, undefined) + } + return changed( + mints.map((mint) => (mint.server === server ? {...mint, pendingMintPubkey: undefined} : mint)), + undefined, + ) +} + +export const cacheMintNodeInfo = ( + mints: TrustedMint[], + server: string, + nodeInfo: TrustedMintNodeInfo, +): MintTransition => { + if (!mints.some((mint) => mint.server === server)) { + return unchanged(mints, undefined) + } + return changed( + mints.map((mint) => (mint.server === server ? {...mint, ...nodeInfo} : mint)), + undefined, + ) +} + +export const removeMint = (mints: TrustedMint[], server: string): MintTransition => { + const existing = mints.find((mint) => mint.server === server) + if (!existing) return unchanged(mints, undefined) + if (existing.locked) { + throw new Error("Can't remove - you hold a bearer note from this mint.") + } + return changed( + mints.filter((mint) => mint.server !== server), + undefined, + ) +} + +export const clearMints = (): MintTransition => changed([], undefined) diff --git a/src/lnurlcash/trustedMints.storageEvents.test.ts b/src/lnurlcash/trustedMints.storageEvents.test.ts new file mode 100644 index 0000000..8f2769c --- /dev/null +++ b/src/lnurlcash/trustedMints.storageEvents.test.ts @@ -0,0 +1,157 @@ +// Cross-tab events are hints, never authority: the current localStorage +// value is the only state a listener may project into a tab. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex} from './keys' +import {onTrustedMintsChange, readTrustedMints, type TrustedMint} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const STORAGE_KEY = 'sattle_trusted_mints' +const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)) +const MINT_KEY = '02' + 'aa'.repeat(32) + +const mint = (server: string): TrustedMint => ({ + server, + mintPubkey: MINT_KEY, + addedAt: 1, + locked: false, +}) + +const store = (mints: TrustedMint[]): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({version: 1, ownerId: OWNER_ID, mints})) +} + +const storageEvent = (key: string | null, newValue: string | null): Event => { + const event = new Event('storage') + Object.defineProperties(event, { + key: {value: key}, + newValue: {value: newValue}, + }) + return event +} + +beforeEach(() => { + vi.unstubAllGlobals() + stubLocalStorage() +}) + +describe('trusted-mint storage-event convergence', () => { + it('projects a replacement from current storage instead of event newValue', () => { + // Given a tab observing an existing trusted mint + const events = new EventTarget() + vi.stubGlobal('window', events) + store([mint('before.example')]) + const observed: string[][] = [] + const unsubscribe = onTrustedMintsChange(() => { + observed.push(readTrustedMints(OWNER_ID).map((entry) => entry.server)) + }) + + try { + // When another tab replaces storage but a delayed event carries obsolete bytes + store([mint('current.example')]) + events.dispatchEvent( + storageEvent(STORAGE_KEY, JSON.stringify({mints: [mint('before.example')]})), + ) + + // Then the projection follows the live registry + expect(observed).toEqual([['current.example']]) + } finally { + unsubscribe() + } + }) + + it('projects removal from current storage instead of a stale replacement event', () => { + // Given a tab observing a trusted mint + const events = new EventTarget() + vi.stubGlobal('window', events) + store([mint('removed.example')]) + const observed: TrustedMint[][] = [] + const unsubscribe = onTrustedMintsChange(() => { + observed.push(readTrustedMints(OWNER_ID)) + }) + + try { + // When the registry is removed while the event claims it still exists + localStorage.removeItem(STORAGE_KEY) + events.dispatchEvent( + storageEvent(STORAGE_KEY, JSON.stringify({mints: [mint('removed.example')]})), + ) + + // Then the live removal wins + expect(observed).toEqual([[]]) + } finally { + unsubscribe() + } + }) + + it('projects a clear event by rereading current localStorage', () => { + // Given a tab observing a trusted mint + const events = new EventTarget() + vi.stubGlobal('window', events) + store([mint('cleared.example')]) + const observed: TrustedMint[][] = [] + const unsubscribe = onTrustedMintsChange(() => { + observed.push(readTrustedMints(OWNER_ID)) + }) + + try { + // When another tab clears storage, which emits key null + localStorage.clear() + events.dispatchEvent(storageEvent(null, JSON.stringify({mints: [mint('cleared.example')]}))) + + // Then the live empty registry wins + expect(observed).toEqual([[]]) + } finally { + unsubscribe() + } + }) + + it('ignores a delayed event after a newer live registry has replaced it', () => { + // Given an observer and an obsolete first registry + const events = new EventTarget() + vi.stubGlobal('window', events) + store([mint('obsolete.example')]) + const observed: string[][] = [] + const unsubscribe = onTrustedMintsChange(() => { + observed.push(readTrustedMints(OWNER_ID).map((entry) => entry.server)) + }) + + try { + // When a newer registry is already live before the obsolete event arrives + store([mint('live.example')]) + events.dispatchEvent( + storageEvent(STORAGE_KEY, JSON.stringify({mints: [mint('obsolete.example')]})), + ) + + // Then the obsolete event cannot resurrect its value + expect(observed).toEqual([['live.example']]) + } finally { + unsubscribe() + } + }) + + it('clears the projection for malformed current storage without throwing', () => { + // Given an observer with a previously valid registry + const events = new EventTarget() + vi.stubGlobal('window', events) + store([mint('valid.example')]) + const observed: TrustedMint[][] = [] + const unsubscribe = onTrustedMintsChange(() => { + observed.push(readTrustedMints(OWNER_ID)) + }) + + try { + // When the current value is malformed and its event claims a valid old value + localStorage.setItem(STORAGE_KEY, '{') + events.dispatchEvent( + storageEvent(STORAGE_KEY, JSON.stringify({mints: [mint('valid.example')]})), + ) + + // Then parsing the live value safely removes it from the projection + expect(observed).toEqual([[]]) + } finally { + unsubscribe() + } + }) +}) diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts index 0d5e54f..28b88e9 100644 --- a/src/lnurlcash/trustedMints.test.ts +++ b/src/lnurlcash/trustedMints.test.ts @@ -3,11 +3,11 @@ import {beforeEach, describe, expect, it} from 'vitest' +import {linkingPubKeyHex, saveLinkingKey} from './keys' import type {TrustedMint} from './trustedMints' import { PUBLIC_MINTS, addTrustedMint, - clearTrustedMints, confirmTrustedMintRekey, dismissTrustedMintRekey, getTrustedMintPubkey, @@ -17,7 +17,7 @@ import { lockTrustedMint, mergeTrustedMints, readTrustedMints, - removeTrustedMint + removeTrustedMint, } from './trustedMints' import {stubLocalStorage} from './test-utils' @@ -25,115 +25,123 @@ const KEY_A = '02' + 'aa'.repeat(32) const KEY_B = '03' + 'bb'.repeat(32) const KEY_C = '02' + 'cc'.repeat(32) const SERVER = 'mint.example' +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) -beforeEach(() => { +beforeEach(async () => { stubLocalStorage() - clearTrustedMints() + await saveLinkingKey(LINKING_KEY) }) +const onlyMint = (): TrustedMint => { + const mint = readTrustedMints(OWNER_ID)[0] + if (!mint) throw new Error('Expected one trusted mint.') + return mint +} + describe('pinning', () => { - it('locks a mint the first time a bearer is held from it', () => { - expect(lockTrustedMint(SERVER, KEY_A)).toBe('added') - expect(isMintTrusted(SERVER)).toBe(true) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) - expect(readTrustedMints()[0]!.locked).toBe(true) + it('locks a mint the first time a bearer is held from it', async () => { + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added') + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) + expect(onlyMint().locked).toBe(true) // same key again: silent no-op - expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged') }) - it('rejects a malformed signing key without throwing', () => { - expect(lockTrustedMint(SERVER, 'not-a-key')).toBe('unchanged') - expect(isMintTrusted(SERVER)).toBe(false) + it('rejects a malformed signing key without throwing', async () => { + expect(await lockTrustedMint(SERVER, 'not-a-key', OWNER_ID)).toBe('unchanged') + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false) }) }) describe('rekey staging', () => { - it('stages a differing advertised key for review, never auto-applies it', () => { - lockTrustedMint(SERVER, KEY_A) + it('stages a differing advertised key for review, never auto-applies it', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) - expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - const mint = readTrustedMints()[0]! + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + const mint = onlyMint() // the staged candidate is visible, but the ORIGINAL pin is still // authoritative - this is the entire point of the staging model expect(mint.pendingMintPubkey).toBe(KEY_B) expect(mint.mintPubkey).toBe(KEY_A) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) // re-advertising the same pending key doesn't duplicate or escalate - expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().mintPubkey).toBe(KEY_A) // and a THIRD key replaces the staged candidate, still not the pin - expect(lockTrustedMint(SERVER, KEY_C)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.pendingMintPubkey).toBe(KEY_C) - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + expect(await lockTrustedMint(SERVER, KEY_C, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().pendingMintPubkey).toBe(KEY_C) + expect(onlyMint().mintPubkey).toBe(KEY_A) }) - it('promotes the staged key only on explicit holder confirmation', () => { - lockTrustedMint(SERVER, KEY_A) - lockTrustedMint(SERVER, KEY_B) + it('promotes the staged key only on explicit holder confirmation', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) - confirmTrustedMintRekey(SERVER) - const mint = readTrustedMints()[0]! + await confirmTrustedMintRekey(SERVER, OWNER_ID) + const mint = onlyMint() expect(mint.mintPubkey).toBe(KEY_B) expect(mint.pendingMintPubkey).toBeUndefined() - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_B) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) }) - it('drops the staged key on dismissal, keeping the original pin', () => { - lockTrustedMint(SERVER, KEY_A) - lockTrustedMint(SERVER, KEY_B) + it('drops the staged key on dismissal, keeping the original pin', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) - dismissTrustedMintRekey(SERVER) - const mint = readTrustedMints()[0]! + await dismissTrustedMintRekey(SERVER, OWNER_ID) + const mint = onlyMint() expect(mint.pendingMintPubkey).toBeUndefined() expect(mint.mintPubkey).toBe(KEY_A) }) - it('stages a rekey even through unlock-time grandfathering', () => { - grandfatherTrustedMint(SERVER, KEY_A) - expect(grandfatherTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + it('stages a rekey even through unlock-time grandfathering', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(await grandfatherTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().mintPubkey).toBe(KEY_A) }) }) describe('grandfathering (storage-sourced claims)', () => { - it('adds an unknown server unlocked and unconfirmed', () => { - expect(grandfatherTrustedMint(SERVER, KEY_A)).toBe('added') - const mint = readTrustedMints()[0]! + it('adds an unknown server unlocked and unconfirmed', async () => { + expect(await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added') + const mint = onlyMint() expect(mint.locked).toBe(false) expect(mint.unconfirmed).toBe(true) // unconfirmed pins stay out of offline signature verification - expect(getTrustedMintPubkey(SERVER)).toBeNull() - expect(isMintUnconfirmed(SERVER)).toBe(true) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBeNull() + expect(isMintUnconfirmed(SERVER, OWNER_ID)).toBe(true) }) - it('is corroborated and locked by a live response advertising the same key', () => { - grandfatherTrustedMint(SERVER, KEY_A) - expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') - const mint = readTrustedMints()[0]! + it('is corroborated and locked by a live response advertising the same key', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged') + const mint = onlyMint() expect(mint.locked).toBe(true) expect(mint.unconfirmed).toBeUndefined() - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) }) }) describe('manual add and removal', () => { - it('validates input instead of silently no-oping', () => { - expect(() => addTrustedMint('', KEY_A)).toThrow() - expect(() => addTrustedMint(SERVER, 'junk')).toThrow() + it('validates input instead of silently no-oping', async () => { + await expect(addTrustedMint('', KEY_A, {ownerId: OWNER_ID})).rejects.toThrow() + await expect(addTrustedMint(SERVER, 'junk', {ownerId: OWNER_ID})).rejects.toThrow() }) - it('refuses to remove a mint locked by a held bearer', () => { - lockTrustedMint(SERVER, KEY_A) - expect(() => removeTrustedMint(SERVER)).toThrow(/bearer/) - expect(isMintTrusted(SERVER)).toBe(true) + it('refuses to remove a mint locked by a held bearer', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/) + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true) }) - it('removes an unlocked mint', () => { - addTrustedMint(SERVER, KEY_A) - removeTrustedMint(SERVER) - expect(isMintTrusted(SERVER)).toBe(false) + it('removes an unlocked mint', async () => { + await addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_ID}) + await removeTrustedMint(SERVER, OWNER_ID) + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false) }) }) @@ -145,12 +153,12 @@ describe('backup merge', () => { locked: true, // must never survive a merge from a file pendingMintPubkey: KEY_C, // must never survive either nodeAlias: 'Backup Mint', - ...overrides + ...overrides, }) - it('merges unknown servers as unlocked, unconfirmed, and without staged keys', () => { - expect(mergeTrustedMints([fromFile()])).toBe(1) - const mint = readTrustedMints()[0]! + it('merges unknown servers as unlocked, unconfirmed, and without staged keys', async () => { + expect(await mergeTrustedMints([fromFile()], OWNER_ID)).toBe(1) + const mint = onlyMint() expect(mint.server).toBe('backup-mint.example') expect(mint.mintPubkey).toBe(KEY_B) expect(mint.locked).toBe(false) @@ -159,25 +167,48 @@ describe('backup merge', () => { expect(mint.nodeAlias).toBe('Backup Mint') }) - it('never overwrites a server this device already knows', () => { - lockTrustedMint(SERVER, KEY_A) - const added = mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})]) + it('never overwrites a server this device already knows', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + const added = await mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})], OWNER_ID) expect(added).toBe(0) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) }) - it('skips malformed entries', () => { + it('skips malformed entries', async () => { // JSON round-trip: a backup file's entries are runtime data, not // compile-time TrustedMints - the merge must filter, not trust const malformed: TrustedMint[] = JSON.parse( - JSON.stringify([ - fromFile({mintPubkey: 'not-hex'}), - fromFile({server: 42}), - null - ]) + JSON.stringify([fromFile({mintPubkey: 'not-hex'}), fromFile({server: 42}), null]), ) - expect(mergeTrustedMints(malformed)).toBe(0) - expect(readTrustedMints()).toEqual([]) + expect(await mergeTrustedMints(malformed, OWNER_ID)).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + }) +}) + +describe('security policy characterization', () => { + it('keeps local pins authoritative and requires explicit rekey confirmation', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + + expect( + await mergeTrustedMints( + [ + { + server: SERVER, + mintPubkey: KEY_C, + addedAt: 123, + locked: false, + }, + ], + OWNER_ID, + ), + ).toBe(0) + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) + + await confirmTrustedMintRekey(SERVER, OWNER_ID) + + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) + await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/) }) }) @@ -188,7 +219,7 @@ describe('PUBLIC_MINTS', () => { '@lnurl.21mint.me', '@mint.forgesworn.dev', '@lnurl.21linz.at', - '@minty.exe.xyz' + '@minty.exe.xyz', ]) }) }) diff --git a/src/lnurlcash/trustedMints.transactions.test.ts b/src/lnurlcash/trustedMints.transactions.test.ts new file mode 100644 index 0000000..77b9382 --- /dev/null +++ b/src/lnurlcash/trustedMints.transactions.test.ts @@ -0,0 +1,287 @@ +// Owner-bound trusted-mint transactions under deterministic Web Locks. +// The fake parks every request until the test releases it in FIFO order. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import { + addTrustedMint, + cacheTrustedMintNodeInfo, + clearTrustedMints, + confirmTrustedMintRekey, + lockTrustedMint, + mergeTrustedMints, + onTrustedMintsChange, + readTrustedMints, + removeTrustedMint, + type TrustedMint, +} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const STORAGE_KEY = 'sattle_trusted_mints' +const LINKING_KEY_A = new Uint8Array(32).fill(7) +const OWNER_A = linkingPubKeyHex(LINKING_KEY_A) +const OWNER_B = linkingPubKeyHex(new Uint8Array(32).fill(9)) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) +const KEY_C = '02' + 'cc'.repeat(32) +const SERVER = 'mint.example' + +type LockRequest = { + readonly name: string + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +class DeferredLocks { + readonly requests: LockRequest[] = [] + held = false + + readonly request = (name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({name, callback, resolve, reject}) + }) + + async releaseNext(): Promise { + const request = this.requests.shift() + if (!request) throw new Error('Expected a queued lock request.') + this.held = true + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } finally { + this.held = false + } + } +} + +const mint = (overrides: Partial = {}): TrustedMint => ({ + server: SERVER, + mintPubkey: KEY_A, + addedAt: 123, + locked: false, + ...overrides, +}) + +const store = (ownerId: string, mints: TrustedMint[]): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({version: 1, ownerId, mints})) +} + +const installLocks = (): DeferredLocks => { + const locks = new DeferredLocks() + vi.stubGlobal('navigator', {locks}) + return locks +} + +const waitForRequests = async (locks: DeferredLocks, count: number): Promise => { + await vi.waitFor(() => expect(locks.requests).toHaveLength(count)) +} + +beforeEach(async () => { + vi.unstubAllGlobals() + stubLocalStorage() + await saveLinkingKey(LINKING_KEY_A) +}) + +describe('serialized owner-bound mutations', () => { + it('reads current storage instead of retaining a stale snapshot', () => { + store(OWNER_A, [mint({nodeAlias: 'first'})]) + expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('first') + + store(OWNER_A, [mint({nodeAlias: 'external update'})]) + + expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('external update') + }) + + it('preserves two queued additions by reading fresh state in FIFO order', async () => { + const locks = installLocks() + const writes = vi.spyOn(localStorage, 'setItem') + + const first = addTrustedMint('one.example', KEY_A, {ownerId: OWNER_A}) + const second = addTrustedMint('two.example', KEY_B, {ownerId: OWNER_A}) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + + await expect(first).resolves.toBe('added') + await expect(second).resolves.toBe('added') + expect(readTrustedMints(OWNER_A).map((entry) => entry.server)).toEqual([ + 'one.example', + 'two.example', + ]) + expect(writes).toHaveBeenCalledTimes(2) + }) + + it('does not let metadata overwrite a concurrently staged rekey', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const rekey = lockTrustedMint(SERVER, KEY_B, OWNER_A) + const metadata = cacheTrustedMintNodeInfo(SERVER, {nodeAlias: 'Fresh alias'}, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([rekey, metadata]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_A, + pendingMintPubkey: KEY_B, + nodeAlias: 'Fresh alias', + }) + }) + + it('keeps a new staged key after queued explicit confirmation', async () => { + store(OWNER_A, [mint({pendingMintPubkey: KEY_B})]) + const locks = installLocks() + + const confirm = confirmTrustedMintRekey(SERVER, OWNER_A) + const stage = lockTrustedMint(SERVER, KEY_C, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([confirm, stage]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_B, + pendingMintPubkey: KEY_C, + }) + }) + + it('lets a live lock corroborate a queued backup merge', async () => { + const locks = installLocks() + const incoming = mint({locked: true, pendingMintPubkey: KEY_B}) + + const merge = mergeTrustedMints([incoming], OWNER_A) + const live = lockTrustedMint(SERVER, KEY_A, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([merge, live]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_A, + locked: true, + }) + expect(readTrustedMints(OWNER_A)[0]?.unconfirmed).toBeUndefined() + }) + + it('rejects queued removal after a live operation locks the mint', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const live = lockTrustedMint(SERVER, KEY_A, OWNER_A) + const removal = removeTrustedMint(SERVER, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + + await expect(live).resolves.toBe('unchanged') + await expect(removal).rejects.toThrow(/bearer/) + expect(readTrustedMints(OWNER_A)[0]?.locked).toBe(true) + }) + + it('applies a queued clear after an earlier writer', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const add = addTrustedMint('queued.example', KEY_B, {ownerId: OWNER_A}) + const clear = clearTrustedMints(OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([add, clear]) + + expect(readTrustedMints(OWNER_A)).toEqual([]) + }) + + it('rejects stale, malformed, and foreign-owner mutations', async () => { + store(OWNER_B, [mint()]) + + await expect(addTrustedMint('stale.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /owner/i, + ) + await expect(addTrustedMint('invalid.example', KEY_B, {ownerId: 'invalid'})).rejects.toThrow( + /owner/i, + ) + + expect(readTrustedMints(OWNER_B)).toEqual([mint()]) + }) + + it('rejects a malformed stored envelope without overwriting it', async () => { + const malformed = JSON.stringify({ + version: 1, + ownerId: 'invalid', + mints: [], + }) + localStorage.setItem(STORAGE_KEY, malformed) + + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /malformed/i, + ) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + }) + + it('rejects a malformed mint member without rewriting stored bytes', async () => { + const malformed = JSON.stringify({ + version: 1, + ownerId: OWNER_A, + mints: [mint(), mint({server: 'broken.example', mintPubkey: 'not-hex'})], + }) + localStorage.setItem(STORAGE_KEY, malformed) + + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /malformed/i, + ) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + }) + + it('keeps storage and listeners unchanged when the single write fails', async () => { + store(OWNER_A, [mint()]) + vi.stubGlobal('navigator', {}) + const before = localStorage.getItem(STORAGE_KEY) + const notified = vi.fn() + const unsubscribe = onTrustedMintsChange(notified) + const setItem = localStorage.setItem.bind(localStorage) + localStorage.setItem = (): void => { + throw new Error('QuotaExceededError') + } + + try { + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + 'QuotaExceededError', + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + expect(notified).not.toHaveBeenCalled() + } finally { + localStorage.setItem = setItem + unsubscribe() + } + }) + + it('notifies listeners only after the storage lock is released', async () => { + const locks = installLocks() + const lockStates: boolean[] = [] + const unsubscribe = onTrustedMintsChange(() => lockStates.push(locks.held)) + + try { + const addition = addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_A}) + await waitForRequests(locks, 1) + await locks.releaseNext() + await addition + + expect(lockStates).toEqual([false]) + } finally { + unsubscribe() + } + }) +}) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index f942110..6d903b5 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -1,10 +1,24 @@ import type {MintAddressInfo} from 'lnurlcash-kit' - -// allow: SIZE_OK — one indivisible registry: every operation below reads -// and writes the same pinned-key cache through the same persist/notify -// path, and the file is a deliberate verbatim-behavior port of -// lnurl-wallet's trustedMints.ts so the two wallets' pinning semantics -// stay auditable side by side. +import { + adoptLegacyStoredTrustedMints, + mutateStoredTrustedMints, + onStoredTrustedMintsChange, + readOwnedTrustedMints, + removeStoredTrustedMintsForOwner, + resetStoredTrustedMints, +} from './trustedMintsRepository' +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import { + addMint, + cacheMintNodeInfo, + clearMints, + confirmMintRekey, + dismissMintRekey, + grandfatherMint, + lockMint, + removeMint, +} from './trustedMintTransitions' +import {mergeMints} from './trustedMintMerge' // A mint's signing key (LUD-25 Offline verification's `mintPubkey`) - not a // secret, just a public identity, so this is plain unencrypted localStorage, @@ -74,7 +88,7 @@ export type TrustedMintNodeInfo = { // resolved, not from that endpoint's response. export const mintAddressCacheInfo = ( info: MintAddressInfo | null, - username: string | null + username: string | null, ): TrustedMintNodeInfo | undefined => { if (!info && !username) return undefined return { @@ -83,7 +97,7 @@ export const mintAddressCacheInfo = ( nodeCapacityMsat: info?.nodeCapacityMsat, nodeNumChannels: info?.nodeNumChannels, nodeNumPeers: info?.nodeNumPeers, - username: username ?? undefined + username: username ?? undefined, } } @@ -99,81 +113,36 @@ export const PUBLIC_MINTS = [ '@lnurl.21mint.me', '@mint.forgesworn.dev', '@lnurl.21linz.at', - '@minty.exe.xyz' + '@minty.exe.xyz', ] -const STORAGE_KEY = 'sattle_trusted_mints' - -// 33-byte compressed secp256k1 pubkey, hex -const PUBKEY_PATTERN = /^[0-9a-f]{66}$/ - -const readStored = (): TrustedMint[] => { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return [] - try { - const parsed: unknown = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - // shape-check every entry - this is the wallet's own persisted state - // (so locked/pendingMintPubkey/unconfirmed are all kept), but a - // tampered or corrupt record must not plant junk entries - return parsed.filter( - (m): m is TrustedMint => - typeof m?.server === 'string' && - typeof m?.mintPubkey === 'string' && - PUBKEY_PATTERN.test(m.mintPubkey.toLowerCase()) && - typeof m?.addedAt === 'number' && - typeof m?.locked === 'boolean' - ) - } catch { - return [] - } -} - -// lazily initialized on first access: importing this module must not touch -// localStorage (plain-Node test environments have none until stubbed) -let cache: TrustedMint[] | null = null -const readCache = (): TrustedMint[] => { - cache ??= readStored() - return cache -} -const listeners = new Set<(mints: TrustedMint[]) => void>() - // the Pinia mints store subscribes here to mirror the registry into // reactive state; returns the unsubscribe -export const onTrustedMintsChange = ( - listener: (mints: TrustedMint[]) => void -): (() => void) => { - listeners.add(listener) - return () => listeners.delete(listener) -} +export const onTrustedMintsChange = (listener: () => void): (() => void) => + onStoredTrustedMintsChange(listener) -export const readTrustedMints = (): TrustedMint[] => readCache() +export const readTrustedMints = (ownerId?: string): TrustedMint[] => readOwnedTrustedMints(ownerId) -const persist = (mints: TrustedMint[]): void => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(mints)) - cache = mints - for (const listener of listeners) listener(mints) -} +export const isMintTrusted = (server: string, ownerId?: string): boolean => + readTrustedMints(ownerId).some((mint) => mint.server === server) -export const isMintTrusted = (server: string): boolean => - readCache().some(m => m.server === server) - -export const getTrustedMintPubkey = (server: string): string | null => - readCache().find(m => m.server === server && !m.unconfirmed)?.mintPubkey ?? null +export const getTrustedMintPubkey = (server: string, ownerId?: string): string | null => + readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed) + ?.mintPubkey ?? null // true when a server has a pin that came from a file/storage rather than a // live response (see TrustedMint.unconfirmed) - callers should treat a // bearer's own cached mintPubkey for such a server as equally // uncorroborated -export const isMintUnconfirmed = (server: string): boolean => - readCache().some(m => m.server === server && m.unconfirmed) +export const isMintUnconfirmed = (server: string, ownerId?: string): boolean => + readTrustedMints(ownerId).some((mint) => mint.server === server && mint.unconfirmed) // this mint's self-reported node color, for tinting its notes' background - // purely cosmetic. Mint-supplied, so it's only ever handed out as a plain // hex color - anything else (a style sink can take far more than colors) is // treated as absent -export const getTrustedMintNodeColor = (server: string): string | null => { - const color = readCache().find(m => m.server === server)?.nodeColor +export const getTrustedMintNodeColor = (server: string, ownerId?: string): string | null => { + const color = readTrustedMints(ownerId).find((mint) => mint.server === server)?.nodeColor return color && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : null } @@ -182,8 +151,8 @@ export const getTrustedMintNodeColor = (server: string): string | null => { // guessing "mint@" - null for a mint with no cached username // (looked up as a bech32 LNURL, or trusted before this wallet learned to // remember one) -export const getTrustedMintAddress = (server: string): string | null => { - const username = readCache().find(m => m.server === server)?.username +export const getTrustedMintAddress = (server: string, ownerId?: string): string | null => { + const username = readTrustedMints(ownerId).find((mint) => mint.server === server)?.username return username ? `${username}@${server}` : null } @@ -193,6 +162,14 @@ export const getTrustedMintAddress = (server: string): string | null => { // silently replacing it. Callers should surface that loudly. export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending' +export type TrustedMintMutationContext = { + readonly ownerId: string +} + +export type AddTrustedMintContext = TrustedMintMutationContext & { + readonly nodeInfo?: TrustedMintNodeInfo +} + // Called whenever this wallet ends up holding (or already holds) a bearer // from `server` - minting, receiving, splitting, merging all route through // the wallet store's addBearers/updateBearer, which is where this gets @@ -204,32 +181,10 @@ export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending' // or dismiss (see confirmTrustedMintRekey). export const lockTrustedMint = ( server: string, - mintPubkey: string -): TrustKeyResult => { - const key = mintPubkey.trim().toLowerCase() - if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' - const existing = readCache().find(m => m.server === server) - if (existing) { - if (existing.mintPubkey === key) { - if (existing.locked && !existing.unconfirmed) return 'unchanged' - // a match here is a live response from the server advertising this - // exact key - it corroborates an unconfirmed (file-sourced) pin - persist( - readCache().map(m => - m.server === server ? {...m, locked: true, unconfirmed: undefined} : m - ) - ) - return 'unchanged' - } - if (existing.pendingMintPubkey === key) return 'rekey-pending' - persist( - readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) - ) - return 'rekey-pending' - } - persist([...readCache(), {server, mintPubkey: key, addedAt: Date.now(), locked: true}]) - return 'added' -} + mintPubkey: string, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => lockMint(mints, {server, mintPubkey})) // unlock-time grandfathering of the mints behind already-stored bearers - // the key claims come from local storage, not a live response, so an @@ -240,25 +195,10 @@ export const lockTrustedMint = ( // lockTrustedMint instead, which is what corroborates and re-locks. export const grandfatherTrustedMint = ( server: string, - mintPubkey: string -): TrustKeyResult => { - const key = mintPubkey.trim().toLowerCase() - if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' - const existing = readCache().find(m => m.server === server) - if (existing) { - if (existing.mintPubkey === key) return 'unchanged' - if (existing.pendingMintPubkey === key) return 'rekey-pending' - persist( - readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) - ) - return 'rekey-pending' - } - persist([ - ...readCache(), - {server, mintPubkey: key, addedAt: Date.now(), locked: false, unconfirmed: true} - ]) - return 'added' -} + mintPubkey: string, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => grandfatherMint(mints, {server, mintPubkey})) // Manual add from the mints settings, or a user-confirmed first encounter - // unlocked, since no bearer necessarily backs it yet. Validates and throws @@ -270,81 +210,26 @@ export const grandfatherTrustedMint = ( export const addTrustedMint = ( server: string, mintPubkey: string, - nodeInfo?: TrustedMintNodeInfo -): TrustKeyResult => { - const trimmedServer = server.trim() - const key = mintPubkey.trim().toLowerCase() - if (!trimmedServer) { - throw new Error('Enter a server.') - } - if (!PUBKEY_PATTERN.test(key)) { - throw new Error( - 'Signing key must be a 33-byte compressed pubkey (66 hex characters).' - ) - } - const existing = readCache().find(m => m.server === trimmedServer) - if (existing) { - if (existing.mintPubkey === key) { - // a match here is a live lookup corroborating the pin - it clears an - // unconfirmed (file-sourced) flag - persist( - readCache().map(m => - m.server === trimmedServer - ? {...m, ...nodeInfo, unconfirmed: undefined} - : m - ) - ) - return 'unchanged' - } - persist( - readCache().map(m => - m.server === trimmedServer - ? {...m, pendingMintPubkey: key, ...nodeInfo} - : m - ) - ) - return 'rekey-pending' - } - persist([ - ...readCache(), - { - server: trimmedServer, - mintPubkey: key, - addedAt: Date.now(), - locked: false, - ...nodeInfo - } - ]) - return 'added' + context?: TrustedMintNodeInfo | AddTrustedMintContext, +): Promise => { + const ownerId = context && 'ownerId' in context ? context.ownerId : undefined + const nodeInfo = context && 'ownerId' in context ? context.nodeInfo : context + return mutateStoredTrustedMints(ownerId, (mints) => + addMint(mints, {server, mintPubkey, nodeInfo}), + ) } // the holder confirms a mint's advertised new signing key - the pending key // becomes the pinned one. Legitimate rotations (a mint moving to a new // node) go through here; nothing else ever replaces a pin. -export const confirmTrustedMintRekey = (server: string): void => { - const existing = readCache().find(m => m.server === server) - if (!existing?.pendingMintPubkey) return - const pending = existing.pendingMintPubkey - persist( - readCache().map(m => - m.server === server - ? {...m, mintPubkey: pending, pendingMintPubkey: undefined, unconfirmed: undefined} - : m - ) - ) -} +export const confirmTrustedMintRekey = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => confirmMintRekey(mints, server)) // the holder rejects the advertised new key - the staged candidate is // dropped, the original pin stays. Worth doing only when the change is // unexpected; the old key stays authoritative either way until confirmed. -export const dismissTrustedMintRekey = (server: string): void => { - if (!readCache().some(m => m.server === server)) return - persist( - readCache().map(m => - m.server === server ? {...m, pendingMintPubkey: undefined} : m - ) - ) -} +export const dismissTrustedMintRekey = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => dismissMintRekey(mints, server)) // refreshes just the cached display info for a server already in the list - // never touches mintPubkey/addedAt/locked, and no-ops for a server that @@ -355,32 +240,35 @@ export const dismissTrustedMintRekey = (server: string): void => { // whatever was known the moment trust was first established. export const cacheTrustedMintNodeInfo = ( server: string, - nodeInfo: TrustedMintNodeInfo -): void => { - if (!readCache().some(m => m.server === server)) return - persist(readCache().map(m => (m.server === server ? {...m, ...nodeInfo} : m))) -} + nodeInfo: TrustedMintNodeInfo, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => cacheMintNodeInfo(mints, server, nodeInfo)) // only succeeds for entries not backed by a held bearer - see // TrustedMint.locked -export const removeTrustedMint = (server: string): void => { - const entry = readCache().find(m => m.server === server) - if (!entry) return - if (entry.locked) { - throw new Error("Can't remove - you hold a bearer note from this mint.") - } - persist(readCache().filter(m => m.server !== server)) -} +export const removeTrustedMint = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => removeMint(mints, server)) // wipes the whole registry - part of forgetting a wallet: nothing about a // wallet's mints (including otherwise-irremovable locked pins) should // linger on the device after it -export const clearTrustedMints = (): void => { - localStorage.removeItem(STORAGE_KEY) - cache = [] - for (const listener of listeners) listener([]) +export const clearTrustedMints = (ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, clearMints) + +export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise => { + const ownerId = linkingPubKeyHex(linkingKey) + if (savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy trusted-mint migration requires a proven owner.') + } + return adoptLegacyStoredTrustedMints(ownerId) } +export const removeTrustedMintsForOwner = (ownerId: string): Promise => + removeStoredTrustedMintsForOwner(ownerId) + +export const resetTrustedMintsForReplacement = (): Promise => resetStoredTrustedMints() + // merges a backup's trusted mints in by server - a server already known on // this device keeps its own current entry rather than being overwritten by // the backup's (possibly stale) copy. Three fields never come across from a @@ -391,43 +279,5 @@ export const clearTrustedMints = (): void => { // and every merged entry is marked `unconfirmed`, keeping it out of offline // signature verification until a live response from that server advertises // the same key (a crafted backup could otherwise forge "signed" badges) -export const mergeTrustedMints = (incoming: TrustedMint[]): number => { - const knownServers = new Set(readCache().map(m => m.server)) - const merged = [...readCache()] - let added = 0 - for (const mint of incoming) { - if ( - typeof mint?.server !== 'string' || - typeof mint?.mintPubkey !== 'string' || - typeof mint?.addedAt !== 'number' || - !PUBKEY_PATTERN.test(mint.mintPubkey.toLowerCase()) - ) { - continue - } - if (knownServers.has(mint.server)) continue - merged.push({ - server: mint.server, - mintPubkey: mint.mintPubkey.toLowerCase(), - addedAt: mint.addedAt, - locked: false, - unconfirmed: true, - nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined, - nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined, - nodeCapacityMsat: - typeof mint.nodeCapacityMsat === 'number' - ? mint.nodeCapacityMsat - : undefined, - nodeNumChannels: - typeof mint.nodeNumChannels === 'number' - ? mint.nodeNumChannels - : undefined, - nodeNumPeers: - typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined, - username: typeof mint.username === 'string' ? mint.username : undefined - }) - knownServers.add(mint.server) - added++ - } - if (added > 0) persist(merged) - return added -} +export const mergeTrustedMints = (incoming: unknown[], ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => mergeMints(mints, incoming)) diff --git a/src/lnurlcash/trustedMints.visibility.test.ts b/src/lnurlcash/trustedMints.visibility.test.ts new file mode 100644 index 0000000..8dacb21 --- /dev/null +++ b/src/lnurlcash/trustedMints.visibility.test.ts @@ -0,0 +1,149 @@ +// A Web Lock handoff is not a localStorage visibility barrier. The next +// holder must reconcile from a durable cross-context commit before writing. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import {stubLocalStorage} from './test-utils' +import {addTrustedMint, readTrustedMints, type TrustedMint} from './trustedMints' +import {trustedMintsCommitStore} from './trustedMintsCommitStore' + +const STORAGE_KEY = 'sattle_trusted_mints' +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) +const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) + +const envelope = (mints: TrustedMint[]): string => + JSON.stringify({version: 1, ownerId: OWNER_ID, mints}) + +const mint = (server: string): TrustedMint => ({ + server, + mintPubkey: KEY_A, + addedAt: 1, + locked: false, +}) + +beforeEach(async () => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + stubLocalStorage() + vi.stubGlobal('navigator', { + locks: { + request: (_name: string, callback: () => unknown): Promise => + Promise.resolve().then(callback), + }, + }) + await saveLinkingKey(LINKING_KEY) +}) + +describe('trusted-mint commit visibility', () => { + it('reconciles the previous holder when localStorage is stale after lock handoff', async () => { + // Given a durable commit mirror shared by two lock holders + let committedRaw: string | null = null + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockImplementation(async () => committedRaw) + vi.spyOn(trustedMintsCommitStore, 'write').mockImplementation(async (raw) => { + committedRaw = raw + }) + + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}) + + // When the next holder sees the pre-commit localStorage view + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + const writes = vi.spyOn(localStorage, 'setItem') + await addTrustedMint('second.example', KEY_B, {ownerId: OWNER_ID}) + + // Then it writes one reconciled envelope and both accepted additions survive + expect(readTrustedMints(OWNER_ID).map((entry) => entry.server)).toEqual([ + 'remote.example', + 'first.example', + 'second.example', + ]) + expect(writes).toHaveBeenCalledTimes(1) + }) + + it('does not resolve success before the durable commit mirror completes', async () => { + // Given a commit store whose durable write is gated + let releaseCommit: (() => void) | undefined + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(null) + vi.spyOn(trustedMintsCommitStore, 'write').mockImplementation( + () => + new Promise((resolve) => { + releaseCommit = resolve + }), + ) + let settled = false + + // When a mutation has written localStorage but not the commit mirror + const addition = addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}).finally(() => { + settled = true + }) + await vi.waitFor(() => expect(releaseCommit).toBeTypeOf('function')) + + // Then success remains pending until the durable mirror completes + expect(settled).toBe(false) + releaseCommit?.() + await expect(addition).resolves.toBe('added') + expect(settled).toBe(true) + }) + + it('rejects malformed local bytes instead of trusting the commit mirror', async () => { + // Given a valid mirror but malformed canonical local storage + const malformed = '{' + localStorage.setItem(STORAGE_KEY, malformed) + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + const readMirror = vi + .spyOn(trustedMintsCommitStore, 'read') + .mockResolvedValue(envelope([mint('mirrored.example')])) + + // When a mutation attempts reconciliation, then malformed local bytes stay authoritative + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_ID})).rejects.toThrow( + /malformed/i, + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + expect(readMirror).not.toHaveBeenCalled() + }) + + it('rejects a foreign-owner commit mirror without rewriting local storage', async () => { + // Given an owner-A local registry and an owner-B durable mirror + const localRaw = envelope([mint('local.example')]) + const foreignRaw = JSON.stringify({ + version: 1, + ownerId: OTHER_OWNER_ID, + mints: [mint('foreign.example')], + }) + localStorage.setItem(STORAGE_KEY, localRaw) + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(foreignRaw) + + // When owner A mutates, then exact owner validation rejects both sources unchanged + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_ID})).rejects.toThrow( + /owner/i, + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(localRaw) + }) + + it('keeps the documented stale-write limitation when Web Locks are unavailable', async () => { + // Given no cross-tab lock capability, even if IndexedDB exists + vi.stubGlobal('navigator', {}) + const readMirror = vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(null) + const writeMirror = vi.spyOn(trustedMintsCommitStore, 'write').mockResolvedValue() + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}) + + // When a later unlocked mutation reads a stale view, then no false convergence is claimed + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('second.example', KEY_B, {ownerId: OWNER_ID}) + + expect(readTrustedMints(OWNER_ID).map((entry) => entry.server)).toEqual([ + 'remote.example', + 'second.example', + ]) + expect(readMirror).not.toHaveBeenCalled() + expect(writeMirror).not.toHaveBeenCalled() + }) +}) diff --git a/src/lnurlcash/trustedMintsCommitStore.ts b/src/lnurlcash/trustedMintsCommitStore.ts new file mode 100644 index 0000000..ceefec0 --- /dev/null +++ b/src/lnurlcash/trustedMintsCommitStore.ts @@ -0,0 +1,98 @@ +// Web Locks serialize registry writers but do not make one renderer's +// localStorage cache current in the next renderer. IndexedDB is the durable, +// cross-context commit mirror used to carry the last completed envelope. + +const DATABASE_NAME = 'sattle-storage-coordination' +const DATABASE_VERSION = 1 +const STORE_NAME = 'trusted-mints' +const REGISTRY_KEY = 'registry' + +let databasePromise: Promise | undefined + +export class TrustedMintsCommitStoreError extends Error { + override readonly name = 'TrustedMintsCommitStoreError' + + constructor(message: string, cause?: unknown) { + super(message, {cause}) + } +} + +const openDatabase = (): Promise => { + if (databasePromise) return databasePromise + databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => { + const database = request.result + database.onversionchange = () => { + database.close() + databasePromise = undefined + } + resolve(database) + } + request.onerror = () => { + databasePromise = undefined + reject( + new TrustedMintsCommitStoreError( + 'Unable to open trusted-mint commit storage.', + request.error, + ), + ) + } + request.onblocked = () => { + databasePromise = undefined + reject(new TrustedMintsCommitStoreError('Trusted-mint commit storage upgrade is blocked.')) + } + }) + return databasePromise +} + +const runRequest = async ( + mode: IDBTransactionMode, + createRequest: (store: IDBObjectStore) => IDBRequest, +): Promise => { + const database = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, mode, { + durability: mode === 'readwrite' ? 'strict' : 'default', + }) + const request = createRequest(transaction.objectStore(STORE_NAME)) + transaction.oncomplete = () => resolve(request.result) + transaction.onerror = () => + reject( + new TrustedMintsCommitStoreError( + 'Trusted-mint commit storage transaction failed.', + transaction.error, + ), + ) + transaction.onabort = () => + reject( + new TrustedMintsCommitStoreError( + 'Trusted-mint commit storage transaction was aborted.', + transaction.error, + ), + ) + }) +} + +export const trustedMintsCommitStore = { + available: (): boolean => typeof indexedDB !== 'undefined', + read: async (): Promise => { + const value = await runRequest('readonly', (store) => store.get(REGISTRY_KEY)) + if (value === undefined) return null + if (typeof value !== 'string') { + throw new TrustedMintsCommitStoreError('Trusted-mint commit storage is malformed.') + } + return value + }, + write: async (raw: string): Promise => { + await runRequest('readwrite', (store) => store.put(raw, REGISTRY_KEY)) + }, + clear: async (): Promise => { + await runRequest('readwrite', (store) => store.delete(REGISTRY_KEY)) + }, +} diff --git a/src/lnurlcash/trustedMintsEvents.ts b/src/lnurlcash/trustedMintsEvents.ts new file mode 100644 index 0000000..4790022 --- /dev/null +++ b/src/lnurlcash/trustedMintsEvents.ts @@ -0,0 +1,27 @@ +// Cross-tab storage events are hints only. Subscribers reread the live +// registry for their active owner rather than projecting event.newValue. + +export const TRUSTED_MINTS_STORAGE_KEY = 'sattle_trusted_mints' + +const listeners = new Set<() => void>() + +export const notifyStoredTrustedMintsChange = (): void => { + for (const listener of listeners) listener() +} + +// Each subscription binds its own storage listener and removes exactly it on +// unsubscribe: no shared reference counting, so an abandoned subscriber can +// never keep another subscriber's window listener (or runtime) alive. +export const onStoredTrustedMintsChange = (listener: () => void): (() => void) => { + listeners.add(listener) + const target = typeof window === 'undefined' ? null : window + const onStorage = (event: StorageEvent): void => { + if (event.key !== TRUSTED_MINTS_STORAGE_KEY && event.key !== null) return + listener() + } + target?.addEventListener('storage', onStorage) + return () => { + listeners.delete(listener) + target?.removeEventListener('storage', onStorage) + } +} diff --git a/src/lnurlcash/trustedMintsRegistry.ts b/src/lnurlcash/trustedMintsRegistry.ts new file mode 100644 index 0000000..99988ef --- /dev/null +++ b/src/lnurlcash/trustedMintsRegistry.ts @@ -0,0 +1,88 @@ +// Strict trusted-mint envelope parsing is shared by localStorage and the +// durable cross-context commit mirror. Either source fails closed as a whole. + +import {isWalletOwnerId} from './storage/walletOwner' +import type {TrustedMint} from './trustedMints' +import {isValidMintPubkey} from './trustedMintTransitions' + +export const TRUSTED_MINTS_REGISTRY_VERSION = 1 + +export type TrustedMintsRegistryEnvelope = { + readonly version: typeof TRUSTED_MINTS_REGISTRY_VERSION + readonly ownerId: string + readonly mints: TrustedMint[] +} + +export type StoredTrustedMintsRegistry = + | {readonly kind: 'absent'} + | {readonly kind: 'malformed'} + | {readonly kind: 'valid'; readonly envelope: TrustedMintsRegistryEnvelope} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const isOptionalString = (value: unknown): boolean => + value === undefined || typeof value === 'string' + +const isOptionalNumber = (value: unknown): boolean => + value === undefined || typeof value === 'number' + +const isTrustedMint = (value: unknown): value is TrustedMint => { + if (!isRecord(value)) return false + return ( + typeof value.server === 'string' && + typeof value.mintPubkey === 'string' && + isValidMintPubkey(value.mintPubkey) && + typeof value.addedAt === 'number' && + typeof value.locked === 'boolean' && + (value.unconfirmed === undefined || typeof value.unconfirmed === 'boolean') && + (value.pendingMintPubkey === undefined || + (typeof value.pendingMintPubkey === 'string' && + isValidMintPubkey(value.pendingMintPubkey))) && + isOptionalString(value.nodeAlias) && + isOptionalString(value.nodeColor) && + isOptionalNumber(value.nodeCapacityMsat) && + isOptionalNumber(value.nodeNumChannels) && + isOptionalNumber(value.nodeNumPeers) && + isOptionalString(value.username) + ) +} + +export const parseStoredTrustedMintsRegistry = (raw: string | null): StoredTrustedMintsRegistry => { + if (raw === null) return {kind: 'absent'} + try { + const parsed: unknown = JSON.parse(raw) + if ( + !isRecord(parsed) || + parsed.version !== TRUSTED_MINTS_REGISTRY_VERSION || + !isWalletOwnerId(parsed.ownerId) || + !Array.isArray(parsed.mints) || + !parsed.mints.every(isTrustedMint) + ) { + return {kind: 'malformed'} + } + return { + kind: 'valid', + envelope: { + version: TRUSTED_MINTS_REGISTRY_VERSION, + ownerId: parsed.ownerId, + mints: parsed.mints, + }, + } + } catch { + return {kind: 'malformed'} + } +} + +export const parseLegacyTrustedMintsRegistry = (raw: string | null): TrustedMint[] | null => { + if (raw === null) return null + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) && parsed.every(isTrustedMint) ? parsed : null + } catch { + return null + } +} + +export const serializeTrustedMintsRegistry = (ownerId: string, mints: TrustedMint[]): string => + JSON.stringify({version: TRUSTED_MINTS_REGISTRY_VERSION, ownerId, mints}) diff --git a/src/lnurlcash/trustedMintsRepository.ts b/src/lnurlcash/trustedMintsRepository.ts new file mode 100644 index 0000000..1e7bdae --- /dev/null +++ b/src/lnurlcash/trustedMintsRepository.ts @@ -0,0 +1,201 @@ +// Owner-bound trusted-mint persistence. Web Locks serialize writers while an +// IndexedDB commit mirror bridges stale cross-renderer localStorage views. + +import {isWalletOwnerId} from './storage/walletOwner' +import {savedKeyOwnerAllows} from './storage/currentOwner' +import {storageLocksAvailable, withStorageLock} from './storageLock' +import type {TrustedMint} from './trustedMints' +import {trustedMintsCommitStore} from './trustedMintsCommitStore' +import {notifyStoredTrustedMintsChange, TRUSTED_MINTS_STORAGE_KEY} from './trustedMintsEvents' +import { + parseLegacyTrustedMintsRegistry, + parseStoredTrustedMintsRegistry, + serializeTrustedMintsRegistry, + type StoredTrustedMintsRegistry, +} from './trustedMintsRegistry' +import type {MintTransition} from './trustedMintTransitions' + +const STORAGE_KEY = TRUSTED_MINTS_STORAGE_KEY + +type MutationState = { + readonly canonicalRaw: string | null + readonly current: TrustedMint[] + readonly localRaw: string | null + readonly mirrorEnabled: boolean +} + +export class InvalidTrustedMintsOwnerError extends Error { + override readonly name = 'InvalidTrustedMintsOwnerError' + + constructor() { + super('Trusted-mint mutation requires a valid wallet owner.') + } +} + +export class TrustedMintsOwnerMismatchError extends Error { + override readonly name = 'TrustedMintsOwnerMismatchError' + + constructor() { + super('Trusted-mint registry belongs to a different wallet owner.') + } +} + +export class MalformedTrustedMintsRegistryError extends Error { + override readonly name = 'MalformedTrustedMintsRegistryError' + + constructor() { + super('Trusted-mint registry storage is malformed.') + } +} + +const requireOwnedRegistry = ( + stored: StoredTrustedMintsRegistry, + ownerId: string, +): StoredTrustedMintsRegistry => { + if (stored.kind === 'malformed') throw new MalformedTrustedMintsRegistryError() + if (stored.kind === 'valid' && stored.envelope.ownerId !== ownerId) { + throw new TrustedMintsOwnerMismatchError() + } + return stored +} + +const mirrorIsEnabled = (): boolean => + storageLocksAvailable() && trustedMintsCommitStore.available() + +const readMutationState = async (ownerId: string): Promise => { + const localRaw = localStorage.getItem(STORAGE_KEY) + const local = requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId) + const mirrorEnabled = mirrorIsEnabled() + if (!mirrorEnabled) { + return { + canonicalRaw: localRaw, + current: local.kind === 'valid' ? local.envelope.mints : [], + localRaw, + mirrorEnabled, + } + } + + const mirrorRaw = await trustedMintsCommitStore.read() + if (mirrorRaw === null) { + return { + canonicalRaw: localRaw, + current: local.kind === 'valid' ? local.envelope.mints : [], + localRaw, + mirrorEnabled, + } + } + const mirror = requireOwnedRegistry(parseStoredTrustedMintsRegistry(mirrorRaw), ownerId) + if (mirror.kind !== 'valid') throw new MalformedTrustedMintsRegistryError() + return { + canonicalRaw: mirrorRaw, + current: mirror.envelope.mints, + localRaw, + mirrorEnabled, + } +} + +const restoreLocalRegistry = (raw: string | null): void => { + if (raw === null) localStorage.removeItem(STORAGE_KEY) + else localStorage.setItem(STORAGE_KEY, raw) +} + +const commitRegistry = async ( + previousRaw: string | null, + nextRaw: string, + mirrorEnabled: boolean, +): Promise => { + const localChanged = previousRaw !== nextRaw + if (localChanged) localStorage.setItem(STORAGE_KEY, nextRaw) + if (!mirrorEnabled) return localChanged + try { + await trustedMintsCommitStore.write(nextRaw) + } catch (error) { + if (localChanged) restoreLocalRegistry(previousRaw) + throw error + } + return localChanged +} + +export {onStoredTrustedMintsChange} from './trustedMintsEvents' + +export const readOwnedTrustedMints = (ownerId: unknown): TrustedMint[] => { + if (!isWalletOwnerId(ownerId)) return [] + const stored = parseStoredTrustedMintsRegistry(localStorage.getItem(STORAGE_KEY)) + return stored.kind === 'valid' && stored.envelope.ownerId === ownerId ? stored.envelope.mints : [] +} + +export const mutateStoredTrustedMints = async ( + ownerId: unknown, + transition: (mints: TrustedMint[]) => MintTransition, +): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + + const committed = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const state = await readMutationState(ownerId) + const next = transition(state.current) + const nextRaw = next.changed + ? serializeTrustedMintsRegistry(ownerId, next.mints) + : state.canonicalRaw + const localChanged = + nextRaw === null ? false : await commitRegistry(state.localRaw, nextRaw, state.mirrorEnabled) + return {localChanged, result: next.result} + }) + + if (committed.localChanged) notifyStoredTrustedMintsChange() + return committed.result +} + +export const adoptLegacyStoredTrustedMints = async (ownerId: unknown): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + const adopted = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const localRaw = localStorage.getItem(STORAGE_KEY) + const legacy = parseLegacyTrustedMintsRegistry(localRaw) + if (legacy === null) { + requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId) + return null + } + await commitRegistry( + localRaw, + serializeTrustedMintsRegistry(ownerId, legacy), + mirrorIsEnabled(), + ) + return legacy + }) + if (adopted !== null) notifyStoredTrustedMintsChange() + return adopted?.length ?? 0 +} + +export const removeStoredTrustedMintsForOwner = async (ownerId: unknown): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + const removed = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const state = await readMutationState(ownerId) + if (state.canonicalRaw === null) return false + if (state.localRaw !== null) localStorage.removeItem(STORAGE_KEY) + try { + if (state.mirrorEnabled) await trustedMintsCommitStore.clear() + } catch (error) { + if (state.localRaw !== null) restoreLocalRegistry(state.localRaw) + throw error + } + return true + }) + if (removed) notifyStoredTrustedMintsChange() +} + +export const resetStoredTrustedMints = async (): Promise => { + const removed = await withStorageLock(STORAGE_KEY, async () => { + const localRaw = localStorage.getItem(STORAGE_KEY) + if (localRaw !== null) localStorage.removeItem(STORAGE_KEY) + try { + if (mirrorIsEnabled()) await trustedMintsCommitStore.clear() + } catch (error) { + if (localRaw !== null) restoreLocalRegistry(localRaw) + throw error + } + return localRaw !== null + }) + if (removed) notifyStoredTrustedMintsChange() +} diff --git a/src/lnurlcash/units.ts b/src/lnurlcash/units.ts index b7c2542..c3beb89 100644 --- a/src/lnurlcash/units.ts +++ b/src/lnurlcash/units.ts @@ -5,14 +5,12 @@ export const MSAT_PER_SAT = 1000 export const msatToSats = (msat: number): number => msat / MSAT_PER_SAT -export const satsToMsat = (sats: number): number => - Math.round(sats * MSAT_PER_SAT) +export const satsToMsat = (sats: number): number => Math.round(sats * MSAT_PER_SAT) // rounds up to the next whole sat - for an msat amount about to be // requested as an invoice, where sub-sat precision (e.g. from a mint fee's // percentage cut, see grossUpForMintFee) isn't reliably payable -export const ceilMsatToSat = (msat: number): number => - Math.ceil(msat / MSAT_PER_SAT) * MSAT_PER_SAT +export const ceilMsatToSat = (msat: number): number => Math.ceil(msat / MSAT_PER_SAT) * MSAT_PER_SAT // rounds down to the nearest whole sat - for a fee-adjusted amount shown // as an upper bound: rounding up there would advertise a note value that 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', }); diff --git a/src/pages/ManageMintsPage.vue b/src/pages/ManageMintsPage.vue index fc4e620..3b2745a 100644 --- a/src/pages/ManageMintsPage.vue +++ b/src/pages/ManageMintsPage.vue @@ -36,15 +36,9 @@ color="warning" text-color="dark" label="Confirm new key" - @click="mints.confirmRekey(mint.server)" - /> - + @@ -195,146 +189,27 @@ diff --git a/src/stores/activity.test.ts b/src/stores/activity.test.ts new file mode 100644 index 0000000..962c5b4 --- /dev/null +++ b/src/stores/activity.test.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import type { ActivityEvent } from '@/lnurlcash/storage'; +import type * as StorageExports from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; + +const persistence = vi.hoisted(() => ({ + persistActivityEvent: vi.fn<(key: CryptoKey, event: ActivityEvent) => Promise>(), +})); + +vi.mock('@/lnurlcash/storage', async (importOriginal) => ({ + ...(await importOriginal()), + persistActivityEvent: persistence.persistActivityEvent, +})); + +import { useActivityStore } from './activity'; + +const LINKING_KEY = new Uint8Array(32).fill(7); + +beforeEach(() => { + vi.clearAllMocks(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('activity store durability', () => { + it('publishes an event only after its encrypted write completes', async () => { + let finishWrite: (() => void) | undefined; + persistence.persistActivityEvent.mockReturnValue( + new Promise((resolve) => { + finishWrite = resolve; + }), + ); + const activity = useActivityStore(); + await activity.loadFor(await deriveBearerAesKey(LINKING_KEY)); + + const logging = activity.log('receive', 'Received funds.', () => { + throw new Error('Unexpected persistence failure.'); + }); + + expect(activity.events).toEqual([]); + finishWrite?.(); + await logging; + expect(activity.events).toHaveLength(1); + }); + + it('rejects a failed write without leaving a false reactive event', async () => { + const writeError = new Error('activity storage unavailable'); + persistence.persistActivityEvent.mockRejectedValue(writeError); + const activity = useActivityStore(); + await activity.loadFor(await deriveBearerAesKey(LINKING_KEY)); + + let surfaced: Error | null = null; + await activity.log('receive', 'Received funds.', (error) => { + surfaced = error; + }); + + expect(activity.events).toEqual([]); + expect(surfaced).toMatchObject({ + name: 'ActivityPersistenceError', + actionCommitted: true, + cause: writeError, + }); + }); +}); diff --git a/src/stores/activity.ts b/src/stores/activity.ts index 2787609..f5c1a96 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -1,60 +1,78 @@ -import {ref} from 'vue' -import {defineStore} from 'pinia' +import { ref } from 'vue'; +import { defineStore } from 'pinia'; -import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage' +import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage'; import { loadActivity, persistActivityEvent, clearAllActivity, newActivityId, - MAX_ACTIVITY_ENTRIES -} from '@/lnurlcash/storage' + MAX_ACTIVITY_ENTRIES, +} from '@/lnurlcash/storage'; + +export class ActivityPersistenceError extends Error { + override readonly name = 'ActivityPersistenceError'; + readonly actionCommitted = true; + + constructor(options: { cause: unknown }) { + super( + 'The wallet action completed, but activity history could not be saved. Do not retry the action.', + options, + ); + } +} // The activity log: append-only, encrypted at rest with the same // bearer-AES key as the notes themselves. Loaded by the wallet store on // unlock (loadFor) and dropped on lock (unload) - it never holds plaintext // while the wallet is locked. export const useActivityStore = defineStore('activity', () => { - const events = ref([]) - let aesKey: CryptoKey | null = null + const events = ref([]); + let aesKey: CryptoKey | null = null; const loadFor = async (key: CryptoKey): Promise => { - aesKey = key - events.value = await loadActivity(key) - } + aesKey = key; + events.value = await loadActivity(key); + }; const unload = (): void => { - aesKey = null - events.value = [] - } + aesKey = null; + events.value = []; + }; // both unload and wipe the stored log - part of forgetting a wallet const unloadAndClear = (): void => { - clearAllActivity() - unload() - } + clearAllActivity(); + unload(); + }; - // best-effort and silent on failure - a wallet action that already - // succeeded (the note was split/melted/whatever) must never surface an - // error just because the log entry for it couldn't be written - const log = (kind: ActivityKind, message: string): void => { - if (!aesKey) return + const log = async ( + kind: ActivityKind, + message: string, + onPersistenceError: (error: ActivityPersistenceError) => void, + ): Promise => { + if (!aesKey) return; const event: ActivityEvent = { id: newActivityId(), kind, message, - createdAt: Date.now() + createdAt: Date.now(), + }; + try { + await persistActivityEvent(aesKey, event); + } catch (error) { + const cause = + error instanceof Error ? error : new Error('Activity storage failed.', { cause: error }); + onPersistenceError(new ActivityPersistenceError({ cause })); + return; } - events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES) - persistActivityEvent(aesKey, event).catch(() => { - // deliberately swallowed - see the comment above - }) - } + events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES); + }; const clear = (): void => { - clearAllActivity() - events.value = [] - } + clearAllActivity(); + events.value = []; + }; - return {events, loadFor, unload, unloadAndClear, log, clear} -}) + return { events, loadFor, unload, unloadAndClear, log, clear }; +}); diff --git a/src/stores/mints.storageEvents.test.ts b/src/stores/mints.storageEvents.test.ts new file mode 100644 index 0000000..f7318b1 --- /dev/null +++ b/src/stores/mints.storageEvents.test.ts @@ -0,0 +1,61 @@ +import { createPinia, disposePinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { useMintsStore } from './mints'; +import { useWalletStore } from './wallet'; + +const LINKING_KEY_HEX = '07'.repeat(32); +const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); +const MINT_KEY = '02' + 'aa'.repeat(32); +let testPinia: ReturnType; + +const storageEvent = (): Event => { + const event = new Event('storage'); + Object.defineProperties(event, { + key: { value: 'sattle_trusted_mints' }, + newValue: { value: 'obsolete' }, + }); + return event; +}; + +beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + testPinia = createPinia(); + setActivePinia(testPinia); +}); + +afterEach(() => disposePinia(testPinia)); + +describe('mints store storage-event convergence', () => { + it('refreshes the active owner view from live storage without reload', async () => { + // Given an unlocked wallet and its mounted mints store + const events = new EventTarget(); + vi.stubGlobal('window', events); + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ enc: false, value: LINKING_KEY_HEX, ownerId: OWNER_ID, version: 1 }), + ); + const wallet = useWalletStore(); + await wallet.init(); + const mints = useMintsStore(); + + // When another tab stores a trusted mint before an obsolete event arrives + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ + version: 1, + ownerId: OWNER_ID, + mints: [{ server: 'remote.example', mintPubkey: MINT_KEY, addedAt: 1, locked: false }], + }), + ); + events.dispatchEvent(storageEvent()); + + // Then Pinia renders the active owner's live registry without a reload + await vi.waitFor(() => + expect(mints.mints.map((entry) => entry.server)).toEqual(['remote.example']), + ); + }); +}); diff --git a/src/stores/mints.ts b/src/stores/mints.ts index 185b5f5..85fbb9f 100644 --- a/src/stores/mints.ts +++ b/src/stores/mints.ts @@ -1,11 +1,7 @@ -import {computed, ref} from 'vue' -import {defineStore} from 'pinia' +import { computed, onScopeDispose, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; -import type { - TrustedMint, - TrustedMintNodeInfo, - TrustKeyResult -} from '@/lnurlcash/trustedMints' +import type { TrustedMint, TrustedMintNodeInfo, TrustKeyResult } from '@/lnurlcash/trustedMints'; import { PUBLIC_MINTS, readTrustedMints, @@ -17,9 +13,10 @@ import { removeTrustedMint, cacheTrustedMintNodeInfo, isMintTrusted, - getTrustedMintPubkey -} from '@/lnurlcash/trustedMints' -import {loadSettings, persistSettings} from '@/lnurlcash/storage' + getTrustedMintPubkey, +} from '@/lnurlcash/trustedMints'; +import { loadSettings, persistSettings } from '@/lnurlcash/storage'; +import { useWalletStore } from './wallet'; // The trusted-mint registry as reactive state. The domain logic (pinning, // rekey staging, backup merge rules) lives framework-free in @@ -28,51 +25,90 @@ import {loadSettings, persistSettings} from '@/lnurlcash/storage' // staged for review (pendingRekeys), never auto-applied: a silently // rotated key would defeat the entire pinning model. export const useMintsStore = defineStore('mints', () => { - const mints = ref(readTrustedMints()) - onTrustedMintsChange(updated => { - mints.value = updated - }) + const wallet = useWalletStore(); + const mints = ref([]); + const activeOwner = (): string | null => (wallet.state === 'unlocked' ? wallet.pubkey : null); + let stopTrustedMintsChanges: (() => void) | null = null; + watch( + () => [wallet.state, wallet.pubkey] as const, + () => { + stopTrustedMintsChanges?.(); + stopTrustedMintsChanges = null; + const ownerId = activeOwner(); + if (ownerId === null) { + mints.value = []; + return; + } + mints.value = readTrustedMints(ownerId); + stopTrustedMintsChanges = onTrustedMintsChange(() => { + if (activeOwner() !== ownerId) return; + mints.value = readTrustedMints(ownerId); + }); + }, + { immediate: true, flush: 'sync' }, + ); + onScopeDispose(() => stopTrustedMintsChanges?.()); + + const requireOwner = (): string => { + const ownerId = activeOwner(); + if (ownerId === null) throw new Error('Wallet is locked.'); + return ownerId; + }; + + const isTrusted = (server: string): boolean => { + const ownerId = activeOwner(); + return ownerId === null ? false : isMintTrusted(server, ownerId); + }; + + const trustedPubkey = (server: string): string | null => { + const ownerId = activeOwner(); + return ownerId === null ? null : getTrustedMintPubkey(server, ownerId); + }; // mints with a staged rekey awaiting holder review - the UI should // surface these loudly - const pendingRekeys = computed(() => - mints.value.filter(m => m.pendingMintPubkey) - ) + const pendingRekeys = computed(() => mints.value.filter((m) => m.pendingMintPubkey)); // ---- default-mint selection (onboarding quick start) ---- - const defaultMint = ref(loadSettings().defaultMint ?? null) + const defaultMint = ref(loadSettings().defaultMint ?? null); const setDefaultMint = (server: string | null): void => { - defaultMint.value = server - const settings = loadSettings() + defaultMint.value = server; + const settings = loadSettings(); if (server === null) { - persistSettings({...settings, defaultMint: undefined}) + persistSettings({ ...settings, defaultMint: undefined }); } else { - persistSettings({...settings, defaultMint: server}) + persistSettings({ ...settings, defaultMint: server }); } - } + }; // manual add from the mints settings, or a user-confirmed first // encounter - validates and throws on junk input const trust = ( server: string, mintPubkey: string, - nodeInfo?: TrustedMintNodeInfo - ): TrustKeyResult => addTrustedMint(server, mintPubkey, nodeInfo) + nodeInfo?: TrustedMintNodeInfo, + ): Promise => + addTrustedMint(server, mintPubkey, { + ownerId: requireOwner(), + nodeInfo, + }); // the silent path: this wallet holds (or just came to hold) a bearer from // this server - trust follows holding funds, never asks, and only ever // STAGES a differing advertised key - const lockFromBearer = (server: string, mintPubkey: string): TrustKeyResult => - lockTrustedMint(server, mintPubkey) + const lockFromBearer = (server: string, mintPubkey: string): Promise => + lockTrustedMint(server, mintPubkey, requireOwner()); - const confirmRekey = (server: string): void => confirmTrustedMintRekey(server) - const dismissRekey = (server: string): void => dismissTrustedMintRekey(server) + const confirmRekey = (server: string): Promise => + confirmTrustedMintRekey(server, requireOwner()); + const dismissRekey = (server: string): Promise => + dismissTrustedMintRekey(server, requireOwner()); // throws for a mint locked by a held bearer - const remove = (server: string): void => removeTrustedMint(server) + const remove = (server: string): Promise => removeTrustedMint(server, requireOwner()); - const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): void => - cacheTrustedMintNodeInfo(server, nodeInfo) + const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): Promise => + cacheTrustedMintNodeInfo(server, nodeInfo, requireOwner()); return { mints, @@ -86,7 +122,7 @@ export const useMintsStore = defineStore('mints', () => { dismissRekey, remove, cacheNodeInfo, - isTrusted: isMintTrusted, - trustedPubkey: getTrustedMintPubkey - } -}) + isTrusted, + trustedPubkey, + }; +}); diff --git a/src/stores/nostrBackup.ts b/src/stores/nostrBackup.ts index 90c175e..7f27ebd 100644 --- a/src/stores/nostrBackup.ts +++ b/src/stores/nostrBackup.ts @@ -7,7 +7,6 @@ import { createBackupPublisher, deriveBackupKey, publishBackup, - restoreFromNostr, } from '@/lnurlcash/nostrBackup'; import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup'; import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage'; @@ -65,7 +64,7 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { const currentPayload = (): BackupPartPayload => ({ notes: readEncryptedBearers(), - mints: readTrustedMints(), + mints: readTrustedMints(wallet.pubkey ?? undefined), settings: loadSettings(), }); @@ -148,9 +147,7 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { // pulls the newest backup for THIS wallet's key and merges it through the // same applyBackup path as a file restore, then reloads the live list const restore = async (): Promise => { - const result = await restoreFromNostr(wallet.requireLinkingKey(), relays.value); - await wallet.reloadBearers(); - return result; + return wallet.restoreCurrentFromNostr(relays.value); }; return { diff --git a/src/stores/nwc.atomic.test.ts b/src/stores/nwc.atomic.test.ts new file mode 100644 index 0000000..c2cc13a --- /dev/null +++ b/src/stores/nwc.atomic.test.ts @@ -0,0 +1,115 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc'; +import type * as NwcExports from '@/lnurlcash/nwc'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { useActivityStore } from './activity'; + +type StartService = (linkingKey: Uint8Array, deps: NwcServiceDeps) => Promise; +const mocks = vi.hoisted(() => ({ startService: vi.fn() })); + +vi.mock('@/lnurlcash/nwc', async (importOriginal) => ({ + ...(await importOriginal()), + startService: mocks.startService, +})); + +import { useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +const note = (secret: string): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); + mocks.startService.mockResolvedValue({ + connections: [], + stop: vi.fn().mockResolvedValue(undefined), + }); +}); + +describe('NWC store changeset adapter', () => { + it('commits the complete engine changeset in one bearer write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + const start = mocks.startService.mock.calls.at(-1); + if (!start) throw new Error('Expected the NWC service to start.'); + const writes = vi.spyOn(storage, 'setItem'); + + await start[1].applyChangeset( + { add: [note('b')], markSpent: [existing.id] }, + { + record: { + version: 1, + ownerId: wallet.pubkey ?? '', + clientPubkey: '11'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 100_000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + walletServicePubkey: '22'.repeat(32), + }, + 'pay_invoice', + start[1].assertCurrentOwner, + ); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('surfaces activity durability failure without rolling back committed funds', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + const start = mocks.startService.mock.calls.at(-1); + if (!start) throw new Error('Expected the NWC service to start.'); + const originalSetItem = storage.setItem; + storage.setItem = (key, value): void => { + if (key === 'sattle_activity') throw new Error('activity storage unavailable'); + originalSetItem(key, value); + }; + + await start[1].applyChangeset( + { add: [], markSpent: [existing.id] }, + { + record: { + version: 1, + ownerId: wallet.pubkey ?? '', + clientPubkey: '11'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 100_000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + walletServicePubkey: '22'.repeat(32), + }, + 'pay_invoice', + start[1].assertCurrentOwner, + ); + + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + expect(useActivityStore().events).toEqual([]); + expect(nwc.lastError).toMatch(/activity history.*do not retry/i); + }); +}); diff --git a/src/stores/nwc.lifecycle.test.ts b/src/stores/nwc.lifecycle.test.ts new file mode 100644 index 0000000..34c6f3b --- /dev/null +++ b/src/stores/nwc.lifecycle.test.ts @@ -0,0 +1,149 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc'; +import type { NwcTransport } from '@/lnurlcash/nwc'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { + CLIENT_SECRET, + RELAYS, + createFakeRelay, + deferred, + methodRequest, +} from '@/lnurlcash/nwc.testProtocol'; +import { setNwcTransportForTests, useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); + setNwcTransportForTests(null); +}); + +describe('NWC wallet lifecycle drain', () => { + it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => { + // Given a real encrypted wallet and NWC service whose payment is held + // after the mint burned its note but before the wallet commits the delta + const mint = await createMockMint(); + try { + const wallet = useWalletStore(); + await wallet.create('password'); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + const k1 = 'd7'.repeat(32); + mint.state.creditNote(k1, 21_000); + const url = buildNoteUrl(`${mint.url}/w`, k1, 21_000); + const info = await fetchNoteInfo(url); + await wallet.addBearers( + [ + { + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: mint.state.pubkey, + }, + ], + wallet.captureOwnerFence(), + ); + const connection = createConnection(wallet.requireLinkingKey(), { + relays: RELAYS, + budget: { maxMsat: 50_000, periodMs: 86_400_000 }, + clientSecret: CLIENT_SECRET, + }); + const relay = createFakeRelay(); + let accepted = false; + const transport = { + ...relay.transport, + subscribe: (relays, filter, onEvent) => + relay.transport.subscribe(relays, filter, (event) => { + accepted = true; + onEvent(event); + }), + } satisfies NwcTransport; + setNwcTransportForTests(transport); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + expect(relay.subscriptionCount()).toBe(1); + + const commit = deferred(); + let commitStarted = false; + const applyChangeset = wallet.applyChangeset.bind(wallet); + vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => { + commitStarted = true; + await commit.promise; + return applyChangeset(changeset, ownerFence); + }); + const request = methodRequest( + connection.walletServicePubkey, + 'pay_invoice', + { + invoice: 'lnbc210n1pjqrstuvwxyz', + }, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emit(request); + expect(accepted).toBe(true); + await vi.waitFor(() => expect(commitStarted || nwc.lastError !== '').toBe(true), { + timeout: 5_000, + }); + expect(commitStarted, nwc.lastError).toBe(true); + expect(mint.state.noteState(k1)).toBe('burned'); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + + // When lock begins, it closes admission immediately but drains the + // already accepted payment while that payment's fence remains valid + let lockSettled = false; + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await vi.waitFor(() => expect(relay.subscriptionCount()).toBe(0)); + expect(nwc.running).toBe(false); + expect(lockSettled).toBe(false); + expect(() => wallet.captureOwnerFence()).toThrow(); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + const rejected = methodRequest( + connection.walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emitAfterClose(rejected); + expect( + relay.published.some( + (event) => + event.kind === NWC_RESPONSE_KIND && + event.tags.some((tag) => tag[0] === 'e' && tag[1] === rejected.id), + ), + ).toBe(false); + + // Then the accepted payment commits and responds before lock clears + // runtime keys; after unlock, durable budget and bearer state agree + commit.resolve(); + await locking; + expect(wallet.state).toBe('locked'); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + await wallet.unlock('password'); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBe(true); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + expect( + relay.published.some( + (event) => + event.kind === NWC_RESPONSE_KIND && + event.tags.some((tag) => tag[0] === 'e' && tag[1] === request.id), + ), + ).toBe(true); + } finally { + setNwcTransportForTests(null); + await mint.close(); + } + }); +}); diff --git a/src/stores/nwc.ts b/src/stores/nwc.ts index c3b7f94..9fdc156 100644 --- a/src/stores/nwc.ts +++ b/src/stores/nwc.ts @@ -14,12 +14,15 @@ import type { import { createConnection, persistNwcConnection, + readNwcEnabled, readNwcConnections, removeNwcConnection, startService, + writeNwcEnabled, } from '@/lnurlcash/nwc'; +import { linkingPubKeyHex } from '@/lnurlcash/keys'; import { msatToSats } from '@/lnurlcash/units'; -import { useWalletStore } from './wallet'; +import { TrustedMintPostCommitError, useWalletStore } from './wallet'; import { useMintsStore } from './mints'; import { useActivityStore } from './activity'; @@ -34,12 +37,6 @@ export const NWC_DEFAULT_BUDGET: NwcBudget = { periodMs: NWC_PERIOD_DAY_MS, }; -// the enabled flag lives outside wallet settings on purpose: settings are -// part of the nostr-backup payload, and a restored device must not start -// answering payment requests before its holder opted in there -const NWC_ENABLED_KEY = 'sattle_nwc_enabled'; -const readNwcEnabled = (): boolean => localStorage.getItem(NWC_ENABLED_KEY) === 'true'; - // e2e test hook: a fake transport so the suite never touches a real relay. // Set before enabling; production never calls this (exposed on window only // in dev builds, at the bottom of this file). @@ -48,6 +45,14 @@ export const setNwcTransportForTests = (transport: NwcTransport | null): void => transportOverride = transport; }; +declare global { + interface Window { + __sattleNwcTest?: { + readonly setTransport: typeof setNwcTransportForTests; + }; + } +} + const fingerprint = (pubkey: string): string => pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey; @@ -64,48 +69,60 @@ export const useNwcStore = defineStore('nwc', () => { const mints = useMintsStore(); const activity = useActivityStore(); - const enabled = ref(readNwcEnabled()); - const connections = ref(readNwcConnections()); + const enabled = ref(false); + const connections = ref([]); const running = ref(false); // background failures (a rejected publish, a lost claim) have no caller // to throw to - the page surfaces them here const lastError = ref(''); - const refresh = (): void => { - connections.value = readNwcConnections(); + const ownerFromWallet = (): string => linkingPubKeyHex(wallet.requireLinkingKey()); + + const refresh = (ownerId: string = ownerFromWallet()): void => { + connections.value = readNwcConnections(ownerId); }; // ---- changeset application ---- // the engine hands money-moving deltas here after an op ran: new notes to // persist, bearer ids to lock spent. Both go through the wallet store's - // one entry points (persist-then-state); failures surface as lastError - // rather than vanishing, since the engine already committed its side. - const applyChangeset = ( + // one entry points (persist-then-state) and are awaited: the engine holds + // its success answer until this resolves, so a failure rejects back into + // the engine's onError (surfaced as lastError) instead of a false success. + const applyChangeset = async ( changeset: NwcChangeset, connection: NwcConnectionInfo, method: NwcMethod, - ): void => { + ownerFence: () => void, + ): Promise => { const client = fingerprint(connection.record.clientPubkey); + try { + await wallet.applyChangeset(changeset, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } if (method === 'pay_invoice') { - // the melt's amount, from the bearers about to be locked spent const spentMsat = changeset.markSpent.reduce( (sum, id) => sum + (wallet.bearers.find((b) => b.id === id)?.amount ?? 0), 0, ); - activity.log('nwc', `NWC client ${client} paid ${formatSats(spentMsat)} sats.`); + await activity.log( + 'nwc', + `NWC client ${client} paid ${formatSats(spentMsat)} sats.`, + (error) => { + lastError.value = error.message; + }, + ); } if (method === 'make_invoice' && changeset.add.length > 0) { const mintedMsat = changeset.add.reduce((sum, note) => sum + note.amount, 0); - activity.log('nwc', `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`); - } - const onFailure = (error: unknown) => { - lastError.value = error instanceof Error ? error.message : 'Applying an NWC change failed.'; - }; - if (changeset.add.length > 0) { - void wallet.addBearers(changeset.add).catch(onFailure); - } - for (const id of changeset.markSpent) { - void wallet.markSpent(id).catch(onFailure); + await activity.log( + 'nwc', + `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`, + (error) => { + lastError.value = error.message; + }, + ); } }; @@ -115,15 +132,21 @@ export const useNwcStore = defineStore('nwc', () => { // a start that is still in flight when stop (or a restart) lands. let service: NwcService | null = null; let startToken = 0; + const pendingStarts = new Set>(); + let stopping: Promise = Promise.resolve(); + let pendingStop: Promise | null = null; - const start = async (): Promise => { - const token = ++startToken; + const startNow = async (token: number): Promise => { + await stopping; + if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return; lastError.value = ''; try { + const ownerFence = wallet.captureOwnerFence(); const started = await startService(wallet.requireLinkingKey(), { // only spendable notes may back an NWC payment getBearers: () => wallet.unspentBearers, getDefaultMint: () => mints.defaultMint, + assertCurrentOwner: ownerFence, applyChangeset, transport: transportOverride ?? undefined, onError: (error) => { @@ -133,7 +156,7 @@ export const useNwcStore = defineStore('nwc', () => { }); if (token !== startToken) { // stopped (or restarted) while we were subscribing - started.stop(); + await started.stop(); return; } service = started; @@ -146,34 +169,74 @@ export const useNwcStore = defineStore('nwc', () => { } }; - const stop = (): void => { - startToken++; - service?.stop(); + const start = (): Promise => { + pendingStop = null; + const token = ++startToken; + const operation = startNow(token); + pendingStarts.add(operation); + void operation.then( + () => pendingStarts.delete(operation), + () => pendingStarts.delete(operation), + ); + return operation; + }; + + const stop = (): Promise => { + if (service === null && pendingStarts.size === 0 && pendingStop !== null) { + const result = pendingStop; + pendingStop = null; + return result; + } + startToken += 1; + const active = service; service = null; running.value = false; + const priorStop = stopping; + const activeStop = active?.stop() ?? Promise.resolve(); + const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined); + pendingStop = completion; + stopping = completion.catch(() => undefined); + return completion; }; watch( - () => [wallet.state, enabled.value] as const, - ([state, on]) => { - if (state === 'unlocked' && on) void start(); - else stop(); + () => wallet.state, + (state) => { + void stop() + .then(() => { + if (state !== 'unlocked') { + enabled.value = false; + connections.value = []; + return; + } + const ownerId = ownerFromWallet(); + refresh(ownerId); + enabled.value = readNwcEnabled(ownerId); + if (enabled.value) return start(); + }) + .catch((error: unknown) => { + lastError.value = + error instanceof Error ? error.message : 'The NWC service failed to stop.'; + }); }, { immediate: true }, ); // the served set is a startup snapshot, so any change to the connection // records (create / budget edit / revoke) restarts the service to match - const restartIfRunning = (): void => { + const restartIfRunning = async (): Promise => { if (!running.value) return; - stop(); - if (wallet.state === 'unlocked' && enabled.value) void start(); + await stop(); + if (wallet.state === 'unlocked' && enabled.value) await start(); }; // ---- settings ---- - const setEnabled = (value: boolean): void => { + const setEnabled = async (value: boolean): Promise => { + const ownerId = ownerFromWallet(); + writeNwcEnabled(ownerId, value); enabled.value = value; - localStorage.setItem(NWC_ENABLED_KEY, String(value)); + if (value) await start(); + else await stop(); }; // ---- connection management ---- @@ -183,22 +246,24 @@ export const useNwcStore = defineStore('nwc', () => { const create = (relays: string[], budget: NwcBudget): CreatedConnection => { const created = createConnection(wallet.requireLinkingKey(), { relays, budget }); refresh(); - restartIfRunning(); + void restartIfRunning(); return created; }; const updateBudget = (clientPubkey: string, budget: NwcBudget): void => { - const record = readNwcConnections().find((r) => r.clientPubkey === clientPubkey); + const ownerId = ownerFromWallet(); + const record = readNwcConnections(ownerId).find((r) => r.clientPubkey === clientPubkey); if (!record) return; - persistNwcConnection({ ...record, budget }); - refresh(); - restartIfRunning(); + persistNwcConnection(ownerId, { ...record, budget }); + refresh(ownerId); + void restartIfRunning(); }; const revoke = (clientPubkey: string): void => { - removeNwcConnection(clientPubkey); - refresh(); - restartIfRunning(); + const ownerId = ownerFromWallet(); + removeNwcConnection(ownerId, clientPubkey); + refresh(ownerId); + void restartIfRunning(); }; return { @@ -206,6 +271,7 @@ export const useNwcStore = defineStore('nwc', () => { connections, running, lastError, + stop, setEnabled, create, updateBudget, @@ -216,7 +282,7 @@ export const useNwcStore = defineStore('nwc', () => { // dev-only e2e hook: lets a spec inject a fake relay transport before // enabling the service, so the suite opens no real WebSocket if (import.meta.env.DEV && typeof window !== 'undefined') { - (window as unknown as Record).__sattleNwcTest = { + window.__sattleNwcTest = { setTransport: setNwcTransportForTests, }; } 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.invalidation.cases.ts b/src/stores/wallet.lifecycle.invalidation.cases.ts new file mode 100644 index 0000000..03f80dd --- /dev/null +++ b/src/stores/wallet.lifecycle.invalidation.cases.ts @@ -0,0 +1,118 @@ +import { MINT_KEY, mocks, 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 { useNwcStore } from './nwc'; +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('surfaces a failed stale-tab drain without an unhandled rejection', async () => { + // Given an unlocked old-owner tab whose live NWC service rejects shutdown + const events = new EventTarget(); + vi.stubGlobal('window', events); + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const serviceStop = vi.fn().mockRejectedValue(new Error('stale drain failed')); + mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop }); + await nwc.setEnabled(true); + + // When another tab replaces the saved wallet owner + 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' }, + }), + ); + await vi.waitFor(() => expect(wallet.state).toBe('locked')); + + // Then the stale runtime is cleared and the queue surfaces the drain failure + expect(wallet.lifecycleError).toMatch(/stale drain failed/i); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(nwc.running).toBe(false); + expect(serviceStop).toHaveBeenCalledTimes(1); + }); + + 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/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/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.nwcDrain.test.ts b/src/stores/wallet.lifecycle.nwcDrain.test.ts new file mode 100644 index 0000000..9f9c65a --- /dev/null +++ b/src/stores/wallet.lifecycle.nwcDrain.test.ts @@ -0,0 +1,186 @@ +// Lock vs NWC drain integration: an accepted pay_invoice that already +// crossed its irreversible melt must reach its truthful durable outcome +// (budget debit, spent bearer, success response) BEFORE wallet.lock() +// invalidates the lifecycle fence and clears runtime keys. Uses the real +// wallet store, the real NWC store, the real service, and the real owner +// fence - only the relay transport is fake and the mint is the local +// conformance mock. Regresses the ordering where lock invalidated the +// captured fence first, leaving a debited budget, a locally unspent burned +// bearer, and no response. + +import { createPinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { v2 as nip44v2 } from 'nostr-tools/nip44'; + +import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc'; +import type { NostrEvent } from '@/lnurlcash/nwc'; +import { isJsonObject } from '@/lnurlcash/jsonParsing'; +import { requiredValue, stubLocalStorage } from '@/lnurlcash/test-utils'; +import { + CLIENT_SECRET, + RELAYS, + createFakeRelay, + deferred, + methodRequest, + waitFor, +} from '@/lnurlcash/nwc.testProtocol'; +import { setNwcTransportForTests, useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +const PASSWORD = 'correct horse battery staple'; +// decodes to exactly 21_000 msat, matching the credited note (exact carve) +const INVOICE_21K = 'lnbc210n1pjqrstuvwxyz'; + +type NwcResponsePayload = { + result_type: string; + error: { code: string; message: string } | null; + result: Record | null; +}; + +const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload => + isJsonObject(value) && + typeof value.result_type === 'string' && + (value.error === null || + (isJsonObject(value.error) && + typeof value.error.code === 'string' && + typeof value.error.message === 'string')) && + (value.result === null || isJsonObject(value.result)); + +const readResponse = (published: NostrEvent[], requestId: string): NwcResponsePayload | null => { + const event = published.find( + (candidate) => + candidate.kind === NWC_RESPONSE_KIND && + candidate.tags.some((tag) => tag[0] === 'e' && tag[1] === requestId), + ); + if (!event) return null; + const plaintext = nip44v2.decrypt( + event.content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey), + ); + const parsed: unknown = JSON.parse(plaintext); + if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.'); + return parsed; +}; + +type Mint = Awaited>; +const mints: Mint[] = []; + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(async () => { + setNwcTransportForTests(null); + await Promise.all(mints.splice(0).map((mint) => mint.close())); +}); + +describe('wallet lock with an in-flight NWC payment', () => { + it('drains an accepted post-melt pay to its durable outcome before invalidating the lifecycle', async () => { + // Given an encrypted unlocked wallet holding one exact-match note, served + // by a running NWC service over the fake relay + const mint = await createMockMint(); + mints.push(mint); + const wallet = useWalletStore(); + await wallet.create(PASSWORD); + const k1 = 'e1'.repeat(32); + mint.state.creditNote(k1, 21_000); + const noteUrl = buildNoteUrl(`${mint.url}/w`, k1, 21_000); + const noteInfo = await fetchNoteInfo(noteUrl); + const [bearer] = await wallet.addBearers( + [ + { + url: noteUrl, + callback: noteInfo.callback, + amount: noteInfo.maxWithdrawable, + verified: true, + mintPubkey: mint.state.pubkey, + }, + ], + wallet.captureOwnerFence(), + ); + if (!bearer) throw new Error('Expected the added bearer.'); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + const connection = createConnection(wallet.requireLinkingKey(), { + relays: RELAYS, + budget: { maxMsat: 1_000_000_000, periodMs: 86_400_000 }, + clientSecret: CLIENT_SECRET, + }); + const relay = createFakeRelay(); + setNwcTransportForTests(relay.transport); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + expect(nwc.running).toBe(true); + + // ... and a pay that crosses the irreversible melt, then pauses at the + // durable bearer commit + const releaseCommit = deferred(); + let commitReached = false; + const applyChangeset = wallet.applyChangeset; + vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => { + commitReached = true; + await releaseCommit.promise; + return applyChangeset(changeset, ownerFence); + }); + const request = methodRequest( + connection.walletServicePubkey, + 'pay_invoice', + { invoice: INVOICE_21K }, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emit(request); + await waitFor(() => commitReached); + expect(mint.state.noteState(k1)).toBe('burned'); + + // When the holder locks the wallet mid-flight + let lockSettled = false; + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + // Then the lock waits for the accepted pay instead of invalidating its + // commit: the lifecycle stays commit-capable while subscriptions are + // already closed, so no new work is accepted once the lock began + expect(lockSettled).toBe(false); + expect(wallet.state).toBe('unlocked'); + expect(relay.subscriptionCount()).toBe(0); + const lateRequest = methodRequest( + connection.walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emitAfterClose(lateRequest); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(readResponse(relay.published, lateRequest.id)).toBeNull(); + + // When the paused payment resumes + releaseCommit.resolve(); + + // Then the client receives its deterministic success BEFORE the lock + // completes, and budget and bearer state commit consistently + await waitFor(() => readResponse(relay.published, request.id) !== null); + const response = requiredValue(readResponse(relay.published, request.id)); + expect(response.error).toBeNull(); + expect(typeof response.result?.preimage).toBe('string'); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + await locking; + expect(wallet.state).toBe('locked'); + expect(wallet.bearers).toEqual([]); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(nwc.running).toBe(false); + + // ... and the spent note survives the lock durably + await wallet.unlock(PASSWORD); + expect(wallet.bearers.find((candidate) => candidate.id === bearer.id)?.spent).toBe(true); + await nwc.setEnabled(false); + }); +}); 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/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/wallet.trust.test.ts b/src/stores/wallet.trust.test.ts new file mode 100644 index 0000000..9ddb091 --- /dev/null +++ b/src/stores/wallet.trust.test.ts @@ -0,0 +1,171 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { TrustedMintPostCommitError, useWalletStore } from './wallet'; +import { useMintsStore } from './mints'; + +const MINT_PUBKEY = '02' + 'aa'.repeat(32); +const NOTE: NewBearer = { + url: buildNoteUrl('https://mint.example/w', 'bb'.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + mintPubkey: MINT_PUBKEY, +}; + +type LockRequest = { + readonly callback: () => unknown; + readonly resolve: (value: unknown) => void; +}; + +class DeferredLocks { + readonly requests: LockRequest[] = []; + + readonly request = (_name: string, callback: () => unknown): Promise => + new Promise((resolve) => { + this.requests.push({ callback, resolve }); + }); + + async releaseNext(): Promise { + const request = this.requests.shift(); + if (!request) throw new Error('Expected a queued lock request.'); + request.resolve(await request.callback()); + } +} + +beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('bearer commit trust side effect', () => { + it('commits a combined addition and spent marker in one bearer write', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([{ ...NOTE, mintPubkey: undefined }], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + const writes = vi.spyOn(storage, 'setItem'); + const applyChangeset = Reflect.get(wallet, 'applyChangeset'); + if (typeof applyChangeset !== 'function') { + throw new TypeError('Expected the wallet to expose atomic changeset application.'); + } + + await Reflect.apply(applyChangeset, wallet, [ + { add: [{ ...NOTE, mintPubkey: undefined }], markSpent: [existing.id] }, + ownerFence, + ]); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('keeps an atomic changeset committed when trust convergence fails afterward', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([{ ...NOTE, mintPubkey: undefined }], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + const originalSetItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === 'sattle_trusted_mints') throw new Error('trust storage unavailable'); + originalSetItem(key, value); + }; + + await expect( + wallet.applyChangeset({ add: [NOTE], markSpent: [existing.id] }, ownerFence), + ).rejects.toBeInstanceOf(TrustedMintPostCommitError); + + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(2); + expect(persisted.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('waits for trust convergence after the bearer is committed', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const locks = new DeferredLocks(); + vi.stubGlobal('navigator', { locks }); + + let settled = false; + const adding = wallet.addBearers([NOTE], ownerFence).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)); + await locks.releaseNext(); + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)); + + expect(wallet.bearers).toHaveLength(1); + expect(settled).toBe(false); + await locks.releaseNext(); + + expect(await adding).toHaveLength(1); + }); + + it('reports trust failure as post-commit while preserving durable funds', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const originalSetItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === 'sattle_trusted_mints') { + throw new Error('trust storage unavailable'); + } + originalSetItem(key, value); + }; + + const adding = wallet.addBearers([NOTE], ownerFence); + + await expect(adding).rejects.toMatchObject({ + name: 'TrustedMintPostCommitError', + fundsCommitted: true, + message: expect.stringMatching(/saved|committed/i), + }); + await expect(adding).rejects.toBeInstanceOf(TrustedMintPostCommitError); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.auxiliaryError).toMatch(/receive succeeded.*do not retry/i); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toHaveLength(1); + }); +}); + +describe('owner-bound trusted-mint reads', () => { + it('uses only the unlocked wallet owner and fails closed while locked', async () => { + const wallet = useWalletStore(); + const mints = useMintsStore(); + + expect(mints.isTrusted('mint.example')).toBe(false); + expect(mints.trustedPubkey('mint.example')).toBeNull(); + + await wallet.create('password'); + await mints.trust('mint.example', MINT_PUBKEY); + expect(mints.isTrusted('mint.example')).toBe(true); + expect(mints.trustedPubkey('mint.example')).toBe(MINT_PUBKEY); + + await wallet.lock(); + expect(mints.isTrusted('mint.example')).toBe(false); + expect(mints.trustedPubkey('mint.example')).toBeNull(); + + await wallet.unlock('password'); + expect(mints.isTrusted('mint.example')).toBe(true); + expect(mints.trustedPubkey('mint.example')).toBe(MINT_PUBKEY); + }); +}); diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index c2343bc..ec8b7ef 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -1,245 +1,263 @@ -import { computed, ref } from 'vue'; +import { computed, onScopeDispose, ref } from 'vue'; import { defineStore } from 'pinia'; -import { serverOf } from 'lnurlcash-kit'; import { - deriveWalletLinkingKey, deriveBearerAesKey, - saveLinkingKey, savedKeyExists, savedKeyIsEncrypted, - getPlainLinkingKey, - decryptSavedLinkingKey, + savedKeyOwnerId, clearSavedLinkingKey, - generateSeedPhrase, - isValidSeedPhrase, - linkingPubKeyHex, } from '@/lnurlcash/keys'; -import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { loadBearers, - persistBearer, - deleteBearerRecord, clearAllBearers, - newBearerId, - mergeBearers, + applyBackup, + parseBackupFile, + clearSettings, } from '@/lnurlcash/storage'; -import { - grandfatherTrustedMint, - lockTrustedMint, - clearTrustedMints, -} from '@/lnurlcash/trustedMints'; -import { clearSettings } from '@/lnurlcash/storage'; -import { unlockWithPasskey as unlockWithPasskeyEngine } from '@/lnurlcash/passkeys'; -import { disableBiometricUnlock, unlockWithBiometrics } from '@/capabilities/biometricUnlock'; -import { msatToSats } from '@/lnurlcash/units'; +import type { RestoreResult } from '@/lnurlcash/storage'; +import { disableBiometricUnlock } from '@/capabilities/biometricUnlock'; +import { restoreFromNostr as restoreFromNostrEngine } from '@/lnurlcash/nostrBackup'; import { useActivityStore } from './activity'; +import { createWalletFunds } from './walletFunds'; +import { createWalletIdleWatch } from './walletIdle'; +import { createWalletAccess } from './walletAccess'; +import { restoreHeldMintTrust } from './walletActivation'; +import { startWalletOwnerMonitor } from './walletOwnerMonitor'; +import { createWalletOwnerFence } from './walletOwnerFence'; +import type { WalletState } from './walletOwnerFence'; +import { + clearOwnerAuthorizations, + clearUnownedAuthorizations, + createSeedInstaller, + createWalletTransitionQueue, + migrateProvenLegacyOwner, + ownerOf, + stopWalletNwcSession, + WalletLifecycleError, +} from './walletLifecycle'; + +export { TrustedMintPostCommitError } from './walletFunds'; // 'none': no wallet on this device yet -> setup // 'locked': linking key present but password-encrypted -> unlock // 'unlocked': linking key (and thus the bearer AES key) in memory -export type WalletState = 'none' | 'locked' | 'unlocked'; - -// idle-timeout auto-lock: only meaningful for a password-encrypted key (see -// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in -// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead -// of that so the UI can warn, and postponeLock() is the "stay unlocked" -// hook it offers. -const AUTO_LOCK_MS = 5 * 60 * 1000; -const LOCK_WARNING_MS = 30 * 1000; +export type { WalletState } from './walletOwnerFence'; // a plaintext-stored key also starts 'locked' - init() unlocks it // immediately without a password, keeping a single code path for deriving // the AES key and loading bearers -const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none'); - export const useWalletStore = defineStore('wallet', () => { - const state = ref(initialState()); - const bearers = ref([]); + const state = ref(savedKeyExists() ? 'locked' : 'none'); const pubkey = ref(null); + const auxiliaryError = ref(''); + const lifecycleError = ref(''); let aesKey: CryptoKey | null = null; // the linking key itself, only while unlocked - needed by backup/passkey // operations (nostrBackup derives the backup key from it, passkey // registration wraps it). Never exposed reactively; cleared on lock/forget let currentLinkingKey: Uint8Array | null = null; + let lifecycleToken = 0; + let acceptingOwnerWork = false; - // ---- idle auto-lock bookkeeping ---- - let lastActivity = Date.now(); - let idleTimer: ReturnType | null = null; const lockWarningSecondsLeft = ref(null); + const runTransition = createWalletTransitionQueue({ + onStart: () => (lifecycleError.value = ''), + onError: (error) => { + lifecycleError.value = error instanceof Error ? error.message : 'Wallet transition failed.'; + }, + }).run; const encrypted = computed(() => savedKeyIsEncrypted()); - // ---- balances: protocol layer is msat; sats are a display helper ---- - const unspentBearers = computed(() => bearers.value.filter((b) => !b.spent)); - const balanceMsat = computed(() => unspentBearers.value.reduce((sum, b) => sum + b.amount, 0)); - const balanceSats = computed(() => msatToSats(balanceMsat.value)); - const balanceByMintMsat = computed(() => { - const byMint = new Map(); - for (const b of unspentBearers.value) { - const server = serverOf(b.url); - byMint.set(server, (byMint.get(server) ?? 0) + b.amount); - } - return byMint; - }); - const balanceByMintSats = computed(() => { - const byMint = new Map(); - for (const [server, msat] of balanceByMintMsat.value) { - byMint.set(server, msatToSats(msat)); - } - return byMint; + const ownerFence = createWalletOwnerFence({ + state: () => state.value, + ownerId: () => pubkey.value, + lifecycleToken: () => lifecycleToken, + accepting: () => acceptingOwnerWork, }); - const stopIdleWatch = () => { - if (idleTimer) clearInterval(idleTimer); - idleTimer = null; - lockWarningSecondsLeft.value = null; - }; - - const lock = () => { - // only meaningful for a password-encrypted key - a plaintext one would - // just auto-unlock again, so the UI only offers Lock when encrypted - if (!savedKeyIsEncrypted()) return; + const clearRuntime = (): void => { aesKey = null; currentLinkingKey = null; pubkey.value = null; - bearers.value = []; + funds.clear(); useActivityStore().unload(); - stopIdleWatch(); + }; + + // ends every captured fence and drops the reactive owner identity. Only + // ever runs after accepted NWC work has drained (or failed to): an + // operation past its irreversible melt must stay commit-capable until + // then, and stop() rejects new requests the moment it is called, so no + // post-lock work is accepted while the fence stays valid + const invalidateLifecycle = (): void => { + acceptingOwnerWork = false; + lifecycleToken += 1; state.value = 'locked'; + pubkey.value = null; }; - // any real interaction restarts the 5-minute clock and clears the - // warning - the UI's "Stay unlocked" button calls this - const postponeLock = () => { - lastActivity = Date.now(); - lockWarningSecondsLeft.value = null; - }; - - // ticks once a second while unlocked and encrypted (the only state - // auto-lock applies to), comparing wall-clock time against lastActivity - // rather than relying on a single setTimeout, since a backgrounded tab - // throttles timers but Date.now() still reflects real elapsed time - // whenever this next gets to run - const startIdleWatch = () => { - stopIdleWatch(); - if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return; - lastActivity = Date.now(); - const registerActivity = () => { - // once the warning is up, passive activity is deliberately ignored - - // only postponeLock() dismisses it, so the "stay unlocked" button - // can't vanish out from under the pointer before the click lands - if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) { - lastActivity = Date.now(); - } - }; - for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) { - window.addEventListener(event, registerActivity, { passive: true }); + const deactivateSession = async (): Promise => { + acceptingOwnerWork = false; + stopOwnerChanges(); + idleWatch.stop(); + try { + await stopWalletNwcSession(); + } finally { + // even a rejected drain ends the session: 'locked' never holds key + // material and no captured fence stays valid + invalidateLifecycle(); + clearRuntime(); } - idleTimer = setInterval(() => { - if (state.value !== 'unlocked') return; - const elapsed = Date.now() - lastActivity; - if (elapsed >= AUTO_LOCK_MS) { - lock(); - } else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) { - lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000); - } - }, 1000); }; - const activate = async (linkingKey: Uint8Array) => { + let stopOwnerChanges = (): void => {}; + const observeOwnerChanges = (): void => { + stopOwnerChanges(); + stopOwnerChanges = startWalletOwnerMonitor({ + snapshot: () => ({ token: lifecycleToken, state: state.value, ownerId: pubkey.value }), + deactivate: deactivateSession, + runTransition, + }); + }; + onScopeDispose(() => stopOwnerChanges()); + + const lock = (): Promise => + runTransition(async () => { + if (!savedKeyIsEncrypted()) return; + await deactivateSession(); + }); + + const idleWatch = createWalletIdleWatch({ + isEncrypted: savedKeyIsEncrypted, + isUnlocked: () => state.value === 'unlocked', + isLockWarningVisible: () => lockWarningSecondsLeft.value !== null, + lock, + setWarningSecondsLeft: (seconds) => { + lockWarningSecondsLeft.value = seconds; + }, + }); + + const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise => { + auxiliaryError.value = ''; + await migrateProvenLegacyOwner(linkingKey, ownerWasMissing); const key = await deriveBearerAesKey(linkingKey); - aesKey = key; - currentLinkingKey = linkingKey; - pubkey.value = linkingPubKeyHex(linkingKey); const loaded = await loadBearers(key); - bearers.value = loaded; const activity = useActivityStore(); await activity.loadFor(key); - // grandfather in every mint already backing a held bearer as trusted - - // holding funds there already implied trusting it. Storage-sourced - // claims only, though: grandfathering never locks and marks new pins - // unconfirmed - both are (re-)earned by live responses during actual - // bearer operations - for (const bearer of loaded) { - if (bearer.mintPubkey) { - grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey); - } - } + const ownerId = ownerOf(linkingKey); + await restoreHeldMintTrust(loaded, ownerId, (message) => { + auxiliaryError.value = message; + }); + aesKey = key; + currentLinkingKey = linkingKey; + lifecycleToken += 1; + observeOwnerChanges(); + pubkey.value = ownerId; + funds.replace(loaded); + acceptingOwnerWork = true; state.value = 'unlocked'; - startIdleWatch(); + idleWatch.start(); }; - // generates a fresh seed phrase, derives and saves the linking key, and - // unlocks. The phrase is returned exactly once - it is never stored, so - // the caller MUST show it to the holder before letting them move on. - const create = async (password?: string): Promise => { - const phrase = generateSeedPhrase(); - await restoreFromSeed(phrase, password); - return phrase; - }; - - const restoreFromSeed = async (seedPhrase: string, password?: string): Promise => { - if (!isValidSeedPhrase(seedPhrase)) { - throw new Error('Not a valid seed phrase.'); - } - const linkingKey = deriveWalletLinkingKey(seedPhrase); - await saveLinkingKey(linkingKey, password); - await activate(linkingKey); - }; - - const unlock = async (password?: string): Promise => { - const linkingKey = savedKeyIsEncrypted() - ? await decryptSavedLinkingKey(password || '') - : getPlainLinkingKey(); - if (!linkingKey) throw new Error('No wallet on this device.'); - await activate(linkingKey); - }; - - // passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key - // the password path protects, so activation is identical either way - const unlockWithPasskey = async (): Promise => { - await activate(await unlockWithPasskeyEngine()); - }; - - // native biometric unlock (capabilities/biometricUnlock.ts): a third wrap - // of the same linking key, behind the device credential prompt - const unlockWithBiometric = async (): Promise => { - await activate(await unlockWithBiometrics()); - }; - - // app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks - // without a password; an encrypted one waits on the unlock screen - const init = async (): Promise => { - if (state.value === 'locked' && !savedKeyIsEncrypted()) { - await unlock(); + const teardownCurrentOwner = async (resetRegistry = false): Promise => { + const ownerId = savedKeyOwnerId() ?? pubkey.value; + acceptingOwnerWork = false; + stopOwnerChanges(); + idleWatch.stop(); + try { + // the drain runs before the fence is invalidated and the runtime is + // cleared so an in-flight fund-critical changeset can still commit + // (its applyChangeset needs the live fence and key) + await stopWalletNwcSession(); + invalidateLifecycle(); + clearRuntime(); + if (ownerId === null) await clearUnownedAuthorizations(); + else await clearOwnerAuthorizations(ownerId, resetRegistry); + await disableBiometricUnlock(); + clearAllBearers(); + useActivityStore().unloadAndClear(); + clearSettings(); + clearSavedLinkingKey(); + state.value = 'none'; + } finally { + // a failed teardown still ends the session: 'locked' must never hold + // key material in memory, and no captured fence may stay usable + if (state.value !== 'none') invalidateLifecycle(); + clearRuntime(); } }; + const prepareInstallation = async (nextOwnerId: string): Promise => { + const installedOwner = savedKeyOwnerId() ?? pubkey.value; + if (savedKeyExists() && installedOwner === nextOwnerId) { + if (state.value === 'unlocked') await deactivateSession(); + return; + } + if (savedKeyExists() || state.value === 'unlocked') { + await teardownCurrentOwner(true); + } + await clearUnownedAuthorizations(); + }; + + const installSeed = createSeedInstaller({ + prepareInstallation, + activate: (linkingKey) => activate(linkingKey, false), + }); + + const access = createWalletAccess({ + runTransition, + installSeed, + activate, + canInit: () => state.value === 'locked', + }); + + const restoreFromBackup = (data: unknown): Promise => + runTransition(async () => { + const backup = parseBackupFile(data); + const hadSavedKey = savedKeyExists(); + const activeOwner = pubkey.value; + const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null; + if (activeKey !== null) await deactivateSession(); + if (!hadSavedKey) await clearUnownedAuthorizations(); + const result = await applyBackup(backup, activeOwner ?? undefined); + if (activeKey !== null) await activate(activeKey, false); + else if (result.linkingKeyRestored) state.value = 'locked'; + return result; + }); + + const restoreFromNostr = ( + seedPhrase: string, + relays: string[], + password?: string, + ): Promise => + runTransition(() => + installSeed(seedPhrase, password, async (linkingKey) => { + await restoreFromNostrEngine(linkingKey, relays); + }), + ); + + const restoreCurrentFromNostr = (relays: string[]) => + runTransition(async () => { + const linkingKey = requireLinkingKey(); + await deactivateSession(); + const result = await restoreFromNostrEngine(linkingKey, relays); + await activate(linkingKey, false); + return result; + }); + // wipes this wallet from the device entirely - the linking key, every // bearer record, the activity log, and the non-secret registries that // would otherwise linger as a fingerprint of it. Not recoverable by // restoring the same seed afterward (the ciphertexts themselves are // gone); only a backup downloaded before this runs can bring the notes // back - the UI should prompt for one - const forgetWallet = () => { - clearSavedLinkingKey(); - // the biometric wrap belongs to this wallet's linking key - drop it too. - // The record removal is synchronous inside; the secure-storage delete - // trails behind, and an orphaned secret there is unusable without the - // record, so a failed delete is safe to swallow - void disableBiometricUnlock().catch(() => {}); - clearAllBearers(); - clearTrustedMints(); - clearSettings(); - useActivityStore().unloadAndClear(); - aesKey = null; - currentLinkingKey = null; - pubkey.value = null; - bearers.value = []; - stopIdleWatch(); - state.value = 'none'; - }; + const forgetWallet = (): Promise => + runTransition(() => + teardownCurrentOwner().catch((error) => { + throw new WalletLifecycleError('forget', error); + }), + ); const requireKey = (): CryptoKey => { if (!aesKey) throw new Error('Wallet is locked.'); @@ -250,99 +268,39 @@ export const useWalletStore = defineStore('wallet', () => { // (nostr backup key derivation, passkey registration) - never reactive, // throws when locked, so callers can't accidentally hold a stale key const requireLinkingKey = (): Uint8Array => { - if (!currentLinkingKey) throw new Error('Wallet is locked.'); + if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.'); return currentLinkingKey; }; - // the one entry point for new notes (minted, received, carved outputs): - // persists first, then updates state. Holding a bearer from a mint - // trusts it by default - this is the one path that never asks (see - // trustedMints.ts); a DIFFERENT advertised key comes back as - // 'rekey-pending' and is staged on the mints store for review, never - // auto-applied. - const addBearers = async (notes: NewBearer[]): Promise => { - const now = Date.now(); - const added: Bearer[] = []; - for (const note of notes) { - const bearer: Bearer = { - id: newBearerId(), - ...note, - createdAt: now, - updatedAt: now, - }; - await persistBearer(requireKey(), bearer); - added.push(bearer); - } - bearers.value = [...added, ...bearers.value]; - for (const bearer of added) { - if (bearer.mintPubkey) { - lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey); - } - } - return added; - }; - - const updateBearer = async (id: string, changes: Partial>): Promise => { - const current = bearers.value.find((b) => b.id === id); - if (!current) return; - const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() }; - await persistBearer(requireKey(), updated); - bearers.value = bearers.value.map((b) => (b.id === id ? updated : b)); - if (updated.mintPubkey) { - lockTrustedMint(serverOf(updated.url), updated.mintPubkey); - } - }; - - const markSpent = async (id: string, spent = true): Promise => { - await updateBearer(id, { spent }); - }; - - const removeNote = async (id: string): Promise => { - bearers.value = bearers.value.filter((b) => b.id !== id); - await deleteBearerRecord(id); - }; - - // merges externally-produced bearers (a decrypted backup restore, later a - // nostr restore) into the live list: union by note identity, spent-wins - - // see storage.ts's mergeBearers. Persists every survivor. - const mergeExternalBearers = async (incoming: Bearer[]): Promise => { - const merged = mergeBearers(bearers.value, incoming); - for (const bearer of merged) { - await persistBearer(requireKey(), bearer); - } - bearers.value = merged; - }; - - const reloadBearers = async (): Promise => { - bearers.value = await loadBearers(requireKey()); - }; + const funds = createWalletFunds({ + requireKey, + ownerId: () => pubkey.value ?? undefined, + setAuxiliaryError: (message) => { + auxiliaryError.value = message; + }, + }); return { state, - bearers, pubkey, + auxiliaryError, + lifecycleError, encrypted, lockWarningSecondsLeft, - balanceMsat, - balanceSats, - balanceByMintMsat, - balanceByMintSats, - unspentBearers, - create, - restoreFromSeed, - unlock, - unlockWithPasskey, - unlockWithBiometric, + ...funds.public, + create: access.create, + restoreFromSeed: access.restoreFromSeed, + restoreFromBackup, + restoreFromNostr, + restoreCurrentFromNostr, + unlock: access.unlock, + unlockWithPasskey: access.unlockWithPasskey, + unlockWithBiometric: access.unlockWithBiometric, lock, - init, + init: access.init, forgetWallet, - postponeLock, + postponeLock: idleWatch.postpone, requireLinkingKey, - addBearers, - updateBearer, - markSpent, - removeNote, - mergeExternalBearers, - reloadBearers, + captureOwnerFence: ownerFence.capture, }; }); 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.', + ); + } + } +}; diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts new file mode 100644 index 0000000..0c989b2 --- /dev/null +++ b/src/stores/walletFunds.atomic.test.ts @@ -0,0 +1,111 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { useWalletStore } from './wallet'; + +const note = (secret: string): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, +}); + +const rejectSecondEncryption = (): void => { + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let encryptions = 0; + vi.spyOn(crypto.subtle, 'encrypt').mockImplementation((algorithm, key, data) => { + encryptions += 1; + return encryptions === 2 + ? Promise.reject(new Error('second encryption failed')) + : encrypt(algorithm, key, data); + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('wallet multi-bearer durability', () => { + it('adds two bearers with one established changeset write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + }); + + it('persists no partial addition when the second bearer encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + rejectSecondEncryption(); + + await expect( + wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()), + ).rejects.toThrow('second encryption failed'); + + expect(wallet.bearers).toEqual([]); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toEqual([]); + }); + + it('persists no partial external merge when the second encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const incoming: Bearer[] = [ + { + id: 'incoming-b', + ...note('b'), + createdAt: Date.now() + 1, + updatedAt: Date.now() + 1, + }, + { + id: 'incoming-c', + ...note('c'), + createdAt: Date.now() + 2, + updatedAt: Date.now() + 2, + }, + ]; + rejectSecondEncryption(); + + await expect(wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence())).rejects.toThrow( + 'second encryption failed', + ); + + expect(wallet.bearers).toEqual([existing]); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toEqual([existing]); + }); + + it('merges multiple external bearers with one established changeset write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + const now = Date.now(); + const incoming: Bearer[] = [ + { id: 'incoming-b', ...note('b'), createdAt: now + 1, updatedAt: now + 1 }, + { id: 'incoming-c', ...note('c'), createdAt: now + 2, updatedAt: now + 2 }, + ]; + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(3); + }); +}); diff --git a/src/stores/walletFunds.fencing.test.ts b/src/stores/walletFunds.fencing.test.ts new file mode 100644 index 0000000..3da2409 --- /dev/null +++ b/src/stores/walletFunds.fencing.test.ts @@ -0,0 +1,206 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys'; +import { loadBearers, readEncryptedBearers } from '@/lnurlcash/storage'; +import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { useWalletStore } from './wallet'; + +const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); + +const note = (secret: string): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, +}); + +// a second tab installing its own wallet: the saved-key record flips to the +// successor owner. Deliberately NO storage event is dispatched - the fence +// must not depend on the wakeup arriving first. +const replacePersistedOwner = (): void => { + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: '09'.repeat(32), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ); +}; + +type LockRequest = { + readonly callback: () => unknown; + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; +}; + +// mirrors BROWSER LockManager semantics: a callback failure rejects the +// request and frees the lock name (Node 24's LockManager wedges instead, +// which is why these tests drive their own fake) +class DeferredLocks { + readonly requests: LockRequest[] = []; + + readonly request = (_name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({ callback, resolve, reject }); + }); + + async releaseNext(): Promise { + const request = this.requests.shift(); + if (!request) throw new Error('Expected a queued lock request.'); + try { + request.resolve(await request.callback()); + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error), { cause: error })); + } + } +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('stale-owner fencing of fund commits', () => { + it('fences a changeset commit after a silent owner replacement', async () => { + // Given an unlocked wallet with one bearer and no pending storage event + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([note('aa')], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + + // When another tab replaced the wallet and this (still unlocked) tab + // tries to commit a fund change + replacePersistedOwner(); + await expect( + wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence), + ).rejects.toBeInstanceOf(WalletOwnerMismatchError); + + // Then nothing moved: not in storage, not in the reactive list + expect(readEncryptedBearers()).toHaveLength(1); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + }); + + it('fences a single-record spent mark after a silent owner replacement', async () => { + // Given an unlocked wallet whose owner was silently replaced + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([note('aa')], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + replacePersistedOwner(); + + // When the stale tab marks the note spent + await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf( + WalletOwnerMismatchError, + ); + + // Then the record is untouched in both worlds + expect(wallet.bearers[0]?.spent).toBeUndefined(); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect((await loadBearers(key))[0]?.spent).toBeUndefined(); + }); + + it('revalidates the owner inside the commit lock, after encryption', async () => { + // Given a changeset commit whose storage write is held at the lock + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([note('aa')], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + + // When the commit passed the entry fence, finished encryption, and is + // parked at the lock - and only NOW another tab replaces the owner + const locks = new DeferredLocks(); + vi.stubGlobal('navigator', { locks }); + const committing = wallet.applyChangeset( + { add: [note('bb')], markSpent: [existing.id] }, + ownerFence, + ); + await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); + replacePersistedOwner(); + await locks.releaseNext(); + + // Then the commit fails closed instead of writing stale-owner ciphertext + await expect(committing).rejects.toBeInstanceOf(WalletOwnerMismatchError); + expect(readEncryptedBearers()).toHaveLength(1); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + }); + + it('rejects a fence captured by an earlier lifecycle of the same owner', async () => { + // Given an operation accepted before the encrypted wallet locked and + // unlocked again under the same persisted owner + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create('password'); + const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const staleFence = wallet.captureOwnerFence(); + await wallet.lock(); + await wallet.unlock('password'); + + // When the old lifecycle tries to commit into the new runtime + await expect( + wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, staleFence), + ).rejects.toBeInstanceOf(WalletOwnerMismatchError); + + // Then exact owner equality alone cannot authorize the stale operation + expect(readEncryptedBearers()).toHaveLength(1); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + }); + + it('revalidates a spent update inside its persistence lock', async () => { + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const ownerFence = wallet.captureOwnerFence(); + const locks = new DeferredLocks(); + vi.stubGlobal('navigator', { locks }); + + const updating = wallet.markSpent(existing.id, ownerFence); + await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); + replacePersistedOwner(); + await locks.releaseNext(); + + await expect(updating).rejects.toBeInstanceOf(WalletOwnerMismatchError); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect((await loadBearers(key))[0]?.spent).toBeUndefined(); + }); + + it('revalidates a deletion inside its persistence lock', async () => { + vi.stubGlobal('navigator', {}); + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const ownerFence = wallet.captureOwnerFence(); + const locks = new DeferredLocks(); + vi.stubGlobal('navigator', { locks }); + + const removing = wallet.removeNote(existing.id, ownerFence); + await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); + replacePersistedOwner(); + await locks.releaseNext(); + + await expect(removing).rejects.toBeInstanceOf(WalletOwnerMismatchError); + expect(readEncryptedBearers()).toHaveLength(1); + expect(wallet.bearers).toHaveLength(1); + }); +}); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts new file mode 100644 index 0000000..32ac47b --- /dev/null +++ b/src/stores/walletFunds.ts @@ -0,0 +1,200 @@ +import { computed, ref } from 'vue'; +import { serverOf } from 'lnurlcash-kit'; + +import { + applyBearerChangeset, + deleteBearerRecord, + loadBearers, + mergeBearers, + persistBearer, +} from '@/lnurlcash/storage'; +import type { BearerChangeset } from '@/lnurlcash/storage'; +import { lockTrustedMint } from '@/lnurlcash/trustedMints'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { msatToSats } from '@/lnurlcash/units'; +import type { WalletOwnerFence } from './walletOwnerFence'; + +export class TrustedMintPostCommitError extends Error { + override readonly name = 'TrustedMintPostCommitError'; + readonly fundsCommitted = true; + + constructor( + readonly committedBearers: Bearer[], + options: { cause: unknown }, + ) { + super( + 'Funds were saved, but the trusted-mint registry could not be updated. The receive succeeded; do not retry it.', + options, + ); + } +} + +type WalletFundsOptions = { + readonly requireKey: () => CryptoKey; + readonly ownerId: () => string | undefined; + readonly setAuxiliaryError: (message: string) => void; +}; + +export const createWalletFunds = (options: WalletFundsOptions) => { + const bearers = ref([]); + const unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent)); + const balanceMsat = computed(() => + unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0), + ); + const balanceSats = computed(() => msatToSats(balanceMsat.value)); + const balanceByMintMsat = computed(() => { + const byMint = new Map(); + for (const bearer of unspentBearers.value) { + const server = serverOf(bearer.url); + byMint.set(server, (byMint.get(server) ?? 0) + bearer.amount); + } + return byMint; + }); + const balanceByMintSats = computed(() => { + const byMint = new Map(); + for (const [server, msat] of balanceByMintMsat.value) { + byMint.set(server, msatToSats(msat)); + } + return byMint; + }); + + const replace = (loaded: Bearer[]): void => { + bearers.value = loaded; + }; + + const clear = (): void => { + bearers.value = []; + }; + + const lockCommittedBearers = async (committed: Bearer[]): Promise => { + try { + for (const bearer of committed) { + if (bearer.mintPubkey) { + await lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey, options.ownerId()); + } + } + } catch (error) { + const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.'); + const postCommitError = new TrustedMintPostCommitError(committed, { cause }); + options.setAuxiliaryError(postCommitError.message); + throw postCommitError; + } + }; + + const addBearers = async ( + notes: NewBearer[], + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + ownerFence(); + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { add: notes, markSpent: [] }, + // re-prove ownership inside the lock: encryption is async, so the + // entry check alone would leave a cross-tab replacement window open + { beforeCommit: ownerFence }, + ); + const added = next.slice(0, notes.length); + bearers.value = next; + await lockCommittedBearers(added); + return added; + }; + + const applyChangeset = async ( + changeset: BearerChangeset, + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + ownerFence(); + const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, { + beforeCommit: ownerFence, + }); + const added = next.slice(0, changeset.add.length); + bearers.value = next; + await lockCommittedBearers(added); + return added; + }; + + const updateBearer = async ( + id: string, + changes: Partial>, + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) return; + ownerFence(); + const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() }; + await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence }); + bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer)); + if (!updated.mintPubkey) return; + try { + await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId()); + } catch (error) { + const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.'); + const postCommitError = new TrustedMintPostCommitError([updated], { cause }); + options.setAuxiliaryError(postCommitError.message); + throw postCommitError; + } + }; + + const markSpent = async ( + id: string, + ownerFence: WalletOwnerFence, + spent = true, + ): Promise => { + await updateBearer(id, { spent }, ownerFence); + }; + + const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise => { + ownerFence(); + await deleteBearerRecord(id, { beforeCommit: ownerFence }); + bearers.value = bearers.value.filter((bearer) => bearer.id !== id); + }; + + const mergeExternalBearers = async ( + incoming: Bearer[], + ownerFence: WalletOwnerFence, + ): Promise => { + ownerFence(); + const merged = mergeBearers(bearers.value, incoming); + const mergedIds = new Set(merged.map((bearer) => bearer.id)); + await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: [], + upsert: merged, + remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id), + }, + { beforeCommit: ownerFence }, + ); + bearers.value = merged; + }; + + const reloadBearers = async (): Promise => { + bearers.value = await loadBearers(options.requireKey()); + }; + + return { + public: { + bearers, + unspentBearers, + balanceMsat, + balanceSats, + balanceByMintMsat, + balanceByMintSats, + addBearers, + applyChangeset, + updateBearer, + markSpent, + removeNote, + mergeExternalBearers, + reloadBearers, + }, + replace, + clear, + }; +}; 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 }; +}; 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); + }; diff --git a/src/stores/walletOwnerFence.ts b/src/stores/walletOwnerFence.ts new file mode 100644 index 0000000..b31eb67 --- /dev/null +++ b/src/stores/walletOwnerFence.ts @@ -0,0 +1,37 @@ +import { assertSavedKeyOwner, WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; + +export type WalletState = 'none' | 'locked' | 'unlocked'; +export type WalletOwnerFence = () => void; + +type WalletOwnerFenceOptions = Readonly<{ + state: () => WalletState; + ownerId: () => string | null; + lifecycleToken: () => number; + accepting: () => boolean; +}>; + +export const createWalletOwnerFence = (options: WalletOwnerFenceOptions) => { + const assertCurrentOwner = (): void => { + const ownerId = options.ownerId(); + if (options.state() !== 'unlocked' || ownerId === null) { + throw new WalletOwnerMismatchError(); + } + assertSavedKeyOwner(ownerId); + }; + + const capture = (): WalletOwnerFence => { + if (!options.accepting()) throw new WalletOwnerMismatchError(); + assertCurrentOwner(); + const token = options.lifecycleToken(); + const ownerId = options.ownerId(); + if (ownerId === null) throw new WalletOwnerMismatchError(); + return () => { + if (options.lifecycleToken() !== token || options.ownerId() !== ownerId) { + throw new WalletOwnerMismatchError(); + } + assertCurrentOwner(); + }; + }; + + return { assertCurrentOwner, capture }; +}; diff --git a/src/stores/walletOwnerMonitor.test.ts b/src/stores/walletOwnerMonitor.test.ts new file mode 100644 index 0000000..ce963ff --- /dev/null +++ b/src/stores/walletOwnerMonitor.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { startWalletOwnerMonitor } from './walletOwnerMonitor'; + +const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); +const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); + +describe('wallet owner monitor', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + }); + + it('consumes a background transition rejection after an owner replacement', () => { + // Given an unlocked stale owner and a transition promise observed by the queue + const events = new EventTarget(); + vi.stubGlobal('window', events); + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: '09'.repeat(32), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ); + const transition = Promise.resolve(); + const catchRejection = vi.spyOn(transition, 'catch'); + startWalletOwnerMonitor({ + snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }), + deactivate: vi.fn().mockResolvedValue(undefined), + runTransition: vi.fn().mockReturnValue(transition), + }); + + // When the browser reports that the saved owner changed + events.dispatchEvent( + Object.defineProperties(new Event('storage'), { + key: { value: 'sattle_linking_key' }, + }), + ); + + // Then the fire-and-forget transition has a rejection consumer + expect(catchRejection).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/stores/walletOwnerMonitor.ts b/src/stores/walletOwnerMonitor.ts new file mode 100644 index 0000000..5453b7f --- /dev/null +++ b/src/stores/walletOwnerMonitor.ts @@ -0,0 +1,41 @@ +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(); + }) + .catch(() => undefined); + }); diff --git a/vitest.config.ts b/vitest.config.ts index 8bbf734..8c67976 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,13 @@ +import { fileURLToPath, URL } from 'node:url'; + import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, test: { // the tested modules are pure crypto/codec/protocol logic - node's own // WebCrypto (crypto.subtle) and fetch cover everything they need, no