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 lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away, // wallet store at app start - a plaintext-stored material record unlocks
// a password-encrypted one lands on 'locked' for the unlock screen, and no // straight away, a password-encrypted one lands on 'locked' for the unlock
// key at all lands on 'none' for onboarding. // 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 () => { export default defineBoot(async () => {
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.init(); await wallet.init();
+1 -8
View File
@@ -47,7 +47,6 @@ import {
savedWalletMaterialOwnerId, savedWalletMaterialOwnerId,
serializeWalletMaterial, serializeWalletMaterial,
walletMaterialHash, walletMaterialHash,
walletMaterialLinkingKey,
type WalletMaterialV2, type WalletMaterialV2,
} from '@/lnurlcash/keys'; } from '@/lnurlcash/keys';
import { isJsonObject } from '@/lnurlcash/jsonParsing'; import { isJsonObject } from '@/lnurlcash/jsonParsing';
@@ -188,12 +187,9 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
}; };
export const enableBiometricUnlock = async ( export const enableBiometricUnlock = async (
material: WalletMaterialV2 | Uint8Array, material: WalletMaterialV2,
): Promise<void> => { ): Promise<void> => {
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); 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 serialized = serializeWalletMaterial(material);
const canonicalMaterial = parseWalletMaterial(serialized); const canonicalMaterial = parseWalletMaterial(serialized);
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.'); if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
@@ -306,9 +302,6 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMateri
return material; return material;
}; };
export const unlockWithBiometrics = async (): Promise<Uint8Array> =>
walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics());
// Native deletion goes first so a failure leaves the complete enrollment // Native deletion goes first so a failure leaves the complete enrollment
// available for the locked lifecycle to retry safely. // available for the locked lifecycle to retry safely.
export const disableBiometricUnlock = async (): Promise<void> => { 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 // Legacy passkey slots may be adopted only after another unlock path has
// proven and stamped the saved wallet owner. The linking key is checked // proven and stamped the saved wallet owner. The linking key is checked
// against that marker before markerless slots are changed under the lock. // 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 {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
import {withStorageLock} from './storageLock' import {withStorageLock} from './storageLock'
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => { export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
const ownerId = savedKeyOwnerId() const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.') throw new Error('Legacy passkey migration requires a proven owner.')
} }
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
+9 -9
View File
@@ -20,7 +20,7 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
@@ -149,11 +149,11 @@ describe('multiple passkeys', () => {
it('keeps slots independent: each passkey unlocks the same key', async () => { it('keeps slots independent: each passkey unlocks the same key', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
name: 'laptop', name: 'laptop',
}) })
const phoneSlot = await registerPasskey(LINKING_KEY, { const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone, credentials: phone,
name: 'phone', name: 'phone',
}) })
@@ -162,25 +162,25 @@ describe('multiple passkeys', () => {
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial) expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
}) })
it('removePasskey drops exactly one slot and leaves the rest working', async () => { it('removePasskey drops exactly one slot and leaves the rest working', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
}) })
await registerPasskey(LINKING_KEY, {credentials: phone}) await registerPasskey(MATERIAL, {credentials: phone})
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true) await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
expect(readPasskeySlots()).toHaveLength(1) expect(readPasskeySlots()).toHaveLength(1)
// the removed passkey no longer matches any offered credential // 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 // 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 // removing again is a no-op
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false) await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
}) })
+20 -19
View File
@@ -20,21 +20,22 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedWalletMaterial,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial, deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord, encryptRecord,
ensureSavedWalletMaterialOwner,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedWalletMaterialOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
walletMaterialLinkingKey,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -133,10 +134,10 @@ const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
} }
const removeSavedOwnerMarker = (): 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.ownerId
delete stored.version delete stored.version
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored))
} }
beforeEach(async () => { beforeEach(async () => {
@@ -151,7 +152,7 @@ describe('slot ownership', () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
// When its linking key registers a passkey // 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 // Then the slot carries that same canonical owner
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) 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 () => { it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
// Given one valid current-owner slot plus copies with untrusted owners // Given one valid current-owner slot plus copies with untrusted owners
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
const current = await registerPasskey(LINKING_KEY, {credentials: auth}) const current = await registerPasskey(MATERIAL, {credentials: auth})
const foreign = { const foreign = {
...current, ...current,
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
@@ -191,9 +192,9 @@ describe('slot ownership', () => {
}) })
it('does not offer markerless slots for passkey-first unlock', async () => { 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() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots() const legacy = readRawSlots()
delete legacy[0]?.ownerId delete legacy[0]?.ownerId
delete legacy[0]?.version delete legacy[0]?.version
@@ -201,7 +202,7 @@ describe('slot ownership', () => {
removeSavedOwnerMarker() removeSavedOwnerMarker()
// When passkey unlock is attempted before another proof path // 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 // Then it fails before asking the authenticator
await expect(attempt).rejects.toThrow('No passkeys') 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 () => { it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
// Given markerless residue from the old wallet // Given markerless residue from the old wallet
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots() const legacy = readRawSlots()
delete legacy[0]?.ownerId delete legacy[0]?.ownerId
delete legacy[0]?.version delete legacy[0]?.version
@@ -231,24 +232,24 @@ describe('slot ownership', () => {
it('adopts legacy slots only after the saved wallet owner is proven', async () => { it('adopts legacy slots only after the saved wallet owner is proven', async () => {
// Given a legacy encrypted wallet and its markerless passkey slot // Given a legacy encrypted wallet and its markerless passkey slot
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const legacy = readRawSlots() const legacy = readRawSlots()
delete legacy[0]?.ownerId delete legacy[0]?.ownerId
delete legacy[0]?.version delete legacy[0]?.version
writeRawSlots(legacy) writeRawSlots(legacy)
await saveLinkingKey(LINKING_KEY, 'correct horse') await saveWalletMaterial(MATERIAL, 'correct horse')
removeSavedOwnerMarker() removeSavedOwnerMarker()
// When migration is attempted before and then after password proof // When migration is attempted before and then after password proof
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
const provenKey = await decryptSavedLinkingKey('correct horse') const provenMaterial = await decryptSavedWalletMaterial('correct horse')
ensureSavedKeyOwner(provenKey) ensureSavedWalletMaterialOwner(provenMaterial)
await migrateLegacyPasskeySlots(provenKey) await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial))
// Then the same slot is stamped once for that proven owner and unlocks // 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()).toHaveLength(1)
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) 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, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
@@ -151,7 +151,7 @@ describe('slot ownership (continued)', () => {
// Given a current-owner slot whose authenticated wrap was replaced with // Given a current-owner slot whose authenticated wrap was replaced with
// a valid wrap of another wallet key // a valid wrap of another wallet key
const auth = new FakeAuthenticator() 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, { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth, credentials: auth,
}) })
@@ -159,7 +159,7 @@ describe('slot ownership (continued)', () => {
writeRawSlots([{...slot, ...foreignWrap}]) writeRawSlots([{...slot, ...foreignWrap}])
// When the authenticator successfully unwraps that foreign key // 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 // Then owner validation rejects it before activation can receive it
await expect(attempt).rejects.toThrow('different wallet') 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 () => { it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
// Given an authenticator that replaces the saved wallet before returning // Given an authenticator that replaces the saved wallet before returning
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const stale: PasskeyCredentials = { const stale: PasskeyCredentials = {
create: auth.create, create: auth.create,
get: async (options) => { get: async (options) => {
@@ -180,7 +180,7 @@ describe('slot ownership (continued)', () => {
} }
// When the old wallet's ceremony completes after replacement // 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 // Then the old linking key is never returned for activation
await expect(attempt).rejects.toThrow('different wallet') 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 () => { it('does not adopt a slot carrying a malformed owner marker', async () => {
// Given an otherwise valid slot whose owner claim is malformed // Given an otherwise valid slot whose owner claim is malformed
const auth = new FakeAuthenticator() 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'} const malformed = {...slot, ownerId: 'not-an-owner'}
writeRawSlots([malformed]) writeRawSlots([malformed])
@@ -204,7 +204,7 @@ describe('slot ownership (continued)', () => {
it('cannot remove a foreign-owner slot', async () => { it('cannot remove a foreign-owner slot', async () => {
// Given a slot owned by another wallet remains in shared storage // Given a slot owned by another wallet remains in shared storage
const auth = new FakeAuthenticator() 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)} const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
writeRawSlots([foreign]) writeRawSlots([foreign])
@@ -219,7 +219,7 @@ describe('slot ownership (continued)', () => {
it('rewraps only current-owner slots and preserves foreign slots', async () => { it('rewraps only current-owner slots and preserves foreign slots', async () => {
// Given current and foreign slots share storage // Given current and foreign slots share storage
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) const slot = await registerPasskey(MATERIAL, {credentials: auth})
const foreign = { const foreign = {
...slot, ...slot,
credentialId: '44'.repeat(16), credentialId: '44'.repeat(16),
+29 -24
View File
@@ -20,7 +20,7 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
@@ -147,9 +147,9 @@ beforeEach(async () => {
}) })
describe('registration and unlock', () => { 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 auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, { const slot = await registerPasskey(MATERIAL, {
credentials: auth, credentials: auth,
name: 'laptop', name: 'laptop',
}) })
@@ -157,33 +157,32 @@ describe('registration and unlock', () => {
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot])
expect(hasPasskeySlots()).toBe(true) expect(hasPasskeySlots()).toBe(true)
const unwrapped = await unlockWithPasskey({credentials: auth}) const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) expect(unwrapped).toEqual(MATERIAL)
}) })
it('never stores the linking key in the clear', async () => { it('never stores the linking key in the clear', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const raw = localStorage.getItem('sattle_passkey_slots') const raw = localStorage.getItem('sattle_passkey_slots')
expect(raw).toBeTruthy() expect(raw).toBeTruthy()
expect(raw).not.toContain(bytesToHex(LINKING_KEY)) 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') await saveWalletMaterial(MATERIAL, 'correct horse')
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const viaPassword = await decryptSavedWalletMaterial('correct horse') const viaPassword = await decryptSavedWalletMaterial('correct horse')
const viaPasskey = await unlockWithPasskey({credentials: auth}) const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth})
const passwordLinkingKey = walletMaterialLinkingKey(viaPassword) expect(viaPasskey).toEqual(viaPassword)
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey))
// and the practical consequence: a bearer record encrypted after a // and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock // 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 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({ await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
note: 'still readable', 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 () => { it('falls back to a get ceremony when create only reports prf.enabled', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
auth.prfResultsOnCreate = false auth.prfResultsOnCreate = false
const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) const slot = await registerPasskey(MATERIAL, {credentials: auth})
expect(auth.getCalls).toBe(1) expect(auth.getCalls).toBe(1)
const unwrapped = await unlockWithPasskey({credentials: auth}) const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) expect(unwrapped).toEqual(MATERIAL)
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId) expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
}) })
it('refuses registration when the authenticator has no PRF support', async () => { it('refuses registration when the authenticator has no PRF support', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
auth.supportsPrf = false 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) expect(hasPasskeySlots()).toBe(false)
}) })
@@ -211,7 +210,7 @@ describe('registration and unlock', () => {
create: async () => null, create: async () => null,
get: async () => null, get: async () => null,
} }
await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow( await expect(registerPasskey(MATERIAL, {credentials: cancelled})).rejects.toThrow(
'cancelled', 'cancelled',
) )
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false)
@@ -219,20 +218,24 @@ describe('registration and unlock', () => {
it('throws before any ceremony when no passkeys are registered', async () => { it('throws before any ceremony when no passkeys are registered', async () => {
const auth = new FakeAuthenticator() 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) expect(auth.getCalls).toBe(0)
}) })
it('rejects unlock when the passkey returns no PRF secret', async () => { it('rejects unlock when the passkey returns no PRF secret', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
auth.prfResultsOnGet = false 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 () => { it('rejects unlock when the ceremony yields an unregistered credential', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
await registerPasskey(LINKING_KEY, {credentials: auth}) await registerPasskey(MATERIAL, {credentials: auth})
const rogue: PasskeyCredentials = { const rogue: PasskeyCredentials = {
create: async () => null, create: async () => null,
get: async () => ({ 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 () => { it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator()
const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) const slot = await registerPasskey(MATERIAL, {credentials: auth})
auth.rotateSecret(slot.credentialId) 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, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
@@ -149,10 +149,10 @@ describe('rewrap for the current owner', () => {
it('refreshes every current-owner wrap all-or-nothing', async () => { it('refreshes every current-owner wrap all-or-nothing', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator()
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator()
const laptopSlot = await registerPasskey(LINKING_KEY, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
}) })
const phoneSlot = await registerPasskey(LINKING_KEY, { const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone, credentials: phone,
}) })
@@ -166,7 +166,7 @@ describe('rewrap for the current owner', () => {
], ],
]) ])
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap') await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap')
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
// full coverage refreshes both wraps around the same proven owner key // full coverage refreshes both wraps around the same proven owner key
const fresh = new Map([ const fresh = new Map([
@@ -184,8 +184,8 @@ describe('rewrap for the current owner', () => {
], ],
]) ])
await rewrapAllSlots(MATERIAL, fresh) await rewrapAllSlots(MATERIAL, fresh)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial) expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
// credential ids and labels survive the re-wrap // credential ids and labels survive the re-wrap
expect( expect(
+1 -2
View File
@@ -20,7 +20,6 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
@@ -148,7 +147,7 @@ beforeEach(async () => {
describe('slot storage hygiene', () => { describe('slot storage hygiene', () => {
it('drops malformed entries instead of throwing', async () => { it('drops malformed entries instead of throwing', async () => {
const auth = new FakeAuthenticator() 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') ?? '[]') const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
localStorage.setItem( localStorage.setItem(
'sattle_passkey_slots', 'sattle_passkey_slots',
-1
View File
@@ -20,7 +20,6 @@ import {
registerPasskey, registerPasskey,
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
+5 -25
View File
@@ -24,7 +24,6 @@
import {sha256} from '@noble/hashes/sha2.js' import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys'
import {withStorageLock} from './storageLock' import {withStorageLock} from './storageLock'
import type {PasskeySlot} from './storage/passkeySlots' import type {PasskeySlot} from './storage/passkeySlots'
import { import {
@@ -154,27 +153,15 @@ export type RegisterPasskeyOptions = {
} }
// Registers a new passkey and persists a slot wrapping the complete material. // 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 // The ceremony is navigator.credentials.create with the PRF
// select already-saved plaintext v2 material but can never create a key-only
// slot. The ceremony is navigator.credentials.create with the PRF
// extension evaluated on creation. Some authenticators only report // extension evaluated on creation. Some authenticators only report
// prf.enabled during create and evaluate the secret on the first get - // prf.enabled during create and evaluate the secret on the first get -
// those get a follow-up get() against the fresh credential. // those get a follow-up get() against the fresh credential.
export const registerPasskey = async ( export const registerPasskey = async (
material: WalletMaterialV2 | Uint8Array, material: WalletMaterialV2,
options: RegisterPasskeyOptions = {}, options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => { ): Promise<PasskeySlot> => {
let walletMaterial: WalletMaterialV2 const ownerId = requireCurrentPasskeyMaterialOwner(material)
if (material instanceof Uint8Array) {
const savedMaterial = getPlainWalletMaterial()
if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) {
throw new Error('Passkey registration requires complete v2 wallet material.')
}
walletMaterial = savedMaterial
} else {
walletMaterial = material
}
const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial)
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials()
const credential = await credentials.create({ const credential = await credentials.create({
publicKey: { publicKey: {
@@ -209,7 +196,7 @@ export const registerPasskey = async (
} }
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
} }
const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial) const wrap = await wrapWalletMaterialWithPrf(prfOutput, material)
const slot: PasskeySlot = { const slot: PasskeySlot = {
credentialId, credentialId,
...wrap, ...wrap,
@@ -219,7 +206,7 @@ export const registerPasskey = async (
version: PASSKEY_SLOT_VERSION, version: PASSKEY_SLOT_VERSION,
} }
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) { if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
throw new Error('Saved wallet material changed during passkey registration.') throw new Error('Saved wallet material changed during passkey registration.')
} }
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
@@ -286,13 +273,6 @@ export const unlockWalletMaterialWithPasskey = async (
return material 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 // Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap. // the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed. // Returns whether a slot was actually removed.
+25 -30
View File
@@ -3,10 +3,10 @@ import {bytesToHex} from '@noble/hashes/utils.js'
import { import {
deriveWalletMaterial, deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedWalletMaterialOwner,
getPlainLinkingKey, getPlainWalletMaterial,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedWalletMaterialOwnerId,
saveWalletMaterial, saveWalletMaterial,
walletMaterialHash, walletMaterialHash,
} from './keys' } from './keys'
@@ -16,7 +16,7 @@ import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) 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 SLOT_STORAGE = 'sattle_passkey_slots'
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
@@ -38,18 +38,24 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots)) 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', () => { describe('passkey-slot schema version', () => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL)
}) })
it('reads a current version 2 slot for the exact saved owner', () => { it('reads a current version 2 slot for the exact saved owner', () => {
// Given a current saved-key marker and passkey record // Given a current saved-material owner marker and passkey record
const ownerId = linkingPubKeyHex(LINKING_KEY) 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} const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2}
writeRawSlots([slot]) writeRawSlots([slot])
@@ -58,24 +64,22 @@ describe('passkey-slot schema version', () => {
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot])
}) })
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => { it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => {
// Given records written before schema versioning // 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) const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem( removeSavedOwnerMarker()
KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
)
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}]) 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') await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
const provenKey = getPlainLinkingKey() const provenMaterial = getPlainWalletMaterial()
if (provenKey === null) throw new Error('expected the compatible plaintext key') if (provenMaterial === null) throw new Error('expected the compatible plaintext material')
ensureSavedKeyOwner(provenKey) ensureSavedWalletMaterialOwner(provenMaterial)
await migrateLegacyPasskeySlots(provenKey) await migrateLegacyPasskeySlots(LINKING_KEY)
// Then both compatible legacy forms become current records // Then both compatible legacy forms become current records
expect(savedKeyOwnerId()).toBe(ownerId) expect(savedWalletMaterialOwnerId()).toBe(ownerId)
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
{...SLOT_BASE, ownerId, version: 2}, {...SLOT_BASE, ownerId, version: 2},
{...SLOT_BASE, credentialId: '55'.repeat(16), 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) => { it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => {
// Given an otherwise valid slot carrying unrecognized metadata // Given an otherwise valid slot carrying unrecognized metadata
const ownerId = linkingPubKeyHex(LINKING_KEY) 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}]) writeRawSlots([{...SLOT_BASE, ownerId, version}])
// When slots are parsed // When slots are parsed
@@ -98,11 +98,6 @@ describe('passkey-slot schema version', () => {
it('hides a foreign version 1 slot from the current owner', () => { it('hides a foreign version 1 slot from the current owner', () => {
// Given the saved owner and slot owner differ // 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}]) writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}])
// When the current wallet reads passkeys // 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 // 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 // successor has installed its saved-key owner marker. Ownerless data may only
// move through explicit migration APIs after their own proof checks succeed. // 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 {savedKeyOwnerId} from '../keys'
import {savedWalletMaterialOwnerId} from '../walletMaterialStorage'
export class WalletOwnerMismatchError extends Error { export class WalletOwnerMismatchError extends Error {
override readonly name = 'WalletOwnerMismatchError' override readonly name = 'WalletOwnerMismatchError'
@@ -12,7 +20,7 @@ export class WalletOwnerMismatchError extends Error {
} }
export const savedKeyOwnerAllows = (ownerId: string): boolean => { export const savedKeyOwnerAllows = (ownerId: string): boolean => {
return savedKeyOwnerId() === ownerId return savedWalletMaterialOwnerId() === ownerId || savedKeyOwnerId() === ownerId
} }
export const assertSavedKeyOwner = (ownerId: string): void => { 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 // parsed before use; ownerless v0 records remain hidden until an already
// proven saved wallet explicitly migrates them. // proven saved wallet explicitly migrates them.
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys' import {linkingPubKeyHex} from '../keys'
import { import {
clearNwcEnabledForOwner, clearNwcEnabledForOwner,
clearUnownedNwcEnabled, clearUnownedNwcEnabled,
@@ -177,7 +177,7 @@ export type NwcLegacyMigrationResult = {
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => { export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
const ownerId = linkingPubKeyHex(linkingKey) const ownerId = linkingPubKeyHex(linkingKey)
if (savedKeyOwnerId() !== ownerId) { if (!savedKeyOwnerAllows(ownerId)) {
throw new Error('Legacy NWC migration requires a proven saved wallet owner.') throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
} }
+3 -2
View File
@@ -7,7 +7,8 @@ import {
removeStoredTrustedMintsForOwner, removeStoredTrustedMintsForOwner,
resetStoredTrustedMints, resetStoredTrustedMints,
} from './trustedMintsRepository' } from './trustedMintsRepository'
import {linkingPubKeyHex, savedKeyOwnerId} from './keys' import {linkingPubKeyHex} from './keys'
import {savedKeyOwnerAllows} from './storage/currentOwner'
import { import {
addMint, addMint,
cacheMintNodeInfo, cacheMintNodeInfo,
@@ -259,7 +260,7 @@ export const clearTrustedMints = (ownerId?: string): Promise<void> =>
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => { export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
const ownerId = linkingPubKeyHex(linkingKey) const ownerId = linkingPubKeyHex(linkingKey)
if (savedKeyOwnerId() !== ownerId) { if (!savedKeyOwnerAllows(ownerId)) {
throw new Error('Legacy trusted-mint migration requires a proven owner.') throw new Error('Legacy trusted-mint migration requires a proven owner.')
} }
return adoptLegacyStoredTrustedMints(ownerId) return adoptLegacyStoredTrustedMints(ownerId)
+2 -2
View File
@@ -249,7 +249,7 @@ const doEnableBiometric = async () => {
biometricBusy.value = true; biometricBusy.value = true;
banner.value = ''; banner.value = '';
try { try {
await enableBiometricUnlock(wallet.requireLinkingKey()); await enableBiometricUnlock(wallet.requireWalletMaterial());
biometricEnrolled.value = true; biometricEnrolled.value = true;
toast('positive', 'Biometric unlock enabled.'); toast('positive', 'Biometric unlock enabled.');
} catch (err) { } catch (err) {
@@ -289,7 +289,7 @@ const doRegister = async () => {
banner.value = ''; banner.value = '';
try { try {
const name = registerName.value.trim(); const name = registerName.value.trim();
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {}); await registerPasskey(wallet.requireWalletMaterial(), name ? { name } : {});
slots.value = readPasskeySlots(); slots.value = readPasskeySlots();
registering.value = false; registering.value = false;
toast('positive', 'Passkey added.'); toast('positive', 'Passkey added.');
+9 -6
View File
@@ -1,13 +1,19 @@
import { createPinia, disposePinia, setActivePinia } from 'pinia'; import { createPinia, disposePinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { stubLocalStorage } from '@/lnurlcash/test-utils';
import { useMintsStore } from './mints'; import { useMintsStore } from './mints';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
const LINKING_KEY_HEX = '07'.repeat(32);
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); 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); const MINT_KEY = '02' + 'aa'.repeat(32);
let testPinia: ReturnType<typeof createPinia>; let testPinia: ReturnType<typeof createPinia>;
@@ -34,10 +40,7 @@ describe('mints store storage-event convergence', () => {
// Given an unlocked wallet and its mounted mints store // Given an unlocked wallet and its mounted mints store
const events = new EventTarget(); const events = new EventTarget();
vi.stubGlobal('window', events); vi.stubGlobal('window', events);
localStorage.setItem( await saveWalletMaterial(MATERIAL);
'sattle_linking_key',
JSON.stringify({ enc: false, value: LINKING_KEY_HEX, ownerId: OWNER_ID, version: 1 }),
);
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.init(); await wallet.init();
const mints = useMintsStore(); const mints = useMintsStore();
@@ -2,13 +2,13 @@ import {
OWNER_ID, OWNER_ID,
PASSWORD, PASSWORD,
deferred, deferred,
installLegacyEncryptedWallet,
installLegacyOwnerlessResidue, installLegacyOwnerlessResidue,
installOwnerlessEncryptedWalletMaterial,
mocks, mocks,
} from './wallet.lifecycle.testHarness'; } from './wallet.lifecycle.testHarness';
import { describe, expect, it, vi } from 'vitest'; 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 { readNwcConnections } from '@/lnurlcash/nwc';
import { readPasskeySlots } from '@/lnurlcash/passkeys'; import { readPasskeySlots } from '@/lnurlcash/passkeys';
import { readTrustedMints } from '@/lnurlcash/trustedMints'; import { readTrustedMints } from '@/lnurlcash/trustedMints';
@@ -17,8 +17,8 @@ import { useWalletStore } from './wallet';
describe('serialized wallet activation', () => { describe('serialized wallet activation', () => {
it('migrates a proven legacy owner before NWC can observe unlocked', async () => { it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
// Given an encrypted legacy wallet with ownerless authorization residue // Given a restored (ownerless) v2 wallet with ownerless authorization residue
await installLegacyEncryptedWallet(); await installOwnerlessEncryptedWalletMaterial();
installLegacyOwnerlessResidue(); installLegacyOwnerlessResidue();
const wallet = useWalletStore(); const wallet = useWalletStore();
useNwcStore(); useNwcStore();
@@ -27,7 +27,7 @@ describe('serialized wallet activation', () => {
await wallet.unlock(PASSWORD); await wallet.unlock(PASSWORD);
// Then every legacy namespace belongs to the proven owner before startup // 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(readPasskeySlots()).toHaveLength(1);
expect(readNwcConnections(OWNER_ID)).toHaveLength(1); expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readTrustedMints(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, // Then the successor cannot install until the old owner drain completes,
// and the session keeps its commit capability while the drain runs // and the session keeps its commit capability while the drain runs
expect(savedKeyExists()).toBe(true); expect(savedWalletMaterialExists()).toBe(true);
expect(wallet.state).toBe('unlocked'); expect(wallet.state).toBe('unlocked');
drain.resolve(); drain.resolve();
await forgetting; await forgetting;
@@ -70,9 +70,10 @@ describe('serialized wallet activation', () => {
// When a valid file restore starts // When a valid file restore starts
const restoring = wallet.restoreFromBackup({ const restoring = wallet.restoreFromBackup({
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: 1, createdAt: 1,
bearers: [], bearers: [],
nextByHost: {},
}); });
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled()); await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
@@ -1,29 +1,29 @@
import { import {
MINT_KEY, MINT_KEY,
OTHER_OWNER_ID, OTHER_OWNER_ID,
OWNER_ID,
PASSWORD, PASSWORD,
encryptedLinkingKeyRecord, encryptedLinkingKeyRecord,
} from './wallet.lifecycle.testHarness'; } from './wallet.lifecycle.testHarness';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { savedKeyOwnerId } from '@/lnurlcash/keys'; import { savedKeyExists, savedWalletMaterialExists } from '@/lnurlcash/keys';
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; import { addTrustedMint } from '@/lnurlcash/trustedMints';
import { useNwcStore } from './nwc'; import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
describe('hostile file backup owner', () => { 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 // Given a fresh device and an encrypted backup whose valid owner claim is foreign
const wallet = useWalletStore(); const wallet = useWalletStore();
useNwcStore(); useNwcStore();
const result = await wallet.restoreFromBackup({ const result = await wallet.restoreFromBackup({
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: 1, createdAt: 1,
ownerId: OTHER_OWNER_ID, ownerId: OTHER_OWNER_ID,
linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID }, linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID },
bearers: [], bearers: [],
nextByHost: {},
trustedMints: [ trustedMints: [
{ {
server: 'file-mint.example', 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(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 });
expect(savedKeyOwnerId()).toBeNull(); expect(savedKeyExists()).toBe(false);
expect(registryBeforeProof).toBeNull(); expect(savedWalletMaterialExists()).toBe(false);
expect(wallet.state).toBe('none');
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
// When password proof activates the restored linking key // And no unlock can ever succeed against the file's claim
await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined(); 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 // When the holder installs their own wallet afterward
expect(readTrustedMints(OTHER_OWNER_ID)).toEqual([]); await wallet.create(PASSWORD);
expect(readTrustedMints(OWNER_ID)).toEqual([]); 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( 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'); ).resolves.toBe('added');
expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual( 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 { 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 { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc';
import type { NwcConnectionRecord } from '@/lnurlcash/nwc'; import type { NwcConnectionRecord } from '@/lnurlcash/nwc';
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; 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.'); if (oldOwner === null) throw new Error('Expected an unlocked old owner.');
// When another tab has already recreated the wallet and a delayed event arrives // When another tab has already recreated the wallet and a delayed event arrives
localStorage.setItem( await saveWalletMaterial(OTHER_MATERIAL);
'sattle_linking_key',
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
);
events.dispatchEvent( events.dispatchEvent(
Object.defineProperties(new Event('storage'), { 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 }) }, newValue: { value: JSON.stringify({ ownerId: oldOwner }) },
}), }),
); );
await vi.waitFor(() => expect(wallet.state).toBe('locked')); await vi.waitFor(() => expect(wallet.state).toBe('locked'));
// Then the stale runtime has no usable key and cannot recreate old-owner state // Then the stale runtime has no usable material and cannot recreate old-owner state
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow( await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow(
/owner/i, /owner/i,
); );
@@ -54,20 +58,17 @@ describe('cross-tab owner invalidation', () => {
await nwc.setEnabled(true); await nwc.setEnabled(true);
// When another tab replaces the saved wallet owner // When another tab replaces the saved wallet owner
localStorage.setItem( await saveWalletMaterial(OTHER_MATERIAL);
'sattle_linking_key',
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
);
events.dispatchEvent( events.dispatchEvent(
Object.defineProperties(new Event('storage'), { Object.defineProperties(new Event('storage'), {
key: { value: 'sattle_linking_key' }, key: { value: 'sattle_wallet_material_v2' },
}), }),
); );
await vi.waitFor(() => expect(wallet.state).toBe('locked')); await vi.waitFor(() => expect(wallet.state).toBe('locked'));
// Then the stale runtime is cleared and the queue surfaces the drain failure // Then the stale runtime is cleared and the queue surfaces the drain failure
expect(wallet.lifecycleError).toMatch(/stale drain failed/i); 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(nwc.running).toBe(false);
expect(serviceStop).toHaveBeenCalledTimes(1); expect(serviceStop).toHaveBeenCalledTimes(1);
}); });
@@ -90,7 +91,7 @@ describe('cross-tab owner invalidation', () => {
// When A is forgotten before successor B is installed // When A is forgotten before successor B is installed
await wallet.forgetWallet(); await wallet.forgetWallet();
expect(savedKeyOwnerId()).toBeNull(); expect(savedWalletMaterialOwnerId()).toBeNull();
// Then no stale normal mutation can recreate A-owned state in the gap // Then no stale normal mutation can recreate A-owned state in the gap
await expect( await expect(
@@ -6,7 +6,11 @@ import {
} from './wallet.lifecycle.testHarness'; } from './wallet.lifecycle.testHarness';
import { describe, expect, it } from 'vitest'; 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 { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc';
import { readPasskeySlots } from '@/lnurlcash/passkeys'; import { readPasskeySlots } from '@/lnurlcash/passkeys';
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; 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 // Then it fails explicitly and does not install a partial wallet
await expect(restoring).rejects.toThrow(/valid sattle backup/i); await expect(restoring).rejects.toThrow(/valid sattle backup/i);
expect(wallet.state).toBe('none'); 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 () => { it('tears down the installed owner before a foreign seed restore', async () => {
@@ -65,9 +69,11 @@ describe('foreign wallet isolation', () => {
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16), hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12), iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48), materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
wrappedMaterial: '44'.repeat(48),
createdAt: 1, createdAt: 1,
ownerId: oldOwner, 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 { describe, expect, it, vi } from 'vitest';
import { savedKeyExists } from '@/lnurlcash/keys'; import { savedWalletMaterialExists, walletMaterialHash } from '@/lnurlcash/keys';
import { addTrustedMint } from '@/lnurlcash/trustedMints'; import { addTrustedMint } from '@/lnurlcash/trustedMints';
import { useNwcStore } from './nwc'; import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
@@ -24,7 +24,7 @@ describe('complete owner teardown', () => {
await expect(locking).rejects.toThrow('NWC lock drain failed'); await expect(locking).rejects.toThrow('NWC lock drain failed');
expect(wallet.state).toBe('locked'); expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i); 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(wallet.bearers).toEqual([]);
expect(nwc.running).toBe(false); expect(nwc.running).toBe(false);
expect(serviceStop).toHaveBeenCalledTimes(1); expect(serviceStop).toHaveBeenCalledTimes(1);
@@ -58,9 +58,11 @@ describe('complete owner teardown', () => {
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16), hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12), iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48), materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
wrappedMaterial: '44'.repeat(48),
createdAt: 1, createdAt: 1,
ownerId, ownerId,
version: 2,
}, },
]), ]),
); );
@@ -79,19 +81,19 @@ describe('complete owner teardown', () => {
// Then completion and destructive storage removal wait for the drain, // Then completion and destructive storage removal wait for the drain,
// and the session stays commit-capable (fence and keys live) until it // and the session stays commit-capable (fence and keys live) until it
// finishes so accepted NWC work can still reach its durable outcome // finishes so accepted NWC work can still reach its durable outcome
expect(savedKeyExists()).toBe(true); expect(savedWalletMaterialExists()).toBe(true);
expect(wallet.state).toBe('unlocked'); expect(wallet.state).toBe('unlocked');
drain.resolve(); drain.resolve();
await forgetting; await forgetting;
expect(wallet.state).toBe('none'); expect(wallet.state).toBe('none');
expect(savedKeyExists()).toBe(false); expect(savedWalletMaterialExists()).toBe(false);
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull(); expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull(); expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull(); expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull(); expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true); expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan( 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 // When forget reaches biometric teardown
const forgetting = wallet.forgetWallet(); 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'); await expect(forgetting).rejects.toThrow('secure delete failed');
expect(wallet.state).toBe('locked'); expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/secure delete failed/i); expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
expect(savedKeyExists()).toBe(true); expect(savedWalletMaterialExists()).toBe(true);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
}); });
it('surfaces NWC drain failure without removing the saved owner', async () => { 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'); await expect(forgetting).rejects.toThrow('NWC drain failed');
expect(wallet.state).toBe('locked'); expect(wallet.state).toBe('locked');
expect(wallet.lifecycleError).toMatch(/NWC drain failed/i); expect(wallet.lifecycleError).toMatch(/NWC drain failed/i);
expect(savedKeyExists()).toBe(true); expect(savedWalletMaterialExists()).toBe(true);
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
}); });
it('completes teardown on retry after a transient biometric deletion failure', async () => { 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 // Then teardown completes and the error surface clears
expect(wallet.state).toBe('none'); expect(wallet.state).toBe('none');
expect(wallet.lifecycleError).toBe(''); 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.activation.cases';
import './wallet.lifecycle.material.cases';
import './wallet.lifecycle.legacyReset.cases';
import './wallet.lifecycle.teardown.cases'; import './wallet.lifecycle.teardown.cases';
import './wallet.lifecycle.lockFailure.cases'; import './wallet.lifecycle.lockFailure.cases';
import './wallet.lifecycle.isolation.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 { createPinia, setActivePinia } from 'pinia';
import { beforeEach, vi } from 'vitest'; 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 NwcExports from '@/lnurlcash/nwc';
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup'; 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 { stubLocalStorage } from '@/lnurlcash/test-utils';
import { lifecycleMocks } from './wallet.lifecycle.testMocks'; import { lifecycleMocks } from './wallet.lifecycle.testMocks';
@@ -13,7 +23,16 @@ vi.mock('@/capabilities/biometricUnlock', async () => {
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks'); const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
return { return {
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock, 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 LINKING_KEY = new Uint8Array(32).fill(7);
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); 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 OWNER_ID = linkingPubKeyHex(LINKING_KEY);
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY); export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
export const PASSWORD = 'correct horse battery staple'; export const PASSWORD = 'correct horse battery staple';
export const MINT_KEY = '02' + 'aa'.repeat(32); export const MINT_KEY = '02' + 'aa'.repeat(32);
export const encryptedLinkingKeyRecord = async () => { export const WALLET_MATERIAL_KEY = WALLET_MATERIAL_STORAGE_KEY;
const parts = await encryptSecretParts( export const LEGACY_LINKING_KEY = 'sattle_linking_key';
Array.from(LINKING_KEY, (byte) => byte.toString(16).padStart(2, '0')).join(''), // the frozen pre-v2 bearer namespace: current storage never writes it, so
PASSWORD, // 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 }; 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 = { type Deferred = {
readonly promise: Promise<void>; readonly promise: Promise<void>;
readonly resolve: () => 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 => { export const installLegacyOwnerlessResidue = (): void => {
localStorage.setItem( localStorage.setItem(
'sattle_passkey_slots', 'sattle_passkey_slots',
@@ -68,7 +171,8 @@ export const installLegacyOwnerlessResidue = (): void => {
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16), hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12), iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48), materialHash: walletMaterialHash(MATERIAL),
wrappedMaterial: '44'.repeat(48),
createdAt: 1, 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
stubLocalStorage(); stubLocalStorage();
setActivePinia(createPinia()); setActivePinia(createPinia());
lifecycleMocks.disableBiometricUnlock.mockResolvedValue(); 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({ lifecycleMocks.restoreFromNostr.mockResolvedValue({
added: 0, added: 0,
skipped: 0, skipped: 0,
+2
View File
@@ -2,6 +2,8 @@ import { vi } from 'vitest';
export const lifecycleMocks = { export const lifecycleMocks = {
disableBiometricUnlock: vi.fn<() => Promise<void>>(), disableBiometricUnlock: vi.fn<() => Promise<void>>(),
unlockWalletMaterialWithBiometrics: vi.fn(),
unlockWalletMaterialWithPasskey: vi.fn(),
restoreFromNostr: vi.fn(), restoreFromNostr: vi.fn(),
startService: 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 { computed, onScopeDispose, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
deriveBearerAesKey,
savedKeyExists,
savedKeyIsEncrypted,
savedKeyOwnerId,
clearSavedLinkingKey, clearSavedLinkingKey,
clearSavedWalletMaterial,
deriveBearerAesKey,
savedWalletMaterialExists,
savedWalletMaterialIsEncrypted,
savedWalletMaterialOwnerId,
walletMaterialLinkingKey,
} from '@/lnurlcash/keys'; } from '@/lnurlcash/keys';
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
import { import {
loadBearers, loadBearers,
clearAllBearers, clearAllBearers,
@@ -32,7 +37,7 @@ import {
createSeedInstaller, createSeedInstaller,
createWalletTransitionQueue, createWalletTransitionQueue,
migrateProvenLegacyOwner, migrateProvenLegacyOwner,
ownerOf, resetUnsupportedLegacyWalletState,
stopWalletNwcSession, stopWalletNwcSession,
WalletLifecycleError, WalletLifecycleError,
} from './walletLifecycle'; } from './walletLifecycle';
@@ -40,23 +45,25 @@ import {
export { TrustedMintPostCommitError } from './walletFunds'; export { TrustedMintPostCommitError } from './walletFunds';
// 'none': no wallet on this device yet -> setup // 'none': no wallet on this device yet -> setup
// 'locked': linking key present but password-encrypted -> unlock // 'locked': wallet material present but password-encrypted -> unlock
// 'unlocked': linking key (and thus the bearer AES key) in memory // 'unlocked': the complete material (linking key + cash root) is in memory
export type { WalletState } from './walletOwnerFence'; export type { WalletState } from './walletOwnerFence';
// a plaintext-stored key also starts 'locked' - init() unlocks it // a plaintext-stored material record also starts 'locked' - init() unlocks
// immediately without a password, keeping a single code path for deriving // it immediately without a password, keeping a single code path for deriving
// the AES key and loading bearers // the AES key and loading bearers
export const useWalletStore = defineStore('wallet', () => { 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 pubkey = ref<string | null>(null);
const auxiliaryError = ref(''); const auxiliaryError = ref('');
const lifecycleError = ref(''); const lifecycleError = ref('');
let aesKey: CryptoKey | null = null; let aesKey: CryptoKey | null = null;
// the linking key itself, only while unlocked - needed by backup/passkey // the complete v2 wallet material, only while unlocked - the cash root
// operations (nostrBackup derives the backup key from it, passkey // funds every BIP-32 secret derivation and the linking key inside it backs
// registration wraps it). Never exposed reactively; cleared on lock/forget // backup/passkey operations (nostrBackup derives the backup key from it,
let currentLinkingKey: Uint8Array | null = null; // 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 lifecycleToken = 0;
let acceptingOwnerWork = false; let acceptingOwnerWork = false;
@@ -68,7 +75,7 @@ export const useWalletStore = defineStore('wallet', () => {
}, },
}).run; }).run;
const encrypted = computed(() => savedKeyIsEncrypted()); const encrypted = computed(() => savedWalletMaterialIsEncrypted());
const ownerFence = createWalletOwnerFence({ const ownerFence = createWalletOwnerFence({
state: () => state.value, state: () => state.value,
@@ -79,7 +86,7 @@ export const useWalletStore = defineStore('wallet', () => {
const clearRuntime = (): void => { const clearRuntime = (): void => {
aesKey = null; aesKey = null;
currentLinkingKey = null; currentMaterial = null;
pubkey.value = null; pubkey.value = null;
funds.clear(); funds.clear();
useActivityStore().unload(); useActivityStore().unload();
@@ -124,12 +131,12 @@ export const useWalletStore = defineStore('wallet', () => {
const lock = (): Promise<void> => const lock = (): Promise<void> =>
runTransition(async () => { runTransition(async () => {
if (!savedKeyIsEncrypted()) return; if (!savedWalletMaterialIsEncrypted()) return;
await deactivateSession(); await deactivateSession();
}); });
const idleWatch = createWalletIdleWatch({ const idleWatch = createWalletIdleWatch({
isEncrypted: savedKeyIsEncrypted, isEncrypted: savedWalletMaterialIsEncrypted,
isUnlocked: () => state.value === 'unlocked', isUnlocked: () => state.value === 'unlocked',
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null, isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
lock, 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 = ''; auxiliaryError.value = '';
await migrateProvenLegacyOwner(linkingKey, ownerWasMissing); const ownerId = walletMaterialOwnerId(material);
const key = await deriveBearerAesKey(linkingKey); 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 loaded = await loadBearers(key);
const activity = useActivityStore(); const activity = useActivityStore();
await activity.loadFor(key); await activity.loadFor(key);
const ownerId = ownerOf(linkingKey);
await restoreHeldMintTrust(loaded, ownerId, (message) => { await restoreHeldMintTrust(loaded, ownerId, (message) => {
auxiliaryError.value = message; auxiliaryError.value = message;
}); });
if (savedWalletMaterialOwnerId() !== ownerId) {
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
}
aesKey = key; aesKey = key;
currentLinkingKey = linkingKey; currentMaterial = material;
lifecycleToken += 1; lifecycleToken += 1;
observeOwnerChanges();
pubkey.value = ownerId; pubkey.value = ownerId;
funds.replace(loaded); funds.replace(loaded);
acceptingOwnerWork = true; acceptingOwnerWork = true;
state.value = 'unlocked'; state.value = 'unlocked';
idleWatch.start(); 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 teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
const ownerId = savedKeyOwnerId() ?? pubkey.value; const ownerId = savedWalletMaterialOwnerId() ?? pubkey.value;
acceptingOwnerWork = false; acceptingOwnerWork = false;
stopOwnerChanges(); stopOwnerChanges();
idleWatch.stop(); idleWatch.stop();
@@ -178,7 +208,12 @@ export const useWalletStore = defineStore('wallet', () => {
clearAllBearers(); clearAllBearers();
useActivityStore().unloadAndClear(); useActivityStore().unloadAndClear();
clearSettings(); 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(); 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'; state.value = 'none';
} finally { } finally {
// a failed teardown still ends the session: 'locked' must never hold // 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 prepareInstallation = async (nextOwnerId: string): Promise<void> => {
const installedOwner = savedKeyOwnerId() ?? pubkey.value; // the install path re-runs the alpha reset, so a boot-time reset that
if (savedKeyExists() && installedOwner === nextOwnerId) { // 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(); if (state.value === 'unlocked') await deactivateSession();
return; return;
} }
if (savedKeyExists() || state.value === 'unlocked') { if (savedWalletMaterialExists() || state.value === 'unlocked') {
await teardownCurrentOwner(true); await teardownCurrentOwner(true);
} }
await clearUnownedAuthorizations(); await clearUnownedAuthorizations();
@@ -202,7 +241,7 @@ export const useWalletStore = defineStore('wallet', () => {
const installSeed = createSeedInstaller({ const installSeed = createSeedInstaller({
prepareInstallation, prepareInstallation,
activate: (linkingKey) => activate(linkingKey, false), activate: (material) => activate(material, false),
}); });
const access = createWalletAccess({ const access = createWalletAccess({
@@ -210,19 +249,25 @@ export const useWalletStore = defineStore('wallet', () => {
installSeed, installSeed,
activate, activate,
canInit: () => state.value === 'locked', canInit: () => state.value === 'locked',
onResetError: (error) => {
lifecycleError.value = error instanceof Error ? error.message : 'Wallet reset failed.';
},
}); });
const restoreFromBackup = (data: unknown): Promise<RestoreResult> => const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
runTransition(async () => { runTransition(async () => {
const backup = parseBackupFile(data); const backup = parseBackupFile(data);
const hadSavedKey = savedKeyExists(); const hadSavedMaterial = savedWalletMaterialExists();
const activeOwner = pubkey.value; const activeOwner = pubkey.value;
const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null; const activeMaterial = state.value === 'unlocked' ? requireWalletMaterial() : null;
if (activeKey !== null) await deactivateSession(); if (activeMaterial !== null) await deactivateSession();
if (!hadSavedKey) await clearUnownedAuthorizations(); if (!hadSavedMaterial) await clearUnownedAuthorizations();
const result = await applyBackup(backup, activeOwner ?? undefined); const result = await applyBackup(backup, activeOwner ?? undefined);
if (activeKey !== null) await activate(activeKey, false); // a file-carried legacy linking key can never become v2 material (the
else if (result.linkingKeyRestored) state.value = 'locked'; // 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; return result;
}); });
@@ -232,21 +277,21 @@ export const useWalletStore = defineStore('wallet', () => {
password?: string, password?: string,
): Promise<void> => ): Promise<void> =>
runTransition(() => runTransition(() =>
installSeed(seedPhrase, password, async (linkingKey) => { installSeed(seedPhrase, password, async (material) => {
await restoreFromNostrEngine(linkingKey, relays); await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
}), }),
); );
const restoreCurrentFromNostr = (relays: string[]) => const restoreCurrentFromNostr = (relays: string[]) =>
runTransition(async () => { runTransition(async () => {
const linkingKey = requireLinkingKey(); const material = requireWalletMaterial();
await deactivateSession(); await deactivateSession();
const result = await restoreFromNostrEngine(linkingKey, relays); const result = await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
await activate(linkingKey, false); await activate(material, false);
return result; 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 // bearer record, the activity log, and the non-secret registries that
// would otherwise linger as a fingerprint of it. Not recoverable by // would otherwise linger as a fingerprint of it. Not recoverable by
// restoring the same seed afterward (the ciphertexts themselves are // 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 // narrow accessor for the operations that need the key material itself
// (nostr backup key derivation, passkey registration) - never reactive, // (nostr backup key derivation, passkey registration) - never reactive,
// throws when locked, so callers can't accidentally hold a stale key // throws when locked, so callers can't accidentally hold a stale key
const requireLinkingKey = (): Uint8Array => { const requireLinkingKey = (): Uint8Array => walletMaterialLinkingKey(requireWalletMaterial());
if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.');
return currentLinkingKey; // 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({ const funds = createWalletFunds({
@@ -301,6 +350,7 @@ export const useWalletStore = defineStore('wallet', () => {
forgetWallet, forgetWallet,
postponeLock: idleWatch.postpone, postponeLock: idleWatch.postpone,
requireLinkingKey, requireLinkingKey,
requireWalletMaterial,
captureOwnerFence: ownerFence.capture, captureOwnerFence: ownerFence.capture,
}; };
}); });
+32 -18
View File
@@ -1,22 +1,28 @@
import { import {
decryptSavedLinkingKey, decryptSavedWalletMaterial,
generateSeedPhrase, generateSeedPhrase,
getPlainLinkingKey, getPlainWalletMaterial,
savedKeyIsEncrypted, savedWalletMaterialIsEncrypted,
savedKeyOwnerId, savedWalletMaterialOwnerId,
} from '@/lnurlcash/keys'; } from '@/lnurlcash/keys';
import { unlockWithPasskey } from '@/lnurlcash/passkeys'; import type { WalletMaterialV2 } from '@/lnurlcash/keys';
import { unlockWithBiometrics } from '@/capabilities/biometricUnlock'; import { unlockWalletMaterialWithPasskey } from '@/lnurlcash/passkeys';
import { unlockWalletMaterialWithBiometrics } from '@/capabilities/biometricUnlock';
import { resetUnsupportedLegacyWalletState } from './walletLifecycle';
type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>; type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>;
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>; 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<{ type WalletAccessOptions = Readonly<{
runTransition: RunTransition; runTransition: RunTransition;
installSeed: InstallSeed; installSeed: InstallSeed;
activate: Activate; activate: Activate;
canInit: () => boolean; 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 = ({ export const createWalletAccess = ({
@@ -24,10 +30,11 @@ export const createWalletAccess = ({
installSeed, installSeed,
activate, activate,
canInit, canInit,
onResetError,
}: WalletAccessOptions) => { }: WalletAccessOptions) => {
const activateSavedKey = async (linkingKey: Uint8Array | null): Promise<void> => { const activateSavedMaterial = async (material: WalletMaterialV2 | null): Promise<void> => {
if (!linkingKey) throw new Error('No wallet on this device.'); if (!material) throw new Error('No wallet on this device.');
await activate(linkingKey, savedKeyOwnerId() === null); await activate(material, savedWalletMaterialOwnerId() === null);
}; };
const create = (password?: string): Promise<string> => const create = (password?: string): Promise<string> =>
runTransition(async () => { runTransition(async () => {
@@ -39,23 +46,30 @@ export const createWalletAccess = ({
runTransition(() => installSeed(seedPhrase, password)); runTransition(() => installSeed(seedPhrase, password));
const unlock = (password?: string): Promise<void> => const unlock = (password?: string): Promise<void> =>
runTransition(async () => { runTransition(async () => {
const linkingKey = savedKeyIsEncrypted() const material = savedWalletMaterialIsEncrypted()
? await decryptSavedLinkingKey(password || '') ? await decryptSavedWalletMaterial(password || '')
: getPlainLinkingKey(); : getPlainWalletMaterial();
await activateSavedKey(linkingKey); await activateSavedMaterial(material);
}); });
const unlockWithPasskeyCredential = (): Promise<void> => const unlockWithPasskeyCredential = (): Promise<void> =>
runTransition(async () => { runTransition(async () => {
await activate(await unlockWithPasskey(), false); await activate(await unlockWalletMaterialWithPasskey(), false);
}); });
const unlockWithBiometric = (): Promise<void> => const unlockWithBiometric = (): Promise<void> =>
runTransition(async () => { runTransition(async () => {
await activateSavedKey(await unlockWithBiometrics()); await activateSavedMaterial(await unlockWalletMaterialWithBiometrics());
}); });
const init = (): Promise<void> => const init = (): Promise<void> =>
runTransition(async () => { runTransition(async () => {
if (!canInit() || savedKeyIsEncrypted()) return; // alpha reset runs before any routing decision can expose the wallet:
await activateSavedKey(getPlainLinkingKey()); // an unsupported legacy install is wiped, never migrated
try {
await resetUnsupportedLegacyWalletState();
} catch (error) {
onResetError(error);
}
if (!canInit() || savedWalletMaterialIsEncrypted()) return;
await activateSavedMaterial(getPlainWalletMaterial());
}); });
return { return {
create, create,
+70 -14
View File
@@ -1,11 +1,15 @@
import { import {
deriveWalletLinkingKey, deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedWalletMaterialOwner,
isValidSeedPhrase, isValidSeedPhrase,
linkingPubKeyHex, saveWalletMaterial,
saveLinkingKey, savedWalletMaterialExists,
walletMaterialLinkingKey,
} from '@/lnurlcash/keys'; } from '@/lnurlcash/keys';
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
import { disableBiometricUnlock } from '@/capabilities/biometricUnlock';
import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys'; import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys';
import { clearAllActivity, clearSettings } from '@/lnurlcash/storage';
import { import {
clearPasskeySlotsForOwner, clearPasskeySlotsForOwner,
clearUnownedPasskeySlots, clearUnownedPasskeySlots,
@@ -17,6 +21,11 @@ import {
migrateLegacyNwcStorage, migrateLegacyNwcStorage,
} from '@/lnurlcash/storage/nwcConnections'; } from '@/lnurlcash/storage/nwcConnections';
import { withStorageLock } from '@/lnurlcash/storageLock'; 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 { import {
migrateLegacyTrustedMints, migrateLegacyTrustedMints,
removeTrustedMintsForOwner, removeTrustedMintsForOwner,
@@ -75,11 +84,12 @@ export const stopWalletNwcSession = async (): Promise<void> => {
}; };
export const migrateProvenLegacyOwner = async ( export const migrateProvenLegacyOwner = async (
linkingKey: Uint8Array, material: WalletMaterialV2,
ownerWasMissing: boolean, ownerWasMissing: boolean,
): Promise<void> => { ): Promise<void> => {
ensureSavedKeyOwner(linkingKey); ensureSavedWalletMaterialOwner(material);
if (!ownerWasMissing) return; if (!ownerWasMissing) return;
const linkingKey = walletMaterialLinkingKey(material);
await migrateLegacyPasskeySlots(linkingKey); await migrateLegacyPasskeySlots(linkingKey);
migrateLegacyNwcStorage(linkingKey); migrateLegacyNwcStorage(linkingKey);
await migrateLegacyTrustedMints(linkingKey); await migrateLegacyTrustedMints(linkingKey);
@@ -103,26 +113,72 @@ export const clearUnownedAuthorizations = async (): Promise<void> => {
await resetTrustedMintsForReplacement(); 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 = { type SeedInstallerOptions = {
readonly prepareInstallation: (ownerId: string) => Promise<void>; readonly prepareInstallation: (ownerId: string) => Promise<void>;
readonly activate: (linkingKey: Uint8Array) => Promise<void>; readonly activate: (material: WalletMaterialV2) => Promise<void>;
}; };
export type SeedInstaller = ( export type SeedInstaller = (
seedPhrase: string, seedPhrase: string,
password?: string, password?: string,
restore?: (linkingKey: Uint8Array) => Promise<void>, restore?: (material: WalletMaterialV2) => Promise<void>,
) => Promise<void>; ) => Promise<void>;
export const createSeedInstaller = export const createSeedInstaller =
(options: SeedInstallerOptions): SeedInstaller => (options: SeedInstallerOptions): SeedInstaller =>
async (seedPhrase, password, restore) => { async (seedPhrase, password, restore) => {
if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.'); if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.');
const linkingKey = deriveWalletLinkingKey(seedPhrase); const material = deriveWalletMaterial(seedPhrase);
await options.prepareInstallation(ownerOf(linkingKey)); await options.prepareInstallation(walletMaterialOwnerId(material));
if (restore) await restore(linkingKey); if (restore) await restore(material);
await saveLinkingKey(linkingKey, password); // a restore engine may have installed a legacy linking-key record from
await options.activate(linkingKey); // 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 { 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 { stubLocalStorage } from '@/lnurlcash/test-utils';
import { bytesToHex } from '@noble/hashes/utils.js';
import { startWalletOwnerMonitor } from './walletOwnerMonitor'; import { startWalletOwnerMonitor } from './walletOwnerMonitor';
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); 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', () => { describe('wallet owner monitor', () => {
beforeEach(() => { beforeEach(() => {
@@ -13,19 +27,12 @@ describe('wallet owner monitor', () => {
stubLocalStorage(); 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 // Given an unlocked stale owner and a transition promise observed by the queue
const events = new EventTarget(); const events = new EventTarget();
vi.stubGlobal('window', events); vi.stubGlobal('window', events);
localStorage.setItem( await saveWalletMaterial(OTHER_MATERIAL);
'sattle_linking_key', expect(walletMaterialHash(OTHER_MATERIAL)).toBeTruthy();
JSON.stringify({
enc: false,
value: '09'.repeat(32),
ownerId: OTHER_OWNER_ID,
version: 1,
}),
);
const transition = Promise.resolve(); const transition = Promise.resolve();
const catchRejection = vi.spyOn(transition, 'catch'); const catchRejection = vi.spyOn(transition, 'catch');
startWalletOwnerMonitor({ startWalletOwnerMonitor({
@@ -34,14 +41,39 @@ describe('wallet owner monitor', () => {
runTransition: vi.fn().mockReturnValue(transition), 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( events.dispatchEvent(
Object.defineProperties(new Event('storage'), { 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 // Then the fire-and-forget transition has a rejection consumer
expect(catchRejection).toHaveBeenCalledOnce(); 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 { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents';
import type { WalletState } from './walletOwnerFence'; import type { WalletState } from './walletOwnerFence';
@@ -14,13 +14,16 @@ type WalletOwnerMonitor = Readonly<{
runTransition: (transition: () => Promise<void>) => Promise<void>; 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) => export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) =>
onSavedKeyStorageChange(() => { onSavedKeyStorageChange(() => {
const expected = monitor.snapshot(); const expected = monitor.snapshot();
if ( if (
expected.state !== 'unlocked' || expected.state !== 'unlocked' ||
expected.ownerId === null || expected.ownerId === null ||
savedKeyOwnerId() === expected.ownerId savedWalletMaterialOwnerId() === expected.ownerId
) { ) {
return; return;
} }
@@ -31,7 +34,7 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi
current.token !== expected.token || current.token !== expected.token ||
current.state !== 'unlocked' || current.state !== 'unlocked' ||
current.ownerId !== expected.ownerId || current.ownerId !== expected.ownerId ||
savedKeyOwnerId() === expected.ownerId savedWalletMaterialOwnerId() === expected.ownerId
) { ) {
return; return;
} }