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; }