fix(wallet): activate complete wallet material

This commit is contained in:
2026-09-05 09:17:12 +02:00
parent 641608eff0
commit e94549a16e
33 changed files with 906 additions and 324 deletions
+6 -3
View File
@@ -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();
+1 -8
View File
@@ -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<void> => {
};
export const enableBiometricUnlock = async (
material: WalletMaterialV2 | Uint8Array,
material: WalletMaterialV2,
): Promise<void> => {
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<WalletMateri
return material;
};
export const unlockWithBiometrics = async (): Promise<Uint8Array> =>
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<void> => {
+5 -2
View File
@@ -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<number> => {
const ownerId = savedKeyOwnerId()
const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.')
}
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
+9 -9
View File
@@ -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)
})
+20 -19
View File
@@ -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<Record<string, unknown>>): 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)
})
})
+8 -8
View File
@@ -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),
+29 -24
View File
@@ -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()
})
})
+6 -6
View File
@@ -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(
+1 -2
View File
@@ -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',
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey,
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
+5 -25
View File
@@ -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<PasskeySlot> => {
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<Uint8Array> =>
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.
+25 -30
View File
@@ -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<string, unknown>[]): 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<string, unknown> = {...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
+9 -1
View File
@@ -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 => {
+2 -2
View File
@@ -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.')
}
+3 -2
View File
@@ -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<void> =>
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
const ownerId = linkingPubKeyHex(linkingKey)
if (savedKeyOwnerId() !== ownerId) {
if (!savedKeyOwnerAllows(ownerId)) {
throw new Error('Legacy trusted-mint migration requires a proven owner.')
}
return adoptLegacyStoredTrustedMints(ownerId)
+2 -2
View File
@@ -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.');
+9 -6
View File
@@ -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<typeof createPinia>;
@@ -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();
@@ -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());
@@ -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 }),
);
});
});
@@ -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(
@@ -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,
},
]),
);
@@ -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);
});
});
@@ -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.');
});
});
+15 -13
View File
@@ -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);
});
});
+2
View File
@@ -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';
+117 -11
View File
@@ -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<typeof PasskeysExports>();
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<void> => {
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<void> => {
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<void>;
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<unknown> =>
new Promise((resolve, reject) => {
requests.push({ callback, resolve, reject });
}),
},
releaseNext: async (): Promise<void> => {
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<void> => {
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,
+2
View File
@@ -2,6 +2,8 @@ import { vi } from 'vitest';
export const lifecycleMocks = {
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
unlockWalletMaterialWithBiometrics: vi.fn(),
unlockWalletMaterialWithPasskey: vi.fn(),
restoreFromNostr: vi.fn(),
startService: vi.fn(),
};
+94 -44
View File
@@ -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<WalletState>(savedKeyExists() ? 'locked' : 'none');
const state = ref<WalletState>(savedWalletMaterialExists() ? 'locked' : 'none');
const pubkey = ref<string | null>(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<void> =>
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<void> => {
// 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<void> => {
auxiliaryError.value = '';
await migrateProvenLegacyOwner(linkingKey, ownerWasMissing);
const key = await deriveBearerAesKey(linkingKey);
const ownerId = walletMaterialOwnerId(material);
observeOwnerChanges();
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);
const ownerId = ownerOf(linkingKey);
await restoreHeldMintTrust(loaded, ownerId, (message) => {
auxiliaryError.value = message;
});
if (savedWalletMaterialOwnerId() !== ownerId) {
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
}
aesKey = key;
currentLinkingKey = linkingKey;
currentMaterial = material;
lifecycleToken += 1;
observeOwnerChanges();
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<void> => {
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<void> => {
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<RestoreResult> =>
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<void> =>
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,
};
});
+32 -18
View File
@@ -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 = <T>(transition: () => Promise<T>) => Promise<T>;
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>;
type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise<void>;
type Activate = (material: WalletMaterialV2, ownerWasMissing: boolean) => Promise<void>;
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<void> => {
if (!linkingKey) throw new Error('No wallet on this device.');
await activate(linkingKey, savedKeyOwnerId() === null);
const activateSavedMaterial = async (material: WalletMaterialV2 | null): Promise<void> => {
if (!material) throw new Error('No wallet on this device.');
await activate(material, savedWalletMaterialOwnerId() === null);
};
const create = (password?: string): Promise<string> =>
runTransition(async () => {
@@ -39,23 +46,30 @@ export const createWalletAccess = ({
runTransition(() => installSeed(seedPhrase, password));
const unlock = (password?: string): Promise<void> =>
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<void> =>
runTransition(async () => {
await activate(await unlockWithPasskey(), false);
await activate(await unlockWalletMaterialWithPasskey(), false);
});
const unlockWithBiometric = (): Promise<void> =>
runTransition(async () => {
await activateSavedKey(await unlockWithBiometrics());
await activateSavedMaterial(await unlockWalletMaterialWithBiometrics());
});
const init = (): Promise<void> =>
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,
+70 -14
View File
@@ -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<void> => {
};
export const migrateProvenLegacyOwner = async (
linkingKey: Uint8Array,
material: WalletMaterialV2,
ownerWasMissing: boolean,
): Promise<void> => {
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<void> => {
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<void> => {
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<void>;
readonly activate: (linkingKey: Uint8Array) => Promise<void>;
readonly activate: (material: WalletMaterialV2) => Promise<void>;
};
export type SeedInstaller = (
seedPhrase: string,
password?: string,
restore?: (linkingKey: Uint8Array) => Promise<void>,
restore?: (material: WalletMaterialV2) => Promise<void>,
) => Promise<void>;
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);
};
+47 -15
View File
@@ -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<void>) => 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();
});
});
+6 -3
View File
@@ -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<void>) => Promise<void>;
}>;
// 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;
}