From cbe49a83afa0e0959791225ade418d7bffde09f4 Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 18:48:11 +0200 Subject: [PATCH 01/29] build(deps): adopt lnurlcash-kit 0.8 contracts --- src/lnurlcash/mintContract.test.ts | 9 +++--- src/lnurlcash/mintContract.v08.cases.ts | 43 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 src/lnurlcash/mintContract.v08.cases.ts diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index 4643fb6..36594f9 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -1,5 +1,5 @@ -// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 / -// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows +// Wire-contract coverage for the pinned lnurlcash-kit 0.8.0 / +// lnurlcash-conformance 0.7.0 artifacts. Contracts the app's mint flows // rely 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 @@ -19,6 +19,7 @@ import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {mintAddressCacheInfo} from './trustedMints' +import './mintContract.v08.cases' type Mint = Awaited> @@ -136,8 +137,8 @@ describe('mint-address wire contract', () => { describe('withdraw-link forms', () => { it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { - // conformance 0.4.0 defaults to plain https links; lnurlw:// (the form - // lnurl-mint emits under LUD-17) is an explicit option + // conformance 0.7.0 defaults to plain https links; lnurlw:// is an + // explicit LUD-17 transport form const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) diff --git a/src/lnurlcash/mintContract.v08.cases.ts b/src/lnurlcash/mintContract.v08.cases.ts new file mode 100644 index 0000000..58306af --- /dev/null +++ b/src/lnurlcash/mintContract.v08.cases.ts @@ -0,0 +1,43 @@ +// The wallet's deterministic-secret and signed-output work depends on this +// exact installed API surface, so package drift must fail before funds code runs. + +import {describe, expect, it} from 'vitest' +import { + cashNodeFromHex, + cashNodeToHex, + cashSecretSource, + deriveCashRoot, + restoreFromSeed, + verifyNoteSignature, + verifyNoteSignatureAgainst, + verifyNoteSignatureHash, + verifyNoteSignatureHashAgainst, +} from 'lnurlcash-kit' +import type {LnurlcashOptions} from 'lnurlcash-kit' +import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json' +import kitPackage from '../../node_modules/lnurlcash-kit/package.json' + +const retryOptions = {mutationRetries: 1} satisfies LnurlcashOptions +const requiredExports = [ + deriveCashRoot, + cashNodeToHex, + cashNodeFromHex, + cashSecretSource, + restoreFromSeed, + verifyNoteSignature, + verifyNoteSignatureAgainst, + verifyNoteSignatureHash, + verifyNoteSignatureHashAgainst, +] as const + +describe('lnurlcash 0.8 dependency contract', () => { + it('exposes the BIP-32, recovery, signature, and retry contracts', () => { + expect(requiredExports.every((value) => typeof value === 'function')).toBe(true) + expect(retryOptions.mutationRetries).toBe(1) + }) + + it('runs against the exact installed package versions', () => { + expect(kitPackage.version).toBe('0.8.0') + expect(conformancePackage.version).toBe('0.7.0') + }) +}) From 0677b650163b5d9d4838e342d7d939e8a72f843b Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 18:48:41 +0200 Subject: [PATCH 02/29] docs: record lnurlcash 0.8 dependency pins --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f9f713f..4525d18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ 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@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm +- `lnurlcash-kit@0.8.0` and `lnurlcash-conformance@0.7.0` 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 From d48deeb6e0b32ffd3f5abc5fbc2080198e4ecb5b Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 19:46:15 +0200 Subject: [PATCH 03/29] feat(keys): persist BIP-32 wallet material --- src/lnurlcash/keys.material-storage.cases.ts | 196 +++++++++++++++++++ src/lnurlcash/keys.material.cases.ts | 159 +++++++++++++++ src/lnurlcash/keys.ts | 113 ++++------- src/lnurlcash/keys.version.cases.ts | 3 + src/lnurlcash/passwordWrap.ts | 63 ++++++ src/lnurlcash/storage/storedSecret.ts | 137 ++++++++++++- src/lnurlcash/storage/walletOwnerEvents.ts | 9 +- src/lnurlcash/walletMaterialStorage.ts | 166 ++++++++++++++++ 8 files changed, 763 insertions(+), 83 deletions(-) create mode 100644 src/lnurlcash/keys.material-storage.cases.ts create mode 100644 src/lnurlcash/keys.material.cases.ts create mode 100644 src/lnurlcash/passwordWrap.ts create mode 100644 src/lnurlcash/walletMaterialStorage.ts diff --git a/src/lnurlcash/keys.material-storage.cases.ts b/src/lnurlcash/keys.material-storage.cases.ts new file mode 100644 index 0000000..d748080 --- /dev/null +++ b/src/lnurlcash/keys.material-storage.cases.ts @@ -0,0 +1,196 @@ +import {describe, expect, it} from 'vitest' + +import { + decryptSavedWalletMaterial, + deriveWalletMaterial, + encryptSecretParts, + ensureSavedWalletMaterialOwner, + getPlainWalletMaterial, + isValidStoredWalletMaterial, + linkingPubKeyHex, + restoreWalletMaterialStored, + savedWalletMaterialExists, + savedWalletMaterialIsEncrypted, + savedWalletMaterialOwnerId, + saveWalletMaterial, + serializeWalletMaterial, + walletMaterialLinkingKey, +} from './keys' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +const FIXED_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const PASSWORD = 'correct horse battery staple' +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' +const LEGACY_STORAGE_KEY = 'sattle_linking_key' +const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' +const CASH_ROOT_HEX = + 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' + +const readRawMaterialRecord = (): Record => { + const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) + if (raw === null) throw new Error('expected a saved wallet-material record') + return parseJsonObject(raw) +} + +describe('WalletMaterialV2 persistence', () => { + it('round-trips canonical plaintext material in the new namespace', async () => { + // Given complete wallet material and an unrelated legacy record + stubLocalStorage() + localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({enc: false, value: '07'.repeat(32)})) + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When the material is saved without a password + await saveWalletMaterial(material) + + // Then only the new namespace carries the exact complete material + expect(savedWalletMaterialExists()).toBe(true) + expect(savedWalletMaterialIsEncrypted()).toBe(false) + expect(getPlainWalletMaterial()).toEqual(material) + expect(readRawMaterialRecord()).toEqual({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }) + expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull() + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC) + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"') + }) + + it('round-trips canonical password-encrypted material without plaintext fields', async () => { + // Given complete wallet material + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When the material is password protected + await saveWalletMaterial(material, PASSWORD) + + // Then only authenticated decryption returns both parts + expect(savedWalletMaterialIsEncrypted()).toBe(true) + expect(getPlainWalletMaterial()).toBeNull() + expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material) + await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow() + const raw = JSON.stringify(readRawMaterialRecord()) + expect(raw).not.toContain(LINKING_KEY_HEX) + expect(raw).not.toContain(CASH_ROOT_HEX) + expect(raw).not.toContain(FIXED_MNEMONIC) + }) + + it('strips restored owner claims until the material proves its real owner', () => { + // Given plaintext material carrying a foreign owner claim + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9)) + + // When the record is restored and subsequently proven by plaintext unlock + restoreWalletMaterialStored({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: foreignOwner, + version: 2, + }) + const beforeProof = readRawMaterialRecord() + const unlocked = getPlainWalletMaterial() + if (unlocked === null) throw new Error('expected restored plaintext material') + ensureSavedWalletMaterialOwner(unlocked) + + // Then the claim was absent before proof and only the derived owner was stamped + expect(beforeProof).toEqual({enc: false, value: serializeWalletMaterial(material)}) + expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) + }) + + it('rejects a plaintext owner mismatch without returning or stamping material', () => { + // Given canonical material stamped with a foreign current owner + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }), + ) + const before = localStorage.getItem(MATERIAL_STORAGE_KEY) + + // When it is read or offered for proof + const unlocked = getPlainWalletMaterial() + const stamp = () => ensureSavedWalletMaterialOwner(material) + + // Then neither operation trusts or rewrites the foreign record + expect(unlocked).toBeNull() + expect(stamp).toThrow('different wallet') + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it('rejects an encrypted owner mismatch after authentication', async () => { + // Given valid encrypted material whose owner marker was replaced + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + const foreign = { + ...readRawMaterialRecord(), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + } + localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign)) + const before = localStorage.getItem(MATERIAL_STORAGE_KEY) + + // When the authenticated material contradicts the current owner marker + const unlock = decryptSavedWalletMaterial(PASSWORD) + + // Then no material is returned or marker changed + await expect(unlock).rejects.toThrow('different wallet') + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it.each([ + ['legacy key-only plaintext', LINKING_KEY_HEX, 2], + ['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1], + [ + 'malformed material plaintext', + JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00'}), + 2, + ], + ])('rejects %s records at the storage boundary', (_name, value, version) => { + // Given an invalid record under the v2 material namespace + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + const record = { + enc: false, + value, + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version, + } + localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record)) + + // When storage helpers parse it + // Then the record is unusable as wallet material + expect(isValidStoredWalletMaterial(record)).toBe(false) + expect(savedWalletMaterialExists()).toBe(false) + expect(getPlainWalletMaterial()).toBeNull() + }) + + it('rejects authenticated ciphertext containing malformed material', async () => { + // Given valid AES-GCM parts whose plaintext is not a v2 material envelope + stubLocalStorage() + const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD) + const material = deriveWalletMaterial(FIXED_MNEMONIC) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }), + ) + + // When the password successfully authenticates the malformed plaintext + const unlock = decryptSavedWalletMaterial(PASSWORD) + + // Then it still fails closed as invalid material + await expect(unlock).rejects.toThrow('valid v2 wallet material') + }) +}) diff --git a/src/lnurlcash/keys.material.cases.ts b/src/lnurlcash/keys.material.cases.ts new file mode 100644 index 0000000..586ef2e --- /dev/null +++ b/src/lnurlcash/keys.material.cases.ts @@ -0,0 +1,159 @@ +import {describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import {getPublicKey} from 'nostr-tools/pure' +import {cashNodeToHex} from 'lnurlcash-kit' + +import { + deriveBearerAesKey, + deriveWalletLinkingKey, + deriveWalletMaterial, + linkingPubKeyHex, + parseWalletMaterial, + serializeWalletMaterial, + walletMaterialCashRoot, + walletMaterialLinkingKey, +} from './keys' +import {backupPubkey, deriveBackupKey} from './nostrBackup' +import {deriveNwcWalletKey, nwcWalletPubkey} from './nwc' + +const FIXED_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17)) +const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' +const CASH_ROOT_HEX = + 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' + +describe('fixed mnemonic identity characterization', () => { + it('pins every public linking-key-derived identity and bearer encryption behavior', async () => { + // Given a mnemonic accepted by the existing wallet derivation + const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC) + const bearerKey = await deriveBearerAesKey(linkingKey) + + // When its owner, bearer, Nostr, and NWC observables are derived + const bearerCiphertext = new Uint8Array( + await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: new Uint8Array(12)}, + bearerKey, + new TextEncoder().encode('sattle-vector-v1'), + ), + ) + + // Then they match the pre-v2 wallet's independently captured vectors + expect(linkingPubKeyHex(linkingKey)).toBe( + '0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481', + ) + expect(bytesToHex(bearerCiphertext)).toBe( + '4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78', + ) + expect(backupPubkey(deriveBackupKey(linkingKey))).toBe( + '6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577', + ) + expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe( + '3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587', + ) + }) +}) + +describe('WalletMaterialV2 derivation and serialization', () => { + it('derives the unchanged linking key and kit cash root from one mnemonic', () => { + // Given the fixed recovery mnemonic + // When its complete wallet material is derived + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // Then both independently captured vectors and the exact schema are stable + expect(material).toEqual({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX, + }) + expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']) + expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC)) + expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX) + }) + + it('round-trips only the canonical exact serialization', () => { + // Given valid v2 material + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When it crosses the serialization boundary + const serialized = serializeWalletMaterial(material) + + // Then its bytes and parsed value are canonical + expect(serialized).toBe( + `{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`, + ) + expect(parseWalletMaterial(serialized)).toEqual(material) + }) + + it.each([ + ['legacy key only', LINKING_KEY_HEX], + [ + 'wrong version', + JSON.stringify({version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX}), + ], + [ + 'missing field', + JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX}), + ], + [ + 'extra field', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX, + seed: 'forbidden', + }), + ], + [ + 'uppercase linking key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX.toUpperCase(), + cashRootHex: CASH_ROOT_HEX, + }), + ], + [ + 'truncated linking key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX.slice(2), + cashRootHex: CASH_ROOT_HEX, + }), + ], + [ + 'uppercase cash root', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX.toUpperCase(), + }), + ], + [ + 'truncated cash root', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX.slice(2), + }), + ], + [ + 'invalid cash root private key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: '00'.repeat(32) + CASH_ROOT_HEX.slice(64), + }), + ], + [ + 'non-canonical field order', + JSON.stringify({cashRootHex: CASH_ROOT_HEX, linkingKeyHex: LINKING_KEY_HEX, version: 2}), + ], + ])('rejects %s', (_name, serialized) => { + // Given malformed or non-canonical serialized material + // When it crosses the parser + const parsed = parseWalletMaterial(serialized) + + // Then no trusted material is returned + expect(parsed).toBeNull() + }) +}) diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index 604553c..a01600c 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -5,6 +5,7 @@ import {hmac} from '@noble/hashes/hmac.js' import {sha256} from '@noble/hashes/sha2.js' import {secp256k1} from '@noble/curves/secp256k1.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode} from 'lnurlcash-kit' import { parseStoredSecret, @@ -14,11 +15,21 @@ import { stripStoredSecretOwner, STORED_SECRET_VERSION, type StoredSecret, + type WalletMaterialV2, } from './storage/storedSecret' +import {decryptSecretParts, encryptSecretParts} from './passwordWrap' import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' -export {isValidStoredSecret} from './storage/storedSecret' -export type {StoredSecret} from './storage/storedSecret' +export { + isValidStoredSecret, + isValidStoredWalletMaterial, + parseWalletMaterial, + serializeWalletMaterial, +} from './storage/storedSecret' +export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +export {decryptSecretParts, encryptSecretParts} from './passwordWrap' +export type {EncryptedSecretParts} from './passwordWrap' +export * from './walletMaterialStorage' // The wallet's identity is derived against this fixed domain rather than // window.location.hostname, so the same seed phrase always yields the same @@ -40,8 +51,7 @@ 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 => { - const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) +const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => { const master = HDKey.fromMasterSeed(seed) const hashingKeyNode = master.derive("m/138'/0") @@ -58,6 +68,9 @@ export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8 return node.privateKey } +export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => + deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain) + // 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 @@ -69,15 +82,28 @@ export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[ export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN) +export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => { + const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) + try { + return { + version: 2, + linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)), + cashRootHex: cashNodeToHex(deriveCashRoot(seed)), + } + } finally { + seed.fill(0) + } +} + +export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array => + hexToBytes(material.linkingKeyHex) + +export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode => + cashNodeFromHex(material.cashRootHex) + export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string => bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true)) -// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the -// stored value is either plaintext or, if the holder opted in with a -// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password - -// GCM's auth tag doubles as the "wrong password" check on decrypt. -const PBKDF2_ITERATIONS = 210_000 - const readSecret = (storageKey: string): StoredSecret | null => { const raw = localStorage.getItem(storageKey) if (!raw) return null @@ -89,69 +115,10 @@ const readSecret = (storageKey: string): StoredSecret | null => { } } -const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => - crypto.subtle - .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) - .then((baseKey) => - crypto.subtle.deriveKey( - // the copy pins the TS type to Uint8Array - hexToBytes - // returns Uint8Array, which BufferSource rejects - { - name: 'PBKDF2', - salt: new Uint8Array(salt), - iterations: PBKDF2_ITERATIONS, - hash: 'SHA-256', - }, - baseKey, - {name: 'AES-GCM', length: 256}, - false, - ['encrypt', 'decrypt'], - ), - ) - -export type EncryptedSecretParts = { - salt: string - iv: string - ciphertext: string -} - -export const encryptSecretParts = async ( - value: 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)), - ) - return { - salt: bytesToHex(salt), - iv: bytesToHex(iv), - ciphertext: bytesToHex(ciphertext), - } -} - -// rejects (WebCrypto's own auth-tag check) if the password is wrong -export const decryptSecretParts = async ( - parts: EncryptedSecretParts, - password: string, -): Promise => { - const salt = hexToBytes(parts.salt) - const iv = hexToBytes(parts.iv) - const aesKey = await deriveAesKeyFromPassword(password, salt) - const plaintext = await crypto.subtle.decrypt( - {name: 'AES-GCM', iv}, - aesKey, - hexToBytes(parts.ciphertext), - ) - return new TextDecoder().decode(plaintext) -} - -// The linking key is the only secret this wallet persists - the seed phrase -// 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. +// The legacy alpha lifecycle below persists only the linking key under its old +// namespace. Task 5 replaces that lifecycle with the v2 material repository; +// these APIs stay byte-compatible until the serialized reset can switch every +// caller atomically rather than creating a split-key wallet. // // The record also carries an ownerId marker: the lowercase compressed // pubkey hex of the key itself (storage/walletOwner.ts), binding every diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts index fd686f5..0e4362d 100644 --- a/src/lnurlcash/keys.version.cases.ts +++ b/src/lnurlcash/keys.version.cases.ts @@ -12,6 +12,9 @@ import { } from './keys' import {parseJsonObject, stubLocalStorage} from './test-utils' +import './keys.material.cases' +import './keys.material-storage.cases' + const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const STORAGE_KEY = 'sattle_linking_key' diff --git a/src/lnurlcash/passwordWrap.ts b/src/lnurlcash/passwordWrap.ts new file mode 100644 index 0000000..ff126ae --- /dev/null +++ b/src/lnurlcash/passwordWrap.ts @@ -0,0 +1,63 @@ +// Password wraps protect serialized wallet secrets with authenticated AES-GCM. +// PBKDF2 parameters and wire fields stay centralized so legacy linking-key and +// v2 wallet-material records remain interoperable with their own readers. + +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' + +const PBKDF2_ITERATIONS = 210_000 + +const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => + crypto.subtle + .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) + .then((baseKey) => + crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array(salt), + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256', + }, + baseKey, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'], + ), + ) + +export type EncryptedSecretParts = { + readonly salt: string + readonly iv: string + readonly ciphertext: string +} + +export const encryptSecretParts = async ( + value: 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)), + ) + return { + salt: bytesToHex(salt), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + } +} + +export const decryptSecretParts = async ( + parts: EncryptedSecretParts, + password: string, +): Promise => { + const salt = hexToBytes(parts.salt) + const iv = hexToBytes(parts.iv) + const aesKey = await deriveAesKeyFromPassword(password, salt) + const plaintext = await crypto.subtle.decrypt( + {name: 'AES-GCM', iv}, + aesKey, + hexToBytes(parts.ciphertext), + ) + return new TextDecoder().decode(plaintext) +} diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts index a053f1c..5a8d0f2 100644 --- a/src/lnurlcash/storage/storedSecret.ts +++ b/src/lnurlcash/storage/storedSecret.ts @@ -1,13 +1,23 @@ -// 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. +// Saved secret records bind either the legacy linking key or canonical v2 +// wallet material to a proven owner. The v2 parser accepts only one exact, +// canonical material encoding so hostile storage cannot smuggle a legacy key, +// a future field, or a malformed BIP-32 root across the unlock boundary. +import {secp256k1} from '@noble/curves/secp256k1.js' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit' import {isJsonObject} from '../jsonParsing' import {isWalletOwnerId} from './walletOwner' export const STORED_SECRET_VERSION = 1 as const +export const WALLET_MATERIAL_VERSION = 2 as const +export const STORED_WALLET_MATERIAL_VERSION = 2 as const + +export type WalletMaterialV2 = { + readonly version: typeof WALLET_MATERIAL_VERSION + readonly linkingKeyHex: string + readonly cashRootHex: string +} type PlainStoredSecret = { readonly enc: false @@ -26,6 +36,7 @@ type EncryptedStoredSecret = { } export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret +export type StoredWalletMaterial = StoredSecret type ParsedStoredSecret = { readonly secret: StoredSecret @@ -35,17 +46,76 @@ type ParsedStoredSecret = { const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const +const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] 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 => { +const hasExactlyKeys = (record: Record, expected: readonly string[]): boolean => + Object.keys(record).length === expected.length && hasOnlyKeys(record, expected) + +export const serializeWalletMaterial = (material: WalletMaterialV2): string => + JSON.stringify({ + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: material.linkingKeyHex, + cashRootHex: material.cashRootHex, + }) + +export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => { + let value: unknown + try { + value = JSON.parse(serialized) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + if ( + !isJsonObject(value) || + !hasExactlyKeys(value, WALLET_MATERIAL_KEYS) || + value.version !== WALLET_MATERIAL_VERSION || + typeof value.linkingKeyHex !== 'string' || + !/^[0-9a-f]{64}$/.test(value.linkingKeyHex) || + typeof value.cashRootHex !== 'string' || + !/^[0-9a-f]{128}$/.test(value.cashRootHex) + ) { + return null + } + const linkingKey = hexToBytes(value.linkingKeyHex) + const cashRoot = cashNodeFromHex(value.cashRootHex) + if ( + !secp256k1.utils.isValidSecretKey(linkingKey) || + !secp256k1.utils.isValidSecretKey(cashRoot.privateKey) || + cashNodeToHex(cashRoot) !== value.cashRootHex + ) { + return null + } + const material: WalletMaterialV2 = { + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: value.linkingKeyHex, + cashRootHex: value.cashRootHex, + } + return serializeWalletMaterial(material) === serialized ? material : null +} + +export const walletMaterialOwnerId = (material: WalletMaterialV2): string => + bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true)) + +type StoredSecretParserOptions = { + readonly version: number + readonly acceptsPlaintext: (value: string) => boolean + readonly acceptsUnversionedOwner: boolean +} + +const parseStoredSecretWith = ( + stored: unknown, + options: StoredSecretParserOptions, +): 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) || + !options.acceptsPlaintext(stored.value) || !hasOnlyKeys(stored, PLAIN_KEYS) ) { return null @@ -80,23 +150,41 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false} if (!isWalletOwnerId(stored.ownerId)) return null if (!hasVersion) { + if (!options.acceptsUnversionedOwner) return null return { secret: {...secret, ownerId: stored.ownerId}, claimedOwnerId: stored.ownerId, isCurrent: false, } } - if (stored.version !== STORED_SECRET_VERSION) return null + if (stored.version !== options.version) return null return { - secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION}, + secret: {...secret, ownerId: stored.ownerId, version: options.version}, claimedOwnerId: stored.ownerId, isCurrent: true, } } +export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => + parseStoredSecretWith(stored, { + version: STORED_SECRET_VERSION, + acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value), + acceptsUnversionedOwner: true, + }) + +export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null => + parseStoredSecretWith(stored, { + version: STORED_WALLET_MATERIAL_VERSION, + acceptsPlaintext: (value) => parseWalletMaterial(value) !== null, + acceptsUnversionedOwner: false, + }) + export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => parseStoredSecret(stored) !== null +export const isValidStoredWalletMaterial = (stored: unknown): stored is StoredWalletMaterial => + parseStoredWalletMaterial(stored) !== null + export const storedSecretOwnerId = (stored: StoredSecret): string | null => { const parsed = parseStoredSecret(stored) return parsed?.isCurrent === true ? parsed.claimedOwnerId : null @@ -105,6 +193,15 @@ export const storedSecretOwnerId = (stored: StoredSecret): string | null => { export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null => parseStoredSecret(stored)?.claimedOwnerId ?? null +export const storedWalletMaterialOwnerId = (stored: StoredWalletMaterial): string | null => { + const parsed = parseStoredWalletMaterial(stored) + return parsed?.isCurrent === true ? parsed.claimedOwnerId : null +} + +export const storedWalletMaterialClaimedOwnerId = ( + stored: StoredWalletMaterial, +): string | null => parseStoredWalletMaterial(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} @@ -119,6 +216,28 @@ export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): S } } +export const stampStoredWalletMaterialOwner = ( + stored: StoredWalletMaterial, + ownerId: string, +): StoredWalletMaterial => { + if (stored.enc === false) { + return { + enc: false, + value: stored.value, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + } + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + } +} + export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { if (stored.enc === false) return {enc: false, value: stored.value} return { diff --git a/src/lnurlcash/storage/walletOwnerEvents.ts b/src/lnurlcash/storage/walletOwnerEvents.ts index ba0b366..fb16e84 100644 --- a/src/lnurlcash/storage/walletOwnerEvents.ts +++ b/src/lnurlcash/storage/walletOwnerEvents.ts @@ -2,6 +2,7 @@ // Their payload may be stale when writes arrive faster than delivery. export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' +export const WALLET_MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' // Each subscription binds its own storage listener and removes exactly it on // unsubscribe: no shared reference counting, so an abandoned subscriber can @@ -9,7 +10,13 @@ export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' 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 + if ( + event.key !== LINKING_KEY_STORAGE_KEY && + event.key !== WALLET_MATERIAL_STORAGE_KEY && + event.key !== null + ) { + return + } listener() } target?.addEventListener('storage', onStorage) diff --git a/src/lnurlcash/walletMaterialStorage.ts b/src/lnurlcash/walletMaterialStorage.ts new file mode 100644 index 0000000..5c4ba70 --- /dev/null +++ b/src/lnurlcash/walletMaterialStorage.ts @@ -0,0 +1,166 @@ +// The v2 wallet-material repository owns one authenticated serialization under +// a new namespace. Owner markers remain claims on restore and become trusted +// only after plaintext or password proof reproduces the linking-key owner. + +import { + parseStoredWalletMaterial, + parseWalletMaterial, + serializeWalletMaterial, + stampStoredWalletMaterialOwner, + storedWalletMaterialClaimedOwnerId, + storedWalletMaterialOwnerId, + stripStoredSecretOwner, + walletMaterialOwnerId, + STORED_WALLET_MATERIAL_VERSION, +} from './storage/storedSecret' +import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +import {decryptSecretParts, encryptSecretParts} from './passwordWrap' +import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents' + +export class InvalidWalletMaterialError extends Error { + override readonly name = 'InvalidWalletMaterialError' + + constructor() { + super('Saved plaintext is not valid v2 wallet material.') + } +} + +export class WalletMaterialOwnerMismatchError extends Error { + override readonly name = 'WalletMaterialOwnerMismatchError' + + constructor() { + super('Saved wallet material is owned by a different wallet.') + } +} + +export class WalletMaterialProofMismatchError extends Error { + override readonly name = 'WalletMaterialProofMismatchError' + + constructor() { + super('Proven material does not match the saved wallet material.') + } +} + +export class NoEncryptedWalletMaterialError extends Error { + override readonly name = 'NoEncryptedWalletMaterialError' + + constructor() { + super('No encrypted wallet material saved.') + } +} + +const readStoredWalletMaterial = (): StoredWalletMaterial | null => { + const raw = localStorage.getItem(WALLET_MATERIAL_STORAGE_KEY) + if (raw === null) return null + let value: unknown + try { + value = JSON.parse(raw) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + return parseStoredWalletMaterial(value)?.secret ?? null +} + +const materialFromPlaintext = (stored: StoredWalletMaterial): WalletMaterialV2 | null => { + if (stored.enc) return null + const material = parseWalletMaterial(stored.value) + if (material === null) return null + const claim = storedWalletMaterialClaimedOwnerId(stored) + return claim === null || claim === walletMaterialOwnerId(material) ? material : null +} + +export const savedWalletMaterialExists = (): boolean => readStoredWalletMaterial() !== null + +export const savedWalletMaterialIsEncrypted = (): boolean => + readStoredWalletMaterial()?.enc === true + +export const getSavedWalletMaterialStored = (): StoredWalletMaterial | null => + readStoredWalletMaterial() + +export const savedWalletMaterialOwnerId = (): string | null => { + const stored = readStoredWalletMaterial() + return stored === null ? null : storedWalletMaterialOwnerId(stored) +} + +export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean => + savedWalletMaterialOwnerId() === walletMaterialOwnerId(material) + +export const getPlainWalletMaterial = (): WalletMaterialV2 | null => { + const stored = readStoredWalletMaterial() + return stored === null ? null : materialFromPlaintext(stored) +} + +export const saveWalletMaterial = async ( + material: WalletMaterialV2, + password?: string, +): Promise => { + const value = serializeWalletMaterial(material) + const ownerId = walletMaterialOwnerId(material) + if (!password) { + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: false, + value, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + }), + ) + return + } + const parts = await encryptSecretParts(value, password) + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + }), + ) +} + +export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => { + if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError() + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify(stripStoredSecretOwner(stored)), + ) +} + +export const decryptSavedWalletMaterial = async ( + password: string, +): Promise => { + const stored = readStoredWalletMaterial() + if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError() + const material = parseWalletMaterial(await decryptSecretParts(stored, password)) + if (material === null) throw new InvalidWalletMaterialError() + const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) + if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) { + throw new WalletMaterialOwnerMismatchError() + } + return material +} + +export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => { + const stored = readStoredWalletMaterial() + if (stored === null) return + if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) { + throw new WalletMaterialProofMismatchError() + } + const ownerId = walletMaterialOwnerId(material) + const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) + if (storedWalletMaterialOwnerId(stored) === ownerId) return + if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { + throw new WalletMaterialOwnerMismatchError() + } + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify(stampStoredWalletMaterialOwner(stored, ownerId)), + ) +} + +export const clearSavedWalletMaterial = (): void => { + localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY) +} From 6099bb4b6b994b682662a33ef18da388e675b07e Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 20:19:48 +0200 Subject: [PATCH 04/29] feat(passkeys): wrap complete wallet material --- src/lnurlcash/passkeyWrap.ts | 44 ++- src/lnurlcash/passkeys.crypto.cases.ts | 33 ++- src/lnurlcash/passkeys.material.cases.ts | 272 +++++++++++++++++++ src/lnurlcash/passkeys.multiple.cases.ts | 15 +- src/lnurlcash/passkeys.ownership-a.cases.ts | 17 +- src/lnurlcash/passkeys.ownership-b.cases.ts | 19 +- src/lnurlcash/passkeys.registration.cases.ts | 28 +- src/lnurlcash/passkeys.rewrap.cases.ts | 19 +- src/lnurlcash/passkeys.storage.cases.ts | 13 +- src/lnurlcash/passkeys.support.cases.ts | 13 +- src/lnurlcash/passkeys.test.ts | 1 + src/lnurlcash/passkeys.ts | 109 +++++--- src/lnurlcash/passkeys.version.cases.ts | 33 ++- src/lnurlcash/storage/passkeySlots.ts | 64 +++-- 14 files changed, 559 insertions(+), 121 deletions(-) create mode 100644 src/lnurlcash/passkeys.material.cases.ts diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index c59f1de..b1246c6 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -1,7 +1,7 @@ // The pure crypto core of passkey unlock (passkeys.ts): HKDF-SHA256 from a -// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of the -// linking key under it. No WebAuthn, no storage - a PRF output is just 32 -// bytes, so everything here is unit-testable in plain Node. +// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of canonical +// wallet material under it. No WebAuthn or storage is involved, so malformed +// decrypted material is rejected at this boundary before it can reach runtime. // // A per-slot random HKDF salt separates the wrap keys of different passkeys // even though the ceremony-side PRF salt is fixed (see passkeys.ts); the @@ -9,10 +9,21 @@ // from the same PRF output. import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' import type {PasskeyWrap} from './storage/passkeySlots' +import {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret' +import type {WalletMaterialV2} from './storage/storedSecret' -const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1' +const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2' + +export class InvalidPasskeyMaterialError extends Error { + override readonly name = 'InvalidPasskeyMaterialError' + + constructor() { + super('Passkey payload is not canonical v2 wallet material.') + } +} export const derivePasskeyWrapKey = async ( prfOutput: Uint8Array, @@ -37,34 +48,43 @@ export const derivePasskeyWrapKey = async ( ) } -export const wrapLinkingKeyWithPrf = async ( +export const wrapWalletMaterialWithPrf = async ( prfOutput: Uint8Array, - linkingKey: Uint8Array, + material: WalletMaterialV2, ): Promise => { const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) + const serialized = serializeWalletMaterial(material) + if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError() 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, utf8ToBytes(serialized)), ) return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - wrappedKey: bytesToHex(ciphertext), + materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + wrappedMaterial: bytesToHex(ciphertext), } } // rejects (WebCrypto's own auth-tag check) if the PRF output is wrong - // i.e. a different passkey than the one that created the slot -export const unwrapLinkingKeyWithPrf = async ( +export const unwrapWalletMaterialWithPrf = async ( prfOutput: Uint8Array, wrap: PasskeyWrap, -): Promise => { +): Promise => { 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.wrappedMaterial)), ) - return new Uint8Array(plaintext) + const serialized = new TextDecoder().decode(plaintext) + if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) { + throw new InvalidPasskeyMaterialError() + } + const material = parseWalletMaterial(serialized) + if (material === null) throw new InvalidPasskeyMaterialError() + return material } diff --git a/src/lnurlcash/passkeys.crypto.cases.ts b/src/lnurlcash/passkeys.crypto.cases.ts index 5cbd799..f4ab10f 100644 --- a/src/lnurlcash/passkeys.crypto.cases.ts +++ b/src/lnurlcash/passkeys.crypto.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, + deriveWalletMaterial, deriveBearerAesKey, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,32 +142,33 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) 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)) + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap) + expect(unwrapped).toEqual(MATERIAL) }) 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() + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() }) it('rejects unwrap with a tampered HKDF salt', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}), + unwrapWalletMaterialWithPrf(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'}` + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}` await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}), + unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, wrappedMaterial: flipped}), ).rejects.toThrow() }) diff --git a/src/lnurlcash/passkeys.material.cases.ts b/src/lnurlcash/passkeys.material.cases.ts new file mode 100644 index 0000000..2d3a174 --- /dev/null +++ b/src/lnurlcash/passkeys.material.cases.ts @@ -0,0 +1,272 @@ +// V2 passkey cases exercise the material boundary without exposing private +// material in assertions or diagnostics. The fake authenticator models only +// WebAuthn credential selection and deterministic PRF evaluation. + +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import { + deriveWalletMaterial, + saveWalletMaterial, + serializeWalletMaterial, +} from './keys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + readPasskeySlots, + registerPasskey, + rewrapAllSlots, + unlockWalletMaterialWithPasskey, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, +} from './passkeys' +import type {CeremonyCredential, PasskeyCredentials, PasskeyWrap} from './passkeys' +import {stubLocalStorage} from './test-utils' + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)} +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const SLOT_STORAGE_KEY = 'sattle_passkey_slots' + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +class FakeAuthenticator implements PasskeyCredentials { + private readonly credentialId = new Uint8Array(16).fill(5) + private secret = new Uint8Array(32).fill(6) + onGet?: () => Promise + cancelCreate = false + + rotateSecret = (): void => { + this.secret = new Uint8Array(32).fill(7) + } + + create = async (options?: CredentialCreationOptions): Promise => { + if (this.cancelCreate) return null + return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + } + + get = async (options?: CredentialRequestOptions): Promise => { + await this.onGet?.() + const allowed = options?.publicKey?.allowCredentials ?? [] + const selected = allowed.some( + (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), + ) + return selected + ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + : null + } + + private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ + type: 'public-key', + rawId: this.credentialId, + getClientExtensionResults: () => ({ + prf: salt + ? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} + : {}, + }), + }) +} + +const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '' +const alterLastByte = (hex: string): string => + `${hex.slice(0, -2)}${hex.endsWith('00') ? '01' : '00'}` + +const encryptPayload = async ( + prfOutput: Uint8Array, + payload: string, + materialHash: string, +): Promise => { + const hkdfSalt = new Uint8Array(16).fill(8) + const iv = new Uint8Array(12).fill(9) + const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) + const ciphertext = await crypto.subtle.encrypt( + {name: 'AES-GCM', iv}, + wrapKey, + utf8ToBytes(payload), + ) + return { + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + materialHash, + wrappedMaterial: bytesToHex(new Uint8Array(ciphertext)), + } +} + +beforeEach(async () => { + stubLocalStorage() + await saveWalletMaterial(MATERIAL) +}) + +describe('v2 passkey material', () => { + it('round-trips canonical material bytes through the PRF wrap', async () => { + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + + await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)).resolves.toEqual(MATERIAL) + }) + + it('rejects wrong PRF and altered ciphertext without changing the wrap', async () => { + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const before = JSON.stringify(wrap) + const altered = { + ...wrap, + wrappedMaterial: alterLastByte(wrap.wrappedMaterial), + } + + await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() + await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, altered)).rejects.toThrow() + expect(JSON.stringify(wrap)).toBe(before) + }) + + it.each([ + ['legacy key-only payload', MATERIAL.linkingKeyHex], + [ + 'malformed root', + JSON.stringify({...MATERIAL, cashRootHex: `${MATERIAL.cashRootHex.slice(0, -2)}00`}), + ], + ])('rejects authenticated %s without changing slots or activation', async (_name, payload) => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + const materialHash = bytesToHex(sha256(utf8ToBytes(serializeWalletMaterial(MATERIAL)))) + const wrap = await encryptPayload(prfOutput, payload, materialHash) + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects a wrong runtime PRF without changing slots or activation', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + let activated = false + authenticator.rotateSecret() + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects altered stored ciphertext without rewriting it or activating', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const altered = { + ...slot, + wrappedMaterial: alterLastByte(slot.wrappedMaterial), + } + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([altered])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects same-owner altered root at registration without a ceremony or slot write', async () => { + const authenticator = new FakeAuthenticator() + const alteredRoot = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} + + await expect(registerPasskey(alteredRoot, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('registers and unlocks complete material without plaintext slot leakage', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + + const unlocked = await unlockWalletMaterialWithPasskey({credentials: authenticator}) + + expect(unlocked).toEqual(MATERIAL) + expect(slot.ownerId).not.toContain(MATERIAL.linkingKeyHex) + expect(rawSlots()).not.toContain(MATERIAL.linkingKeyHex) + expect(rawSlots()).not.toContain(MATERIAL.cashRootHex) + }) + + it('rejects authenticated foreign-owner material without changing slots or activation', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...foreignWrap}])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow('different wallet') + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects a slot changed during its ceremony without changing storage or activation', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + let changed = '' + authenticator.onGet = async () => { + const slot = readPasskeySlots()[0] + if (slot === undefined) throw new Error('expected registered slot') + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, createdAt: slot.createdAt + 1}])) + changed = rawSlots() + } + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow('changed') + expect(activated).toBe(false) + expect(rawSlots()).toBe(changed) + }) + + it('rewraps every slot around byte-identical material or writes nothing', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + + await expect(rewrapAllSlots(MATERIAL, new Map())).rejects.toThrow('partial re-wrap') + expect(rawSlots()).toBe(before) + + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]])) + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual( + MATERIAL, + ) + }) + + it('does not create a slot when registration is cancelled', async () => { + const authenticator = new FakeAuthenticator() + authenticator.cancelCreate = true + + await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow('cancelled') + expect(rawSlots()).toBe('') + }) +}) diff --git a/src/lnurlcash/passkeys.multiple.cases.ts b/src/lnurlcash/passkeys.multiple.cases.ts index f948226..fff5196 100644 --- a/src/lnurlcash/passkeys.multiple.cases.ts +++ b/src/lnurlcash/passkeys.multiple.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('multiple passkeys', () => { @@ -151,7 +160,7 @@ describe('multiple passkeys', () => { 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(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial) expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) diff --git a/src/lnurlcash/passkeys.ownership-a.cases.ts b/src/lnurlcash/passkeys.ownership-a.cases.ts index e9c1ac5..05b6fb1 100644 --- a/src/lnurlcash/passkeys.ownership-a.cases.ts +++ b/src/lnurlcash/passkeys.ownership-a.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot ownership', () => { @@ -167,7 +176,8 @@ describe('slot ownership', () => { credentialId: '33'.repeat(16), hkdfSalt: current.hkdfSalt, iv: current.iv, - wrappedKey: current.wrappedKey, + materialHash: current.materialHash, + wrappedMaterial: current.wrappedMaterial, createdAt: current.createdAt, } writeRawSlots([foreign, malformed, unowned]) @@ -210,6 +220,7 @@ describe('slot ownership', () => { // When a different wallet is installed with its canonical owner await saveLinkingKey(OTHER_LINKING_KEY) + await saveWalletMaterial({...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)}) // Then the residue stays unowned and unavailable to the new wallet expect(readPasskeySlots()).toEqual([]) diff --git a/src/lnurlcash/passkeys.ownership-b.cases.ts b/src/lnurlcash/passkeys.ownership-b.cases.ts index 6df9811..882c4cd 100644 --- a/src/lnurlcash/passkeys.ownership-b.cases.ts +++ b/src/lnurlcash/passkeys.ownership-b.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,13 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} +const OTHER_MATERIAL = {...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot ownership (continued)', () => { @@ -145,7 +155,7 @@ describe('slot ownership (continued)', () => { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: auth, }) - const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY) + const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) writeRawSlots([{...slot, ...foreignWrap}]) // When the authenticator successfully unwraps that foreign key @@ -164,6 +174,7 @@ describe('slot ownership (continued)', () => { get: async (options) => { const credential = await auth.get(options) await saveLinkingKey(OTHER_LINKING_KEY) + await saveWalletMaterial(OTHER_MATERIAL) return credential }, } @@ -220,7 +231,7 @@ describe('slot ownership (continued)', () => { }) // When the current wallet rewraps its slots - await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]])) + await rewrapAllSlots(MATERIAL, 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 index 8e7065d..ec6cd33 100644 --- a/src/lnurlcash/passkeys.registration.cases.ts +++ b/src/lnurlcash/passkeys.registration.cases.ts @@ -21,18 +21,21 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, - decryptSavedLinkingKey, + decryptSavedWalletMaterial, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, + walletMaterialLinkingKey, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +43,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('registration and unlock', () => { @@ -160,18 +170,18 @@ describe('registration and unlock', () => { }) it('yields the same key material unlock(password) yields', async () => { - const linkingKey = crypto.getRandomValues(new Uint8Array(32)) - await saveLinkingKey(linkingKey, 'correct horse') + await saveWalletMaterial(MATERIAL, 'correct horse') const auth = new FakeAuthenticator() - await registerPasskey(linkingKey, {credentials: auth}) + await registerPasskey(MATERIAL, {credentials: auth}) - const viaPassword = await decryptSavedLinkingKey('correct horse') + const viaPassword = await decryptSavedWalletMaterial('correct horse') const viaPasskey = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword)) + const passwordLinkingKey = walletMaterialLinkingKey(viaPassword) + expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey)) // and the practical consequence: a bearer record encrypted after a // password unlock decrypts after a passkey unlock - const passwordAes = await deriveBearerAesKey(viaPassword) + const passwordAes = await deriveBearerAesKey(passwordLinkingKey) const record = await encryptRecord(passwordAes, {note: 'still readable'}) const passkeyAes = await deriveBearerAesKey(viaPasskey) await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ diff --git a/src/lnurlcash/passkeys.rewrap.cases.ts b/src/lnurlcash/passkeys.rewrap.cases.ts index db5e833..d192f10 100644 --- a/src/lnurlcash/passkeys.rewrap.cases.ts +++ b/src/lnurlcash/passkeys.rewrap.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('rewrap for the current owner', () => { @@ -156,7 +165,7 @@ describe('rewrap for the current owner', () => { }), ], ]) - await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap') + await expect(rewrapAllSlots(MATERIAL, 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 @@ -174,10 +183,10 @@ describe('rewrap for the current owner', () => { }), ], ]) - await rewrapAllSlots(LINKING_KEY, fresh) + await rewrapAllSlots(MATERIAL, 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) + expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial) // credential ids and labels survive the re-wrap expect( readPasskeySlots() diff --git a/src/lnurlcash/passkeys.storage.cases.ts b/src/lnurlcash/passkeys.storage.cases.ts index 3d6f3ca..6eb181b 100644 --- a/src/lnurlcash/passkeys.storage.cases.ts +++ b/src/lnurlcash/passkeys.storage.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot storage hygiene', () => { diff --git a/src/lnurlcash/passkeys.support.cases.ts b/src/lnurlcash/passkeys.support.cases.ts index 4ee6a30..5d0e4d8 100644 --- a/src/lnurlcash/passkeys.support.cases.ts +++ b/src/lnurlcash/passkeys.support.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('passkeySupported', () => { diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index ab091b1..5e81125 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,4 +1,5 @@ import './passkeys.crypto.cases' +import './passkeys.material.cases' import './passkeys.registration.cases' import './passkeys.ownership-a.cases' import './passkeys.ownership-b.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index e37270f..9242d30 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -1,16 +1,10 @@ -// Passkey (WebAuthn PRF) unlock: an ALTERNATIVE wrap of the same linking -// key the password path protects (keys.ts) - never a second key, so notes -// encrypted under a password unlock stay readable after a passkey unlock -// and vice versa (both yield the identical linking key, from which the -// bearer AES key derives). +// Passkey (WebAuthn PRF) unlock is an alternative wrap of the complete +// canonical wallet material protected by the password path. Slot ownership +// remains derived only from the linking key inside that material. // // No master-key indirection is introduced: unlike Bitwarden, this wallet -// persists exactly one secret - the seed-derived linking key - and the -// bearer-encryption key is derived from it (not wrapped by it), so a random -// master key would only ever encrypt that one 32-byte value while forcing a -// migration of every existing store. The linking key IS the "master key" -// here: the password wrap (keys.ts) and each passkey slot below are -// independent wraps of the same key material. +// persists one authenticated material envelope. The password wrap and each +// passkey slot are independent wraps of those same canonical bytes. // // The module is split into a pure-crypto core (passkeyWrap.ts: HKDF from a // PRF output to an AES-GCM wrap key, slot wrap/unwrap - fully unit-tested) @@ -30,21 +24,31 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' -import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys' import {withStorageLock} from './storageLock' import type {PasskeySlot} from './storage/passkeySlots' import { PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOT_VERSION, + passkeySlotsEqual, readPasskeySlots, + requireCurrentPasskeyMaterialOwner, writePasskeySlots, } from './storage/passkeySlots' -import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap' +import type {WalletMaterialV2} from './storage/storedSecret' +import {walletMaterialOwnerId} from './storage/storedSecret' +import {savedWalletMaterialOwnerId} from './walletMaterialStorage' +import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export {migrateLegacyPasskeySlots} from './passkeyOwnership' -export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap' +export { + derivePasskeyWrapKey, + InvalidPasskeyMaterialError, + wrapWalletMaterialWithPrf, + unwrapWalletMaterialWithPrf, +} from './passkeyWrap' // 32 bytes, fixed - the authenticator requires exactly 32 const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) @@ -120,8 +124,7 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => { } // one get() ceremony against a single known credential, returning its fresh -// PRF output - the building block for re-wrap ceremonies during linking-key -// rotation +// PRF output - the building block for complete-material re-wrap ceremonies export const getPasskeyPrfOutput = async ( credentialId: string, options: {credentials?: PasskeyCredentials} = {}, @@ -149,20 +152,28 @@ export type RegisterPasskeyOptions = { authenticatorAttachment?: AuthenticatorAttachment } -// Registers a new passkey and persists a slot wrapping the given linking -// key. The caller supplies the linking key from the currently unlocked -// wallet; the ceremony is navigator.credentials.create with the PRF +// Registers a new passkey and persists a slot wrapping the complete material. +// The Uint8Array input exists only until Task 5 updates the UI caller; it can +// select already-saved plaintext v2 material but can never create a key-only +// slot. The ceremony is navigator.credentials.create with the PRF // extension evaluated on creation. Some authenticators only report // prf.enabled during create and evaluate the secret on the first get - // those get a follow-up get() against the fresh credential. export const registerPasskey = async ( - linkingKey: Uint8Array, + material: WalletMaterialV2 | Uint8Array, options: RegisterPasskeyOptions = {}, ): Promise => { - const ownerId = savedKeyOwnerId() - if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { - throw new Error('Passkey registration requires the proven saved wallet owner.') + let walletMaterial: WalletMaterialV2 + if (material instanceof Uint8Array) { + const savedMaterial = getPlainWalletMaterial() + if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) { + throw new Error('Passkey registration requires complete v2 wallet material.') + } + walletMaterial = savedMaterial + } else { + walletMaterial = material } + const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial) const credentials = options.credentials ?? defaultCredentials() const credential = await credentials.create({ publicKey: { @@ -197,7 +208,7 @@ export const registerPasskey = async ( } prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) } - const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey) + const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial) const slot: PasskeySlot = { credentialId, ...wrap, @@ -214,13 +225,13 @@ export const registerPasskey = async ( return slot } -// Unlocks via any registered passkey: one get() ceremony offering every -// 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 ( +// Unlocks complete material via one get() ceremony offering every current +// slot. The selected slot is re-read after the ceremony before any plaintext +// reaches the caller, rejecting owner or storage changes during user presence. +export const unlockWalletMaterialWithPasskey = async ( options: {credentials?: PasskeyCredentials} = {}, -): Promise => { - const ownerId = savedKeyOwnerId() +): Promise => { + const ownerId = savedWalletMaterialOwnerId() const slots = readPasskeySlots() if (ownerId === null || slots.length === 0) { throw new Error('No passkeys registered on this device.') @@ -247,18 +258,32 @@ export const unlockWithPasskey = async ( if (!prfOutput) { throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } - const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot) - if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) { + if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } - return linkingKey + const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId) + if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) { + throw new Error('The passkey slot changed during the unlock ceremony.') + } + const material = await unwrapWalletMaterialWithPrf(prfOutput, currentSlot) + if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) { + throw new Error('This passkey belongs to a different wallet.') + } + return material } +// Task 5 replaces this temporary projection when lifecycle activation accepts +// complete wallet material. Slots already contain only canonical v2 material. +export const unlockWithPasskey = async ( + options: {credentials?: PasskeyCredentials} = {}, +): Promise => + walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options)) + // 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 => { - const ownerId = savedKeyOwnerId() + const ownerId = savedWalletMaterialOwnerId() if (ownerId === null) return false let removed = false await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { @@ -270,23 +295,19 @@ export const removePasskey = async (credentialId: string): Promise => { return removed } -// Refreshes every current-owner slot around the same proven key material. +// Refreshes every current-owner slot around the same proven wallet 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 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. +// A password change does not need this because password and passkey slots are +// independent wraps of the same canonical material. export const rewrapAllSlots = async ( - linkingKey: Uint8Array, + material: WalletMaterialV2, prfOutputs: ReadonlyMap, ): Promise => { - const ownerId = savedKeyOwnerId() - if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { - throw new Error('Passkey re-wrap requires the proven saved wallet owner.') - } + const ownerId = requireCurrentPasskeyMaterialOwner(material) await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { const slots = readPasskeySlots() const rewrapped: PasskeySlot[] = [] @@ -297,7 +318,7 @@ export const rewrapAllSlots = async ( } rewrapped.push({ ...slot, - ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)), + ...(await wrapWalletMaterialWithPrf(prfOutput, material)), }) } writePasskeySlots(ownerId, rewrapped) diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts index afd8b0e..f8bf2fc 100644 --- a/src/lnurlcash/passkeys.version.cases.ts +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -1,7 +1,14 @@ import {beforeEach, describe, expect, it} from 'vitest' import {bytesToHex} from '@noble/hashes/utils.js' -import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys' +import { + deriveWalletMaterial, + ensureSavedKeyOwner, + getPlainLinkingKey, + linkingPubKeyHex, + savedKeyOwnerId, + saveWalletMaterial, +} from './keys' import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' import type {PasskeySlot} from './passkeys' import {parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -10,12 +17,19 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const SLOT_BASE = { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), + materialHash: '44'.repeat(32), + wrappedMaterial: '55'.repeat(128), createdAt: 1, } as const @@ -24,17 +38,18 @@ const writeRawSlots = (slots: readonly Record[]): void => { } describe('passkey-slot schema version', () => { - beforeEach(() => { + beforeEach(async () => { stubLocalStorage() + await saveWalletMaterial(MATERIAL) }) - it('reads a current version 1 slot for the exact saved owner', () => { + it('reads a current version 2 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} + const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2} writeRawSlots([slot]) // When current-owner slots are read @@ -61,12 +76,12 @@ describe('passkey-slot schema version', () => { // 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}, + {...SLOT_BASE, ownerId, version: 2}, + {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2}, ]) }) - it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => { + it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => { // Given an otherwise valid slot carrying unrecognized metadata const ownerId = linkingPubKeyHex(LINKING_KEY) localStorage.setItem( @@ -87,7 +102,7 @@ describe('passkey-slot schema version', () => { KEY_STORAGE, JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), ) - writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}]) + writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}]) // When the current wallet reads passkeys // Then the foreign credential is unavailable diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 3b43cef..258b041 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -1,19 +1,22 @@ -// Passkey-slot persistence: every new slot is bound to the canonical owner -// of the saved linking key. localStorage remains hostile input, so owner +// Passkey-slot persistence: every v2 slot is bound to the canonical owner +// of the saved wallet material. 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 type {WalletMaterialV2} from './storedSecret' +import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret' +import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage' import {isWalletOwnerId} from './walletOwner' export type PasskeyWrap = { readonly hkdfSalt: string readonly iv: string - readonly wrappedKey: string + readonly materialHash: string + readonly wrappedMaterial: string } -export const PASSKEY_SLOT_VERSION = 1 as const +export const PASSKEY_SLOT_VERSION = 2 as const export type PasskeySlot = PasskeyWrap & { readonly credentialId: string @@ -23,6 +26,30 @@ export type PasskeySlot = PasskeyWrap & { readonly version: typeof PASSKEY_SLOT_VERSION } +export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolean => + left.credentialId === right.credentialId && + left.hkdfSalt === right.hkdfSalt && + left.iv === right.iv && + left.materialHash === right.materialHash && + left.wrappedMaterial === right.wrappedMaterial && + left.createdAt === right.createdAt && + left.name === right.name && + left.ownerId === right.ownerId && + left.version === right.version + +export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => { + const ownerId = savedWalletMaterialOwnerId() + const plaintext = getPlainWalletMaterial() + if ( + ownerId === null || + walletMaterialOwnerId(material) !== ownerId || + (plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material)) + ) { + throw new Error('Passkey operation requires the proven saved wallet material.') + } + return ownerId +} + type StoredPasskeySlot = PasskeyWrap & { readonly credentialId: string readonly createdAt: number @@ -43,7 +70,8 @@ const SLOT_KEYS: readonly string[] = [ 'credentialId', 'hkdfSalt', 'iv', - 'wrappedKey', + 'materialHash', + 'wrappedMaterial', 'createdAt', 'name', 'ownerId', @@ -61,10 +89,12 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { /^[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.materialHash === 'string' && + /^[0-9a-f]{64}$/.test(slot.materialHash) && + typeof slot.wrappedMaterial === 'string' && + slot.wrappedMaterial.length > 0 && + slot.wrappedMaterial.length % 2 === 0 && + /^[0-9a-f]+$/.test(slot.wrappedMaterial) && typeof slot.createdAt === 'number' && (slot.name === undefined || typeof slot.name === 'string') && Object.keys(slot).every((key) => SLOT_KEYS.includes(key)) @@ -73,7 +103,8 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { credentialId: slot.credentialId, hkdfSalt: slot.hkdfSalt, iv: slot.iv, - wrappedKey: slot.wrappedKey, + materialHash: slot.materialHash, + wrappedMaterial: slot.wrappedMaterial, createdAt: slot.createdAt, ...(slot.name !== undefined ? {name: slot.name} : {}), } @@ -124,7 +155,8 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | credentialId: record.credentialId, hkdfSalt: record.hkdfSalt, iv: record.iv, - wrappedKey: record.wrappedKey, + materialHash: record.materialHash, + wrappedMaterial: record.wrappedMaterial, createdAt: record.createdAt, ...(record.name !== undefined ? {name: record.name} : {}), ownerId, @@ -133,7 +165,7 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | } export const readPasskeySlots = (): PasskeySlot[] => { - const ownerId = savedKeyOwnerId() + const ownerId = savedWalletMaterialOwnerId() if (ownerId === null) return [] return readStoredPasskeySlots() .map((slot) => asOwnedSlot(slot, ownerId)) @@ -143,7 +175,7 @@ export const readPasskeySlots = (): PasskeySlot[] => { export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Passkey slots require the proven saved wallet owner.') } if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { @@ -156,7 +188,7 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void = } export const adoptLegacyPasskeySlots = (ownerId: string): number => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Legacy passkey migration requires a proven owner.') } const stored = readStoredPasskeySlots() @@ -183,7 +215,7 @@ const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => { } export const clearPasskeySlotsForOwner = (ownerId: string): void => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Passkey teardown requires the proven saved wallet owner.') } persistStoredPasskeySlots( From b7c0a33a6968756e372a8b99156d70b766c2691d Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 20:53:45 +0200 Subject: [PATCH 05/29] feat(biometrics): unlock complete wallet material --- src/capabilities/biometricUnlock.test.ts | 346 ++++++++++++++++++----- src/capabilities/biometricUnlock.ts | 168 ++++++++--- 2 files changed, 408 insertions(+), 106 deletions(-) diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index 150777d..4f5fc8d 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -1,101 +1,317 @@ +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; 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 { + deriveWalletMaterial, + serializeWalletMaterial, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; +import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage'; import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils'; const pluginMocks = vi.hoisted(() => ({ authenticate: vi.fn<() => Promise>(), + checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(), secureGet: vi.fn<(key: string) => Promise>(), + secureRemove: vi.fn<(key: string) => Promise>(), + secureSet: vi.fn<(key: string, value: string) => Promise>(), + secureValues: new Map(), })); vi.mock('@aparajita/capacitor-biometric-auth', () => ({ AndroidBiometryStrength: { weak: 'weak' }, - BiometricAuth: { authenticate: pluginMocks.authenticate }, + BiometricAuth: { + authenticate: pluginMocks.authenticate, + checkBiometry: pluginMocks.checkBiometry, + }, BiometryError: class BiometryError extends Error {}, BiometryErrorType: { userCancel: 'userCancel' }, })); vi.mock('@aparajita/capacitor-secure-storage', () => ({ - SecureStorage: { get: pluginMocks.secureGet }, + SecureStorage: { + get: pluginMocks.secureGet, + remove: pluginMocks.secureRemove, + set: pluginMocks.secureSet, + }, })); vi.mock('./platform', () => ({ isNative: () => true })); -import { unlockWithBiometrics } from './biometricUnlock'; +import { + disableBiometricUnlock, + enableBiometricUnlock, + unlockWalletMaterialWithBiometrics, +} from './biometricUnlock'; -const LINKING_KEY = new Uint8Array(32).fill(7); -const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const SEED = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; +const MATERIAL = deriveWalletMaterial(SEED); +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED); const WRAP_SECRET = new Uint8Array(32).fill(3); +const WRAP_RECORD_KEY = 'sattle_biometric_wrap'; +const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2'; +const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2'); + +const persistedState = () => ({ + legacyKey: localStorage.getItem('sattle_linking_key'), + legacyNwc: localStorage.getItem('sattle_nwc_connections'), + legacyPasskeys: localStorage.getItem('sattle_passkey_slots'), + legacyTrust: localStorage.getItem('sattle_trusted_mints'), + material: localStorage.getItem(MATERIAL_STORAGE_KEY), + secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null, + wrap: localStorage.getItem(WRAP_RECORD_KEY), +}); + +const deriveFixtureWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(WRAP_KEY_HKDF_INFO), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +const storePayload = async ( + plaintext: Uint8Array, + pubkey: string, + secret = WRAP_SECRET, +): Promise => { + const hkdfSalt = new Uint8Array(16).fill(4); + const iv = new Uint8Array(12).fill(5); + const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)), + ); + localStorage.setItem( + WRAP_RECORD_KEY, + JSON.stringify({ + version: 2, + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + wrappedKey: bytesToHex(ciphertext), + pubkey, + createdAt: 1, + }), + ); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret)); +}; + +const storeMaterialPayload = async ( + material: WalletMaterialV2, + pubkey = walletMaterialOwnerId(material), +): Promise => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey); beforeEach(() => { vi.clearAllMocks(); stubLocalStorage(); + pluginMocks.secureValues.clear(); pluginMocks.authenticate.mockResolvedValue(); - pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); + pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true }); + pluginMocks.secureGet.mockImplementation((key) => + Promise.resolve(pluginMocks.secureValues.get(key) ?? null), + ); + pluginMocks.secureSet.mockImplementation((key, value) => { + pluginMocks.secureValues.set(key, value); + return Promise.resolve(); + }); + pluginMocks.secureRemove.mockImplementation((key) => { + pluginMocks.secureValues.delete(key); + return Promise.resolve(); + }); }); -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); +describe('biometric wallet material', () => { + it('rejects foreign enrollment before writing either biometric store', async () => { + // Given one proven saved owner and complete material for another owner + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the foreign material + const attempt = enableBiometricUnlock(OTHER_MATERIAL); + + // Then ownership fails before native or local persistence changes + await expect(attempt).rejects.toThrow('proven saved wallet owner'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('does not create a local wrap when secure-secret persistence fails', async () => { + // Given valid material and a rejecting native secure store + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed')); + + // When enrollment reaches native secret persistence + const attempt = enableBiometricUnlock(MATERIAL); + + // Then failure is reported without a misleading local enrollment record + await expect(attempt).rejects.toThrow('secure write failed'); + expect(persistedState()).toEqual(before); + }); + + it('round-trips both key parts when enrollment belongs to the saved owner', async () => { + // Given complete canonical material persisted for its proven owner + await saveWalletMaterial(MATERIAL); + + // When the capability enrolls and unlocks it through the native adapter + await enableBiometricUnlock(MATERIAL); + const beforeUnlock = persistedState(); + const unlocked = await unlockWalletMaterialWithBiometrics(); + + // Then both key parts return together and unlock does not rewrite persistence + expect(unlocked).toEqual(MATERIAL); + expect(persistedState()).toEqual(beforeUnlock); + }); + + it.each([ + ['malformed JSON', utf8ToBytes('{')], + ['legacy key-only payload', new Uint8Array(32).fill(7)], + [ + 'altered root', + utf8ToBytes( + JSON.stringify({ + ...MATERIAL, + cashRootHex: '00'.repeat(64), + }), + ), + ], + ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { + // Given an authenticated non-canonical payload bound to the saved owner + await saveWalletMaterial(MATERIAL); + await storePayload(payload, walletMaterialOwnerId(MATERIAL)); + const before = persistedState(); + + // When biometric unlock decrypts the payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part crosses the boundary and persistence is byte-identical + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for a wrong secure secret and preserves state', async () => { + // Given a valid wrap whose secure secret has been replaced + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32)); + const before = persistedState(); + + // When biometric unlock authenticates with the wrong wrapping secret + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then authenticated decryption fails without changing either store + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for altered ciphertext and preserves state', async () => { + // Given a valid wrap with one ciphertext nibble altered + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}'); + const wrappedKey = record.wrappedKey; + if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext'); localStorage.setItem( - 'sattle_biometric_wrap', + WRAP_RECORD_KEY, JSON.stringify({ - ...wrap, - pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), - createdAt: 1, + ...record, + wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(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 + const before = persistedState(); + + // When biometric unlock verifies the AES-GCM tag + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part returns and persistence remains untouched + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => { + // Given material wrapped for one owner and saved material replaced by another + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + await saveWalletMaterial(OTHER_MATERIAL); + const before = persistedState(); + + // When the stale biometric record is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then no old material returns and both stores remain byte-identical + await expect(attempt).rejects.toThrow('different wallet'); + expect(persistedState()).toEqual(before); + }); + + it('rejects authenticated material owned by another linking key', async () => { + // Given the saved owner and wrap claim owner A but authenticated material owner B + await saveWalletMaterial(MATERIAL); + localStorage.setItem('sattle_linking_key', 'legacy-key-residue'); + localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue'); + localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue'); + localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue'); + await storeMaterialPayload(OTHER_MATERIAL, walletMaterialOwnerId(MATERIAL)); + const before = persistedState(); + + // When biometric unlock checks the decrypted linking-key owner + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither foreign key part returns and persistence is unchanged 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); + expect(persistedState()).toEqual(before); + }); +}); + +describe('biometric deletion ordering', () => { + it('keeps native and local state retryable when secure-secret deletion fails', async () => { + // Given a complete enrolled biometric wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed')); + + // When native secure-secret deletion fails + const attempt = disableBiometricUnlock(); + + // Then failure is reported before either persisted part is removed + await expect(attempt).rejects.toThrow('native deletion failed'); + expect(persistedState()).toEqual(before); + }); + + it('supports repeated delete interruption followed by a successful retry', async () => { + // Given two interrupted deletion attempts for an enrolled wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption')); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption')); + + // When deletion is retried until native removal succeeds + await expect(disableBiometricUnlock()).rejects.toThrow('first interruption'); + expect(persistedState()).toEqual(before); + await expect(disableBiometricUnlock()).rejects.toThrow('second interruption'); + expect(persistedState()).toEqual(before); + await disableBiometricUnlock(); + + // Then native state is gone before the local wrap is removed + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material); }); }); diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index f911eb1..e6b83bd 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -1,22 +1,21 @@ -// Native biometric unlock: a THIRD wrap of the same linking key, alongside -// the password wrap (keys.ts) and the passkey slots (passkeys.ts). +// Native biometric unlock: a THIRD wrap of the same canonical wallet material, +// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts). // // Why native: Android WebView has no usable WebAuthn platform authenticator // for this app (passkeys need Play Services / Digital Asset Links wiring we // cannot rely on), so the biometric path on native is a device-credential // prompt instead of a passkey ceremony. // -// Design (mirrors the passkey-slot construction, reusing its wrap -// primitives verbatim): on enrollment a random 32-byte wrap secret is -// generated, the linking key is AES-GCM-wrapped under an HKDF of that -// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32 -// bytes of IKM), and only the SECRET goes into biometric-gated secure +// Design (mirrors the passkey-slot construction with a biometric-specific +// HKDF domain): on enrollment a random 32-byte wrap secret is generated, +// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret, +// and only the SECRET goes into biometric-gated secure // storage (Android Keystore-backed AES-GCM via // @aparajita/capacitor-secure-storage). The wrapped blob stays in -// localStorage as a record shaped like a passkey slot, plus the linking +// localStorage as a versioned AES-GCM record, plus the linking // pubkey as an identity check: restoring a DIFFERENT seed leaves a stale // wrap behind, and unlocking with it must fail loudly (never activate the -// old wallet silently), so unlock verifies the unwrapped key against the +// old wallet silently), so unlock verifies the unwrapped material owner against the // recorded pubkey and tells the holder to re-enroll. // // The biometric gate is app-level (a BiometricPrompt before the secure @@ -36,39 +35,84 @@ import { BiometryErrorType, } from '@aparajita/capacitor-biometric-auth'; import { SecureStorage } from '@aparajita/capacitor-secure-storage'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex } from '@/lnurlcash/keys'; -import type { PasskeyWrap } from '@/lnurlcash/passkeys'; -import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; +import { + parseWalletMaterial, + savedWalletMaterialOwnerId, + serializeWalletMaterial, + walletMaterialLinkingKey, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { isJsonObject } from '@/lnurlcash/jsonParsing'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; import { isNative } from './platform'; -type BiometricWrapRecord = PasskeyWrap & { - pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps - createdAt: number; +type BiometricWrapRecord = { + readonly version: 2; + readonly hkdfSalt: string; + readonly iv: string; + readonly wrappedKey: string; + readonly pubkey: string; + readonly createdAt: number; }; // the wrapped blob is useless without the secure-storage secret, so (like // the passkey slots) this record sits in plain localStorage const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap'; const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const BIOMETRIC_WRAP_VERSION = 2 as const; +const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2'; +const WRAP_RECORD_KEYS = [ + 'version', + 'hkdfSalt', + 'iv', + 'wrappedKey', + 'pubkey', + 'createdAt', +] as const; + +const deriveBiometricWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { - if (typeof record !== 'object' || record === null) return false; - const r = record as Record; + if (!isJsonObject(record)) return false; return ( - typeof r.hkdfSalt === 'string' && - /^[0-9a-f]{32}$/i.test(r.hkdfSalt) && - typeof r.iv === 'string' && - /^[0-9a-f]{24}$/i.test(r.iv) && - typeof r.wrappedKey === 'string' && - r.wrappedKey.length > 0 && - r.wrappedKey.length % 2 === 0 && - /^[0-9a-f]+$/i.test(r.wrappedKey) && - typeof r.pubkey === 'string' && - /^[0-9a-f]{66}$/i.test(r.pubkey) && - typeof r.createdAt === 'number' + Object.keys(record).length === WRAP_RECORD_KEYS.length && + Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) && + record.version === BIOMETRIC_WRAP_VERSION && + typeof record.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/.test(record.hkdfSalt) && + typeof record.iv === 'string' && + /^[0-9a-f]{24}$/.test(record.iv) && + typeof record.wrappedKey === 'string' && + record.wrappedKey.length > 0 && + record.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/.test(record.wrappedKey) && + typeof record.pubkey === 'string' && + /^[0-9a-f]{66}$/.test(record.pubkey) && + typeof record.createdAt === 'number' && + Number.isSafeInteger(record.createdAt) && + record.createdAt >= 0 ); }; @@ -121,8 +165,20 @@ const authenticateOrThrow = async (reason: string): Promise => { } }; -export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { +export const enableBiometricUnlock = async ( + material: WalletMaterialV2 | Uint8Array, +): Promise => { if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); + if (material instanceof Uint8Array) { + throw new Error('Biometric enrollment requires complete v2 wallet material.'); + } + const serialized = serializeWalletMaterial(material); + const canonicalMaterial = parseWalletMaterial(serialized); + if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.'); + const ownerId = walletMaterialOwnerId(canonicalMaterial); + if (savedWalletMaterialOwnerId() !== ownerId) { + throw new Error('Biometric enrollment requires the proven saved wallet owner.'); + } const biometry = await BiometricAuth.checkBiometry(); if (!biometry.isAvailable) { throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); @@ -130,43 +186,73 @@ export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { +export const unlockWalletMaterialWithBiometrics = async (): Promise => { const record = readWrapRecord(); if (!isNative() || !record) { throw new Error('Biometric unlock is not set up on this device.'); } + const ownerId = savedWalletMaterialOwnerId(); + if (ownerId === null || ownerId !== record.pubkey) { + throw new Error( + 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', + ); + } await authenticateOrThrow('Unlock your sattle wallet'); const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); } - const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record); - if (linkingPubKeyHex(linkingKey) !== record.pubkey) { + const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt)); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) }, + wrapKey, + new Uint8Array(hexToBytes(record.wrappedKey)), + ); + const material = parseWalletMaterial(new TextDecoder().decode(plaintext)); + if ( + material === null || + savedWalletMaterialOwnerId() !== ownerId || + walletMaterialOwnerId(material) !== ownerId + ) { throw new Error( 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', ); } - return linkingKey; + return material; }; -// the localStorage record goes first and synchronously: even if the caller -// fire-and-forgets this (forgetWallet), a later unlock attempt can never -// reach the secure secret with a stale record +export const unlockWithBiometrics = async (): Promise => + walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics()); + +// Native deletion goes first so a failure leaves the complete enrollment +// available for the locked lifecycle to retry safely. export const disableBiometricUnlock = async (): Promise => { - localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); if (isNative()) { await SecureStorage.remove(SECURE_SECRET_KEY); } + localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); }; From 9374a87d0d64cfd70f5bf938cdf4582331cea87a Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 21:27:14 +0200 Subject: [PATCH 06/29] fix(passkeys): bind slots to wallet material --- .../keys.material-commitment.cases.ts | 202 ++++++++++++++++++ src/lnurlcash/keys.material-storage.cases.ts | 16 +- src/lnurlcash/keys.ts | 1 + src/lnurlcash/keys.version.cases.ts | 1 + src/lnurlcash/passkeyRewrap.ts | 66 ++++++ src/lnurlcash/passkeyWrap.ts | 6 +- src/lnurlcash/passkeys.commitment.cases.ts | 129 +++++++++++ src/lnurlcash/passkeys.material.cases.ts | 3 +- src/lnurlcash/passkeys.test.ts | 1 + src/lnurlcash/passkeys.ts | 50 ++--- src/lnurlcash/passkeys.version.cases.ts | 3 +- src/lnurlcash/storage/passkeySlots.ts | 34 ++- src/lnurlcash/storage/storedSecret.ts | 131 ++++++------ src/lnurlcash/storage/walletMaterial.ts | 77 +++++++ src/lnurlcash/walletMaterialStorage.ts | 27 ++- 15 files changed, 631 insertions(+), 116 deletions(-) create mode 100644 src/lnurlcash/keys.material-commitment.cases.ts create mode 100644 src/lnurlcash/passkeyRewrap.ts create mode 100644 src/lnurlcash/passkeys.commitment.cases.ts create mode 100644 src/lnurlcash/storage/walletMaterial.ts diff --git a/src/lnurlcash/keys.material-commitment.cases.ts b/src/lnurlcash/keys.material-commitment.cases.ts new file mode 100644 index 0000000..0fac6d5 --- /dev/null +++ b/src/lnurlcash/keys.material-commitment.cases.ts @@ -0,0 +1,202 @@ +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import { + decryptSavedWalletMaterial, + deriveWalletMaterial, + encryptSecretParts, + ensureSavedWalletMaterialOwner, + getPlainWalletMaterial, + isValidStoredWalletMaterial, + linkingPubKeyHex, + restoreWalletMaterialStored, + saveWalletMaterial, + serializeWalletMaterial, + savedWalletMaterialOwnerId, + walletMaterialLinkingKey, + WalletMaterialProofMismatchError, +} from './keys' +import {walletMaterialOwnerId} from './storage/storedSecret' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const OTHER_MNEMONIC = + 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' +const PASSWORD = 'correct horse battery staple' + +const materialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + +const readRawMaterialRecord = (): Record => { + const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) + if (raw === null) throw new Error('expected a saved wallet-material record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +describe('baseline: wallet material identity', () => { + it('derives identity only from the linking key when the cash root changes', () => { + const material = deriveWalletMaterial(MNEMONIC) + const other = deriveWalletMaterial(OTHER_MNEMONIC) + const alteredRoot = {...material, cashRootHex: other.cashRootHex} + + expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material)) + expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material)) + }) +}) + +describe('saved wallet material commitment', () => { + it('stores the canonical material commitment on plaintext and encrypted saves', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const expectedHash = materialHash(serializeWalletMaterial(material)) + + await saveWalletMaterial(material) + const plaintext = readRawMaterialRecord() + await saveWalletMaterial(material, PASSWORD) + const encrypted = readRawMaterialRecord() + + expect(plaintext.materialHash).toBe(expectedHash) + expect(encrypted.materialHash).toBe(expectedHash) + }) + + it('rejects authenticated encrypted material when its commitment differs', async () => { + const material = deriveWalletMaterial(MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}), + ) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( + WalletMaterialProofMismatchError, + ) + }) + + it('rejects plaintext material when its commitment differs', async () => { + const material = deriveWalletMaterial(MNEMONIC) + await saveWalletMaterial(material) + const before = JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}) + localStorage.setItem(MATERIAL_STORAGE_KEY, before) + + expect(getPlainWalletMaterial()).toBeNull() + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it('refuses to stamp same-owner material with an altered cash root', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const other = deriveWalletMaterial(OTHER_MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + + expect(() => + ensureSavedWalletMaterialOwner({...material, cashRootHex: other.cashRootHex}), + ).toThrow(WalletMaterialProofMismatchError) + }) + + it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const record = { + enc: false, + value: serialized, + materialHash: materialHash(serialized), + ownerId: walletMaterialOwnerId(material), + version: 2, + } + + expect(isValidStoredWalletMaterial(record)).toBe(true) + expect(isValidStoredWalletMaterial({...record, materialHash: record.materialHash.toUpperCase()})).toBe( + false, + ) + expect( + isValidStoredWalletMaterial({ + enc: record.enc, + value: record.value, + ownerId: record.ownerId, + version: record.version, + }), + ).toBe(false) + }) + + it('rejects malformed authenticated material after its correct commitment matches', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const malformed = JSON.stringify({...material, cashRootHex: '00'.repeat(64)}) + const parts = await encryptSecretParts(malformed, PASSWORD) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + materialHash: materialHash(malformed), + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }), + ) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow( + 'valid v2 wallet material', + ) + }) + + it('preserves a verified restored commitment while discarding its owner claim', () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + + restoreWalletMaterialStored({ + enc: false, + value: serialized, + materialHash: materialHash(serialized), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }) + + expect(readRawMaterialRecord()).toEqual({ + enc: false, + value: serialized, + materialHash: materialHash(serialized), + }) + expect(getPlainWalletMaterial()).toEqual(material) + }) + + it('proves an encrypted restored commitment before owner stamping', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const parts = await encryptSecretParts(serialized, PASSWORD) + restoreWalletMaterialStored({ + enc: true, + ...parts, + materialHash: materialHash(serialized), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }) + + expect(savedWalletMaterialOwnerId()).toBeNull() + const proven = await decryptSavedWalletMaterial(PASSWORD) + ensureSavedWalletMaterialOwner(proven) + + expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material)) + }) + + it('rejects an unproven restored hash that contradicts encrypted material', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const parts = await encryptSecretParts(serialized, PASSWORD) + restoreWalletMaterialStored({ + enc: true, + ...parts, + materialHash: '00'.repeat(32), + ownerId: walletMaterialOwnerId(material), + version: 2, + }) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( + WalletMaterialProofMismatchError, + ) + expect(savedWalletMaterialOwnerId()).toBeNull() + }) +}) diff --git a/src/lnurlcash/keys.material-storage.cases.ts b/src/lnurlcash/keys.material-storage.cases.ts index d748080..8f7dd5e 100644 --- a/src/lnurlcash/keys.material-storage.cases.ts +++ b/src/lnurlcash/keys.material-storage.cases.ts @@ -1,3 +1,5 @@ +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import {describe, expect, it} from 'vitest' import { @@ -27,6 +29,9 @@ const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9 const CASH_ROOT_HEX = 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' +const materialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + const readRawMaterialRecord = (): Record => { const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) if (raw === null) throw new Error('expected a saved wallet-material record') @@ -50,6 +55,7 @@ describe('WalletMaterialV2 persistence', () => { expect(readRawMaterialRecord()).toEqual({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }) @@ -87,6 +93,7 @@ describe('WalletMaterialV2 persistence', () => { restoreWalletMaterialStored({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: foreignOwner, version: 2, }) @@ -96,7 +103,11 @@ describe('WalletMaterialV2 persistence', () => { ensureSavedWalletMaterialOwner(unlocked) // Then the claim was absent before proof and only the derived owner was stamped - expect(beforeProof).toEqual({enc: false, value: serializeWalletMaterial(material)}) + expect(beforeProof).toEqual({ + enc: false, + value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), + }) expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) }) @@ -109,6 +120,7 @@ describe('WalletMaterialV2 persistence', () => { JSON.stringify({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), version: 2, }), @@ -160,6 +172,7 @@ describe('WalletMaterialV2 persistence', () => { const record = { enc: false, value, + materialHash: materialHash(value), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version, } @@ -182,6 +195,7 @@ describe('WalletMaterialV2 persistence', () => { JSON.stringify({ enc: true, ...parts, + materialHash: materialHash(LINKING_KEY_HEX), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }), diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index a01600c..6518bad 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -25,6 +25,7 @@ export { isValidStoredWalletMaterial, parseWalletMaterial, serializeWalletMaterial, + walletMaterialHash, } from './storage/storedSecret' export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' export {decryptSecretParts, encryptSecretParts} from './passwordWrap' diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts index 0e4362d..d779cc1 100644 --- a/src/lnurlcash/keys.version.cases.ts +++ b/src/lnurlcash/keys.version.cases.ts @@ -13,6 +13,7 @@ import { import {parseJsonObject, stubLocalStorage} from './test-utils' import './keys.material.cases' +import './keys.material-commitment.cases' import './keys.material-storage.cases' const LINKING_KEY = new Uint8Array(32).fill(7) diff --git a/src/lnurlcash/passkeyRewrap.ts b/src/lnurlcash/passkeyRewrap.ts new file mode 100644 index 0000000..2fe01aa --- /dev/null +++ b/src/lnurlcash/passkeyRewrap.ts @@ -0,0 +1,66 @@ +// Rewrap proves every existing slot before replacing any bytes. A supplied PRF +// is therefore both authority for the old wrap and input for its fresh wrap; +// one wrong credential aborts the complete in-memory batch before persistence. + +import type {WalletMaterialV2} from './storage/storedSecret' +import {serializeWalletMaterial, walletMaterialHash} from './storage/storedSecret' +import type {PasskeySlot} from './storage/passkeySlots' +import { + PASSKEY_SLOTS_STORAGE_KEY, + passkeySlotsEqual, + readPasskeySlots, + requireCurrentPasskeyMaterialOwner, + writePasskeySlots, +} from './storage/passkeySlots' +import {withStorageLock} from './storageLock' +import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' + +type ProvenSlot = { + readonly slot: PasskeySlot + readonly prfOutput: Uint8Array +} + +export const rewrapAllSlots = async ( + material: WalletMaterialV2, + prfOutputs: ReadonlyMap, +): Promise => { + const ownerId = requireCurrentPasskeyMaterialOwner(material) + const materialHash = walletMaterialHash(material) + const serialized = serializeWalletMaterial(material) + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { + const provenSlots: ProvenSlot[] = readPasskeySlots().map((slot) => { + const prfOutput = prfOutputs.get(slot.credentialId) + if (!prfOutput) { + throw new Error('Missing old PRF output for a passkey slot - refusing a partial re-wrap.') + } + return {slot, prfOutput} + }) + for (const {slot, prfOutput} of provenSlots) { + if (slot.materialHash !== materialHash) { + throw new Error('Passkey slot does not match the saved wallet material.') + } + const current = await unwrapWalletMaterialWithPrf(prfOutput, slot) + if (serializeWalletMaterial(current) !== serialized) { + throw new Error('Passkey slot contains different wallet material.') + } + } + const rewrapped: PasskeySlot[] = [] + for (const {slot, prfOutput} of provenSlots) { + rewrapped.push({...slot, ...(await wrapWalletMaterialWithPrf(prfOutput, material))}) + } + if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) { + throw new Error('Saved wallet material changed during passkey re-wrap.') + } + const currentSlots = readPasskeySlots() + if ( + currentSlots.length !== provenSlots.length || + currentSlots.some((current, index) => { + const original = provenSlots[index]?.slot + return original === undefined || !passkeySlotsEqual(original, current) + }) + ) { + throw new Error('Passkey slots changed during re-wrap.') + } + writePasskeySlots(ownerId, rewrapped) + }) +} diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index b1246c6..8bfed74 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -9,11 +9,11 @@ // from the same PRF output. import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' -import {sha256} from '@noble/hashes/sha2.js' import type {PasskeyWrap} from './storage/passkeySlots' import {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret' import type {WalletMaterialV2} from './storage/storedSecret' +import {serializedWalletMaterialHash, walletMaterialHash} from './storage/walletMaterial' const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2' @@ -63,7 +63,7 @@ export const wrapWalletMaterialWithPrf = async ( return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + materialHash: walletMaterialHash(material), wrappedMaterial: bytesToHex(ciphertext), } } @@ -81,7 +81,7 @@ export const unwrapWalletMaterialWithPrf = async ( new Uint8Array(hexToBytes(wrap.wrappedMaterial)), ) const serialized = new TextDecoder().decode(plaintext) - if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) { + if (serializedWalletMaterialHash(serialized) !== wrap.materialHash) { throw new InvalidPasskeyMaterialError() } const material = parseWalletMaterial(serialized) diff --git a/src/lnurlcash/passkeys.commitment.cases.ts b/src/lnurlcash/passkeys.commitment.cases.ts new file mode 100644 index 0000000..4ec14fe --- /dev/null +++ b/src/lnurlcash/passkeys.commitment.cases.ts @@ -0,0 +1,129 @@ +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import {deriveWalletMaterial, saveWalletMaterial} from './keys' +import { + getPasskeyPrfOutput, + registerPasskey, + rewrapAllSlots, + unlockWalletMaterialWithPasskey, +} from './passkeys' +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import {stubLocalStorage} from './test-utils' + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)} +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) +const ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} +const SLOT_STORAGE_KEY = 'sattle_passkey_slots' + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +class FakeAuthenticator implements PasskeyCredentials { + private readonly credentialId = new Uint8Array(16).fill(12) + private readonly secret = new Uint8Array(32).fill(13) + onCreate?: () => Promise + onGet?: () => Promise + + create = async (options?: CredentialCreationOptions): Promise => { + await this.onCreate?.() + return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + } + + get = async (options?: CredentialRequestOptions): Promise => { + await this.onGet?.() + const allowed = options?.publicKey?.allowCredentials ?? [] + return allowed.some( + (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), + ) + ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + : null + } + + private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ + type: 'public-key', + rawId: this.credentialId, + getClientExtensionResults: () => ({ + prf: salt + ? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} + : {}, + }), + }) +} + +const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '' + +beforeEach(async () => { + stubLocalStorage() + await saveWalletMaterial(MATERIAL) +}) + +describe('exact wallet material passkey commitment', () => { + it('rejects a wrong old PRF before changing any slot bytes', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + const wrongPrf = new Uint8Array(32).fill(99) + + await expect( + rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])), + ).rejects.toThrow() + expect(rawSlots()).toBe(before) + }) + + it('rejects same-owner altered-root registration against encrypted saved material', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL, 'password') + + await expect(registerPasskey(ALTERED_ROOT, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('rejects registration when same-owner saved material changes during the ceremony', async () => { + const authenticator = new FakeAuthenticator() + authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT) + + await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('rejects unlock after same-owner saved material was replaced', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + await saveWalletMaterial(ALTERED_ROOT, 'password') + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow() + }) + + it('rejects same-owner saved-material replacement during the unlock ceremony', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT) + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow( + 'changed', + ) + }) + + it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const oldPrf = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + + await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]])) + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual( + MATERIAL, + ) + }) +}) diff --git a/src/lnurlcash/passkeys.material.cases.ts b/src/lnurlcash/passkeys.material.cases.ts index 2d3a174..c5abd9a 100644 --- a/src/lnurlcash/passkeys.material.cases.ts +++ b/src/lnurlcash/passkeys.material.cases.ts @@ -10,7 +10,6 @@ import {beforeEach, describe, expect, it} from 'vitest' import { deriveWalletMaterial, saveWalletMaterial, - serializeWalletMaterial, } from './keys' import { derivePasskeyWrapKey, @@ -135,7 +134,7 @@ describe('v2 passkey material', () => { const authenticator = new FakeAuthenticator() const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) - const materialHash = bytesToHex(sha256(utf8ToBytes(serializeWalletMaterial(MATERIAL)))) + const materialHash = bytesToHex(sha256(utf8ToBytes(payload))) const wrap = await encryptPayload(prfOutput, payload, materialHash) localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}])) const before = rawSlots() diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index 5e81125..1c0cf7e 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,4 +1,5 @@ import './passkeys.crypto.cases' +import './passkeys.commitment.cases' import './passkeys.material.cases' import './passkeys.registration.cases' import './passkeys.ownership-a.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index 9242d30..9b39171 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -36,13 +36,14 @@ import { writePasskeySlots, } from './storage/passkeySlots' import type {WalletMaterialV2} from './storage/storedSecret' -import {walletMaterialOwnerId} from './storage/storedSecret' -import {savedWalletMaterialOwnerId} from './walletMaterialStorage' +import {walletMaterialHash, walletMaterialOwnerId} from './storage/storedSecret' +import {savedWalletMaterialHash, savedWalletMaterialOwnerId} from './walletMaterialStorage' import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export {migrateLegacyPasskeySlots} from './passkeyOwnership' +export {rewrapAllSlots} from './passkeyRewrap' export { derivePasskeyWrapKey, InvalidPasskeyMaterialError, @@ -218,6 +219,9 @@ export const registerPasskey = async ( version: PASSKEY_SLOT_VERSION, } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { + if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) { + throw new Error('Saved wallet material changed during passkey registration.') + } const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) slots.push(slot) writePasskeySlots(ownerId, slots) @@ -232,10 +236,14 @@ export const unlockWalletMaterialWithPasskey = async ( options: {credentials?: PasskeyCredentials} = {}, ): Promise => { const ownerId = savedWalletMaterialOwnerId() + const materialHash = savedWalletMaterialHash() const slots = readPasskeySlots() - if (ownerId === null || slots.length === 0) { + if (ownerId === null || materialHash === null || slots.length === 0) { throw new Error('No passkeys registered on this device.') } + if (slots.some((slot) => slot.materialHash !== materialHash)) { + throw new Error('Passkey slot belongs to different wallet material.') + } const credentials = options.credentials ?? defaultCredentials() const assertion = await credentials.get({ publicKey: { @@ -261,6 +269,9 @@ export const unlockWalletMaterialWithPasskey = async ( if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } + if (savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.') + } const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId) if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) { throw new Error('The passkey slot changed during the unlock ceremony.') @@ -269,6 +280,9 @@ export const unlockWalletMaterialWithPasskey = async ( if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } + if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.') + } return material } @@ -294,33 +308,3 @@ export const removePasskey = async (credentialId: string): Promise => { }) return removed } - -// Refreshes every current-owner slot around the same proven wallet 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 refresh before anything is written. -// -// A password change does not need this because password and passkey slots are -// independent wraps of the same canonical material. -export const rewrapAllSlots = async ( - material: WalletMaterialV2, - prfOutputs: ReadonlyMap, -): Promise => { - const ownerId = requireCurrentPasskeyMaterialOwner(material) - 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.') - } - rewrapped.push({ - ...slot, - ...(await wrapWalletMaterialWithPrf(prfOutput, material)), - }) - } - writePasskeySlots(ownerId, rewrapped) - }) -} diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts index f8bf2fc..3924c5a 100644 --- a/src/lnurlcash/passkeys.version.cases.ts +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -8,6 +8,7 @@ import { linkingPubKeyHex, savedKeyOwnerId, saveWalletMaterial, + walletMaterialHash, } from './keys' import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' import type {PasskeySlot} from './passkeys' @@ -28,7 +29,7 @@ const SLOT_BASE = { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - materialHash: '44'.repeat(32), + materialHash: walletMaterialHash(MATERIAL), wrappedMaterial: '55'.repeat(128), createdAt: 1, } as const diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 258b041..8eca766 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -5,8 +5,12 @@ import {isJsonObject} from '../jsonParsing' import type {WalletMaterialV2} from './storedSecret' -import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret' -import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage' +import {serializeWalletMaterial, walletMaterialHash, walletMaterialOwnerId} from './storedSecret' +import { + getPlainWalletMaterial, + savedWalletMaterialHash, + savedWalletMaterialOwnerId, +} from '../walletMaterialStorage' import {isWalletOwnerId} from './walletOwner' export type PasskeyWrap = { @@ -39,10 +43,13 @@ export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolea export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => { const ownerId = savedWalletMaterialOwnerId() + const materialHash = savedWalletMaterialHash() const plaintext = getPlainWalletMaterial() if ( ownerId === null || + materialHash === null || walletMaterialOwnerId(material) !== ownerId || + walletMaterialHash(material) !== materialHash || (plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material)) ) { throw new Error('Passkey operation requires the proven saved wallet material.') @@ -175,10 +182,22 @@ export const readPasskeySlots = (): PasskeySlot[] => { export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { - if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { + const materialHash = savedWalletMaterialHash() + if ( + !isWalletOwnerId(ownerId) || + savedWalletMaterialOwnerId() !== ownerId || + materialHash === null + ) { throw new Error('Passkey slots require the proven saved wallet owner.') } - if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { + if ( + slots.some( + (slot) => + slot.ownerId !== ownerId || + slot.version !== PASSKEY_SLOT_VERSION || + slot.materialHash !== materialHash, + ) + ) { throw new Error('Refusing to write a passkey slot for a different wallet.') } const preserved = readStoredPasskeySlots() @@ -188,13 +207,18 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void = } export const adoptLegacyPasskeySlots = (ownerId: string): number => { + const materialHash = savedWalletMaterialHash() if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== 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)) { + if ( + slot.isCurrent || + slot.record.materialHash !== materialHash || + (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId) + ) { return slot.record } adopted += 1 diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts index 5a8d0f2..7b8c305 100644 --- a/src/lnurlcash/storage/storedSecret.ts +++ b/src/lnurlcash/storage/storedSecret.ts @@ -3,21 +3,24 @@ // canonical material encoding so hostile storage cannot smuggle a legacy key, // a future field, or a malformed BIP-32 root across the unlock boundary. -import {secp256k1} from '@noble/curves/secp256k1.js' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' -import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit' import {isJsonObject} from '../jsonParsing' import {isWalletOwnerId} from './walletOwner' +import { + parseWalletMaterial, + serializedWalletMaterialHash, + STORED_WALLET_MATERIAL_VERSION, +} from './walletMaterial' +export { + parseWalletMaterial, + serializeWalletMaterial, + walletMaterialHash, + walletMaterialOwnerId, + WALLET_MATERIAL_VERSION, +} from './walletMaterial' +export type {WalletMaterialV2} from './walletMaterial' export const STORED_SECRET_VERSION = 1 as const -export const WALLET_MATERIAL_VERSION = 2 as const -export const STORED_WALLET_MATERIAL_VERSION = 2 as const - -export type WalletMaterialV2 = { - readonly version: typeof WALLET_MATERIAL_VERSION - readonly linkingKeyHex: string - readonly cashRootHex: string -} +export {STORED_WALLET_MATERIAL_VERSION} from './walletMaterial' type PlainStoredSecret = { readonly enc: false @@ -36,7 +39,7 @@ type EncryptedStoredSecret = { } export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret -export type StoredWalletMaterial = StoredSecret +export type StoredWalletMaterial = StoredSecret & {readonly materialHash: string} type ParsedStoredSecret = { readonly secret: StoredSecret @@ -44,66 +47,24 @@ type ParsedStoredSecret = { readonly isCurrent: boolean } +type ParsedStoredWalletMaterial = Omit & { + readonly secret: StoredWalletMaterial +} + const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const -const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const +const PLAIN_MATERIAL_KEYS = [...PLAIN_KEYS, 'materialHash'] as const +const ENCRYPTED_MATERIAL_KEYS = [...ENCRYPTED_KEYS, 'materialHash'] as const const hasOnlyKeys = (record: Record, allowed: readonly string[]): boolean => Object.keys(record).every((key) => allowed.includes(key)) -const hasExactlyKeys = (record: Record, expected: readonly string[]): boolean => - Object.keys(record).length === expected.length && hasOnlyKeys(record, expected) - -export const serializeWalletMaterial = (material: WalletMaterialV2): string => - JSON.stringify({ - version: WALLET_MATERIAL_VERSION, - linkingKeyHex: material.linkingKeyHex, - cashRootHex: material.cashRootHex, - }) - -export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => { - let value: unknown - try { - value = JSON.parse(serialized) - } catch (error) { - if (error instanceof SyntaxError) return null - throw error - } - if ( - !isJsonObject(value) || - !hasExactlyKeys(value, WALLET_MATERIAL_KEYS) || - value.version !== WALLET_MATERIAL_VERSION || - typeof value.linkingKeyHex !== 'string' || - !/^[0-9a-f]{64}$/.test(value.linkingKeyHex) || - typeof value.cashRootHex !== 'string' || - !/^[0-9a-f]{128}$/.test(value.cashRootHex) - ) { - return null - } - const linkingKey = hexToBytes(value.linkingKeyHex) - const cashRoot = cashNodeFromHex(value.cashRootHex) - if ( - !secp256k1.utils.isValidSecretKey(linkingKey) || - !secp256k1.utils.isValidSecretKey(cashRoot.privateKey) || - cashNodeToHex(cashRoot) !== value.cashRootHex - ) { - return null - } - const material: WalletMaterialV2 = { - version: WALLET_MATERIAL_VERSION, - linkingKeyHex: value.linkingKeyHex, - cashRootHex: value.cashRootHex, - } - return serializeWalletMaterial(material) === serialized ? material : null -} - -export const walletMaterialOwnerId = (material: WalletMaterialV2): string => - bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true)) - type StoredSecretParserOptions = { readonly version: number readonly acceptsPlaintext: (value: string) => boolean readonly acceptsUnversionedOwner: boolean + readonly plainKeys: readonly string[] + readonly encryptedKeys: readonly string[] } const parseStoredSecretWith = ( @@ -116,7 +77,7 @@ const parseStoredSecretWith = ( if ( typeof stored.value !== 'string' || !options.acceptsPlaintext(stored.value) || - !hasOnlyKeys(stored, PLAIN_KEYS) + !hasOnlyKeys(stored, options.plainKeys) ) { return null } @@ -131,7 +92,7 @@ const parseStoredSecretWith = ( stored.ciphertext.length === 0 || stored.ciphertext.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(stored.ciphertext) || - !hasOnlyKeys(stored, ENCRYPTED_KEYS) + !hasOnlyKeys(stored, options.encryptedKeys) ) { return null } @@ -170,14 +131,35 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => version: STORED_SECRET_VERSION, acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value), acceptsUnversionedOwner: true, + plainKeys: PLAIN_KEYS, + encryptedKeys: ENCRYPTED_KEYS, }) -export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null => - parseStoredSecretWith(stored, { +export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredWalletMaterial | null => { + if ( + !isJsonObject(stored) || + typeof stored.materialHash !== 'string' || + !/^[0-9a-f]{64}$/.test(stored.materialHash) + ) { + return null + } + const materialHash = stored.materialHash + const parsed = parseStoredSecretWith(stored, { version: STORED_WALLET_MATERIAL_VERSION, acceptsPlaintext: (value) => parseWalletMaterial(value) !== null, acceptsUnversionedOwner: false, + plainKeys: PLAIN_MATERIAL_KEYS, + encryptedKeys: ENCRYPTED_MATERIAL_KEYS, }) + if ( + parsed === null || + (parsed.secret.enc === false && serializedWalletMaterialHash(parsed.secret.value) !== materialHash) + ) { + return null + } + const secret: StoredWalletMaterial = {...parsed.secret, materialHash} + return {...parsed, secret} +} export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => parseStoredSecret(stored) !== null @@ -224,6 +206,7 @@ export const stampStoredWalletMaterialOwner = ( return { enc: false, value: stored.value, + materialHash: stored.materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, } @@ -233,6 +216,7 @@ export const stampStoredWalletMaterialOwner = ( salt: stored.salt, iv: stored.iv, ciphertext: stored.ciphertext, + materialHash: stored.materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, } @@ -247,3 +231,18 @@ export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { ciphertext: stored.ciphertext, } } + +export const stripStoredWalletMaterialOwner = ( + stored: StoredWalletMaterial, +): StoredWalletMaterial => { + if (stored.enc === false) { + return {enc: false, value: stored.value, materialHash: stored.materialHash} + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + materialHash: stored.materialHash, + } +} diff --git a/src/lnurlcash/storage/walletMaterial.ts b/src/lnurlcash/storage/walletMaterial.ts new file mode 100644 index 0000000..7283ed7 --- /dev/null +++ b/src/lnurlcash/storage/walletMaterial.ts @@ -0,0 +1,77 @@ +// Canonical wallet material combines stable LUD-05 identity with the BIP-32 +// cash root. Its hash commits to equality and freshness only; wallet identity +// remains derived exclusively from the linking key. + +import {secp256k1} from '@noble/curves/secp256k1.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit' + +import {isJsonObject} from '../jsonParsing' + +export const WALLET_MATERIAL_VERSION = 2 as const +export const STORED_WALLET_MATERIAL_VERSION = 2 as const + +export type WalletMaterialV2 = { + readonly version: typeof WALLET_MATERIAL_VERSION + readonly linkingKeyHex: string + readonly cashRootHex: string +} + +const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const + +const hasExactlyKeys = (value: Record, expected: readonly string[]): boolean => + Object.keys(value).length === expected.length && + Object.keys(value).every((key) => expected.includes(key)) + +export const serializeWalletMaterial = (material: WalletMaterialV2): string => + JSON.stringify({ + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: material.linkingKeyHex, + cashRootHex: material.cashRootHex, + }) + +export const serializedWalletMaterialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + +export const walletMaterialHash = (material: WalletMaterialV2): string => + serializedWalletMaterialHash(serializeWalletMaterial(material)) + +export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => { + let value: unknown + try { + value = JSON.parse(serialized) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + if ( + !isJsonObject(value) || + !hasExactlyKeys(value, WALLET_MATERIAL_KEYS) || + value.version !== WALLET_MATERIAL_VERSION || + typeof value.linkingKeyHex !== 'string' || + !/^[0-9a-f]{64}$/.test(value.linkingKeyHex) || + typeof value.cashRootHex !== 'string' || + !/^[0-9a-f]{128}$/.test(value.cashRootHex) + ) { + return null + } + const linkingKey = hexToBytes(value.linkingKeyHex) + const cashRoot = cashNodeFromHex(value.cashRootHex) + if ( + !secp256k1.utils.isValidSecretKey(linkingKey) || + !secp256k1.utils.isValidSecretKey(cashRoot.privateKey) || + cashNodeToHex(cashRoot) !== value.cashRootHex + ) { + return null + } + const material: WalletMaterialV2 = { + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: value.linkingKeyHex, + cashRootHex: value.cashRootHex, + } + return serializeWalletMaterial(material) === serialized ? material : null +} + +export const walletMaterialOwnerId = (material: WalletMaterialV2): string => + bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true)) diff --git a/src/lnurlcash/walletMaterialStorage.ts b/src/lnurlcash/walletMaterialStorage.ts index 5c4ba70..74e1516 100644 --- a/src/lnurlcash/walletMaterialStorage.ts +++ b/src/lnurlcash/walletMaterialStorage.ts @@ -9,11 +9,13 @@ import { stampStoredWalletMaterialOwner, storedWalletMaterialClaimedOwnerId, storedWalletMaterialOwnerId, - stripStoredSecretOwner, + stripStoredWalletMaterialOwner, + walletMaterialHash, walletMaterialOwnerId, STORED_WALLET_MATERIAL_VERSION, } from './storage/storedSecret' import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +import {serializedWalletMaterialHash} from './storage/walletMaterial' import {decryptSecretParts, encryptSecretParts} from './passwordWrap' import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents' @@ -86,6 +88,9 @@ export const savedWalletMaterialOwnerId = (): string | null => { export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean => savedWalletMaterialOwnerId() === walletMaterialOwnerId(material) +export const savedWalletMaterialHash = (): string | null => + readStoredWalletMaterial()?.materialHash ?? null + export const getPlainWalletMaterial = (): WalletMaterialV2 | null => { const stored = readStoredWalletMaterial() return stored === null ? null : materialFromPlaintext(stored) @@ -96,6 +101,7 @@ export const saveWalletMaterial = async ( password?: string, ): Promise => { const value = serializeWalletMaterial(material) + const materialHash = walletMaterialHash(material) const ownerId = walletMaterialOwnerId(material) if (!password) { localStorage.setItem( @@ -103,6 +109,7 @@ export const saveWalletMaterial = async ( JSON.stringify({ enc: false, value, + materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, }), @@ -115,6 +122,7 @@ export const saveWalletMaterial = async ( JSON.stringify({ enc: true, ...parts, + materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, }), @@ -122,10 +130,11 @@ export const saveWalletMaterial = async ( } export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => { - if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError() + const parsed = parseStoredWalletMaterial(stored) + if (parsed === null) throw new InvalidWalletMaterialError() localStorage.setItem( WALLET_MATERIAL_STORAGE_KEY, - JSON.stringify(stripStoredSecretOwner(stored)), + JSON.stringify(stripStoredWalletMaterialOwner(parsed.secret)), ) } @@ -134,7 +143,11 @@ export const decryptSavedWalletMaterial = async ( ): Promise => { const stored = readStoredWalletMaterial() if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError() - const material = parseWalletMaterial(await decryptSecretParts(stored, password)) + const serialized = await decryptSecretParts(stored, password) + if (serializedWalletMaterialHash(serialized) !== stored.materialHash) { + throw new WalletMaterialProofMismatchError() + } + const material = parseWalletMaterial(serialized) if (material === null) throw new InvalidWalletMaterialError() const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) { @@ -146,7 +159,11 @@ export const decryptSavedWalletMaterial = async ( export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => { const stored = readStoredWalletMaterial() if (stored === null) return - if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) { + const serialized = serializeWalletMaterial(material) + if ( + walletMaterialHash(material) !== stored.materialHash || + (stored.enc === false && stored.value !== serialized) + ) { throw new WalletMaterialProofMismatchError() } const ownerId = walletMaterialOwnerId(material) From aaee045916c39e437030ad31cde974ffb973c9d4 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 07:27:56 +0200 Subject: [PATCH 07/29] test(passkeys): reverify exact-material slot binding --- src/lnurlcash/passkeys.reverify.cases.ts | 156 +++++++++++++++++++++++ src/lnurlcash/passkeys.test.ts | 1 + 2 files changed, 157 insertions(+) create mode 100644 src/lnurlcash/passkeys.reverify.cases.ts diff --git a/src/lnurlcash/passkeys.reverify.cases.ts b/src/lnurlcash/passkeys.reverify.cases.ts new file mode 100644 index 0000000..ca83af9 --- /dev/null +++ b/src/lnurlcash/passkeys.reverify.cases.ts @@ -0,0 +1,156 @@ +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import {deriveWalletMaterial, saveWalletMaterial} from './keys' +import {registerPasskey, rewrapAllSlots, unlockWalletMaterialWithPasskey} from './passkeys' +import type {CeremonyCredential, PasskeyCredentials} from './passkeys' +import {stubLocalStorage} from './test-utils' + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const MATERIAL = deriveWalletMaterial(MNEMONIC) +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) +// same linking key (same owner) as MATERIAL, but a different, VALID cash root +const ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} +const SLOT_STORAGE_KEY = 'sattle_passkey_slots' +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +class FakeAuthenticator implements PasskeyCredentials { + private readonly credentialId = new Uint8Array(16).fill(12) + private readonly secret = new Uint8Array(32).fill(13) + onCreate?: () => Promise + onGet?: () => Promise + + create = async (options?: CredentialCreationOptions): Promise => { + await this.onCreate?.() + return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + } + + get = async (options?: CredentialRequestOptions): Promise => { + await this.onGet?.() + const allowed = options?.publicKey?.allowCredentials ?? [] + return allowed.some( + (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), + ) + ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + : null + } + + private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ + type: 'public-key', + rawId: this.credentialId, + getClientExtensionResults: () => ({ + prf: salt + ? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} + : {}, + }), + }) +} + +const storageState = (): {slots: string | null; material: string | null} => ({ + slots: localStorage.getItem(SLOT_STORAGE_KEY), + material: localStorage.getItem(MATERIAL_STORAGE_KEY), +}) + +beforeEach(() => { + stubLocalStorage() +}) + +describe('passkey exact-material commitment re-verification', () => { + it('rejects same-owner altered-root registration with byte-identical storage', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + const before = storageState() + + await expect(registerPasskey(ALTERED_ROOT, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(storageState()).toEqual(before) + }) + + it('rejects same-owner altered-root registration against encrypted saved material', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL, 'password') + const before = storageState() + + await expect(registerPasskey(ALTERED_ROOT, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(storageState()).toEqual(before) + }) + + it('writes nothing when same-owner saved material changes during registration', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + let swapped: {slots: string | null; material: string | null} | null = null + authenticator.onCreate = async () => { + await saveWalletMaterial(ALTERED_ROOT) + swapped = storageState() + } + + await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(swapped).not.toBeNull() + expect(storageState()).toEqual(swapped) + }) + + it('rejects unlock after same-owner saved replacement with byte-identical storage', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + await registerPasskey(MATERIAL, {credentials: authenticator}) + await saveWalletMaterial(ALTERED_ROOT, 'password') + const before = storageState() + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow() + expect(storageState()).toEqual(before) + }) + + it('writes nothing when same-owner saved material changes during the unlock ceremony', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + await registerPasskey(MATERIAL, {credentials: authenticator}) + let swapped: {slots: string | null; material: string | null} | null = null + authenticator.onGet = async () => { + await saveWalletMaterial(ALTERED_ROOT) + swapped = storageState() + } + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow( + 'changed', + ) + expect(swapped).not.toBeNull() + expect(storageState()).toEqual(swapped) + }) + + it('rejects a same-owner altered-root re-wrap before changing any bytes', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = storageState() + + await expect( + rewrapAllSlots(ALTERED_ROOT, new Map([[slot.credentialId, new Uint8Array(32).fill(13)]])), + ).rejects.toThrow('saved wallet material') + expect(storageState()).toEqual(before) + }) + + it('returns the exact saved material on a committed unlock', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL) + await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = storageState() + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual( + MATERIAL, + ) + expect(storageState()).toEqual(before) + }) +}) diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index 1c0cf7e..ea1ebe2 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,5 +1,6 @@ import './passkeys.crypto.cases' import './passkeys.commitment.cases' +import './passkeys.reverify.cases' import './passkeys.material.cases' import './passkeys.registration.cases' import './passkeys.ownership-a.cases' From 7e4e67e1cddc836830add3baf787a0c5368102f7 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 07:28:03 +0200 Subject: [PATCH 08/29] fix(unlock): bind factors to wallet material --- src/capabilities/biometricUnlock.test.ts | 165 ++++++++++++++++++++++- src/capabilities/biometricUnlock.ts | 69 +++++++++- 2 files changed, 226 insertions(+), 8 deletions(-) diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index 4f5fc8d..b49fcc1 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -1,9 +1,10 @@ -import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveWalletMaterial, serializeWalletMaterial, + walletMaterialHash, type WalletMaterialV2, } from '@/lnurlcash/keys'; import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; @@ -50,6 +51,11 @@ const SEED = const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; const MATERIAL = deriveWalletMaterial(SEED); const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED); +// same linking key (same owner) as MATERIAL, but a different, VALID cash root +const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = { + ...MATERIAL, + cashRootHex: OTHER_MATERIAL.cashRootHex, +}; const WRAP_SECRET = new Uint8Array(32).fill(3); const WRAP_RECORD_KEY = 'sattle_biometric_wrap'; const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; @@ -90,6 +96,7 @@ const deriveFixtureWrapKey = async ( const storePayload = async ( plaintext: Uint8Array, pubkey: string, + materialHash: string, secret = WRAP_SECRET, ): Promise => { const hkdfSalt = new Uint8Array(16).fill(4); @@ -106,6 +113,7 @@ const storePayload = async ( iv: bytesToHex(iv), wrappedKey: bytesToHex(ciphertext), pubkey, + materialHash, createdAt: 1, }), ); @@ -115,7 +123,10 @@ const storePayload = async ( const storeMaterialPayload = async ( material: WalletMaterialV2, pubkey = walletMaterialOwnerId(material), -): Promise => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey); + secret = WRAP_SECRET, + materialHash = walletMaterialHash(material), +): Promise => + storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret); beforeEach(() => { vi.clearAllMocks(); @@ -194,7 +205,11 @@ describe('biometric wallet material', () => { ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { // Given an authenticated non-canonical payload bound to the saved owner await saveWalletMaterial(MATERIAL); - await storePayload(payload, walletMaterialOwnerId(MATERIAL)); + await storePayload( + payload, + walletMaterialOwnerId(MATERIAL), + walletMaterialHash(MATERIAL), + ); const before = persistedState(); // When biometric unlock decrypts the payload @@ -266,7 +281,12 @@ describe('biometric wallet material', () => { localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue'); localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue'); localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue'); - await storeMaterialPayload(OTHER_MATERIAL, walletMaterialOwnerId(MATERIAL)); + await storeMaterialPayload( + OTHER_MATERIAL, + walletMaterialOwnerId(MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); const before = persistedState(); // When biometric unlock checks the decrypted linking-key owner @@ -278,6 +298,143 @@ describe('biometric wallet material', () => { }); }); +describe('biometric exact-material commitment', () => { + it('rejects same-owner altered-root enrollment before writing either biometric store', async () => { + // Given one proven saved owner and well-formed material with only the cash root swapped + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the exact-material commitment fails before native or local persistence changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects same-owner altered-root enrollment against encrypted saved material', async () => { + // Given the saved material envelope is password-encrypted (no plaintext available) + await saveWalletMaterial(MATERIAL, 'password'); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the hash commitment alone rejects it before either store changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects enrollment when same-owner saved material changes during the prompt', async () => { + // Given valid enrollment input and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + }); + + // When enrollment runs the biometric prompt + const attempt = enableBiometricUnlock(MATERIAL); + + // Then the post-prompt commitment recheck fails and no biometric store is written + await expect(attempt).rejects.toThrow('changed'); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects an authenticated well-formed same-owner altered-root payload', async () => { + // Given an authenticated wrap that claims the saved commitment but whose + // decrypted material swaps only the cash root + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload( + ALTERED_ROOT_MATERIAL, + walletMaterialOwnerId(ALTERED_ROOT_MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); + const before = persistedState(); + + // When biometric unlock decrypts the well-formed same-owner payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the material commitment rejects it and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => { + // Given an authenticated wrap committed to same-owner altered-root material + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When biometric unlock inspects the record commitment + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then it rejects before any prompt and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(pluginMocks.authenticate).not.toHaveBeenCalled(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after same-owner saved material replacement', async () => { + // Given an enrolled wrap and a later saved-material swap of only the cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When the now-stale biometric wrap is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the stale commitment rejects and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects unlock when same-owner saved material changes during the prompt', async () => { + // Given an enrolled wrap and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the post-prompt commitment recheck rejects and the ceremony writes nothing + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); + + it('rejects unlock when the wrap record is replaced during the prompt', async () => { + // Given an enrolled wrap replaced mid-prompt by another valid same-owner record + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY); + if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret'); + await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret)); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the record recheck rejects and the ceremony writes nothing itself + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); +}); + describe('biometric deletion ordering', () => { it('keeps native and local state retryable when secure-secret deletion fails', async () => { // Given a complete enrolled biometric wrap diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index e6b83bd..c1878aa 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -13,10 +13,13 @@ // storage (Android Keystore-backed AES-GCM via // @aparajita/capacitor-secure-storage). The wrapped blob stays in // localStorage as a versioned AES-GCM record, plus the linking -// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale -// wrap behind, and unlocking with it must fail loudly (never activate the -// old wallet silently), so unlock verifies the unwrapped material owner against the -// recorded pubkey and tells the holder to re-enroll. +// pubkey as an identity check AND the SHA-256 commitment of the canonical +// saved material: restoring a DIFFERENT seed leaves a stale wrap behind, +// and so does replacing only the saved cash root with same-owner material. +// Unlocking with either must fail loudly (never activate the old wallet +// silently), so unlock verifies the record commitment and the unwrapped +// material against the saved wallet material and tells the holder to +// re-enroll. // // The biometric gate is app-level (a BiometricPrompt before the secure // read), not a keystore key invalidated on biometric re-enrollment - 04 @@ -38,9 +41,12 @@ import { SecureStorage } from '@aparajita/capacitor-secure-storage'; import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { + getPlainWalletMaterial, parseWalletMaterial, + savedWalletMaterialHash, savedWalletMaterialOwnerId, serializeWalletMaterial, + walletMaterialHash, walletMaterialLinkingKey, type WalletMaterialV2, } from '@/lnurlcash/keys'; @@ -55,6 +61,7 @@ type BiometricWrapRecord = { readonly iv: string; readonly wrappedKey: string; readonly pubkey: string; + readonly materialHash: string; readonly createdAt: number; }; @@ -70,6 +77,7 @@ const WRAP_RECORD_KEYS = [ 'iv', 'wrappedKey', 'pubkey', + 'materialHash', 'createdAt', ] as const; @@ -110,6 +118,8 @@ const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { /^[0-9a-f]+$/.test(record.wrappedKey) && typeof record.pubkey === 'string' && /^[0-9a-f]{66}$/.test(record.pubkey) && + typeof record.materialHash === 'string' && + /^[0-9a-f]{64}$/.test(record.materialHash) && typeof record.createdAt === 'number' && Number.isSafeInteger(record.createdAt) && record.createdAt >= 0 @@ -127,6 +137,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => { } }; +const biometricWrapRecordsEqual = ( + left: BiometricWrapRecord, + right: BiometricWrapRecord, +): boolean => + left.version === right.version && + left.hkdfSalt === right.hkdfSalt && + left.iv === right.iv && + left.wrappedKey === right.wrappedKey && + left.pubkey === right.pubkey && + left.materialHash === right.materialHash && + left.createdAt === right.createdAt; + // sync on purpose (same convention as hasPasskeySlots): the unlock form and // the security page ask this during render/setup export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null; @@ -179,12 +201,27 @@ export const enableBiometricUnlock = async ( if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Biometric enrollment requires the proven saved wallet owner.'); } + // exact-material commitment (same check as passkey slots): the enrolled + // bytes must equal the canonical saved material, not just share its owner + const materialHash = savedWalletMaterialHash(); + const plaintext = getPlainWalletMaterial(); + if ( + materialHash === null || + walletMaterialHash(canonicalMaterial) !== materialHash || + (plaintext !== null && serializeWalletMaterial(plaintext) !== serialized) + ) { + throw new Error('Biometric enrollment requires the exact saved wallet material.'); + } const biometry = await BiometricAuth.checkBiometry(); if (!biometry.isAvailable) { throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); } // prove presence before storing anything under the biometric gate await authenticateOrThrow('Set up biometric unlock for your wallet'); + // the prompt yields time: re-verify the commitment before writing anything + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during biometric enrollment.'); + } const secret = crypto.getRandomValues(new Uint8Array(32)); const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -205,6 +242,7 @@ export const enableBiometricUnlock = async ( iv: bytesToHex(iv), wrappedKey: bytesToHex(ciphertext), pubkey: ownerId, + materialHash, createdAt: Date.now(), }; localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record)); @@ -221,7 +259,24 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise Security.', ); } + // the stored commitment must match the canonical saved material, or the + // wrap is stale (same-owner cash-root replacement) and must not unlock + const materialHash = savedWalletMaterialHash(); + if (materialHash === null || record.materialHash !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } await authenticateOrThrow('Unlock your sattle wallet'); + // the prompt yields time: re-read the record and the saved commitment + // before any decrypted plaintext can reach the caller + const currentRecord = readWrapRecord(); + if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) { + throw new Error('The biometric wrap changed during the unlock ceremony.'); + } + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.'); + } const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); @@ -242,6 +297,12 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise Security.', ); } + // the decrypted material itself must carry the committed cash root + if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } return material; }; From 0cec423d6fe6edafa1d4d3a6520d2bc659906a1c Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 08:22:02 +0200 Subject: [PATCH 09/29] feat(funds): persist BIP-32 counters atomically --- src/lnurlcash/storage.changeset.cases.ts | 44 +- src/lnurlcash/storage.test.ts | 8 +- src/lnurlcash/storage.ts | 23 +- .../storage/bearers.baseline.cases.ts | 6 +- src/lnurlcash/storage/bearers.test.ts | 430 +++++++++++++++--- src/lnurlcash/storage/bearers.ts | 385 +++++++++++++--- src/lnurlcash/storageLock.test.ts | 38 +- src/lnurlcash/storageLock.ts | 20 + src/stores/walletFunds.atomic.test.ts | 143 +++++- src/stores/walletFunds.fencing.test.ts | 53 ++- src/stores/walletFunds.ts | 258 ++++++++--- 11 files changed, 1181 insertions(+), 227 deletions(-) diff --git a/src/lnurlcash/storage.changeset.cases.ts b/src/lnurlcash/storage.changeset.cases.ts index 4ca5fef..8b4cda3 100644 --- a/src/lnurlcash/storage.changeset.cases.ts +++ b/src/lnurlcash/storage.changeset.cases.ts @@ -16,6 +16,15 @@ import { } from './storage' import type {BearerChangeset} from './storage' import type {Bearer, NewBearer} from './types' +import {FUNDS_STORAGE_KEY, readFundsDocument, writeFundsDocument} from './storage/bearers' + +// mutations of the funds document require Web Locks; tests that don't care +// about lock timing install a present-but-non-serializing fake +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) +} const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_KEY = new Uint8Array(32).fill(9) @@ -45,6 +54,7 @@ const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ describe('baseline: per-record bearer persistence', () => { it('writes once for every persist or delete call', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const writes = vi.spyOn(localStorage, 'setItem') @@ -53,13 +63,14 @@ describe('baseline: per-record bearer persistence', () => { await deleteBearerRecord('a') expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(3) }) }) describe('applyBearerChangeset', () => { it('commits additions and spent replacements with exactly one write', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const oldA = bearerFixture({id: 'old-a'}) const oldB = bearerFixture({ @@ -85,7 +96,7 @@ describe('applyBearerChangeset', () => { const result = await applyBearerChangeset(key, snapshot, changeset) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(1) expect(result).toHaveLength(4) expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) @@ -96,12 +107,13 @@ describe('applyBearerChangeset', () => { expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual( result.map((bearer) => bearer.id).sort(), ) - const raw = localStorage.getItem('sattle_bearers') ?? '' + const raw = localStorage.getItem(FUNDS_STORAGE_KEY) ?? '' expect(raw).not.toContain(K1_C) expect(raw).not.toContain(K1_D) }) it('writes nothing when the second record encryption fails', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) await persistBearer(key, bearerFixture({id: 'kept'})) const writes = vi.spyOn(localStorage, 'setItem') @@ -124,7 +136,7 @@ describe('applyBearerChangeset', () => { ).rejects.toThrow('second encryption failed') expect(encrypt).toHaveBeenCalledTimes(2) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(0) expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) } finally { @@ -133,14 +145,14 @@ describe('applyBearerChangeset', () => { }) it('rejects a failed storage write without changing persisted state', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const kept = bearerFixture({id: 'kept'}) await persistBearer(key, kept) - const before = localStorage.getItem('sattle_bearers') + const before = localStorage.getItem(FUNDS_STORAGE_KEY) const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError') }) - vi.stubGlobal('navigator', {}) try { await expect( @@ -149,7 +161,7 @@ describe('applyBearerChangeset', () => { markSpent: ['kept'], }), ).rejects.toThrow('QuotaExceededError') - expect(localStorage.getItem('sattle_bearers')).toBe(before) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) } finally { write.mockRestore() vi.unstubAllGlobals() @@ -157,6 +169,7 @@ describe('applyBearerChangeset', () => { }) it('deduplicates repeated spent ids into one stored replacement', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const bearer = bearerFixture({id: 'duplicate'}) await persistBearer(key, bearer) @@ -171,7 +184,7 @@ describe('applyBearerChangeset', () => { expect((await loadBearers(key))[0]?.spent).toBe(true) }) - it('encrypts before locking and preserves a fresh unrelated record', async () => { + it('fresh-reads inside the lock and preserves a fresh unrelated record', async () => { const key = await deriveBearerAesKey(LINKING_KEY) const other = await deriveBearerAesKey(OTHER_KEY) const mine = bearerFixture({id: 'mine'}) @@ -205,11 +218,12 @@ describe('applyBearerChangeset', () => { markSpent: [], }) await vi.waitFor(() => expect(queue).toHaveLength(1)) - expect(encrypted).toHaveBeenCalledTimes(1) - localStorage.setItem( - 'sattle_bearers', - JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]), - ) + // nothing is encrypted before the lock: the document is re-read fresh + // inside it, so a record another tab commits in the meantime survives + expect(encrypted).toHaveBeenCalledTimes(0) + const doc = readFundsDocument() + doc.bearers.push({id: foreignId, ...foreignParts}) + writeFundsDocument(doc) const pendingLock = queue.at(0) if (pendingLock === undefined) throw new Error('Expected pending lock') @@ -230,7 +244,7 @@ describe('applyBearerChangeset', () => { it('replaces corrupt JSON with the single committed changeset write', async () => { const key = await deriveBearerAesKey(LINKING_KEY) - localStorage.setItem('sattle_bearers', 'not json {{{') + localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{') const writes = vi.spyOn(localStorage, 'setItem') const result = await applyBearerChangeset(key, [], { @@ -240,7 +254,7 @@ describe('applyBearerChangeset', () => { expect(result).toHaveLength(1) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).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 4f00acd..eed155b 100644 --- a/src/lnurlcash/storage.test.ts +++ b/src/lnurlcash/storage.test.ts @@ -22,6 +22,7 @@ import { MAX_ACTIVITY_ENTRIES, } from './storage' import {saveLinkingKey} from './keys' +import {FUNDS_STORAGE_KEY} from './storage/bearers' import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) @@ -52,7 +53,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, bearer) // at rest, nothing plaintext leaks: no k1, no amounts - const raw = requiredValue(localStorage.getItem('sattle_bearers')) + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) expect(raw).not.toContain(K1_A) expect(raw).not.toContain('21000') @@ -233,19 +234,22 @@ describe('backup', () => { applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}), ).rejects.toThrow() await expect(applyBackup(null)).rejects.toThrow() + await expect(applyBackup({type: 'sattle-backup', version: 1, bearers: []})).rejects.toThrow() + // a v2 file without the counter map is not a valid backup either await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow() }) it('skips malformed records instead of failing the whole restore', async () => { const result = await applyBackup({ type: 'sattle-backup', - version: 1, + version: 2, createdAt: 1, bearers: [ {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 42, iv: null, ciphertext: 'xx'}, 'garbage', ], + nextByHost: {}, }) expect(result.added).toBe(1) expect(result.skipped).toBe(2) diff --git a/src/lnurlcash/storage.ts b/src/lnurlcash/storage.ts index 6f53b75..579bbb9 100644 --- a/src/lnurlcash/storage.ts +++ b/src/lnurlcash/storage.ts @@ -5,8 +5,9 @@ // 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, changeset commits, -// mergeBearers +// storage/bearers.ts - the funds document (sattle_funds_v2): encrypted +// bearer records, pending journal, BIP-32 counters; +// locked single-write commit primitives, mergeBearers // storage/activityLog.ts - the append-only encrypted activity log // storage/settings.ts - plaintext wallet settings // storage/backup.ts - buildBackup / applyBackup @@ -17,14 +18,30 @@ export { compareBearerOrder, newBearerId, readEncryptedBearers, + readFundsDocument, + readFundsRevision, + readNextByHost, + readPendingJournal, loadBearers, persistBearer, deleteBearerRecord, applyBearerChangeset, + reserveCashIndices, + commitFundsRestore, clearAllBearers, mergeBearers, + FUNDS_STORAGE_KEY, +} from './storage/bearers' +export type { + BearerChangeset, + EncryptedBearerRecord, + EncryptedJournalRecord, + FundsReservation, + FundsRestoreMerge, + PendingStaging, + ReservedCashRange, + StoredFundsV2, } from './storage/bearers' -export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers' export { newActivityId, diff --git a/src/lnurlcash/storage/bearers.baseline.cases.ts b/src/lnurlcash/storage/bearers.baseline.cases.ts index 8631efe..957a0b4 100644 --- a/src/lnurlcash/storage/bearers.baseline.cases.ts +++ b/src/lnurlcash/storage/bearers.baseline.cases.ts @@ -3,6 +3,7 @@ import {buildNoteUrl} from 'lnurlcash-kit' import type {Bearer} from '../types' import {deriveBearerAesKey} from '../keys' import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' +import {FUNDS_STORAGE_KEY} from './bearers' import {stubLocalStorage} from '../test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) @@ -19,11 +20,14 @@ const bearerFixture = (): Bearer => ({ describe('baseline: per-record bearer persistence', () => { it('persistBearer/deleteBearerRecord perform one write per call', async () => { const storage = stubLocalStorage() + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) 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) + expect(writes.mock.calls.filter(([keyName]) => keyName === FUNDS_STORAGE_KEY)).toHaveLength(3) }) }) diff --git a/src/lnurlcash/storage/bearers.test.ts b/src/lnurlcash/storage/bearers.test.ts index 91d1886..54874eb 100644 --- a/src/lnurlcash/storage/bearers.test.ts +++ b/src/lnurlcash/storage/bearers.test.ts @@ -10,15 +10,22 @@ import {buildNoteUrl} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {deriveBearerAesKey, encryptRecord} from '../keys' +import {StorageLocksUnavailableError} from '../storageLock' import { applyBearerChangeset, loadBearers, newBearerId, persistBearer, readEncryptedBearers, + reserveCashIndices, } from '../storage' import type {BearerChangeset} from '../storage' -import {writeEncryptedBearers} from './bearers' +import { + FUNDS_STORAGE_KEY, + MAX_COUNTER_HOSTS, + readFundsDocument, + writeFundsDocument, +} from './bearers' import {requiredValue, stubLocalStorage} from '../test-utils' import type {LocalStorageStub} from '../test-utils' import './bearers.baseline.cases' @@ -50,16 +57,159 @@ const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ ...overrides, }) +// a present-but-non-serializing LockManager fake: enough for single-writer +// tests that must not trip the no-locks refusal +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) +} + +type LockRequest = { + readonly name: string + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +// a controllable Web Locks fake: lock requests park until the test releases +// them, so a foreign write can interleave deterministically +class DeferredLocks { + readonly requests: LockRequest[] = [] + + 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.') + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } + } + + readonly stub = (): void => { + vi.stubGlobal('navigator', {locks: this}) + } +} + let stub: LocalStorageStub beforeEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() stub = stubLocalStorage() }) -const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => - spy.mock.calls.filter(([k]) => k === 'sattle_bearers') +const fundsWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => + spy.mock.calls.filter(([k]) => k === FUNDS_STORAGE_KEY) + +describe('stored funds document', () => { + it('persists one strict v2 document holding bearers, pending, counters and revision', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const doc: unknown = JSON.parse(raw) + expect(doc).toEqual({ + version: 2, + bearers: [expect.objectContaining({id: 'a'})], + pending: [], + nextByHost: {}, + revision: 1, + }) + // strict: exactly these five keys, and the legacy bearer key is gone + expect(Object.keys(requiredValue(doc as object)).sort()).toEqual([ + 'bearers', + 'nextByHost', + 'pending', + 'revision', + 'version', + ]) + expect(localStorage.getItem('sattle_bearers')).toBeNull() + }) + + it('bumps the revision on every committed write', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + await persistBearer(key, bearerFixture({id: 'b'})) + expect(readFundsDocument().revision).toBe(2) + }) + + it('reads work without Web Locks', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + vi.stubGlobal('navigator', {}) + + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['a']) + expect(await loadBearers(key)).toHaveLength(1) + expect(readFundsDocument().revision).toBe(1) + }) + + it('treats a malformed document as empty instead of throwing', () => { + localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify({version: 2, bearers: 'junk'})) + expect(readFundsDocument()).toEqual({ + version: 2, + bearers: [], + pending: [], + nextByHost: {}, + revision: 0, + }) + localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{') + expect(readFundsDocument().bearers).toEqual([]) + }) +}) + +describe('required locks', () => { + it('rejects a changeset mutation before any encryption when Web Locks are unavailable', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + vi.stubGlobal('navigator', {}) + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect( + applyBearerChangeset(key, [], {add: [newBearerFixture()], markSpent: []}), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('rejects per-record writes and reservations without Web Locks', async () => { + vi.stubGlobal('navigator', {}) + const key = await deriveBearerAesKey(LINKING_KEY) + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect(persistBearer(key, bearerFixture({id: 'a'}))).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ) + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 1}, () => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: {}, + })), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull() + }) +}) describe('applyBearerChangeset (single-write changeset commit)', () => { + beforeEach(() => { + stubPassthroughLocks() + }) + it('commits additions and spent replacements with exactly one write', async () => { const key = await deriveBearerAesKey(LINKING_KEY) const oldA = bearerFixture({id: 'old-a'}) @@ -83,8 +233,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { markSpent: ['old-a', 'old-b'], }) - // the whole changeset is ONE setItem on sattle_bearers - expect(bearerWrites(writes)).toHaveLength(1) + // the whole changeset is ONE setItem on the funds document + expect(fundsWrites(writes)).toHaveLength(1) // the returned next list: additions first, then the snapshot with spent // marks applied @@ -110,7 +260,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { 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')) + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) expect(raw).not.toContain(K1_C) expect(raw).not.toContain(K1_D) }) @@ -133,6 +283,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { it('persists nothing when encryption fails', async () => { const key = await deriveBearerAesKey(LINKING_KEY) await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) // a decrypt-only key makes every AES-GCM encrypt call reject const decryptOnly = await crypto.subtle.importKey( 'raw', @@ -150,7 +301,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }), ).rejects.toThrow() - expect(bearerWrites(writes)).toHaveLength(0) + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept']) }) @@ -158,30 +310,20 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { const key = await deriveBearerAesKey(LINKING_KEY) const kept = bearerFixture({id: 'kept'}) await persistBearer(key, kept) - const before = readEncryptedBearers() + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) 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() - } + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + + // nothing was persisted: the document is byte-identical + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) }) it('upserts changed ids and dedupes repeated markSpent ids', async () => { @@ -208,37 +350,19 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { 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))) - } - }, - }) - }), - }, - }) + const locks = new DeferredLocks() + locks.stub() try { const commit = applyBearerChangeset(key, [mine], { add: [newBearerFixture()], markSpent: [], }) - // encryption happens BEFORE the lock request; wait for it to arrive + // the lock request arrives before any inside-lock work await vi.waitFor(() => { - expect(queue).toHaveLength(1) + expect(locks.requests).toHaveLength(1) }) - const queuedLock = requiredValue(queue[0]) - expect(queuedLock.name).toBe('sattle_bearers') + const queuedLock = requiredValue(locks.requests[0]) + expect(queuedLock.name).toBe(FUNDS_STORAGE_KEY) // while our commit waits on the lock, another tab commits a record we // cannot even decrypt (written under a different seed's key) @@ -248,9 +372,11 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }) const {id: foreignId, ...foreignPlain} = foreign const foreignParts = await encryptRecord(other, foreignPlain) - writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]) + const doc = readFundsDocument() + doc.bearers.push({id: foreignId, ...foreignParts}) + writeFundsDocument(doc) - await queuedLock.release() + await locks.releaseNext() const result = await commit // the foreign ciphertext survived our upsert, untouched @@ -268,26 +394,71 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { } }) + it('aborts the whole write when the inside-lock fence throws', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + applyBearerChangeset( + key, + [], + {add: [newBearerFixture()], markSpent: []}, + { + beforeCommit: () => { + throw new Error('stale owner') + }, + }, + ), + ).rejects.toThrow('stale owner') + + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('clears pending journal records in the same write as the changeset', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const staged = await reserveCashIndices(key, {host: 'mint.example', count: 2}, () => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: {host: 'mint.example', start: 0, count: 2}, + })) + const writes = vi.spyOn(stub, 'setItem') + + await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: [], + clearPending: [staged.pendingId], + }) + + expect(fundsWrites(writes)).toHaveLength(1) + expect(readFundsDocument().pending).toEqual([]) + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 2}) + }) + 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 {{{') + localStorage.setItem(FUNDS_STORAGE_KEY, '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 + // readFundsDocument's contract: unparseable storage reads as the empty + // document - the changeset still commits and its single write replaces + // the corrupt blob with a valid one expect(result).toHaveLength(1) expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id]) + expect(readFundsDocument().version).toBe(2) }) 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 before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) const writes = vi.spyOn(stub, 'setItem') // 'foreign' is not in the caller's snapshot; deriving its spent copy @@ -299,7 +470,148 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }) expect(result).toEqual([]) - expect(bearerWrites(writes)).toHaveLength(0) - expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign']) + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) +}) + +describe('reserveCashIndices (counter reservation plus staging)', () => { + beforeEach(() => { + stubPassthroughLocks() + }) + + const stage = (reserved: {host: string; start: number; count: number}) => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: reserved, + }) + + it('reserves a range and stages one encrypted pending record in a single write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(stub, 'setItem') + + const reserved = await reserveCashIndices(key, {host: 'mint.example', count: 3}, stage) + + expect(reserved).toEqual({ + host: 'mint.example', + start: 0, + count: 3, + pendingId: expect.any(String), + }) + expect(fundsWrites(writes)).toHaveLength(1) + const doc = readFundsDocument() + expect(doc.nextByHost).toEqual({'mint.example': 3}) + expect(doc.revision).toBe(1) + expect(doc.pending).toHaveLength(1) + const record = requiredValue(doc.pending[0]) + expect(record.id).toBe(reserved.pendingId) + expect(record.kind).toBe('cash-allocation') + expect(record.phase).toBe('reserved') + expect(typeof record.iv).toBe('string') + expect(typeof record.ciphertext).toBe('string') + // the staged payload is ciphertext-only at rest + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + expect(raw).not.toContain('"start":0') + }) + + it('hands two same-host tabs disjoint ranges under a contended lock', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const locks = new DeferredLocks() + locks.stub() + + // both tabs request the same lock; B's callback must not run (and must + // not fresh-read) until A's reservation has fully landed + const tabA = reserveCashIndices(key, {host: 'mint.example', count: 3}, stage) + const tabB = reserveCashIndices(key, {host: 'mint.example', count: 2}, stage) + await vi.waitFor(() => expect(locks.requests).toHaveLength(2)) + + await locks.releaseNext() + // A committed; B is still parked on the lock with no counter consumed + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 3}) + await locks.releaseNext() + + const [a, b] = await Promise.all([tabA, tabB]) + expect(a.start).toBe(0) + expect(b.start).toBe(3) + const doc = readFundsDocument() + expect(doc.nextByHost).toEqual({'mint.example': 5}) + expect(doc.pending).toHaveLength(2) + }) + + it('rejects counter overflow without writing or encrypting', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const doc = readFundsDocument() + doc.nextByHost['mint.example'] = 2 ** 31 - 2 + writeFundsDocument(doc) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const writes = vi.spyOn(stub, 'setItem') + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 3}, stage), + ).rejects.toThrow(/exhausted|overflow/i) + + expect(fundsWrites(writes)).toHaveLength(0) + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('rejects an overlong host and an eleventh counter host', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const before = localStorage.getItem(FUNDS_STORAGE_KEY) + + await expect( + reserveCashIndices(key, {host: `${'h'.repeat(253)}.example`, count: 1}, stage), + ).rejects.toThrow(/host/i) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + + const doc = readFundsDocument() + for (let i = 0; i < MAX_COUNTER_HOSTS; i += 1) { + doc.nextByHost[`mint-${i}.example`] = 1 + } + writeFundsDocument(doc) + const filled = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + + await expect( + reserveCashIndices(key, {host: 'mint-overflow.example', count: 1}, stage), + ).rejects.toThrow(/host/i) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(filled) + }) + + it('burns no index and writes nothing when the staging encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const decryptOnly = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32).fill(3), + 'AES-GCM', + false, + ['decrypt'], + ) + + await expect( + reserveCashIndices(decryptOnly, {host: 'mint.example', count: 2}, stage), + ).rejects.toThrow() + + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + expect(readFundsDocument().nextByHost).toEqual({}) + expect(readFundsDocument().pending).toEqual([]) + }) + + it('writes nothing when the inside-lock fence throws before reservation', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 1}, stage, { + beforeCommit: () => { + throw new Error('stale owner') + }, + }), + ).rejects.toThrow('stale owner') + + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull() }) }) diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 8e9cdf4..4c1ee6e 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -1,13 +1,45 @@ -// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext -// record under a key derived from the linking key (see keys.ts), so a note -// URL - which IS the money - never touches disk in plaintext. +// allow: SIZE_OK — one document, one lock, one write path; splitting the +// schema from its commit primitive would invite drift between them. +// +// The funds document: every fund-critical fact lives in ONE localStorage +// value, `sattle_funds_v2` - encrypted bearer records, the pending-mutation +// journal (opaque encrypted records; the per-flow taxonomy is defined by the +// recovery seam, not here), the per-host BIP-32 next-unused cash indices, and +// a monotonic revision. A note URL - which IS the money - never touches disk +// in plaintext: bearers and journal payloads are AES-GCM ciphertexts under a +// key derived from the linking key (see keys.ts). +// +// Why one document: a counter bump without its staged outputs (or vice +// versa) is how indices get burned twice or fresh secrets get lost. Because +// everything shares one key, one locked fresh-read…write commits or fails as +// a unit, and one getItem is a consistent snapshot for backups. +// +// Mutation discipline (fund-critical, non-negotiable): +// - EVERY mutation runs inside withRequiredStorageLock on the document key: +// fresh-read the document, re-validate, run the caller's owner fence, +// reserve counter indices, derive/encrypt, then ONE setItem. There is no +// unlocked fallback - without Web Locks a second tab could interleave a +// fresh-read…write and burn the same BIP-32 index twice, so mutations fail +// closed (StorageLocksUnavailableError) before any derivation, encryption, +// or I/O. Reads never need the lock. +// - encryption happens INSIDE the lock: the staged payload depends on the +// reserved range, which only exists once the counter bump is serialized. +// Web Locks callbacks may be async; holding the lock across crypto costs +// latency but never correctness, and the alternative (encrypt first, +// re-read later) cannot express counter reservation at all. +// - any failure (crypto, quota, fence, overflow) rejects with the document +// bytes untouched and zero network I/O performed. import type {EncryptedRecordParts} from '../keys' import {encryptRecord, decryptRecord} from '../keys' import type {Bearer, NewBearer} from '../types' import {isJsonObject} from '../jsonParsing' import {noteK1, serverOf} from 'lnurlcash-kit' -import {withStorageLock} from '../storageLock' +import { + StorageLocksUnavailableError, + storageLocksAvailable, + withRequiredStorageLock, +} from '../storageLock' // the wallet's default note order (newest first) with manually dragged // notes taking priority once they have an explicit rank @@ -16,12 +48,48 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number => export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts +// the forward-compatible pending-mutation journal slot: an opaque encrypted +// payload behind a kind/phase pair. The per-flow variants (rotate, split, +// merge, mint, melt-return, transfer) are defined by the recovery seam; this +// layer only guarantees they persist atomically with everything else. +export type EncryptedJournalRecord = { + id: string + kind: string + phase: string +} & EncryptedRecordParts + +export const FUNDS_STORAGE_KEY = 'sattle_funds_v2' +export const FUNDS_DOCUMENT_VERSION = 2 as const + +// counter bounds: BIP-32 indices stay below the hardened offset; the host +// map stays small enough that a corrupt or hostile restore cannot exhaust +// quota or smuggle unbounded state (253 = DNS name ceiling) +export const MAX_CASH_INDEX = 2 ** 31 +export const MAX_COUNTER_HOSTS = 10 +export const MAX_COUNTER_HOST_LENGTH = 253 + +export type StoredFundsV2 = { + version: typeof FUNDS_DOCUMENT_VERSION + bearers: EncryptedBearerRecord[] + pending: EncryptedJournalRecord[] + nextByHost: Record + revision: number +} + const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord => isJsonObject(value) && typeof value.id === 'string' && typeof value.iv === 'string' && typeof value.ciphertext === 'string' +const isEncryptedJournalRecord = (value: unknown): value is EncryptedJournalRecord => + isEncryptedBearerRecord(value) && + typeof (value as EncryptedJournalRecord).kind === 'string' && + typeof (value as EncryptedJournalRecord).phase === 'string' + +const isValidCounter = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value < MAX_CASH_INDEX + const isStoredBearer = (value: unknown): value is Omit => isJsonObject(value) && typeof value.url === 'string' && @@ -34,32 +102,104 @@ const isStoredBearer = (value: unknown): value is Omit => (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' + (value.deviceId === undefined || typeof value.deviceId === 'string') && + (value.pendingMint === undefined || + (isJsonObject(value.pendingMint) && + (value.pendingMint.sourceBearerId === undefined || + typeof value.pendingMint.sourceBearerId === 'string') && + (value.pendingMint.sourceRecoverySecret === undefined || + typeof value.pendingMint.sourceRecoverySecret === 'string') && + (value.pendingMint.mintPubkey === undefined || + typeof value.pendingMint.mintPubkey === 'string') && + (value.pendingMint.retireAfter === undefined || + (typeof value.pendingMint.retireAfter === 'number' && + Number.isSafeInteger(value.pendingMint.retireAfter) && + value.pendingMint.retireAfter > 0)))) export const newBearerId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) .map((b) => b.toString(16).padStart(2, '0')) .join('') -export const readEncryptedBearers = (): EncryptedBearerRecord[] => { - const raw = localStorage.getItem(BEARERS_STORAGE_KEY) - if (!raw) return [] - try { - const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : [] - } catch { - return [] +const emptyFundsDocument = (): StoredFundsV2 => ({ + version: FUNDS_DOCUMENT_VERSION, + bearers: [], + pending: [], + nextByHost: {}, + revision: 0, +}) + +// strict shape, tolerant read: anything that is not a well-formed v2 +// document reads as the empty document (the long-standing "never throw on +// read" contract - a corrupt blob must not wedge the wallet), and malformed +// entries inside a valid document are dropped, never thrown on +const parseFundsDocument = (value: unknown): StoredFundsV2 | null => { + if (!isJsonObject(value) || value.version !== FUNDS_DOCUMENT_VERSION) return null + if (!Array.isArray(value.bearers) || !Array.isArray(value.pending)) return null + if (!isJsonObject(value.nextByHost)) return null + if ( + typeof value.revision !== 'number' || + !Number.isSafeInteger(value.revision) || + value.revision < 0 + ) { + return null + } + const nextByHost: Record = {} + for (const [host, next] of Object.entries(value.nextByHost)) { + if (host.length === 0 || host.length > MAX_COUNTER_HOST_LENGTH || !isValidCounter(next)) { + continue + } + nextByHost[host] = next + } + return { + version: FUNDS_DOCUMENT_VERSION, + bearers: value.bearers.filter(isEncryptedBearerRecord), + pending: value.pending.filter(isEncryptedJournalRecord), + nextByHost, + revision: value.revision, } } -export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { - localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) +export const readFundsDocument = (): StoredFundsV2 => { + const raw = localStorage.getItem(FUNDS_STORAGE_KEY) + if (!raw) return emptyFundsDocument() + try { + return parseFundsDocument(JSON.parse(raw)) ?? emptyFundsDocument() + } catch { + return emptyFundsDocument() + } } +export const writeFundsDocument = (doc: StoredFundsV2): void => { + localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc)) +} + +// COMPAT shim for the pre-counter backup path (storage/backup.ts before the +// backup-projection commit): replaces the document's bearer array wholesale, +// preserving pending journal and counters. Deleted together with that path's +// last call site; new code writes through the locked commit primitives only. +export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { + const doc = readFundsDocument() + doc.bearers = records + writeFundsDocument(doc) +} + +export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers + +export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending + +export const readNextByHost = (): Record => ({...readFundsDocument().nextByHost}) + +export const readFundsRevision = (): number => readFundsDocument().revision + type BearerCommitOptions = {beforeCommit?: () => void} +// mutations have no unlocked fallback: reject BEFORE any derivation or +// encryption when cross-tab serialization cannot be guaranteed +const requireMutationLocks = (): void => { + if (!storageLocksAvailable()) throw new StorageLocksUnavailableError() +} + // 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 @@ -83,13 +223,16 @@ export const persistBearer = async ( bearer: Bearer, options: BearerCommitOptions = {}, ): Promise => { + requireMutationLocks() const {id, ...plain} = bearer - const parts = await encryptRecord(aesKey, plain) - await withStorageLock(BEARERS_STORAGE_KEY, () => { + await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() options.beforeCommit?.() - const records = readEncryptedBearers().filter((r) => r.id !== id) - records.push({id, ...parts}) - writeEncryptedBearers(records) + const parts = await encryptRecord(aesKey, plain) + doc.bearers = doc.bearers.filter((r) => r.id !== id) + doc.bearers.push({id, ...parts}) + doc.revision += 1 + writeFundsDocument(doc) }) } @@ -97,51 +240,49 @@ export const deleteBearerRecord = async ( id: string, options: BearerCommitOptions = {}, ): Promise => { - await withStorageLock(BEARERS_STORAGE_KEY, () => { + requireMutationLocks() + await withRequiredStorageLock(FUNDS_STORAGE_KEY, () => { + const doc = readFundsDocument() options.beforeCommit?.() - writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id)) + doc.bearers = doc.bearers.filter((r) => r.id !== id) + doc.revision += 1 + writeFundsDocument(doc) }) } // 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. +// never the wallet's money in a trackable state. clearPending removes +// pending-journal records (by id) in the SAME write, which is how a +// finalized staged mutation retires its journal entry atomically with the +// bearer changes that settle it. export type BearerChangeset = { add: NewBearer[] markSpent: string[] upsert?: Bearer[] remove?: string[] + clearPending?: string[] } -// Commits a whole changeset as ONE storage write - the fund-critical +// Commits a whole changeset as ONE funds-document 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: +// success. 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) +// - the caller's snapshot is only a PLAN: inside the lock the document is +// re-read FRESH, so records another tab committed after the snapshot +// survive the upsert - changed ids replace their stored copy, everything +// else is kept as-is (unrelated records are never decrypted or +// re-encrypted) +// - added notes get their id/timestamps assigned HERE (so state and storage +// can never disagree about them), spent marks copy the snapshot's record +// - the owner fence (options.beforeCommit) runs INSIDE the lock after the +// fresh read and before any encryption: throwing aborts the commit with +// the document untouched // - 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 @@ -168,26 +309,33 @@ export const applyBearerChangeset = async ( } const upserted = changeset.upsert ?? [] const removedIds = new Set(changeset.remove ?? []) + const clearPendingIds = new Set(changeset.clearPending ?? []) 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, () => { + if (changed.length === 0 && removedIds.size === 0 && clearPendingIds.size === 0) return snapshot + requireMutationLocks() + await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() options.beforeCommit?.() + const encrypted: EncryptedBearerRecord[] = [] + for (const bearer of changed) { + const {id, ...plain} = bearer + const parts = await encryptRecord(aesKey, plain) + encrypted.push({id, ...parts}) + } const changedIds = new Set(encrypted.map((r) => r.id)) - const records = readEncryptedBearers().filter( + doc.bearers = doc.bearers.filter( (record) => !changedIds.has(record.id) && !removedIds.has(record.id), ) - records.push(...encrypted) - writeEncryptedBearers(records) + doc.bearers.push(...encrypted) + if (clearPendingIds.size > 0) { + doc.pending = doc.pending.filter((record) => !clearPendingIds.has(record.id)) + } + doc.revision += 1 + writeFundsDocument(doc) }) const snapshotIds = new Set(snapshot.map((bearer) => bearer.id)) const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id)) @@ -197,12 +345,131 @@ export const applyBearerChangeset = async ( return [...added, ...inserted, ...retained] } +// ---- counter reservation ---- + +export type FundsReservation = {host: string; count: number} +export type ReservedCashRange = {host: string; start: number; count: number} + +// what the caller wants staged for the reserved range - the payload is +// opaque to this layer (encrypted as-is); kind/phase let the recovery seam +// route it later +export type PendingStaging = {kind: string; phase: string; payload: object} + +const assertValidReservation = ({host, count}: FundsReservation): void => { + if (typeof host !== 'string' || host.length === 0 || host.length > MAX_COUNTER_HOST_LENGTH) { + throw new Error(`A counter host is a canonical server name of at most ${MAX_COUNTER_HOST_LENGTH} characters.`) + } + if (!Number.isSafeInteger(count) || count < 1 || count >= MAX_CASH_INDEX) { + throw new Error('A reservation count is a positive safe integer below 2^31.') + } +} + +// Reserves `count` fresh BIP-32 indices for a canonical host and stages one +// encrypted pending-journal record for them - allocation plus staging in ONE +// locked write, before any mutation may reach a mint. The reserved range is +// only known once the counter bump is serialized, so the caller's `stage` +// callback runs INSIDE the lock with the assigned range (that is where the +// secrets for [start, start+count) get derived and encrypted into the +// payload). Burned gaps are never reused: a crash after this commit leaves +// the counter advanced, which is safe - reuse never is. +// +// Rejects - with the document bytes untouched and no encryption performed - +// on a stale owner fence, an overlong host, an eleventh counter host, and +// counter exhaustion at the 2^31 BIP-32 ceiling. +export const reserveCashIndices = async ( + aesKey: CryptoKey, + reservation: FundsReservation, + stage: (reserved: ReservedCashRange) => PendingStaging, + options: BearerCommitOptions = {}, +): Promise => { + assertValidReservation(reservation) + requireMutationLocks() + const {host, count} = reservation + return withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() + options.beforeCommit?.() + const start = doc.nextByHost[host] ?? 0 + if (doc.nextByHost[host] === undefined) { + if (Object.keys(doc.nextByHost).length >= MAX_COUNTER_HOSTS) { + throw new Error( + `The wallet already tracks counters for ${MAX_COUNTER_HOSTS} mint hosts; no room for another.`, + ) + } + } + if (start + count >= MAX_CASH_INDEX) { + throw new Error('The cash index space for this mint host is exhausted.') + } + const staged = stage({host, start, count}) + if (typeof staged.kind !== 'string' || typeof staged.phase !== 'string') { + throw new Error('A staged pending record needs string kind and phase.') + } + const parts = await encryptRecord(aesKey, staged.payload) + const pendingId = newBearerId() + doc.pending.push({id: pendingId, kind: staged.kind, phase: staged.phase, ...parts}) + doc.nextByHost[host] = start + count + doc.revision += 1 + writeFundsDocument(doc) + return {host, start, count, pendingId} + }) +} + +export type FundsRestoreMerge = {added: number; skipped: number} + +// The funds half of a backup restore, as one locked write: bearer records +// union by id (already-present ids are left as-is, never overwritten) and +// counters merge upward-only - max(local, incoming) per host, so a stale +// backup can never rewind a counter and reopen burned indices. Local hosts +// always keep their slot; incoming hosts beyond the counter cap are skipped. +// Invalid incoming entries are skipped, never thrown on (same policy as the +// bearer records). Pending journal state is device-local and never part of +// a backup, so it is not a parameter here at all. +export const commitFundsRestore = async ( + bearersToMerge: EncryptedBearerRecord[], + countersToMerge: Record, +): Promise => { + const counters = Object.entries(countersToMerge).filter( + ([host, next]) => + host.length > 0 && host.length <= MAX_COUNTER_HOST_LENGTH && isValidCounter(next), + ) + if (bearersToMerge.length === 0 && counters.length === 0) { + return {added: 0, skipped: 0} + } + requireMutationLocks() + return withRequiredStorageLock(FUNDS_STORAGE_KEY, () => { + const doc = readFundsDocument() + const existingIds = new Set(doc.bearers.map((r) => r.id)) + let added = 0 + let skipped = 0 + for (const record of bearersToMerge) { + if (!isEncryptedBearerRecord(record) || existingIds.has(record.id)) { + skipped++ + continue + } + doc.bearers.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext}) + existingIds.add(record.id) + added++ + } + for (const [host, next] of counters) { + const local = doc.nextByHost[host] + if (local === undefined) { + if (Object.keys(doc.nextByHost).length >= MAX_COUNTER_HOSTS) continue + doc.nextByHost[host] = next + } else if (next > local) { + doc.nextByHost[host] = next + } + } + doc.revision += 1 + writeFundsDocument(doc) + return {added, skipped} + }) +} + // 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 // backup file can bring them back export const clearAllBearers = (): void => { - localStorage.removeItem(BEARERS_STORAGE_KEY) + localStorage.removeItem(FUNDS_STORAGE_KEY) } // Merge two decrypted bearer lists into one, keyed by note identity diff --git a/src/lnurlcash/storageLock.test.ts b/src/lnurlcash/storageLock.test.ts index a6a9afb..f976c44 100644 --- a/src/lnurlcash/storageLock.test.ts +++ b/src/lnurlcash/storageLock.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest' -import {withStorageLock} from './storageLock' +import {StorageLocksUnavailableError, withRequiredStorageLock, withStorageLock} from './storageLock' describe('withStorageLock fallback', () => { it('runs unlocked when Web Locks are unavailable without promising serialization', async () => { @@ -28,3 +28,39 @@ describe('withStorageLock fallback', () => { } }) }) + +describe('withRequiredStorageLock', () => { + it('rejects before running the callback when Web Locks are unavailable', async () => { + vi.stubGlobal('navigator', {}) + let ran = false + try { + await expect( + withRequiredStorageLock('funds', () => { + ran = true + }), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + expect(ran).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('serializes same-name callbacks across concurrent callers when locks exist', async () => { + const entered: string[] = [] + let releaseFirst: (() => void) | undefined + const first = withRequiredStorageLock('funds', async () => { + entered.push('first') + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + const second = withRequiredStorageLock('funds', () => { + entered.push('second') + }) + + await vi.waitFor(() => expect(entered).toEqual(['first'])) + releaseFirst?.() + await Promise.all([first, second]) + expect(entered).toEqual(['first', 'second']) + }) +}) diff --git a/src/lnurlcash/storageLock.ts b/src/lnurlcash/storageLock.ts index 16ff91b..c5024fc 100644 --- a/src/lnurlcash/storageLock.ts +++ b/src/lnurlcash/storageLock.ts @@ -13,3 +13,23 @@ export const withStorageLock = (name: string, fn: () => T | Promise): Prom export const storageLocksAvailable = (): boolean => typeof navigator !== 'undefined' && navigator.locks !== undefined + +// fund-critical mutations have no fallback: without Web Locks a second tab +// could interleave its own fresh-read…write between this tab's read and +// write and burn the same BIP-32 indices twice (or resurrect a spent note). +// So the funds document refuses to mutate at all where serialization cannot +// be guaranteed - reads stay available, writes fail closed before any key +// derivation, encryption, or network work has happened. +export class StorageLocksUnavailableError extends Error { + override readonly name = 'StorageLocksUnavailableError' + + constructor() { + super('This browser cannot guarantee exclusive storage access (Web Locks unavailable).') + } +} + +export const withRequiredStorageLock = (name: string, fn: () => T | Promise): Promise => { + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined + if (!locks) return Promise.reject(new StorageLocksUnavailableError()) + return locks.request(name, () => Promise.resolve().then(fn)) +} diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts index 0c989b2..5f53fae 100644 --- a/src/stores/walletFunds.atomic.test.ts +++ b/src/stores/walletFunds.atomic.test.ts @@ -1,13 +1,25 @@ import { createPinia, setActivePinia } from 'pinia'; -import { buildNoteUrl } from 'lnurlcash-kit'; +import { buildNoteUrl, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveBearerAesKey } from '@/lnurlcash/keys'; -import { loadBearers } from '@/lnurlcash/storage'; +import { loadBearers, readFundsDocument } from '@/lnurlcash/storage'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; +import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { useWalletStore } from './wallet'; +const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1)); + +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + const note = (secret: string): NewBearer => ({ url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), callback: 'https://mint.example/w/cb', @@ -29,12 +41,45 @@ const rejectSecondEncryption = (): void => { beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); }); describe('wallet multi-bearer durability', () => { + it('keeps both concurrent additions in reactive and persisted state', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let releaseFirst: (() => void) | undefined; + const firstPaused = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstStarted: (() => void) | undefined; + const firstEntered = new Promise((resolve) => { + firstStarted = resolve; + }); + let encryptions = 0; + vi.spyOn(crypto.subtle, 'encrypt').mockImplementation(async (algorithm, key, data) => { + encryptions += 1; + if (encryptions === 1) { + firstStarted?.(); + await firstPaused; + } + return encrypt(algorithm, key, data); + }); + + const first = wallet.addBearers([note('a')], wallet.captureOwnerFence()); + await firstEntered; + const second = wallet.addBearers([note('b')], wallet.captureOwnerFence()); + releaseFirst?.(); + await Promise.all([first, second]); + + expect(wallet.bearers).toHaveLength(2); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toHaveLength(2); + }); + it('adds two bearers with one established changeset write', async () => { const storage = stubLocalStorage(); const wallet = useWalletStore(); @@ -43,7 +88,7 @@ describe('wallet multi-bearer durability', () => { await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(2); }); @@ -105,7 +150,95 @@ describe('wallet multi-bearer durability', () => { await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence()); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(3); }); }); + +describe('wallet BIP-32 counter allocation', () => { + it('reserves a range, derives its secrets, and stages the journal record in one write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const revisionBefore = wallet.fundsRevision; + const writes = vi.spyOn(storage, 'setItem'); + + const allocated = await wallet.allocateCashSecrets( + CASH_ROOT, + 'mint.example', + 3, + wallet.captureOwnerFence(), + ); + + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); + expect(allocated.host).toBe('mint.example'); + expect(allocated.start).toBe(0); + expect(allocated.secrets).toEqual([ + deriveCashSecret(CASH_ROOT, 'mint.example', 0), + deriveCashSecret(CASH_ROOT, 'mint.example', 1), + deriveCashSecret(CASH_ROOT, 'mint.example', 2), + ]); + const doc = readFundsDocument(); + expect(doc.nextByHost).toEqual({ 'mint.example': 3 }); + expect(doc.pending).toHaveLength(1); + expect(doc.pending[0]?.id).toBe(allocated.pendingId); + expect(doc.pending[0]?.kind).toBe('cash-allocation'); + // counter-only commits are observable to the nostr-backup scheduler + expect(wallet.fundsRevision).toBeGreaterThan(revisionBefore); + // the staged secrets never touch disk in plaintext + const raw = storage.getItem(FUNDS_STORAGE_KEY) ?? ''; + for (const secret of allocated.secrets) expect(raw).not.toContain(secret); + }); + + it('never hands out the same index twice across sequential reservations', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const fence = wallet.captureOwnerFence(); + + const first = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence); + const second = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence); + const otherHost = await wallet.allocateCashSecrets(CASH_ROOT, 'other.example:9735', 1, fence); + + expect(first.start).toBe(0); + expect(second.start).toBe(2); + expect(second.secrets[0]).toBe(deriveCashSecret(CASH_ROOT, 'mint.example', 2)); + // a different canonical host (port included) has its own counter + expect(otherHost.start).toBe(0); + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 4, + 'other.example:9735': 1, + }); + }); + + it('burns no index and writes nothing when the staging encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const before = readFundsDocument().revision; + // the reservation performs exactly one encryption (the staged payload) - + // reject them all + vi.spyOn(crypto.subtle, 'encrypt').mockRejectedValue(new Error('crypto subsystem died')); + + await expect( + wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence()), + ).rejects.toThrow('crypto subsystem died'); + + const doc = readFundsDocument(); + expect(doc.nextByHost).toEqual({}); + expect(doc.pending).toEqual([]); + expect(doc.revision).toBe(before); + }); + + it('rejects the allocation before derivation when Web Locks are unavailable', async () => { + const wallet = useWalletStore(); + await wallet.create(); + vi.stubGlobal('navigator', {}); + const encrypt = vi.spyOn(crypto.subtle, 'encrypt'); + + await expect( + wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 1, wallet.captureOwnerFence()), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError); + + expect(encrypt).not.toHaveBeenCalled(); + expect(readFundsDocument().nextByHost).toEqual({}); + }); +}); diff --git a/src/stores/walletFunds.fencing.test.ts b/src/stores/walletFunds.fencing.test.ts index 3da2409..000cf4c 100644 --- a/src/stores/walletFunds.fencing.test.ts +++ b/src/stores/walletFunds.fencing.test.ts @@ -3,14 +3,24 @@ 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 { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; +import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock'; 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)); +// fund commits REQUIRE Web Locks now; tests that don't care about lock +// timing install a present-but-non-serializing fake +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + const note = (secret: string): NewBearer => ({ url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), callback: 'https://mint.example/w/cb', @@ -71,7 +81,7 @@ beforeEach(() => { 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', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -93,7 +103,7 @@ describe('stale-owner fencing of fund commits', () => { 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', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -114,7 +124,7 @@ describe('stale-owner fencing of fund commits', () => { 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', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -143,7 +153,7 @@ describe('stale-owner fencing of fund commits', () => { 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', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create('password'); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -164,7 +174,7 @@ describe('stale-owner fencing of fund commits', () => { }); it('revalidates a spent update inside its persistence lock', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -185,7 +195,7 @@ describe('stale-owner fencing of fund commits', () => { }); it('revalidates a deletion inside its persistence lock', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -203,4 +213,33 @@ describe('stale-owner fencing of fund commits', () => { expect(readEncryptedBearers()).toHaveLength(1); expect(wallet.bearers).toHaveLength(1); }); + + it('refuses a fund commit entirely when Web Locks are unavailable', async () => { + // Given an unlocked wallet with one bearer, in a browser without Web Locks + stubPassthroughLocks(); + 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.'); + const bytesBefore = localStorage.getItem(FUNDS_STORAGE_KEY); + + // When the environment cannot serialize storage access across tabs + vi.stubGlobal('navigator', {}); + + // Then every mutation rejects before derivation, and the document is + // byte-identical - a no-lock commit could burn a BIP-32 index twice + await expect( + wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError); + await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ); + await expect(wallet.removeNote(existing.id, ownerFence)).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ); + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(bytesBefore); + expect(readFundsDocument().revision).toBe(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + }); }); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index 32ac47b..bd97251 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,17 +1,21 @@ +// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. import { computed, ref } from 'vue'; -import { serverOf } from 'lnurlcash-kit'; +import { deriveCashSecret, serverOf } from 'lnurlcash-kit'; +import type { CashNode } from 'lnurlcash-kit'; import { applyBearerChangeset, deleteBearerRecord, - loadBearers, mergeBearers, persistBearer, + readFundsRevision, + reserveCashIndices, } 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 { CarveResult } from '@/lnurlcash/ops'; import type { WalletOwnerFence } from './walletOwnerFence'; export class TrustedMintPostCommitError extends Error { @@ -29,6 +33,24 @@ export class TrustedMintPostCommitError extends Error { } } +export class BearerNotFoundError extends Error { + override readonly name = 'BearerNotFoundError'; + + constructor() { + super('The wallet record no longer exists.'); + } +} + +// the result of one atomic counter reservation: the assigned BIP-32 index +// range, the secrets derived at those indices, and the staged journal record +// that makes the reservation recoverable after a crash +export type AllocatedCashSecrets = { + readonly host: string; + readonly start: number; + readonly secrets: string[]; + readonly pendingId: string; +}; + type WalletFundsOptions = { readonly requireKey: () => CryptoKey; readonly ownerId: () => string | undefined; @@ -37,6 +59,25 @@ type WalletFundsOptions = { export const createWalletFunds = (options: WalletFundsOptions) => { const bearers = ref([]); + // reactive mirror of the persisted document revision: counter-only commits + // (reservations) don't touch the bearer list, so nostr backup scheduling + // watches this instead of inferring change from the notes alone + const fundsRevision = ref(0); + const syncRevision = (): void => { + fundsRevision.value = readFundsRevision(); + }; + let mutationTail: Promise = Promise.resolve(); + const mutate = (operation: () => Promise): Promise => { + const result = mutationTail.then(operation, operation); + mutationTail = result.then( + () => undefined, + () => undefined, + ); + // a post-commit failure (trusted-mint update) still bumped the persisted + // revision, so refresh on settle, not just on success + void result.then(syncRevision, syncRevision); + return result; + }; const unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent)); const balanceMsat = computed(() => unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0), @@ -60,10 +101,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const replace = (loaded: Bearer[]): void => { bearers.value = loaded; + syncRevision(); }; const clear = (): void => { bearers.value = []; + fundsRevision.value = 0; }; const lockCommittedBearers = async (committed: Bearer[]): Promise => { @@ -81,63 +124,87 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } }; - 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 addBearers = async (notes: NewBearer[], ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + 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, + ): Promise => + mutate(async () => { + 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 commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + const existing = bearers.value.find((bearer) => bearer.url === carve.note.url); + const additions: NewBearer[] = []; + if (!existing) additions.push(carve.note); + if (carve.change) additions.push(carve.change); + ownerFence(); + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + }, + { beforeCommit: ownerFence }, + ); + const added = next.slice(0, additions.length); + bearers.value = next; + await lockCommittedBearers(added); + const committed = existing ?? added[0]; + if (!committed) throw new Error('The carved note was not tracked.'); + return committed; }); - 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; - } - }; + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + 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, @@ -147,40 +214,80 @@ export const createWalletFunds = (options: WalletFundsOptions) => { 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 removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + 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; - }; + ): Promise => + mutate(async () => { + 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()); - }; + // Reserves `count` fresh BIP-32 cash secrets for a canonical mint host and + // stages the reservation in the pending journal - allocation plus staging + // land in ONE locked document write (see lnurlcash/storage/bearers.ts), so + // a crash after this resolves can neither double-spend an index nor lose + // the staged secrets. The cash root stays a caller-supplied parameter: this + // store never holds key material it doesn't need persistently. Secrets are + // derived inside the storage lock (the range only exists once serialized) + // and returned to the caller; they are never logged. + const allocateCashSecrets = ( + cashRoot: CashNode, + host: string, + count: number, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + ownerFence(); + let secrets: string[] = []; + const reserved = await reserveCashIndices( + options.requireKey(), + { host, count }, + ({ host: reservedHost, start, count: reservedCount }) => { + secrets = Array.from({ length: reservedCount }, (_, offset) => + deriveCashSecret(cashRoot, reservedHost, start + offset), + ); + return { + kind: 'cash-allocation', + phase: 'reserved', + payload: { host: reservedHost, start, count: reservedCount, secrets }, + }; + }, + { beforeCommit: ownerFence }, + ); + return { + host: reserved.host, + start: reserved.start, + secrets, + pendingId: reserved.pendingId, + }; + }); return { public: { bearers, + fundsRevision, unspentBearers, balanceMsat, balanceSats, @@ -188,11 +295,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => { balanceByMintSats, addBearers, applyChangeset, + commitCarve, updateBearer, + allocateCashSecrets, markSpent, removeNote, mergeExternalBearers, - reloadBearers, }, replace, clear, From 641608eff07464613570818cf1463ca28702d7c7 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 08:22:21 +0200 Subject: [PATCH 10/29] feat(backup): preserve BIP-32 counters --- src/components/welcome/WelcomeNostrPanel.vue | 2 +- src/lnurlcash/nostr/events.ts | 54 ++++++--- src/lnurlcash/nostr/sync.ts | 17 +-- src/lnurlcash/nostrBackup.codec.cases.ts | 27 ++++- src/lnurlcash/nostrBackup.restore.cases.ts | 56 +++++++++- src/lnurlcash/nostrBackup.transport.cases.ts | 5 +- src/lnurlcash/storage.backup.test.ts | 111 ++++++++++++++++++- src/lnurlcash/storage/backup.ts | 78 ++++++++----- src/lnurlcash/storage/bearers.ts | 10 -- src/stores/nostrBackup.test.ts | 68 ++++++++++++ src/stores/nostrBackup.ts | 22 +++- 11 files changed, 374 insertions(+), 76 deletions(-) create mode 100644 src/stores/nostrBackup.test.ts diff --git a/src/components/welcome/WelcomeNostrPanel.vue b/src/components/welcome/WelcomeNostrPanel.vue index a0ee4a4..ec0d70b 100644 --- a/src/components/welcome/WelcomeNostrPanel.vue +++ b/src/components/welcome/WelcomeNostrPanel.vue @@ -131,7 +131,7 @@ const lookForBackup = async (): Promise => { looked.value = true; if (parts.notes || parts.mints || parts.settings) { found.value = { - notes: parts.notes?.length ?? 0, + notes: parts.notes?.bearers.length ?? 0, mints: parts.mints?.length ?? 0, settings: parts.settings !== undefined, }; diff --git a/src/lnurlcash/nostr/events.ts b/src/lnurlcash/nostr/events.ts index bffeae5..a45e915 100644 --- a/src/lnurlcash/nostr/events.ts +++ b/src/lnurlcash/nostr/events.ts @@ -9,11 +9,14 @@ // // Three d-tags carry three payloads in separate replaceable slots, so a // notes publish never clobbers settings: -// notes - the encrypted bearer records exactly as they sit in -// localStorage (each note is already an AES-GCM ciphertext -// under the seed-derived bearer key, so the blob goes up -// as-is; the linking key itself is NEVER part of a payload - -// the seed phrase is its recovery path) +// notes - the funds projection: encrypted bearer records exactly as +// they sit in the funds document (each note is already an +// AES-GCM ciphertext under the seed-derived bearer key, so the +// blob goes up as-is; the linking key itself is NEVER part of a +// payload - the seed phrase is its recovery path) plus the +// per-host BIP-32 next-unused counters, so a restored device +// never reissues a burned index. Pending journal state is +// device-local recovery data and is never published. // mints - the trusted-mint registry // settings - plaintext wallet settings @@ -46,9 +49,16 @@ export const BACKUP_D_TAGS: Record = { settings: 'settings', } +// the notes payload: bearers plus the counter map, from one consistent +// funds-document read +export type NotesBackupPayload = { + bearers: EncryptedBearerRecord[] + nextByHost: Record +} + // the decrypted payload of each part, without its envelope export type BackupPartPayload = { - notes: EncryptedBearerRecord[] + notes: NotesBackupPayload mints: TrustedMint[] settings: WalletSettings } @@ -77,10 +87,21 @@ const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' +// per-part envelope versions: the notes part went v2 with the funds +// document (counters joined the payload - a breaking change); mints and +// settings envelopes are unchanged +const PART_ENVELOPE_VERSION: Record = { + notes: 2, + mints: 1, + settings: 1, +} + const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { switch (part) { - case 'notes': - return JSON.stringify({version: 1, bearers: payload}) + case 'notes': { + const notes = payload as NotesBackupPayload + return JSON.stringify({version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost}) + } case 'mints': return JSON.stringify({version: 1, trustedMints: payload}) case 'settings': @@ -122,18 +143,18 @@ export const buildBackupEvents = ( } export type ParsedBackupEvent = - | {part: 'notes'; bearers: EncryptedBearerRecord[]} + | {part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record} | {part: 'mints'; trustedMints: TrustedMint[]} | {part: 'settings'; settings: WalletSettings} // Light shape checks so parse returns typed values; the strict bounds -// (record counts, field lengths, pubkey patterns) are enforced by -// applyBackup / mergeTrustedMints on the restore path, same as file +// (record counts, field lengths, counter ranges and caps) are enforced by +// applyBackup / commitFundsRestore on the restore path, same as file // backups. 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 + if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null switch (dTag) { case 'notes': { if (!Array.isArray(envelope.bearers)) return null @@ -148,7 +169,14 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null ) { return null } - return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]} + if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null + const counters = envelope.nextByHost as Record + if (!Object.values(counters).every((v) => typeof v === 'number')) return null + return { + part: 'notes', + bearers: bearers as EncryptedBearerRecord[], + nextByHost: counters as Record, + } } case 'mints': { if (!Array.isArray(envelope.trustedMints)) return null diff --git a/src/lnurlcash/nostr/sync.ts b/src/lnurlcash/nostr/sync.ts index 67515cb..cb34d08 100644 --- a/src/lnurlcash/nostr/sync.ts +++ b/src/lnurlcash/nostr/sync.ts @@ -2,11 +2,11 @@ // // v1 is single-device last-writer-wins at the relay (addressable events // replace). Restore merges locally through storage/backup.ts's applyBackup -// - the SAME entry point as file restore (union by record id, mints merged -// unconfirmed, settings fill-only) - so the two restore paths can't drift -// apart. The note-level dedupe (same note under different record ids, -// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly -// as with a file backup. +// - the SAME entry point as file restore (union by record id, counters +// merged upward-only, mints merged unconfirmed, settings fill-only) - so the +// two restore paths can't drift apart. The note-level dedupe (same note +// under different record ids, spent-wins) happens after decrypt in +// bearers.ts's mergeBearers, exactly as with a file backup. import type {RestoreResult} from '../storage/backup' import {applyBackup} from '../storage/backup' @@ -94,7 +94,7 @@ export const fetchBackup = async ( if (!parsed) continue switch (parsed.part) { case 'notes': - parts.notes = parsed.bearers + parts.notes = {bearers: parsed.bearers, nextByHost: parsed.nextByHost} break case 'mints': parts.mints = parsed.trustedMints @@ -134,9 +134,10 @@ export const restoreFromNostr = async ( const result = await applyBackup( { type: 'sattle-backup', - version: 1, + version: 2, createdAt: Date.now(), - bearers: parts.notes ?? [], + bearers: parts.notes?.bearers ?? [], + nextByHost: parts.notes?.nextByHost ?? {}, trustedMints: parts.mints, settings: parts.settings, }, diff --git a/src/lnurlcash/nostrBackup.codec.cases.ts b/src/lnurlcash/nostrBackup.codec.cases.ts index 8d84386..c6c8527 100644 --- a/src/lnurlcash/nostrBackup.codec.cases.ts +++ b/src/lnurlcash/nostrBackup.codec.cases.ts @@ -103,11 +103,12 @@ describe('buildBackupEvent / parseBackupEvent', () => { // assertion below {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, ] + const notes = {bearers: records, nextByHost: {'mint.example': 3}} 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) + const events = buildBackupEvents(secretKey, {notes, mints, settings}, 1000) expect(events).toHaveLength(3) expect(events.map((e) => e.kind)).toEqual([ BACKUP_EVENT_KIND, @@ -124,6 +125,7 @@ describe('buildBackupEvent / parseBackupEvent', () => { expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({ part: 'notes', bearers: records, + nextByHost: {'mint.example': 3}, }) expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ part: 'mints', @@ -136,7 +138,7 @@ describe('buildBackupEvent / parseBackupEvent', () => { }) it('leaves no plaintext in the payload', () => { - const event = buildBackupEvent(secretKey, 'notes', records) + const event = buildBackupEvent(secretKey, 'notes', notes) expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') expect(event.content).not.toContain('ciphertext') }) @@ -188,4 +190,25 @@ describe('buildBackupEvent / parseBackupEvent', () => { ) expect(parseBackupEvent(secretKey, event)).toBeNull() }) + + it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => { + const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) + const build = (payload: unknown): NostrEvent => + finalizeEvent( + { + kind: BACKUP_EVENT_KIND, + created_at: 1000, + tags: [['d', 'notes']], + content: nip44v2.encrypt(JSON.stringify(payload), conversationKey), + }, + secretKey, + ) + // v1 notes carried bare bearer arrays and no counters - pre-funds-document + expect(parseBackupEvent(secretKey, build({version: 1, bearers: records}))).toBeNull() + expect(parseBackupEvent(secretKey, build({version: 2, bearers: records}))).toBeNull() + // a v2 envelope whose counters are malformed is rejected, not coerced + expect( + parseBackupEvent(secretKey, build({version: 2, bearers: records, nextByHost: 'junk'})), + ).toBeNull() + }) }) diff --git a/src/lnurlcash/nostrBackup.restore.cases.ts b/src/lnurlcash/nostrBackup.restore.cases.ts index fc394e7..35ea094 100644 --- a/src/lnurlcash/nostrBackup.restore.cases.ts +++ b/src/lnurlcash/nostrBackup.restore.cases.ts @@ -30,7 +30,9 @@ import { persistBearer, persistSettings, readEncryptedBearers, + readNextByHost, } from './storage' +import {readFundsDocument, writeFundsDocument} from './storage/bearers' import type {Bearer} from './types' import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import {requiredValue, stubLocalStorage} from './test-utils' @@ -108,7 +110,7 @@ describe('restoreFromNostr', () => { await publishBackup( secretKey, { - notes: readEncryptedBearers(), + notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, mints: readTrustedMints(OWNER_ID), settings: loadSettings(), }, @@ -145,7 +147,7 @@ describe('restoreFromNostr', () => { // device A publishes its store holding the spendable note await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { transport, createdAt: 1000, }) @@ -155,7 +157,7 @@ describe('restoreFromNostr', () => { stubLocalStorage() await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { transport, createdAt: 2000, }) @@ -190,7 +192,7 @@ describe('restoreFromNostr', () => { persistSettings({defaultMint: 'remote.example'}) await publishBackup( secretKey, - {notes: readEncryptedBearers(), settings: loadSettings()}, + {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, settings: loadSettings()}, RELAYS, {transport, createdAt: 1000}, ) @@ -220,4 +222,50 @@ describe('restoreFromNostr', () => { expect(result.found).toEqual([]) expect(result.added).toBe(0) }) + + it('merges relayed counters upward-only and never carries pending state', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A: counter at 5 for mint.example, one note, all published + await persistBearer(aesKey, bearerFixture({id: 'note-a'})) + writeFundsDocument({ + version: 2, + bearers: readEncryptedBearers(), + pending: [ + {id: 'device-local-pending', kind: 'cash-allocation', phase: 'reserved', iv: '00', ciphertext: '00'}, + ], + nextByHost: {'mint.example': 5}, + revision: 3, + }) + await publishBackup( + secretKey, + {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, + RELAYS, + {transport, createdAt: 1000}, + ) + + // device B: same seed, a HIGHER local counter for one host and a lower + // incoming one for the other; the pending record must not cross over + stubLocalStorage() + writeFundsDocument({ + version: 2, + bearers: [], + pending: [], + nextByHost: {'mint.example': 2, 'local-only.example': 7}, + revision: 1, + }) + + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + expect(result.found).toEqual(['notes']) + expect(result.added).toBe(1) + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 5, + 'local-only.example': 7, + }) + expect(readFundsDocument().pending).toEqual([]) + expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) + }) }) diff --git a/src/lnurlcash/nostrBackup.transport.cases.ts b/src/lnurlcash/nostrBackup.transport.cases.ts index ab3873f..dfc3839 100644 --- a/src/lnurlcash/nostrBackup.transport.cases.ts +++ b/src/lnurlcash/nostrBackup.transport.cases.ts @@ -100,7 +100,10 @@ describe('publishBackup / fetchBackup', () => { 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)}], + notes: { + bearers: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], + nextByHost: {'mint.example': 2}, + }, mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}], settings: {defaultMint: 'mint.example'}, } diff --git a/src/lnurlcash/storage.backup.test.ts b/src/lnurlcash/storage.backup.test.ts index 1c10fce..5d7926f 100644 --- a/src/lnurlcash/storage.backup.test.ts +++ b/src/lnurlcash/storage.backup.test.ts @@ -1,12 +1,16 @@ // Backup restore waits for owner-bound trusted-mint convergence before it -// reports success, while retaining the hostile-file merge policy. +// reports success, while retaining the hostile-file merge policy. The v2 +// format also projects the BIP-32 counters: backups carry nextByHost, +// restore merges upward-only, and pending journal state never leaves the +// device. import {beforeEach, describe, expect, it, vi} from 'vitest' import {linkingPubKeyHex, saveLinkingKey} from './keys' -import {applyBackup, buildBackup} from './storage' +import {applyBackup, buildBackup, parseBackupFile} from './storage' +import {readFundsDocument, writeFundsDocument} from './storage/bearers' import {addTrustedMint, readTrustedMints} from './trustedMints' -import {stubLocalStorage} from './test-utils' +import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) const OWNER_ID = linkingPubKeyHex(LINKING_KEY) @@ -40,9 +44,10 @@ class DeferredLocks { const backup = (server: string, mintPubkey: string) => ({ type: 'sattle-backup' as const, - version: 1 as const, + version: 2 as const, createdAt: 1, bearers: [], + nextByHost: {}, trustedMints: [ { server, @@ -153,3 +158,101 @@ describe('owner-bound backup restore', () => { ]) }) }) + +describe('backup counter projection', () => { + const fundsDoc = (nextByHost: Record) => ({ + version: 2 as const, + bearers: [] as {id: string; iv: string; ciphertext: string}[], + pending: [] as {id: string; kind: string; phase: string; iv: string; ciphertext: string}[], + nextByHost, + revision: 1, + }) + + it('exports the counters and never the pending journal', () => { + const doc = fundsDoc({'mint.example': 5}) + doc.pending.push({ + id: 'pending-sentinel-never-exported', + kind: 'cash-allocation', + phase: 'reserved', + iv: '00', + ciphertext: '00', + }) + writeFundsDocument(doc) + + const built = buildBackup(OWNER_ID) + + expect(built.version).toBe(2) + expect(built.nextByHost).toEqual({'mint.example': 5}) + expect('pending' in built).toBe(false) + expect(JSON.stringify(built)).not.toContain('pending-sentinel-never-exported') + }) + + it('round-trips counters through a restore and merges upward-only', async () => { + writeFundsDocument(fundsDoc({'mint.example': 5, 'other.example': 2})) + + const result = await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: {'mint.example': 3, 'other.example': 9, 'new.example': 4}, + }) + + expect(result.added).toBe(0) + // lower incoming values never rewind a counter; higher ones advance it; + // unknown hosts adopt the backup's value + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 5, + 'other.example': 9, + 'new.example': 4, + }) + }) + + it('skips out-of-bounds counter entries instead of rejecting the restore', async () => { + writeFundsDocument(fundsDoc({'mint.example': 5})) + + await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: { + [`${'h'.repeat(254)}`]: 9, + 'overflow.example': 2 ** 31, + 'negative.example': -1, + 'fractional.example': 1.5, + 'valid.example': 7, + }, + }) + + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 5, 'valid.example': 7}) + }) + + it('never grows the counter map beyond ten hosts on restore', async () => { + const incoming: Record = {} + for (let i = 0; i < 12; i += 1) incoming[`mint-${i}.example`] = i + 1 + + await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: incoming, + }) + + const merged = readFundsDocument().nextByHost + expect(Object.keys(merged)).toHaveLength(10) + }) + + it('rejects a legacy version-1 backup file', () => { + expect(() => + parseBackupFile({type: 'sattle-backup', version: 1, createdAt: 1, bearers: []}), + ).toThrow('Not a valid sattle backup file.') + }) + + it('rejects a v2 file without a counter map', () => { + expect(() => + parseBackupFile({type: 'sattle-backup', version: 2, createdAt: 1, bearers: []}), + ).toThrow('Not a valid sattle backup file.') + }) +}) diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts index c277264..f768ff4 100644 --- a/src/lnurlcash/storage/backup.ts +++ b/src/lnurlcash/storage/backup.ts @@ -1,8 +1,14 @@ // Backup files: everything exactly as it sits in localStorage - bearer -// ciphertexts always, the linking-key record only when it is itself -// password-encrypted. A plaintext linking key never leaves the device in a -// backup; the seed phrase is the recovery path for it instead. Trusted -// mints are plain (not secret - a mintPubkey is public), included as-is. +// ciphertexts and the BIP-32 counter map always, the linking-key record only +// when it is itself password-encrypted. A plaintext linking key never leaves +// the device in a backup; the seed phrase is the recovery path for it +// instead. Trusted mints are plain (not secret - a mintPubkey is public), +// included as-is. The pending-mutation journal is device-local recovery +// state and is deliberately NOT part of any backup. +// +// Format v2 (breaking): the funds document made bearers + counters one +// atomic unit, so the backup projects both from the same single read - +// they can never disagree about which generation they came from. import type {StoredSecret} from '../keys' import { @@ -16,39 +22,45 @@ 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 {commitFundsRestore, readFundsDocument} from './bearers' import type {WalletSettings} from './settings' import {loadSettings, persistSettings} from './settings' import {isJsonObject} from '../jsonParsing' export type BackupFile = { type: 'sattle-backup' - version: 1 + version: 2 createdAt: number ownerId?: unknown linkingKey?: StoredSecret bearers: EncryptedBearerRecord[] + nextByHost: Record trustedMints?: TrustedMint[] settings?: WalletSettings } type ParsedBackupFile = { type: 'sattle-backup' - version: 1 + version: 2 createdAt?: unknown ownerId?: unknown linkingKey?: unknown bearers: unknown[] + nextByHost: Record trustedMints?: unknown settings?: unknown } export const buildBackup = (ownerId?: string): BackupFile => { + // one document, one getItem: bearers and counters are snapshot-consistent + // by construction; pending journal records are never read here + const funds = readFundsDocument() const backup: BackupFile = { type: 'sattle-backup', - version: 1, + version: 2, createdAt: Date.now(), - bearers: readEncryptedBearers(), + bearers: funds.bearers, + nextByHost: funds.nextByHost, trustedMints: readTrustedMints(ownerId), settings: loadSettings(), } @@ -91,8 +103,9 @@ const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 const isBackupFile = (data: unknown): data is ParsedBackupFile => isJsonObject(data) && data.type === 'sattle-backup' && - data.version === 1 && - Array.isArray(data.bearers) + data.version === 2 && + Array.isArray(data.bearers) && + isJsonObject(data.nextByHost) export const parseBackupFile = (data: unknown): ParsedBackupFile => { if (!isBackupFile(data)) { @@ -102,7 +115,10 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => { } // merges a backup into localStorage: bearer records are added by id -// (already present ids are left as-is - union, never overwrite), the +// (already present ids are left as-is - union, never overwrite) and counter +// entries merge upward-only (max per host - a stale backup must never rewind +// a BIP-32 counter and reopen burned indices), both inside ONE locked funds +// document write (commitFundsRestore enforces the counter bounds). The // backup's linking key is only installed when this device has none yet - // never overwriting an existing wallet. That guard is deliberate (a // stale/wrong backup must never clobber a wallet already holding funds), @@ -114,14 +130,12 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => { // decrypt, in bearers.ts's mergeBearers. 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)) 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.`, ) } - let added = 0 + const incomingBearers: EncryptedBearerRecord[] = [] let skipped = 0 for (const record of backup.bearers) { if ( @@ -136,20 +150,30 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise = {} + for (const [host, next] of Object.entries(backup.nextByHost)) { + if (typeof next === 'number') incomingCounters[host] = next + } + + let added: number try { - 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.', - ) + const merged = await commitFundsRestore(incomingBearers, incomingCounters) + added = merged.added + skipped += merged.skipped + } catch (error) { + const name = error instanceof Error ? error.name : '' + const message = error instanceof Error ? error.message : '' + if (name === 'QuotaExceededError' || /quota|full/i.test(message)) { + throw new Error( + 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', + {cause: error}, + ) + } + throw error } let linkingKeyRestored = false diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 4c1ee6e..a26c33d 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -174,16 +174,6 @@ export const writeFundsDocument = (doc: StoredFundsV2): void => { localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc)) } -// COMPAT shim for the pre-counter backup path (storage/backup.ts before the -// backup-projection commit): replaces the document's bearer array wholesale, -// preserving pending journal and counters. Deleted together with that path's -// last call site; new code writes through the locked commit primitives only. -export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { - const doc = readFundsDocument() - doc.bearers = records - writeFundsDocument(doc) -} - export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending diff --git a/src/stores/nostrBackup.test.ts b/src/stores/nostrBackup.test.ts new file mode 100644 index 0000000..db94d1b --- /dev/null +++ b/src/stores/nostrBackup.test.ts @@ -0,0 +1,68 @@ +// The nostr-backup store's scheduling surface: a counter-only funds commit +// (a BIP-32 reservation that adds no bearer) must still schedule a publish, +// and the published notes payload carries bearers plus counters - never the +// device-local pending journal. The engine's publish is mocked; everything +// below it (debounce, wallet wiring, payload assembly) is real. + +import { createPinia, setActivePinia } from 'pinia'; +import { deriveCashRoot } from 'lnurlcash-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { publishBackup } from '@/lnurlcash/nostrBackup'; +import type * as NostrBackupEngine from '@/lnurlcash/nostrBackup'; +import { readFundsDocument } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { useNostrBackupStore } from './nostrBackup'; +import { useWalletStore } from './wallet'; + +vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, publishBackup: vi.fn().mockResolvedValue({ published: [] }) }; +}); + +const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1)); + +const publishCalls = (): { parts: { notes?: { nextByHost: Record } } }[] => + vi.mocked(publishBackup).mock.calls.map((call) => ({ parts: call[1] })); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(publishBackup).mockResolvedValue({ published: [] }); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('counter-aware backup scheduling', () => { + it('schedules a publish for a counter-only reservation and includes the counters', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const backup = useNostrBackupStore(); + + vi.useFakeTimers(); + backup.setEnabled(true); + // the initial activation publish drains first + await vi.advanceTimersByTimeAsync(5000); + expect(publishCalls()).toHaveLength(1); + vi.mocked(publishBackup).mockClear(); + + // a reservation changes no bearer - only the counter map and the pending + // journal - yet it must still schedule a publish + await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence()); + await vi.advanceTimersByTimeAsync(5000); + + const calls = publishCalls(); + expect(calls).toHaveLength(1); + expect(calls[0]?.parts.notes?.nextByHost).toEqual({ 'mint.example': 2 }); + // the pending journal never leaves the device + expect(JSON.stringify(calls[0]?.parts)).not.toContain('cash-allocation'); + expect(readFundsDocument().pending).toHaveLength(1); + }); +}); diff --git a/src/stores/nostrBackup.ts b/src/stores/nostrBackup.ts index 7f27ebd..7069c05 100644 --- a/src/stores/nostrBackup.ts +++ b/src/stores/nostrBackup.ts @@ -9,7 +9,7 @@ import { publishBackup, } from '@/lnurlcash/nostrBackup'; import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup'; -import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage'; +import { loadSettings, persistSettings, readFundsDocument } from '@/lnurlcash/storage'; import { readTrustedMints } from '@/lnurlcash/trustedMints'; import { useWalletStore } from './wallet'; import { useMintsStore } from './mints'; @@ -62,11 +62,17 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null, ); - const currentPayload = (): BackupPartPayload => ({ - notes: readEncryptedBearers(), - mints: readTrustedMints(wallet.pubkey ?? undefined), - settings: loadSettings(), - }); + const currentPayload = (): BackupPartPayload => { + // one document, one getItem: bearers and counters come from the same + // locked generation by construction; pending journal state is + // device-local and never published + const funds = readFundsDocument(); + return { + notes: { bearers: funds.bearers, nextByHost: funds.nextByHost }, + mints: readTrustedMints(wallet.pubkey ?? undefined), + settings: loadSettings(), + }; + }; const publishNow = async (): Promise => { const secretKey = deriveBackupKey(wallet.requireLinkingKey()); @@ -97,6 +103,10 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { const schedule = () => publisher?.schedule(currentPayload()); const stops = [ watch(() => wallet.bearers, schedule), + // counter-only commits (reservations) leave the bearer list untouched, + // so watch the persisted document revision as well - a restored or + // freshly burned counter must still reach the relays + watch(() => wallet.fundsRevision, schedule), watch(() => mints.mints, schedule), watch(() => mints.defaultMint, schedule), ]; From e94549a16e983875eaf445287108b62212255d61 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 09:17:12 +0200 Subject: [PATCH 11/29] fix(wallet): activate complete wallet material --- src/boot/wallet.ts | 9 +- src/capabilities/biometricUnlock.ts | 9 +- src/lnurlcash/passkeyOwnership.ts | 7 +- src/lnurlcash/passkeys.crypto.cases.ts | 1 - src/lnurlcash/passkeys.multiple.cases.ts | 18 +- src/lnurlcash/passkeys.ownership-a.cases.ts | 39 ++-- src/lnurlcash/passkeys.ownership-b.cases.ts | 16 +- src/lnurlcash/passkeys.registration.cases.ts | 53 ++--- src/lnurlcash/passkeys.rewrap.cases.ts | 12 +- src/lnurlcash/passkeys.storage.cases.ts | 3 +- src/lnurlcash/passkeys.support.cases.ts | 1 - src/lnurlcash/passkeys.ts | 30 +-- src/lnurlcash/passkeys.version.cases.ts | 55 +++-- src/lnurlcash/storage/currentOwner.ts | 10 +- src/lnurlcash/storage/nwcConnections.ts | 4 +- src/lnurlcash/trustedMints.ts | 5 +- src/pages/SecurityPage.vue | 4 +- src/stores/mints.storageEvents.test.ts | 15 +- .../wallet.lifecycle.activation.cases.ts | 15 +- .../wallet.lifecycle.backupOwner.cases.ts | 42 ++-- .../wallet.lifecycle.invalidation.cases.ts | 33 +-- .../wallet.lifecycle.isolation.cases.ts | 12 +- .../wallet.lifecycle.legacyReset.cases.ts | 122 ++++++++++++ src/stores/wallet.lifecycle.material.cases.ts | 188 ++++++++++++++++++ src/stores/wallet.lifecycle.teardown.cases.ts | 28 +-- src/stores/wallet.lifecycle.test.ts | 2 + src/stores/wallet.lifecycle.testHarness.ts | 128 +++++++++++- src/stores/wallet.lifecycle.testMocks.ts | 2 + src/stores/wallet.ts | 162 +++++++++------ src/stores/walletAccess.ts | 50 +++-- src/stores/walletLifecycle.ts | 84 ++++++-- src/stores/walletOwnerMonitor.test.ts | 62 ++++-- src/stores/walletOwnerMonitor.ts | 9 +- 33 files changed, 906 insertions(+), 324 deletions(-) create mode 100644 src/stores/wallet.lifecycle.legacyReset.cases.ts create mode 100644 src/stores/wallet.lifecycle.material.cases.ts diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts index 88f9f58..4ec96bb 100644 --- a/src/boot/wallet.ts +++ b/src/boot/wallet.ts @@ -13,9 +13,12 @@ declare global { } // 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 -// key at all lands on 'none' for onboarding. +// wallet store at app start - a plaintext-stored material record unlocks +// straight away, a password-encrypted one lands on 'locked' for the unlock +// screen, and no record at all lands on 'none' for onboarding. init() first +// runs the alpha reset: unsupported legacy state (the linking-key-only +// record or the pre-v2 bearer namespace) is wiped, never migrated, before +// any route can expose it. export default defineBoot(async () => { const wallet = useWalletStore(); await wallet.init(); diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index c1878aa..71d2e9b 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -47,7 +47,6 @@ import { savedWalletMaterialOwnerId, serializeWalletMaterial, walletMaterialHash, - walletMaterialLinkingKey, type WalletMaterialV2, } from '@/lnurlcash/keys'; import { isJsonObject } from '@/lnurlcash/jsonParsing'; @@ -188,12 +187,9 @@ const authenticateOrThrow = async (reason: string): Promise => { }; export const enableBiometricUnlock = async ( - material: WalletMaterialV2 | Uint8Array, + material: WalletMaterialV2, ): Promise => { if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); - if (material instanceof Uint8Array) { - throw new Error('Biometric enrollment requires complete v2 wallet material.'); - } const serialized = serializeWalletMaterial(material); const canonicalMaterial = parseWalletMaterial(serialized); if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.'); @@ -306,9 +302,6 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise => - walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics()); - // Native deletion goes first so a failure leaves the complete enrollment // available for the locked lifecycle to retry safely. export const disableBiometricUnlock = async (): Promise => { diff --git a/src/lnurlcash/passkeyOwnership.ts b/src/lnurlcash/passkeyOwnership.ts index 58337e2..a3a9ad9 100644 --- a/src/lnurlcash/passkeyOwnership.ts +++ b/src/lnurlcash/passkeyOwnership.ts @@ -1,13 +1,16 @@ // 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. +// The proven marker lives on the v2 wallet-material record; adoption itself +// additionally requires the slot to commit to the exact saved material. -import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {linkingPubKeyHex} from './keys' import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots' import {withStorageLock} from './storageLock' +import {savedWalletMaterialOwnerId} from './walletMaterialStorage' export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise => { - const ownerId = savedKeyOwnerId() + const ownerId = savedWalletMaterialOwnerId() if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { throw new Error('Legacy passkey migration requires a proven owner.') } diff --git a/src/lnurlcash/passkeys.crypto.cases.ts b/src/lnurlcash/passkeys.crypto.cases.ts index f4ab10f..011327b 100644 --- a/src/lnurlcash/passkeys.crypto.cases.ts +++ b/src/lnurlcash/passkeys.crypto.cases.ts @@ -20,7 +20,6 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' diff --git a/src/lnurlcash/passkeys.multiple.cases.ts b/src/lnurlcash/passkeys.multiple.cases.ts index fff5196..131b940 100644 --- a/src/lnurlcash/passkeys.multiple.cases.ts +++ b/src/lnurlcash/passkeys.multiple.cases.ts @@ -20,7 +20,7 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, + unlockWalletMaterialWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' @@ -149,11 +149,11 @@ 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, { + const laptopSlot = await registerPasskey(MATERIAL, { credentials: laptop, name: 'laptop', }) - const phoneSlot = await registerPasskey(LINKING_KEY, { + const phoneSlot = await registerPasskey(MATERIAL, { credentials: phone, name: 'phone', }) @@ -162,25 +162,25 @@ describe('multiple passkeys', () => { expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial) - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) + expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) }) 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, { + const laptopSlot = await registerPasskey(MATERIAL, { credentials: laptop, }) - await registerPasskey(LINKING_KEY, {credentials: phone}) + await registerPasskey(MATERIAL, {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') + await expect(unlockWalletMaterialWithPasskey({credentials: laptop})).rejects.toThrow('cancelled') // the survivor is unaffected - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) // 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 index 05b6fb1..ecc2cad 100644 --- a/src/lnurlcash/passkeys.ownership-a.cases.ts +++ b/src/lnurlcash/passkeys.ownership-a.cases.ts @@ -20,21 +20,22 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, + unlockWalletMaterialWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, - decryptSavedLinkingKey, + decryptSavedWalletMaterial, deriveBearerAesKey, deriveWalletMaterial, - ensureSavedKeyOwner, encryptRecord, + ensureSavedWalletMaterialOwner, linkingPubKeyHex, - savedKeyOwnerId, + savedWalletMaterialOwnerId, saveLinkingKey, saveWalletMaterial, + walletMaterialLinkingKey, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -133,10 +134,10 @@ const writeRawSlots = (slots: Array>): void => { } const removeSavedOwnerMarker = (): void => { - const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') + const stored = parseJsonObject(localStorage.getItem('sattle_wallet_material_v2') ?? '{}') delete stored.ownerId delete stored.version - localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) + localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored)) } beforeEach(async () => { @@ -151,7 +152,7 @@ describe('slot ownership', () => { const auth = new FakeAuthenticator() // When its linking key registers a passkey - const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const slot = await registerPasskey(MATERIAL, {credentials: auth}) // Then the slot carries that same canonical owner expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) @@ -161,7 +162,7 @@ describe('slot ownership', () => { 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 current = await registerPasskey(MATERIAL, {credentials: auth}) const foreign = { ...current, credentialId: '11'.repeat(16), @@ -191,9 +192,9 @@ describe('slot ownership', () => { }) it('does not offer markerless slots for passkey-first unlock', async () => { - // Given a legacy slot and a saved key with no proven owner marker + // Given a legacy slot and a saved wallet with no proven owner marker const auth = new FakeAuthenticator() - await registerPasskey(LINKING_KEY, {credentials: auth}) + await registerPasskey(MATERIAL, {credentials: auth}) const legacy = readRawSlots() delete legacy[0]?.ownerId delete legacy[0]?.version @@ -201,7 +202,7 @@ describe('slot ownership', () => { removeSavedOwnerMarker() // When passkey unlock is attempted before another proof path - const attempt = unlockWithPasskey({credentials: auth}) + const attempt = unlockWalletMaterialWithPasskey({credentials: auth}) // Then it fails before asking the authenticator await expect(attempt).rejects.toThrow('No passkeys') @@ -212,7 +213,7 @@ describe('slot ownership', () => { 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}) + await registerPasskey(MATERIAL, {credentials: auth}) const legacy = readRawSlots() delete legacy[0]?.ownerId delete legacy[0]?.version @@ -231,24 +232,24 @@ describe('slot ownership', () => { 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}) + await registerPasskey(MATERIAL, {credentials: auth}) const legacy = readRawSlots() delete legacy[0]?.ownerId delete legacy[0]?.version writeRawSlots(legacy) - await saveLinkingKey(LINKING_KEY, 'correct horse') + await saveWalletMaterial(MATERIAL, '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) + const provenMaterial = await decryptSavedWalletMaterial('correct horse') + ensureSavedWalletMaterialOwner(provenMaterial) + await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial)) // Then the same slot is stamped once for that proven owner and unlocks - expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(savedWalletMaterialOwnerId()).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) + await expect(unlockWalletMaterialWithPasskey({credentials: auth})).resolves.toEqual(MATERIAL) }) }) diff --git a/src/lnurlcash/passkeys.ownership-b.cases.ts b/src/lnurlcash/passkeys.ownership-b.cases.ts index 882c4cd..979e91b 100644 --- a/src/lnurlcash/passkeys.ownership-b.cases.ts +++ b/src/lnurlcash/passkeys.ownership-b.cases.ts @@ -20,7 +20,7 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, + unlockWalletMaterialWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' @@ -151,7 +151,7 @@ describe('slot ownership (continued)', () => { // 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 slot = await registerPasskey(MATERIAL, {credentials: auth}) const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: auth, }) @@ -159,7 +159,7 @@ describe('slot ownership (continued)', () => { writeRawSlots([{...slot, ...foreignWrap}]) // When the authenticator successfully unwraps that foreign key - const attempt = unlockWithPasskey({credentials: auth}) + const attempt = unlockWalletMaterialWithPasskey({credentials: auth}) // Then owner validation rejects it before activation can receive it await expect(attempt).rejects.toThrow('different wallet') @@ -168,7 +168,7 @@ describe('slot ownership (continued)', () => { 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}) + await registerPasskey(MATERIAL, {credentials: auth}) const stale: PasskeyCredentials = { create: auth.create, get: async (options) => { @@ -180,7 +180,7 @@ describe('slot ownership (continued)', () => { } // When the old wallet's ceremony completes after replacement - const attempt = unlockWithPasskey({credentials: stale}) + const attempt = unlockWalletMaterialWithPasskey({credentials: stale}) // Then the old linking key is never returned for activation await expect(attempt).rejects.toThrow('different wallet') @@ -189,7 +189,7 @@ describe('slot ownership (continued)', () => { 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 slot = await registerPasskey(MATERIAL, {credentials: auth}) const malformed = {...slot, ownerId: 'not-an-owner'} writeRawSlots([malformed]) @@ -204,7 +204,7 @@ describe('slot ownership (continued)', () => { 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 slot = await registerPasskey(MATERIAL, {credentials: auth}) const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)} writeRawSlots([foreign]) @@ -219,7 +219,7 @@ describe('slot ownership (continued)', () => { 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 slot = await registerPasskey(MATERIAL, {credentials: auth}) const foreign = { ...slot, credentialId: '44'.repeat(16), diff --git a/src/lnurlcash/passkeys.registration.cases.ts b/src/lnurlcash/passkeys.registration.cases.ts index ec6cd33..e90a303 100644 --- a/src/lnurlcash/passkeys.registration.cases.ts +++ b/src/lnurlcash/passkeys.registration.cases.ts @@ -20,7 +20,7 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, + unlockWalletMaterialWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' @@ -147,9 +147,9 @@ beforeEach(async () => { }) describe('registration and unlock', () => { - it('registers a passkey and unlocks the same linking key', async () => { + it('registers a passkey and unlocks the same complete material', async () => { const auth = new FakeAuthenticator() - const slot = await registerPasskey(LINKING_KEY, { + const slot = await registerPasskey(MATERIAL, { credentials: auth, name: 'laptop', }) @@ -157,33 +157,32 @@ describe('registration and unlock', () => { expect(readPasskeySlots()).toEqual([slot]) expect(hasPasskeySlots()).toBe(true) - const unwrapped = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth}) + expect(unwrapped).toEqual(MATERIAL) }) it('never stores the linking key in the clear', async () => { const auth = new FakeAuthenticator() - await registerPasskey(LINKING_KEY, {credentials: auth}) + await registerPasskey(MATERIAL, {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 () => { + it('yields the same wallet material unlock(password) yields', async () => { await saveWalletMaterial(MATERIAL, 'correct horse') const auth = new FakeAuthenticator() await registerPasskey(MATERIAL, {credentials: auth}) const viaPassword = await decryptSavedWalletMaterial('correct horse') - const viaPasskey = await unlockWithPasskey({credentials: auth}) - const passwordLinkingKey = walletMaterialLinkingKey(viaPassword) - expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey)) + const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth}) + expect(viaPasskey).toEqual(viaPassword) // and the practical consequence: a bearer record encrypted after a // password unlock decrypts after a passkey unlock - const passwordAes = await deriveBearerAesKey(passwordLinkingKey) + const passwordAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPassword)) const record = await encryptRecord(passwordAes, {note: 'still readable'}) - const passkeyAes = await deriveBearerAesKey(viaPasskey) + const passkeyAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPasskey)) await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ note: 'still readable', }) @@ -192,17 +191,17 @@ describe('registration and unlock', () => { 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}) + const slot = await registerPasskey(MATERIAL, {credentials: auth}) expect(auth.getCalls).toBe(1) - const unwrapped = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth}) + expect(unwrapped).toEqual(MATERIAL) 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') + await expect(registerPasskey(MATERIAL, {credentials: auth})).rejects.toThrow('PRF') expect(hasPasskeySlots()).toBe(false) }) @@ -211,7 +210,7 @@ describe('registration and unlock', () => { create: async () => null, get: async () => null, } - await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow( + await expect(registerPasskey(MATERIAL, {credentials: cancelled})).rejects.toThrow( 'cancelled', ) expect(hasPasskeySlots()).toBe(false) @@ -219,20 +218,24 @@ describe('registration and unlock', () => { it('throws before any ceremony when no passkeys are registered', async () => { const auth = new FakeAuthenticator() - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('No passkeys') + await expect(unlockWalletMaterialWithPasskey({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}) + await registerPasskey(MATERIAL, {credentials: auth}) auth.prfResultsOnGet = false - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('PRF secret') + await expect(unlockWalletMaterialWithPasskey({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}) + await registerPasskey(MATERIAL, {credentials: auth}) const rogue: PasskeyCredentials = { create: async () => null, get: async () => ({ @@ -243,13 +246,15 @@ describe('registration and unlock', () => { }), }), } - await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow('not registered') + await expect(unlockWalletMaterialWithPasskey({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}) + const slot = await registerPasskey(MATERIAL, {credentials: auth}) auth.rotateSecret(slot.credentialId) - await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow() + await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow() }) }) diff --git a/src/lnurlcash/passkeys.rewrap.cases.ts b/src/lnurlcash/passkeys.rewrap.cases.ts index d192f10..faa39e5 100644 --- a/src/lnurlcash/passkeys.rewrap.cases.ts +++ b/src/lnurlcash/passkeys.rewrap.cases.ts @@ -20,7 +20,7 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, + unlockWalletMaterialWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' @@ -149,10 +149,10 @@ 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, { + const laptopSlot = await registerPasskey(MATERIAL, { credentials: laptop, }) - const phoneSlot = await registerPasskey(LINKING_KEY, { + const phoneSlot = await registerPasskey(MATERIAL, { credentials: phone, }) @@ -166,7 +166,7 @@ describe('rewrap for the current owner', () => { ], ]) await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap') - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) // full coverage refreshes both wraps around the same proven owner key const fresh = new Map([ @@ -184,8 +184,8 @@ describe('rewrap for the current owner', () => { ], ]) await rewrapAllSlots(MATERIAL, fresh) - expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) - expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) + expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial) // credential ids and labels survive the re-wrap expect( diff --git a/src/lnurlcash/passkeys.storage.cases.ts b/src/lnurlcash/passkeys.storage.cases.ts index 6eb181b..4f7efcd 100644 --- a/src/lnurlcash/passkeys.storage.cases.ts +++ b/src/lnurlcash/passkeys.storage.cases.ts @@ -20,7 +20,6 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' @@ -148,7 +147,7 @@ beforeEach(async () => { 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 slot = await registerPasskey(MATERIAL, {credentials: auth}) const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') localStorage.setItem( 'sattle_passkey_slots', diff --git a/src/lnurlcash/passkeys.support.cases.ts b/src/lnurlcash/passkeys.support.cases.ts index 5d0e4d8..8b96e86 100644 --- a/src/lnurlcash/passkeys.support.cases.ts +++ b/src/lnurlcash/passkeys.support.cases.ts @@ -20,7 +20,6 @@ import { registerPasskey, removePasskey, rewrapAllSlots, - unlockWithPasskey, unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf, } from './passkeys' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index 9b39171..7f57f1e 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -24,7 +24,6 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' -import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys' import {withStorageLock} from './storageLock' import type {PasskeySlot} from './storage/passkeySlots' import { @@ -154,27 +153,15 @@ export type RegisterPasskeyOptions = { } // Registers a new passkey and persists a slot wrapping the complete material. -// The Uint8Array input exists only until Task 5 updates the UI caller; it can -// select already-saved plaintext v2 material but can never create a key-only -// slot. The ceremony is navigator.credentials.create with the PRF +// The ceremony is navigator.credentials.create with the PRF // extension evaluated on creation. Some authenticators only report // prf.enabled during create and evaluate the secret on the first get - // those get a follow-up get() against the fresh credential. export const registerPasskey = async ( - material: WalletMaterialV2 | Uint8Array, + material: WalletMaterialV2, options: RegisterPasskeyOptions = {}, ): Promise => { - let walletMaterial: WalletMaterialV2 - if (material instanceof Uint8Array) { - const savedMaterial = getPlainWalletMaterial() - if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) { - throw new Error('Passkey registration requires complete v2 wallet material.') - } - walletMaterial = savedMaterial - } else { - walletMaterial = material - } - const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial) + const ownerId = requireCurrentPasskeyMaterialOwner(material) const credentials = options.credentials ?? defaultCredentials() const credential = await credentials.create({ publicKey: { @@ -209,7 +196,7 @@ export const registerPasskey = async ( } prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) } - const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial) + const wrap = await wrapWalletMaterialWithPrf(prfOutput, material) const slot: PasskeySlot = { credentialId, ...wrap, @@ -219,7 +206,7 @@ export const registerPasskey = async ( version: PASSKEY_SLOT_VERSION, } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { - if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) { + if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) { throw new Error('Saved wallet material changed during passkey registration.') } const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) @@ -286,13 +273,6 @@ export const unlockWalletMaterialWithPasskey = async ( return material } -// Task 5 replaces this temporary projection when lifecycle activation accepts -// complete wallet material. Slots already contain only canonical v2 material. -export const unlockWithPasskey = async ( - options: {credentials?: PasskeyCredentials} = {}, -): Promise => - walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options)) - // 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. diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts index 3924c5a..57a8191 100644 --- a/src/lnurlcash/passkeys.version.cases.ts +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -3,10 +3,10 @@ import {bytesToHex} from '@noble/hashes/utils.js' import { deriveWalletMaterial, - ensureSavedKeyOwner, - getPlainLinkingKey, + ensureSavedWalletMaterialOwner, + getPlainWalletMaterial, linkingPubKeyHex, - savedKeyOwnerId, + savedWalletMaterialOwnerId, saveWalletMaterial, walletMaterialHash, } from './keys' @@ -16,7 +16,7 @@ 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 MATERIAL_STORAGE = 'sattle_wallet_material_v2' const SLOT_STORAGE = 'sattle_passkey_slots' const MATERIAL = { ...deriveWalletMaterial( @@ -38,18 +38,24 @@ const writeRawSlots = (slots: readonly Record[]): void => { localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots)) } +// strips the v2 record's owner marker back to the unproven post-restore form +const removeSavedOwnerMarker = (): void => { + const stored: unknown = JSON.parse(localStorage.getItem(MATERIAL_STORAGE) ?? '{}') + if (typeof stored !== 'object' || stored === null) throw new TypeError('expected a record') + const stripped: Record = {...stored} + delete stripped.ownerId + delete stripped.version + localStorage.setItem(MATERIAL_STORAGE, JSON.stringify(stripped)) +} + describe('passkey-slot schema version', () => { beforeEach(async () => { stubLocalStorage() await saveWalletMaterial(MATERIAL) }) it('reads a current version 2 slot for the exact saved owner', () => { - // Given a current saved-key marker and passkey record + // Given a current saved-material owner 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: 2} writeRawSlots([slot]) @@ -58,24 +64,22 @@ describe('passkey-slot schema version', () => { expect(readPasskeySlots()).toEqual([slot]) }) - it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => { - // Given records written before schema versioning + it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => { + // Given records written before schema versioning and a saved material + // whose owner marker was never proven (the post-restore form) const ownerId = linkingPubKeyHex(LINKING_KEY) - localStorage.setItem( - KEY_STORAGE, - JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}), - ) + removeSavedOwnerMarker() writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}]) - // When migration is attempted before and after the saved key proves ownership + // When migration is attempted before and after the saved material 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) + const provenMaterial = getPlainWalletMaterial() + if (provenMaterial === null) throw new Error('expected the compatible plaintext material') + ensureSavedWalletMaterialOwner(provenMaterial) + await migrateLegacyPasskeySlots(LINKING_KEY) // Then both compatible legacy forms become current records - expect(savedKeyOwnerId()).toBe(ownerId) + expect(savedWalletMaterialOwnerId()).toBe(ownerId) expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ {...SLOT_BASE, ownerId, version: 2}, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2}, @@ -85,10 +89,6 @@ describe('passkey-slot schema version', () => { it.each([1, 3, '2', 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 @@ -98,11 +98,6 @@ describe('passkey-slot schema version', () => { 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: 2}]) // When the current wallet reads passkeys diff --git a/src/lnurlcash/storage/currentOwner.ts b/src/lnurlcash/storage/currentOwner.ts index 9b3a64b..2ed48a1 100644 --- a/src/lnurlcash/storage/currentOwner.ts +++ b/src/lnurlcash/storage/currentOwner.ts @@ -1,8 +1,16 @@ // 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. +// +// The canonical owner lives on the v2 wallet-material record. The legacy +// linking-key record is accepted as a fallback so engine-level consumers and +// their harnesses keep working while the lifecycle cuts over; the cut-over +// lifecycle itself never writes that record and deletes it at install, +// reset, backup-restore, and teardown time, so in the shipped app only the +// v2 branch can ever match. import {savedKeyOwnerId} from '../keys' +import {savedWalletMaterialOwnerId} from '../walletMaterialStorage' export class WalletOwnerMismatchError extends Error { override readonly name = 'WalletOwnerMismatchError' @@ -12,7 +20,7 @@ export class WalletOwnerMismatchError extends Error { } export const savedKeyOwnerAllows = (ownerId: string): boolean => { - return savedKeyOwnerId() === ownerId + return savedWalletMaterialOwnerId() === ownerId || savedKeyOwnerId() === ownerId } export const assertSavedKeyOwner = (ownerId: string): void => { diff --git a/src/lnurlcash/storage/nwcConnections.ts b/src/lnurlcash/storage/nwcConnections.ts index b2b8b5a..20745b1 100644 --- a/src/lnurlcash/storage/nwcConnections.ts +++ b/src/lnurlcash/storage/nwcConnections.ts @@ -4,7 +4,7 @@ // parsed before use; ownerless v0 records remain hidden until an already // proven saved wallet explicitly migrates them. -import {linkingPubKeyHex, savedKeyOwnerId} from '../keys' +import {linkingPubKeyHex} from '../keys' import { clearNwcEnabledForOwner, clearUnownedNwcEnabled, @@ -177,7 +177,7 @@ export type NwcLegacyMigrationResult = { export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => { const ownerId = linkingPubKeyHex(linkingKey) - if (savedKeyOwnerId() !== ownerId) { + if (!savedKeyOwnerAllows(ownerId)) { throw new Error('Legacy NWC migration requires a proven saved wallet owner.') } diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index 1fa8ac7..3358353 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -7,7 +7,8 @@ import { removeStoredTrustedMintsForOwner, resetStoredTrustedMints, } from './trustedMintsRepository' -import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {linkingPubKeyHex} from './keys' +import {savedKeyOwnerAllows} from './storage/currentOwner' import { addMint, cacheMintNodeInfo, @@ -259,7 +260,7 @@ export const clearTrustedMints = (ownerId?: string): Promise => export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise => { const ownerId = linkingPubKeyHex(linkingKey) - if (savedKeyOwnerId() !== ownerId) { + if (!savedKeyOwnerAllows(ownerId)) { throw new Error('Legacy trusted-mint migration requires a proven owner.') } return adoptLegacyStoredTrustedMints(ownerId) diff --git a/src/pages/SecurityPage.vue b/src/pages/SecurityPage.vue index 2df2d4f..93e2d95 100644 --- a/src/pages/SecurityPage.vue +++ b/src/pages/SecurityPage.vue @@ -249,7 +249,7 @@ const doEnableBiometric = async () => { biometricBusy.value = true; banner.value = ''; try { - await enableBiometricUnlock(wallet.requireLinkingKey()); + await enableBiometricUnlock(wallet.requireWalletMaterial()); biometricEnrolled.value = true; toast('positive', 'Biometric unlock enabled.'); } catch (err) { @@ -289,7 +289,7 @@ const doRegister = async () => { banner.value = ''; try { const name = registerName.value.trim(); - await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {}); + await registerPasskey(wallet.requireWalletMaterial(), name ? { name } : {}); slots.value = readPasskeySlots(); registering.value = false; toast('positive', 'Passkey added.'); diff --git a/src/stores/mints.storageEvents.test.ts b/src/stores/mints.storageEvents.test.ts index f7318b1..0cb070f 100644 --- a/src/stores/mints.storageEvents.test.ts +++ b/src/stores/mints.storageEvents.test.ts @@ -1,13 +1,19 @@ import { createPinia, disposePinia, setActivePinia } from 'pinia'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial } 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(new Uint8Array(32).fill(7)), +}; const MINT_KEY = '02' + 'aa'.repeat(32); let testPinia: ReturnType; @@ -34,10 +40,7 @@ describe('mints store storage-event convergence', () => { // 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 }), - ); + await saveWalletMaterial(MATERIAL); const wallet = useWalletStore(); await wallet.init(); const mints = useMintsStore(); diff --git a/src/stores/wallet.lifecycle.activation.cases.ts b/src/stores/wallet.lifecycle.activation.cases.ts index e557bc4..e4a16c3 100644 --- a/src/stores/wallet.lifecycle.activation.cases.ts +++ b/src/stores/wallet.lifecycle.activation.cases.ts @@ -2,13 +2,13 @@ import { OWNER_ID, PASSWORD, deferred, - installLegacyEncryptedWallet, installLegacyOwnerlessResidue, + installOwnerlessEncryptedWalletMaterial, mocks, } from './wallet.lifecycle.testHarness'; import { describe, expect, it, vi } from 'vitest'; -import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys'; +import { savedWalletMaterialExists, savedWalletMaterialOwnerId } from '@/lnurlcash/keys'; import { readNwcConnections } from '@/lnurlcash/nwc'; import { readPasskeySlots } from '@/lnurlcash/passkeys'; import { readTrustedMints } from '@/lnurlcash/trustedMints'; @@ -17,8 +17,8 @@ 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(); + // Given a restored (ownerless) v2 wallet with ownerless authorization residue + await installOwnerlessEncryptedWalletMaterial(); installLegacyOwnerlessResidue(); const wallet = useWalletStore(); useNwcStore(); @@ -27,7 +27,7 @@ describe('serialized wallet activation', () => { await wallet.unlock(PASSWORD); // Then every legacy namespace belongs to the proven owner before startup - expect(savedKeyOwnerId()).toBe(OWNER_ID); + expect(savedWalletMaterialOwnerId()).toBe(OWNER_ID); expect(readPasskeySlots()).toHaveLength(1); expect(readNwcConnections(OWNER_ID)).toHaveLength(1); expect(readTrustedMints(OWNER_ID)).toHaveLength(1); @@ -50,7 +50,7 @@ describe('serialized wallet activation', () => { // 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(savedWalletMaterialExists()).toBe(true); expect(wallet.state).toBe('unlocked'); drain.resolve(); await forgetting; @@ -70,9 +70,10 @@ describe('serialized wallet activation', () => { // When a valid file restore starts const restoring = wallet.restoreFromBackup({ type: 'sattle-backup', - version: 1, + version: 2, createdAt: 1, bearers: [], + nextByHost: {}, }); await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled()); diff --git a/src/stores/wallet.lifecycle.backupOwner.cases.ts b/src/stores/wallet.lifecycle.backupOwner.cases.ts index 500871b..d4c4666 100644 --- a/src/stores/wallet.lifecycle.backupOwner.cases.ts +++ b/src/stores/wallet.lifecycle.backupOwner.cases.ts @@ -1,29 +1,29 @@ 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 { savedKeyExists, savedWalletMaterialExists } from '@/lnurlcash/keys'; +import { addTrustedMint } 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 () => { + it('never installs or namespaces trust from a file-carried legacy key', 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, + version: 2, createdAt: 1, ownerId: OTHER_OWNER_ID, linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID }, bearers: [], + nextByHost: {}, trustedMints: [ { server: 'file-mint.example', @@ -34,24 +34,32 @@ describe('hostile file backup owner', () => { }, ], }); - const registryBeforeProof: unknown = JSON.parse( - localStorage.getItem('sattle_trusted_mints') ?? 'null', - ); + + // Then the merge reports what the file carried, but the v2 lifecycle + // cannot activate a linking-key-only record (the cash root is + // unrecoverable from it): the residue is removed instead of leaving a + // half-installed locked wallet behind expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 }); - expect(savedKeyOwnerId()).toBeNull(); - expect(registryBeforeProof).toBeNull(); + expect(savedKeyExists()).toBe(false); + expect(savedWalletMaterialExists()).toBe(false); + expect(wallet.state).toBe('none'); + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull(); - // When password proof activates the restored linking key - await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined(); + // And no unlock can ever succeed against the file's claim + await expect(wallet.unlock(PASSWORD)).rejects.toThrow(); + expect(wallet.state).toBe('none'); - // 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([]); + // When the holder installs their own wallet afterward + await wallet.create(PASSWORD); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + + // Then only the device's own proven wallet can initialize trust await expect( - addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId: OWNER_ID }), + addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId }), ).resolves.toBe('added'); expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual( - expect.objectContaining({ ownerId: OWNER_ID }), + expect.objectContaining({ ownerId }), ); }); }); diff --git a/src/stores/wallet.lifecycle.invalidation.cases.ts b/src/stores/wallet.lifecycle.invalidation.cases.ts index 03f80dd..6a68eb7 100644 --- a/src/stores/wallet.lifecycle.invalidation.cases.ts +++ b/src/stores/wallet.lifecycle.invalidation.cases.ts @@ -1,7 +1,13 @@ -import { MINT_KEY, mocks, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness'; +import { + MINT_KEY, + OTHER_MATERIAL, + OTHER_OWNER_ID, + PASSWORD, + mocks, +} from './wallet.lifecycle.testHarness'; import { describe, expect, it, vi } from 'vitest'; -import { savedKeyOwnerId } from '@/lnurlcash/keys'; +import { saveWalletMaterial, savedWalletMaterialOwnerId } from '@/lnurlcash/keys'; import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc'; import type { NwcConnectionRecord } from '@/lnurlcash/nwc'; import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; @@ -19,20 +25,18 @@ describe('cross-tab owner invalidation', () => { 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 }), - ); + await saveWalletMaterial(OTHER_MATERIAL); events.dispatchEvent( Object.defineProperties(new Event('storage'), { - key: { value: 'sattle_linking_key' }, + key: { value: 'sattle_wallet_material_v2' }, + // a stale payload on purpose: the monitor must re-read storage 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.'); + // Then the stale runtime has no usable material and cannot recreate old-owner state + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow( /owner/i, ); @@ -54,20 +58,17 @@ describe('cross-tab owner invalidation', () => { 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 }), - ); + await saveWalletMaterial(OTHER_MATERIAL); events.dispatchEvent( Object.defineProperties(new Event('storage'), { - key: { value: 'sattle_linking_key' }, + key: { value: 'sattle_wallet_material_v2' }, }), ); 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(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); expect(nwc.running).toBe(false); expect(serviceStop).toHaveBeenCalledTimes(1); }); @@ -90,7 +91,7 @@ describe('cross-tab owner invalidation', () => { // When A is forgotten before successor B is installed await wallet.forgetWallet(); - expect(savedKeyOwnerId()).toBeNull(); + expect(savedWalletMaterialOwnerId()).toBeNull(); // Then no stale normal mutation can recreate A-owned state in the gap await expect( diff --git a/src/stores/wallet.lifecycle.isolation.cases.ts b/src/stores/wallet.lifecycle.isolation.cases.ts index 30cdc92..519ac54 100644 --- a/src/stores/wallet.lifecycle.isolation.cases.ts +++ b/src/stores/wallet.lifecycle.isolation.cases.ts @@ -6,7 +6,11 @@ import { } from './wallet.lifecycle.testHarness'; import { describe, expect, it } from 'vitest'; -import { generateSeedPhrase, savedKeyExists } from '@/lnurlcash/keys'; +import { + generateSeedPhrase, + savedWalletMaterialExists, + walletMaterialHash, +} from '@/lnurlcash/keys'; import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc'; import { readPasskeySlots } from '@/lnurlcash/passkeys'; import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; @@ -48,7 +52,7 @@ describe('foreign wallet isolation', () => { // 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); + expect(savedWalletMaterialExists()).toBe(false); }); it('tears down the installed owner before a foreign seed restore', async () => { @@ -65,9 +69,11 @@ describe('foreign wallet isolation', () => { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), + materialHash: walletMaterialHash(wallet.requireWalletMaterial()), + wrappedMaterial: '44'.repeat(48), createdAt: 1, ownerId: oldOwner, + version: 2, }, ]), ); diff --git a/src/stores/wallet.lifecycle.legacyReset.cases.ts b/src/stores/wallet.lifecycle.legacyReset.cases.ts new file mode 100644 index 0000000..9d45cff --- /dev/null +++ b/src/stores/wallet.lifecycle.legacyReset.cases.ts @@ -0,0 +1,122 @@ +import { + LEGACY_BEARERS_KEY, + LEGACY_LINKING_KEY, + MATERIAL, + OWNER_ID, + WALLET_MATERIAL_KEY, + installLegacyOwnerlessResidue, + installLegacyWalletState, + mocks, +} from './wallet.lifecycle.testHarness'; +import { describe, expect, it } from 'vitest'; + +import { saveWalletMaterial, savedWalletMaterialExists } from '@/lnurlcash/keys'; +import { useWalletStore } from './wallet'; + +describe('unsupported legacy install reset', () => { + it('resets legacy saved-key and bearer state before routing', async () => { + // Given an alpha install: the linking-key-only record, the legacy bearer + // namespace, and ownerless authorization residue + installLegacyWalletState(); + installLegacyOwnerlessResidue(); + + // When the store is constructed, it must not advertise the legacy wallet + const wallet = useWalletStore(); + expect(wallet.state).toBe('none'); + + // When startup initialization runs + await wallet.init(); + + // Then every legacy namespace is gone and nothing was activated + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull(); + 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(localStorage.getItem(WALLET_MATERIAL_KEY)).toBeNull(); + expect(wallet.state).toBe('none'); + expect(mocks.disableBiometricUnlock).toHaveBeenCalledTimes(1); + }); + + it('resets on a corrupt legacy record without parsing it', async () => { + // Given a legacy entry that no parser can read + localStorage.setItem(LEGACY_LINKING_KEY, '{{{not json'); + const wallet = useWalletStore(); + expect(wallet.state).toBe('none'); + + // When startup initialization runs + await wallet.init(); + + // Then the reset keyed on raw presence, not on a successful parse + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + expect(wallet.state).toBe('none'); + }); + + it('keeps a v2 install and only removes stray legacy key residue', async () => { + // Given a current plaintext v2 wallet plus a stray legacy record (an old + // tab or a restored backup left it behind) + await saveWalletMaterial(MATERIAL); + localStorage.setItem( + LEGACY_LINKING_KEY, + JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: 'ab'.repeat(33), version: 1 }), + ); + const wallet = useWalletStore(); + expect(wallet.state).toBe('locked'); + + // When startup initialization runs + await wallet.init(); + + // Then the v2 wallet activated and only the legacy residue is gone + expect(wallet.state).toBe('unlocked'); + expect(wallet.pubkey).toBe(OWNER_ID); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + expect(savedWalletMaterialExists()).toBe(true); + }); + + it('leaves legacy state intact and retryable when native biometric deletion fails', async () => { + // Given an alpha install whose secure-storage deletion rejects + installLegacyWalletState(); + const wallet = useWalletStore(); + mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed')); + + // When startup initialization runs + await wallet.init(); + + // Then the failure is surfaced, nothing was half-wiped, and the legacy + // state is still all there for the next attempt + expect(wallet.lifecycleError).toMatch(/secure delete failed/i); + expect(wallet.state).toBe('none'); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull(); + expect(localStorage.getItem(LEGACY_BEARERS_KEY)).not.toBeNull(); + + // When initialization runs again with native deletion succeeding + await wallet.init(); + + // Then the reset completes + expect(wallet.lifecycleError).toBe(''); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull(); + }); + + it('retries the legacy reset before installing a successor wallet', async () => { + // Given an alpha install whose first reset attempt failed at native + // biometric deletion + installLegacyWalletState(); + const wallet = useWalletStore(); + mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed')); + await wallet.init(); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull(); + + // When the holder installs a fresh wallet + const phrase = await wallet.create(); + + // Then the pending reset ran first: no legacy residue survives into the + // successor installation + expect(phrase.split(' ')).toHaveLength(12); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull(); + expect(wallet.state).toBe('unlocked'); + expect(savedWalletMaterialExists()).toBe(true); + }); +}); diff --git a/src/stores/wallet.lifecycle.material.cases.ts b/src/stores/wallet.lifecycle.material.cases.ts new file mode 100644 index 0000000..cba295d --- /dev/null +++ b/src/stores/wallet.lifecycle.material.cases.ts @@ -0,0 +1,188 @@ +import { + LEGACY_LINKING_KEY, + MATERIAL, + MINT_KEY, + OTHER_MATERIAL, + PASSWORD, + WALLET_MATERIAL_KEY, + deferredLocks, + installEncryptedWalletMaterial, + installLegacyOwnerlessResidue, + installOwnerlessEncryptedWalletMaterial, + mocks, +} from './wallet.lifecycle.testHarness'; +import { describe, expect, it, vi } from 'vitest'; +import { buildNoteUrl } from 'lnurlcash-kit'; + +import { + deriveBearerAesKey, + encryptRecord, + saveWalletMaterial, + savedWalletMaterialExists, + savedWalletMaterialHash, + savedWalletMaterialOwnerId, + walletMaterialHash, + walletMaterialLinkingKey, +} from '@/lnurlcash/keys'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; +import { useWalletStore } from './wallet'; + +describe('complete material activation', () => { + it('persists v2 material on create and activates the same value on every unlock factor', async () => { + // Given a wallet created through the lifecycle + const wallet = useWalletStore(); + const phrase = await wallet.create(PASSWORD); + expect(phrase.split(' ')).toHaveLength(12); + + // Then the v2 envelope is the only saved secret record + expect(savedWalletMaterialExists()).toBe(true); + expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull(); + const active = wallet.requireWalletMaterial(); + expect(savedWalletMaterialHash()).toBe(walletMaterialHash(active)); + expect(wallet.state).toBe('unlocked'); + + // When the wallet is unlocked through each factor in turn + await wallet.lock(); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + await wallet.unlock(PASSWORD); + expect(wallet.requireWalletMaterial()).toEqual(active); + + await wallet.lock(); + mocks.unlockWalletMaterialWithPasskey.mockResolvedValue(active); + await wallet.unlockWithPasskey(); + expect(wallet.requireWalletMaterial()).toEqual(active); + + await wallet.lock(); + mocks.unlockWalletMaterialWithBiometrics.mockResolvedValue(active); + await wallet.unlockWithBiometric(); + + // Then every factor activated the exact same complete material + expect(wallet.requireWalletMaterial()).toEqual(active); + expect(wallet.state).toBe('unlocked'); + }); + + it('keeps the material private and memory-only while unlocked', async () => { + // Given an encrypted wallet + const wallet = useWalletStore(); + await wallet.create(PASSWORD); + + // Then the persisted record holds only the wrap, never plaintext material + const stored = JSON.parse(localStorage.getItem(WALLET_MATERIAL_KEY) ?? '{}') as Record< + string, + unknown + >; + expect(stored.enc).toBe(true); + expect(stored.value).toBeUndefined(); + + // And locking drops every runtime reference to it + await wallet.lock(); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(wallet.state).toBe('locked'); + }); + + it('fences activation when the saved owner is replaced during deferred loading', async () => { + // Given a restored (ownerless) wallet whose activation pauses inside the + // legacy-adoption storage lock + await installOwnerlessEncryptedWalletMaterial(); + installLegacyOwnerlessResidue(); + const deferred = deferredLocks(); + vi.stubGlobal('navigator', { locks: deferred.locks }); + const wallet = useWalletStore(); + + // When the saved material is replaced by a different owner mid-activation + const unlocking = wallet.unlock(PASSWORD); + await vi.waitFor(() => expect(deferred.requests).toHaveLength(1)); + await saveWalletMaterial(OTHER_MATERIAL); + await deferred.releaseNext(); + + // Then activation rejects, no runtime material survives, and the + // replacement's locked record is what remains + await expect(unlocking).rejects.toThrow(); + expect(wallet.state).toBe('locked'); + expect(savedWalletMaterialOwnerId()).not.toBeNull(); + expect(wallet.pubkey).toBeNull(); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + expect(wallet.bearers).toEqual([]); + }); + + it('fences activation on a final pre-unlocked owner re-read after deferred trust work', async () => { + // Given an owned wallet holding a mint-pinned bearer whose trust + // restoration pauses inside the trusted-mints storage lock + await installEncryptedWalletMaterial(); + const aesKey = await deriveBearerAesKey(walletMaterialLinkingKey(MATERIAL)); + const bearer = { + url: buildNoteUrl('https://mint.example/w', 'bb'.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + mintPubkey: MINT_KEY, + createdAt: 1, + updatedAt: 1, + }; + const record = await encryptRecord(aesKey, bearer); + localStorage.setItem( + FUNDS_STORAGE_KEY, + JSON.stringify({ + version: 2, + bearers: [{ id: 'b1', ...record }], + pending: [], + nextByHost: {}, + revision: 0, + }), + ); + const deferred = deferredLocks(); + vi.stubGlobal('navigator', { locks: deferred.locks }); + const wallet = useWalletStore(); + + // When the saved owner is replaced while trust restoration is suspended + const unlocking = wallet.unlock(PASSWORD); + await vi.waitFor(() => expect(deferred.requests).toHaveLength(1)); + await saveWalletMaterial(OTHER_MATERIAL); + await deferred.releaseNext(); + + // Then the pre-unlocked re-read rejects the stale activation before the + // wallet ever exposes an unlocked state + await expect(unlocking).rejects.toThrow(); + expect(wallet.state).toBe('locked'); + expect(wallet.pubkey).toBeNull(); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + expect(wallet.bearers).toEqual([]); + }); + + it('leaves no half-installed wallet when the material write fails during create', async () => { + // Given a store whose material write hits a full disk + const wallet = useWalletStore(); + const setItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = (key: string, value: string): void => { + if (key === WALLET_MATERIAL_KEY) throw new Error('quota exceeded'); + setItem(key, value); + }; + + // When create cannot persist the complete material + await expect(wallet.create(PASSWORD)).rejects.toThrow(/quota exceeded/); + + // Then nothing is installed and no runtime material lingers + expect(wallet.state).toBe('none'); + expect(savedWalletMaterialExists()).toBe(false); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + + // And the transition is retryable once storage works again + localStorage.setItem = setItem; + const phrase = await wallet.create(PASSWORD); + expect(phrase.split(' ')).toHaveLength(12); + expect(wallet.state).toBe('unlocked'); + }); + + it('rejects unlock without saved material and stays at none', async () => { + // Given no wallet on this device + const wallet = useWalletStore(); + + // When an unlock is attempted anyway + await expect(wallet.unlock(PASSWORD)).rejects.toThrow(); + + // Then the device stays uninstalled and retryable + expect(wallet.state).toBe('none'); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); + }); +}); diff --git a/src/stores/wallet.lifecycle.teardown.cases.ts b/src/stores/wallet.lifecycle.teardown.cases.ts index 5821063..eab5fb3 100644 --- a/src/stores/wallet.lifecycle.teardown.cases.ts +++ b/src/stores/wallet.lifecycle.teardown.cases.ts @@ -1,7 +1,7 @@ -import { MINT_KEY, PASSWORD, deferred, mocks } from './wallet.lifecycle.testHarness'; +import { MINT_KEY, PASSWORD, WALLET_MATERIAL_KEY, deferred, mocks } from './wallet.lifecycle.testHarness'; import { describe, expect, it, vi } from 'vitest'; -import { savedKeyExists } from '@/lnurlcash/keys'; +import { savedWalletMaterialExists, walletMaterialHash } from '@/lnurlcash/keys'; import { addTrustedMint } from '@/lnurlcash/trustedMints'; import { useNwcStore } from './nwc'; import { useWalletStore } from './wallet'; @@ -24,7 +24,7 @@ describe('complete owner teardown', () => { 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.requireWalletMaterial()).toThrow('Wallet is locked.'); expect(wallet.bearers).toEqual([]); expect(nwc.running).toBe(false); expect(serviceStop).toHaveBeenCalledTimes(1); @@ -58,9 +58,11 @@ describe('complete owner teardown', () => { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), + materialHash: walletMaterialHash(wallet.requireWalletMaterial()), + wrappedMaterial: '44'.repeat(48), createdAt: 1, ownerId, + version: 2, }, ]), ); @@ -79,19 +81,19 @@ describe('complete owner teardown', () => { // 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(savedWalletMaterialExists()).toBe(true); expect(wallet.state).toBe('unlocked'); drain.resolve(); await forgetting; expect(wallet.state).toBe('none'); - expect(savedKeyExists()).toBe(false); + expect(savedWalletMaterialExists()).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'), + cleanupOrder.indexOf(`storage:${WALLET_MATERIAL_KEY}`), ); }); @@ -105,12 +107,12 @@ describe('complete owner teardown', () => { // When forget reaches biometric teardown const forgetting = wallet.forgetWallet(); - // Then the caller sees failure and the saved key is not falsely removed + // Then the caller sees failure and the saved material 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.'); + expect(savedWalletMaterialExists()).toBe(true); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); }); it('surfaces NWC drain failure without removing the saved owner', async () => { @@ -127,8 +129,8 @@ describe('complete owner teardown', () => { 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.'); + expect(savedWalletMaterialExists()).toBe(true); + expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.'); }); it('completes teardown on retry after a transient biometric deletion failure', async () => { @@ -146,6 +148,6 @@ describe('complete owner teardown', () => { // Then teardown completes and the error surface clears expect(wallet.state).toBe('none'); expect(wallet.lifecycleError).toBe(''); - expect(savedKeyExists()).toBe(false); + expect(savedWalletMaterialExists()).toBe(false); }); }); diff --git a/src/stores/wallet.lifecycle.test.ts b/src/stores/wallet.lifecycle.test.ts index b33f3f5..eb308d7 100644 --- a/src/stores/wallet.lifecycle.test.ts +++ b/src/stores/wallet.lifecycle.test.ts @@ -1,4 +1,6 @@ import './wallet.lifecycle.activation.cases'; +import './wallet.lifecycle.material.cases'; +import './wallet.lifecycle.legacyReset.cases'; import './wallet.lifecycle.teardown.cases'; import './wallet.lifecycle.lockFailure.cases'; import './wallet.lifecycle.isolation.cases'; diff --git a/src/stores/wallet.lifecycle.testHarness.ts b/src/stores/wallet.lifecycle.testHarness.ts index 7613923..2030660 100644 --- a/src/stores/wallet.lifecycle.testHarness.ts +++ b/src/stores/wallet.lifecycle.testHarness.ts @@ -1,9 +1,19 @@ +import { bytesToHex } from '@noble/hashes/utils.js'; import { createPinia, setActivePinia } from 'pinia'; import { beforeEach, vi } from 'vitest'; -import { encryptSecretParts, linkingPubKeyHex } from '@/lnurlcash/keys'; +import { + deriveWalletMaterial, + encryptSecretParts, + linkingPubKeyHex, + serializeWalletMaterial, + walletMaterialHash, +} from '@/lnurlcash/keys'; +import type { WalletMaterialV2 } from '@/lnurlcash/keys'; import type * as NwcExports from '@/lnurlcash/nwc'; import type * as NostrBackupExports from '@/lnurlcash/nostrBackup'; +import type * as PasskeysExports from '@/lnurlcash/passkeys'; +import { WALLET_MATERIAL_STORAGE_KEY } from '@/lnurlcash/storage/walletOwnerEvents'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import { lifecycleMocks } from './wallet.lifecycle.testMocks'; @@ -13,7 +23,16 @@ vi.mock('@/capabilities/biometricUnlock', async () => { const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); return { disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock, - unlockWithBiometrics: vi.fn(), + unlockWalletMaterialWithBiometrics: lifecycleMocks.unlockWalletMaterialWithBiometrics, + }; +}); + +vi.mock('@/lnurlcash/passkeys', async (importOriginal) => { + const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); + const actual = await importOriginal(); + return { + ...actual, + unlockWalletMaterialWithPasskey: lifecycleMocks.unlockWalletMaterialWithPasskey, }; }); @@ -31,19 +50,71 @@ vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => { export const LINKING_KEY = new Uint8Array(32).fill(7); export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +export const MATERIAL: WalletMaterialV2 = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +}; +export const OTHER_MATERIAL: WalletMaterialV2 = { + ...MATERIAL, + linkingKeyHex: bytesToHex(OTHER_LINKING_KEY), +}; 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, +export const WALLET_MATERIAL_KEY = WALLET_MATERIAL_STORAGE_KEY; +export const LEGACY_LINKING_KEY = 'sattle_linking_key'; +// the frozen pre-v2 bearer namespace: current storage never writes it, so +// its presence means an alpha install +export const LEGACY_BEARERS_KEY = 'sattle_bearers'; + +export const encryptedWalletMaterialRecord = async ( + material: WalletMaterialV2 = MATERIAL, + password: string = PASSWORD, +) => { + const parts = await encryptSecretParts(serializeWalletMaterial(material), password); + return { enc: true as const, ...parts, materialHash: walletMaterialHash(material) }; +}; + +// a v2 install whose owner marker is already proven (the steady state after +// any successful unlock) +export const installEncryptedWalletMaterial = async (): Promise => { + const record = await encryptedWalletMaterialRecord(); + localStorage.setItem( + WALLET_MATERIAL_KEY, + JSON.stringify({ ...record, ownerId: OWNER_ID, version: 2 }), ); +}; + +// a v2 install whose owner marker was stripped on restore - usable but not +// yet proven, the state a backup/relay restore leaves behind +export const installOwnerlessEncryptedWalletMaterial = async (): Promise => { + localStorage.setItem( + WALLET_MATERIAL_KEY, + JSON.stringify(await encryptedWalletMaterialRecord()), + ); +}; + +// the pre-v2 record shape a backup file can still carry - the lifecycle must +// never activate it, only clear it +export const encryptedLinkingKeyRecord = async () => { + const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD); return { enc: true as const, ...parts }; }; +// unsupported alpha state: the linking-key-only record and the legacy bearer +// namespace, never written by the v2 lifecycle +export const installLegacyWalletState = (): void => { + localStorage.setItem( + LEGACY_LINKING_KEY, + JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId: OWNER_ID, version: 1 }), + ); + localStorage.setItem(LEGACY_BEARERS_KEY, JSON.stringify([])); +}; + type Deferred = { readonly promise: Promise; readonly resolve: () => void; @@ -60,6 +131,38 @@ export const deferred = (): Deferred => { }; }; +type LockRequest = { + readonly callback: () => unknown; + readonly resolve: (value: unknown) => void; + readonly reject: (error: unknown) => void; +}; + +// a navigator.locks stand-in whose requests queue up until the test releases +// them one at a time - the deferral hook for mid-activation interruptions. +// A callback failure rejects the lock promise (the real LockManager settles +// the caller's promise with the callback outcome) +export const deferredLocks = () => { + const requests: LockRequest[] = []; + return { + requests, + locks: { + request: (_name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + requests.push({ callback, resolve, reject }); + }), + }, + releaseNext: async (): Promise => { + const request = requests.shift(); + if (!request) throw new Error('Expected a queued lock request.'); + try { + request.resolve(await request.callback()); + } catch (error) { + request.reject(error); + } + }, + }; +}; + export const installLegacyOwnerlessResidue = (): void => { localStorage.setItem( 'sattle_passkey_slots', @@ -68,7 +171,8 @@ export const installLegacyOwnerlessResidue = (): void => { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), + materialHash: walletMaterialHash(MATERIAL), + wrappedMaterial: '44'.repeat(48), createdAt: 1, }, ]), @@ -99,16 +203,18 @@ export const installLegacyOwnerlessResidue = (): void => { ); }; -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.unlockWalletMaterialWithBiometrics.mockRejectedValue( + new Error('Biometric unlock is not set up on this device.'), + ); + lifecycleMocks.unlockWalletMaterialWithPasskey.mockRejectedValue( + new Error('No passkeys registered on this device.'), + ); lifecycleMocks.restoreFromNostr.mockResolvedValue({ added: 0, skipped: 0, diff --git a/src/stores/wallet.lifecycle.testMocks.ts b/src/stores/wallet.lifecycle.testMocks.ts index 0fa789d..8f3d4fd 100644 --- a/src/stores/wallet.lifecycle.testMocks.ts +++ b/src/stores/wallet.lifecycle.testMocks.ts @@ -2,6 +2,8 @@ import { vi } from 'vitest'; export const lifecycleMocks = { disableBiometricUnlock: vi.fn<() => Promise>(), + unlockWalletMaterialWithBiometrics: vi.fn(), + unlockWalletMaterialWithPasskey: vi.fn(), restoreFromNostr: vi.fn(), startService: vi.fn(), }; diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index ec8b7ef..7463dc7 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -1,13 +1,18 @@ +// allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface. import { computed, onScopeDispose, ref } from 'vue'; import { defineStore } from 'pinia'; import { - deriveBearerAesKey, - savedKeyExists, - savedKeyIsEncrypted, - savedKeyOwnerId, clearSavedLinkingKey, + clearSavedWalletMaterial, + deriveBearerAesKey, + savedWalletMaterialExists, + savedWalletMaterialIsEncrypted, + savedWalletMaterialOwnerId, + walletMaterialLinkingKey, } from '@/lnurlcash/keys'; +import type { WalletMaterialV2 } from '@/lnurlcash/keys'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; import { loadBearers, clearAllBearers, @@ -32,7 +37,7 @@ import { createSeedInstaller, createWalletTransitionQueue, migrateProvenLegacyOwner, - ownerOf, + resetUnsupportedLegacyWalletState, stopWalletNwcSession, WalletLifecycleError, } from './walletLifecycle'; @@ -40,23 +45,25 @@ import { 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 +// 'locked': wallet material present but password-encrypted -> unlock +// 'unlocked': the complete material (linking key + cash root) is in memory 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 +// a plaintext-stored material record also starts 'locked' - init() unlocks +// it immediately without a password, keeping a single code path for deriving // the AES key and loading bearers export const useWalletStore = defineStore('wallet', () => { - const state = ref(savedKeyExists() ? 'locked' : 'none'); + const state = ref(savedWalletMaterialExists() ? '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; + // the complete v2 wallet material, only while unlocked - the cash root + // funds every BIP-32 secret derivation and the linking key inside it backs + // backup/passkey operations (nostrBackup derives the backup key from it, + // passkey and biometric enrollment wrap the whole value). Never exposed + // reactively; cleared on lock/forget/failed activation + let currentMaterial: WalletMaterialV2 | null = null; let lifecycleToken = 0; let acceptingOwnerWork = false; @@ -68,7 +75,7 @@ export const useWalletStore = defineStore('wallet', () => { }, }).run; - const encrypted = computed(() => savedKeyIsEncrypted()); + const encrypted = computed(() => savedWalletMaterialIsEncrypted()); const ownerFence = createWalletOwnerFence({ state: () => state.value, @@ -79,7 +86,7 @@ export const useWalletStore = defineStore('wallet', () => { const clearRuntime = (): void => { aesKey = null; - currentLinkingKey = null; + currentMaterial = null; pubkey.value = null; funds.clear(); useActivityStore().unload(); @@ -124,12 +131,12 @@ export const useWalletStore = defineStore('wallet', () => { const lock = (): Promise => runTransition(async () => { - if (!savedKeyIsEncrypted()) return; + if (!savedWalletMaterialIsEncrypted()) return; await deactivateSession(); }); const idleWatch = createWalletIdleWatch({ - isEncrypted: savedKeyIsEncrypted, + isEncrypted: savedWalletMaterialIsEncrypted, isUnlocked: () => state.value === 'unlocked', isLockWarningVisible: () => lockWarningSecondsLeft.value !== null, lock, @@ -138,30 +145,53 @@ export const useWalletStore = defineStore('wallet', () => { }, }); - const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise => { + // Activation enters the unlocked state for exactly the saved material. + // The owner monitor is subscribed BEFORE the first async gap and the saved + // owner is re-read immediately before exposing 'unlocked': a replacement + // that landed during deferred loading must fence this activation instead + // of letting a stale owner through + const activate = async (material: WalletMaterialV2, ownerWasMissing: boolean): Promise => { auxiliaryError.value = ''; - await migrateProvenLegacyOwner(linkingKey, ownerWasMissing); - const key = await deriveBearerAesKey(linkingKey); - const loaded = await loadBearers(key); - const activity = useActivityStore(); - await activity.loadFor(key); - const ownerId = ownerOf(linkingKey); - await restoreHeldMintTrust(loaded, ownerId, (message) => { - auxiliaryError.value = message; - }); - aesKey = key; - currentLinkingKey = linkingKey; - lifecycleToken += 1; + const ownerId = walletMaterialOwnerId(material); observeOwnerChanges(); - pubkey.value = ownerId; - funds.replace(loaded); - acceptingOwnerWork = true; - state.value = 'unlocked'; - idleWatch.start(); + try { + await migrateProvenLegacyOwner(material, ownerWasMissing); + if (savedWalletMaterialOwnerId() !== ownerId) { + throw new WalletLifecycleError('activate', new Error('saved wallet changed')); + } + const key = await deriveBearerAesKey(walletMaterialLinkingKey(material)); + const loaded = await loadBearers(key); + const activity = useActivityStore(); + await activity.loadFor(key); + await restoreHeldMintTrust(loaded, ownerId, (message) => { + auxiliaryError.value = message; + }); + if (savedWalletMaterialOwnerId() !== ownerId) { + throw new WalletLifecycleError('activate', new Error('saved wallet changed')); + } + aesKey = key; + currentMaterial = material; + lifecycleToken += 1; + pubkey.value = ownerId; + funds.replace(loaded); + acceptingOwnerWork = true; + state.value = 'unlocked'; + idleWatch.start(); + } catch (error) { + stopOwnerChanges(); + acceptingOwnerWork = false; + lifecycleToken += 1; + pubkey.value = null; + clearRuntime(); + // a failed activation is locked when a wallet record exists (retry via + // unlock) and uninstalled when none does - never half-installed + state.value = savedWalletMaterialExists() ? 'locked' : 'none'; + throw error; + } }; const teardownCurrentOwner = async (resetRegistry = false): Promise => { - const ownerId = savedKeyOwnerId() ?? pubkey.value; + const ownerId = savedWalletMaterialOwnerId() ?? pubkey.value; acceptingOwnerWork = false; stopOwnerChanges(); idleWatch.stop(); @@ -178,7 +208,12 @@ export const useWalletStore = defineStore('wallet', () => { clearAllBearers(); useActivityStore().unloadAndClear(); clearSettings(); + // invalidate the legacy saved-key entry too: old tabs watching it must + // fence themselves, and the record is residue under the v2 lifecycle clearSavedLinkingKey(); + // the saved material goes last: a failure above leaves the complete + // enrollment (and this record) in place for a safe retry + clearSavedWalletMaterial(); state.value = 'none'; } finally { // a failed teardown still ends the session: 'locked' must never hold @@ -189,12 +224,16 @@ export const useWalletStore = defineStore('wallet', () => { }; const prepareInstallation = async (nextOwnerId: string): Promise => { - const installedOwner = savedKeyOwnerId() ?? pubkey.value; - if (savedKeyExists() && installedOwner === nextOwnerId) { + // the install path re-runs the alpha reset, so a boot-time reset that + // failed (e.g. native biometric deletion) is retried before any + // successor is installed + await resetUnsupportedLegacyWalletState(); + const installedOwner = savedWalletMaterialOwnerId() ?? pubkey.value; + if (savedWalletMaterialExists() && installedOwner === nextOwnerId) { if (state.value === 'unlocked') await deactivateSession(); return; } - if (savedKeyExists() || state.value === 'unlocked') { + if (savedWalletMaterialExists() || state.value === 'unlocked') { await teardownCurrentOwner(true); } await clearUnownedAuthorizations(); @@ -202,7 +241,7 @@ export const useWalletStore = defineStore('wallet', () => { const installSeed = createSeedInstaller({ prepareInstallation, - activate: (linkingKey) => activate(linkingKey, false), + activate: (material) => activate(material, false), }); const access = createWalletAccess({ @@ -210,19 +249,25 @@ export const useWalletStore = defineStore('wallet', () => { installSeed, activate, canInit: () => state.value === 'locked', + onResetError: (error) => { + lifecycleError.value = error instanceof Error ? error.message : 'Wallet reset failed.'; + }, }); const restoreFromBackup = (data: unknown): Promise => runTransition(async () => { const backup = parseBackupFile(data); - const hadSavedKey = savedKeyExists(); + const hadSavedMaterial = savedWalletMaterialExists(); const activeOwner = pubkey.value; - const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null; - if (activeKey !== null) await deactivateSession(); - if (!hadSavedKey) await clearUnownedAuthorizations(); + const activeMaterial = state.value === 'unlocked' ? requireWalletMaterial() : null; + if (activeMaterial !== null) await deactivateSession(); + if (!hadSavedMaterial) await clearUnownedAuthorizations(); const result = await applyBackup(backup, activeOwner ?? undefined); - if (activeKey !== null) await activate(activeKey, false); - else if (result.linkingKeyRestored) state.value = 'locked'; + // a file-carried legacy linking key can never become v2 material (the + // cash root is unrecoverable from it): the record applyBackup just + // installed is residue here, and removing it keeps old tabs fenced + clearSavedLinkingKey(); + if (activeMaterial !== null) await activate(activeMaterial, false); return result; }); @@ -232,21 +277,21 @@ export const useWalletStore = defineStore('wallet', () => { password?: string, ): Promise => runTransition(() => - installSeed(seedPhrase, password, async (linkingKey) => { - await restoreFromNostrEngine(linkingKey, relays); + installSeed(seedPhrase, password, async (material) => { + await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays); }), ); const restoreCurrentFromNostr = (relays: string[]) => runTransition(async () => { - const linkingKey = requireLinkingKey(); + const material = requireWalletMaterial(); await deactivateSession(); - const result = await restoreFromNostrEngine(linkingKey, relays); - await activate(linkingKey, false); + const result = await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays); + await activate(material, false); return result; }); - // wipes this wallet from the device entirely - the linking key, every + // wipes this wallet from the device entirely - the wallet material, 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 @@ -267,9 +312,13 @@ export const useWalletStore = defineStore('wallet', () => { // narrow accessor for the operations that need the key material itself // (nostr backup key derivation, passkey registration) - never reactive, // throws when locked, so callers can't accidentally hold a stale key - const requireLinkingKey = (): Uint8Array => { - if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.'); - return currentLinkingKey; + const requireLinkingKey = (): Uint8Array => walletMaterialLinkingKey(requireWalletMaterial()); + + // the complete material for the enrollment flows that wrap it (passkey, + // biometric) - same access rules as requireLinkingKey + const requireWalletMaterial = (): WalletMaterialV2 => { + if (!acceptingOwnerWork || !currentMaterial) throw new Error('Wallet is locked.'); + return currentMaterial; }; const funds = createWalletFunds({ @@ -301,6 +350,7 @@ export const useWalletStore = defineStore('wallet', () => { forgetWallet, postponeLock: idleWatch.postpone, requireLinkingKey, + requireWalletMaterial, captureOwnerFence: ownerFence.capture, }; }); diff --git a/src/stores/walletAccess.ts b/src/stores/walletAccess.ts index 17bc85c..b71f4cb 100644 --- a/src/stores/walletAccess.ts +++ b/src/stores/walletAccess.ts @@ -1,22 +1,28 @@ import { - decryptSavedLinkingKey, + decryptSavedWalletMaterial, generateSeedPhrase, - getPlainLinkingKey, - savedKeyIsEncrypted, - savedKeyOwnerId, + getPlainWalletMaterial, + savedWalletMaterialIsEncrypted, + savedWalletMaterialOwnerId, } from '@/lnurlcash/keys'; -import { unlockWithPasskey } from '@/lnurlcash/passkeys'; -import { unlockWithBiometrics } from '@/capabilities/biometricUnlock'; +import type { WalletMaterialV2 } from '@/lnurlcash/keys'; +import { unlockWalletMaterialWithPasskey } from '@/lnurlcash/passkeys'; +import { unlockWalletMaterialWithBiometrics } from '@/capabilities/biometricUnlock'; + +import { resetUnsupportedLegacyWalletState } from './walletLifecycle'; type RunTransition = (transition: () => Promise) => Promise; type InstallSeed = (seedPhrase: string, password?: string) => Promise; -type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise; +type Activate = (material: WalletMaterialV2, ownerWasMissing: boolean) => Promise; type WalletAccessOptions = Readonly<{ runTransition: RunTransition; installSeed: InstallSeed; activate: Activate; canInit: () => boolean; + // reset failures must surface without killing boot: routing proceeds to + // the uninstalled state and the next install attempt retries the reset + onResetError: (error: unknown) => void; }>; export const createWalletAccess = ({ @@ -24,10 +30,11 @@ export const createWalletAccess = ({ installSeed, activate, canInit, + onResetError, }: WalletAccessOptions) => { - const activateSavedKey = async (linkingKey: Uint8Array | null): Promise => { - if (!linkingKey) throw new Error('No wallet on this device.'); - await activate(linkingKey, savedKeyOwnerId() === null); + const activateSavedMaterial = async (material: WalletMaterialV2 | null): Promise => { + if (!material) throw new Error('No wallet on this device.'); + await activate(material, savedWalletMaterialOwnerId() === null); }; const create = (password?: string): Promise => runTransition(async () => { @@ -39,23 +46,30 @@ export const createWalletAccess = ({ runTransition(() => installSeed(seedPhrase, password)); const unlock = (password?: string): Promise => runTransition(async () => { - const linkingKey = savedKeyIsEncrypted() - ? await decryptSavedLinkingKey(password || '') - : getPlainLinkingKey(); - await activateSavedKey(linkingKey); + const material = savedWalletMaterialIsEncrypted() + ? await decryptSavedWalletMaterial(password || '') + : getPlainWalletMaterial(); + await activateSavedMaterial(material); }); const unlockWithPasskeyCredential = (): Promise => runTransition(async () => { - await activate(await unlockWithPasskey(), false); + await activate(await unlockWalletMaterialWithPasskey(), false); }); const unlockWithBiometric = (): Promise => runTransition(async () => { - await activateSavedKey(await unlockWithBiometrics()); + await activateSavedMaterial(await unlockWalletMaterialWithBiometrics()); }); const init = (): Promise => runTransition(async () => { - if (!canInit() || savedKeyIsEncrypted()) return; - await activateSavedKey(getPlainLinkingKey()); + // alpha reset runs before any routing decision can expose the wallet: + // an unsupported legacy install is wiped, never migrated + try { + await resetUnsupportedLegacyWalletState(); + } catch (error) { + onResetError(error); + } + if (!canInit() || savedWalletMaterialIsEncrypted()) return; + await activateSavedMaterial(getPlainWalletMaterial()); }); return { create, diff --git a/src/stores/walletLifecycle.ts b/src/stores/walletLifecycle.ts index e0c19eb..6ec7155 100644 --- a/src/stores/walletLifecycle.ts +++ b/src/stores/walletLifecycle.ts @@ -1,11 +1,15 @@ import { - deriveWalletLinkingKey, - ensureSavedKeyOwner, + deriveWalletMaterial, + ensureSavedWalletMaterialOwner, isValidSeedPhrase, - linkingPubKeyHex, - saveLinkingKey, + saveWalletMaterial, + savedWalletMaterialExists, + walletMaterialLinkingKey, } from '@/lnurlcash/keys'; +import type { WalletMaterialV2 } from '@/lnurlcash/keys'; +import { disableBiometricUnlock } from '@/capabilities/biometricUnlock'; import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys'; +import { clearAllActivity, clearSettings } from '@/lnurlcash/storage'; import { clearPasskeySlotsForOwner, clearUnownedPasskeySlots, @@ -17,6 +21,11 @@ import { migrateLegacyNwcStorage, } from '@/lnurlcash/storage/nwcConnections'; import { withStorageLock } from '@/lnurlcash/storageLock'; +import { + LINKING_KEY_STORAGE_KEY, + WALLET_MATERIAL_STORAGE_KEY, +} from '@/lnurlcash/storage/walletOwnerEvents'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; import { migrateLegacyTrustedMints, removeTrustedMintsForOwner, @@ -75,11 +84,12 @@ export const stopWalletNwcSession = async (): Promise => { }; export const migrateProvenLegacyOwner = async ( - linkingKey: Uint8Array, + material: WalletMaterialV2, ownerWasMissing: boolean, ): Promise => { - ensureSavedKeyOwner(linkingKey); + ensureSavedWalletMaterialOwner(material); if (!ownerWasMissing) return; + const linkingKey = walletMaterialLinkingKey(material); await migrateLegacyPasskeySlots(linkingKey); migrateLegacyNwcStorage(linkingKey); await migrateLegacyTrustedMints(linkingKey); @@ -103,26 +113,72 @@ export const clearUnownedAuthorizations = async (): Promise => { await resetTrustedMintsForReplacement(); }; -export const ownerOf = (linkingKey: Uint8Array): string => linkingPubKeyHex(linkingKey); +// ---- unsupported legacy (alpha) install reset ---- + +// Frozen namespaces the v2 lifecycle never writes: the linking-key-only +// record and the pre-funds-document bearer store. Their raw presence (even +// corrupt or unparseable) means this device holds an alpha install, which +// cannot be migrated - the cash root is unrecoverable from a linking key - +// so it is reset, never upgraded. +const LEGACY_BEARERS_STORAGE_KEY = 'sattle_bearers'; +// current owner-bound namespaces that a full reset removes outright; the +// key strings are duplicated here because the reset deletes whole +// namespaces by name rather than going through owner-scoped APIs +const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections'; +const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled'; + +export const hasUnsupportedLegacyWalletState = (): boolean => + !savedWalletMaterialExists() && + (localStorage.getItem(LINKING_KEY_STORAGE_KEY) !== null || + localStorage.getItem(LEGACY_BEARERS_STORAGE_KEY) !== null); + +// Alpha reset. No migration: every namespace the alpha wallet could have +// touched is removed and the device returns to the uninstalled state. The +// native biometric secret is deleted FIRST (same ordering as forget): if +// that fails, nothing local has been wiped yet, so the next attempt retries +// the whole reset instead of leaving a half-wiped install. When a v2 +// install is present instead, only stray legacy-key residue is removed - the +// live wallet (including its bearer store) is untouched. +export const resetUnsupportedLegacyWalletState = async (): Promise => { + if (savedWalletMaterialExists()) { + localStorage.removeItem(LINKING_KEY_STORAGE_KEY); + return; + } + if (!hasUnsupportedLegacyWalletState()) return; + await disableBiometricUnlock(); + localStorage.removeItem(LINKING_KEY_STORAGE_KEY); + localStorage.removeItem(LEGACY_BEARERS_STORAGE_KEY); + localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY); + localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY); + localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY); + localStorage.removeItem(NWC_ENABLED_STORAGE_KEY); + clearAllActivity(); + clearSettings(); + await resetTrustedMintsForReplacement(); +}; type SeedInstallerOptions = { readonly prepareInstallation: (ownerId: string) => Promise; - readonly activate: (linkingKey: Uint8Array) => Promise; + readonly activate: (material: WalletMaterialV2) => Promise; }; export type SeedInstaller = ( seedPhrase: string, password?: string, - restore?: (linkingKey: Uint8Array) => Promise, + restore?: (material: WalletMaterialV2) => 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); + const material = deriveWalletMaterial(seedPhrase); + await options.prepareInstallation(walletMaterialOwnerId(material)); + if (restore) await restore(material); + // a restore engine may have installed a legacy linking-key record from + // the backup payload: the v2 lifecycle never activates it, so it is + // residue - removing it here also fences old tabs still watching that key + localStorage.removeItem(LINKING_KEY_STORAGE_KEY); + await saveWalletMaterial(material, password); + await options.activate(material); }; diff --git a/src/stores/walletOwnerMonitor.test.ts b/src/stores/walletOwnerMonitor.test.ts index ce963ff..c7ca6bd 100644 --- a/src/stores/walletOwnerMonitor.test.ts +++ b/src/stores/walletOwnerMonitor.test.ts @@ -1,11 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { linkingPubKeyHex, saveWalletMaterial, walletMaterialHash } from '@/lnurlcash/keys'; +import type { WalletMaterialV2 } from '@/lnurlcash/keys'; +import { deriveWalletMaterial } from '@/lnurlcash/keys'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { bytesToHex } from '@noble/hashes/utils.js'; import { startWalletOwnerMonitor } from './walletOwnerMonitor'; -const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); -const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); +const LINKING_KEY = new Uint8Array(32).fill(7); +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const MATERIAL: WalletMaterialV2 = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +}; +const OTHER_MATERIAL: WalletMaterialV2 = { + ...MATERIAL, + linkingKeyHex: bytesToHex(OTHER_LINKING_KEY), +}; +const OWNER_ID = linkingPubKeyHex(LINKING_KEY); describe('wallet owner monitor', () => { beforeEach(() => { @@ -13,19 +27,12 @@ describe('wallet owner monitor', () => { stubLocalStorage(); }); - it('consumes a background transition rejection after an owner replacement', () => { + it('consumes a background transition rejection after an owner replacement', async () => { // 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, - }), - ); + await saveWalletMaterial(OTHER_MATERIAL); + expect(walletMaterialHash(OTHER_MATERIAL)).toBeTruthy(); const transition = Promise.resolve(); const catchRejection = vi.spyOn(transition, 'catch'); startWalletOwnerMonitor({ @@ -34,14 +41,39 @@ describe('wallet owner monitor', () => { runTransition: vi.fn().mockReturnValue(transition), }); - // When the browser reports that the saved owner changed + // When the browser reports that the saved material changed events.dispatchEvent( Object.defineProperties(new Event('storage'), { - key: { value: 'sattle_linking_key' }, + key: { value: 'sattle_wallet_material_v2' }, }), ); // Then the fire-and-forget transition has a rejection consumer expect(catchRejection).toHaveBeenCalledOnce(); }); + + it('ignores legacy-key wakeups while the saved v2 owner is intact', async () => { + // Given an unlocked owner whose v2 record is untouched + const events = new EventTarget(); + vi.stubGlobal('window', events); + await saveWalletMaterial(MATERIAL); + const deactivate = vi.fn().mockResolvedValue(undefined); + const stop = startWalletOwnerMonitor({ + snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }), + deactivate, + runTransition: vi.fn((transition: () => Promise) => transition()), + }); + + // When a legacy-key storage event arrives (an old tab clearing residue) + events.dispatchEvent( + Object.defineProperties(new Event('storage'), { + key: { value: 'sattle_linking_key' }, + }), + ); + await Promise.resolve(); + + // Then the session stays active: the v2 owner never changed + expect(deactivate).not.toHaveBeenCalled(); + stop(); + }); }); diff --git a/src/stores/walletOwnerMonitor.ts b/src/stores/walletOwnerMonitor.ts index 5453b7f..1935f75 100644 --- a/src/stores/walletOwnerMonitor.ts +++ b/src/stores/walletOwnerMonitor.ts @@ -1,4 +1,4 @@ -import { savedKeyOwnerId } from '@/lnurlcash/keys'; +import { savedWalletMaterialOwnerId } from '@/lnurlcash/keys'; import { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents'; import type { WalletState } from './walletOwnerFence'; @@ -14,13 +14,16 @@ type WalletOwnerMonitor = Readonly<{ runTransition: (transition: () => Promise) => Promise; }>; +// Wakes on any saved-key storage event (the v2 material record, the legacy +// linking-key entry, or a clear) and re-reads the CURRENT v2 owner: payload +// and legacy-entry changes are only wakeups, never trusted state export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) => onSavedKeyStorageChange(() => { const expected = monitor.snapshot(); if ( expected.state !== 'unlocked' || expected.ownerId === null || - savedKeyOwnerId() === expected.ownerId + savedWalletMaterialOwnerId() === expected.ownerId ) { return; } @@ -31,7 +34,7 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi current.token !== expected.token || current.state !== 'unlocked' || current.ownerId !== expected.ownerId || - savedKeyOwnerId() === expected.ownerId + savedWalletMaterialOwnerId() === expected.ownerId ) { return; } From ff9e8a073e07c683b86cd4b160aba6e8e5a9c1fb Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 09:29:05 +0200 Subject: [PATCH 12/29] test(funds): fence harness on v2 owner install --- src/lnurlcash/nostrBackup.restore.cases.ts | 13 +++++-- src/lnurlcash/storage.backup.test.ts | 14 ++++++- src/stores/walletFunds.fencing.test.ts | 45 ++++++++++++---------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/lnurlcash/nostrBackup.restore.cases.ts b/src/lnurlcash/nostrBackup.restore.cases.ts index 35ea094..fffc551 100644 --- a/src/lnurlcash/nostrBackup.restore.cases.ts +++ b/src/lnurlcash/nostrBackup.restore.cases.ts @@ -9,7 +9,7 @@ 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 {deriveBearerAesKey, deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys' import { BACKUP_EVENT_KIND, backupPubkey, @@ -40,6 +40,13 @@ 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) +// full v2 material for the fixed harness linking key (real BIP-32 cash root) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const K1_A = 'aa'.repeat(32) const K1_B = 'bb'.repeat(32) @@ -103,7 +110,7 @@ describe('restoreFromNostr', () => { const {transport} = createRecordingTransport() // device A: one note, one trusted mint, one setting - all published - await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) await persistBearer(aesKey, bearerFixture({id: 'note-a'})) await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID}) persistSettings({defaultMint: 'mint.example'}) @@ -120,7 +127,7 @@ describe('restoreFromNostr', () => { // device B: the same seed on empty storage stubLocalStorage() - await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) expect(result.found).toEqual(['notes', 'mints', 'settings']) diff --git a/src/lnurlcash/storage.backup.test.ts b/src/lnurlcash/storage.backup.test.ts index 5d7926f..4175e22 100644 --- a/src/lnurlcash/storage.backup.test.ts +++ b/src/lnurlcash/storage.backup.test.ts @@ -6,7 +6,9 @@ import {beforeEach, describe, expect, it, vi} from 'vitest' -import {linkingPubKeyHex, saveLinkingKey} from './keys' +import {bytesToHex} from '@noble/hashes/utils.js' + +import {deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys' import {applyBackup, buildBackup, parseBackupFile} from './storage' import {readFundsDocument, writeFundsDocument} from './storage/bearers' import {addTrustedMint, readTrustedMints} from './trustedMints' @@ -16,6 +18,14 @@ 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) +// full v2 material for this harness's fixed linking key (real BIP-32 cash +// root, same construction as the lifecycle test harness) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} type LockRequest = { readonly callback: () => unknown @@ -59,7 +69,7 @@ const backup = (server: string, mintPubkey: string) => ({ ], }) -const installProvenOwner = (): Promise => saveLinkingKey(LINKING_KEY) +const installProvenOwner = (): Promise => saveWalletMaterial(MATERIAL) beforeEach(() => { vi.unstubAllGlobals() diff --git a/src/stores/walletFunds.fencing.test.ts b/src/stores/walletFunds.fencing.test.ts index 000cf4c..fb86ea5 100644 --- a/src/stores/walletFunds.fencing.test.ts +++ b/src/stores/walletFunds.fencing.test.ts @@ -2,7 +2,11 @@ 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 { + deriveBearerAesKey, + deriveWalletMaterial, + saveWalletMaterial, +} from '@/lnurlcash/keys'; import { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage'; import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; @@ -11,7 +15,14 @@ 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)); +// a full v2 material for the successor owner: real BIP-32 cash root, fixed +// other linking key (same construction as the lifecycle test harness) +const OTHER_MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: '09'.repeat(32), +}; // fund commits REQUIRE Web Locks now; tests that don't care about lock // timing install a present-but-non-serializing fake @@ -28,19 +39,13 @@ const note = (secret: string): NewBearer => ({ 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, - }), - ); +// a second tab installing its own wallet: the successor lifecycle writes its +// v2 wallet-material record and deletes the legacy linking-key record, so the +// saved-key owner flips to the successor. Deliberately NO storage event is +// dispatched - the fence must not depend on the wakeup arriving first. +const replacePersistedOwner = async (): Promise => { + localStorage.removeItem('sattle_linking_key'); + await saveWalletMaterial(OTHER_MATERIAL); }; type LockRequest = { @@ -90,7 +95,7 @@ describe('stale-owner fencing of fund commits', () => { // When another tab replaced the wallet and this (still unlocked) tab // tries to commit a fund change - replacePersistedOwner(); + await replacePersistedOwner(); await expect( wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence), ).rejects.toBeInstanceOf(WalletOwnerMismatchError); @@ -109,7 +114,7 @@ describe('stale-owner fencing of fund commits', () => { const ownerFence = wallet.captureOwnerFence(); const [existing] = await wallet.addBearers([note('aa')], ownerFence); if (!existing) throw new Error('Expected the initial bearer.'); - replacePersistedOwner(); + await replacePersistedOwner(); // When the stale tab marks the note spent await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf( @@ -140,7 +145,7 @@ describe('stale-owner fencing of fund commits', () => { ownerFence, ); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); - replacePersistedOwner(); + await replacePersistedOwner(); await locks.releaseNext(); // Then the commit fails closed instead of writing stale-owner ciphertext @@ -185,7 +190,7 @@ describe('stale-owner fencing of fund commits', () => { const updating = wallet.markSpent(existing.id, ownerFence); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); - replacePersistedOwner(); + await replacePersistedOwner(); await locks.releaseNext(); await expect(updating).rejects.toBeInstanceOf(WalletOwnerMismatchError); @@ -206,7 +211,7 @@ describe('stale-owner fencing of fund commits', () => { const removing = wallet.removeNote(existing.id, ownerFence); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); - replacePersistedOwner(); + await replacePersistedOwner(); await locks.releaseNext(); await expect(removing).rejects.toBeInstanceOf(WalletOwnerMismatchError); From 7b7d4edaf362371d5a0e149161b363dd9897a47c Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 09:42:51 +0200 Subject: [PATCH 13/29] test(stores): stub mutation locks in trust and nwc-drain harnesses --- src/stores/wallet.lifecycle.nwcDrain.test.ts | 6 +++++- src/stores/wallet.trust.test.ts | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/stores/wallet.lifecycle.nwcDrain.test.ts b/src/stores/wallet.lifecycle.nwcDrain.test.ts index 9f9c65a..8b3f3a9 100644 --- a/src/stores/wallet.lifecycle.nwcDrain.test.ts +++ b/src/stores/wallet.lifecycle.nwcDrain.test.ts @@ -69,7 +69,11 @@ const mints: Mint[] = []; beforeEach(() => { vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + // fund mutations require Web Locks; this harness doesn't care about lock + // timing, so a present-but-non-serializing fake is enough + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); stubLocalStorage(); setActivePinia(createPinia()); }); diff --git a/src/stores/wallet.trust.test.ts b/src/stores/wallet.trust.test.ts index 9ddb091..79f9a4f 100644 --- a/src/stores/wallet.trust.test.ts +++ b/src/stores/wallet.trust.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveBearerAesKey } from '@/lnurlcash/keys'; import { loadBearers } from '@/lnurlcash/storage'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { TrustedMintPostCommitError, useWalletStore } from './wallet'; @@ -23,6 +24,14 @@ type LockRequest = { readonly resolve: (value: unknown) => void; }; +// fund mutations require Web Locks; tests that don't care about lock timing +// install a present-but-non-serializing fake +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + class DeferredLocks { readonly requests: LockRequest[] = []; @@ -46,7 +55,7 @@ beforeEach(() => { describe('bearer commit trust side effect', () => { it('commits a combined addition and spent marker in one bearer write', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const storage = stubLocalStorage(); const wallet = useWalletStore(); await wallet.create(); @@ -64,13 +73,13 @@ describe('bearer commit trust side effect', () => { ownerFence, ]); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).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', {}); + stubPassthroughLocks(); const storage = stubLocalStorage(); const wallet = useWalletStore(); await wallet.create(); @@ -119,7 +128,7 @@ describe('bearer commit trust side effect', () => { }); it('reports trust failure as post-commit while preserving durable funds', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const storage = stubLocalStorage(); const wallet = useWalletStore(); await wallet.create(); From a30c4bb2cc60250a22c3c3f2c044393bd4d2d778 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 10:22:24 +0200 Subject: [PATCH 14/29] feat(trust): keep previous mint signing key verifiable --- src/lnurlcash/trustedMintTransitions.ts | 4 ++++ src/lnurlcash/trustedMints.test.ts | 24 ++++++++++++++++++++++++ src/lnurlcash/trustedMints.ts | 22 ++++++++++++++++++++++ src/lnurlcash/trustedMintsRegistry.ts | 3 +++ 4 files changed, 53 insertions(+) diff --git a/src/lnurlcash/trustedMintTransitions.ts b/src/lnurlcash/trustedMintTransitions.ts index ef3287f..328ce2f 100644 --- a/src/lnurlcash/trustedMintTransitions.ts +++ b/src/lnurlcash/trustedMintTransitions.ts @@ -164,6 +164,10 @@ export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTran ...mint, mintPubkey: pending, pendingMintPubkey: undefined, + // the retiring pin stays verifiable for the notes it already + // signed (landed-mutation verification accepts current OR + // previous); it is never a target for new trust decisions + previousMintPubkey: mint.mintPubkey, unconfirmed: undefined, } : mint, diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts index 33611f3..a87f93c 100644 --- a/src/lnurlcash/trustedMints.test.ts +++ b/src/lnurlcash/trustedMints.test.ts @@ -11,6 +11,7 @@ import { confirmTrustedMintRekey, dismissTrustedMintRekey, getTrustedMintPubkey, + getTrustedMintVerificationKeys, grandfatherTrustedMint, isMintTrusted, isMintUnconfirmed, @@ -88,6 +89,29 @@ describe('rekey staging', () => { expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) }) + it('keeps the retiring key verifiable as previous after a confirmed rekey', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_A]) + + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) + // while the rekey is only STAGED, the candidate is not a verification key + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_A]) + + await confirmTrustedMintRekey(SERVER, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_B, KEY_A]) + + // dismissing a later staged candidate keeps the previous key intact + await lockTrustedMint(SERVER, KEY_C, OWNER_ID) + await dismissTrustedMintRekey(SERVER, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([KEY_B, KEY_A]) + }) + + it('excludes unconfirmed pins from verification keys', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(getTrustedMintVerificationKeys(SERVER, OWNER_ID)).toEqual([]) + expect(getTrustedMintVerificationKeys('unknown.example', OWNER_ID)).toEqual([]) + }) + 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) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index 3358353..fa4fec5 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -49,6 +49,14 @@ export type TrustedMint = { // would defeat the entire pinning model (a compromised mint could sign // unbacked notes that then show the "signed" badge). pendingMintPubkey?: string + // the key this mint signed under immediately BEFORE the holder confirmed + // the current pin (set by confirmTrustedMintRekey): notes issued just + // before a legitimate rotation stay verifiable against it. Never imported + // from a backup (a crafted file could otherwise plant a forgeable + // "previous" key, exactly like pendingMintPubkey) and never staged from + // an advertisement - only a confirmed rekey creates it, from the key that + // was actually pinned. + previousMintPubkey?: string // best-effort node identity/capacity, cached from the mint-address // discovery endpoint (see the kit's fetchMintAddress) purely for display - // absent for a mint that doesn't support it, or one trusted before this @@ -132,6 +140,20 @@ export const getTrustedMintPubkey = (server: string, ownerId?: string): string | readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed) ?.mintPubkey ?? null +// the keys a landed mutation signature may be checked against for this +// server: the pinned current key plus the previous one (kept across a +// confirmed rekey so the old key's last notes still verify). Unconfirmed +// pins contribute nothing - see TrustedMint.unconfirmed. +export const getTrustedMintVerificationKeys = (server: string, ownerId?: string): string[] => { + const mint = readTrustedMints(ownerId).find( + (entry) => entry.server === server && !entry.unconfirmed, + ) + if (!mint) return [] + return mint.previousMintPubkey + ? [mint.mintPubkey, mint.previousMintPubkey] + : [mint.mintPubkey] +} + // 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 diff --git a/src/lnurlcash/trustedMintsRegistry.ts b/src/lnurlcash/trustedMintsRegistry.ts index 99988ef..1acb4aa 100644 --- a/src/lnurlcash/trustedMintsRegistry.ts +++ b/src/lnurlcash/trustedMintsRegistry.ts @@ -39,6 +39,9 @@ const isTrustedMint = (value: unknown): value is TrustedMint => { (value.pendingMintPubkey === undefined || (typeof value.pendingMintPubkey === 'string' && isValidMintPubkey(value.pendingMintPubkey))) && + (value.previousMintPubkey === undefined || + (typeof value.previousMintPubkey === 'string' && + isValidMintPubkey(value.previousMintPubkey))) && isOptionalString(value.nodeAlias) && isOptionalString(value.nodeColor) && isOptionalNumber(value.nodeCapacityMsat) && From 3876791a0bc12851d74f7dd98424c4b30b788207 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 10:22:51 +0200 Subject: [PATCH 15/29] feat(funds): recover staged mutations --- src/lnurlcash/mintContract.test.ts | 90 ++++-- src/lnurlcash/ops.carve-crash.cases.ts | 165 +++++++++++ src/lnurlcash/ops.carve.cases.ts | 146 ++++++++-- src/lnurlcash/ops.mint-named.cases.ts | 117 +++----- src/lnurlcash/ops.mint-receive.cases.ts | 61 +--- src/lnurlcash/ops.pay.cases.ts | 61 +++- src/lnurlcash/ops.retry-refusal.cases.ts | 44 ++- src/lnurlcash/ops.signature.cases.ts | 145 ++++++++++ src/lnurlcash/ops.test.ts | 2 + src/lnurlcash/ops.testHarness.ts | 9 + src/lnurlcash/ops.transfer.cases.ts | 145 +++++----- src/lnurlcash/ops.ts | 33 ++- src/lnurlcash/ops/carve.ts | 330 +++++++++------------ src/lnurlcash/ops/carveRecovery.ts | 50 ++++ src/lnurlcash/ops/mint.ts | 345 +++++++--------------- src/lnurlcash/ops/pay.ts | 50 ++-- src/lnurlcash/ops/receiveBearer.ts | 10 +- src/lnurlcash/ops/shared.ts | 71 ++++- src/lnurlcash/ops/transfer.ts | 300 +++++++++---------- src/lnurlcash/types.ts | 15 + src/stores/walletFunds.recovery.test.ts | 350 +++++++++++++++++++++++ src/stores/walletFunds.ts | 268 ++++++++++++++++- 22 files changed, 1838 insertions(+), 969 deletions(-) create mode 100644 src/lnurlcash/ops.carve-crash.cases.ts create mode 100644 src/lnurlcash/ops.signature.cases.ts create mode 100644 src/lnurlcash/ops/carveRecovery.ts create mode 100644 src/stores/walletFunds.recovery.test.ts diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index 36594f9..1b73be9 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -8,10 +8,10 @@ // mock emits either, selected by its withdrawLinkForm option, so do NOT // assume a default anywhere in the receive path); and the LUD-06 quote // request must carry the output-naming hash (as BOTH `comment` and `h`) -// exactly when - and only when - the payRequest advertised the capability -// (commentAllowed >= 64 or mintToHash): a new-generation mint -// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must -// see the byte-identical request this wallet always sent. +// only when the payRequest advertises commentAllowed >= 64. The wallet's +// output secret must be durably staged before that quote request is sent; +// mintToHash alone is only an additive receipt extension, not permission +// to mint. import {afterEach, describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' @@ -47,6 +47,7 @@ const settleLastInvoice = async (m: Mint): Promise => { } const MINT_PUBKEY = `02${'ab'.repeat(32)}` +const persistOutput = async (): Promise => undefined // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: // node stats under their WIRE names - `nodeCapacity` is msat like every @@ -123,7 +124,7 @@ describe('mint-address wire contract', () => { 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) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput}) // the metadata is advertised at the mint-address endpoint itself - // the payRequest never carried it expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) @@ -140,7 +141,7 @@ describe('withdraw-link forms', () => { // conformance 0.7.0 defaults to plain https links; lnurlw:// is an // explicit LUD-17 transport form const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput}) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) // and the link is fully usable: settle the invoice, claim the note @@ -150,9 +151,9 @@ describe('withdraw-link forms', () => { intervalCapMs: 50, maxWaitMs: 5_000, }) - expect(claimed.rotated).toBe(true) expect(claimed.note.amount).toBe(21_000) - expect(m.state.noteState(preimage)).toBe('burned') + expect(m.state.noteState(preimage)).toBeNull() + expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding') }) it('accepts an HTTPS withdraw link', async () => { @@ -166,13 +167,14 @@ describe('withdraw-link forms', () => { minSendable: 1_000, maxSendable: 100_000_000_000, withdrawLink: 'https://mint.example/note', + commentAllowed: 64, 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}) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput}) 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') @@ -210,27 +212,39 @@ describe('quote output-naming wire contract', () => { return new URL(hit) } - it('sends no comment/h to a mint that never advertised output naming', async () => { + it('rejects a mint that cannot carry the mandatory output commitment', async () => { const seen: string[] = [] - const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({})), seen), - }) - expect(prepared.mode).toBe('unnamed') - expect(prepared.noteSecret).toBeUndefined() - // an old mint sees the byte-identical request this wallet always sent - const url = callbackUrl(seen) - expect(url.searchParams.get('comment')).toBeNull() - expect(url.searchParams.get('h')).toBeNull() + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({})), seen), + persistOutput, + }), + ).rejects.toThrow(/64-character output commitment/i) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) }) - it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => { + it('persists the wallet output before sending its hash as comment and h', async () => { const seen: string[] = [] + const events: string[] = [] const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), + fetch: (input, init) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery') + return capturingFetch( + routesFor(payRequestFixture({commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}`})), + seen, + )(input, init) + }, + persistOutput: async (note) => { + events.push('persist') + expect(note.verified).toBe(false) + expect(note.callback).toBe('') + expect(note.amount).toBe(0) + expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY) + }, }) - expect(prepared.mode).toBe('named') const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('named mode without a note secret') const url = callbackUrl(seen) // both spellings go out together: `comment` is the LUD-25 form // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read @@ -239,16 +253,30 @@ describe('quote output-naming wire contract', () => { expect(comment).toMatch(/^[0-9a-f]{64}$/) expect(h).toBe(comment) expect(comment).toBe(hashK1(noteSecret)) + expect(prepared.mintPubkey).toBe(MINT_PUBKEY) + expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0) + expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice')) }) - it('names the output when only the mintToHash spelling is advertised', async () => { + it('does not request an invoice when output persistence fails', async () => { const seen: string[] = [] - const prepared = await prepareMint('mint@mint.example', 21_000, { - fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), - }) - expect(prepared.mode).toBe('named') - const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('named mode without a note secret') - expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), + persistOutput: () => Promise.reject(new Error('storage full')), + }), + ).rejects.toThrow(/storage full/) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) + }) + + it('rejects mintToHash without the mandatory comment capacity', async () => { + const seen: string[] = [] + await expect( + prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), + persistOutput, + }), + ).rejects.toThrow(/64-character output commitment/i) + expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) }) }) diff --git a/src/lnurlcash/ops.carve-crash.cases.ts b/src/lnurlcash/ops.carve-crash.cases.ts new file mode 100644 index 0000000..ff236ee --- /dev/null +++ b/src/lnurlcash/ops.carve-crash.cases.ts @@ -0,0 +1,165 @@ +import {describe, expect, it} from 'vitest' +import {noteK1} from 'lnurlcash-kit' + +import type {CarveResult} from './ops' +import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, secret} from './ops.testHarness' + +const secretSource = (...secrets: string[]): (() => string) => { + let index = 0 + return () => { + const next = secrets[index] + if (!next) throw new Error('The test exhausted its prepared secrets.') + index += 1 + return next + } +} + +describe('ensureExactAmount crash safety', () => { + it('stages both split secrets before either hash reaches the mint', async () => { + const instance = await mint() + const sourceSecret = secret('70') + const targetSecret = secret('71') + const changeSecret = secret('72') + const bearer = await makeBearer(instance, sourceSecret, 21_000) + let staged: CarveResult | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + expect(staged).toBeDefined() + expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret) + expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret) + expect(instance.state.noteState(sourceSecret)).toBe('outstanding') + } + return fetch(input, init) + } + + const result = await ensureExactAmount([bearer], 5_000, { + fetch: observingFetch, + randomSecret: secretSource(targetSecret, changeSecret), + onCarve: (prepared) => { + staged = prepared + }, + }) + + expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + }) + + it('stages the merge secret before its hash reaches the mint', async () => { + const instance = await mint() + const firstSecret = secret('73') + const secondSecret = secret('74') + const mergedSecret = secret('75') + const first = await makeBearer(instance, firstSecret, 3_000) + const second = await makeBearer(instance, secondSecret, 4_000) + let staged: CarveResult | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + expect(staged).toBeDefined() + expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret) + expect(instance.state.noteState(firstSecret)).toBe('outstanding') + expect(instance.state.noteState(secondSecret)).toBe('outstanding') + } + return fetch(input, init) + } + + await ensureExactAmount([first, second], 7_000, { + fetch: observingFetch, + randomSecret: secretSource(mergedSecret), + onCarve: (prepared) => { + staged = prepared + }, + }) + + expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]) + }) + + it('does not expose a staged split output to a second ambiguous rotation', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('76'), 21_000) + let mutationCount = 0 + const dropSecondMutation: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (!url.includes('/w/cb')) return fetch(input, init) + mutationCount += 1 + const response = await fetch(input, init) + if (mutationCount === 2) { + await response.arrayBuffer() + throw new Error('second mutation response lost') + } + return response + } + + const result = await ensureExactAmount([bearer], 5_000, { + fetch: dropSecondMutation, + mutationRetries: 0, + randomSecret: secretSource(secret('77'), secret('78')), + onCarve: () => undefined, + }) + + expect(mutationCount).toBe(1) + const targetSecret = requiredValue(noteK1(result.note.url)) + const changeSecret = requiredValue(noteK1(requiredValue(result.change).url)) + expect(instance.state.noteState(targetSecret)).toBe('outstanding') + expect(instance.state.noteState(changeSecret)).toBe('outstanding') + }) + + it('rejects a multi-batch merge before any partial fold can land', async () => { + const instance = await mint() + const bearers = await Promise.all( + Array.from({length: 21}, (_, index) => + makeBearer(instance, secret((128 + index).toString(16)), 1_000), + ), + ) + let mutationCount = 0 + const countingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) mutationCount += 1 + return fetch(input, init) + } + + const failure = ensureExactAmount(bearers, 21_000, { + fetch: countingFetch, + onCarve: () => undefined, + }) + await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError) + expect(mutationCount).toBe(0) + expect( + bearers.every( + (bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding', + ), + ).toBe(true) + }) + + it('checks ownership again immediately before a merge mutation', async () => { + const instance = await mint() + const firstSecret = secret('79') + const secondSecret = secret('7a') + const first = await makeBearer(instance, firstSecret, 3_000) + const second = await makeBearer(instance, secondSecret, 4_000) + let ownerChecks = 0 + let mutationCount = 0 + const countingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) mutationCount += 1 + return fetch(input, init) + } + + await expect( + ensureExactAmount([first, second], 7_000, { + fetch: countingFetch, + assertOwner: () => { + ownerChecks += 1 + if (ownerChecks === 3) throw new Error('wallet owner changed') + }, + onCarve: () => undefined, + }), + ).rejects.toThrow(/owner changed/) + + expect(mutationCount).toBe(0) + expect(instance.state.noteState(firstSecret)).toBe('outstanding') + expect(instance.state.noteState(secondSecret)).toBe('outstanding') + }) +}) diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index c9a8327..897fa9f 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -1,11 +1,11 @@ import {describe, expect, it} from 'vitest' -import {fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit' import type {Bearer} from './types' import type {CarveResult} from './ops' -import {UncertainOutcomeError, ensureExactAmount} from './ops' +import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret} from './ops.testHarness' +import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' describe('ensureExactAmount', () => { it('returns an already-exact note untouched, burning nothing', async () => { @@ -23,7 +23,10 @@ describe('ensureExactAmount', () => { const instance = await mint() const k1 = secret('02') const bearer = await makeBearer(instance, k1, 21_000) - const result = await ensureExactAmount([bearer], 5_000) + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) expect(result.note.amount).toBe(5_000) expect(result.note.verified).toBe(true) expect(result.change?.amount).toBe(16_000) @@ -39,7 +42,7 @@ describe('ensureExactAmount', () => { 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) + const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined}) expect(result.note.amount).toBe(7_000) expect(result.change).toBeUndefined() expect(result.consumed).toHaveLength(2) @@ -53,7 +56,7 @@ describe('ensureExactAmount', () => { 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) + const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined}) expect(result.note.amount).toBe(5_000) expect(result.change?.amount).toBe(2_000) expect(result.consumed).toHaveLength(2) @@ -68,7 +71,7 @@ describe('ensureExactAmount', () => { const spentBearer = await makeBearer(instance, secret('07'), 50_000) const unverified: Bearer = { ...(await makeBearer(instance, secret('08'), 50_000)), - callback: '', + verified: false, } await expect( ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000), @@ -85,7 +88,7 @@ describe('ensureExactAmount', () => { 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 result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined}) const partK1 = requiredValue(noteK1(result.note.url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(partK1).not.toBe(k1) @@ -95,33 +98,40 @@ describe('ensureExactAmount', () => { }) it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { - const instance = await mint({dropAfterMutation: true}) + const instance = await mint() const k1 = secret('11') const bearer = await makeBearer(instance, k1, 21_000) - const probeKillingFetch: typeof fetch = (input, init) => { + // every mutation answer is lost - the first attempt's AND the forced + // byte-identical replay's (the mint executed both; the replay is the + // recorded success served again, then dropped by the wrapper) - and + // every informational probe is unreachable, so neither the mutation + // nor the probe can be confirmed + const everythingDyingFetch: typeof fetch = async (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url.includes('/w/cb')) return fetch(input, init) + if (url.includes('/w/cb')) { + const response = await fetch(input, init) + await response.arrayBuffer() + throw new Error('mutation response lost') + } return Promise.reject(new Error('probe unreachable')) } + const checkpoints: CarveResult[] = [] const failure = await ensureExactAmount([bearer], 5_000, { - fetch: probeKillingFetch, + fetch: everythingDyingFetch, + onCarve: (carve) => { + checkpoints.push(carve) + }, }).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) + expect(failure.possibleOutputs).toEqual([]) + expect(checkpoints).toHaveLength(1) + expect(checkpoints[0]?.consumed).toEqual([]) + expect(checkpoints[0]?.note.amount).toBe(5_000) + expect(checkpoints[0]?.change?.amount).toBe(16_000) }) - it('reports a mutation-bearing carve through onCarve before returning', async () => { + it('stages outputs before wire and retires inputs only after the mutation lands', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('12'), 21_000) const seen: CarveResult[] = [] @@ -129,13 +139,82 @@ describe('ensureExactAmount', () => { onCarve: (carve) => { seen.push(carve) }, + mintSignatureKeys: signatureKeysFor(instance), }) - // the hook saw exactly the returned result, once - expect(seen).toHaveLength(1) - expect(seen[0]).toBe(result) + expect(seen).toHaveLength(2) + expect(seen[0]?.note.verified).toBe(false) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[0]?.change).toBeDefined() + expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url)) + expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe( + noteK1(requiredValue(result.change).url), + ) + // the landed checkpoint re-reports the SAME staged output (same secret) + // - its URL has since gained the mint's signature + expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url)) + expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull() + expect(seen[1]?.note.verified).toBe(true) + expect(seen[1]?.change).toBeUndefined() + expect(seen[1]?.consumed).toEqual(result.consumed) expect(result.consumed).toHaveLength(1) }) + it('does not stage the split change again during the landed checkpoint', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('17'), 21_000) + const trackedSecrets = new Set() + const retiredIds = new Set() + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: (carve) => { + // checkpoints identify an output by its secret: the landed phase's + // URL has gained a signature the staged phase could not have had + const k1 = noteK1(carve.note.url) + if (k1) trackedSecrets.add(k1) + const changeK1 = carve.change ? noteK1(carve.change.url) : null + if (changeK1) trackedSecrets.add(changeK1) + for (const consumed of carve.consumed) retiredIds.add(consumed.id) + }, + }) + + expect(trackedSecrets).toEqual( + new Set([ + requiredValue(noteK1(result.note.url)), + requiredValue(noteK1(requiredValue(result.change).url)), + ]), + ) + expect(retiredIds).toEqual(new Set([bearer.id])) + }) + + it('keeps staged outputs recoverable when the landed checkpoint fails', async () => { + const instance = await mint() + const sourceSecret = secret('15') + const bearer = await makeBearer(instance, sourceSecret, 21_000) + const seen: CarveResult[] = [] + + await expect( + ensureExactAmount([bearer], 5_000, { + onCarve: (carve) => { + seen.push(carve) + if (carve.consumed.length > 0) throw new Error('final commit failed') + }, + }), + ).rejects.toThrow(/final commit failed/) + + expect(seen).toHaveLength(2) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[0]?.change).toBeDefined() + expect(instance.state.noteState(sourceSecret)).toBe('burned') + expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe( + 'outstanding', + ) + expect( + instance.state.noteState( + requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)), + ), + ).toBe('outstanding') + }) + it('never fires onCarve for an already-exact note - nothing was burned', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('13'), 21_000) @@ -149,7 +228,17 @@ describe('ensureExactAmount', () => { expect(result.consumed).toEqual([]) }) - it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => { + it('requires a typed durable checkpoint before a mutating carve', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('16'), 21_000) + + await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf( + CarveCheckpointRequiredError, + ) + expect(instance.state.noteState(secret('16'))).toBe('outstanding') + }) + + it('propagates an onCarve failure before the carve reaches the mint', async () => { const instance = await mint() const bearer = await makeBearer(instance, secret('14'), 21_000) await expect( @@ -159,5 +248,6 @@ describe('ensureExactAmount', () => { }, }), ).rejects.toThrow(/commit failed/) + expect(instance.state.noteState(secret('14'))).toBe('outstanding') }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 5e2d3de..886f165 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -4,118 +4,65 @@ import {hashK1, noteK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, settleLastInvoice} from './ops.testHarness' +import {mint, persistOutput, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ -// the invoice whose quote the wallet bound to its own output hash const boundInvoice = (instance: Mint) => [...instance.state.invoices.values()].find((invoice) => invoice.boundTo) -describe('mint -> claim, named output (comment/mintToHash)', () => { +describe('mint -> claim, comment-bound output', () => { it('names the quote with the hash of a wallet-chosen secret', async () => { - const instance = await mint({commentAllowed: 64, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('named') - const noteSecret = requiredValue(prepared.noteSecret) - expect(noteSecret).toMatch(HEX32) - // the mint bound the quote to sha256 of the wallet's secret - the kit - // sends it as both comment and h, the mock read one of them - expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret)) + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) + expect(prepared.noteSecret).toMatch(HEX32) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) }) - it('claims a named output at the wallet secret, without a rotate', async () => { - const instance = await mint({commentAllowed: 64, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const noteSecret = requiredValue(prepared.noteSecret) + it('claims the output at the wallet secret without rotating it', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) 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.verified).toBe(true) expect(claimed.note.amount).toBe(21_000) expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) - expect(noteK1(claimed.note.url)).toBe(noteSecret) - // no rotate happened: the note still stands at the wallet's own - // secret, and the payment preimage keys nothing at all - expect(instance.state.noteState(noteSecret)).toBe('outstanding') + expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) + expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding') expect(instance.state.noteState(preimage)).toBeNull() }) - it('names the output through the mintToHash spelling too', async () => { + 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, { + persistOutput, + }) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), + ).rejects.toThrow(/not confirmed/i) + }) + + it('uses the required comment even when the optional h extension is advertised', async () => { const instance = await mint({mintToHash: true, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('named') - expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret))) - const preimage = await settleLastInvoice(instance) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + }) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) + await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000, }) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) - expect(instance.state.noteState(preimage)).toBeNull() - }) - - it('keeps an unnamed mint on the preimage path', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - expect(prepared.mode).toBe('unnamed') - expect(prepared.noteSecret).toBeUndefined() - // the quote was NOT bound - nothing named an output - expect(boundInvoice(instance)).toBeUndefined() - }) - - it('times out cleanly when a named mint invoice is never paid', async () => { - const instance = await mint({commentAllowed: 64, 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) - }) - - it('rescues through the preimage when the mint credited it anyway and serves verify', async () => { - // the mintToHashIgnoresH adversary: claims the capability, binds - // nothing, credits the payment hash - the preimage is still the money - const instance = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const preimage = await settleLastInvoice(instance) - expect(instance.state.noteState(preimage)).toBe('outstanding') - const claimed = await claimMintedNote(prepared, { - intervalMs: 10, - intervalCapMs: 20, - maxWaitMs: 300, - }) - expect(claimed.rotated).toBe(true) - expect(claimed.note.verified).toBe(true) - // claimed through the preimage path and rotated off it, because that - // preimage rode the invoice - 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('fails loudly when the mint credits the preimage and serves no verify', async () => { - const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - // verify is withheld on the unnamed fallback - there the preimage IS - // the note, so a compliant mint never serves it - expect(prepared.verifyUrl).toBeNull() - const preimage = await settleLastInvoice(instance) - await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}), - ).rejects.toThrow(/not confirmed/i) - // the money exists at the preimage but is unreachable without verify - - // the wallet must NOT report a success here - expect(instance.state.noteState(preimage)).toBe('outstanding') + expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32) }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 3989d7c..6c0635d 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -10,33 +10,21 @@ import { 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') - }) +import { + makeBearer, + mint, + noteUrl, + persistOutput, + secret, + settleLastInvoice, +} from './ops.testHarness' +describe('mint fee', () => { 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) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, { + persistOutput, + }) expect(prepared.grossMsat).toBeGreaterThan(100_000) await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { @@ -48,31 +36,6 @@ describe('mint -> claim -> rotate', () => { // from the gross, so the note nets between the asked value and the gross expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000) expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) - expect(claimed.rotated).toBe(true) - }) - - it('refuses a verify response naming a different invoice', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - await settleLastInvoice(instance) - // a verify answer that names any invoice but the requested one must - // stop the claim - the preimage it carries belongs to another payment - const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}` - await expect( - claimMintedNote({...prepared, invoice: other}, { - intervalMs: 10, - intervalCapMs: 20, - maxWaitMs: 5_000, - }), - ).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) }) }) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index 08b1e7c..bb770e6 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -18,10 +18,28 @@ describe('payWithBearers', () => { 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}`, { + const payeeFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) { + return Promise.resolve( + Response.json({ + tag: 'payRequest', + callback: 'https://payee.example/invoice', + minSendable: 1_000, + maxSendable: 100_000_000, + metadata: '[]', + }), + ) + } + if (url.startsWith('https://payee.example/invoice')) { + return Promise.resolve(Response.json({pr: 'lnbc210n1pjqrstuvwxyz'})) + } + return fetch(input, init) + } + const result = await payWithBearers([bearer], 'alice@payee.example', { amountMsat: 21_000, + kit: {fetch: payeeFetch}, poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, }) expect(result.outcome).toBe('settled') @@ -34,6 +52,7 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('32'), 50_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + onCarve: () => undefined, }) expect(result.outcome).toBe('settled') expect(instance.state.noteState(secret('32'))).toBe('burned') @@ -51,14 +70,35 @@ describe('payWithBearers', () => { }) expect(result.outcome).toBe('failed-funds-returned') expect(instance.state.noteState(secret('33'))).toBe('burned') - // the returned funds come back re-secured at the classification - // rotate's fresh secret - carried as rotatedNote, NOT folded into the - // carve (which an onCarve checkpoint may already have committed) const rotatedNote = requiredValue(result.rotatedNote) expect(rotatedNote.verified).toBe(true) - const returnedK1 = requiredValue(noteK1(rotatedNote.url)) - expect(instance.state.noteState(returnedK1)).toBe('outstanding') - expect(rotatedNote.amount).toBe(21_000) + expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding') + }) + + it('waits for durable recovery-secret staging before rotating returned funds', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('39'), 21_000) + let releaseStage: (() => void) | undefined + const stageGate = new Promise((resolve) => { + releaseStage = resolve + }) + let stageStarted: (() => void) | undefined + const stageEntered = new Promise((resolve) => { + stageStarted = resolve + }) + const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + onReturnReady: async () => { + stageStarted?.() + await stageGate + }, + }) + + await stageEntered + expect(instance.state.noteState(secret('39'))).toBe('outstanding') + releaseStage?.() + expect((await payment).outcome).toBe('failed-funds-returned') + expect(instance.state.noteState(secret('39'))).toBe('burned') }) it('classifies a never-settling melt as unknown-still-pending', async () => { @@ -90,9 +130,8 @@ describe('payWithBearers', () => { }, }), ).rejects.toThrow(/commit failed/) - // the split landed (the input is burned) but the melt never happened: - // nothing sits pending at the mint - expect(instance.state.noteState(secret('36'))).toBe('burned') + // The checkpoint failed before either split or melt reached the mint. + expect(instance.state.noteState(secret('36'))).toBe('outstanding') expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) }) diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts index 9cf73c4..4d3263e 100644 --- a/src/lnurlcash/ops.retry-refusal.cases.ts +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -1,17 +1,10 @@ import {describe, expect, it} from 'vitest' import {noteK1} from 'lnurlcash-kit' +import type {CarveResult} from './ops' import {ensureExactAmount, receiveBearer} from './ops' -import {claimFromPreimage, prepareMint} from './ops/mint' import {requiredValue} from './test-utils' -import { - makeBearer, - mint, - noteUrl, - retryingCbFetch, - secret, - settleLastInvoice, -} from './ops.testHarness' +import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness' // The redeem callback is a GET, and HTTP stacks retry GETs - so a mint // can execute the first attempt of a mutation and refuse the byte-identical @@ -24,7 +17,10 @@ describe('mutation retry refusals', () => { const instance = await mint() const k1 = secret('60') const bearer = await makeBearer(instance, k1, 50_000) - const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}) + const result = await ensureExactAmount([bearer], 25_000, { + fetch: retryingCbFetch(), + onCarve: () => undefined, + }) // the split landed on the first (unseen) attempt: the input is burned // and both outputs are live under the secrets the wallet carried expect(instance.state.noteState(k1)).toBe('burned') @@ -41,7 +37,10 @@ describe('mutation retry refusals', () => { const instance = await mint() const a = await makeBearer(instance, secret('61'), 20_000) const b = await makeBearer(instance, secret('62'), 5_000) - const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()}) + const result = await ensureExactAmount([a, b], 25_000, { + fetch: retryingCbFetch(), + onCarve: () => undefined, + }) expect(result.consumed).toHaveLength(2) expect(instance.state.noteState(secret('61'))).toBe('burned') expect(instance.state.noteState(secret('62'))).toBe('burned') @@ -50,19 +49,6 @@ describe('mutation retry refusals', () => { expect(instance.state.noteState(mergedK1)).toBe('outstanding') }) - it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => { - const instance = await mint({testHooks: true}) - const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) - const preimage = await settleLastInvoice(instance) - const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()}) - expect(claimed.rotated).toBe(true) - 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('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { const instance = await mint() const senderK1 = secret('63') @@ -83,8 +69,16 @@ describe('mutation retry refusals', () => { const k1 = secret('64') const bearer = await makeBearer(instance, k1, 50_000) instance.state.settleMelt(k1) // burned elsewhere, before we ever asked + const checkpoints: CarveResult[] = [] await expect( - ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}), + ensureExactAmount([bearer], 25_000, { + fetch: retryingCbFetch(), + onCarve: (carve) => { + checkpoints.push(carve) + }, + }), ).rejects.toThrow(/spent/i) + expect(checkpoints).toHaveLength(1) + expect(checkpoints[0]?.consumed).toEqual([]) }) }) diff --git a/src/lnurlcash/ops.signature.cases.ts b/src/lnurlcash/ops.signature.cases.ts new file mode 100644 index 0000000..4079533 --- /dev/null +++ b/src/lnurlcash/ops.signature.cases.ts @@ -0,0 +1,145 @@ +import {describe, expect, it} from 'vitest' +import {noteK1, noteSignature} from 'lnurlcash-kit' + +import {ensureExactAmount} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' +import {fetchNoteInfo} from 'lnurlcash-kit' + +// Landed split/merge outputs carry the mint's signature in their URL (sig +// param), and earn verified:true only when that signature checks against a +// trusted CURRENT or PREVIOUS key for the mint. Invalid, missing, or +// wrong-key signatures - and mints with no trusted keys at all - keep the +// staged outputs unverified; the money is tracked either way (the notes +// exist at the mint), only the offline-verifiable badge is withheld. +describe('landed mutation signature verification', () => { + it('verifies a landed split against the current key and preserves its signature', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a0'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(result.note.verified).toBe(true) + const signature = noteSignature(result.note.url) + expect(signature).not.toBeNull() + expect(noteSignature(requiredValue(result.change).url)).not.toBeNull() + // change stays unverified (its true post-fee value is unknown until a + // refresh) even though its signature is preserved + expect(result.change?.verified).toBe(false) + }) + + it('verifies a landed merge against the current key and preserves its signature', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('a1'), 3_000) + const second = await makeBearer(instance, secret('a2'), 4_000) + + const result = await ensureExactAmount([first, second], 7_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(result.note.verified).toBe(true) + expect(noteSignature(result.note.url)).not.toBeNull() + }) + + it('verifies against the previous key after the mint rotated its signing key', async () => { + // the mint signs every output under its RETIRED key while advertising + // the current one - exactly the rekey window a holder must tolerate + const instance = await mint({ + previousPrivateKey: '22'.repeat(32), + signWithPreviousKey: true, + }) + const bearer = await makeBearer(instance, secret('a3'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + expect(instance.state.previousPubkeys).toHaveLength(1) + expect(result.note.verified).toBe(true) + expect(noteSignature(result.note.url)).not.toBeNull() + }) + + it('rejects verification when the previous key is not trusted', async () => { + const instance = await mint({ + previousPrivateKey: '22'.repeat(32), + signWithPreviousKey: true, + }) + const bearer = await makeBearer(instance, secret('a4'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // only the current key is trusted - the signature under the retired + // key must not verify + mintSignatureKeys: () => [instance.state.pubkey], + }) + + expect(result.note.verified).toBe(false) + // the note and its signature are still tracked, recoverable by refresh + expect(noteSignature(result.note.url)).not.toBeNull() + const partK1 = requiredValue(noteK1(result.note.url)) + expect(instance.state.noteState(partK1)).toBe('outstanding') + expect(instance.state.noteState(secret('a4'))).toBe('burned') + }) + + it('keeps a wrong-key signature staged unverified', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a5'), 21_000) + const stranger = await mint({privateKey: '33'.repeat(32)}) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // the only trusted key on file belongs to a DIFFERENT mint + mintSignatureKeys: () => [stranger.state.pubkey], + }) + + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).not.toBeNull() + expect(instance.state.noteState(secret('a5'))).toBe('burned') + expect( + instance.state.noteState(requiredValue(noteK1(result.note.url))), + ).toBe('outstanding') + }) + + it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => { + const instance = await mint({signatures: false}) + const k1 = secret('a6') + const bearer = await makeBearer(instance, k1, 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + mintSignatureKeys: signatureKeysFor(instance), + }) + + // the mutation landed (the kit refused the missing signature, the + // rescue probe proved the burn): the inputs are retired, the staged + // outputs are tracked at exactly their staged secrets, unverified + expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).toBeNull() + expect(instance.state.noteState(k1)).toBe('burned') + const partK1 = requiredValue(noteK1(result.note.url)) + expect(instance.state.noteState(partK1)).toBe('outstanding') + expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) + }) + + it('never verifies a landed mutation without any trusted keys on file', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('a7'), 21_000) + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + // no key source at all: nothing may be trusted blindly + }) + + expect(result.note.verified).toBe(false) + expect(noteSignature(result.note.url)).not.toBeNull() + expect( + instance.state.noteState(requiredValue(noteK1(result.note.url))), + ).toBe('outstanding') + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index 97ce1bf..d9ca000 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -1,4 +1,6 @@ import './ops.carve.cases' +import './ops.carve-crash.cases' +import './ops.signature.cases' import './ops.mint-named.cases' import './ops.mint-receive.cases' import './ops.pay.cases' diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index ee6a1b1..2b4c281 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -24,6 +24,15 @@ afterEach(async () => { export const secret = (seed: string): string => bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) +export const persistOutput = async (): Promise => undefined + +// the trusted signing keys a production caller would serve from the +// trusted-mint registry: the mock mint's current key plus any it retired +export const signatureKeysFor = + (instance: Mint) => + (): readonly string[] => + [instance.state.pubkey, ...instance.state.previousPubkeys] + export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string => buildNoteUrl(`${instance.url}/w`, k1, amountMsat) diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 7ef3767..7f70578 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -2,15 +2,30 @@ import {describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {hashK1, noteK1} from 'lnurlcash-kit' -import {transferBetweenMints} from './ops' -import type {CarveResult} from './ops' +import {transferBetweenMints as transferBetweenMintsEngine} from './ops' +import type {CarveResult, TransferOptions} from './ops' +import type {Bearer} from './types' import {requiredValue} from './test-utils' -import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness' +import { + expectBurned, + makeBearer, + mint, + persistOutput, + secret, + settleWhenRequested, +} from './ops.testHarness' + +const transferBetweenMints = ( + bearers: Bearer[], + amountMsat: number, + targetMint: string, + options: Omit = {}, +) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput}) 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 () => { + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) const k1 = secret('40') @@ -31,10 +46,9 @@ describe('transferBetweenMints', () => { 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') + expect(target.state.noteState(preimage)).toBeNull() const newK1 = requiredValue(noteK1(claimed.note.url)) expect(newK1).not.toBe(preimage) expect(target.state.noteState(newK1)).toBe('outstanding') @@ -45,9 +59,15 @@ describe('transferBetweenMints', () => { const target = await mint() const k1 = secret('41') const bearer = await makeBearer(source, k1, 5_000) + let persisted = false await expect( - transferBetweenMints([bearer], 50_000, `mint@127.0.0.1:${target.port}`), + transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, { + persistOutput: () => { + persisted = true + }, + }), ).rejects.toThrow(/enough/) + expect(persisted).toBe(false) expect(source.state.noteState(k1)).toBe('outstanding') }) @@ -72,6 +92,22 @@ describe('transferBetweenMints', () => { expect(source.state.noteState(k1)).toBe('outstanding') }) + it('reports a definitively spent source before waiting on the target', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('54') + const bearer = await makeBearer(source, k1, 21_000) + source.state.settleMelt(k1) + + const result = await transferBetweenMints( + [bearer], + 21_000, + `mint@127.0.0.1:${target.port}`, + ) + + expect(result.outcome).toBe('note-already-spent') + }) + 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}) @@ -85,26 +121,6 @@ describe('transferBetweenMints', () => { 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 () => { @@ -140,9 +156,9 @@ describe('transferBetweenMints', () => { expect(rotatedNote.amount).toBe(21_000) }) - it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { + it('lands the target note at the staged wallet secret without rotating', async () => { const source = await mint() - const target = await mint({commentAllowed: 64, testHooks: true}) + const target = await mint({testHooks: true}) const k1 = secret('48') const bearer = await makeBearer(source, k1, 21_000) const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { @@ -153,7 +169,6 @@ describe('transferBetweenMints', () => { expect(result.outcome).toBe('settled') 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) const newK1 = requiredValue(noteK1(claimed.note.url)) @@ -166,7 +181,7 @@ describe('transferBetweenMints', () => { it('keeps the named claim material when the target never credits the note', async () => { const source = await mint({meltNeverSettles: true}) - const target = await mint({commentAllowed: 64, testHooks: true}) + const target = await mint({testHooks: true}) const k1 = secret('49') const bearer = await makeBearer(source, k1, 21_000) const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { @@ -190,36 +205,7 @@ describe('transferBetweenMints', () => { expect(noteK1(material.url)).toBe(noteSecret) }) - it('rescues a named transfer through the preimage when the target credited it anyway', async () => { - const source = await mint() - // the mintToHashIgnoresH adversary: advertises naming, credits the - // payment hash - and (non-compliantly) still serves verify - const target = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const k1 = secret('50') - const bearer = await makeBearer(source, k1, 21_000) - const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { - poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, - }) - const preimage = await settleWhenRequested(target) - const result = await pending - expect(result.outcome).toBe('settled') - await expectBurned(source, k1) - const claimed = requiredValue(result.mintedAtTarget) - expect(claimed.rotated).toBe(true) - // claimed through the preimage path and rotated off it, because that - // preimage rode the invoice - 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('fires onCarve after the carve landed and before the melt starts', async () => { + it('fires onCarve before the carve or melt reaches the mint', async () => { const source = await mint() const target = await mint({testHooks: true}) const bearer = await makeBearer(source, secret('51'), 21_000) @@ -228,16 +214,21 @@ describe('transferBetweenMints', () => { poll: fastPoll, onCarve: (carve) => { seen.push(carve) - // at hook time the melt must NOT have started: the carved note is - // still outstanding (a melt would leave it pending) - expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') + if (carve.consumed.length === 0) { + expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull() + expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding') + } else { + expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') + expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned') + } }, }) await settleWhenRequested(target) const result = await pending expect(result.outcome).toBe('settled') - expect(seen).toHaveLength(1) - expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(seen).toHaveLength(2) + expect(seen[0]?.consumed).toEqual([]) + expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) }) it('aborts before the melt when the carve commit hook fails', async () => { @@ -252,9 +243,25 @@ describe('transferBetweenMints', () => { }, }), ).rejects.toThrow(/commit failed/) - // the split landed (the input is burned) but the melt never happened: - // nothing sits pending at the source, nothing was ever paid - expect(source.state.noteState(k1)).toBe('burned') + // The checkpoint failed before either split or melt reached the source. + expect(source.state.noteState(k1)).toBe('outstanding') expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) + + it('aborts before an exact-note melt when the final checkpoint fails', async () => { + const source = await mint() + const target = await mint() + const k1 = secret('53') + const bearer = await makeBearer(source, k1, 21_000) + + await expect( + transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + onMeltReady: () => { + throw new Error('source link commit failed') + }, + }), + ).rejects.toThrow(/source link commit failed/) + + expect(source.state.noteState(k1)).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index af69955..bc2efe9 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -5,12 +5,9 @@ // new/changed notes out - it never mutates wallet state itself. // // Fund-critical invariants enforced across the flows (see the project plan): -// - rotate on every receive, and immediately after claiming a fresh mint -// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice -// knows the payment hash, and the mint necessarily saw the preimage - -// the preimage IS the note secret). A NAMED mint (LUD-25 comment / -// mintToHash) credits the note to a wallet-chosen secret instead; that -// secret never rode an invoice, so no rotate follows its claim +// - rotate every externally received bearer. Minted notes are staged at a +// wallet-chosen secret before invoice creation and claimed there without +// rotating; the payment preimage is not bearer material // - a note's declared amount is a claim; the service's maxWithdrawable is // authoritative // - a melt's "OK" only means the payment is in flight; its verify URL (or @@ -29,15 +26,29 @@ // ops/shared.ts - bounded polling, UncertainOutcomeError export {UncertainOutcomeError} from './ops/shared' -export type {PollOptions} from './ops/shared' -export {ensureExactAmount} from './ops/carve' +export type {FundOperationOptions, PollOptions} from './ops/shared' +export { + CarveCheckpointRequiredError, + ensureExactAmount, + UnsupportedMultiBatchMergeError, +} from './ops/carve' export type {CarveResult} from './ops/carve' -export {prepareMint, claimMintedNote} from './ops/mint' -export type {PreparedMint, ClaimedNote} from './ops/mint' +export { + MintedNoteSpentError, + prepareMint, + claimMintedNote, + recoverStagedMintOutput, +} from './ops/mint' +export type { + PreparedMint, + PrepareMintOptions, + ClaimedNote, + StagedMintRecovery, +} from './ops/mint' export {receiveBearer} from './ops/receiveBearer' export {payWithBearers} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' -export {transferBetweenMints} from './ops/transfer' +export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' export type { TransferClaimMaterial, TransferOptions, diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index b8b65c2..3c107c9 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -2,31 +2,37 @@ // and/or splitting as needed, into a single fresh note worth exactly the // target - the operation every send and every melt starts from. // -// A carve whose mutation landed reports itself through options.onCarve -// BEFORE returning, so a caller about to do something slow (a settlement -// wait) can durably commit first - a flow aborted mid-wait must never -// leave burned inputs looking spendable or strand the fresh outputs. +// Every output secret is chosen and reported through options.onCarve BEFORE +// its hash reaches the mint. That first checkpoint stages possible outputs +// without retiring inputs; a second checkpoint retires the inputs only after +// the mutation is known to have landed. import { AmbiguousMutationError, - mergeNotes, + defaultRandomSecret, + mergeBatches, newSecretsOf, noteK1, probeBurnedNote, requireNoteK1, serverOf, - settleNote, splitNote, withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import {mergeAmbiguitySafe} from './carveRecovery' import type {FundOperationOptions} from './shared' -import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared' +import { + assertFundOwner, + landedNoteVerifies, + probeMutationOutput, + UncertainOutcomeError, + withMutationSafety, +} from './shared' -// the changeset stores apply after a mutation: `note`/`change` BEFORE -// `consumed` - the mint call already burned every consumed input -// server-side, so the outputs are the only money left and must be tracked -// first; a crash between the two must strand a duplicate, never a secret +// The same shape serves both checkpoint phases: the pre-wire phase carries +// note/change with an empty consumed list, and the landed phase carries the +// already-staged note plus the inputs the caller can now retire. export type CarveResult = { // the exact-amount note, ready to hand over or melt note: NewBearer @@ -37,6 +43,22 @@ export type CarveResult = { consumed: Bearer[] } +export class UnsupportedMultiBatchMergeError extends Error { + override readonly name = 'UnsupportedMultiBatchMergeError' + + constructor() { + super('This carve would require multiple merge requests and cannot be performed safely.') + } +} + +export class CarveCheckpointRequiredError extends Error { + override readonly name = 'CarveCheckpointRequiredError' + + constructor() { + super('A mutating carve requires a durable onCarve checkpoint.') + } +} + // Selection: only notes that can actually take part - verified (callback // known), not locally spent, holding a real k1 (device-backed mirrors are // excluded; the ops engine cannot mutate a secret it doesn't hold). Notes @@ -46,12 +68,12 @@ export type CarveResult = { // // Execution, mirroring lnurl-wallet's SendDialog: // - one note already exact: returned as-is, nothing burned -// - several notes summing exactly: one merge, then settle (reads the true -// post-fee value back and rotates, since the read put k1 on the wire) +// - several notes summing exactly: one merge; folds spanning multiple +// requests are rejected because partial progress needs a larger journal // - total above target (one or many notes): a single split request (LUD-25 -// split takes many k1s - no merge round trip first), then the change is -// settled for its true value; the target part carries the mint's -// signature and needs no settle +// split takes many k1s - no merge round trip first); change stays +// unverified until refresh because a second settle mutation would create +// another crash window export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, @@ -60,8 +82,11 @@ export const ensureExactAmount = async ( if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } + // the forced mutation policy (signatures required, one byte-identical + // replay) applies to every mint call below regardless of caller options + const mutationOptions = withMutationSafety(options) const eligible = bearers.filter( - (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), + (b) => b.verified && !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 @@ -86,13 +111,17 @@ export const ensureExactAmount = async ( const total = pick.reduce((sum, b) => sum + b.amount, 0) const k1s = pick.map((b) => requireNoteK1(b.url)) - // a carve that burned inputs reports itself the moment its outcome is - // known, before the caller's flow moves on to anything slow or - // uncertain - see FundOperationOptions.onCarve - const checkpoint = async (result: CarveResult): Promise => { - await options.onCarve?.(result) - return result + const checkpoint = async (result: CarveResult): Promise => { + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + assertFundOwner(options) + await options.onCarve(result) + assertFundOwner(options) } + const stage = (result: CarveResult): Promise => + checkpoint({...result, consumed: []}) + const retire = (note: NewBearer): Promise => checkpoint({note, consumed: pick}) if (pick.length === 1 && total === amountMsat) { // already exact - hand over the note itself, untouched @@ -109,94 +138,94 @@ export const ensureExactAmount = async ( } if (total === amountMsat) { - // 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), + if (mergeBatches(base.callback, k1s).length !== 1) { + throw new UnsupportedMultiBatchMergeError() + } + const mergeSecret = (options.randomSecret ?? defaultRandomSecret)() + const staged: NewBearer = { + url: withNewK1(base.url, mergeSecret, total), callback: base.callback, amount: total, verified: false, mintPubkey: base.mintPubkey, } - // a merge whose answer was lost leaves the service in an unknown - // state from here - settling fires another mutation (the rotate - // inside settleNote) at it, whose own ambiguous failure would strand - // the rescued secret. Don't compound: return unverified and let a - // refresh repair. - if (merged.rescued) return checkpoint({note: unverified, consumed: pick}) - try { - const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) - return checkpoint({ - note: { - url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), - callback: settled.callback, - amount: settled.amountMsat, - verified: true, - mintPubkey: base.mintPubkey, - }, - consumed: pick, - }) - } catch { - return checkpoint({note: unverified, consumed: pick}) - } + await stage({note: staged, consumed: pick}) + assertFundOwner(options) + const merged = await mergeAmbiguitySafe(base, k1s, { + ...mutationOptions, + randomSecret: () => mergeSecret, + }) + // a landed merge reports its signature (carried in the URL's sig param) + // and earns verified only when that signature checks against a trusted + // current or previous key; a rescued merge saw no answer, so its note + // stays exactly as staged - unverified, same URL + const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature) + const result: CarveResult = merged.rescued + ? {note: staged, consumed: pick} + : { + note: { + ...staged, + url: landedUrl, + verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys), + }, + consumed: pick, + } + await retire(result.note) + return result } // split path: total above target - one split request across all picked // k1s, carving the target off and leaving the change as a fresh note - let partK1: string + const randomSecret = options.randomSecret ?? defaultRandomSecret + const partK1 = randomSecret() + const changeK1 = randomSecret() let partSignature: string | undefined - let changeK1: string let changeSignature: string | undefined - let partVerified = false - // true when the split's answer was lost and the probe proved the burn - - // the carried secrets were rescued, but the service is in an unknown - // 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 + const stagedNote: NewBearer = { + url: withNewK1(base.url, partK1, amountMsat), + callback: base.callback, + amount: amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + } + const stagedChange: NewBearer = { + url: withNewK1(base.url, changeK1, total - amountMsat), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + } + await stage({note: stagedNote, change: stagedChange, consumed: pick}) + let secretIndex = 0 + const preparedSecret = (): string => { + secretIndex += 1 + if (secretIndex === 1) return partK1 + if (secretIndex === 2) return changeK1 + throw new Error('The split requested more output secrets than were staged.') + } assertFundOwner(options) try { - const parts = await splitNote(base.callback, k1s, amountMsat, options) - partK1 = parts.k1 + const parts = await splitNote(base.callback, k1s, amountMsat, { + ...mutationOptions, + randomSecret: preparedSecret, + }) partSignature = parts.signature - changeK1 = parts.change changeSignature = parts.changeSignature - partVerified = true } catch (err) { if (err instanceof AmbiguousMutationError) { // the split request may have landed despite the failure - probe one // input before deciding what the carried secrets are worth - const outcome = await probeBurnedNote(base.url, options) + const outcome = await probeBurnedNote(base.url, mutationOptions) if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'unknown') { // can't tell: surface both possible outputs unverified WITHOUT // consuming the inputs, and stop here rather than spend from limbo throw new UncertainOutcomeError( - 'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, err.newSecrets[0], amountMsat), - callback: base.callback, - amount: amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - { - url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), - callback: base.callback, - amount: total - amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], + 'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.', + [], ) } // 'gone': the burn landed - the carried secrets are the only money - partK1 = err.newSecrets[0] - changeK1 = err.newSecrets[1] - rescued = true } else { // A classified refusal can still be a LANDED split: the callback is // a GET and HTTP stacks retry GETs, so the service may have executed @@ -205,79 +234,53 @@ export const ensureExactAmount = async ( // one output before deciding they are worthless. const carried = newSecretsOf(err) if (carried.length !== 2) throw err - const outcome = await probeMutationOutput(base.url, carried[0], options) + const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions) if (outcome === 'absent') throw err // never landed - a plain refusal if (outcome === 'unknown') { // can't tell whether the refusal named a retry - same limbo as // the ambiguous case above: track the possible outputs, consume // nothing, stop here throw new UncertainOutcomeError( - 'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, carried[0], amountMsat), - callback: base.callback, - amount: amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - { - url: withNewK1(base.url, carried[1], total - amountMsat), - callback: base.callback, - amount: total - amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], + 'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.', + [], ) } // 'live': the split landed and this answer was its retried twin - // the carried secrets are the only money left - partK1 = carried[0] - changeK1 = carried[1] - rescued = true } } + // the landed answer's signatures ride in the URLs (sig param); a rescued + // split saw no answer, so its notes keep the staged sig-less URLs. Only a + // signature that verifies against a trusted current/previous key earns + // verified:true - anything else stays staged unverified for a refresh. + const noteUrl = + partSignature === undefined + ? stagedNote.url + : withNewK1(base.url, partK1, amountMsat, partSignature) const note: NewBearer = { - url: withNewK1(base.url, partK1, amountMsat, partSignature), + url: noteUrl, callback: base.callback, amount: amountMsat, - verified: partVerified, + verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys), 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 - // split-off amount) - it comes back at its true value, or stays - // unverified at the naive pre-fee one for a refresh to repair - let change: NewBearer = { - url: withNewK1(base.url, changeK1, total - amountMsat, changeSignature), + // The change may be worth less than total - amount when the mint charges + // split fees. It remains unverified at that upper bound until refresh; + // settling it here would rotate to an unstaged secret in a second request. + const changeUrl = + changeSignature === undefined + ? stagedChange.url + : withNewK1(base.url, changeK1, total - amountMsat, changeSignature) + const change: NewBearer = { + url: changeUrl, callback: base.callback, amount: total - amountMsat, verified: false, mintPubkey: base.mintPubkey, } - if (!rescued) { - try { - const settled = await settleNote( - base.url, - changeK1, - total - amountMsat, - changeSignature, - options, - ) - change = { - url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), - callback: settled.callback, - amount: settled.amountMsat, - verified: true, - mintPubkey: base.mintPubkey, - } - } catch (error) { - // settle is best-effort - the unverified change above is still tracked - if (!(error instanceof Error)) throw error - } - } - return checkpoint({note, change, consumed: pick}) + const result = {note, change, consumed: pick} + await retire(note) + return result } // smallest-first accumulation until the target is covered; null when the @@ -301,60 +304,3 @@ const better = (a: Bearer[], b: Bearer[], target: number): boolean => { if (wasteA !== wasteB) return wasteA < wasteB return a.length < b.length } - -// merge with the full ambiguity protocol: probe one input, rescue the -// carried secret only once the burn is confirmed, surface it unverified -// when the probe can't tell either -const mergeAmbiguitySafe = async ( - base: Bearer, - k1s: string[], - total: number, - options: FundOperationOptions, -): Promise<{k1: string; signature?: string; rescued: boolean}> => { - try { - const merged = await mergeNotes(base.callback, k1s, options) - return {k1: merged.k1, signature: merged.signature, rescued: false} - } catch (err) { - if (err instanceof AmbiguousMutationError) { - const outcome = await probeBurnedNote(base.url, options) - if (outcome === 'live') throw err // nothing burned - a plain failure - if (outcome === 'unknown') { - throw new UncertainOutcomeError( - 'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, err.newSecrets[0], total), - callback: base.callback, - amount: total, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], - ) - } - // 'gone': the burn landed - the carried secret is the only money left - return {k1: err.newSecrets[0], rescued: true} - } - // same retry-refusal rescue as the split path: the refusal may name a - // merge that already landed - probe the would-be combined note - const carried = newSecretsOf(err) - if (carried.length !== 1) throw err - const outcome = await probeMutationOutput(base.url, carried[0], options) - if (outcome === 'absent') throw err - if (outcome === 'unknown') { - throw new UncertainOutcomeError( - 'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, carried[0], total), - callback: base.callback, - amount: total, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], - ) - } - return {k1: carried[0], rescued: true} - } -} diff --git a/src/lnurlcash/ops/carveRecovery.ts b/src/lnurlcash/ops/carveRecovery.ts new file mode 100644 index 0000000..6985c1c --- /dev/null +++ b/src/lnurlcash/ops/carveRecovery.ts @@ -0,0 +1,50 @@ +// A merge failure can hide a landed mutation because callback GETs may be +// retried below the engine. This module owns the probes that distinguish a +// plain refusal from a live or still-uncertain wallet-chosen output. + +import {AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote} from 'lnurlcash-kit' +import type {Bearer} from '../types' +import type {FundOperationOptions} from './shared' +import {probeMutationOutput, UncertainOutcomeError} from './shared' + +export type MergeOutcome = { + readonly rescued: boolean + // the mint's signature over the combined note - present only when the + // merge's own answer was seen; a rescued merge (lost answer, retried + // twin) has none to offer + readonly signature?: string +} + +export const mergeAmbiguitySafe = async ( + base: Bearer, + k1s: string[], + options: FundOperationOptions, +): Promise => { + try { + const merged = await mergeNotes(base.callback, k1s, options) + return {rescued: false, signature: merged.signature} + } catch (error) { + if (error instanceof AmbiguousMutationError) { + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw error + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.', + [], + ) + } + return {rescued: true} + } + const carried = newSecretsOf(error) + if (carried.length !== 1) throw error + const outcome = await probeMutationOutput(base.url, carried[0], options) + if (outcome === 'absent') throw error + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.', + [], + ) + } + return {rescued: true} + } +} diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index ccc6044..4869b0c 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -1,69 +1,39 @@ -// Minting: receiving over Lightning. prepareMint resolves a mint and -// requests the invoice; paying it brings the note into existence; -// claimMintedNote watches the payment and converts it into a wallet-owned -// bearer note. -// -// Two mint generations exist on the wire, and this module speaks both: -// -// - UNNAMED (old): the minted note's k1 IS the payment preimage. The -// claim takes the preimage from the LUD-21 verify answer and rotates -// immediately - every routing node and anyone who saw the invoice knew -// that secret. -// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the -// wallet names the note at quote time with comment = sha256(secret) of -// a secret it chose, and the note lands at THAT secret. The preimage -// opens nothing, so no observer race exists and no rotate follows the -// claim; the claim poll itself is the placement proof (see -// claimFromSecret). -// -// The mode is decided per mint from the payRequest's capability -// advertisement (commentAllowed >= 64 or mintToHash), never assumed. +// Minting: receiving over Lightning. Current LUD-25 minting is always +// comment-bound: the wallet chooses the future note secret, durably stages +// an unverified bearer for it, and only then sends sha256(secret) with the +// invoice request. Paying the invoice brings that already-tracked note into +// existence; claiming confirms its authoritative value and callback. import { - AmbiguousMutationError, buildNoteUrl, claimMintedNote as fetchMintClaim, defaultRandomSecret, - fetchInvoiceVerification, fetchMintAddress, - fetchNoteInfo, fetchPayRequest, grossUpForMintFee, hashK1, - isPreimage, lightningAddressUsername, mintAddressUrl, - namesMintOutput, - newSecretsOf, - NoteSpentError, - probeBurnedNote, requestInvoice, + noteDeclaredAmount, + noteK1, resolveMintInput, - rotateNote, - sameInvoice, serverOf, - withNewK1, } from 'lnurlcash-kit' import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' -import type {NewBearer} from '../types' +import type {Bearer, NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared' +import {assertFundOwner, pollUntil, withMutationSafety} from './shared' export type PreparedMint = { invoice: string verifyUrl: string | null - // how this mint keys the note the invoice pays for: 'named' mints credit - // a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints - // credit the payment preimage - mode: 'named' | 'unnamed' - // named mode only: the wallet-chosen secret the minted note lands at. - // Drawn before the invoice is requested (its hash binds the quote) and - // carried in memory only - never persisted, never logged. Losing it - // after payment loses the note, but nothing here was ever recoverable - // after a crash: the invoice and verify URL die in the same memory - noteSecret?: string + // The wallet-chosen secret the minted note lands at. Its unverified note + // is persisted before the invoice request can put this secret's hash on + // the wire. + noteSecret: string // the net note value asked for - the claim cross-checks the service's // authoritative maxWithdrawable against it expectedNoteValueMsat: number @@ -78,6 +48,14 @@ export type PreparedMint = { nodeInfo: MintAddressInfo | null } +export type PrepareMintOptions = FundOperationOptions & { + readonly persistOutput: (note: NewBearer) => void | Promise + readonly beforePersist?: (quote: { + readonly grossMsat: number + readonly server: string + }) => void | Promise +} + // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover // its mint address (best-effort LUD-25 experimental endpoint), read its // payRequest, gross the requested net amount up for the advertised mint @@ -86,8 +64,11 @@ export type PreparedMint = { export const prepareMint = async ( mintInput: string, amountMsat: number, - options: FundOperationOptions = {}, + options: PrepareMintOptions, ): Promise => { + const {beforePersist, persistOutput, ...rest} = options + // the forced mutation policy (see shared.ts) covers the invoice request + const kitOptions = withMutationSafety(rest) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } @@ -101,14 +82,14 @@ export const prepareMint = async ( let payUrl = url if (addressUrl) { try { - nodeInfo = await fetchMintAddress(addressUrl, options) + nodeInfo = await fetchMintAddress(addressUrl, kitOptions) payUrl = nodeInfo.payLink } catch (error) { // no mint-address support here - proceed with just the guess if (!(error instanceof Error)) throw error } } - const info = await fetchPayRequest(payUrl, options) + const info = await fetchPayRequest(payUrl, kitOptions) if (!info.withdrawLink) { throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') } @@ -118,52 +99,57 @@ export const prepareMint = async ( if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { throw new Error("Amount is outside this mint's sendable range.") } - // a mint that names its outputs takes comment = sha256(secret) with the - // quote; one that doesn't gets the byte-identical request this wallet - // always sent, and its note stays keyed by the payment preimage - const named = namesMintOutput(info) - let noteSecret: string | undefined - let h: string | undefined - if (named) { - noteSecret = defaultRandomSecret() - h = hashK1(noteSecret) + const server = serverOf(payUrl) + await beforePersist?.({grossMsat, server}) + const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)() + const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey + const staged: NewBearer = { + url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), + callback: '', + // The URL retains the claimed amount for recovery, but the wallet's + // balance stays at zero until the mint confirms the authoritative value. + amount: 0, + verified: false, + pendingMint: mintPubkey ? {mintPubkey} : {}, } - const invoice = await requestInvoice( - info.callback, - grossMsat, - h ? {...options, h} : options, - ) + assertFundOwner(options) + await persistOutput(staged) + assertFundOwner(options) + const invoice = await requestInvoice(info.callback, grossMsat, { + ...kitOptions, + h: hashK1(noteSecret), + }) const prepared: PreparedMint = { invoice: invoice.pr, verifyUrl: invoice.verify ?? null, - mode: named ? 'named' : 'unnamed', + noteSecret, expectedNoteValueMsat: amountMsat, grossMsat, mintUrl: payUrl, withdrawLink: info.withdrawLink, - server: serverOf(payUrl), + server, username: lightningAddressUsername(payUrl), nodeInfo, } - if (noteSecret) prepared.noteSecret = noteSecret - if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey + if (mintPubkey) prepared.mintPubkey = mintPubkey return prepared } export type ClaimedNote = { note: NewBearer - // false when the note may be exposed to someone besides this wallet and - // the mint: an unnamed mint's fresh note whose rotate failed (its k1 was - // the payment preimage, transmitted and mint-known). A named mint's - // note always reports true - its secret never rode an invoice, so there - // is nothing to rotate away from rotated: boolean - // set when the rotate's answer was lost and the probe couldn't tell: the - // possible rotated copy, to track unverified alongside `note` possibleCopy?: NewBearer rotationError?: string } +export class MintedNoteSpentError extends Error { + override readonly name = 'MintedNoteSpentError' + + constructor() { + super('The mint reports the freshly minted note as already spent.') + } +} + // Polls a named mint output's informational GET until the note is there: // 'minted' resolves the wait ('spent' resolves it too - surfaced for the // caller to reject, never retried); 'unminted' and 'pending' keep it @@ -185,67 +171,27 @@ export const pollMintClaim = async ( options, ) -// what a claim needs once the preimage is known - PreparedMint satisfies -// this, and so does the target side of an inter-mint transfer (see -// transfer.ts) export type ClaimTarget = { withdrawLink: string + noteSecret: string // the net note value asked for - a claim, cross-checked against the // service's authoritative maxWithdrawable expectedNoteValueMsat: number mintPubkey?: string } -// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's -// verify URL and invoice as rescue anchors for a mint that took the hash -// but credited the preimage anyway. PreparedMint satisfies this. -export type NamedClaimTarget = ClaimTarget & { - noteSecret: string - verifyUrl?: string | null - invoice?: string -} - -// The named-mode claim: the note was credited to the wallet's own secret, -// which rode to the mint as a HASH inside the invoice request - the -// payment preimage opens nothing, so the claim needs no preimage and no -// rotate follows it. The rescue covers the mint that claimed the -// capability but credited the preimage (the kit's mintToHashIgnoresH -// adversary: the wallet was told no rotate is needed while the preimage -// is still the money): when that mint still serves the quote's verify, one -// check recovers the preimage and the note is claimed through the old -// path, WITH the rotate that path exists for. When it serves none there -// is nothing to rescue with - the error stays loud. +// The note was credited to the wallet's own secret, which rode to the mint +// only as a hash inside the invoice request. The payment preimage opens +// nothing, so the claim needs no preimage and no rotate follows it. export const claimFromSecret = async ( - target: NamedClaimTarget, + target: ClaimTarget, poll: PollOptions = {}, options: FundOperationOptions = {}, ): Promise => { assertFundOwner(options) - let claim: MintClaim - try { - claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) - } catch (err) { - if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) { - try { - const result = await fetchInvoiceVerification(target.verifyUrl, options) - if ( - result.settled && - sameInvoice(result.pr, target.invoice) && - result.preimage && - isPreimage(result.preimage) - ) { - return claimFromPreimage(target, result.preimage, options) - } - } catch { - // the rescue is best-effort - the original error stands - } - } - throw err - } + const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) if (claim.state === 'spent') { - // a fresh wallet-chosen secret cannot already be spent - the service - // is lying about the note it just credited - throw new Error('The mint reports the freshly minted note as already spent.') + throw new MintedNoteSpentError() } if (claim.k1 !== target.noteSecret) { // belt and suspenders: the kit's claimMintedNote returns the queried @@ -267,141 +213,48 @@ export const claimFromSecret = async ( return {note, rotated: true} } -// Polls until the minted note is claimable, then claims it. Unnamed mints: -// watch the LUD-21 verify URL for settlement, take the preimage (which IS -// the note secret there), and claim through claimFromPreimage with its -// mandatory rotate. Named mints: watch the note itself appear at the -// wallet's own secret (see claimFromSecret). +// Polls until the minted note is claimable at the wallet's staged secret. export const claimMintedNote = async ( prepared: PreparedMint, poll: PollOptions = {}, options: FundOperationOptions = {}, -): Promise => { - if (prepared.mode === 'named') { - const noteSecret = prepared.noteSecret - if (!noteSecret) { - throw new Error('This mint was prepared in named mode without a note secret.') - } - return claimFromSecret({...prepared, noteSecret}, poll, options) - } - if (!prepared.verifyUrl) { - throw new Error( - 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', - ) - } - const verifyUrl = prepared.verifyUrl - const result = await pollVerifyUntilSettled(verifyUrl, poll, options) - // 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.") - } - const preimage = result.preimage - if (!preimage || !isPreimage(preimage)) { - throw new Error('The payment settled but the service did not reveal the preimage.') - } - return claimFromPreimage(prepared, preimage, options) -} +): Promise => claimFromSecret(prepared, poll, options) -// The claim itself, once the payment's preimage is known: the preimage IS -// the note secret. The claim (an informational GET) puts that secret on -// the wire, and the mint has known it since it generated the invoice - so -// the fresh note is rotated immediately and unconditionally (observer -// race: anyone who saw the unpaid invoice knows the payment hash), before -// anything else happens with it. -export const claimFromPreimage = async ( - claim: ClaimTarget, - preimage: string, +// One bounded observation for durable recovery. An unpaid invoice is normal: +// keep the staged output and try again at the next wallet/service activation. +export type StagedMintRecovery = + | {readonly state: 'unminted'} + | {readonly state: 'pending'} + | {readonly state: 'spent'} + | {readonly state: 'minted'; readonly note: NewBearer} + +export const recoverStagedMintOutput = async ( + staged: Bearer, 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) +): Promise => { + const noteSecret = noteK1(staged.url) + const expectedNoteValueMsat = noteDeclaredAmount(staged.url) + if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) { + throw new Error('The pending mint output is malformed.') + } + const withdrawLink = new URL(staged.url) + withdrawLink.searchParams.delete('k1') + withdrawLink.searchParams.delete('amount') + withdrawLink.searchParams.delete('sig') 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 - const noteInfo = await fetchNoteInfo(declaredUrl, options) - const mintPubkey = noteInfo.mintPubkey ?? claim.mintPubkey - const base: NewBearer = { - url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), - callback: noteInfo.callback, - amount: noteInfo.maxWithdrawable, + const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options)) + if (claim.state === 'unminted') return {state: 'unminted'} + if (claim.state === 'pending') return {state: 'pending'} + if (claim.state === 'spent') return {state: 'spent'} + if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) { + throw new Error('The mint did not return the pending output details.') + } + const note: NewBearer = { + url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat), + callback: claim.callback, + amount: claim.amountMsat, verified: true, } - if (mintPubkey) base.mintPubkey = mintPubkey - - let url = base.url - let rotated = true - let possibleCopy: NewBearer | undefined - let rotationError: string | undefined - try { - const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options) - url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature) - } catch (err) { - rotated = false - if (err instanceof AmbiguousMutationError) { - // the rotate request may have landed despite the failure - the fresh - // secret it carried is then the only copy of this note - 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) - 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), - callback: noteInfo.callback, - amount: noteInfo.maxWithdrawable, - 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.` - } else { - rotationError = err.message - } - } else { - // A classified refusal can still be a LANDED rotate: the callback is - // a GET and HTTP stacks retry GETs, so the service may have executed - // the first attempt and refused this one as an already-spent input. - // "Already spent" then PROVES the preimage note is burned - the - // carried fresh secret is the only money left, so probe it before - // believing the refusal. - const carried = newSecretsOf(err) - if (carried.length === 1) { - const outcome = await probeMutationOutput(declaredUrl, carried[0], options) - if (outcome === 'live') { - // the rotate landed and this answer was its retried twin - url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable) - rotated = true - } else if (outcome === 'unknown') { - possibleCopy = { - url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable), - callback: noteInfo.callback, - amount: noteInfo.maxWithdrawable, - verified: false, - } - if (mintPubkey) possibleCopy.mintPubkey = mintPubkey - // the preimage note itself may be burned - it cannot stay verified - base.verified = false - rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.` - } else if (err instanceof NoteSpentError) { - // a genuine already-spent refusal with no live output: the note - // the service just described is burned - it cannot stay verified - base.verified = false - rotationError = err.message - } else { - rotationError = err instanceof Error ? err.message : String(err) - } - } else { - rotationError = err instanceof Error ? err.message : String(err) - } - } - } - const claimed: ClaimedNote = {note: {...base, url}, rotated} - if (possibleCopy) claimed.possibleCopy = possibleCopy - if (rotationError) claimed.rotationError = rotationError - return claimed + if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey + return {state: 'minted', note} } diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index f5d397b..2b67feb 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -8,6 +8,7 @@ import { NoteSpentError, PendingNoteError, decodeBolt11AmountMsat, + defaultRandomSecret, fetchPayRequest, isBolt11Invoice, meltNote, @@ -24,7 +25,7 @@ import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {assertFundOwner, pollVerifyUntilSettled} from './shared' +import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared' export type PayOutcome = | 'settled' @@ -41,16 +42,7 @@ export type PayResult = { invoice: string amountMsat: number verifyUrl: string | null - // on failed-funds-returned, when the classification rotate succeeded: - // the returned funds re-secured at a fresh secret. Kept OUT of `carve` - // (which always describes the carve as committed by the onCarve - // checkpoint) so an early-committed carve and a post-wait rotation can - // both be persisted - the carved note the rotate burned must be marked - // spent, this note added rotatedNote?: NewBearer - // a fresh secret rescued from an ambiguous rotate during outcome - // classification - the caller must track it unverified; if the rotate - // landed, this is the only copy of the (returned) funds rescuedNote?: NewBearer } @@ -67,6 +59,9 @@ export type PayOptions = { // so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] + // Called before the classification rotate puts its chosen output secret + // on the wire. Production callers durably link it to the carved source. + onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise } // A melt's resolved promise only means the payment is in flight; the @@ -82,13 +77,15 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {}, + {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {}, ): Promise => { - const options: FundOperationOptions = { + // the forced mutation policy (see shared.ts) covers the carve, the melt, + // and the classification rotate regardless of caller kit options + const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), - } + }) let invoice: string let amount: number const trimmed = input.trim() @@ -159,20 +156,21 @@ export const payWithBearers = async ( } return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} } catch { - // the verify budget ran out - probe the note itself with a rotate: a - // failed melt is only observable as the note becoming spendable again + // The verify budget ran out. Journal the exact output secret before the + // classification rotate reaches the mint, so a crash can recover either + // the returned source or the rotated replacement. + const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret() + await onReturnReady?.(carve, recoverySecret) try { - const rotated = await rotateNote(carve.note.callback, k1, options) - // the rotate succeeded, so the mint restored the note - and k1 had - // been on the wire since the melt, so the rotation doubles as the - // required re-securing of the returned funds. Reported separately - // from `carve` (see PayResult.rotatedNote): the carve may already be - // committed, and its note is what this rotate burned + const rotated = await rotateNote(carve.note.callback, k1, { + ...options, + randomSecret: () => recoverySecret, + }) return { outcome: 'failed-funds-returned', carve, rotatedNote: { - url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), + url: withNewK1(carve.note.url, recoverySecret, amount, rotated.signature), callback: carve.note.callback, amount, verified: true, @@ -184,20 +182,14 @@ export const payWithBearers = async ( } } catch (err) { if (err instanceof PendingNoteError) { - // still locked mid-melt - no outcome either way return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} } if (err instanceof NoteSpentError) { - // burned without a settled proof - the money is gone either way return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} } if (err instanceof AmbiguousMutationError) { - // the rotate's answer was lost. Had the note still been pending, - // the service would have said so cleanly - so the funds ARE back, - // but whether the rotation landed is unknown: the original k1 may - // be live, or the fresh secret may be the only copy. Surface both. const rescuedNote: NewBearer = { - url: withNewK1(carve.note.url, err.newSecrets[0], amount), + url: withNewK1(carve.note.url, recoverySecret, amount), callback: carve.note.callback, amount, verified: false, diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index e3ccc80..298aa38 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -17,7 +17,7 @@ import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' import type {ClaimedNote} from './mint' import type {FundOperationOptions} from './shared' -import {assertFundOwner, probeMutationOutput} from './shared' +import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, @@ -27,13 +27,15 @@ export const receiveBearer = async ( existing: Bearer[], options: FundOperationOptions = {}, ): Promise => { + // the forced mutation policy (see shared.ts) covers the rotate below + const mutationOptions = withMutationSafety(options) const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { return {note, rotated: false} } try { assertFundOwner(options) - const rotatedUrl = await secureReceivedNote(note, options) + const rotatedUrl = await secureReceivedNote(note, mutationOptions) return {note: {...note, url: rotatedUrl}, rotated: true} } catch (err) { // A classified rotate refusal can still be a LANDED rotate: the @@ -44,7 +46,7 @@ export const receiveBearer = async ( // it before believing the refusal. const carried = newSecretsOf(err) if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { - const outcome = await probeMutationOutput(note.url, carried[0], options) + const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions) if (outcome === 'live') { return { note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, @@ -81,7 +83,7 @@ export const receiveBearer = async ( throw err } if (err instanceof AmbiguousMutationError) { - const outcome = await probeBurnedNote(note.url, options) + const outcome = await probeBurnedNote(note.url, mutationOptions) if (outcome === 'gone') { return { note: { diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index 68573d2..518d086 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -2,7 +2,15 @@ // every flow that waits on a payment uses, and the uncertainty type a lost // mutation answer surfaces as. -import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' +import { + fetchInvoiceVerification, + fetchNoteInfo, + noteK1, + noteSignature, + serverOf, + verifyNoteSignature, + withNewK1, +} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import type {NewBearer} from '../types' @@ -10,8 +18,9 @@ import type {CarveResult} from './carve' // a mutation's answer was lost AND the probe could not tell whether it // landed - the possible outputs the fresh secrets would control, for the -// caller to track unverified alongside the (kept) inputs. Never dropped: -// if the mutation did land, these are the only money left. +// caller to track unverified alongside the kept inputs. A pre-wire carve +// checkpoint may already have persisted them, in which case this list is +// empty so callers do not add duplicate records. export class UncertainOutcomeError extends Error { readonly possibleOutputs: NewBearer[] constructor(message: string, possibleOutputs: NewBearer[]) { @@ -32,16 +41,54 @@ export class PollAbortedError extends Error { export type FundOperationOptions = LnurlcashOptions & { readonly assertOwner?: () => void - // called by a carve the moment its mutation has LANDED server-side (the - // inputs are burned, the outputs are known), before the flow moves on to - // anything slow or uncertain - the caller's one chance to durably commit - // the changeset so an abort during a long settlement wait can never - // strand the outputs or leave burned inputs looking spendable. If it - // rejects, the flow stops BEFORE anything further is spent and the - // rejection propagates: the carve state is then landed-but-maybe- - // uncommitted, which the caller must surface loudly. Never called for a - // carve that mutated nothing. + // Two durable phases for a mutating carve. Before wire use, unverified + // outputs are reported with `consumed: []`. After the mint mutation is + // known to have landed, the already-staged note is reported again with + // the inputs that can now be retired. A rejection in either phase stops + // the flow; exact-note carves call neither phase. readonly onCarve?: (carve: CarveResult) => void | Promise + // the trusted signing keys a landed mutation output's signature is + // checked against before it may report verified: the mint's pinned + // current key AND its previous one (a just-rotated mint's last notes + // stay verifiable). Production supplies the trusted-mint registry's + // keys; an absent source (or empty list) keeps every landed output + // staged unverified rather than trusting it blindly. + readonly mintSignatureKeys?: MintSignatureKeys +} + +// the trusted signing keys available for one mint server +export type MintSignatureKeys = (server: string) => readonly string[] + +// The mutation safety policy, forced where the engine talks to a mint: +// every mutation must come back signed (the signature is the only offline +// proof the mint issued the output) and a lost answer is retried exactly +// once, byte-identically (the replay the pre-wire staging exists to make +// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the +// caller spread means no caller's options object can quietly strip either +// guarantee. +export const withMutationSafety = (options: Options): Options => ({ + ...options, + requireSignatures: true, + mutationRetries: 1, +}) + +// A landed mutation output earns verified:true only when its signature +// (carried as the URL's sig param) verifies against a trusted key for its +// server. Missing, malformed, or wrong-key signatures - or no trusted keys +// on file at all - leave the note staged unverified; a refresh can repair +// it later. The money is never at stake here (the note exists at the mint +// either way), only the offline-verifiable badge is. +export const landedNoteVerifies = ( + noteUrl: string, + keysFor: MintSignatureKeys | undefined, +): boolean => { + if (!keysFor) return false + const k1 = noteK1(noteUrl) + const amountMsat = noteDeclaredAmount(noteUrl) + const signature = noteSignature(noteUrl) + if (!k1 || amountMsat === null || !signature) return false + const keys = keysFor(serverOf(noteUrl)) + return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]) } export const assertFundOwner = (options: FundOperationOptions): void => { diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index c41a344..cc910b6 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -3,20 +3,9 @@ // that exist: this wallet requests an invoice FROM the target mint // (grossed up for its advertised mint fee, so the note that comes out // nets the requested amount), melts source notes to pay it, then claims -// the target note exactly like any other minted receive. The target -// invoice settling is the transfer's ground truth: it can only settle if -// the source melt's payment arrived. How settling is observed and claimed -// depends on the target's minting mode (see mint.ts): -// -// - UNNAMED target: the verify URL is the observation - its settled -// response reveals the preimage, which IS the note's secret, and the -// claim rotates off it immediately. -// - NAMED target: the note itself is the observation - the wallet named -// the output with its own secret at quote time, so the informational -// GET reporting 'minted' IS the settlement proof, and the note is -// already at a secret nobody else has seen. No verify URL is needed -// (but one is used to rescue the mint that took the hash and credited -// the preimage anyway). +// the target note exactly like any other minted receive. The target note +// appearing at the wallet's pre-staged secret is the transfer's ground +// truth: it can only appear if the source melt's payment arrived. // // When settlement never confirms, the source note itself is the oracle - // a successful rotate proves the melt never burned it (funds returned), @@ -25,16 +14,16 @@ import { AmbiguousMutationError, NoteSpentError, + NoteUnknownError, PendingNoteError, buildNoteUrl, - decodeBolt11AmountMsat, - fetchInvoiceVerification, - isPreimage, + defaultRandomSecret, + fetchNoteInfo, meltNote, + newSecretsOf, noteK1, requireNoteK1, rotateNote, - sameInvoice, serverOf, withNewK1, } from 'lnurlcash-kit' @@ -42,11 +31,11 @@ import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' -import type {ClaimedNote, PreparedMint} from './mint' -import {claimFromPreimage, pollMintClaim, prepareMint} from './mint' +import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint' +import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {assertFundOwner, pollVerifyUntilSettled} from './shared' +import {assertFundOwner, withMutationSafety} from './shared' export type TransferOutcome = // the melt settled and the target note was claimed (and rotated) @@ -85,13 +74,11 @@ export type TransferClaimMaterial = { invoice: string withdrawLink: string expectedNoteValueMsat: number - // the preimage note, unverified, once the preimage is known - the - // preimage IS the note secret; the caller must track it and retry - note?: NewBearer - // named target only: the wallet-chosen secret the target note lands at. + note: NewBearer + // the wallet-chosen secret the target note lands at. // The note exists at this secret from the moment the invoice settles - // the caller can rebuild the note URL from it and retry the claim - noteSecret?: string + noteSecret: string } export type TransferResult = { @@ -101,8 +88,8 @@ export type TransferResult = { quote: TransferQuote // the invoice the source note was melted to pay invoice: string - // the target invoice's verify URL - an unnamed target's ground truth - // (a named target may not serve one at all; the note itself is observed) + // the target invoice's optional verify URL (used for a payment receipt; + // the staged note itself is the settlement observation) verifyUrl: string | null sourceServer: string targetServer: string @@ -132,6 +119,10 @@ export type TransferOptions = { // settlement wait, so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] + // persists the target note before its hash reaches the invoice callback + persistOutput: PrepareMintOptions['persistOutput'] + // Last durable checkpoint before the source melt can become irreversible. + onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise } // the claim retry material for a named target: the note at the wallet's @@ -147,41 +138,113 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer = return note } +const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => { + const eligible = bearers.filter( + (bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url), + ) + const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer) + if (eligible.length > 0 && candidates.length === 0) { + throw new Error("That's the mint these notes are already on - pick a different target.") + } + const totals = new Map() + for (const bearer of candidates) { + const server = serverOf(bearer.url) + totals.set(server, (totals.get(server) ?? 0) + bearer.amount) + } + if (![...totals.values()].some((total) => total >= grossMsat)) { + throw new Error('No mint holds enough verified, unspent balance to cover that amount.') + } + return candidates +} + +export type PendingTransferSourceRecovery = + | {readonly state: 'pending'} + | {readonly state: 'spent'} + | {readonly state: 'returned'; readonly note: NewBearer} + +export const recoverPendingTransferSource = async ( + staged: Bearer, + source: Bearer, + options: FundOperationOptions = {}, +): Promise => { + // a recovery only ever PROBES the melt outcome and rotates - it never + // replays the melt itself; the forced policy pins signature/retry safety + const mutationOptions = withMutationSafety(options) + const recoverySecret = staged.pendingMint?.sourceRecoverySecret + if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.') + const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount) + assertFundOwner(options) + try { + const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions) + const note: NewBearer = { + url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable), + callback: recovered.callback, + amount: recovered.maxWithdrawable, + verified: true, + } + if (source.mintPubkey) note.mintPubkey = source.mintPubkey + return {state: 'returned', note} + } catch (error) { + if (error instanceof NoteSpentError) return {state: 'spent'} + if (!(error instanceof NoteUnknownError)) throw error + } + assertFundOwner(options) + try { + const rotated = await rotateNote(source.callback, requireNoteK1(source.url), { + ...mutationOptions, + randomSecret: () => recoverySecret, + }) + const note: NewBearer = { + url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature), + callback: source.callback, + amount: source.amount, + verified: true, + } + if (source.mintPubkey) note.mintPubkey = source.mintPubkey + return {state: 'returned', note} + } catch (error) { + if ( + error instanceof PendingNoteError || + error instanceof NoteSpentError || + error instanceof AmbiguousMutationError || + newSecretsOf(error).includes(recoverySecret) + ) { + return {state: 'pending'} + } + throw error + } +} + export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {}, + {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions, ): Promise => { - const options: FundOperationOptions = { + const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), - } + }) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } // resolving the target and requesting its invoice touches only the // TARGET mint - a failure here (unreachable, no minting support, amount // out of range) leaves every source note untouched - const prepared = await prepareMint(targetMint, amountMsat, options) - if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { - throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', - ) - } + const prepared = await prepareMint(targetMint, amountMsat, { + ...options, + persistOutput, + beforePersist: ({grossMsat, server}) => { + transferSources(bearers, server, grossMsat) + }, + }) const verifyUrl = prepared.verifyUrl const targetServer = prepared.server // the source must be a DIFFERENT mint - value "moved" within one mint // 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), - ) - 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.") - } + const offTarget = transferSources(bearers, targetServer, prepared.grossMsat) const quote: TransferQuote = { requestedMsat: amountMsat, grossMsat: prepared.grossMsat, @@ -197,25 +260,22 @@ export const transferBetweenMints = async ( invoice, withdrawLink: prepared.withdrawLink, expectedNoteValueMsat: prepared.expectedNoteValueMsat, - } - if (prepared.noteSecret) { - claimMaterial.noteSecret = prepared.noteSecret - // a named target's note is claimable from the wallet's secret alone, - // the moment the invoice settles - track it unverified from the start, - // so a melt that settles after the budget is never a lost note - claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret) + noteSecret: prepared.noteSecret, + note: namedClaimNote(prepared, prepared.noteSecret), } // 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) + const sourceRecoverySecret = (kit.randomSecret ?? defaultRandomSecret)() if (carve.consumed.length === 0) assertFundOwner(options) + await onMeltReady?.(carve, sourceRecoverySecret) + assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) - } catch (err) { - if (err instanceof NoteSpentError) { - // this melt names a single note, so this is unambiguous - it was - // already gone before the melt even started + } catch (error) { + if (!(error instanceof Error)) throw error + if (error instanceof NoteSpentError) { return {...base, outcome: 'note-already-spent'} } // anything else - a clean refusal, a dropped response, a lost answer - @@ -223,119 +283,16 @@ export const transferBetweenMints = async ( // payment arrived, and the source probe tells the rest } try { - if (prepared.mode === 'named') { - const noteSecret = prepared.noteSecret - if (!noteSecret) throw new Error('The target mint was prepared without a note secret.') - // the note appearing at the wallet's own secret IS the settlement - // proof - the target only credits it once the melt's payment landed - try { - const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) - if ( - claim.state !== 'minted' || - claim.k1 !== noteSecret || - claim.amountMsat === null || - !claim.callback - ) { - // 'spent' on a fresh wallet-chosen secret, a claim naming a - // secret other than the one polled (impossible through the kit, - // which returns the queried secret and rejects mismatched - // echoes - asserted anyway: building from it would track the - // wrong note), or an incomplete answer: the target misbehaved - - // the way back to the note is already in the claim material - return {...base, outcome: 'settled-claim-failed', claimMaterial} - } - const note: NewBearer = { - url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat), - callback: claim.callback, - amount: claim.amountMsat, - verified: true, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - // no rotate: the secret never rode an invoice (see mint.ts) - return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}} - } catch (err) { - // the note never appeared at the wallet's secret within budget. - // Rescue through the quote's verify when the target serves one: - // a mint that took the hash but credited the PREIMAGE (the - // mintToHashIgnoresH adversary) is claimable through the old path - if (verifyUrl) { - try { - const proof = await fetchInvoiceVerification(verifyUrl, options) - if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) { - if (isPreimage(proof.preimage)) { - try { - const claimed = await claimFromPreimage(prepared, proof.preimage, options) - return {...base, outcome: 'settled', mintedAtTarget: claimed} - } catch { - const note: NewBearer = { - url: buildNoteUrl( - prepared.withdrawLink, - proof.preimage, - prepared.expectedNoteValueMsat, - ), - callback: '', - amount: prepared.expectedNoteValueMsat, - verified: false, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - return { - ...base, - outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note}, - } - } - } - } - } catch { - // best-effort rescue - fall through to the source-note oracle - } - } - throw err - } - } - if (!verifyUrl) { - throw new Error( - 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', - ) - } - const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) - // the proof-binding rule from pay.ts, extended for the gross-up: the - // verify URL is scoped to this invoice's payment hash, so an exact pr - // match binds it; short of that, a proof amount that is neither the - // invoiced gross nor the expected net belongs to another payment, - // while an undecodable one says nothing either way and is tolerated - const proofAmount = decodeBolt11AmountMsat(proof.pr) - if ( - !sameInvoice(proof.pr, invoice) && - proofAmount !== null && - proofAmount !== prepared.grossMsat && - proofAmount !== prepared.expectedNoteValueMsat - ) { - return {...base, outcome: 'unknown-still-pending', claimMaterial} - } - if (!proof.preimage || !isPreimage(proof.preimage)) { - // settled, but the service won't reveal the preimage - the claim - // cannot complete automatically - return {...base, outcome: 'settled-claim-failed', claimMaterial} - } try { - const claimed = await claimFromPreimage(prepared, proof.preimage, options) + const claimed = await claimFromSecret(prepared, poll, options) return {...base, outcome: 'settled', mintedAtTarget: claimed} - } catch { - // 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), - callback: '', - amount: prepared.expectedNoteValueMsat, - verified: false, - } - if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey - return { - ...base, - outcome: 'settled-claim-failed', - claimMaterial: {...claimMaterial, note}, + } catch (error) { + if (error instanceof MintedNoteSpentError) { + return {...base, outcome: 'settled-claim-failed', claimMaterial} } + throw new Error('The target note could not be confirmed at its staged secret.', { + cause: error, + }) } } catch { // the target invoice never settled within budget - the source note is @@ -345,7 +302,10 @@ export const transferBetweenMints = async ( // left but never arrived within the budget - the claim material stays // as the way back to the money if the invoice settles later try { - const rotated = await rotateNote(carve.note.callback, k1, options) + const rotated = await rotateNote(carve.note.callback, k1, { + ...options, + randomSecret: () => sourceRecoverySecret, + }) // the rotate succeeded: the melt provably never landed, and the // funds are back - re-secured, since the melt attempt put k1 on the // wire. Reported separately from `carve` (see rotatedNote): the @@ -367,10 +327,8 @@ export const transferBetweenMints = async ( return {...base, outcome: 'unknown-still-pending', claimMaterial} } if (err instanceof AmbiguousMutationError) { - // the rotate's answer was lost - pay.ts's reasoning: had the note - // still been pending the service would have said so, so the funds - // ARE back, but whether the rotation landed is unknown. Surface - // the possible fresh copy alongside the unchanged note. + // The rotate's answer was lost. Keep the transfer journal until + // recovery proves whether the original or recovery secret is live. const rescuedNote: NewBearer = { url: withNewK1(carve.note.url, err.newSecrets[0], carve.note.amount), callback: carve.note.callback, @@ -378,7 +336,7 @@ export const transferBetweenMints = async ( verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey - return {...base, outcome: 'failed-funds-returned', rescuedNote} + return {...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote} } return {...base, outcome: 'unknown-still-pending', claimMaterial} } diff --git a/src/lnurlcash/types.ts b/src/lnurlcash/types.ts index 974c196..185977c 100644 --- a/src/lnurlcash/types.ts +++ b/src/lnurlcash/types.ts @@ -35,6 +35,15 @@ export type Bearer = { // blank mirror, kept only so this bearer displays like any other // (amount/host/label/state) deviceId?: string + // A wallet-chosen mint output persisted before invoice creation. It stays + // zero-valued until the mint confirms it; transfers also remember the + // source record that must be locked when a late settlement is recovered. + pendingMint?: { + sourceBearerId?: string + sourceRecoverySecret?: string + mintPubkey?: string + retireAfter?: number + } createdAt: number updatedAt: number } @@ -46,4 +55,10 @@ export type NewBearer = { verified: boolean mintPubkey?: string deviceId?: string + pendingMint?: { + sourceBearerId?: string + sourceRecoverySecret?: string + mintPubkey?: string + retireAfter?: number + } } diff --git a/src/stores/walletFunds.recovery.test.ts b/src/stores/walletFunds.recovery.test.ts new file mode 100644 index 0000000..97a83e8 --- /dev/null +++ b/src/stores/walletFunds.recovery.test.ts @@ -0,0 +1,350 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { useWalletStore } from './wallet'; + +type Mint = Awaited>; + +const mints: Mint[] = []; + +const mint = async (): Promise => { + const instance = await createMockMint(); + mints.push(instance); + return instance; +}; + +const stagedNote = ( + instance: Mint, + secret: string, + sourceBearerId?: string, + sourceRecoverySecret?: string, +): NewBearer => ({ + url: buildNoteUrl(`${instance.url}/w`, secret, 21_000), + callback: '', + amount: 0, + verified: false, + pendingMint: sourceBearerId ? { sourceBearerId, sourceRecoverySecret } : {}, +}); + +const liveNote = (instance: Mint, secret: string): NewBearer => ({ + url: buildNoteUrl(`${instance.url}/w`, secret, 21_000), + callback: `${instance.url}/w/cb`, + amount: 21_000, + verified: true, +}); + +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + stubPassthroughLocks(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(async () => { + await Promise.all(mints.splice(0).map((instance) => instance.close())); +}); + +describe('pending mint recovery', () => { + it('keeps an unpaid staged output at zero value', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'a'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers).toEqual([staged]); + expect(wallet.balanceMsat).toBe(0); + }); + + it('retires an unminted NWC output after its safe settlement window', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const note = stagedNote(instance, '7'.repeat(64)); + const [staged] = await wallet.addBearers( + [{ ...note, pendingMint: { retireAfter: Math.floor(Date.now() / 1000) - 1 } }], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + }); + + it('finalizes a paid stage under the same id and spends its transfer source atomically', async () => { + const storage = stubLocalStorage(); + const instance = await mint(); + const secret = 'b'.repeat(64); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, 'c'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + const [staged] = await wallet.addBearers( + [stagedNote(instance, secret, source.id)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + instance.state.creditNote(secret, 21_000); + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1); + expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + amount: 21_000, + callback: `${instance.url}/w/cb`, + verified: true, + pendingMint: undefined, + }); + expect(wallet.balanceMsat).toBe(21_000); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('rejects finalization when the staged record is missing', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + + await expect( + wallet.finalizeStagedMintOutput( + 'missing', + liveNote(instance, 'd'.repeat(64)), + wallet.captureOwnerFence(), + ), + ).rejects.toThrow('record no longer exists'); + }); + + it('reserves a transfer source in the same write that links its staged output', async () => { + const storage = stubLocalStorage(); + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, 'e'.repeat(64))], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'f'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + const writes = vi.spyOn(storage, 'setItem'); + + const recoverySecret = '2'.repeat(64); + await wallet.reserveStagedMintSource( + staged.id, + source.id, + recoverySecret, + wallet.captureOwnerFence(), + ); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1); + expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)?.pendingMint).toEqual({ + sourceBearerId: source.id, + sourceRecoverySecret: recoverySecret, + }); + }); + + it('rejects a second reservation of the same source', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const [source] = await wallet.addBearers( + [liveNote(instance, '3'.repeat(64))], + wallet.captureOwnerFence(), + ); + const [first, second] = await wallet.addBearers( + [stagedNote(instance, '4'.repeat(64)), stagedNote(instance, '5'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !first || !second) throw new Error('Expected source and staged bearers.'); + await wallet.reserveStagedMintSource( + first.id, + source.id, + '6'.repeat(64), + wallet.captureOwnerFence(), + ); + + await expect( + wallet.reserveStagedMintSource( + second.id, + source.id, + '7'.repeat(64), + wallet.captureOwnerFence(), + ), + ).rejects.toThrow(/already reserved/i); + }); + + it('re-secures a live linked source after a crash before melt', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '8'.repeat(64); + const recoverySecret = '9'.repeat(64); + instance.state.creditNote(sourceSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'a'.repeat(64))], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.reserveStagedMintSource( + staged.id, + source.id, + recoverySecret, + wallet.captureOwnerFence(), + ); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + expect(instance.state.noteState(sourceSecret)).toBe('burned'); + expect(instance.state.noteState(recoverySecret)).toBe('outstanding'); + }); + + it('adopts the journaled source secret after a recovery rotate landed before a crash', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = 'd'.repeat(64); + const recoverySecret = 'e'.repeat(64); + instance.state.creditNote(recoverySecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, 'f'.repeat(64), source?.id, recoverySecret)], + wallet.captureOwnerFence(), + ); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('deduplicates a returned source saved before its transfer journal was removed', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '1'.repeat(64); + const recoverySecret = '2'.repeat(64); + instance.state.creditNote(recoverySecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + const [staged] = await wallet.addBearers( + [stagedNote(instance, '3'.repeat(64), source?.id, recoverySecret)], + wallet.captureOwnerFence(), + ); + await wallet.addBearers([liveNote(instance, recoverySecret)], wallet.captureOwnerFence()); + if (!source || !staged) throw new Error('Expected source and staged bearers.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.filter((bearer) => bearer.url.includes(recoverySecret))).toHaveLength(1); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('returns a live source even when the staged target cannot be observed', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '4'.repeat(64); + const recoverySecret = '5'.repeat(64); + instance.state.creditNote(sourceSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + const [staged] = await wallet.addBearers( + [ + { + url: buildNoteUrl('http://127.0.0.1:1/w', '6'.repeat(64), 21_000), + callback: '', + amount: 0, + verified: false, + pendingMint: { sourceBearerId: source.id, sourceRecoverySecret: recoverySecret }, + }, + ], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + await wallet.markSpent(source.id, wallet.captureOwnerFence()); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + expect(wallet.bearers.find((bearer) => bearer.url.includes(recoverySecret))).toMatchObject({ + amount: 21_000, + verified: true, + }); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('ends recovery when the staged target is definitively spent', async () => { + const instance = await mint(); + const secret = '1'.repeat(64); + const wallet = useWalletStore(); + await wallet.create(); + const [staged] = await wallet.addBearers( + [stagedNote(instance, secret)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + instance.state.creditNote(secret, 21_000); + instance.state.settleMelt(secret); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + spent: true, + pendingMint: undefined, + }); + }); +}); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index bd97251..c4608b1 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,6 +1,6 @@ // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. import { computed, ref } from 'vue'; -import { deriveCashSecret, serverOf } from 'lnurlcash-kit'; +import { deriveCashSecret, noteK1, serverOf } from 'lnurlcash-kit'; import type { CashNode } from 'lnurlcash-kit'; import { @@ -15,7 +15,9 @@ import type { BearerChangeset } from '@/lnurlcash/storage'; import { lockTrustedMint } from '@/lnurlcash/trustedMints'; import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { msatToSats } from '@/lnurlcash/units'; +import { recoverPendingTransferSource } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops'; +import { recoverStagedMintOutput } from '@/lnurlcash/ops/mint'; import type { WalletOwnerFence } from './walletOwnerFence'; export class TrustedMintPostCommitError extends Error { @@ -160,10 +162,30 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise => mutate(async () => { - const existing = bearers.value.find((bearer) => bearer.url === carve.note.url); + // a carve checkpoint is identified by its output secret, not the + // whole URL: the landed phase's note carries the mint's signature in + // the URL that the pre-wire staged phase could not have had yet + const sameOutput = (bearer: Bearer, note: NewBearer): boolean => { + const k1 = noteK1(note.url); + return ( + k1 !== null && + serverOf(bearer.url) === serverOf(note.url) && + noteK1(bearer.url) === k1 + ); + }; + const staged = bearers.value.find((bearer) => sameOutput(bearer, carve.note)); + const change = carve.change; const additions: NewBearer[] = []; - if (!existing) additions.push(carve.note); - if (carve.change) additions.push(carve.change); + if (!staged) additions.push(carve.note); + if (change && !bearers.value.some((bearer) => sameOutput(bearer, change))) { + additions.push(change); + } + // the landed checkpoint re-reports the staged note with its landed + // signature and verification state - refresh the record in place so + // a replayed checkpoint is a no-op rather than a duplicate + const upserts: Bearer[] = staged + ? [{ ...staged, ...carve.note, id: staged.id, updatedAt: Date.now() }] + : []; ownerFence(); const next = await applyBearerChangeset( options.requireKey(), @@ -171,13 +193,14 @@ export const createWalletFunds = (options: WalletFundsOptions) => { { add: additions, markSpent: carve.consumed.map((bearer) => bearer.id), + upsert: upserts, }, { beforeCommit: ownerFence }, ); const added = next.slice(0, additions.length); bearers.value = next; - await lockCommittedBearers(added); - const committed = existing ?? added[0]; + await lockCommittedBearers([...added, ...upserts]); + const committed = upserts[0] ?? added[0]; if (!committed) throw new Error('The carved note was not tracked.'); return committed; }); @@ -206,6 +229,234 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } }); + const reserveStagedMintSource = ( + stagedId: string, + sourceId: string, + sourceRecoverySecret: string, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const staged = bearers.value.find((bearer) => bearer.id === stagedId); + const source = bearers.value.find((bearer) => bearer.id === sourceId); + if (!staged || !source) throw new BearerNotFoundError(); + if (!staged.pendingMint) throw new Error('The wallet record is not a pending mint output.'); + if (source.spent) throw new Error('The source note is already reserved.'); + ownerFence(); + const linked: Bearer = { + ...staged, + pendingMint: { + ...staged.pendingMint, + sourceBearerId: source.id, + sourceRecoverySecret, + }, + updatedAt: Date.now(), + }; + bearers.value = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { add: [], markSpent: [source.id], upsert: [linked] }, + { beforeCommit: ownerFence }, + ); + }); + + const restoreStagedMintSource = ( + stagedId: string, + note: NewBearer, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + const staged = bearers.value.find((bearer) => bearer.id === stagedId); + if (!staged?.pendingMint?.sourceBearerId) throw new BearerNotFoundError(); + const recoveryK1 = noteK1(note.url); + const existing = recoveryK1 + ? bearers.value.find( + (bearer) => + bearer.id !== staged.id && + serverOf(bearer.url) === serverOf(note.url) && + noteK1(bearer.url) === recoveryK1, + ) + : undefined; + ownerFence(); + const additions = existing ? [] : [note]; + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: additions, + markSpent: [staged.pendingMint.sourceBearerId], + remove: [staged.id], + upsert: existing + ? [{ ...existing, ...note, id: existing.id, updatedAt: Date.now() }] + : [], + }, + { beforeCommit: ownerFence }, + ); + bearers.value = next; + const committed = existing + ? next.find((bearer) => bearer.id === existing.id) + : next[0]; + if (!committed) throw new BearerNotFoundError(); + await lockCommittedBearers([committed]); + }); + + const finalizeStagedMintOutput = async ( + id: string, + note: NewBearer, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + if (!current.pendingMint) { + if ( + current.url === note.url && + current.callback === note.callback && + current.amount === note.amount && + current.verified === note.verified && + current.mintPubkey === note.mintPubkey + ) { + return; + } + throw new Error('The wallet record is not a pending mint output.'); + } + const sourceBearerId = current.pendingMint.sourceBearerId; + if (sourceBearerId && !bearers.value.some((bearer) => bearer.id === sourceBearerId)) { + throw new BearerNotFoundError(); + } + ownerFence(); + const confirmed: Bearer = { + ...current, + ...note, + pendingMint: undefined, + updatedAt: Date.now(), + }; + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: sourceBearerId ? [sourceBearerId] : [], + upsert: [confirmed], + }, + { beforeCommit: ownerFence }, + ); + bearers.value = next; + await lockCommittedBearers([confirmed]); + }); + + const finalizeSpentStagedMintOutput = (id: string, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + if (!current.pendingMint) return; + const sourceBearerId = current.pendingMint.sourceBearerId; + ownerFence(); + const spent: Bearer = { + ...current, + pendingMint: undefined, + spent: true, + updatedAt: Date.now(), + }; + bearers.value = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: sourceBearerId ? [sourceBearerId] : [], + upsert: [spent], + }, + { beforeCommit: ownerFence }, + ); + }); + + let recoveryRun: Promise | null = null; + const recoverPendingMints = (ownerFence: WalletOwnerFence): Promise => { + if (recoveryRun) return recoveryRun; + recoveryRun = (async () => { + for (const staged of [...bearers.value]) { + if (!staged.pendingMint || staged.spent) continue; + try { + const recoverLinkedSource = async (): Promise => { + const sourceBearerId = staged.pendingMint?.sourceBearerId; + if (!sourceBearerId) return; + const source = bearers.value.find((bearer) => bearer.id === sourceBearerId); + if (!source) throw new BearerNotFoundError(); + const sourceRecovery = await recoverPendingTransferSource(staged, source, { + assertOwner: ownerFence, + }); + switch (sourceRecovery.state) { + case 'pending': + break; + case 'returned': + await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence); + break; + case 'spent': + await finalizeSpentStagedMintOutput(staged.id, ownerFence); + break; + } + }; + const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence }); + switch (recovered.state) { + case 'unminted': + if ( + !staged.pendingMint.sourceBearerId && + staged.pendingMint.retireAfter !== undefined && + staged.pendingMint.retireAfter <= Math.floor(Date.now() / 1000) + ) { + await removeNote(staged.id, ownerFence); + break; + } + await recoverLinkedSource(); + break; + case 'pending': + await recoverLinkedSource(); + break; + case 'minted': + await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence); + break; + case 'spent': + await finalizeSpentStagedMintOutput(staged.id, ownerFence); + break; + } + } catch (error) { + ownerFence(); + if (staged.pendingMint.sourceBearerId) { + try { + const source = bearers.value.find( + (bearer) => bearer.id === staged.pendingMint?.sourceBearerId, + ); + if (!source) throw new BearerNotFoundError(); + const sourceRecovery = await recoverPendingTransferSource(staged, source, { + assertOwner: ownerFence, + }); + if (sourceRecovery.state === 'returned') { + await restoreStagedMintSource(staged.id, sourceRecovery.note, ownerFence); + continue; + } + } catch (sourceError) { + ownerFence(); + if (sourceError instanceof TrustedMintPostCommitError) { + options.setAuxiliaryError(sourceError.message); + continue; + } + } + } + options.setAuxiliaryError( + error instanceof TrustedMintPostCommitError + ? error.message + : 'A pending mint output could not be refreshed. It will be retried later.', + ); + } + } + })().finally(() => { + recoveryRun = null; + }); + return recoveryRun; + }; + const markSpent = async ( id: string, ownerFence: WalletOwnerFence, @@ -297,6 +548,11 @@ export const createWalletFunds = (options: WalletFundsOptions) => { applyChangeset, commitCarve, updateBearer, + reserveStagedMintSource, + restoreStagedMintSource, + finalizeStagedMintOutput, + finalizeSpentStagedMintOutput, + recoverPendingMints, allocateCashSecrets, markSpent, removeNote, From e6d0712258dd31d85690687824d25b48240ca16e Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:33:42 +0200 Subject: [PATCH 16/29] feat(wallet): allocate operation secrets from the cash root --- src/stores/wallet.ts | 3 + src/stores/walletFunds.atomic.test.ts | 21 ++++++- src/stores/walletFunds.recovery.test.ts | 83 ++++++++++++++++++++++++- src/stores/walletFunds.ts | 69 +++++++++++++++++++- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 7463dc7..f31106d 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -1,6 +1,7 @@ // allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface. import { computed, onScopeDispose, ref } from 'vue'; import { defineStore } from 'pinia'; +import { cashNodeFromHex } from 'lnurlcash-kit'; import { clearSavedLinkingKey, @@ -176,6 +177,7 @@ export const useWalletStore = defineStore('wallet', () => { funds.replace(loaded); acceptingOwnerWork = true; state.value = 'unlocked'; + await funds.public.recoverPendingMints(ownerFence.capture()); idleWatch.start(); } catch (error) { stopOwnerChanges(); @@ -323,6 +325,7 @@ export const useWalletStore = defineStore('wallet', () => { const funds = createWalletFunds({ requireKey, + requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex), ownerId: () => pubkey.value ?? undefined, setAuxiliaryError: (message) => { auxiliaryError.value = message; diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts index 5f53fae..5544914 100644 --- a/src/stores/walletFunds.atomic.test.ts +++ b/src/stores/walletFunds.atomic.test.ts @@ -1,5 +1,5 @@ import { createPinia, setActivePinia } from 'pinia'; -import { buildNoteUrl, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit'; +import { buildNoteUrl, cashNodeFromHex, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveBearerAesKey } from '@/lnurlcash/keys'; @@ -241,4 +241,23 @@ describe('wallet BIP-32 counter allocation', () => { expect(encrypt).not.toHaveBeenCalled(); expect(readFundsDocument().nextByHost).toEqual({}); }); + + it('serves the operation-facing allocation from the unlocked wallet material', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const root = cashNodeFromHex(wallet.requireWalletMaterial().cashRootHex); + + const secrets = await wallet.allocateOutputSecrets( + 'mint.example', + 2, + wallet.captureOwnerFence(), + ); + + expect(secrets).toEqual([ + deriveCashSecret(root, 'mint.example', 0), + deriveCashSecret(root, 'mint.example', 1), + ]); + expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 }); + expect(readFundsDocument().pending).toHaveLength(1); + }); }); diff --git a/src/stores/walletFunds.recovery.test.ts b/src/stores/walletFunds.recovery.test.ts index 97a83e8..8d8b894 100644 --- a/src/stores/walletFunds.recovery.test.ts +++ b/src/stores/walletFunds.recovery.test.ts @@ -1,8 +1,10 @@ import { createPinia, setActivePinia } from 'pinia'; -import { buildNoteUrl } from 'lnurlcash-kit'; +import { buildNoteUrl, cashNodeFromHex } from 'lnurlcash-kit'; import { createMockMint } from 'lnurlcash-conformance/mock-mint'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readPendingJournal } from '@/lnurlcash/storage'; +import { readFundsDocument } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { useWalletStore } from './wallet'; @@ -329,9 +331,9 @@ describe('pending mint recovery', () => { it('ends recovery when the staged target is definitively spent', async () => { const instance = await mint(); - const secret = '1'.repeat(64); const wallet = useWalletStore(); await wallet.create(); + const secret = '1'.repeat(64); const [staged] = await wallet.addBearers( [stagedNote(instance, secret)], wallet.captureOwnerFence(), @@ -347,4 +349,81 @@ describe('pending mint recovery', () => { pendingMint: undefined, }); }); + + it('finalizes a landed receive rotation under the staged record id', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const originalK1 = '1c'.repeat(32); + const rotationSecret = '1d'.repeat(32); + // the rotate landed before the crash: the mint credited the staged secret + instance.state.creditNote(rotationSecret, 21_000); + const [staged] = await wallet.addBearers( + [ + { + url: buildNoteUrl(`${instance.url}/w`, rotationSecret, 21_000), + callback: `${instance.url}/w/cb`, + amount: 21_000, + verified: false, + pendingMint: { sourceRecoverySecret: originalK1 }, + }, + ], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + amount: 21_000, + verified: true, + pendingMint: undefined, + }); + expect(wallet.balanceMsat).toBe(21_000); + }); + + it('restores a received note at its original secret when the rotation never landed', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const originalK1 = '1e'.repeat(32); + const rotationSecret = '1f'.repeat(32); + // the rotate never reached the mint: the sender's copy is still live + instance.state.creditNote(originalK1, 21_000); + const [staged] = await wallet.addBearers( + [ + { + url: buildNoteUrl(`${instance.url}/w`, rotationSecret, 21_000), + callback: `${instance.url}/w/cb`, + amount: 21_000, + verified: false, + pendingMint: { sourceRecoverySecret: originalK1 }, + }, + ], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + const restored = wallet.bearers.find((bearer) => bearer.id === staged.id); + expect(restored?.url).toContain(originalK1); + expect(restored).toMatchObject({ amount: 21_000, verified: true, pendingMint: undefined }); + expect(wallet.balanceMsat).toBe(21_000); + expect(instance.state.noteState(originalK1)).toBe('outstanding'); + }); + + it('retires cash-allocation journal records once the bearer side is reconciled', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const root = cashNodeFromHex(wallet.requireWalletMaterial().cashRootHex); + await wallet.allocateCashSecrets(root, 'mint.example', 2, wallet.captureOwnerFence()); + expect(readPendingJournal()).toHaveLength(1); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + // the journal record is retired; the reserved counter range never rewinds + expect(readPendingJournal()).toEqual([]); + expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 }); + }); }); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index c4608b1..15eb9a0 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,6 +1,6 @@ // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. import { computed, ref } from 'vue'; -import { deriveCashSecret, noteK1, serverOf } from 'lnurlcash-kit'; +import { deriveCashSecret, noteK1, serverOf, withNewK1 } from 'lnurlcash-kit'; import type { CashNode } from 'lnurlcash-kit'; import { @@ -9,6 +9,7 @@ import { mergeBearers, persistBearer, readFundsRevision, + readPendingJournal, reserveCashIndices, } from '@/lnurlcash/storage'; import type { BearerChangeset } from '@/lnurlcash/storage'; @@ -55,6 +56,7 @@ export type AllocatedCashSecrets = { type WalletFundsOptions = { readonly requireKey: () => CryptoKey; + readonly requireCashRoot: () => CashNode; readonly ownerId: () => string | undefined; readonly setAuxiliaryError: (message: string) => void; }; @@ -409,6 +411,33 @@ export const createWalletFunds = (options: WalletFundsOptions) => { await removeNote(staged.id, ownerFence); break; } + if ( + !staged.pendingMint.sourceBearerId && + staged.pendingMint.sourceRecoverySecret + ) { + // a receive rotation that never landed: nothing exists at + // the staged secret, so the note is restored at the + // original k1 it was received on (journal GC below retires + // the allocation record separately) + await finalizeStagedMintOutput( + staged.id, + { + url: withNewK1( + staged.url, + staged.pendingMint.sourceRecoverySecret, + staged.amount, + ), + callback: staged.callback, + amount: staged.amount, + verified: true, + ...(staged.pendingMint.mintPubkey + ? { mintPubkey: staged.pendingMint.mintPubkey } + : {}), + }, + ownerFence, + ); + break; + } await recoverLinkedSource(); break; case 'pending': @@ -451,6 +480,28 @@ export const createWalletFunds = (options: WalletFundsOptions) => { ); } } + // Retire cash-allocation journal records: their secrets are either + // already carried by staged bearer records (the operative journal) + // or never reached a mint (staging always precedes the wire), so the + // records are crash-window proofs, safe to clear once recovery has + // reconciled the bearer side. The counter bump they document is + // permanent either way - burned indices are never reused. + try { + const staleAllocationIds = readPendingJournal() + .filter((record) => record.kind === 'cash-allocation') + .map((record) => record.id); + if (staleAllocationIds.length > 0) { + await applyChangeset( + { add: [], markSpent: [], clearPending: staleAllocationIds }, + ownerFence, + ); + } + } catch (error) { + ownerFence(); + options.setAuxiliaryError( + 'Pending allocation records could not be cleared. They will be retired later.', + ); + } })().finally(() => { recoveryRun = null; }); @@ -535,6 +586,21 @@ export const createWalletFunds = (options: WalletFundsOptions) => { }; }); + // The operation-facing allocation path: the ops engine asks for exactly + // the output secrets it is about to stage, keyed by the mint's canonical + // host, and the reservation (counter bump + encrypted journal record) + // commits before any of them can reach the wire. The cash root never + // leaves the wallet store - it is re-read from the unlocked material per + // call. + const allocateOutputSecrets = ( + server: string, + count: number, + ownerFence: WalletOwnerFence, + ): Promise => + allocateCashSecrets(options.requireCashRoot(), server, count, ownerFence).then( + (allocated) => allocated.secrets, + ); + return { public: { bearers, @@ -554,6 +620,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { finalizeSpentStagedMintOutput, recoverPendingMints, allocateCashSecrets, + allocateOutputSecrets, markSpent, removeNote, mergeExternalBearers, From 22b1c5595cea73c8e796e22b381d8c6a5d7fd564 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:34:01 +0200 Subject: [PATCH 17/29] feat(ops): draw mint-touching output secrets from caller allocation --- src/composables/useReceiveTokenDialog.ts | 39 +++++++- src/lnurlcash/ops.carve.cases.ts | 53 +++++++++++ src/lnurlcash/ops.mint-named.cases.ts | 20 ++++- src/lnurlcash/ops.mint-receive.cases.ts | 61 +++++++++++++ src/lnurlcash/ops.pay.cases.ts | 41 +++++++++ src/lnurlcash/ops.testHarness.ts | 18 ++++ src/lnurlcash/ops.transfer.cases.ts | 34 +++++++ src/lnurlcash/ops.ts | 5 +- src/lnurlcash/ops/allocation.ts | 43 +++++++++ src/lnurlcash/ops/carve.ts | 22 ++++- src/lnurlcash/ops/mint.ts | 11 ++- src/lnurlcash/ops/pay.ts | 25 +++++- src/lnurlcash/ops/receiveBearer.ts | 109 ++++++++++++++++++----- src/lnurlcash/ops/transfer.ts | 26 ++++-- 14 files changed, 463 insertions(+), 44 deletions(-) create mode 100644 src/lnurlcash/ops/allocation.ts diff --git a/src/composables/useReceiveTokenDialog.ts b/src/composables/useReceiveTokenDialog.ts index 3f53601..8b5ffb2 100644 --- a/src/composables/useReceiveTokenDialog.ts +++ b/src/composables/useReceiveTokenDialog.ts @@ -8,7 +8,7 @@ import { } from 'lnurlcash-kit'; import { receiveBearer } from '@/lnurlcash/ops'; -import type { NewBearer } from '@/lnurlcash/types'; +import type { Bearer } from '@/lnurlcash/types'; import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; import { useMintsStore } from '@/stores/mints'; @@ -95,18 +95,42 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok if (busy.value || value === '') return; busy.value = true; clearError(); + let fundOperation: ReturnType | undefined; + let staged: Bearer | undefined; try { - const ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + // the rotate's fresh secret is reserved and its future note staged + // before the mutation can land - a crash mid-rotate never loses the + // money (wallet recovery reconciles the staged record) const claimed = await receiveBearer(value, wallet.bearers, { assertOwner: ownerFence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, ownerFence), + stageRotation: async (note) => { + [staged] = await wallet.addBearers([note], 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); + if (claimed.stage === 'finalize') { + // the rotate landed: the staged record becomes the confirmed note + if (!staged) throw new Error('The rotated note was not staged.'); + await wallet.finalizeStagedMintOutput(staged.id, note, ownerFence); + } else if (claimed.stage === 'discard') { + // the rotate provably never landed: drop the worthless stage and + // keep the original (exposed - see rotationWarning) + if (staged) await wallet.removeNote(staged.id, ownerFence); + await wallet.addBearers([note], ownerFence); + } else { + // 'keep': the staged record is the possible rotated copy; the + // original is tracked unverified alongside it. 'none': no rotate + // was attempted - the note goes in as received. + await wallet.addBearers([note], ownerFence); + } } catch (error) { if (!(error instanceof TrustedMintPostCommitError)) throw error; trustWarning = error.message; @@ -132,8 +156,15 @@ export const useReceiveTokenDialog = (props: ReceiveTokenProps, emit: ReceiveTok trustPrompt.openTrust(server, note.mintPubkey); } } catch (error) { + // a definitive failure thrown after staging leaves the staged record + // behind - drop it best-effort (wallet recovery restores the original + // note from it if this discard itself is impossible right now) + if (staged && fundOperation) { + await wallet.removeNote(staged.id, fundOperation.ownerFence).catch(() => undefined); + } classifyError(error instanceof Error ? error : new Error(String(error))); } finally { + fundOperation?.complete(); busy.value = false; } }; diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index 897fa9f..9f2a30d 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -250,4 +250,57 @@ describe('ensureExactAmount', () => { ).rejects.toThrow(/commit failed/) expect(instance.state.noteState(secret('14'))).toBe('outstanding') }) + + it('draws the split outputs from the caller allocation path, exactly two', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('80'), 21_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('81'), secret('82')] + + const result = await ensureExactAmount([bearer], 5_000, { + onCarve: () => undefined, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 2}]) + expect(noteK1(result.note.url)).toBe(allocated[0]) + expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]) + }) + + it('draws the merge output from the caller allocation path, exactly one', async () => { + const instance = await mint() + const first = await makeBearer(instance, secret('83'), 3_000) + const second = await makeBearer(instance, secret('84'), 4_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('85')] + + const result = await ensureExactAmount([first, second], 7_000, { + onCarve: () => undefined, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(noteK1(result.note.url)).toBe(allocated[0]) + }) + + it('allocates nothing when one note already holds the exact amount', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('86'), 21_000) + let allocations = 0 + + await ensureExactAmount([bearer], 21_000, { + allocateOutputSecrets: () => { + allocations += 1 + return Promise.resolve([]) + }, + }) + + expect(allocations).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 886f165..16ce2b0 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -4,7 +4,7 @@ import {hashK1, noteK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, persistOutput, settleLastInvoice} from './ops.testHarness' +import {mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ @@ -65,4 +65,22 @@ describe('mint -> claim, comment-bound output', () => { expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32) }) + + it('draws the staged note secret from the caller allocation path', async () => { + const instance = await mint({testHooks: true}) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('90')] + + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + }) + + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(prepared.noteSecret).toBe(allocated[0]) + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0]))) + }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 6c0635d..5c5b989 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -10,6 +10,7 @@ import { import {claimMintedNote, prepareMint, receiveBearer} from './ops' import {requiredValue} from './test-utils' +import type {NewBearer} from './types' import { makeBearer, mint, @@ -40,6 +41,66 @@ describe('mint fee', () => { }) describe('receiveBearer', () => { + it('stages the rotation output before the rotate can reach the mint', async () => { + const instance = await mint() + const senderK1 = secret('94') + instance.state.creditNote(senderK1, 21_000) + const rotationSecret = secret('95') + let staged: NewBearer | undefined + const observingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) expect(staged).toBeDefined() + return fetch(input, init) + } + + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + fetch: observingFetch, + allocateOutputSecrets: () => Promise.resolve([rotationSecret]), + stageRotation: (note) => { + staged = note + }, + }) + + expect(received.stage).toBe('finalize') + expect(received.rotated).toBe(true) + expect(noteK1(received.note.url)).toBe(rotationSecret) + expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret) + expect(requiredValue(staged).verified).toBe(false) + expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1) + expect(instance.state.noteState(senderK1)).toBe('burned') + expect(instance.state.noteState(rotationSecret)).toBe('outstanding') + }) + + it('reports discard when the rotate provably never landed', async () => { + const instance = await mint() + const senderK1 = secret('96') + instance.state.creditNote(senderK1, 21_000) + const rotationSecret = secret('97') + let staged: NewBearer | undefined + const failingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset')) + return fetch(input, init) + } + + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + fetch: failingFetch, + allocateOutputSecrets: () => Promise.resolve([rotationSecret]), + stageRotation: (note) => { + staged = note + }, + }) + + expect(received.stage).toBe('discard') + expect(received.rotated).toBe(false) + expect(received.note.verified).toBe(true) + expect(received.rotationError).toBeDefined() + // nothing landed: both the sender's copy and the would-be output live on + expect(instance.state.noteState(senderK1)).toBe('outstanding') + expect(instance.state.noteState(rotationSecret)).toBeNull() + expect(staged).toBeDefined() + }) + it("verifies an incoming note and rotates it, burning the sender's copy", async () => { const instance = await mint() const senderK1 = secret('20') diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index bb770e6..04105ab 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -134,4 +134,45 @@ describe('payWithBearers', () => { expect(instance.state.noteState(secret('36'))).toBe('outstanding') expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) }) + + it('draws the return recovery secret from the caller allocation path', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8a'), 21_000) + const allocations: Array<{server: string; count: number}> = [] + const allocated = [secret('8b')] + const stagedSecrets: string[] = [] + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + return Promise.resolve(allocated.slice(0, count)) + }, + onReturnReady: (_carve, recoverySecret) => { + stagedSecrets.push(recoverySecret) + }, + }) + + expect(result.outcome).toBe('failed-funds-returned') + expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) + expect(stagedSecrets).toEqual(allocated) + expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]) + }) + + it('allocates nothing while the melt settles cleanly', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('8c'), 21_000) + let allocations = 0 + + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + allocateOutputSecrets: () => { + allocations += 1 + return Promise.resolve([]) + }, + }) + + expect(result.outcome).toBe('settled') + expect(allocations).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 2b4c281..4796a79 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -26,6 +26,24 @@ export const secret = (seed: string): string => export const persistOutput = async (): Promise => undefined +// deterministic stand-in for the wallet's BIP-32 allocation path: answers +// every reservation with fresh unique secrets, never reusing one +let allocatedSecretCounter = 0 +export const allocateOutputSecrets = ( + _server: string, + count: number, +): Promise => { + const secrets = Array.from({length: count}, () => { + allocatedSecretCounter += 1 + return secret(`allocated-${allocatedSecretCounter.toString(16)}`) + }) + return Promise.resolve(secrets) +} + +// the receive-rotation staging checkpoint - engine tests keep no wallet, +// so staging is a recorded no-op for them +export const stageRotation = async (): Promise => undefined + // the trusted signing keys a production caller would serve from the // trusted-mint registry: the mock mint's current key plus any it retired export const signatureKeysFor = diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 7f70578..2c944c6 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -25,6 +25,40 @@ const transferBetweenMints = ( describe('transferBetweenMints', () => { const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + it('draws the target and source secrets from the caller allocation path', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('91'), 21_000) + const targetSecret = secret('92') + const sourceRecoverySecret = secret('93') + const allocations: Array<{server: string; count: number}> = [] + const meltReadiness: string[] = [] + + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + allocateOutputSecrets: (server, count) => { + allocations.push({server, count}) + const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret] + return Promise.resolve(secrets.slice(0, count)) + }, + onMeltReady: (_carve, recovery) => { + meltReadiness.push(recovery) + }, + }) + await settleWhenRequested(target) + const result = await pending + + expect(result.outcome).toBe('settled') + // the staged target note first, then the source recovery secret - an + // exact source carve allocates nothing + expect(allocations).toEqual([ + {server: `127.0.0.1:${target.port}`, count: 1}, + {server: `127.0.0.1:${source.port}`, count: 1}, + ]) + expect(meltReadiness).toEqual([sourceRecoverySecret]) + expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) + }) + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index bc2efe9..eaa2d83 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -27,12 +27,14 @@ export {UncertainOutcomeError} from './ops/shared' export type {FundOperationOptions, PollOptions} from './ops/shared' +export {OutputSecretAllocationRequiredError} from './ops/allocation' +export type {OutputSecretAllocator} from './ops/allocation' export { CarveCheckpointRequiredError, ensureExactAmount, UnsupportedMultiBatchMergeError, } from './ops/carve' -export type {CarveResult} from './ops/carve' +export type {CarveOptions, CarveResult} from './ops/carve' export { MintedNoteSpentError, prepareMint, @@ -46,6 +48,7 @@ export type { StagedMintRecovery, } from './ops/mint' export {receiveBearer} from './ops/receiveBearer' +export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer' export {payWithBearers} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' diff --git a/src/lnurlcash/ops/allocation.ts b/src/lnurlcash/ops/allocation.ts new file mode 100644 index 0000000..6ca95bb --- /dev/null +++ b/src/lnurlcash/ops/allocation.ts @@ -0,0 +1,43 @@ +// Output-secret allocation: a mint-touching operation never invents the +// secrets its outputs will live at. It asks the caller's durable allocation +// path (the wallet's BIP-32 counter reservation, see +// stores/walletFunds.ts) for exactly the secrets it is about to stage, at +// the moment the target server is known - before any hash of them can +// reach a mint. The reservation commit (counter bump plus encrypted +// journal record) is what makes a crash between staging and wire safe: +// indices may be burned, never reused, and the staged secrets are +// recoverable from the funds document. +// +// The ops layer stays framework-free: this is just the option type and the +// point-of-use requirement. Tests inject deterministic allocators; +// production callers serve reserveCashIndices-backed secrets. + +export type OutputSecretAllocator = (server: string, count: number) => Promise + +export class OutputSecretAllocationRequiredError extends Error { + override readonly name = 'OutputSecretAllocationRequiredError' + + constructor() { + super('This operation requires durably allocated output secrets.') + } +} + +// The one place secrets enter an operation: exactly `count` fresh secrets +// for `server`, or a rejection before anything is staged or sent. A +// short/long/wrong-shaped answer is a caller bug - fail the operation +// rather than stage a note at an unmappable secret. +export const requireOutputSecrets = async ( + allocate: OutputSecretAllocator | undefined, + server: string, + count: number, +): Promise => { + if (!allocate) throw new OutputSecretAllocationRequiredError() + const secrets = await allocate(server, count) + if ( + secrets.length !== count || + secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) + ) { + throw new Error('The output secret allocation did not match the request.') + } + return secrets +} diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 3c107c9..9cfee6f 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -20,6 +20,8 @@ import { withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import {mergeAmbiguitySafe} from './carveRecovery' import type {FundOperationOptions} from './shared' import { @@ -59,6 +61,15 @@ export class CarveCheckpointRequiredError extends Error { } } +export type CarveOptions = FundOperationOptions & { + // the caller's durable allocation path for the carve's output secrets + // (split: target + change; merge: the combined note). When present the + // secrets come from the wallet's reserved BIP-32 indices; when absent the + // carve falls back to random secrets (legacy callers only - production + // always allocates). + readonly allocateOutputSecrets?: OutputSecretAllocator +} + // Selection: only notes that can actually take part - verified (callback // known), not locally spent, holding a real k1 (device-backed mirrors are // excluded; the ops engine cannot mutate a secret it doesn't hold). Notes @@ -77,7 +88,7 @@ export class CarveCheckpointRequiredError extends Error { export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, - options: FundOperationOptions = {}, + options: CarveOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -141,7 +152,9 @@ export const ensureExactAmount = async ( if (mergeBatches(base.callback, k1s).length !== 1) { throw new UnsupportedMultiBatchMergeError() } - const mergeSecret = (options.randomSecret ?? defaultRandomSecret)() + const [mergeSecret] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 1) + : [(options.randomSecret ?? defaultRandomSecret)()] const staged: NewBearer = { url: withNewK1(base.url, mergeSecret, total), callback: base.callback, @@ -177,8 +190,9 @@ export const ensureExactAmount = async ( // split path: total above target - one split request across all picked // k1s, carving the target off and leaving the change as a fresh note const randomSecret = options.randomSecret ?? defaultRandomSecret - const partK1 = randomSecret() - const changeK1 = randomSecret() + const [partK1, changeK1] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2) + : [randomSecret(), randomSecret()] let partSignature: string | undefined let changeSignature: string | undefined const stagedNote: NewBearer = { diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 4869b0c..6d9c245 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -23,6 +23,8 @@ import { import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {ceilMsatToSat} from '../units' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' import {assertFundOwner, pollUntil, withMutationSafety} from './shared' @@ -54,6 +56,11 @@ export type PrepareMintOptions = FundOperationOptions & { readonly grossMsat: number readonly server: string }) => void | Promise + // the caller's durable allocation path for the staged note's secret. + // When present the secret comes from the wallet's reserved BIP-32 + // indices; when absent a random secret is generated (legacy callers + // only - production always allocates). + readonly allocateOutputSecrets?: OutputSecretAllocator } // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover @@ -101,7 +108,9 @@ export const prepareMint = async ( } const server = serverOf(payUrl) await beforePersist?.({grossMsat, server}) - const noteSecret = (kitOptions.randomSecret ?? defaultRandomSecret)() + const [noteSecret] = options.allocateOutputSecrets + ? await requireOutputSecrets(options.allocateOutputSecrets, server, 1) + : [(kitOptions.randomSecret ?? defaultRandomSecret)()] const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const staged: NewBearer = { url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index 2b67feb..64af71c 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -17,14 +17,17 @@ import { resolveLnurlInput, rotateNote, sameInvoice, + serverOf, withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' -import type {FundOperationOptions} from './shared' +import type {FundOperationOptions, MintSignatureKeys} from './shared' import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared' export type PayOutcome = @@ -59,6 +62,14 @@ export type PayOptions = { // so an abort mid-wait never strands the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] + // the caller's durable allocation path: the carve's output secrets and - + // only when the return classification actually needs one - the recovery + // secret. When absent, secrets fall back to random generation (legacy + // callers only - production always allocates). + allocateOutputSecrets?: OutputSecretAllocator + // trusted signing keys the carve's landed outputs verify against (see + // FundOperationOptions.mintSignatureKeys) + mintSignatureKeys?: MintSignatureKeys // Called before the classification rotate puts its chosen output secret // on the wire. Production callers durably link it to the carved source. onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise @@ -77,7 +88,7 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, onReturnReady}: PayOptions = {}, + {amountMsat, poll = {}, kit = {}, assertOwner, onCarve, allocateOutputSecrets, mintSignatureKeys, onReturnReady}: PayOptions = {}, ): Promise => { // the forced mutation policy (see shared.ts) covers the carve, the melt, // and the classification rotate regardless of caller kit options @@ -85,6 +96,7 @@ export const payWithBearers = async ( ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), + ...(mintSignatureKeys ? {mintSignatureKeys} : {}), }) let invoice: string let amount: number @@ -118,7 +130,10 @@ export const payWithBearers = async ( amount = amountMsat } - const carve = await ensureExactAmount(bearers, amount, options) + const carve = await ensureExactAmount(bearers, amount, { + ...options, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), + }) const k1 = requireNoteK1(carve.note.url) if (carve.consumed.length === 0) assertFundOwner(options) let melt: MeltResult @@ -159,7 +174,9 @@ export const payWithBearers = async ( // The verify budget ran out. Journal the exact output secret before the // classification rotate reaches the mint, so a crash can recover either // the returned source or the rotated replacement. - const recoverySecret = kit.randomSecret?.() ?? defaultRandomSecret() + const [recoverySecret] = allocateOutputSecrets + ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) + : [(kit.randomSecret ?? defaultRandomSecret)()] await onReturnReady?.(carve, recoverySecret) try { const rotated = await rotateNote(carve.note.callback, k1, { diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 298aa38..3d9a9d9 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -3,6 +3,16 @@ // bech32, lnurlw://), verify it with the issuing service, then rotate // immediately, since the previous holder (and anything that logged the URL // in transit) still knows the old secret. +// +// Durability contract: when the caller stages (options.stageRotation), the +// rotate's fresh secret comes from the caller's reservation path and the +// future rotated note is persisted BEFORE the rotate can land - a crash +// after the mint burned the sender's copy then never loses the only secret +// the money answers to. The staged record carries the original k1 as its +// pendingMint.sourceRecoverySecret so wallet recovery can restore the +// received note when the rotate provably never landed. A caller that +// stages MUST allocate; the legacy no-staging path (random secret, no +// checkpoint) remains only for engine tests. import { AmbiguousMutationError, @@ -11,32 +21,92 @@ import { PendingNoteError, newSecretsOf, probeBurnedNote, + requireNoteK1, + serverOf, withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' -import type {ClaimedNote} from './mint' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {FundOperationOptions} from './shared' import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' +export type ReceiveBearerOptions = FundOperationOptions & { + // the caller's durable allocation path for the rotation's fresh secret - + // required when stageRotation is present + readonly allocateOutputSecrets?: OutputSecretAllocator + // durably persist the future rotated note (unverified, pendingMint + // carrying the original k1 as sourceRecoverySecret) before the rotate + // reaches the mint. The record's fate is decided by the result's + // `stage` disposition. + readonly stageRotation?: (staged: NewBearer) => void | Promise +} + +export type ReceivedNote = { + readonly note: NewBearer + readonly rotated: boolean + readonly rotationError?: string + // how the caller disposes the staged rotation output: + // - 'finalize': the rotate landed - replace the staged record with `note` + // - 'keep': the outcome is genuinely ambiguous - the staged record IS + // the possible rotated copy, kept unverified alongside `note` (the + // original, demoted to unverified since its copy may be burned) + // - 'discard': the rotate provably never landed - drop the staged + // record; `note` is the original, still verified as received + // - 'none': no rotate was attempted or nothing was staged (unverifiable + // note, or the legacy no-staging path) + readonly stage: 'finalize' | 'keep' | 'discard' | 'none' +} + // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, // unverified, at the sender's declared amount. export const receiveBearer = async ( input: string, existing: Bearer[], - options: FundOperationOptions = {}, -): Promise => { + options: ReceiveBearerOptions = {}, +): Promise => { // the forced mutation policy (see shared.ts) covers the rotate below const mutationOptions = withMutationSafety(options) const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { - return {note, rotated: false} + return {note, rotated: false, stage: 'none'} + } + // staging without allocation is a caller bug: the staged record would + // hold a secret no reservation can account for + const staging = options.stageRotation + const [rotationSecret] = staging + ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(note.url), 1) + : [] + // the rotate burns the sender's secret at a fresh one: stage that future + // note durably before the mutation can land, or a crash strands the money + if (staging && rotationSecret) { + const staged: NewBearer = { + url: withNewK1(note.url, rotationSecret, note.amount), + callback: note.callback, + amount: note.amount, + verified: false, + pendingMint: { + sourceRecoverySecret: requireNoteK1(note.url), + ...(note.mintPubkey ? {mintPubkey: note.mintPubkey} : {}), + }, + } + await staging(staged) + } + const rotateOptions = rotationSecret + ? {...mutationOptions, randomSecret: () => rotationSecret} + : mutationOptions + // the disposition the caller applies to its staged record + const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => { + if (!staging) return 'none' + if (rotated) return 'finalize' + return ambiguous ? 'keep' : 'discard' } try { assertFundOwner(options) - const rotatedUrl = await secureReceivedNote(note, mutationOptions) - return {note: {...note, url: rotatedUrl}, rotated: true} + const rotatedUrl = await secureReceivedNote(note, rotateOptions) + return {note: {...note, url: rotatedUrl}, rotated: true, stage: disposition(true, false)} } catch (err) { // A classified rotate refusal can still be a LANDED rotate: the // callback is a GET and HTTP stacks retry GETs, so the service may @@ -51,21 +121,16 @@ export const receiveBearer = async ( return { note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, rotated: true, + stage: disposition(true, false), } } if (outcome === 'unknown') { - const possibleCopy: NewBearer = { - url: withNewK1(note.url, carried[0], note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - } - if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { - // the sender's copy may be burned - it cannot stay verified + // the sender's copy may be burned - it cannot stay verified; the + // staged record is the possible rotated copy note: {...note, verified: false}, rotated: false, - possibleCopy, + stage: disposition(false, true), rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`, } } @@ -91,30 +156,26 @@ export const receiveBearer = async ( url: withNewK1(note.url, err.newSecrets[0], note.amount), }, rotated: true, + stage: disposition(true, false), } } if (outcome === 'unknown') { - const possibleCopy: NewBearer = { - url: withNewK1(note.url, err.newSecrets[0], note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - } - if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { note, rotated: false, - possibleCopy, + stage: disposition(false, true), rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, } } } // a failed rotate never fails the receive - the note is money as it - // is; the caller warns that it must be treated as exposed + // is; the caller warns that it must be treated as exposed. The rotate + // provably never landed, so the staged record is worthless. return { note, rotated: false, rotationError: err instanceof Error ? err.message : String(err), + stage: disposition(false, false), } } } diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index cc910b6..1628ae0 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -29,12 +29,14 @@ import { } from 'lnurlcash-kit' import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' +import type {OutputSecretAllocator} from './allocation' +import {requireOutputSecrets} from './allocation' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint' import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint' import type {PollOptions} from './shared' -import type {FundOperationOptions} from './shared' +import type {FundOperationOptions, MintSignatureKeys} from './shared' import {assertFundOwner, withMutationSafety} from './shared' export type TransferOutcome = @@ -116,11 +118,18 @@ export type TransferOptions = { kit?: LnurlcashOptions assertOwner?: () => void // carve checkpoint - committed before the melt and the target - // settlement wait, so an abort mid-wait never strands the carve (see + // settlement wait, so an abort mid-wait can never strand the carve (see // FundOperationOptions.onCarve) onCarve?: FundOperationOptions['onCarve'] // persists the target note before its hash reaches the invoice callback persistOutput: PrepareMintOptions['persistOutput'] + // the caller's durable allocation path: the staged target note's secret, + // the source carve's outputs, and the source recovery secret. When + // absent, secrets fall back to random generation (legacy callers only - + // production always allocates). + allocateOutputSecrets?: OutputSecretAllocator + // trusted signing keys the source carve's landed outputs verify against + mintSignatureKeys?: MintSignatureKeys // Last durable checkpoint before the source melt can become irreversible. onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise } @@ -219,12 +228,13 @@ export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, targetMint: string, - {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, onMeltReady}: TransferOptions, + {poll = {}, kit = {}, assertOwner, onCarve, persistOutput, allocateOutputSecrets, mintSignatureKeys, onMeltReady}: TransferOptions, ): Promise => { const options: FundOperationOptions = withMutationSafety({ ...kit, ...(assertOwner ? {assertOwner} : {}), ...(onCarve ? {onCarve} : {}), + ...(mintSignatureKeys ? {mintSignatureKeys} : {}), }) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -235,6 +245,7 @@ export const transferBetweenMints = async ( const prepared = await prepareMint(targetMint, amountMsat, { ...options, persistOutput, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), beforePersist: ({grossMsat, server}) => { transferSources(bearers, server, grossMsat) }, @@ -253,7 +264,10 @@ export const transferBetweenMints = async ( } // carving burns its inputs server-side, so it happens only once the // target is known good and the invoice exists - const carve = await ensureExactAmount(offTarget, prepared.grossMsat, options) + const carve = await ensureExactAmount(offTarget, prepared.grossMsat, { + ...options, + ...(allocateOutputSecrets ? {allocateOutputSecrets} : {}), + }) const sourceServer = serverOf(carve.note.url) const invoice = prepared.invoice const claimMaterial: TransferClaimMaterial = { @@ -267,7 +281,9 @@ export const transferBetweenMints = async ( // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) - const sourceRecoverySecret = (kit.randomSecret ?? defaultRandomSecret)() + const [sourceRecoverySecret] = allocateOutputSecrets + ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) + : [(kit.randomSecret ?? defaultRandomSecret)()] if (carve.consumed.length === 0) assertFundOwner(options) await onMeltReady?.(carve, sourceRecoverySecret) assertFundOwner(options) From 29830dc9e3d34d2e626d0d6df52c86beca02d4b8 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:38:29 +0200 Subject: [PATCH 18/29] fix(nwc): await durable commits before responses --- src/lnurlcash/nwc.invoice-a.cases.ts | 155 ++++------------------ src/lnurlcash/nwc.invoice-b.cases.ts | 94 +++++++++++++ src/lnurlcash/nwc.ownership.cases.ts | 22 ++++ src/lnurlcash/nwc.pay-b.cases.ts | 31 ++++- src/lnurlcash/nwc.testService.ts | 119 ++++++++++++++++- src/lnurlcash/nwc/context.ts | 36 +++++ src/lnurlcash/nwc/dispatch.ts | 74 ++++++++++- src/lnurlcash/nwc/invoices.ts | 190 ++++++++++----------------- src/lnurlcash/nwc/pay.ts | 66 ++++++++-- src/lnurlcash/nwc/service.ts | 23 +++- src/stores/nwc.atomic.test.ts | 9 +- src/stores/nwc.lifecycle.test.ts | 44 ++++++- src/stores/nwc.ts | 80 ++++++++++- src/stores/wallet.ts | 38 +++++- 14 files changed, 700 insertions(+), 281 deletions(-) diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts index 109e548..456878b 100644 --- a/src/lnurlcash/nwc.invoice-a.cases.ts +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -57,8 +57,23 @@ import { waitFor, } from './nwc.testProtocol' import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +import {invoiceRetireAfter} from './nwc/invoices' + +export const SIGNED_BOLT11_FIXTURE = + 'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz' + describe('service: make_invoice / lookup_invoice', () => { - it('issues an invoice, settles it in the background, and reports the preimage', async () => { + it('derives a safe retirement time from a signed BOLT11 invoice', () => { + expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0) + }) + + it('rejects a retirement time outside the safe integer range', () => { + const timestamp = 'q'.repeat(7) + const expiryTag = `xqv${'l'.repeat(12)}` + expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull() + }) + + it('stages an output, 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}`, @@ -88,7 +103,9 @@ describe('service: make_invoice / lookup_invoice', () => { } expect(invoice).toMatch(/^lnbc/) expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) - + const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-'))) + expect(staged.verified).toBe(false) + expect(staged.callback).toBe('') // before settlement the lookup reports the pending invoice const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { payment_hash: paymentHash, @@ -101,7 +118,7 @@ describe('service: make_invoice / lookup_invoice', () => { // 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)) + await waitFor(() => state.bearers.some((bearer) => bearer.verified)) const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { payment_hash: paymentHash, @@ -112,11 +129,10 @@ describe('service: make_invoice / lookup_invoice', () => { 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-'))) + // The staged record was updated in place before settlement became + // visible. The payment preimage is only a receipt and keys no note. + expect(m.state.noteState(preimage)).toBeNull() + const minted = requiredValue(state.bearers.find((b) => b.id === staged.id)) expect(minted.amount).toBe(21_000) expect(minted.verified).toBe(true) expect(noteK1(minted.url)).not.toBe(preimage) @@ -124,129 +140,6 @@ describe('service: make_invoice / lookup_invoice', () => { await stop() }) - it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => { - const m = await mint({commentAllowed: 64, 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, - }) - expect(made.error).toBeNull() - const paymentHash = made.result?.payment_hash - if (typeof paymentHash !== 'string') { - throw new TypeError('make_invoice did not return a payment hash') - } - - 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') - // a named mint's verify preimage is an ordinary payment receipt - - // it keys nothing at the mint - so it is always safe to hand out - const preimage = requiredString(settled.result?.preimage) - expect(preimage).toMatch(/^[0-9a-f]{64}$/) - expect(m.state.noteState(preimage)).toBeNull() - - // the note was claimed at the wallet's own secret - unrotated, - // because nothing exposed it - const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) - expect(minted.amount).toBe(21_000) - expect(minted.verified).toBe(true) - const mintedK1 = requiredValue(noteK1(minted.url)) - expect(m.state.noteState(mintedK1)).toBe('outstanding') - // the quote was bound to that secret's hash - expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1)) - await stop() - }) - - it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => { - // the mintToHashIgnoresH adversary: advertises output naming, binds - // nothing, credits the payment hash - and (non-compliantly) still - // serves verify, so the preimage path can recover the note - const m = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const {relay, walletServicePubkey, state, stop} = await startTestService({ - defaultMint: `mint@127.0.0.1:${m.port}`, - claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, - }) - - 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 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.result?.state).toBe('settled') - // the rescue claimed through the preimage path and rotated off it, so - // the receipt handed to the client is a burned secret - const preimage = requiredString(settled.result?.preimage) - expect(m.state.noteState(preimage)).toBe('burned') - const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) - const mintedK1 = requiredValue(noteK1(minted.url)) - expect(mintedK1).not.toBe(preimage) - expect(m.state.noteState(mintedK1)).toBe('outstanding') - await stop() - }) - - it('withholds the preimage when the rescue claim could not rotate', async () => { - // same lying mint, but the rotate's request never lands (the mint - // dies between the claim and the rotate): the preimage may still be - // the note's secret, so it stays a secret even though the invoice - // reads settled - const m = await mint({ - commentAllowed: 64, - mintToHashIgnoresH: true, - verifyOnUnnamedMint: true, - testHooks: true, - }) - const rotateFailingFetch: typeof fetch = (input, init) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset')) - return fetch(input, init) - } - const {relay, walletServicePubkey, state, stop} = await startTestService({ - defaultMint: `mint@127.0.0.1:${m.port}`, - claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, - kit: {fetch: rotateFailingFetch}, - }) - - 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 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.result?.state).toBe('settled') - // settled, but NO receipt: an unrotated preimage may still be the note - expect(settled.result?.preimage).toBeUndefined() - // the note is tracked either way (it IS money) - expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true) - await stop() - }) - it('keeps a paid invoice pending while its bearer commit is deferred', async () => { const m = await mint({testHooks: true}) const commit = deferred() diff --git a/src/lnurlcash/nwc.invoice-b.cases.ts b/src/lnurlcash/nwc.invoice-b.cases.ts index 115e22a..80d8a7c 100644 --- a/src/lnurlcash/nwc.invoice-b.cases.ts +++ b/src/lnurlcash/nwc.invoice-b.cases.ts @@ -57,6 +57,7 @@ import { waitFor, } from './nwc.testProtocol' import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +import {SIGNED_BOLT11_FIXTURE} from './nwc.invoice-a.cases' 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}) @@ -230,6 +231,99 @@ describe('service: make_invoice / lookup_invoice (continued)', () => { await stop() }) + it('rejects invoice creation when the durable pending-output cap is reached', async () => { + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: 'mint@example.com', + }) + for (let index = 0; index < 20; index += 1) { + state.bearers.push({ + id: `pending-${index}`, + url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`, + callback: '', + amount: 0, + verified: false, + pendingMint: {}, + createdAt: index, + updatedAt: index, + }) + } + + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + + expect(response.error?.code).toBe('QUOTA_EXCEEDED') + expect(state.bearers).toHaveLength(20) + await stop() + }) + + it('removes staged outputs when invoice preparation fails before publication', async () => { + const m = await mint({testHooks: true}) + let rejectQuotes = true + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + kit: { + fetch: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + if (rejectQuotes && url.includes('comment=')) { + return Promise.resolve(new Response('quote failed', {status: 500})) + } + return fetch(input, init) + }, + }, + }) + + for (let attempt = 0; attempt < 20; attempt += 1) { + const failed = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + expect(failed.error?.code).toBe('INTERNAL') + expect(state.bearers).toHaveLength(0) + } + + rejectQuotes = false + const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + expect(made.error).toBeNull() + expect(state.bearers).toHaveLength(1) + await stop() + }) + + it('removes an unpublished stage when retirement persistence fails', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')), + kit: { + fetch: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + if (url.includes('comment=')) { + return Promise.resolve( + new Response(JSON.stringify({pr: SIGNED_BOLT11_FIXTURE}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ) + } + return fetch(input, init) + }, + }, + }) + + const response = await call(relay, walletServicePubkey, 'make_invoice', {amount: 25_000}) + + expect(response.error?.code).toBe('INTERNAL') + expect(state.bearers).toHaveLength(0) + 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', { diff --git a/src/lnurlcash/nwc.ownership.cases.ts b/src/lnurlcash/nwc.ownership.cases.ts index 8b1f154..57c298a 100644 --- a/src/lnurlcash/nwc.ownership.cases.ts +++ b/src/lnurlcash/nwc.ownership.cases.ts @@ -71,8 +71,19 @@ describe('service ownership', () => { const service = await startService(LINKING_KEY, { assertCurrentOwner: () => undefined, + allocateOutputSecrets: () => Promise.resolve([]), + mintSignatureKeys: () => [], + recoverPendingMints: () => Promise.resolve(), + reserveMintOutputSource: () => Promise.resolve(), + finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, + persistMintOutput: () => Promise.reject(new Error('unused')), + discardMintOutput: () => Promise.reject(new Error('unused')), + setMintOutputRetirement: () => Promise.reject(new Error('unused')), + finalizeMintOutput: () => Promise.reject(new Error('unused')), + finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), + commitCarve: () => Promise.reject(new Error('unused')), applyChangeset: () => Promise.resolve(), transport: relay.transport, nowSeconds, @@ -102,8 +113,19 @@ describe('service ownership', () => { LINKING_KEY, { assertCurrentOwner: () => undefined, + allocateOutputSecrets: () => Promise.resolve([]), + mintSignatureKeys: () => [], + recoverPendingMints: () => Promise.resolve(), + reserveMintOutputSource: () => Promise.resolve(), + finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, + persistMintOutput: () => Promise.reject(new Error('unused')), + discardMintOutput: () => Promise.reject(new Error('unused')), + setMintOutputRetirement: () => Promise.reject(new Error('unused')), + finalizeMintOutput: () => Promise.reject(new Error('unused')), + finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), + commitCarve: () => Promise.reject(new Error('unused')), applyChangeset: () => Promise.resolve(), transport: relay.transport, nowSeconds, diff --git a/src/lnurlcash/nwc.pay-b.cases.ts b/src/lnurlcash/nwc.pay-b.cases.ts index 30f30cf..5465ae2 100644 --- a/src/lnurlcash/nwc.pay-b.cases.ts +++ b/src/lnurlcash/nwc.pay-b.cases.ts @@ -138,21 +138,38 @@ describe('service: pay_invoice (continued)', () => { // 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 original = await makeBearer(m, '01'.repeat(32), 21_000) + state.bearers = [original] 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(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true) + expect( + state.bearers.find((bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent), + ).toBeDefined() + expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) await stop() }) + + it('conserves balance when a split payment fails and returns its carved funds', async () => { + const m = await mint({meltAlwaysFails: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + expect(response.error?.code).toBe('PAYMENT_FAILED') + expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(30_000) + expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined() + await stop() + }) }) diff --git a/src/lnurlcash/nwc.testService.ts b/src/lnurlcash/nwc.testService.ts index 4036e0e..197b0a1 100644 --- a/src/lnurlcash/nwc.testService.ts +++ b/src/lnurlcash/nwc.testService.ts @@ -7,11 +7,12 @@ // already-rotated (burned) note secret. import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} 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 {buildNoteUrl, fetchNoteInfo, noteK1, serverOf} from 'lnurlcash-kit' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { @@ -114,6 +115,7 @@ afterEach(async () => { }) let bearerCounter = 0 +let allocationCounter = 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) @@ -143,6 +145,7 @@ export const startTestService = async (options: { claimPoll?: typeof FAST_POLL kit?: NwcServiceDeps['kit'] commitChangeset?: (changeset: NwcChangeset) => Promise + setMintOutputRetirement?: () => Promise }): Promise<{ relay: ReturnType walletServicePubkey: string @@ -164,8 +167,120 @@ export const startTestService = async (options: { } const service = await startService(options.linkingKey ?? LINKING_KEY, { assertCurrentOwner: () => undefined, + // deterministic stand-in for the wallet's BIP-32 allocation path + allocateOutputSecrets: (_server, count) => { + const secrets = Array.from({length: count}, () => { + allocationCounter += 1 + return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`))) + }) + return Promise.resolve(secrets) + }, + // the keys a production caller serves from the trusted-mint registry: + // each created mock mint's current key plus any it retired + mintSignatureKeys: (server) => + mints.flatMap((m) => + new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [], + ), + recoverPendingMints: () => Promise.resolve(), getBearers: () => state.bearers, getDefaultMint: () => options.defaultMint ?? null, + persistMintOutput: (note) => { + bearerCounter += 1 + const bearer: Bearer = { + ...note, + id: `staged-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now(), + } + state.bearers.push(bearer) + return Promise.resolve(bearer) + }, + discardMintOutput: (staged) => { + state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) + return Promise.resolve() + }, + setMintOutputRetirement: (staged, retireAfter) => { + const override = options.setMintOutputRetirement?.() + if (override) return override + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) return Promise.reject(new Error('The staged bearer is missing.')) + const updated: Bearer = { + ...staged, + pendingMint: {...staged.pendingMint, retireAfter}, + updatedAt: Date.now(), + } + state.bearers[index] = updated + return Promise.resolve() + }, + reserveMintOutputSource: (staged, sourceId, recoverySecret) => { + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + const source = state.bearers.find((bearer) => bearer.id === sourceId) + if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.')) + source.spent = true + state.bearers[index] = { + ...state.bearers[index]!, + pendingMint: { + ...state.bearers[index]!.pendingMint, + sourceBearerId: sourceId, + sourceRecoverySecret: recoverySecret, + }, + } + return Promise.resolve() + }, + finalizeMintOutput: async (staged, note) => { + const changeset: NwcChangeset = {add: [], markSpent: []} + await options.commitChangeset?.(changeset) + state.changesets.push(changeset) + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) throw new Error('The staged bearer is missing.') + state.bearers[index] = {...staged, ...note, pendingMint: undefined, updatedAt: Date.now()} + }, + finalizePaymentReturn: async (staged, note) => { + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) throw new Error('The staged bearer is missing.') + const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId + if (sourceId) { + const source = state.bearers.find((bearer) => bearer.id === sourceId) + if (source) source.spent = true + } + state.bearers[index] = {...state.bearers[index]!, ...note, pendingMint: undefined, updatedAt: Date.now()} + }, + finalizeSpentMintOutput: (staged) => { + state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) + return Promise.resolve() + }, + commitCarve: async (carve) => { + // a checkpoint is identified by its output secret: the landed phase's + // URL carries a signature the staged phase's did not have yet + const sameOutput = (url: string, noteUrl: string): boolean => + serverOf(url) === serverOf(noteUrl) && + noteK1(url) !== null && + noteK1(url) === noteK1(noteUrl) + const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter( + (note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)), + ) + const changeset: NwcChangeset = { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + } + await options.commitChangeset?.(changeset) + state.changesets.push(changeset) + for (const bearer of state.bearers) { + if (changeset.markSpent.includes(bearer.id)) bearer.spent = true + } + for (const note of additions) { + bearerCounter += 1 + state.bearers.unshift({ + ...note, + id: `carve-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + } + const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url)) + if (!committed) throw new Error('The carved note was not committed.') + return committed + }, applyChangeset: async (changeset: NwcChangeset) => { await options.commitChangeset?.(changeset) state.changesets.push(changeset) diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts index 1e5148f..4414447 100644 --- a/src/lnurlcash/nwc/context.ts +++ b/src/lnurlcash/nwc/context.ts @@ -10,6 +10,9 @@ import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {NwcConnectionRecord} from '../storage/nwcConnections' import type {PreparedMint} from '../ops' +import type {CarveResult} from '../ops' +import type {OutputSecretAllocator} from '../ops/allocation' +import type {MintSignatureKeys} from '../ops/shared' import type {PollOptions} from '../ops/shared' import type {NwcConnectionInfo} from './connection' @@ -31,6 +34,38 @@ export type NwcServiceDeps = { // mint - NIP-47's make_invoice carries no mint choice) getDefaultMint: () => string | null assertCurrentOwner: () => void + // the wallet's durable allocation path (BIP-32 reservation) for every + // note output secret a request stages + allocateOutputSecrets: ( + server: string, + count: number, + assertOwner: () => void, + ) => Promise + // trusted current+previous signing keys a landed carve output verifies + // against - the trusted-mint registry, owner-scoped + mintSignatureKeys: MintSignatureKeys + recoverPendingMints: (assertOwner: () => void) => Promise + persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise + discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise + setMintOutputRetirement: ( + staged: Bearer, + retireAfter: number, + assertOwner: () => void, + ) => Promise + reserveMintOutputSource: ( + staged: Bearer, + sourceId: string, + recoverySecret: string, + assertOwner: () => void, + ) => Promise + finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise + finalizePaymentReturn: ( + staged: Bearer, + note: NewBearer, + assertOwner: () => void, + ) => Promise + finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise + commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise applyChangeset: ( changeset: NwcChangeset, connection: NwcConnectionInfo, @@ -61,6 +96,7 @@ export type PendingInvoice = { createdAt: number // unix seconds expiresAt?: number prepared: PreparedMint + stagedOutput: Bearer state: 'pending' | 'settled' | 'failed' preimage?: string settledAt?: number diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts index 99c06f8..0ed69e3 100644 --- a/src/lnurlcash/nwc/dispatch.ts +++ b/src/lnurlcash/nwc/dispatch.ts @@ -12,11 +12,13 @@ import {prepareMint} from '../ops' import {PollAbortedError} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' -import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' +import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices' import {handlePayInvoice} from './pay' import type {NwcRequest, NwcResponse} from './protocol' import {NWC_METHODS, errResult, okResult} from './protocol' +const MAX_PENDING_MINT_OUTPUTS = 20 + // the same eligibility carve applies - the balance answers "what could // this wallet actually pay with right now" const spendable = (bearer: Bearer): boolean => @@ -55,18 +57,86 @@ const handleMakeInvoice = async ( if (!mint) { return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.') } + const now = ctx.nowSeconds() + if ( + ctx.deps + .getBearers() + .some( + (bearer) => + !bearer.spent && + bearer.pendingMint?.retireAfter !== undefined && + bearer.pendingMint.retireAfter <= now, + ) + ) { + await ctx.deps.recoverPendingMints(ctx.assertOwner) + } + const pendingOutputs = ctx.deps + .getBearers() + .filter((bearer) => bearer.pendingMint && !bearer.spent).length + if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) { + return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.') + } let prepared: PreparedMint + let stagedOutput: Bearer | undefined try { - prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {}) + prepared = await prepareMint(mint, amountMsat, { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + allocateOutputSecrets: (server, count) => + ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), + persistOutput: async (note) => { + stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner) + }, + }) } catch (err) { + if (stagedOutput) { + try { + await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) + } catch (cleanupError) { + return errResult( + 'make_invoice', + 'INTERNAL', + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ) + } + } return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) } + if (!stagedOutput) { + return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.') + } + const retireAfter = invoiceRetireAfter(prepared.invoice) + if (retireAfter !== null) { + try { + await ctx.deps.setMintOutputRetirement( + stagedOutput, + retireAfter, + ctx.assertOwner, + ) + } catch (error) { + try { + await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) + } catch (cleanupError) { + return errResult( + 'make_invoice', + 'INTERNAL', + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ) + } + return errResult( + 'make_invoice', + 'INTERNAL', + error instanceof Error ? error.message : String(error), + ) + } + } const entry: PendingInvoice = { invoice: prepared.invoice, paymentHash: resolvePaymentHash(prepared), amountMsat: prepared.grossMsat, createdAt: ctx.nowSeconds(), prepared, + stagedOutput, state: 'pending', } if (typeof params.description === 'string' && params.description) { diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts index e1a8caa..c684d27 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -1,26 +1,16 @@ // The invoice registry the two-phase make_invoice / lookup_invoice pair -// shares: make_invoice answers with the invoice immediately while this -// module's settleAndClaim watches settlement in the background, claims -// the minted note, and only THEN records the settlement the lookup -// reports. -// -// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment -// preimage IS the minted note's initial secret. NIP-47 clients expect it -// as the settlement receipt, so it is handed out - but only after -// claimFromPreimage has rotated the fresh note, when that secret is -// burned and worthless. If the rotation failed (claimed.rotated === -// false) the preimage is withheld: fund safety over spec comfort. On a -// NAMED mint the note lives at a wallet-chosen secret from the start and -// the preimage opens nothing at all, so it is an ordinary payment receipt -// and always safe to hand out. +// shares. A minted note always lives at the wallet-chosen secret that was +// durably staged before quote creation; the payment preimage is only a +// receipt. Settlement becomes visible only after the staged bearer has +// been replaced with the confirmed note details. import {isPreimage, sameInvoice} from 'lnurlcash-kit' import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' -import type {NewBearer} from '../types' import type {PreparedMint} from '../ops' -import {claimFromPreimage, claimFromSecret} from '../ops/mint' +import {MintedNoteSpentError} from '../ops' +import {claimFromSecret} from '../ops/mint' import {pollVerifyUntilSettled} from '../ops/shared' import type {PollOptions} from '../ops/shared' @@ -32,18 +22,56 @@ export const DEFAULT_CLAIM_POLL: Required> = { maxWaitMs: 15 * 60_000, } -// LUD-21 verify URLs end in /verify/ (the protocol's verify -// convention) - that suffix is the invoice's real payment hash. A service -// that shapes its verify URL differently gets a wallet-local correlation -// id instead (sha256 of the invoice): still unique and stable for -// make_invoice <-> lookup_invoice correlation, just not the on-chain hash. +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x') +const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600 +const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60 + +// The kit has already validated the invoice before this parser runs. Read +// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays +// durable forever rather than risking retirement while it could still settle. +export const invoiceRetireAfter = (invoice: string): number | null => { + const normalized = invoice.toLowerCase() + const separator = normalized.lastIndexOf('1') + if (separator < 1 || normalized.length - separator <= 6) return null + const words = [...normalized.slice(separator + 1, -6)].map((character) => + BECH32_CHARSET.indexOf(character), + ) + if (words.length < 7 || words.some((word) => word < 0)) return null + const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0) + if (!Number.isSafeInteger(timestamp)) return null + let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS + let offset = 7 + // Conformance invoices omit the 65-byte signature; real BOLT11 invoices + // include its fixed 104 words after the tagged fields. + const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length + while (offset + 3 <= taggedEnd) { + const tag = words[offset] + const firstLength = words[offset + 1] + const secondLength = words[offset + 2] + if (tag === undefined || firstLength === undefined || secondLength === undefined) return null + const length = firstLength * 32 + secondLength + const valueStart = offset + 3 + const valueEnd = valueStart + length + if (valueEnd > taggedEnd) return null + if (tag === BOLT11_EXPIRY_TAG) { + expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0) + if (!Number.isSafeInteger(expiry)) return null + } + offset = valueEnd + } + const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS + return Number.isSafeInteger(retireAfter) ? retireAfter : null +} + +// LUD-21 verify URLs conventionally end in the payment hash. A service +// that uses another shape gets a stable wallet-local correlation id. export const resolvePaymentHash = (prepared: PreparedMint): string => { const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1] if (fromVerify) return fromVerify.toLowerCase() return bytesToHex(sha256(utf8ToBytes(prepared.invoice))) } -// the NIP-47 transaction object make_invoice and lookup_invoice share export const invoiceResult = (entry: PendingInvoice): Record => { const result: Record = { type: 'incoming', @@ -56,89 +84,18 @@ export const invoiceResult = (entry: PendingInvoice): Record => } if (entry.description) result.description = entry.description if (entry.expiresAt) result.expires_at = entry.expiresAt - if (entry.state === 'settled' && entry.preimage) { - result.preimage = entry.preimage - } + if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage if (entry.settledAt) result.settled_at = entry.settledAt return result } -// the background half of make_invoice: watch the invoice, and once it -// settles claim the note (rotating it immediately on an unnamed mint) and -// hand the fresh bearer to the caller. Settlement is recorded LAST - after -// the claim and bearer commit - so lookup can only reveal durably tracked -// funds and, on an unnamed mint, 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 => { - if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry) - if (!entry.prepared.verifyUrl) { - throw new Error( - '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), 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.") - } - const preimage = result.preimage - if (!preimage || !isPreimage(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 ?? {}), - 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) { - // see the header: only a rotated note makes the preimage a worthless - // secret, safe to hand out as the settlement receipt - entry.preimage = preimage - } -} - -// the named-mint half of settleAndClaim: the note lands at the wallet's -// own secret, so its APPEARANCE there is the settlement observation and no -// rotate follows. When the mint also serves the quote's verify, that -// answer's preimage is kept as the NIP-47 receipt (an ordinary payment -// proof on a named mint); a named mint without verify yields a settled -// lookup with no preimage - correct, just receipt-less. -const settleAndClaimNamed = async ( - ctx: RequestContext, - entry: PendingInvoice, -): Promise => { - const noteSecret = entry.prepared.noteSecret - if (!noteSecret) { - throw new Error('This mint was prepared in named mode without a note secret.') - } const prepared = entry.prepared const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL let preimage: string | null = null if (prepared.verifyUrl) { - // the interruptible observation half: watch settlement on verify, - // then claim by secret below (fund-critical, uninterruptible) + // Observation remains interruptible. Once settlement is observed, the + // claim and durable update ignore shutdown while stop drains the task. const result = await pollVerifyUntilSettled( prepared.verifyUrl, {...claimPoll, signal: ctx.stopSignal}, @@ -149,30 +106,23 @@ const settleAndClaimNamed = async ( } if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage } - // without a verify URL the claim poll IS the observation half, so it - // stays interruptible; after a verify-observed settlement the note is - // already there and this returns on the first check - const claimed = await claimFromSecret( - {...prepared, noteSecret}, - prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal}, - { - ...(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, - ) + let claimed + try { + claimed = await claimFromSecret( + prepared, + prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal}, + { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + }, + ) + } catch (error) { + if (!(error instanceof MintedNoteSpentError)) throw error + await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner) + throw error + } + await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner) entry.settledAt = ctx.nowSeconds() entry.state = 'settled' - // the rotate guard still matters here: the rescue path (a mint that - // took the hash but credited the PREIMAGE) claims through - // claimFromPreimage, and if its rotate failed the preimage is still a - // LIVE note secret - withheld exactly like the unnamed path - if (preimage && claimed.rotated) entry.preimage = preimage + if (preimage) entry.preimage = preimage } diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts index d05ea4e..bdbf2ae 100644 --- a/src/lnurlcash/nwc/pay.ts +++ b/src/lnurlcash/nwc/pay.ts @@ -10,6 +10,7 @@ import { fetchInvoiceVerification, isBolt11Invoice, noteK1, + withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -33,19 +34,29 @@ import {errResult, okResult} from './protocol' export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { const add: NewBearer[] = [] const markSpent: string[] = result.carve.consumed.map((b) => b.id) - if (result.carve.change) add.push(result.carve.change) + // the staged change's URL may lack the signature the landed result's URL + // carries - identify outputs by their secret, not the whole URL + const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null + if ( + result.carve.change && + !bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1) + ) { + add.push(result.carve.change) + } + const carvedK1 = noteK1(result.carve.note.url) + const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined if (result.outcome === 'failed-funds-returned') { - add.push(result.rotatedNote ?? result.carve.note) + if (existing) { + const index = markSpent.indexOf(existing.id) + if (index >= 0) markSpent.splice(index, 1) + } } else { // settled / still-pending / already-spent: the carved note is gone or // locked. When it was one of the wallet's own bearers (an exact-match // 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 if (existing) markSpent.push(existing.id) } - if (result.rescuedNote) add.push(result.rescuedNote) return {add, markSpent} } @@ -79,11 +90,40 @@ export const handlePayInvoice = async ( } const bearers = ctx.deps.getBearers() let result: PayResult + let committedCarve: Bearer | undefined + let returnStage: Bearer | undefined try { result = await payWithBearers(bearers, invoice, { poll: ctx.deps.poll ?? {}, kit: ctx.deps.kit ?? {}, assertOwner: ctx.assertOwner, + allocateOutputSecrets: (server, count) => + ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), + mintSignatureKeys: ctx.deps.mintSignatureKeys, + onCarve: async (carve) => { + committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner) + }, + onReturnReady: async (carve, recoverySecret) => { + const source = + committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url) + if (!source) throw new Error('The carved note was not tracked.') + returnStage = await ctx.deps.persistMintOutput( + { + url: withNewK1(carve.note.url, recoverySecret, carve.note.amount), + callback: '', + amount: 0, + verified: false, + pendingMint: {retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60}, + }, + ctx.assertOwner, + ) + await ctx.deps.reserveMintOutputSource( + returnStage, + source.id, + recoverySecret, + ctx.assertOwner, + ) + }, }) } catch (err) { if (err instanceof UncertainOutcomeError) { @@ -109,6 +149,14 @@ export const handlePayInvoice = async ( message, ) } + if (returnStage) { + const returned = result.rotatedNote ?? result.rescuedNote + if (returned) { + await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner) + } else if (result.outcome !== 'unknown-still-pending') { + await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner) + } + } const spendRecorded = (): void => { // This conservative budget debit is persisted separately from bearer // storage. A later bearer commit failure does not roll it back. @@ -119,7 +167,7 @@ export const handlePayInvoice = async ( case 'settled': { spendRecorded() await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -142,7 +190,7 @@ export const handlePayInvoice = async ( } case 'failed-funds-returned': await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -154,7 +202,7 @@ export const handlePayInvoice = async ( ) case 'note-already-spent': await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -167,7 +215,7 @@ export const handlePayInvoice = async ( case 'unknown-still-pending': spendRecorded() await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, diff --git a/src/lnurlcash/nwc/service.ts b/src/lnurlcash/nwc/service.ts index 2a43e72..9fee523 100644 --- a/src/lnurlcash/nwc/service.ts +++ b/src/lnurlcash/nwc/service.ts @@ -89,13 +89,24 @@ export const startService = async ( ) } - // pay_invoice goes through the connection's queue (budget atomicity); - // everything else dispatches directly + let invoiceQueue: Promise = Promise.resolve() + + // Payments serialize per connection for budget atomicity. Invoice + // creation serializes across the service so different clients cannot + // race the wallet-wide durable pending-output cap. const dispatchSerialized = ( runtime: ConnectionRuntime, ctx: RequestContext, request: NwcRequest, ): Promise => { + if (request.method === 'make_invoice') { + const run = invoiceQueue.then( + () => dispatch(ctx, request), + () => dispatch(ctx, request), + ) + invoiceQueue = run.catch(() => undefined) + return run + } if (request.method !== 'pay_invoice') return dispatch(ctx, request) const run = runtime.queue.then( () => dispatch(ctx, request), @@ -223,8 +234,12 @@ export const startService = async ( accepting = false for (const runtime of runtimes) runtime.sub.close() stopController.abort() - stopPromise = Promise.all([...inFlight]) - .then(() => undefined) + stopPromise = Promise.allSettled([...inFlight]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') throw result.reason + } + }) .finally(() => { for (const runtime of runtimes) { runtime.walletSecret?.fill(0) diff --git a/src/stores/nwc.atomic.test.ts b/src/stores/nwc.atomic.test.ts index c2cc13a..62dbbb7 100644 --- a/src/stores/nwc.atomic.test.ts +++ b/src/stores/nwc.atomic.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc'; import type * as NwcExports from '@/lnurlcash/nwc'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { useActivityStore } from './activity'; @@ -29,7 +30,11 @@ const note = (secret: string): NewBearer => ({ beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + // a present-but-non-serializing LockManager fake: walletFunds serializes + // its own mutations, so single-store tests only need locks to EXIST + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); stubLocalStorage(); setActivePinia(createPinia()); mocks.startService.mockResolvedValue({ @@ -69,7 +74,7 @@ describe('NWC store changeset adapter', () => { start[1].assertCurrentOwner, ); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(2); expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); }); diff --git a/src/stores/nwc.lifecycle.test.ts b/src/stores/nwc.lifecycle.test.ts index 34c6f3b..211a69f 100644 --- a/src/stores/nwc.lifecycle.test.ts +++ b/src/stores/nwc.lifecycle.test.ts @@ -16,16 +16,58 @@ import { import { setNwcTransportForTests, useNwcStore } from './nwc'; import { useWalletStore } from './wallet'; +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); setNwcTransportForTests(null); }); describe('NWC wallet lifecycle drain', () => { + it('keeps one service when enabling twice', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const relay = createFakeRelay(); + setNwcTransportForTests(relay.transport); + const nwc = useNwcStore(); + nwc.create(RELAYS, { maxMsat: 50_000, periodMs: 86_400_000 }); + + await nwc.setEnabled(true); + await nwc.setEnabled(true); + + expect(relay.subscriptionCount()).toBe(1); + await nwc.stop(); + expect(relay.subscriptionCount()).toBe(0); + }); + + it('drains an accepted foreground fund operation before lock clears its fence', async () => { + const wallet = useWalletStore(); + await wallet.create('password'); + const operation = wallet.beginFundOperation(); + let lockSettled = false; + + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await vi.waitFor(() => expect(() => wallet.captureOwnerFence()).toThrow()); + expect(lockSettled).toBe(false); + operation.ownerFence(); + + operation.complete(); + await locking; + expect(wallet.state).toBe('locked'); + }); + 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 diff --git a/src/stores/nwc.ts b/src/stores/nwc.ts index 9fdc156..f3e4015 100644 --- a/src/stores/nwc.ts +++ b/src/stores/nwc.ts @@ -1,3 +1,4 @@ +// allow: SIZE_OK — indivisible NWC service lifecycle state machine and Pinia control surface. import { ref, watch } from 'vue'; import { defineStore } from 'pinia'; @@ -21,6 +22,7 @@ import { writeNwcEnabled, } from '@/lnurlcash/nwc'; import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import { msatToSats } from '@/lnurlcash/units'; import { TrustedMintPostCommitError, useWalletStore } from './wallet'; import { useMintsStore } from './mints'; @@ -142,11 +144,77 @@ export const useNwcStore = defineStore('nwc', () => { lastError.value = ''; try { const ownerFence = wallet.captureOwnerFence(); + if (wallet.bearers.some((bearer) => bearer.pendingMint && !bearer.spent)) { + await wallet.recoverPendingMints(ownerFence); + if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return; + } const started = await startService(wallet.requireLinkingKey(), { // only spendable notes may back an NWC payment getBearers: () => wallet.unspentBearers, getDefaultMint: () => mints.defaultMint, assertCurrentOwner: ownerFence, + allocateOutputSecrets: (server, count, assertOwner) => + wallet.allocateOutputSecrets(server, count, assertOwner), + // a mid-request lockdown must not verify anything new: no keys + mintSignatureKeys: (server) => + wallet.state === 'unlocked' && wallet.pubkey !== null + ? getTrustedMintVerificationKeys(server, wallet.pubkey) + : [], + recoverPendingMints: (assertOwner) => wallet.recoverPendingMints(assertOwner), + persistMintOutput: async (note, assertOwner) => { + const [persisted] = await wallet.addBearers([note], assertOwner); + if (!persisted) throw new Error('The pending mint output was not persisted.'); + return persisted; + }, + discardMintOutput: async (staged, assertOwner) => { + await wallet.removeNote(staged.id, assertOwner); + }, + setMintOutputRetirement: async (staged, retireAfter, assertOwner) => + wallet.updateBearer( + staged.id, + { pendingMint: { ...staged.pendingMint, retireAfter } }, + assertOwner, + ), + reserveMintOutputSource: async (staged, sourceId, recoverySecret, assertOwner) => { + await wallet.reserveStagedMintSource(staged.id, sourceId, recoverySecret, assertOwner); + }, + finalizeMintOutput: async (staged, note, assertOwner) => { + try { + await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } + await activity.log( + 'nwc', + `Received ${formatSats(note.amount)} sats via NWC.`, + (error) => { + lastError.value = error.message; + }, + ); + }, + finalizePaymentReturn: async (staged, note, assertOwner) => { + try { + await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } + }, + finalizeSpentMintOutput: async (staged, assertOwner) => { + await wallet.finalizeSpentStagedMintOutput(staged.id, assertOwner); + }, + commitCarve: async (carve, assertOwner) => { + try { + return await wallet.commitCarve(carve, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + const committed = wallet.bearers.find((bearer) => bearer.url === carve.note.url); + if (!committed) throw error; + return committed; + } + }, applyChangeset, transport: transportOverride ?? undefined, onError: (error) => { @@ -193,7 +261,13 @@ export const useNwcStore = defineStore('nwc', () => { running.value = false; const priorStop = stopping; const activeStop = active?.stop() ?? Promise.resolve(); - const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined); + const completion = Promise.allSettled([priorStop, activeStop, ...pendingStarts]).then( + (results) => { + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + } + }, + ); pendingStop = completion; stopping = completion.catch(() => undefined); return completion; @@ -232,6 +306,10 @@ export const useNwcStore = defineStore('nwc', () => { // ---- settings ---- const setEnabled = async (value: boolean): Promise => { + if (value === enabled.value) { + if (value && !running.value && pendingStarts.size === 0) await start(); + return; + } const ownerId = ownerFromWallet(); writeNwcEnabled(ownerId, value); enabled.value = value; diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index f31106d..1899452 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -67,6 +67,7 @@ export const useWalletStore = defineStore('wallet', () => { let currentMaterial: WalletMaterialV2 | null = null; let lifecycleToken = 0; let acceptingOwnerWork = false; + const foregroundFundOperations = new Set>(); const lockWarningSecondsLeft = ref(null); const runTransition = createWalletTransitionQueue({ @@ -105,12 +106,22 @@ export const useWalletStore = defineStore('wallet', () => { pubkey.value = null; }; + const drainAcceptedOwnerWork = async (): Promise => { + const results = await Promise.allSettled([ + stopWalletNwcSession(), + ...foregroundFundOperations, + ]); + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + } + }; + const deactivateSession = async (): Promise => { acceptingOwnerWork = false; stopOwnerChanges(); idleWatch.stop(); try { - await stopWalletNwcSession(); + await drainAcceptedOwnerWork(); } finally { // even a rejected drain ends the session: 'locked' never holds key // material and no captured fence stays valid @@ -201,7 +212,7 @@ export const useWalletStore = defineStore('wallet', () => { // 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(); + await drainAcceptedOwnerWork(); invalidateLifecycle(); clearRuntime(); if (ownerId === null) await clearUnownedAuthorizations(); @@ -323,6 +334,28 @@ export const useWalletStore = defineStore('wallet', () => { return currentMaterial; }; + const beginFundOperation = (): { + readonly ownerFence: () => void; + readonly complete: () => void; + } => { + const captured = ownerFence.capture(); + let completePromise: (() => void) | undefined; + const completion = new Promise((resolve) => { + completePromise = resolve; + }); + foregroundFundOperations.add(completion); + let active = true; + return { + ownerFence: captured, + complete: () => { + if (!active) return; + active = false; + foregroundFundOperations.delete(completion); + completePromise?.(); + }, + }; + }; + const funds = createWalletFunds({ requireKey, requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex), @@ -355,5 +388,6 @@ export const useWalletStore = defineStore('wallet', () => { requireLinkingKey, requireWalletMaterial, captureOwnerFence: ownerFence.capture, + beginFundOperation, }; }); From 04e62110b66c4be50f07a97aec48d3f68f6c9989 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:44:29 +0200 Subject: [PATCH 19/29] fix(composables): commit carve checkpoints by output secret --- src/composables/walletCarveCommit.test.ts | 52 +++++++++++++++++++++-- src/composables/walletCarveCommit.ts | 35 +++++---------- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/composables/walletCarveCommit.test.ts b/src/composables/walletCarveCommit.test.ts index eef9a71..abc42bc 100644 --- a/src/composables/walletCarveCommit.test.ts +++ b/src/composables/walletCarveCommit.test.ts @@ -5,6 +5,7 @@ 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 { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { commitCarve } from './walletCarveCommit'; @@ -21,7 +22,7 @@ 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') { + if (key === FUNDS_STORAGE_KEY) { writes += 1; if (writes === occurrence) throw new Error('bearer storage unavailable'); } @@ -29,10 +30,18 @@ const failOnBearerWrite = (occurrence: number): void => { }); }; +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); }); @@ -59,7 +68,7 @@ describe('commitCarve', () => { }); // Then the whole rotation landed as ONE durable write - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).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); @@ -71,7 +80,7 @@ describe('commitCarve', () => { 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 + // funds-document 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(); @@ -127,4 +136,39 @@ describe('commitCarve', () => { expect(persisted).toHaveLength(1); expect(persisted[0]?.spent).toBeUndefined(); }); + + it('commits the staged and landed phases of the same output as one record', 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 context = { ownerFence, warn: () => undefined }; + + // When the pre-wire phase stages the outputs (sig-less URLs), then the + // landed phase re-reports them with the mint's signature in the URL + await commitCarve(wallet, { note: note('bb'), change: note('cc'), consumed: [] }, context); + expect(wallet.bearers).toHaveLength(3); + const stagedNote = note('bb'); + const committed = await commitCarve( + wallet, + { + note: { ...stagedNote, url: `${stagedNote.url}&sig=${'ab'.repeat(64)}`, verified: true }, + consumed: [input], + }, + context, + ); + + // Then the staged record was refreshed in place: still one record per + // output, the input retired, no duplicate from the URL difference + expect(wallet.bearers).toHaveLength(3); + expect(committed.verified).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + expect( + wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32))), + ).toHaveLength(1); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toHaveLength(3); + }); }); diff --git a/src/composables/walletCarveCommit.ts b/src/composables/walletCarveCommit.ts index ed9b032..1c5b817 100644 --- a/src/composables/walletCarveCommit.ts +++ b/src/composables/walletCarveCommit.ts @@ -1,5 +1,4 @@ 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'; @@ -7,10 +6,7 @@ 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; + readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise; }; type CarveCommitContext = Readonly<{ @@ -35,33 +31,24 @@ export const addCommittedBearers = async ( // 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. +// but not spent, or vice versa) would strand or double-show money. Both +// checkpoint phases (pre-wire staging, landed retirement) go through the +// wallet's k1-aware one-write boundary: the landed phase's URLs carry the +// mint's signature the staged phase's could not have, so the store matches +// checkpoints by output SECRET and refreshes the staged record in place - +// a replayed checkpoint is a no-op, never a duplicate. 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, - ); + return await wallet.commitCarve(carve, context.ownerFence); } catch (error) { if (!(error instanceof TrustedMintPostCommitError)) throw error; context.warn(error.message); - added = error.committedBearers; + const committed = error.committedBearers[0]; + if (!committed) throw error; + return committed; } - const committed = existing ?? added[0]; - if (!committed) throw new Error('The carved note was not tracked.'); - return committed; }; From 8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:44:29 +0200 Subject: [PATCH 20/29] feat(composables): route fund dialogs through the durable seam --- .../receive/ReceiveLightningDialog.vue | 2 + src/composables/useMoveFundsPage.ts | 72 +++++++++++------ src/composables/usePayInvoiceDialog.ts | 80 ++++++++++++++----- src/composables/useReceiveLightningDialog.ts | 57 ++++++++----- src/composables/useSendTokenDialog.ts | 15 +++- 5 files changed, 162 insertions(+), 64 deletions(-) diff --git a/src/components/receive/ReceiveLightningDialog.vue b/src/components/receive/ReceiveLightningDialog.vue index 4851300..5f7208c 100644 --- a/src/components/receive/ReceiveLightningDialog.vue +++ b/src/components/receive/ReceiveLightningDialog.vue @@ -113,6 +113,7 @@ {{ claimError }} { if (!sats) return; step.value = 'working'; stage.value = 'Asking the target mint for an invoice…'; + let fundOperation: ReturnType | undefined; try { - const ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + const ownerId = wallet.pubkey ?? undefined; const commitContext = { ownerFence, warn: warnCommitted }; + let stagedOutput: Bearer | undefined; + let carvedOutput: Bearer | undefined; // the carve commits the moment it lands server-side (onCarve), not // after the melt + settlement wait - an abort mid-wait can then // never leave burned inputs looking spendable or strand the outputs const carveState: { committed?: Bearer } = {}; const transfer = await transferBetweenMints( - wallet.bearers, + wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value), satsToMsat(sats), targetInput.value, { assertOwner: ownerFence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, ownerFence), + mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId), + persistOutput: async (note) => { + [stagedOutput] = await addCommittedBearers(wallet, [note], commitContext); + }, onCarve: async (carve) => { carveState.committed = await commitCarve(wallet, carve, commitContext); }, + onMeltReady: async (carve, sourceRecoverySecret) => { + if (!stagedOutput) throw new Error('The pending target note was not persisted.'); + carvedOutput = + carveState.committed ?? + wallet.bearers.find((bearer) => bearer.url === carve.note.url); + if (!carvedOutput) throw new Error('The carved note was not tracked.'); + await wallet.reserveStagedMintSource( + stagedOutput.id, + carvedOutput.id, + sourceRecoverySecret, + ownerFence, + ); + }, }, ); + if (!stagedOutput) throw new Error('The pending target note was not persisted.'); stage.value = 'Confirming the result…'; // a carve that mutated nothing fires no hook - the note is already // tracked; look it up const carved = + carvedOutput ?? carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url); if (!carved) throw new Error('The carved note was not tracked.'); @@ -162,13 +190,14 @@ export const useMoveFundsPage = () => { } 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); + try { + await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + warnCommitted(error.message); + } } await activity.log( 'transfer', @@ -178,11 +207,11 @@ export const useMoveFundsPage = () => { toast('positive', `Moved ${sats.toLocaleString()} sats.`); } else if (transfer.outcome === 'failed-funds-returned') { if (transfer.rotatedNote) { - // the source-probe rotate re-secured the returned funds at a - // fresh secret, burning the carved note the checkpoint already - // committed - add the new note BEFORE marking the old one spent - await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext); - await wallet.markSpent(carved.id, ownerFence); + await wallet.restoreStagedMintSource( + stagedOutput.id, + transfer.rotatedNote, + ownerFence, + ); } await activity.log( 'transfer', @@ -191,22 +220,13 @@ export const useMoveFundsPage = () => { ); } else if (transfer.outcome === 'unknown-still-pending') { await wallet.markSpent(carved.id, ownerFence); - // a named target's claim material carries the note at the wallet's - // own secret - claimable the moment the melt settles, so it is - // tracked unverified rather than lost with the result object - if (transfer.claimMaterial?.note) { - await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext); - } 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 wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence); await activity.log( 'transfer', `${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`, @@ -220,7 +240,7 @@ export const useMoveFundsPage = () => { (error) => warnCommitted(error.message), ); } - const claimNote = transfer.claimMaterial?.note ?? null; + const claimNote = transfer.claimMaterial?.note ?? stagedOutput; result.value = { outcome: transfer.outcome, requestedSats: sats, @@ -237,6 +257,8 @@ export const useMoveFundsPage = () => { : message; toast('negative', inlineError.value); step.value = 'form'; + } finally { + fundOperation?.complete(); } }; return { diff --git a/src/composables/usePayInvoiceDialog.ts b/src/composables/usePayInvoiceDialog.ts index 7f770e8..d0f1aa9 100644 --- a/src/composables/usePayInvoiceDialog.ts +++ b/src/composables/usePayInvoiceDialog.ts @@ -1,10 +1,16 @@ import { computed, ref, watch } from 'vue'; import { useQuasar } from 'quasar'; -import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit'; +import { + decodeBolt11AmountMsat, + isBolt11Invoice, + resolveLnurlInput, + withNewK1, +} from 'lnurlcash-kit'; import { readClipboard } from '@/capabilities/clipboard'; import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; @@ -143,31 +149,72 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit step.value = 'working'; stage.value = 'Preparing the exact amount and sending the payment…'; let ownerFence: WalletOwnerFence | undefined; + let fundOperation: ReturnType | undefined; try { - ownerFence = wallet.captureOwnerFence(); - const commitContext = { ownerFence, warn: warnCommitted }; + fundOperation = wallet.beginFundOperation(); + ownerFence = fundOperation.ownerFence; + const fence = ownerFence; + const ownerId = wallet.pubkey ?? undefined; + const commitContext = { ownerFence: fence, warn: warnCommitted }; // the carve commits the moment it lands server-side (onCarve), not // after the settlement wait - an abort mid-wait can then never leave // burned inputs looking spendable or strand the fresh outputs const carveState: { committed?: Bearer } = {}; + let returnStage: Bearer | undefined; const onCarve = async (carve: Parameters[1]): Promise => { carveState.committed = await commitCarve(wallet, carve, commitContext); }; - const paid = await payWithBearers( - wallet.bearers, - payment.input, - payment.kind === 'address' - ? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve } - : { assertOwner: ownerFence, onCarve }, - ); + const allocateOutputSecrets = (server: string, count: number) => + wallet.allocateOutputSecrets(server, count, fence); + const mintSignatureKeys = (server: string) => + getTrustedMintVerificationKeys(server, ownerId); + // the pay-return recovery output: staged durably (allocated secret, + // zero value, pendingMint) and linked to the carved source BEFORE the + // classification rotate can burn it + const onReturnReady = async ( + carve: Parameters[1], + recoverySecret: string, + ): Promise => { + const source = + carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url); + if (!source) throw new Error('The carved note was not tracked.'); + [returnStage] = await addCommittedBearers( + wallet, + [ + { + url: withNewK1(carve.note.url, recoverySecret, carve.note.amount), + callback: '', + amount: 0, + verified: false, + pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 }, + }, + ], + commitContext, + ); + if (!returnStage) throw new Error('The payment recovery output was not persisted.'); + await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence); + }; + const paid = await payWithBearers(wallet.bearers, payment.input, { + ...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}), + assertOwner: fence, + onCarve, + allocateOutputSecrets, + mintSignatureKeys, + onReturnReady, + }); stage.value = 'Confirming the result…'; // a carve that mutated nothing (one note already held the exact // amount) fires no hook - the note is already tracked; look it up const committed = carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url); if (!committed) throw new Error('The carved note was not tracked.'); - if (paid.rescuedNote) { - await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); + if (returnStage) { + const returned = paid.rotatedNote ?? paid.rescuedNote; + if (returned) { + await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence); + } else if (paid.outcome !== 'unknown-still-pending') { + await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence); + } } const sats = formatSats(msatToSats(paid.amountMsat)); if (paid.outcome === 'settled') { @@ -178,13 +225,6 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit toast('positive', `Paid ${sats} sats.`); emit('sent'); } else if (paid.outcome === 'failed-funds-returned') { - if (paid.rotatedNote) { - // the classification rotate re-secured the returned funds at a - // fresh secret, burning the carved note the checkpoint already - // committed - add the new note BEFORE marking the old one spent - await addCommittedBearers(wallet, [paid.rotatedNote], commitContext); - await wallet.markSpent(committed.id, ownerFence); - } await activity.log( 'transfer', `A ${sats} sat payment failed - funds are back in your wallet.`, @@ -227,6 +267,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit toast('negative', inlineError.value); } step.value = 'input'; + } finally { + fundOperation?.complete(); } }; const closeResult = (): void => { diff --git a/src/composables/useReceiveLightningDialog.ts b/src/composables/useReceiveLightningDialog.ts index 619efce..eeed7e0 100644 --- a/src/composables/useReceiveLightningDialog.ts +++ b/src/composables/useReceiveLightningDialog.ts @@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue'; import { Notify } from 'quasar'; import { writeClipboard } from '@/capabilities/clipboard'; -import { prepareMint, claimMintedNote } from '@/lnurlcash/ops'; +import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops'; import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops'; -import type { NewBearer } from '@/lnurlcash/types'; +import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; @@ -35,8 +35,10 @@ export const useReceiveLightningDialog = ( const preparing = ref(false); const formError = ref(''); const prepared = ref(null); + const stagedOutput = ref(null); const waiting = ref(false); const claimError = ref(''); + const claimRetryable = ref(true); const receivedSats = ref(0); const receivedServer = ref(''); const rotationWarning = ref(''); @@ -85,23 +87,21 @@ export const useReceiveLightningDialog = ( const onClaimed = async ( claimed: ClaimedNote, from: PreparedMint, + staged: Bearer, 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); + await wallet.finalizeStagedMintOutput(staged.id, claimed.note, 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 ?? ''; + rotationWarning.value = ''; await activity.log( 'mint', `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`, @@ -130,23 +130,35 @@ export const useReceiveLightningDialog = ( } }; const beginClaim = (): void => { - if (!prepared.value || claimRun) return; + if (!prepared.value || !stagedOutput.value || claimRun) return; waiting.value = true; claimError.value = ''; + claimRetryable.value = true; const current = prepared.value; + const staged = stagedOutput.value; claimRun = (async () => { + const fundOperation = wallet.beginFundOperation(); try { - const ownerFence = wallet.captureOwnerFence(); + const ownerFence = fundOperation.ownerFence; await onClaimed( await claimMintedNote(current, {}, { assertOwner: ownerFence }), current, + staged, ownerFence, ); } catch (error) { if (!(error instanceof Error)) throw error; + if (error instanceof MintedNoteSpentError) { + await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence); + claimRetryable.value = false; + claimError.value = errorMessage(error); + Notify.create({ type: 'negative', message: claimError.value }); + return; + } claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`; Notify.create({ type: 'negative', message: errorMessage(error) }); } finally { + fundOperation.complete(); waiting.value = false; } })(); @@ -156,20 +168,26 @@ export const useReceiveLightningDialog = ( if (!sats || preparing.value) return; preparing.value = true; formError.value = ''; + let fundOperation: ReturnType | undefined; try { const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; - const next = await prepareMint(input, satsToMsat(sats)); - // named mints claim through the wallet's own secret and may - // legitimately serve no verify URL; unnamed mints need one (the - // preimage it reveals IS the claim) - if (next.mode === 'unnamed' && !next.verifyUrl) { - formError.value = - 'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; - return; - } + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + let persisted: Bearer | undefined; + const next = await prepareMint(input, satsToMsat(sats), { + assertOwner: ownerFence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, ownerFence), + persistOutput: async (note) => { + [persisted] = await wallet.addBearers([note], ownerFence); + }, + }); + if (!persisted) throw new Error('The pending mint output was not persisted.'); + stagedOutput.value = persisted; prepared.value = next; claimRun = null; claimError.value = ''; + claimRetryable.value = true; step.value = 'invoice'; beginClaim(); } catch (error) { @@ -177,6 +195,7 @@ export const useReceiveLightningDialog = ( formError.value = errorMessage(error); Notify.create({ type: 'negative', message: formError.value }); } finally { + fundOperation?.complete(); preparing.value = false; } }; @@ -211,6 +230,7 @@ export const useReceiveLightningDialog = ( preparing.value = false; formError.value = ''; prepared.value = null; + stagedOutput.value = null; waiting.value = false; rotationWarning.value = ''; }, @@ -219,6 +239,7 @@ export const useReceiveLightningDialog = ( CUSTOM_MINT, amountSats, claimError, + claimRetryable, copyInvoice, createInvoice, customMint, diff --git a/src/composables/useSendTokenDialog.ts b/src/composables/useSendTokenDialog.ts index 684bcb5..a28c7ff 100644 --- a/src/composables/useSendTokenDialog.ts +++ b/src/composables/useSendTokenDialog.ts @@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit'; import { writeClipboard } from '@/capabilities/clipboard'; import { canShareText, shareText } from '@/capabilities/share'; import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; @@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = preparing.value = true; errorMessage.value = null; let ownerFence: WalletOwnerFence | undefined; + let fundOperation: ReturnType | undefined; try { - ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + ownerFence = fundOperation.ownerFence; const fence = ownerFence; + const ownerId = wallet.pubkey ?? undefined; // the carve commits the moment it lands server-side (onCarve), so // even a crash right after can never strand the fresh outputs or // leave burned inputs looking spendable const carveState: { committed?: Bearer } = {}; const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { assertOwner: fence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, fence), + mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId), onCarve: async (landed) => { carveState.committed = await commitCarve(wallet, landed, { ownerFence: fence, @@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = : message; toast('negative', errorMessage.value); } finally { + fundOperation?.complete(); preparing.value = false; } }; @@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = const note = prepared.value; if (!note) return; removing.value = true; + let fundOperation: ReturnType | undefined; try { - await wallet.markSpent(note.id, wallet.captureOwnerFence()); + fundOperation = wallet.beginFundOperation(); + await wallet.markSpent(note.id, fundOperation.ownerFence); await activity.log( 'spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`, @@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = } catch (error) { toast('negative', error instanceof Error ? error.message : 'Something went wrong.'); } finally { + fundOperation?.complete(); removing.value = false; } }; From a91ef30498870dc021e49dc876752478c225f106 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:59:34 +0200 Subject: [PATCH 21/29] refactor(ops): require durable allocation and checkpoints --- src/lnurlcash/mintContract.test.ts | 20 +++++-- src/lnurlcash/ops.carve-crash.cases.ts | 20 +++---- src/lnurlcash/ops.carve.cases.ts | 53 ++++++++++++++++-- src/lnurlcash/ops.mint-named.cases.ts | 24 ++++++++- src/lnurlcash/ops.mint-receive.cases.ts | 49 +++++++++++++++-- src/lnurlcash/ops.pay.cases.ts | 43 ++++++++++++++- src/lnurlcash/ops.retry-refusal.cases.ts | 15 +++++- src/lnurlcash/ops.signature.cases.ts | 16 +++++- src/lnurlcash/ops.testHarness.ts | 4 +- src/lnurlcash/ops.transfer.cases.ts | 44 ++++++++++++++- src/lnurlcash/ops.ts | 10 ++-- src/lnurlcash/ops/carve.ts | 31 ++++++----- src/lnurlcash/ops/mint.ts | 12 ++--- src/lnurlcash/ops/pay.ts | 23 +++++--- src/lnurlcash/ops/receiveBearer.ts | 68 ++++++++++++------------ src/lnurlcash/ops/transfer.ts | 26 ++++++--- src/stores/walletFunds.ts | 2 +- 17 files changed, 356 insertions(+), 104 deletions(-) diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index 1b73be9..5e5f614 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -49,6 +49,16 @@ const settleLastInvoice = async (m: Mint): Promise => { const MINT_PUBKEY = `02${'ab'.repeat(32)}` const persistOutput = async (): Promise => undefined +// deterministic stand-in for the wallet's BIP-32 allocation path +let allocatedCounter = 0 +const allocateOutputSecrets = (_server: string, count: number): Promise => { + const secrets = Array.from({length: count}, () => { + allocatedCounter += 1 + return `03${allocatedCounter.toString(16).padStart(62, '0')}` + }) + return Promise.resolve(secrets) +} + // 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 @@ -124,7 +134,7 @@ describe('mint-address wire contract', () => { 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, {persistOutput}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) // the metadata is advertised at the mint-address endpoint itself - // the payRequest never carried it expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) @@ -141,7 +151,7 @@ describe('withdraw-link forms', () => { // conformance 0.7.0 defaults to plain https links; lnurlw:// is an // explicit LUD-17 transport form const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) - const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput}) + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) // and the link is fully usable: settle the invoice, claim the note @@ -174,7 +184,7 @@ describe('withdraw-link forms', () => { // 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, persistOutput}) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput, allocateOutputSecrets}) 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') @@ -218,6 +228,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({})), seen), persistOutput, + allocateOutputSecrets, }), ).rejects.toThrow(/64-character output commitment/i) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) @@ -227,6 +238,7 @@ describe('quote output-naming wire contract', () => { const seen: string[] = [] const events: string[] = [] const prepared = await prepareMint('mint@mint.example', 21_000, { + allocateOutputSecrets, fetch: (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url @@ -264,6 +276,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), persistOutput: () => Promise.reject(new Error('storage full')), + allocateOutputSecrets, }), ).rejects.toThrow(/storage full/) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) @@ -275,6 +288,7 @@ describe('quote output-naming wire contract', () => { prepareMint('mint@mint.example', 21_000, { fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), persistOutput, + allocateOutputSecrets, }), ).rejects.toThrow(/64-character output commitment/i) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) diff --git a/src/lnurlcash/ops.carve-crash.cases.ts b/src/lnurlcash/ops.carve-crash.cases.ts index ff236ee..74a68c9 100644 --- a/src/lnurlcash/ops.carve-crash.cases.ts +++ b/src/lnurlcash/ops.carve-crash.cases.ts @@ -6,13 +6,12 @@ import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops' import {requiredValue} from './test-utils' import {makeBearer, mint, secret} from './ops.testHarness' -const secretSource = (...secrets: string[]): (() => string) => { - let index = 0 - return () => { - const next = secrets[index] - if (!next) throw new Error('The test exhausted its prepared secrets.') - index += 1 - return next +const allocatedSecrets = (...secrets: string[]): ((server: string, count: number) => Promise) => { + let served = false + return (_server, count) => { + if (served) throw new Error('The test expected a single allocation.') + served = true + return Promise.resolve(secrets.slice(0, count)) } } @@ -37,7 +36,7 @@ describe('ensureExactAmount crash safety', () => { const result = await ensureExactAmount([bearer], 5_000, { fetch: observingFetch, - randomSecret: secretSource(targetSecret, changeSecret), + allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret), onCarve: (prepared) => { staged = prepared }, @@ -67,7 +66,7 @@ describe('ensureExactAmount crash safety', () => { await ensureExactAmount([first, second], 7_000, { fetch: observingFetch, - randomSecret: secretSource(mergedSecret), + allocateOutputSecrets: allocatedSecrets(mergedSecret), onCarve: (prepared) => { staged = prepared }, @@ -95,7 +94,7 @@ describe('ensureExactAmount crash safety', () => { const result = await ensureExactAmount([bearer], 5_000, { fetch: dropSecondMutation, mutationRetries: 0, - randomSecret: secretSource(secret('77'), secret('78')), + allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')), onCarve: () => undefined, }) @@ -150,6 +149,7 @@ describe('ensureExactAmount crash safety', () => { await expect( ensureExactAmount([first, second], 7_000, { fetch: countingFetch, + allocateOutputSecrets: allocatedSecrets(secret('7b')), assertOwner: () => { ownerChecks += 1 if (ownerChecks === 3) throw new Error('wallet owner changed') diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts index 9f2a30d..a2396c6 100644 --- a/src/lnurlcash/ops.carve.cases.ts +++ b/src/lnurlcash/ops.carve.cases.ts @@ -3,9 +3,21 @@ import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit' import type {Bearer} from './types' import type {CarveResult} from './ops' -import {CarveCheckpointRequiredError, UncertainOutcomeError, ensureExactAmount} from './ops' +import { + CarveCheckpointRequiredError, + OutputSecretAllocationRequiredError, + UncertainOutcomeError, + ensureExactAmount, +} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' +import { + allocateOutputSecrets, + makeBearer, + mint, + noteUrl, + secret, + signatureKeysFor, +} from './ops.testHarness' describe('ensureExactAmount', () => { it('returns an already-exact note untouched, burning nothing', async () => { @@ -25,6 +37,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, k1, 21_000) const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) expect(result.note.amount).toBe(5_000) @@ -42,7 +55,7 @@ describe('ensureExactAmount', () => { 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, {onCarve: () => undefined}) + const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined, allocateOutputSecrets}) expect(result.note.amount).toBe(7_000) expect(result.change).toBeUndefined() expect(result.consumed).toHaveLength(2) @@ -56,7 +69,7 @@ describe('ensureExactAmount', () => { 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, {onCarve: () => undefined}) + const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) expect(result.note.amount).toBe(5_000) expect(result.change?.amount).toBe(2_000) expect(result.consumed).toHaveLength(2) @@ -88,7 +101,7 @@ describe('ensureExactAmount', () => { 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, {onCarve: () => undefined}) + const result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) const partK1 = requiredValue(noteK1(result.note.url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(partK1).not.toBe(k1) @@ -118,6 +131,7 @@ describe('ensureExactAmount', () => { const checkpoints: CarveResult[] = [] const failure = await ensureExactAmount([bearer], 5_000, { fetch: everythingDyingFetch, + allocateOutputSecrets, onCarve: (carve) => { checkpoints.push(carve) }, @@ -136,6 +150,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, secret('12'), 21_000) const seen: CarveResult[] = [] const result = await ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: (carve) => { seen.push(carve) }, @@ -166,6 +181,7 @@ describe('ensureExactAmount', () => { const retiredIds = new Set() const result = await ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: (carve) => { // checkpoints identify an output by its secret: the landed phase's // URL has gained a signature the staged phase could not have had @@ -194,6 +210,7 @@ describe('ensureExactAmount', () => { await expect( ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: (carve) => { seen.push(carve) if (carve.consumed.length > 0) throw new Error('final commit failed') @@ -243,6 +260,7 @@ describe('ensureExactAmount', () => { const bearer = await makeBearer(instance, secret('14'), 21_000) await expect( ensureExactAmount([bearer], 5_000, { + allocateOutputSecrets, onCarve: () => { throw new Error('commit failed') }, @@ -303,4 +321,29 @@ describe('ensureExactAmount', () => { expect(allocations).toBe(0) }) + + it('refuses a mutating carve without an allocation path, before any staging', async () => { + const instance = await mint() + const split = await makeBearer(instance, secret('87'), 21_000) + const mergeA = await makeBearer(instance, secret('88'), 3_000) + const mergeB = await makeBearer(instance, secret('89'), 4_000) + let mutations = 0 + const countingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) mutations += 1 + return fetch(input, init) + } + + await expect( + ensureExactAmount([split], 5_000, {fetch: countingFetch, onCarve: () => undefined}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + await expect( + ensureExactAmount([mergeA, mergeB], 7_000, {fetch: countingFetch, onCarve: () => undefined}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + + expect(mutations).toBe(0) + expect(instance.state.noteState(secret('87'))).toBe('outstanding') + expect(instance.state.noteState(secret('88'))).toBe('outstanding') + expect(instance.state.noteState(secret('89'))).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts index 16ce2b0..28fb9c1 100644 --- a/src/lnurlcash/ops.mint-named.cases.ts +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -1,10 +1,10 @@ import {describe, expect, it} from 'vitest' import {hashK1, noteK1} from 'lnurlcash-kit' -import {claimMintedNote, prepareMint} from './ops' +import {claimMintedNote, OutputSecretAllocationRequiredError, prepareMint} from './ops' import {requiredValue} from './test-utils' import type {Mint} from './ops.testHarness' -import {mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' +import {allocateOutputSecrets, mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' const HEX32 = /^[0-9a-f]{64}$/ @@ -16,6 +16,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) expect(prepared.noteSecret).toMatch(HEX32) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) @@ -25,6 +26,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) const preimage = await settleLastInvoice(instance) const claimed = await claimMintedNote(prepared, { @@ -44,6 +46,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) await expect( claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), @@ -54,6 +57,7 @@ describe('mint -> claim, comment-bound output', () => { const instance = await mint({mintToHash: true, testHooks: true}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { persistOutput, + allocateOutputSecrets, }) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) await settleLastInvoice(instance) @@ -83,4 +87,20 @@ describe('mint -> claim, comment-bound output', () => { expect(prepared.noteSecret).toBe(allocated[0]) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0]))) }) + + it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => { + const instance = await mint({testHooks: true}) + let persisted = 0 + + await expect( + prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { + persistOutput: () => { + persisted += 1 + }, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + + expect(persisted).toBe(0) + expect(instance.state.invoices.size).toBe(0) + }) }) diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 5c5b989..13b51be 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -8,16 +8,24 @@ import { rotateNote, } from 'lnurlcash-kit' -import {claimMintedNote, prepareMint, receiveBearer} from './ops' +import { + claimMintedNote, + OutputSecretAllocationRequiredError, + prepareMint, + receiveBearer, + ReceiveRotationStagingRequiredError, +} from './ops' import {requiredValue} from './test-utils' import type {NewBearer} from './types' import { + allocateOutputSecrets, makeBearer, mint, noteUrl, persistOutput, secret, settleLastInvoice, + stageRotation, } from './ops.testHarness' describe('mint fee', () => { @@ -25,6 +33,7 @@ describe('mint fee', () => { 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, { persistOutput, + allocateOutputSecrets, }) expect(prepared.grossMsat).toBeGreaterThan(100_000) await settleLastInvoice(instance) @@ -105,8 +114,12 @@ describe('receiveBearer', () => { const instance = await mint() const senderK1 = secret('20') instance.state.creditNote(senderK1, 21_000) - const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), []) + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + allocateOutputSecrets, + stageRotation, + }) expect(received.rotated).toBe(true) + expect(received.stage).toBe('finalize') expect(received.note.amount).toBe(21_000) expect(received.note.verified).toBe(true) const newK1 = requiredValue(noteK1(received.note.url)) @@ -140,8 +153,34 @@ describe('receiveBearer', () => { 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, - ) + await expect( + receiveBearer(noteUrl(instance, k1, 21_000), [], { + allocateOutputSecrets, + stageRotation, + }), + ).rejects.toBeInstanceOf(PendingNoteError) + }) + + it('refuses to rotate without a durable staging checkpoint', async () => { + const instance = await mint() + const senderK1 = secret('9a') + instance.state.creditNote(senderK1, 21_000) + + await expect( + receiveBearer(noteUrl(instance, senderK1, 21_000), [], {allocateOutputSecrets}), + ).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError) + // the sender's copy is untouched - no rotation was attempted + expect(instance.state.noteState(senderK1)).toBe('outstanding') + }) + + it('refuses to rotate without an allocation path', async () => { + const instance = await mint() + const senderK1 = secret('9b') + instance.state.creditNote(senderK1, 21_000) + + await expect( + receiveBearer(noteUrl(instance, senderK1, 21_000), [], {stageRotation}), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(instance.state.noteState(senderK1)).toBe('outstanding') }) }) diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts index 04105ab..5208b37 100644 --- a/src/lnurlcash/ops.pay.cases.ts +++ b/src/lnurlcash/ops.pay.cases.ts @@ -1,9 +1,13 @@ import {describe, expect, it} from 'vitest' import {noteK1} from 'lnurlcash-kit' -import {payWithBearers} from './ops' +import { + OutputSecretAllocationRequiredError, + PayReturnCheckpointRequiredError, + payWithBearers, +} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, secret} from './ops.testHarness' +import {allocateOutputSecrets, makeBearer, mint, secret} from './ops.testHarness' describe('payWithBearers', () => { it('pays a bolt11 invoice by melting an exact note (settled)', async () => { @@ -52,6 +56,7 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('32'), 50_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + allocateOutputSecrets, onCarve: () => undefined, }) expect(result.outcome).toBe('settled') @@ -67,6 +72,8 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('33'), 21_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + onReturnReady: () => undefined, }) expect(result.outcome).toBe('failed-funds-returned') expect(instance.state.noteState(secret('33'))).toBe('burned') @@ -88,6 +95,7 @@ describe('payWithBearers', () => { }) const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, onReturnReady: async () => { stageStarted?.() await stageGate @@ -106,6 +114,8 @@ describe('payWithBearers', () => { const bearer = await makeBearer(instance, secret('34'), 21_000) const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + onReturnReady: () => undefined, }) expect(result.outcome).toBe('unknown-still-pending') expect(instance.state.noteState(secret('34'))).toBe('pending') @@ -125,6 +135,7 @@ describe('payWithBearers', () => { // through the hook before the melt may start await expect( payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { + allocateOutputSecrets, onCarve: () => { throw new Error('commit failed') }, @@ -175,4 +186,32 @@ describe('payWithBearers', () => { expect(result.outcome).toBe('settled') expect(allocations).toBe(0) }) + + it('refuses the return classification without its durable checkpoint', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8d'), 21_000) + + await expect( + payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + allocateOutputSecrets, + }), + ).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError) + // the payment failed and the mint restored the note; the refusal came + // before the classification rotate, so nothing else moved + expect(instance.state.noteState(secret('8d'))).toBe('outstanding') + }) + + it('refuses the return classification without an allocation path', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('8e'), 21_000) + + await expect( + payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + onReturnReady: () => undefined, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(instance.state.noteState(secret('8e'))).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts index 4d3263e..dce23a9 100644 --- a/src/lnurlcash/ops.retry-refusal.cases.ts +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -4,7 +4,15 @@ import {noteK1} from 'lnurlcash-kit' import type {CarveResult} from './ops' import {ensureExactAmount, receiveBearer} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness' +import { + allocateOutputSecrets, + makeBearer, + mint, + noteUrl, + retryingCbFetch, + secret, + stageRotation, +} from './ops.testHarness' // The redeem callback is a GET, and HTTP stacks retry GETs - so a mint // can execute the first attempt of a mutation and refuse the byte-identical @@ -19,6 +27,7 @@ describe('mutation retry refusals', () => { const bearer = await makeBearer(instance, k1, 50_000) const result = await ensureExactAmount([bearer], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: () => undefined, }) // the split landed on the first (unseen) attempt: the input is burned @@ -39,6 +48,7 @@ describe('mutation retry refusals', () => { const b = await makeBearer(instance, secret('62'), 5_000) const result = await ensureExactAmount([a, b], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: () => undefined, }) expect(result.consumed).toHaveLength(2) @@ -55,6 +65,8 @@ describe('mutation retry refusals', () => { instance.state.creditNote(senderK1, 21_000) const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { fetch: retryingCbFetch(), + allocateOutputSecrets, + stageRotation, }) expect(received.rotated).toBe(true) expect(received.note.amount).toBe(21_000) @@ -73,6 +85,7 @@ describe('mutation retry refusals', () => { await expect( ensureExactAmount([bearer], 25_000, { fetch: retryingCbFetch(), + allocateOutputSecrets, onCarve: (carve) => { checkpoints.push(carve) }, diff --git a/src/lnurlcash/ops.signature.cases.ts b/src/lnurlcash/ops.signature.cases.ts index 4079533..63e3938 100644 --- a/src/lnurlcash/ops.signature.cases.ts +++ b/src/lnurlcash/ops.signature.cases.ts @@ -3,7 +3,14 @@ import {noteK1, noteSignature} from 'lnurlcash-kit' import {ensureExactAmount} from './ops' import {requiredValue} from './test-utils' -import {makeBearer, mint, noteUrl, secret, signatureKeysFor} from './ops.testHarness' +import { + allocateOutputSecrets, + makeBearer, + mint, + noteUrl, + secret, + signatureKeysFor, +} from './ops.testHarness' import {fetchNoteInfo} from 'lnurlcash-kit' // Landed split/merge outputs carry the mint's signature in their URL (sig @@ -19,6 +26,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -38,6 +46,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([first, second], 7_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -56,6 +65,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -73,6 +83,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // only the current key is trusted - the signature under the retired // key must not verify mintSignatureKeys: () => [instance.state.pubkey], @@ -93,6 +104,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // the only trusted key on file belongs to a DIFFERENT mint mintSignatureKeys: () => [stranger.state.pubkey], }) @@ -112,6 +124,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, mintSignatureKeys: signatureKeysFor(instance), }) @@ -133,6 +146,7 @@ describe('landed mutation signature verification', () => { const result = await ensureExactAmount([bearer], 5_000, { onCarve: () => undefined, + allocateOutputSecrets, // no key source at all: nothing may be trusted blindly }) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 4796a79..9b0daa8 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -1,6 +1,6 @@ import {afterEach, expect} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {sha256} from '@noble/hashes/sha2.js' import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' @@ -35,7 +35,7 @@ export const allocateOutputSecrets = ( ): Promise => { const secrets = Array.from({length: count}, () => { allocatedSecretCounter += 1 - return secret(`allocated-${allocatedSecretCounter.toString(16)}`) + return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`))) }) return Promise.resolve(secrets) } diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 2c944c6..13f9404 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -3,10 +3,15 @@ import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {hashK1, noteK1} from 'lnurlcash-kit' import {transferBetweenMints as transferBetweenMintsEngine} from './ops' +import { + OutputSecretAllocationRequiredError, + TransferMeltCheckpointRequiredError, +} from './ops' import type {CarveResult, TransferOptions} from './ops' import type {Bearer} from './types' import {requiredValue} from './test-utils' import { + allocateOutputSecrets, expectBurned, makeBearer, mint, @@ -20,7 +25,13 @@ const transferBetweenMints = ( amountMsat: number, targetMint: string, options: Omit = {}, -) => transferBetweenMintsEngine(bearers, amountMsat, targetMint, {...options, persistOutput}) +) => + transferBetweenMintsEngine(bearers, amountMsat, targetMint, { + allocateOutputSecrets, + onMeltReady: () => undefined, + ...options, + persistOutput, + }) describe('transferBetweenMints', () => { const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} @@ -59,6 +70,37 @@ describe('transferBetweenMints', () => { expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) }) + it('refuses the source melt without its durable checkpoint', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('98'), 21_000) + + await expect( + transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + allocateOutputSecrets, + persistOutput, + }), + ).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError) + + // the target stage happened (invoice exists) but the source note is + // untouched and nothing melted + expect(source.state.noteState(secret('98'))).toBe('outstanding') + }) + + it('refuses at target staging without an allocation path', async () => { + const source = await mint() + const target = await mint({testHooks: true}) + const bearer = await makeBearer(source, secret('99'), 21_000) + + await expect( + transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + onMeltReady: () => undefined, + persistOutput, + }), + ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) + expect(source.state.noteState(secret('99'))).toBe('outstanding') + }) + it('moves value to another mint at a wallet-chosen target secret', async () => { const source = await mint() const target = await mint({testHooks: true}) diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index eaa2d83..2a9ce18 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -47,11 +47,15 @@ export type { ClaimedNote, StagedMintRecovery, } from './ops/mint' -export {receiveBearer} from './ops/receiveBearer' +export {receiveBearer, ReceiveRotationStagingRequiredError} from './ops/receiveBearer' export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer' -export {payWithBearers} from './ops/pay' +export {payWithBearers, PayReturnCheckpointRequiredError} from './ops/pay' export type {PayOutcome, PayResult, PayOptions} from './ops/pay' -export {recoverPendingTransferSource, transferBetweenMints} from './ops/transfer' +export { + recoverPendingTransferSource, + transferBetweenMints, + TransferMeltCheckpointRequiredError, +} from './ops/transfer' export type { TransferClaimMaterial, TransferOptions, diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 9cfee6f..c3dbcb5 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -9,7 +9,6 @@ import { AmbiguousMutationError, - defaultRandomSecret, mergeBatches, newSecretsOf, noteK1, @@ -63,10 +62,9 @@ export class CarveCheckpointRequiredError extends Error { export type CarveOptions = FundOperationOptions & { // the caller's durable allocation path for the carve's output secrets - // (split: target + change; merge: the combined note). When present the - // secrets come from the wallet's reserved BIP-32 indices; when absent the - // carve falls back to random secrets (legacy callers only - production - // always allocates). + // (split: target + change; merge: the combined note). Required for any + // mutating carve: the secrets come from the wallet's reserved BIP-32 + // indices so a restart can never reuse or lose them. readonly allocateOutputSecrets?: OutputSecretAllocator } @@ -152,9 +150,14 @@ export const ensureExactAmount = async ( if (mergeBatches(base.callback, k1s).length !== 1) { throw new UnsupportedMultiBatchMergeError() } - const [mergeSecret] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 1) - : [(options.randomSecret ?? defaultRandomSecret)()] + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + const [mergeSecret] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(base.url), + 1, + ) const staged: NewBearer = { url: withNewK1(base.url, mergeSecret, total), callback: base.callback, @@ -189,10 +192,14 @@ export const ensureExactAmount = async ( // split path: total above target - one split request across all picked // k1s, carving the target off and leaving the change as a fresh note - const randomSecret = options.randomSecret ?? defaultRandomSecret - const [partK1, changeK1] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(base.url), 2) - : [randomSecret(), randomSecret()] + if (!options.onCarve) { + throw new CarveCheckpointRequiredError() + } + const [partK1, changeK1] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(base.url), + 2, + ) let partSignature: string | undefined let changeSignature: string | undefined const stagedNote: NewBearer = { diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 6d9c245..ea61d37 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -7,7 +7,6 @@ import { buildNoteUrl, claimMintedNote as fetchMintClaim, - defaultRandomSecret, fetchMintAddress, fetchPayRequest, grossUpForMintFee, @@ -56,10 +55,9 @@ export type PrepareMintOptions = FundOperationOptions & { readonly grossMsat: number readonly server: string }) => void | Promise - // the caller's durable allocation path for the staged note's secret. - // When present the secret comes from the wallet's reserved BIP-32 - // indices; when absent a random secret is generated (legacy callers - // only - production always allocates). + // the caller's durable allocation path for the staged note's secret - + // required: the wallet's reserved BIP-32 indices are the only source a + // restart can never reuse or lose readonly allocateOutputSecrets?: OutputSecretAllocator } @@ -108,9 +106,7 @@ export const prepareMint = async ( } const server = serverOf(payUrl) await beforePersist?.({grossMsat, server}) - const [noteSecret] = options.allocateOutputSecrets - ? await requireOutputSecrets(options.allocateOutputSecrets, server, 1) - : [(kitOptions.randomSecret ?? defaultRandomSecret)()] + const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1) const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const staged: NewBearer = { url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index 64af71c..69d6687 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -8,7 +8,6 @@ import { NoteSpentError, PendingNoteError, decodeBolt11AmountMsat, - defaultRandomSecret, fetchPayRequest, isBolt11Invoice, meltNote, @@ -49,6 +48,14 @@ export type PayResult = { rescuedNote?: NewBearer } +export class PayReturnCheckpointRequiredError extends Error { + override readonly name = 'PayReturnCheckpointRequiredError' + + constructor() { + super('A pay-return classification requires a durable onReturnReady checkpoint.') + } +} + export type PayOptions = { // required when `input` is a Lightning Address / LNURL-pay (a bolt11 // carries its own amount) @@ -64,8 +71,7 @@ export type PayOptions = { onCarve?: FundOperationOptions['onCarve'] // the caller's durable allocation path: the carve's output secrets and - // only when the return classification actually needs one - the recovery - // secret. When absent, secrets fall back to random generation (legacy - // callers only - production always allocates). + // secret. Required for any mutating carve or return classification. allocateOutputSecrets?: OutputSecretAllocator // trusted signing keys the carve's landed outputs verify against (see // FundOperationOptions.mintSignatureKeys) @@ -174,10 +180,13 @@ export const payWithBearers = async ( // The verify budget ran out. Journal the exact output secret before the // classification rotate reaches the mint, so a crash can recover either // the returned source or the rotated replacement. - const [recoverySecret] = allocateOutputSecrets - ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) - : [(kit.randomSecret ?? defaultRandomSecret)()] - await onReturnReady?.(carve, recoverySecret) + if (!onReturnReady) throw new PayReturnCheckpointRequiredError() + const [recoverySecret] = await requireOutputSecrets( + allocateOutputSecrets, + serverOf(carve.note.url), + 1, + ) + await onReturnReady(carve, recoverySecret) try { const rotated = await rotateNote(carve.note.callback, k1, { ...options, diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 3d9a9d9..9e4a110 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -4,15 +4,13 @@ // immediately, since the previous holder (and anything that logged the URL // in transit) still knows the old secret. // -// Durability contract: when the caller stages (options.stageRotation), the -// rotate's fresh secret comes from the caller's reservation path and the -// future rotated note is persisted BEFORE the rotate can land - a crash -// after the mint burned the sender's copy then never loses the only secret -// the money answers to. The staged record carries the original k1 as its -// pendingMint.sourceRecoverySecret so wallet recovery can restore the -// received note when the rotate provably never landed. A caller that -// stages MUST allocate; the legacy no-staging path (random secret, no -// checkpoint) remains only for engine tests. +// Durability contract: the rotate's fresh secret comes from the caller's +// reservation path (options.allocateOutputSecrets) and the future rotated +// note is persisted through options.stageRotation BEFORE the rotate can +// land - a crash after the mint burned the sender's copy never loses the +// only secret the money answers to. The staged record carries the original +// k1 as its pendingMint.sourceRecoverySecret so wallet recovery can restore +// the received note when the rotate provably never landed. import { AmbiguousMutationError, @@ -32,6 +30,14 @@ import {requireOutputSecrets} from './allocation' import type {FundOperationOptions} from './shared' import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' +export class ReceiveRotationStagingRequiredError extends Error { + override readonly name = 'ReceiveRotationStagingRequiredError' + + constructor() { + super('A receive rotation requires a durable stageRotation checkpoint.') + } +} + export type ReceiveBearerOptions = FundOperationOptions & { // the caller's durable allocation path for the rotation's fresh secret - // required when stageRotation is present @@ -54,8 +60,8 @@ export type ReceivedNote = { // original, demoted to unverified since its copy may be burned) // - 'discard': the rotate provably never landed - drop the staged // record; `note` is the original, still verified as received - // - 'none': no rotate was attempted or nothing was staged (unverifiable - // note, or the legacy no-staging path) + // - 'none': no rotate was attempted (the note could not be verified) - + // nothing was staged readonly stage: 'finalize' | 'keep' | 'discard' | 'none' } @@ -73,33 +79,29 @@ export const receiveBearer = async ( if (!note.verified || !note.callback) { return {note, rotated: false, stage: 'none'} } - // staging without allocation is a caller bug: the staged record would - // hold a secret no reservation can account for - const staging = options.stageRotation - const [rotationSecret] = staging - ? await requireOutputSecrets(options.allocateOutputSecrets, serverOf(note.url), 1) - : [] // the rotate burns the sender's secret at a fresh one: stage that future // note durably before the mutation can land, or a crash strands the money - if (staging && rotationSecret) { - const staged: NewBearer = { - url: withNewK1(note.url, rotationSecret, note.amount), - callback: note.callback, - amount: note.amount, - verified: false, - pendingMint: { - sourceRecoverySecret: requireNoteK1(note.url), - ...(note.mintPubkey ? {mintPubkey: note.mintPubkey} : {}), - }, - } - await staging(staged) + const staging = options.stageRotation + if (!staging) throw new ReceiveRotationStagingRequiredError() + const [rotationSecret] = await requireOutputSecrets( + options.allocateOutputSecrets, + serverOf(note.url), + 1, + ) + const staged: NewBearer = { + url: withNewK1(note.url, rotationSecret, note.amount), + callback: note.callback, + amount: note.amount, + verified: false, + pendingMint: { + sourceRecoverySecret: requireNoteK1(note.url), + ...(note.mintPubkey ? {mintPubkey: note.mintPubkey} : {}), + }, } - const rotateOptions = rotationSecret - ? {...mutationOptions, randomSecret: () => rotationSecret} - : mutationOptions + await staging(staged) + const rotateOptions = {...mutationOptions, randomSecret: () => rotationSecret} // the disposition the caller applies to its staged record const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => { - if (!staging) return 'none' if (rotated) return 'finalize' return ambiguous ? 'keep' : 'discard' } diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index 1628ae0..80936a5 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -17,7 +17,6 @@ import { NoteUnknownError, PendingNoteError, buildNoteUrl, - defaultRandomSecret, fetchNoteInfo, meltNote, newSecretsOf, @@ -111,6 +110,14 @@ export type TransferResult = { rescuedNote?: NewBearer } +export class TransferMeltCheckpointRequiredError extends Error { + override readonly name = 'TransferMeltCheckpointRequiredError' + + constructor() { + super('A source melt requires a durable onMeltReady checkpoint.') + } +} + export type TransferOptions = { // verify-poll budget - tests shrink this poll?: PollOptions @@ -124,9 +131,9 @@ export type TransferOptions = { // persists the target note before its hash reaches the invoice callback persistOutput: PrepareMintOptions['persistOutput'] // the caller's durable allocation path: the staged target note's secret, - // the source carve's outputs, and the source recovery secret. When - // absent, secrets fall back to random generation (legacy callers only - - // production always allocates). + // the source carve's outputs, and the source recovery secret. Required: + // the wallet's reserved BIP-32 indices are the only source a restart can + // never reuse or lose. allocateOutputSecrets?: OutputSecretAllocator // trusted signing keys the source carve's landed outputs verify against mintSignatureKeys?: MintSignatureKeys @@ -281,11 +288,14 @@ export const transferBetweenMints = async ( // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const k1 = requireNoteK1(carve.note.url) - const [sourceRecoverySecret] = allocateOutputSecrets - ? await requireOutputSecrets(allocateOutputSecrets, serverOf(carve.note.url), 1) - : [(kit.randomSecret ?? defaultRandomSecret)()] + if (!onMeltReady) throw new TransferMeltCheckpointRequiredError() + const [sourceRecoverySecret] = await requireOutputSecrets( + allocateOutputSecrets, + serverOf(carve.note.url), + 1, + ) if (carve.consumed.length === 0) assertFundOwner(options) - await onMeltReady?.(carve, sourceRecoverySecret) + await onMeltReady(carve, sourceRecoverySecret) assertFundOwner(options) try { await meltNote(carve.note.callback, k1, invoice, options) diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index 15eb9a0..9bf22e2 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -496,7 +496,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { ownerFence, ); } - } catch (error) { + } catch { ownerFence(); options.setAuxiliaryError( 'Pending allocation records could not be cleared. They will be retired later.', From f62e5d7ad4dafec0ca02f5404d93a03aa5e8d60e Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:21:47 +0200 Subject: [PATCH 22/29] build(deps): pin lnurlcash-kit 0.8.0 and conformance 0.7.0 --- package-lock.json | 16 ++++++------- package.json | 4 ++-- src/lnurlcash/conformance-mockmint.d.ts | 30 ------------------------- 3 files changed, 10 insertions(+), 40 deletions(-) delete mode 100644 src/lnurlcash/conformance-mockmint.d.ts diff --git a/package-lock.json b/package-lock.json index d9f2235..320bbd2 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": "0.5.0", + "lnurlcash-kit": "0.8.0", "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": "0.4.0", + "lnurlcash-conformance": "0.7.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", @@ -5961,9 +5961,9 @@ } }, "node_modules/lnurlcash-conformance": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz", - "integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz", + "integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==", "dev": true, "license": "MIT", "dependencies": { @@ -5987,9 +5987,9 @@ } }, "node_modules/lnurlcash-kit": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz", - "integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz", + "integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==", "license": "MIT", "dependencies": { "@noble/curves": "^2.3.0", diff --git a/package.json b/package.json index b6a9531..d571000 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": "0.5.0", + "lnurlcash-kit": "0.8.0", "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": "0.4.0", + "lnurlcash-conformance": "0.7.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", diff --git a/src/lnurlcash/conformance-mockmint.d.ts b/src/lnurlcash/conformance-mockmint.d.ts deleted file mode 100644 index 0637d1f..0000000 --- a/src/lnurlcash/conformance-mockmint.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// The published lnurlcash-conformance 0.4.0 hand-written declarations -// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the -// comment-naming flags are implemented and documented in the CHANGELOG -// ("The mock mint gains commentAllowed, commentRefusesMalformed and -// verifyOnUnnamedMint") but never made it into the type surface. This -// augmentation restores them so tests can drive the comment-named mint -// mode without `as any`. Report upstream; drop this file once a release -// ships the declarations. -// -// Semantics (from mock-mint/index.mjs): -// - commentAllowed: false (default) means the pay callback ignores the -// `comment` parameter entirely. A number advertises that many -// characters on the payRequest and reads `comment` on the callback: a -// bare 64-hex sha256 binds the minted note to the wallet's secret -// (k1 = secret, preimage opens nothing); anything else falls back to -// preimage keying and verify is withheld for that quote. -// - commentRefusesMalformed: non-compliant; refuses a non-hash comment -// instead of falling back. -// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the -// no-comment fallback, where the preimage it hands out IS the note. - -declare module 'lnurlcash-conformance/mock-mint' { - interface MockMintOptions { - commentAllowed?: number | false - commentRefusesMalformed?: boolean - verifyOnUnnamedMint?: boolean - } -} - -export {} From b2b5974dae6ab7ca4899e817de512f6f2c0b0e57 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:21:55 +0200 Subject: [PATCH 23/29] chore: ignore Playwright MCP artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4cb1ded..4175c94 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ yarn-error.log* # Playwright (e2e/ itself is tracked - only its artifacts are ignored) /playwright-report/ /test-results/ +/.playwright-mcp/ From 5d6e092ae4953fc04ae898c221cbf9f2796023b1 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:21:55 +0200 Subject: [PATCH 24/29] docs(lnurlcash): update the minting contract and test harness description --- src/lnurlcash/AGENTS.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/lnurlcash/AGENTS.md b/src/lnurlcash/AGENTS.md index b5d8982..e0b5cf0 100644 --- a/src/lnurlcash/AGENTS.md +++ b/src/lnurlcash/AGENTS.md @@ -6,14 +6,11 @@ lnurlcash-kit directly. ## FUND-CRITICAL INVARIANTS (non-negotiable) -- Rotate on every receive, and immediately after claiming a fresh mint - from an UNNAMED mint (one that keys the note by the payment preimage). - A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint - ≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the - quote; that secret never rode an invoice, so the claim needs no rotate. - The claim poll itself is the placement proof: a mint that took the hash - but credited the preimage is caught there and rescued through the - quote's verify URL when one is served. +- Rotate every externally received bearer. Minted notes are different: + current LUD-25 requires `commentAllowed >= 64`, so the wallet durably + stages its chosen secret before requesting the invoice and claims at + that secret without rotating. `mintToHash` is only an additive receipt + extension, never a capability fallback. - A note's declared amount is a claim; the service's `maxWithdrawable` is authoritative. - A melt's "OK" only means the payment is in flight — the verify URL (or @@ -68,7 +65,6 @@ test-utils.ts # mock mint harness used by *.test.ts ## TESTS `npx vitest run` — node environment, `*.test.ts` next to the modules. -Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts` -(`conformance-mockmint.d.ts` augments its stale published declarations -with the comment-naming flags). The mock's `commentAllowed` option models -the named-mint generation; default options model the old one. +The adversarial mock mint from lnurlcash-conformance is wrapped by +`ops.testHarness.ts`; its default contract is mandatory comment-bound +minting. From 1beb474012b0e73c783708cec53f2ac523c75f07 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:22:02 +0200 Subject: [PATCH 25/29] test(e2e): cover signed BIP-32 wallet flows --- e2e/helpers/MintMocker.ts | 308 +++++++++++++++++++++++++--- e2e/helpers/wallet.ts | 35 ++++ e2e/specs/backup-security.spec.ts | 138 ++++++++++--- e2e/specs/manage-mints.spec.ts | 5 +- e2e/specs/move-funds.spec.ts | 36 +--- e2e/specs/nwc.spec.ts | 127 +++++++++++- e2e/specs/receive.spec.ts | 23 ++- e2e/specs/send.spec.ts | 114 ++++++++++ e2e/specs/trusted-mint-tabs.spec.ts | 2 +- e2e/specs/wallet-lifecycle.spec.ts | 299 ++++++++------------------- 10 files changed, 777 insertions(+), 310 deletions(-) create mode 100644 e2e/specs/send.spec.ts 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([]); }); }); From 3a7aa682c9239e42e0011bef5e0480afd5c02386 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:46:47 +0200 Subject: [PATCH 26/29] test(e2e): warm the dev server before the suite runs --- e2e/global-setup.ts | 39 +++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 1 + 2 files changed, 40 insertions(+) create mode 100644 e2e/global-setup.ts diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..f1fd4b1 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,39 @@ +import { chromium } from '@playwright/test'; +import { execSync } from 'child_process'; + +// Warms the freshly booted dev server before any test runs. `quasar dev` +// answers HTTP the moment its port is up, but vite still transforms the +// module graph on the first page load - a window in which the first test's +// multi-step onboarding flow can race lazy compilation (observed as the +// create flow never reaching /#/). One full load plus a reload completes +// that work before the suite starts. Uses the same system-chromium +// resolution as the main config (CI falls back to the bundled browser). +const findSystemChromium = (): string | undefined => { + try { + return execSync('command -v chromium').toString().trim() || undefined; + } catch { + return undefined; + } +}; + +const globalSetup = async (): Promise => { + const executablePath = process.env.CHROMIUM_PATH || findSystemChromium(); + const browser = await chromium.launch({ + ...(executablePath ? { executablePath } : {}), + }); + try { + const page = await (await browser.newContext({ baseURL: 'http://localhost:9333' })).newPage(); + await page.goto('/'); + await page + .getByRole('button', { name: 'Get started' }) + .waitFor({ state: 'visible', timeout: 60_000 }); + await page.reload(); + await page + .getByRole('button', { name: 'Get started' }) + .waitFor({ state: 'visible', timeout: 60_000 }); + } finally { + await browser.close(); + } +}; + +export default globalSetup; diff --git a/playwright.config.ts b/playwright.config.ts index ebc968a..35ce509 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => { export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, From 454a9db1d58245c90a31e1e2cddad223d042b731 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 15:56:23 +0200 Subject: [PATCH 27/29] test(e2e): clean the e2e program's diagnostics --- e2e/global-setup.ts | 1 + e2e/helpers/MintMocker.ts | 10 +++------- e2e/specs/move-funds.spec.ts | 2 +- e2e/specs/nwc.spec.ts | 4 ++-- e2e/specs/send.spec.ts | 1 - e2e/specs/trusted-mint-tabs.spec.ts | 8 ++++++++ 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index f1fd4b1..d88f071 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,3 +1,4 @@ +/// import { chromium } from '@playwright/test'; import { execSync } from 'child_process'; diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index 53888f6..0594393 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -1,7 +1,7 @@ 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 { bytesToHex } from '@noble/hashes/utils.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { utf8ToBytes } from '@noble/hashes/utils.js'; @@ -312,11 +312,7 @@ 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, - mintPubkey: string, - ): Promise { + private async mockMintAddress(origin: string, mintPubkey: string): Promise { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), async (route: Route) => { @@ -344,7 +340,7 @@ export class MintMocker { const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin); let stagedHash: string | null = null; let settled = false; - await this.mockMintAddress(options, origin, mintPubkey); + await this.mockMintAddress(origin, mintPubkey); await this.mockMutationEndpoint(origin); await this.page.route( new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), diff --git a/e2e/specs/move-funds.spec.ts b/e2e/specs/move-funds.spec.ts index ebd7530..0adcb52 100644 --- a/e2e/specs/move-funds.spec.ts +++ b/e2e/specs/move-funds.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '../fixtures'; import type { Page } from '@playwright/test'; import { MINT2_ORIGIN } from '../helpers/MintMocker'; -import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet'; +import { fundMockMintWallet } from '../helpers/wallet'; const AMOUNT_MSAT = 50_000; // 50 sats // 64 hex chars - a valid payment receipt, distinct from the claimed note secret diff --git a/e2e/specs/nwc.spec.ts b/e2e/specs/nwc.spec.ts index f958d29..789c2ca 100644 --- a/e2e/specs/nwc.spec.ts +++ b/e2e/specs/nwc.spec.ts @@ -212,7 +212,7 @@ test.describe('NWC settings page', () => { // 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( + const content = nip04Encrypt( clientSecret, walletServicePubkey, JSON.stringify({ method: 'pay_invoice', params: { invoice } }), @@ -254,7 +254,7 @@ test.describe('NWC settings page', () => { ); if (!responseEvent) throw new Error('the service published no response'); const response: unknown = JSON.parse( - await nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content), + nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content), ); if (typeof response !== 'object' || response === null) { throw new Error('the service response is not an object'); diff --git a/e2e/specs/send.spec.ts b/e2e/specs/send.spec.ts index 4710dbd..4f28698 100644 --- a/e2e/specs/send.spec.ts +++ b/e2e/specs/send.spec.ts @@ -7,7 +7,6 @@ import { noteSignature, verifyNoteSignature, } from 'lnurlcash-kit'; -import { MINT_ORIGIN } from '../helpers/MintMocker'; import { fundMockMintWallet } from '../helpers/wallet'; const FUNDING_MSAT = 50_000; // 50 sats diff --git a/e2e/specs/trusted-mint-tabs.spec.ts b/e2e/specs/trusted-mint-tabs.spec.ts index d9a60de..444cabd 100644 --- a/e2e/specs/trusted-mint-tabs.spec.ts +++ b/e2e/specs/trusted-mint-tabs.spec.ts @@ -3,6 +3,14 @@ import type { Page, TestInfo } from '@playwright/test'; import { test, expect } from '../fixtures'; import { createFreshWallet } from '../helpers/wallet'; +// the dev-only lifecycle hook (boot/wallet.ts) used to drive the forget +// transition, which has no UI surface +declare global { + interface Window { + __sattleWalletTest: { state: () => string; forget: () => Promise }; + } +} + const MINT_KEY_A = `02${'aa'.repeat(32)}`; const MINT_KEY_B = `03${'bb'.repeat(32)}`; const MINT_KEY_C = `02${'cc'.repeat(32)}`; From 4caa07505215e7bde7b67206bc58ac45bd443fc6 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 18:30:35 +0200 Subject: [PATCH 28/29] Formatting --- CHANGELOG.md | 156 +++-- public/.well-known/assetlinks.json | 4 +- src-pwa/register-sw.ts | 4 +- src-pwa/sw/custom-sw.ts | 20 +- src/capabilities/biometricUnlock.test.ts | 6 +- src/capabilities/biometricUnlock.ts | 4 +- src/components/QrScanner.vue | 13 +- src/components/receive/ReceiveDialog.vue | 8 +- src/components/send/SendDialog.vue | 7 +- src/composables/useMoveFundsPage.ts | 6 +- src/composables/usePayInvoiceDialog.ts | 3 +- src/composables/walletCarveCommit.test.ts | 4 +- src/lnurlcash/fees.ts | 52 +- src/lnurlcash/jsonParsing.ts | 24 +- .../keys.material-commitment.cases.ts | 183 +++--- src/lnurlcash/keys.material-storage.cases.ts | 180 ++--- src/lnurlcash/keys.material.cases.ts | 81 ++- src/lnurlcash/keys.test.ts | 332 +++++----- src/lnurlcash/keys.ts | 200 +++--- src/lnurlcash/keys.version.cases.ts | 94 +-- src/lnurlcash/mintContract.test.ts | 274 ++++---- src/lnurlcash/mintContract.v08.cases.ts | 28 +- src/lnurlcash/nostr/events.ts | 153 ++--- src/lnurlcash/nostr/publisher.ts | 68 +- src/lnurlcash/nostr/sync.ts | 110 ++-- src/lnurlcash/nostr/transport.ts | 24 +- src/lnurlcash/nostrBackup.codec.cases.ts | 174 ++--- src/lnurlcash/nostrBackup.keys.cases.ts | 78 +-- src/lnurlcash/nostrBackup.publisher.cases.ts | 211 +++--- src/lnurlcash/nostrBackup.restore.cases.ts | 276 ++++---- src/lnurlcash/nostrBackup.test.ts | 10 +- src/lnurlcash/nostrBackup.transport.cases.ts | 136 ++-- src/lnurlcash/nostrBackup.ts | 14 +- src/lnurlcash/nwc.connection.cases.ts | 96 +-- src/lnurlcash/nwc.fencing.cases.ts | 52 +- src/lnurlcash/nwc.invoice-a.cases.ts | 224 +++---- src/lnurlcash/nwc.invoice-b.cases.ts | 334 +++++----- src/lnurlcash/nwc.ownership.cases.ts | 128 ++-- src/lnurlcash/nwc.pay-a.cases.ts | 238 +++---- src/lnurlcash/nwc.pay-b.cases.ts | 184 +++--- src/lnurlcash/nwc.storage.cases.ts | 136 ++-- src/lnurlcash/nwc.test.ts | 18 +- src/lnurlcash/nwc.testProtocol.ts | 160 ++--- src/lnurlcash/nwc.testService.ts | 273 ++++---- src/lnurlcash/nwc.ts | 32 +- src/lnurlcash/nwc.validation.cases.ts | 164 ++--- src/lnurlcash/nwc/budget.ts | 20 +- src/lnurlcash/nwc/connection.ts | 98 +-- src/lnurlcash/nwc/context.ts | 114 ++-- src/lnurlcash/nwc/dispatch.ts | 132 ++-- src/lnurlcash/nwc/invoices.ts | 134 ++-- src/lnurlcash/nwc/pay.ts | 124 ++-- src/lnurlcash/nwc/protocol.ts | 105 +-- src/lnurlcash/nwc/service.ts | 194 +++--- src/lnurlcash/nwc/transport.ts | 31 +- src/lnurlcash/ops.carve-crash.cases.ts | 198 +++--- src/lnurlcash/ops.carve.cases.ts | 428 ++++++------ src/lnurlcash/ops.mint-named.cases.ts | 116 ++-- src/lnurlcash/ops.mint-receive.cases.ts | 200 +++--- src/lnurlcash/ops.pay.cases.ts | 244 +++---- src/lnurlcash/ops.retry-refusal.cases.ts | 104 +-- src/lnurlcash/ops.signature.cases.ts | 138 ++-- src/lnurlcash/ops.test.ts | 16 +- src/lnurlcash/ops.testHarness.ts | 112 ++-- src/lnurlcash/ops.transfer.cases.ts | 405 ++++++------ src/lnurlcash/ops.ts | 33 +- src/lnurlcash/ops/allocation.ts | 16 +- src/lnurlcash/ops/carve.ts | 199 +++--- src/lnurlcash/ops/carveRecovery.ts | 40 +- src/lnurlcash/ops/mint.ts | 204 +++--- src/lnurlcash/ops/pay.ts | 169 ++--- src/lnurlcash/ops/receiveBearer.ts | 90 +-- src/lnurlcash/ops/shared.ts | 152 ++--- src/lnurlcash/ops/transfer.ts | 257 ++++---- src/lnurlcash/passkeyOwnership.ts | 16 +- src/lnurlcash/passkeyRewrap.ts | 62 +- src/lnurlcash/passkeyWrap.ts | 60 +- src/lnurlcash/passkeys.commitment.cases.ts | 145 ++-- src/lnurlcash/passkeys.crypto.cases.ts | 160 ++--- src/lnurlcash/passkeys.material.cases.ts | 315 ++++----- src/lnurlcash/passkeys.multiple.cases.ts | 158 ++--- src/lnurlcash/passkeys.ownership-a.cases.ts | 228 +++---- src/lnurlcash/passkeys.ownership-b.cases.ts | 202 +++--- src/lnurlcash/passkeys.registration.cases.ts | 248 +++---- src/lnurlcash/passkeys.reverify.cases.ts | 193 +++--- src/lnurlcash/passkeys.rewrap.cases.ts | 144 ++-- src/lnurlcash/passkeys.storage.cases.ts | 143 ++-- src/lnurlcash/passkeys.support.cases.ts | 142 ++-- src/lnurlcash/passkeys.test.ts | 24 +- src/lnurlcash/passkeys.ts | 228 +++---- src/lnurlcash/passkeys.version.cases.ts | 100 +-- src/lnurlcash/passwordWrap.ts | 44 +- src/lnurlcash/receive.ts | 40 +- src/lnurlcash/storage.backup.test.ts | 202 +++--- src/lnurlcash/storage.changeset.cases.ts | 236 +++---- src/lnurlcash/storage.test.ts | 270 ++++---- src/lnurlcash/storage.ts | 18 +- src/lnurlcash/storage/activityLog.ts | 86 +-- src/lnurlcash/storage/backup.ts | 164 ++--- .../storage/bearers.baseline.cases.ts | 42 +- src/lnurlcash/storage/bearers.test.ts | 620 +++++++++--------- src/lnurlcash/storage/bearers.ts | 364 +++++----- src/lnurlcash/storage/currentOwner.ts | 16 +- src/lnurlcash/storage/nwcConnections.ts | 201 +++--- src/lnurlcash/storage/nwcEnabled.ts | 68 +- src/lnurlcash/storage/passkeySlots.ts | 192 +++--- src/lnurlcash/storage/settings.ts | 34 +- src/lnurlcash/storage/storedSecret.ts | 178 ++--- src/lnurlcash/storage/walletMaterial.ts | 56 +- src/lnurlcash/storage/walletOwner.ts | 12 +- src/lnurlcash/storage/walletOwnerEvents.ts | 18 +- src/lnurlcash/storageLock.test.ts | 82 +-- src/lnurlcash/storageLock.ts | 22 +- src/lnurlcash/test-utils.ts | 46 +- src/lnurlcash/trustedMintMerge.ts | 28 +- src/lnurlcash/trustedMintTransitions.ts | 132 ++-- .../trustedMints.storageEvents.test.ts | 160 ++--- src/lnurlcash/trustedMints.test.ts | 271 ++++---- .../trustedMints.transactions.test.ts | 310 ++++----- src/lnurlcash/trustedMints.ts | 142 ++-- src/lnurlcash/trustedMints.visibility.test.ts | 154 ++--- src/lnurlcash/trustedMintsCommitStore.ts | 82 +-- src/lnurlcash/trustedMintsEvents.ts | 28 +- src/lnurlcash/trustedMintsRegistry.ts | 58 +- src/lnurlcash/trustedMintsRepository.ts | 192 +++--- src/lnurlcash/types.ts | 60 +- src/lnurlcash/units.ts | 11 +- src/lnurlcash/walletMaterialStorage.ts | 148 +++-- src/stores/wallet.lifecycle.teardown.cases.ts | 8 +- src/stores/wallet.lifecycle.testHarness.ts | 5 +- src/stores/wallet.ts | 5 +- src/stores/walletFunds.fencing.test.ts | 6 +- src/stores/walletFunds.ts | 13 +- 133 files changed, 8260 insertions(+), 8181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3266d3a..79a9886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,108 +2,102 @@ ## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31) - ### Features -* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) -* **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb)) -* **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2)) -* **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf)) -* **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed)) -* **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c)) -* support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5)) - +- **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) +- **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb)) +- **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2)) +- **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf)) +- **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed)) +- **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c)) +- support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5)) ### Bug Fixes -* commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62)) -* commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306)) -* **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927)) -* **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86)) -* **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df)) -* replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7)) +- commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62)) +- commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306)) +- **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927)) +- **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86)) +- **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df)) +- replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7)) ## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24) - ### Features -* activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) -* add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331)) -* apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1)) -* apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37)) -* await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6)) -* await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e)) -* bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a)) -* commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4)) -* converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c)) -* create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30)) -* drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904)) -* expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755)) -* fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544)) -* harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039)) -* invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) -* preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341)) -* restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635)) -* scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf)) -* scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb)) -* scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab)) -* serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4)) -* serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) -* validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2)) - +- activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) +- add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331)) +- apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1)) +- apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37)) +- await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6)) +- await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e)) +- bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a)) +- commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4)) +- converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c)) +- create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30)) +- drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904)) +- expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755)) +- fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544)) +- harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039)) +- invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) +- preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341)) +- restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635)) +- scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf)) +- scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb)) +- scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab)) +- serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4)) +- serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) +- validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2)) ### Bug Fixes -* await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) -* await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205)) -* clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329)) -* commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354)) -* consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6)) -* drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18)) -* fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5)) -* fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a)) -* fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60)) -* fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6)) -* fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c)) -* hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382)) -* mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff)) -* normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a)) -* preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155)) -* publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c)) -* surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa)) -* validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19)) -* validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) -* **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8)) +- await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) +- await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205)) +- clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329)) +- commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354)) +- consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6)) +- drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18)) +- fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5)) +- fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a)) +- fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60)) +- fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6)) +- fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c)) +- hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382)) +- mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff)) +- normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a)) +- preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155)) +- publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c)) +- surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa)) +- validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19)) +- validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) +- **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8)) ## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20) - ### Features -* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) -* capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece)) -* capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c)) -* deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd)) -* inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6)) -* manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607)) -* native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7)) -* nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f)) -* nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0)) -* nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a)) -* pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2)) -* reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d)) -* sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0)) -* send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f)) -* wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898)) -* wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178)) -* webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815)) - +- backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) +- capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece)) +- capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c)) +- deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd)) +- inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6)) +- manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607)) +- native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7)) +- nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f)) +- nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0)) +- nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a)) +- pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2)) +- reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d)) +- sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0)) +- send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f)) +- wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898)) +- wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178)) +- webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815)) ### Bug Fixes -* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) -* discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592)) +- bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) +- discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592)) ## Changelog diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index 3d2c304..ec3737f 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -4,9 +4,7 @@ "target": { "namespace": "android_app", "package_name": "eu.protom.sattle", - "sha256_cert_fingerprints": [ - "REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT" - ] + "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"] } } ] diff --git a/src-pwa/register-sw.ts b/src-pwa/register-sw.ts index eeffb6b..6c1882b 100644 --- a/src-pwa/register-sw.ts +++ b/src-pwa/register-sw.ts @@ -1,4 +1,4 @@ -import { register } from "register-service-worker"; +import { register } from 'register-service-worker'; // The ready(), registered(), cached(), updatefound() and updated() // events passes a ServiceWorkerRegistration instance in their arguments. @@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, { error(/* err */) { // console.error('Error during service worker registration:', err) - } + }, }); diff --git a/src-pwa/sw/custom-sw.ts b/src-pwa/sw/custom-sw.ts index 3832060..171d7d2 100644 --- a/src-pwa/sw/custom-sw.ts +++ b/src-pwa/sw/custom-sw.ts @@ -4,13 +4,13 @@ * quasar.config file > pwa > workboxMode is set to "InjectManifest" */ -import { clientsClaim } from "workbox-core"; -import { NavigationRoute, registerRoute } from "workbox-routing"; +import { clientsClaim } from 'workbox-core'; +import { NavigationRoute, registerRoute } from 'workbox-routing'; import { cleanupOutdatedCaches, createHandlerBoundToURL, - precacheAndRoute -} from "workbox-precaching"; + precacheAndRoute, +} from 'workbox-precaching'; declare const self: ServiceWorkerGlobalScope & typeof globalThis; @@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) { // Non-SSR/SSG fallbacks to index.html // Production SSR/SSG fallbacks to offline.html (except for dev) registerRoute( - new NavigationRoute( - createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), - { - denylist: [ - new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), - /workbox-(.)*\.js$/ - ] - } - ) + new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), { + denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/], + }), ); } diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index b49fcc1..fd880d5 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -205,11 +205,7 @@ describe('biometric wallet material', () => { ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { // Given an authenticated non-canonical payload bound to the saved owner await saveWalletMaterial(MATERIAL); - await storePayload( - payload, - walletMaterialOwnerId(MATERIAL), - walletMaterialHash(MATERIAL), - ); + await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL)); const before = persistedState(); // When biometric unlock decrypts the payload diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index 71d2e9b..ae9239f 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -186,9 +186,7 @@ const authenticateOrThrow = async (reason: string): Promise => { } }; -export const enableBiometricUnlock = async ( - material: WalletMaterialV2, -): Promise => { +export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise => { if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); const serialized = serializeWalletMaterial(material); const canonicalMaterial = parseWalletMaterial(serialized); diff --git a/src/components/QrScanner.vue b/src/components/QrScanner.vue index 4f35253..db31c2d 100644 --- a/src/components/QrScanner.vue +++ b/src/components/QrScanner.vue @@ -19,19 +19,14 @@ onMounted(async () => { return; } if (!videoEl.value) return; - scanner = new QrScanner( - videoEl.value, - (result) => emit('decode', result.data), - { highlightScanRegion: true }, - ); + scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), { + highlightScanRegion: true, + }); await scanner.start(); starting.value = false; } catch (err) { starting.value = false; - emit( - 'error', - err instanceof Error ? err.message : 'Camera access failed.', - ); + emit('error', err instanceof Error ? err.message : 'Camera access failed.'); } }); diff --git a/src/components/receive/ReceiveDialog.vue b/src/components/receive/ReceiveDialog.vue index 1d718ad..5010425 100644 --- a/src/components/receive/ReceiveDialog.vue +++ b/src/components/receive/ReceiveDialog.vue @@ -50,9 +50,7 @@
Bearer note
-
- Paste or scan a note someone sent you -
+
Paste or scan a note someone sent you
@@ -108,7 +106,9 @@ const onReceived = () => { border-radius: 12px; padding: 14px 16px; cursor: pointer; - transition: background-color 0.15s ease, border-color 0.15s ease; + transition: + background-color 0.15s ease, + border-color 0.15s ease; } .action-row:hover, diff --git a/src/components/send/SendDialog.vue b/src/components/send/SendDialog.vue index e4d75f4..02d2316 100644 --- a/src/components/send/SendDialog.vue +++ b/src/components/send/SendDialog.vue @@ -34,7 +34,12 @@ const onSent = () => emit('sent');