diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index e46a39d..53888f6 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -1,13 +1,26 @@ import type { Page, Route } from '@playwright/test'; +import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { utf8ToBytes } from '@noble/hashes/utils.js'; // The mock mint lives on a .test origin that never resolves - every request // to it is intercepted by Playwright and fulfilled in-process, so the specs // never touch the network. Protocol shapes mirror lnurlcash-kit's client: // the informational GET on the note URL (fetchNoteInfo) expects an LUD-03 // withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the -// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same -// callback shape also answers a melt (meltNote sends k1 + pr to the same -// callback and only requires status "OK"). +// mutating GET on the callback (rotate/split/merge/melt) expects +// {status: "OK"} plus the LUD-25 signature(s) over every minted output - +// the app forces the kit's requireSignatures policy, so an unsigned +// mutation answer is not a success the wallet accepts quietly. +// +// Signing: each origin has a stable secp256k1 signing key derived from its +// origin string, so a spec can pin the advertised mintPubkey via +// signingPubkey(origin). `invalidSignatures = true` signs every output with +// a foreign key instead - the notes are real mint-side, but the offline +// proof verifies against no key the mint advertises, which is exactly the +// "landed but unverifiable" case the wallet must refuse to mark spendable. // // Every method takes an optional origin, so a spec can stand up a SECOND // mint (e.g. MINT2_ORIGIN) for inter-mint transfers. @@ -29,13 +42,28 @@ const fulfillJson = async (route: Route, body: unknown): Promise => { }); }; +// deterministic per-origin signing keys: the mock mints are stable across +// tests, and the key derivation needs nothing but the origin string +const signingKeyFor = (origin: string): Uint8Array => + sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`)); + +// the key every output is signed with in invalid-signature mode: a real +// secp256k1 key, just never advertised by any mock mint +const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key')); + +// a melt's settle proof reveals a payment receipt - derived from the paid +// invoice so it is stable per payment and never collides with a note secret +const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`))); + interface MockNoteInfoOptions { // note value in msat, reported as maxWithdrawable amountMsat?: number; // when set, the mint answers the ERROR envelope with this reason instead // (a reason matching /spent/i surfaces the "already been spent" UI) spentReason?: string; - // the mint's signing pubkey, advertised as mintPubkey in responses + // the mint's signing pubkey, advertised as mintPubkey in responses - + // defaults to the mock origin's real signing key (kit 0.8 refuses a + // withdrawRequest that publishes none, so every mock mint has one) mintPubkey?: string; } @@ -43,14 +71,14 @@ interface MockTargetMintOptions { // 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; + // once masked a real bug. Defaults to the origin's real signing key. + 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 // amount cross-check invoice: string; - // the payment preimage a settled verify reveals - 64 hex chars (it IS the - // claimed note's secret) + // the payment preimage a settled verify reveals - a receipt distinct from + // the wallet-chosen secret committed in the invoice comment preimage: string; // value of the note a claim mints, in msat noteAmountMsat: number; @@ -62,10 +90,69 @@ interface MockTargetMintOptions { export class MintMocker { constructor(private page: Page) {} + // when true, every minted output is signed with a foreign key: the + // mutation lands mint-side, but the signature verifies against no key + // this mint advertises - the wallet must keep the outputs unverified + invalidSignatures = false; + + // every signature this mock issued, in order - lets a spec assert the + // mint actually signed (the kit's requireSignatures policy makes an + // unsigned success impossible to confuse with a signed one) + readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = []; + + // the mint's advertised signing pubkey for an origin (compressed, hex) + signingPubkey(origin = MINT_ORIGIN): string { + return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true)); + } + + // note id (sha256 of the secret) -> outstanding value in msat, per origin. + // A real mint keys its ledger by the hash; the mock does the same so a + // mutation can sign the output with the amount the inputs actually held. + private readonly outstanding = new Map(); + private readonly burned = new Set(); + // invoices this mint reports as settled on its verify endpoint, per origin + private readonly settledInvoices = new Map(); + + private noteKey(origin: string, noteId: string): string { + return `${origin}|${noteId}`; + } + + private sign(origin: string, h: string, amountMsat: number): string { + const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin); + // noble's 'recovered' layout is [recovery, r, s]; the wire layout is + // r || s || recovery (the kit accepts either, the trailing layout is + // what lnurl-mint emits) + const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, { + format: 'recovered', + prehash: false, + }); + this.signaturesIssued.push({ origin, h, amountMsat }); + return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]])); + } + + // mint outputs at the given hashes, burning the inputs - the ledger + // transition every successful mutation makes + private remint( + origin: string, + inputIds: string[], + outputs: [h: string, amountMsat: number][], + ): void { + for (const id of inputIds) { + this.outstanding.delete(this.noteKey(origin, id)); + this.burned.add(this.noteKey(origin, id)); + } + for (const [h, amountMsat] of outputs) { + this.outstanding.set(this.noteKey(origin, h), amountMsat); + } + } + // The note's informational GET: a spec-shaped withdrawRequest, or the - // ERROR envelope for a note the mint considers spent. The kit validates - // that the service echoes back the queried k1, so read it off the request. + // ERROR envelope for a note the mint considers spent or never issued. The + // kit validates that the service echoes back the queried k1, so read it + // off the request. A first-seen k1 registers at the configured amount - + // the mock's way of "issuing" the note a spec hands to the wallet. async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise { + const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin); await this.page.route( new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), async (route: Route) => { @@ -74,27 +161,147 @@ export class MintMocker { return; } const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; + if (!/^[0-9a-f]{64}$/i.test(k1)) { + // a by-hash lookup (h=) or a malformed k1 - this mock answers + // secret-keyed info only + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + const id = this.noteKey(origin, hashK1(k1)); + if (this.burned.has(id)) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (!this.outstanding.has(id)) { + this.outstanding.set(id, options.amountMsat ?? 0); + } + const amountMsat = this.outstanding.get(id) ?? 0; await fulfillJson(route, { tag: 'withdrawRequest', callback: `${origin}${CALLBACK_PATH}`, k1, - minWithdrawable: options.amountMsat ?? 0, - maxWithdrawable: options.amountMsat ?? 0, + minWithdrawable: amountMsat, + maxWithdrawable: amountMsat, defaultDescription: 'mock mint note', - ...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}), + mintPubkey, }); }, ); } - // The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr - // params) both expect a plain {status: "OK"} confirmation. No sig is - // returned - a plain LUD-03-style service that does not sign notes. - async mockRotateOk(origin = MINT_ORIGIN): Promise { + // The settle proof a melt's verify URL serves: settled once the melt + // landed, carrying the paid invoice and its preimage receipt. + private async mockVerify(origin: string): Promise { + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), + async (route: Route) => { + const pr = new URL(route.request().url()).searchParams.get('pr') ?? ''; + const settled = (this.settledInvoices.get(origin) ?? []).includes(pr); + await fulfillJson(route, { + settled, + ...(settled ? { preimage: preimageFor(pr) } : {}), + pr, + }); + }, + ); + } + + // The mutating callback GET, signed per LUD-25: + // - melt (k1 + pr): burns the input, answers OK with a settle-proof URL + // - rotate (k1 + h): burns the input, mints at h for its full value + // - split (k1... + amount + h + h2): carves amount off the inputs' total, + // minting h for it and h2 for the change + // - merge (k1... + h): burns all inputs, mints h for their total + // Every minted output's signature rides in the response (sig / sig2), and + // a mutation naming an unknown or burned input is refused like a real + // mint's. A byte-identical replay gets the same success - the mutations + // are deterministic in their inputs, so the replayed answer re-derives + // the same signatures. + async mockMutationEndpoint(origin = MINT_ORIGIN): Promise { + await this.mockVerify(origin); await this.page.route( new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`), async (route: Route) => { - await fulfillJson(route, { status: 'OK' }); + const url = new URL(route.request().url()); + const k1s = url.searchParams.getAll('k1'); + if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + const pr = url.searchParams.get('pr'); + const h = url.searchParams.get('h'); + const h2 = url.searchParams.get('h2'); + const amountRaw = url.searchParams.get('amount'); + const inputIds = k1s.map((k1) => hashK1(k1)); + const lookup = (id: string): number | 'unknown' | 'burned' => { + const key = this.noteKey(origin, id); + if (this.burned.has(key)) return 'burned'; + return this.outstanding.get(key) ?? 'unknown'; + }; + + // melt: the note burns once the payment settles; the mock answers + // OK (payment in flight) and the verify endpoint reports it settled + if (pr !== null) { + for (const id of inputIds) { + const state = lookup(id); + if (state === 'burned') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (state === 'unknown') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + } + this.remint(origin, inputIds, []); + const paid = this.settledInvoices.get(origin) ?? []; + paid.push(pr); + this.settledInvoices.set(origin, paid); + await fulfillJson(route, { + status: 'OK', + verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`, + }); + return; + } + + // every other mutation mints outputs whose signatures the wallet + // must be able to verify offline + const total = inputIds.reduce((sum, id) => { + const state = lookup(id); + return typeof state === 'number' ? sum + state : sum; + }, 0); + for (const id of inputIds) { + const state = lookup(id); + if (state === 'burned') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (state === 'unknown') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + } + if (h !== null && h2 !== null && amountRaw !== null) { + // split: amount carved off, the rest is change + const amountMsat = Number(amountRaw); + this.remint(origin, inputIds, [ + [h, amountMsat], + [h2, total - amountMsat], + ]); + await fulfillJson(route, { + status: 'OK', + sig: this.sign(origin, h, amountMsat), + sig2: this.sign(origin, h2, total - amountMsat), + }); + return; + } + if (h !== null) { + // rotate (one input) or merge (several): full value at h + this.remint(origin, inputIds, [[h, total]]); + await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) }); + return; + } + await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' }); }, ); } @@ -105,7 +312,11 @@ export class MintMocker { // 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 { + private async mockMintAddress( + options: MockTargetMintOptions, + origin: string, + mintPubkey: string, + ): Promise { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), async (route: Route) => { @@ -114,7 +325,7 @@ export class MintMocker { callback: `${origin}${CALLBACK_PATH}`, minWithdrawable: 1000, maxWithdrawable: 100_000_000_000, - mintPubkey: options.mintPubkey, + mintPubkey, payLink: `${origin}/.well-known/lnurlp/mint`, nodeCapacity: 500_000_000, nodeNumChannels: 4, @@ -127,15 +338,33 @@ export class MintMocker { // Everything the target side of a transfer (or a Lightning receive) // 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. + // immediately-settled verify endpoint revealing the payment receipt, + // and note info at the wallet-chosen committed secret. async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise { - await this.mockMintAddress(options, origin); - await this.mockNoteInfo( - { amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, - origin, + const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin); + let stagedHash: string | null = null; + let settled = false; + await this.mockMintAddress(options, origin, mintPubkey); + await this.mockMutationEndpoint(origin); + await this.page.route( + new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), + async (route: Route) => { + const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; + if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + await fulfillJson(route, { + tag: 'withdrawRequest', + callback: `${origin}${CALLBACK_PATH}`, + k1, + minWithdrawable: options.noteAmountMsat, + maxWithdrawable: options.noteAmountMsat, + defaultDescription: 'mock minted note', + mintPubkey, + }); + }, ); - await this.mockRotateOk(origin); await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`), async (route: Route) => { @@ -144,6 +373,7 @@ export class MintMocker { callback: `${origin}/pay`, minSendable: 1000, maxSendable: 100_000_000_000, + commentAllowed: 64, withdrawLink: `${origin}${NOTE_PATH}`, metadata: options.mintFeeMetadata ?? '[]', }); @@ -152,18 +382,38 @@ export class MintMocker { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`), async (route: Route) => { + const request = new URL(route.request().url()); + const comment = request.searchParams.get('comment'); + const commitment = request.searchParams.get('h'); + if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) { + await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' }); + return; + } + stagedHash = comment.toLowerCase(); await fulfillJson(route, { pr: options.invoice, verify: `${origin}/verify`, }); }, ); + await this.page.route( + new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`), + async (route: Route) => { + const paidInvoice = new URL(route.request().url()).searchParams.get('pr'); + if (paidInvoice !== options.invoice) { + await route.fallback(); + return; + } + settled = true; + await fulfillJson(route, { status: 'OK' }); + }, + ); await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), async (route: Route) => { await fulfillJson(route, { - settled: true, - preimage: options.preimage, + settled, + ...(settled ? { preimage: options.preimage } : {}), pr: options.invoice, }); }, diff --git a/e2e/helpers/wallet.ts b/e2e/helpers/wallet.ts index fd708d8..0d74605 100644 --- a/e2e/helpers/wallet.ts +++ b/e2e/helpers/wallet.ts @@ -1,4 +1,8 @@ import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; +import { MINT_ORIGIN, NOTE_PATH } from './MintMocker'; +import type { MintMocker } from './MintMocker'; // Drives onboarding end to end on a fresh browser context: /#/welcome -> // "Create wallet" (empty password = stored unencrypted) -> confirm the @@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise => { await page.getByRole('button', { name: 'Continue' }).click(); await page.waitForURL(/#\/$/); }; + +// Give a fresh wallet one verified, spendable note at the mock mint: the +// mock answers the note's informational GET and signs every mutation, and +// the receive's first-contact trust prompt is confirmed, pinning the mint's +// signing key so the wallet can verify what this mint mints. +export const fundMockMintWallet = async ( + page: Page, + mint: MintMocker, + amountMsat: number, +): Promise => { + await mint.mockNoteInfo({ amountMsat }); + await mint.mockMutationEndpoint(); + await createFreshWallet(page); + + await page.getByRole('button', { name: 'Receive' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await dialog + .locator('textarea') + .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat)); + await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); + await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible(); + // the note advertises the mint's key, so the first-contact trust prompt + // opens as its own dialog - trust it (that also pins the signing key) + await page + .locator('.q-dialog', { hasText: 'New mint' }) + .getByRole('button', { name: 'Trust this mint' }) + .click(); + await dialog.getByRole('button', { name: 'Done' }).click(); +}; diff --git a/e2e/specs/backup-security.spec.ts b/e2e/specs/backup-security.spec.ts index 6aed51e..897ec09 100644 --- a/e2e/specs/backup-security.spec.ts +++ b/e2e/specs/backup-security.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from '../fixtures'; import { createFreshWallet } from '../helpers/wallet'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { utf8ToBytes } from '@noble/hashes/utils.js'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; // M4 backup & security surfaces. No real WebAuthn: headless Chromium has no // platform authenticator, so the security page is asserted in its honest @@ -12,6 +16,72 @@ const VALID_PHRASE = const readSettings = async (page: import('@playwright/test').Page) => JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}'); +// A v2 encrypted wallet-material record built with the app's own wrap +// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that +// need a saved, locked wallet without driving the onboarding UI. +const PASSWORD = 'correct horse battery staple'; +const PBKDF2_ITERATIONS = 210_000; + +const encryptedWalletMaterialRecord = async ( + password: string, +): Promise<{ + enc: true; + salt: string; + iv: string; + ciphertext: string; + materialHash: string; + ownerId: string; + version: 2; +}> => { + const linkingKeyHex = '07'.repeat(32); + const material = { + version: 2, + linkingKeyHex, + cashRootHex: '08'.repeat(64), + }; + const serialized = JSON.stringify(material); + 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(serialized), + ), + ); + return { + enc: true, + salt: bytesToHex(salt), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)), + version: 2, + }; +}; + +const 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; +}; + test.describe('Backup page', () => { test('renders all sections and exports a JSON backup file', async ({ page }) => { await createFreshWallet(page); @@ -93,19 +163,22 @@ test.describe('Security page', () => { }); await createFreshWallet(page); await page.evaluate(() => { - const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}'); + const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}'); if ( typeof saved !== 'object' || saved === null || !('ownerId' in saved) || - typeof saved.ownerId !== 'string' + typeof saved.ownerId !== 'string' || + !('materialHash' in saved) || + typeof saved.materialHash !== 'string' ) { throw new Error('expected saved wallet owner'); } const wrap = { hkdfSalt: '11'.repeat(16), iv: '22'.repeat(12), - wrappedKey: '33'.repeat(48), + materialHash: saved.materialHash, + wrappedMaterial: '33'.repeat(48), createdAt: 1, }; localStorage.setItem( @@ -116,14 +189,14 @@ test.describe('Security page', () => { credentialId: '44'.repeat(16), name: 'Current wallet passkey', ownerId: saved.ownerId, - version: 1, + version: 2, }, { ...wrap, credentialId: '55'.repeat(16), name: 'Foreign wallet passkey', ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', - version: 1, + version: 2, }, ]), ); @@ -137,36 +210,39 @@ test.describe('Security page', () => { 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, - }, - ]), - ); - }); + test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({ + page, + }) => { + // Given an encrypted v2 saved wallet whose only passkey slot belongs to + // a DIFFERENT owner (a foreign wallet's residue on the same device) + const record = await encryptedWalletMaterialRecord(PASSWORD); + await page.addInitScript( + ({ storedRecord }) => { + localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord)); + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + credentialId: '44'.repeat(16), + hkdfSalt: '55'.repeat(16), + iv: '66'.repeat(12), + materialHash: '77'.repeat(32), + wrappedMaterial: '88'.repeat(48), + createdAt: 1, + ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', + version: 2, + }, + ]), + ); + }, + { storedRecord: record }, + ); // When the locked wallet renders await page.goto('/'); - // Then passkey-first unlock is unavailable until another owner proof + // Then passkey-first unlock is unavailable - the one slot on this + // device is not this wallet's await expect(page.getByText('Wallet locked')).toBeVisible(); await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0); }); diff --git a/e2e/specs/manage-mints.spec.ts b/e2e/specs/manage-mints.spec.ts index d9dc426..2f987b8 100644 --- a/e2e/specs/manage-mints.spec.ts +++ b/e2e/specs/manage-mints.spec.ts @@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise => { callback: 'https://mint.600.wtf/pay', minSendable: 1000, maxSendable: 100_000_000_000, + commentAllowed: 64, withdrawLink: 'https://mint.600.wtf/note', metadata: '[]', }), @@ -87,8 +88,8 @@ test.describe('Manage mints', () => { test('a mint with held notes cannot be removed', async ({ page, mint }) => { // hold a 21-sat note from the mock mint - holding funds locks the mint // against removal - await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: 21_000 }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); await page.getByRole('button', { name: 'Receive' }).click(); diff --git a/e2e/specs/move-funds.spec.ts b/e2e/specs/move-funds.spec.ts index 5a0cd7a..ebd7530 100644 --- a/e2e/specs/move-funds.spec.ts +++ b/e2e/specs/move-funds.spec.ts @@ -1,13 +1,10 @@ import { test, expect } from '../fixtures'; import type { Page } from '@playwright/test'; -import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; -import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; -import { createFreshWallet } from '../helpers/wallet'; +import { MINT2_ORIGIN } from '../helpers/MintMocker'; +import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet'; const AMOUNT_MSAT = 50_000; // 50 sats -const MINT_PUBKEY = `02${'ab'.repeat(32)}`; -const TARGET_PUBKEY = `03${'cd'.repeat(32)}`; -// 64 hex chars - a valid preimage (it becomes the claimed note's secret) +// 64 hex chars - a valid payment receipt, distinct from the claimed note secret const PREIMAGE = 'ef'.repeat(32); // amount-less by decodeBolt11AmountMsat, so the kit skips its invoice // amount cross-check against the requested value @@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer'; // give the wallet a verified, spendable 50-sat note at the source mint const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); - await createFreshWallet(page); - - await page.getByRole('button', { name: 'Receive' }).click(); - const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); - await chooser.getByRole('button', { name: 'Bearer note' }).click(); - const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); - await dialog - .locator('textarea') - .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT)); - await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); - await expect(dialog.getByText('Received 50 sats')).toBeVisible(); - // the note advertises a mint key, so the first-contact trust prompt opens - // as its own dialog - trust it (that also locks the mint) - await page - .locator('.q-dialog', { hasText: 'New mint' }) - .getByRole('button', { name: 'Trust this mint' }) - .click(); - await dialog.getByRole('button', { name: 'Done' }).click(); + await fundMockMintWallet(page, mint, AMOUNT_MSAT); }; // pick an option from a Quasar select identified by its label @@ -78,7 +56,6 @@ test.describe('Move funds', () => { // the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm) await mint.mockTargetMint( { - mintPubkey: TARGET_PUBKEY, invoice: INVOICE, preimage: PREIMAGE, noteAmountMsat: AMOUNT_MSAT, @@ -100,11 +77,10 @@ test.describe('Move funds', () => { test('a two-mint transfer moves the balance', async ({ page, mint }) => { await fundSourceMint(page, mint); - // the target mint: hands out the invoice, reports it settled with the - // preimage, and answers the claim's note info + rotation + // the target mint commits the wallet-chosen secret, hands out the invoice, + // and exposes that note only after the source-mint melt pays it await mint.mockTargetMint( { - mintPubkey: TARGET_PUBKEY, invoice: INVOICE, preimage: PREIMAGE, noteAmountMsat: AMOUNT_MSAT, diff --git a/e2e/specs/nwc.spec.ts b/e2e/specs/nwc.spec.ts index e7a77ce..f958d29 100644 --- a/e2e/specs/nwc.spec.ts +++ b/e2e/specs/nwc.spec.ts @@ -1,17 +1,34 @@ import { test, expect } from '../fixtures'; -import { createFreshWallet } from '../helpers/wallet'; +import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet'; +import { hexToBytes } from '@noble/hashes/utils.js'; +import { finalizeEvent } from 'nostr-tools/pure'; +import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04'; // M5 Nostr Wallet Connect settings surface. No real relay traffic: the one // test that runs the service injects a fake transport through the dev-only // window hook (stores/nwc.ts) before enabling - every other test stays // UI-only with the service off. +// a Nostr event as the fake transport carries it - the plain JSON shape +// NIP-47 requests and responses take on the wire +type WireEvent = { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; + sig: string; +}; + type FakeSub = { closed: boolean; close: () => void }; declare global { interface Window { __sattleNwcTest: { setTransport: (transport: unknown) => void }; __nwcSubs: FakeSub[]; + __nwcPublished: WireEvent[]; + __nwcOnEvent?: (event: WireEvent) => void; } } @@ -146,4 +163,112 @@ test.describe('NWC settings page', () => { .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed))) .toBe(true); }); + + test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => { + // a funded wallet: 50 sats at the mock mint, its signing key pinned + await fundMockMintWallet(page, mint, 50_000); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + + // create a connection and read its one-time URI: the wallet service + // pubkey is the host, the client secret the `secret` param + await openNwcPage(page); + await page.getByRole('button', { name: 'Create connection' }).click(); + 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.'); + await page.getByRole('button', { name: "Done - I've saved it" }).click(); + const connectionUrl = new URL(uri); + const walletServicePubkey = connectionUrl.host; + const clientSecret = connectionUrl.searchParams.get('secret') ?? ''; + + // a fake transport that captures the request handler and every + // published event, installed before the service starts + await page.evaluate(() => { + window.__nwcSubs = []; + window.__nwcPublished = []; + window.__sattleNwcTest.setTransport({ + publish: (_relays: string[], event: WireEvent) => { + window.__nwcPublished.push(event); + return Promise.resolve(); + }, + subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => { + window.__nwcOnEvent = onEvent; + const sub: FakeSub = { + closed: false, + close() { + this.closed = true; + }, + }; + window.__nwcSubs.push(sub); + return sub; + }, + }); + }); + await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click(); + await expect(page.getByText('Service running', { exact: false })).toBeVisible(); + await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function'); + + // a 21-sat invoice the mint will settle; the request is exactly what a + // NIP-47 client sends: kind 23194, NIP-04 encrypted to the service, + // schnorr-signed by the connection's client secret + const invoice = 'lnbc210n1pjqrstuvwxyz'; + const content = await nip04Encrypt( + clientSecret, + walletServicePubkey, + JSON.stringify({ method: 'pay_invoice', params: { invoice } }), + ); + const request = finalizeEvent( + { + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip04'], + ], + content, + }, + hexToBytes(clientSecret), + ); + await page.evaluate((event) => window.__nwcOnEvent?.(event), request); + + // the service pays and answers on the same scheme + await expect + .poll(async () => + page.evaluate( + (id) => + window.__nwcPublished.find( + (event) => + event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id), + ) ?? null, + request.id, + ), + ) + .not.toBeNull(); + const responseEvent = await page.evaluate( + (id) => + window.__nwcPublished.find( + (event) => + event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id), + ) ?? null, + request.id, + ); + if (!responseEvent) throw new Error('the service published no response'); + const response: unknown = JSON.parse( + await nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content), + ); + if (typeof response !== 'object' || response === null) { + throw new Error('the service response is not an object'); + } + const result = response as { result_type?: unknown; error?: unknown; result?: unknown }; + expect(result.result_type).toBe('pay_invoice'); + expect(result.error).toBeNull(); + // the receipt is the melt's payment preimage from the mint's own + // settle proof - 64 hex chars, never a note secret + const payload = result.result as { preimage?: unknown }; + expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/); + + // the wallet paid 21 of its 50 sats; the change stays in the wallet + await page.goto('/#/'); + await expect(page.locator('.balance-card .text-h2')).toHaveText('29'); + }); }); diff --git a/e2e/specs/receive.spec.ts b/e2e/specs/receive.spec.ts index f5eeee7..11afbae 100644 --- a/e2e/specs/receive.spec.ts +++ b/e2e/specs/receive.spec.ts @@ -5,7 +5,6 @@ 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 @@ -26,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise => { test.describe('Receive bearer note', () => { test('redeeming a valid note updates the balance', async ({ page, mint }) => { await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); - await mint.mockRotateOk(); + await mint.mockMutationEndpoint(); await createFreshWallet(page); const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); @@ -38,6 +37,18 @@ test.describe('Receive bearer note', () => { // were really consumed (the kit bug this guards against silently fell // back to the declared amount without any request) await expect(dialog.locator('.q-banner')).toHaveCount(0); + // the mint signed the rotation's output (LUD-25) - the wallet's + // requireSignatures policy accepted a signed answer, not a rescue + expect( + mint.signaturesIssued.some( + (entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT, + ), + ).toBe(true); + + // the note advertised the mint's key, so the first-contact trust prompt + // opened - dismiss it, then close the success screen + 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 expect(page.locator('.balance-card .text-h2')).toHaveText('21'); }); @@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => { page, mint, }) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); await redeemNote(page, freshNoteUrl()); @@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => { page, mint, }) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); await page.evaluate(() => { localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}'); diff --git a/e2e/specs/send.spec.ts b/e2e/specs/send.spec.ts new file mode 100644 index 0000000..4710dbd --- /dev/null +++ b/e2e/specs/send.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { + fromBech32Lnurl, + noteDeclaredAmount, + noteK1, + noteSignature, + verifyNoteSignature, +} from 'lnurlcash-kit'; +import { MINT_ORIGIN } from '../helpers/MintMocker'; +import { fundMockMintWallet } from '../helpers/wallet'; + +const FUNDING_MSAT = 50_000; // 50 sats +const SEND_MSAT = 21_000; // 21 sats + +// give the wallet a verified, spendable 50-sat note at the mock mint, with +// the mint's signing key pinned through the first-contact trust prompt +const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { + await fundMockMintWallet(page, mint, FUNDING_MSAT); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); +}; + +// prepare a note hand-over and read the displayed note off the clipboard +const prepareNote = async (page: Page, sats: string): Promise => { + await page.getByRole('button', { name: 'Send' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await dialog.getByLabel('Amount (sats)').fill(sats); + await dialog.getByRole('button', { name: 'Prepare note' }).click(); + await expect(dialog.getByText('Tap to reveal')).toBeVisible(); + await dialog.getByRole('button', { name: 'Copy' }).click(); + return page.evaluate(() => navigator.clipboard.readText()); +}; + +test.describe('Send a note', () => { + test('a carved note carries a signature that verifies against the mint', async ({ + page, + mint, + }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await fundMockMint(page, mint); + + // the mint signed the split's two outputs (the carved note and the + // change), and the note the dialog hands over carries that signature + // in its URL + const beforeSend = mint.signaturesIssued.length; + const bech32 = await prepareNote(page, '21'); + const issued = mint.signaturesIssued + .slice(beforeSend) + .map((entry) => entry.amountMsat) + .sort((a, b) => a - b); + expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]); + const url = fromBech32Lnurl(bech32.trim()); + expect(url).not.toBeNull(); + if (!url) throw new Error('the dialog handed over an undecodable note'); + const k1 = noteK1(url); + const amountMsat = noteDeclaredAmount(url); + const signature = noteSignature(url); + expect(k1).not.toBeNull(); + expect(amountMsat).toBe(SEND_MSAT); + expect(signature).not.toBeNull(); + if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param'); + // the offline proof verifies against the key the mint advertised and + // the wallet pinned - this is what marked the carved note spendable + expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true); + + // keep the note: nothing was handed over, the balance is untouched + const dialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await dialog.getByRole('button', { name: 'Keep in wallet' }).click(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + }); + + test('outputs the mint signs with a foreign key never become spendable', async ({ + page, + mint, + }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await fundMockMint(page, mint); + + // the mint's mutations land but are signed by a key it never advertised + mint.invalidSignatures = true; + const bech32 = await prepareNote(page, '21'); + + // the note exists (the split landed mint-side), but its offline proof + // verifies against no key this mint publishes + const url = fromBech32Lnurl(bech32.trim()); + if (!url) throw new Error('the dialog handed over an undecodable note'); + const k1 = noteK1(url); + const amountMsat = noteDeclaredAmount(url); + const signature = noteSignature(url); + if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param'); + expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false); + + // keep the note: the balance is not corrupted by the bad proof + const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + + // the carve's outputs stayed unverified, so the wallet refuses to spend + // from them - a second preparation fails instead of moving bad notes + await page.getByRole('button', { name: 'Send' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const retry = page.locator('.q-dialog', { hasText: 'Send a note' }); + await retry.getByLabel('Amount (sats)').fill('21'); + await retry.getByRole('button', { name: 'Prepare note' }).click(); + await expect( + retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }), + ).toBeVisible(); + await expect(retry.getByText('Tap to reveal')).toHaveCount(0); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + }); +}); diff --git a/e2e/specs/trusted-mint-tabs.spec.ts b/e2e/specs/trusted-mint-tabs.spec.ts index ff2566c..d9a60de 100644 --- a/e2e/specs/trusted-mint-tabs.spec.ts +++ b/e2e/specs/trusted-mint-tabs.spec.ts @@ -23,7 +23,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise< const ownerId = async (page: Page): Promise => page.evaluate(() => { - const raw = localStorage.getItem('sattle_linking_key'); + const raw = localStorage.getItem('sattle_wallet_material_v2'); if (raw === null) return null; const saved: unknown = JSON.parse(raw); if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null; diff --git a/e2e/specs/wallet-lifecycle.spec.ts b/e2e/specs/wallet-lifecycle.spec.ts index 217ee15..7fdf13f 100644 --- a/e2e/specs/wallet-lifecycle.spec.ts +++ b/e2e/specs/wallet-lifecycle.spec.ts @@ -1,215 +1,95 @@ -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. +// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha) +// install - the linking-key-only record, the pre-v2 bearer store and their +// ownerless residue - is never migrated (the cash root is unrecoverable +// from a linking key), so boot resets the device to the uninstalled state +// before any route can expose it, and a successor wallet starts without +// any of the old residue. -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 = [ +// every namespace the alpha wallet could have touched +const LEGACY_KEYS = [ 'sattle_linking_key', 'sattle_bearers', - 'sattle_activity', - 'sattle_settings', + 'sattle_wallet_material_v2', 'sattle_passkey_slots', 'sattle_nwc_connections', 'sattle_nwc_enabled', 'sattle_trusted_mints', 'sattle_biometric_wrap', + 'sattle_activity', + 'sattle_settings', ] as const; test.describe('wallet lifecycle', () => { - test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({ + test('a legacy alpha install is reset at boot and the 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 }, - ); + // Given a legacy encrypted linking-key record plus ownerless residue + 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_bearers', + JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]), + ); + 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: `02${'aa'.repeat(32)}`, + addedAt: 1, + locked: false, + }, + ]), + ); + }); + // When the app boots 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 + // Then the alpha reset landed on the uninstalled state - no unlock + // surface for a wallet that can never be derived again await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); - await expect - .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed))) - .toBe(true); + await expect(page.getByText('Wallet locked')).toHaveCount(0); + + // ...and every namespace the alpha wallet could have touched is gone const remaining = await page.evaluate( (keys) => keys.filter((key) => localStorage.getItem(key) !== null), - [...WALLET_KEYS], + [...LEGACY_KEYS], ); expect(remaining).toEqual([]); @@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => { 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([]); + // Then it unlocks into a working wallet with zero adopted residue + // (settings/activity are the live app's own namespaces - the residue + // check covers only the stores a predecessor could have planted) + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('0'); + const residue = await page.evaluate( + (keys) => keys.filter((key) => localStorage.getItem(key) !== null), + [ + 'sattle_linking_key', + 'sattle_bearers', + 'sattle_passkey_slots', + 'sattle_nwc_connections', + 'sattle_nwc_enabled', + 'sattle_trusted_mints', + 'sattle_biometric_wrap', + ], + ); + expect(residue).toEqual([]); }); });