mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(wallet): activate complete wallet material
This commit is contained in:
+6
-3
@@ -13,9 +13,12 @@ declare global {
|
||||
}
|
||||
|
||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
||||
// a password-encrypted one lands on 'locked' for the unlock screen, and no
|
||||
// key at all lands on 'none' for onboarding.
|
||||
// wallet store at app start - a plaintext-stored material record unlocks
|
||||
// straight away, a password-encrypted one lands on 'locked' for the unlock
|
||||
// screen, and no record at all lands on 'none' for onboarding. init() first
|
||||
// runs the alpha reset: unsupported legacy state (the linking-key-only
|
||||
// record or the pre-v2 bearer namespace) is wiped, never migrated, before
|
||||
// any route can expose it.
|
||||
export default defineBoot(async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.init();
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
savedWalletMaterialOwnerId,
|
||||
serializeWalletMaterial,
|
||||
walletMaterialHash,
|
||||
walletMaterialLinkingKey,
|
||||
type WalletMaterialV2,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
||||
@@ -188,12 +187,9 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
|
||||
};
|
||||
|
||||
export const enableBiometricUnlock = async (
|
||||
material: WalletMaterialV2 | Uint8Array,
|
||||
material: WalletMaterialV2,
|
||||
): Promise<void> => {
|
||||
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
||||
if (material instanceof Uint8Array) {
|
||||
throw new Error('Biometric enrollment requires complete v2 wallet material.');
|
||||
}
|
||||
const serialized = serializeWalletMaterial(material);
|
||||
const canonicalMaterial = parseWalletMaterial(serialized);
|
||||
if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.');
|
||||
@@ -306,9 +302,6 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMateri
|
||||
return material;
|
||||
};
|
||||
|
||||
export const unlockWithBiometrics = async (): Promise<Uint8Array> =>
|
||||
walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics());
|
||||
|
||||
// Native deletion goes first so a failure leaves the complete enrollment
|
||||
// available for the locked lifecycle to retry safely.
|
||||
export const disableBiometricUnlock = async (): Promise<void> => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// Legacy passkey slots may be adopted only after another unlock path has
|
||||
// proven and stamped the saved wallet owner. The linking key is checked
|
||||
// against that marker before markerless slots are changed under the lock.
|
||||
// The proven marker lives on the v2 wallet-material record; adoption itself
|
||||
// additionally requires the slot to commit to the exact saved material.
|
||||
|
||||
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||
import {linkingPubKeyHex} from './keys'
|
||||
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots'
|
||||
import {withStorageLock} from './storageLock'
|
||||
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
|
||||
|
||||
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
|
||||
const ownerId = savedKeyOwnerId()
|
||||
const ownerId = savedWalletMaterialOwnerId()
|
||||
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
|
||||
throw new Error('Legacy passkey migration requires a proven owner.')
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unlockWalletMaterialWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
@@ -149,11 +149,11 @@ describe('multiple passkeys', () => {
|
||||
it('keeps slots independent: each passkey unlocks the same key', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||
credentials: laptop,
|
||||
name: 'laptop',
|
||||
})
|
||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
||||
const phoneSlot = await registerPasskey(MATERIAL, {
|
||||
credentials: phone,
|
||||
name: 'phone',
|
||||
})
|
||||
@@ -162,25 +162,25 @@ describe('multiple passkeys', () => {
|
||||
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
|
||||
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
|
||||
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
|
||||
})
|
||||
|
||||
it('removePasskey drops exactly one slot and leaves the rest working', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||
credentials: laptop,
|
||||
})
|
||||
await registerPasskey(LINKING_KEY, {credentials: phone})
|
||||
await registerPasskey(MATERIAL, {credentials: phone})
|
||||
|
||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true)
|
||||
expect(readPasskeySlots()).toHaveLength(1)
|
||||
|
||||
// the removed passkey no longer matches any offered credential
|
||||
await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow('cancelled')
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: laptop})).rejects.toThrow('cancelled')
|
||||
// the survivor is unaffected
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
|
||||
// removing again is a no-op
|
||||
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
@@ -20,21 +20,22 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unlockWalletMaterialWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
import {
|
||||
decryptRecord,
|
||||
decryptSavedLinkingKey,
|
||||
decryptSavedWalletMaterial,
|
||||
deriveBearerAesKey,
|
||||
deriveWalletMaterial,
|
||||
ensureSavedKeyOwner,
|
||||
encryptRecord,
|
||||
ensureSavedWalletMaterialOwner,
|
||||
linkingPubKeyHex,
|
||||
savedKeyOwnerId,
|
||||
savedWalletMaterialOwnerId,
|
||||
saveLinkingKey,
|
||||
saveWalletMaterial,
|
||||
walletMaterialLinkingKey,
|
||||
} from './keys'
|
||||
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
||||
|
||||
@@ -133,10 +134,10 @@ const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
|
||||
}
|
||||
|
||||
const removeSavedOwnerMarker = (): void => {
|
||||
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')
|
||||
const stored = parseJsonObject(localStorage.getItem('sattle_wallet_material_v2') ?? '{}')
|
||||
delete stored.ownerId
|
||||
delete stored.version
|
||||
localStorage.setItem('sattle_linking_key', JSON.stringify(stored))
|
||||
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored))
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -151,7 +152,7 @@ describe('slot ownership', () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
|
||||
// When its linking key registers a passkey
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
|
||||
// Then the slot carries that same canonical owner
|
||||
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||
@@ -161,7 +162,7 @@ describe('slot ownership', () => {
|
||||
it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
|
||||
// Given one valid current-owner slot plus copies with untrusted owners
|
||||
const auth = new FakeAuthenticator()
|
||||
const current = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const current = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const foreign = {
|
||||
...current,
|
||||
credentialId: '11'.repeat(16),
|
||||
@@ -191,9 +192,9 @@ describe('slot ownership', () => {
|
||||
})
|
||||
|
||||
it('does not offer markerless slots for passkey-first unlock', async () => {
|
||||
// Given a legacy slot and a saved key with no proven owner marker
|
||||
// Given a legacy slot and a saved wallet with no proven owner marker
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const legacy = readRawSlots()
|
||||
delete legacy[0]?.ownerId
|
||||
delete legacy[0]?.version
|
||||
@@ -201,7 +202,7 @@ describe('slot ownership', () => {
|
||||
removeSavedOwnerMarker()
|
||||
|
||||
// When passkey unlock is attempted before another proof path
|
||||
const attempt = unlockWithPasskey({credentials: auth})
|
||||
const attempt = unlockWalletMaterialWithPasskey({credentials: auth})
|
||||
|
||||
// Then it fails before asking the authenticator
|
||||
await expect(attempt).rejects.toThrow('No passkeys')
|
||||
@@ -212,7 +213,7 @@ describe('slot ownership', () => {
|
||||
it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
|
||||
// Given markerless residue from the old wallet
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const legacy = readRawSlots()
|
||||
delete legacy[0]?.ownerId
|
||||
delete legacy[0]?.version
|
||||
@@ -231,24 +232,24 @@ describe('slot ownership', () => {
|
||||
it('adopts legacy slots only after the saved wallet owner is proven', async () => {
|
||||
// Given a legacy encrypted wallet and its markerless passkey slot
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const legacy = readRawSlots()
|
||||
delete legacy[0]?.ownerId
|
||||
delete legacy[0]?.version
|
||||
writeRawSlots(legacy)
|
||||
await saveLinkingKey(LINKING_KEY, 'correct horse')
|
||||
await saveWalletMaterial(MATERIAL, 'correct horse')
|
||||
removeSavedOwnerMarker()
|
||||
|
||||
// When migration is attempted before and then after password proof
|
||||
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||
const provenKey = await decryptSavedLinkingKey('correct horse')
|
||||
ensureSavedKeyOwner(provenKey)
|
||||
await migrateLegacyPasskeySlots(provenKey)
|
||||
const provenMaterial = await decryptSavedWalletMaterial('correct horse')
|
||||
ensureSavedWalletMaterialOwner(provenMaterial)
|
||||
await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial))
|
||||
|
||||
// Then the same slot is stamped once for that proven owner and unlocks
|
||||
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||
expect(readPasskeySlots()).toHaveLength(1)
|
||||
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
|
||||
await expect(unlockWithPasskey({credentials: auth})).resolves.toEqual(LINKING_KEY)
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).resolves.toEqual(MATERIAL)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unlockWalletMaterialWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
@@ -151,7 +151,7 @@ describe('slot ownership (continued)', () => {
|
||||
// Given a current-owner slot whose authenticated wrap was replaced with
|
||||
// a valid wrap of another wallet key
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
|
||||
credentials: auth,
|
||||
})
|
||||
@@ -159,7 +159,7 @@ describe('slot ownership (continued)', () => {
|
||||
writeRawSlots([{...slot, ...foreignWrap}])
|
||||
|
||||
// When the authenticator successfully unwraps that foreign key
|
||||
const attempt = unlockWithPasskey({credentials: auth})
|
||||
const attempt = unlockWalletMaterialWithPasskey({credentials: auth})
|
||||
|
||||
// Then owner validation rejects it before activation can receive it
|
||||
await expect(attempt).rejects.toThrow('different wallet')
|
||||
@@ -168,7 +168,7 @@ describe('slot ownership (continued)', () => {
|
||||
it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
|
||||
// Given an authenticator that replaces the saved wallet before returning
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const stale: PasskeyCredentials = {
|
||||
create: auth.create,
|
||||
get: async (options) => {
|
||||
@@ -180,7 +180,7 @@ describe('slot ownership (continued)', () => {
|
||||
}
|
||||
|
||||
// When the old wallet's ceremony completes after replacement
|
||||
const attempt = unlockWithPasskey({credentials: stale})
|
||||
const attempt = unlockWalletMaterialWithPasskey({credentials: stale})
|
||||
|
||||
// Then the old linking key is never returned for activation
|
||||
await expect(attempt).rejects.toThrow('different wallet')
|
||||
@@ -189,7 +189,7 @@ describe('slot ownership (continued)', () => {
|
||||
it('does not adopt a slot carrying a malformed owner marker', async () => {
|
||||
// Given an otherwise valid slot whose owner claim is malformed
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const malformed = {...slot, ownerId: 'not-an-owner'}
|
||||
writeRawSlots([malformed])
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('slot ownership (continued)', () => {
|
||||
it('cannot remove a foreign-owner slot', async () => {
|
||||
// Given a slot owned by another wallet remains in shared storage
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)}
|
||||
writeRawSlots([foreign])
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('slot ownership (continued)', () => {
|
||||
it('rewraps only current-owner slots and preserves foreign slots', async () => {
|
||||
// Given current and foreign slots share storage
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const foreign = {
|
||||
...slot,
|
||||
credentialId: '44'.repeat(16),
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unlockWalletMaterialWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
@@ -147,9 +147,9 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
describe('registration and unlock', () => {
|
||||
it('registers a passkey and unlocks the same linking key', async () => {
|
||||
it('registers a passkey and unlocks the same complete material', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {
|
||||
const slot = await registerPasskey(MATERIAL, {
|
||||
credentials: auth,
|
||||
name: 'laptop',
|
||||
})
|
||||
@@ -157,33 +157,32 @@ describe('registration and unlock', () => {
|
||||
expect(readPasskeySlots()).toEqual([slot])
|
||||
expect(hasPasskeySlots()).toBe(true)
|
||||
|
||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
||||
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||
expect(unwrapped).toEqual(MATERIAL)
|
||||
})
|
||||
|
||||
it('never stores the linking key in the clear', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const raw = localStorage.getItem('sattle_passkey_slots')
|
||||
expect(raw).toBeTruthy()
|
||||
expect(raw).not.toContain(bytesToHex(LINKING_KEY))
|
||||
})
|
||||
|
||||
it('yields the same key material unlock(password) yields', async () => {
|
||||
it('yields the same wallet material unlock(password) yields', async () => {
|
||||
await saveWalletMaterial(MATERIAL, 'correct horse')
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
|
||||
const viaPassword = await decryptSavedWalletMaterial('correct horse')
|
||||
const viaPasskey = await unlockWithPasskey({credentials: auth})
|
||||
const passwordLinkingKey = walletMaterialLinkingKey(viaPassword)
|
||||
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey))
|
||||
const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||
expect(viaPasskey).toEqual(viaPassword)
|
||||
|
||||
// and the practical consequence: a bearer record encrypted after a
|
||||
// password unlock decrypts after a passkey unlock
|
||||
const passwordAes = await deriveBearerAesKey(passwordLinkingKey)
|
||||
const passwordAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPassword))
|
||||
const record = await encryptRecord(passwordAes, {note: 'still readable'})
|
||||
const passkeyAes = await deriveBearerAesKey(viaPasskey)
|
||||
const passkeyAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPasskey))
|
||||
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
|
||||
note: 'still readable',
|
||||
})
|
||||
@@ -192,17 +191,17 @@ describe('registration and unlock', () => {
|
||||
it('falls back to a get ceremony when create only reports prf.enabled', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
auth.prfResultsOnCreate = false
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
expect(auth.getCalls).toBe(1)
|
||||
const unwrapped = await unlockWithPasskey({credentials: auth})
|
||||
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
|
||||
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth})
|
||||
expect(unwrapped).toEqual(MATERIAL)
|
||||
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId)
|
||||
})
|
||||
|
||||
it('refuses registration when the authenticator has no PRF support', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
auth.supportsPrf = false
|
||||
await expect(registerPasskey(LINKING_KEY, {credentials: auth})).rejects.toThrow('PRF')
|
||||
await expect(registerPasskey(MATERIAL, {credentials: auth})).rejects.toThrow('PRF')
|
||||
expect(hasPasskeySlots()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -211,7 +210,7 @@ describe('registration and unlock', () => {
|
||||
create: async () => null,
|
||||
get: async () => null,
|
||||
}
|
||||
await expect(registerPasskey(LINKING_KEY, {credentials: cancelled})).rejects.toThrow(
|
||||
await expect(registerPasskey(MATERIAL, {credentials: cancelled})).rejects.toThrow(
|
||||
'cancelled',
|
||||
)
|
||||
expect(hasPasskeySlots()).toBe(false)
|
||||
@@ -219,20 +218,24 @@ describe('registration and unlock', () => {
|
||||
|
||||
it('throws before any ceremony when no passkeys are registered', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('No passkeys')
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow(
|
||||
'No passkeys',
|
||||
)
|
||||
expect(auth.getCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects unlock when the passkey returns no PRF secret', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
auth.prfResultsOnGet = false
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow('PRF secret')
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow(
|
||||
'PRF secret',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unlock when the ceremony yields an unregistered credential', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const rogue: PasskeyCredentials = {
|
||||
create: async () => null,
|
||||
get: async () => ({
|
||||
@@ -243,13 +246,15 @@ describe('registration and unlock', () => {
|
||||
}),
|
||||
}),
|
||||
}
|
||||
await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow('not registered')
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: rogue})).rejects.toThrow(
|
||||
'not registered',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
auth.rotateSecret(slot.credentialId)
|
||||
await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow()
|
||||
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unlockWalletMaterialWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
@@ -149,10 +149,10 @@ describe('rewrap for the current owner', () => {
|
||||
it('refreshes every current-owner wrap all-or-nothing', async () => {
|
||||
const laptop = new FakeAuthenticator()
|
||||
const phone = new FakeAuthenticator()
|
||||
const laptopSlot = await registerPasskey(LINKING_KEY, {
|
||||
const laptopSlot = await registerPasskey(MATERIAL, {
|
||||
credentials: laptop,
|
||||
})
|
||||
const phoneSlot = await registerPasskey(LINKING_KEY, {
|
||||
const phoneSlot = await registerPasskey(MATERIAL, {
|
||||
credentials: phone,
|
||||
})
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('rewrap for the current owner', () => {
|
||||
],
|
||||
])
|
||||
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap')
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
|
||||
|
||||
// full coverage refreshes both wraps around the same proven owner key
|
||||
const fresh = new Map([
|
||||
@@ -184,8 +184,8 @@ describe('rewrap for the current owner', () => {
|
||||
],
|
||||
])
|
||||
await rewrapAllSlots(MATERIAL, fresh)
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL)
|
||||
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL)
|
||||
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
|
||||
// credential ids and labels survive the re-wrap
|
||||
expect(
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
@@ -148,7 +147,7 @@ beforeEach(async () => {
|
||||
describe('slot storage hygiene', () => {
|
||||
it('drops malformed entries instead of throwing', async () => {
|
||||
const auth = new FakeAuthenticator()
|
||||
const slot = await registerPasskey(LINKING_KEY, {credentials: auth})
|
||||
const slot = await registerPasskey(MATERIAL, {credentials: auth})
|
||||
const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]')
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
rewrapAllSlots,
|
||||
unlockWithPasskey,
|
||||
unwrapWalletMaterialWithPrf,
|
||||
wrapWalletMaterialWithPrf,
|
||||
} from './passkeys'
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||
|
||||
import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys'
|
||||
import {withStorageLock} from './storageLock'
|
||||
import type {PasskeySlot} from './storage/passkeySlots'
|
||||
import {
|
||||
@@ -154,27 +153,15 @@ export type RegisterPasskeyOptions = {
|
||||
}
|
||||
|
||||
// Registers a new passkey and persists a slot wrapping the complete material.
|
||||
// The Uint8Array input exists only until Task 5 updates the UI caller; it can
|
||||
// select already-saved plaintext v2 material but can never create a key-only
|
||||
// slot. The ceremony is navigator.credentials.create with the PRF
|
||||
// The ceremony is navigator.credentials.create with the PRF
|
||||
// extension evaluated on creation. Some authenticators only report
|
||||
// prf.enabled during create and evaluate the secret on the first get -
|
||||
// those get a follow-up get() against the fresh credential.
|
||||
export const registerPasskey = async (
|
||||
material: WalletMaterialV2 | Uint8Array,
|
||||
material: WalletMaterialV2,
|
||||
options: RegisterPasskeyOptions = {},
|
||||
): Promise<PasskeySlot> => {
|
||||
let walletMaterial: WalletMaterialV2
|
||||
if (material instanceof Uint8Array) {
|
||||
const savedMaterial = getPlainWalletMaterial()
|
||||
if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) {
|
||||
throw new Error('Passkey registration requires complete v2 wallet material.')
|
||||
}
|
||||
walletMaterial = savedMaterial
|
||||
} else {
|
||||
walletMaterial = material
|
||||
}
|
||||
const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial)
|
||||
const ownerId = requireCurrentPasskeyMaterialOwner(material)
|
||||
const credentials = options.credentials ?? defaultCredentials()
|
||||
const credential = await credentials.create({
|
||||
publicKey: {
|
||||
@@ -209,7 +196,7 @@ export const registerPasskey = async (
|
||||
}
|
||||
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
|
||||
}
|
||||
const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial)
|
||||
const wrap = await wrapWalletMaterialWithPrf(prfOutput, material)
|
||||
const slot: PasskeySlot = {
|
||||
credentialId,
|
||||
...wrap,
|
||||
@@ -219,7 +206,7 @@ export const registerPasskey = async (
|
||||
version: PASSKEY_SLOT_VERSION,
|
||||
}
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) {
|
||||
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
|
||||
throw new Error('Saved wallet material changed during passkey registration.')
|
||||
}
|
||||
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
||||
@@ -286,13 +273,6 @@ export const unlockWalletMaterialWithPasskey = async (
|
||||
return material
|
||||
}
|
||||
|
||||
// Task 5 replaces this temporary projection when lifecycle activation accepts
|
||||
// complete wallet material. Slots already contain only canonical v2 material.
|
||||
export const unlockWithPasskey = async (
|
||||
options: {credentials?: PasskeyCredentials} = {},
|
||||
): Promise<Uint8Array> =>
|
||||
walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options))
|
||||
|
||||
// Removes the slot only: WebAuthn has no API to delete the credential from
|
||||
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
|
||||
// Returns whether a slot was actually removed.
|
||||
|
||||
@@ -3,10 +3,10 @@ import {bytesToHex} from '@noble/hashes/utils.js'
|
||||
|
||||
import {
|
||||
deriveWalletMaterial,
|
||||
ensureSavedKeyOwner,
|
||||
getPlainLinkingKey,
|
||||
ensureSavedWalletMaterialOwner,
|
||||
getPlainWalletMaterial,
|
||||
linkingPubKeyHex,
|
||||
savedKeyOwnerId,
|
||||
savedWalletMaterialOwnerId,
|
||||
saveWalletMaterial,
|
||||
walletMaterialHash,
|
||||
} from './keys'
|
||||
@@ -16,7 +16,7 @@ import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
|
||||
const KEY_STORAGE = 'sattle_linking_key'
|
||||
const MATERIAL_STORAGE = 'sattle_wallet_material_v2'
|
||||
const SLOT_STORAGE = 'sattle_passkey_slots'
|
||||
const MATERIAL = {
|
||||
...deriveWalletMaterial(
|
||||
@@ -38,18 +38,24 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
|
||||
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots))
|
||||
}
|
||||
|
||||
// strips the v2 record's owner marker back to the unproven post-restore form
|
||||
const removeSavedOwnerMarker = (): void => {
|
||||
const stored: unknown = JSON.parse(localStorage.getItem(MATERIAL_STORAGE) ?? '{}')
|
||||
if (typeof stored !== 'object' || stored === null) throw new TypeError('expected a record')
|
||||
const stripped: Record<string, unknown> = {...stored}
|
||||
delete stripped.ownerId
|
||||
delete stripped.version
|
||||
localStorage.setItem(MATERIAL_STORAGE, JSON.stringify(stripped))
|
||||
}
|
||||
|
||||
describe('passkey-slot schema version', () => {
|
||||
beforeEach(async () => {
|
||||
stubLocalStorage()
|
||||
await saveWalletMaterial(MATERIAL)
|
||||
})
|
||||
it('reads a current version 2 slot for the exact saved owner', () => {
|
||||
// Given a current saved-key marker and passkey record
|
||||
// Given a current saved-material owner marker and passkey record
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2}
|
||||
writeRawSlots([slot])
|
||||
|
||||
@@ -58,24 +64,22 @@ describe('passkey-slot schema version', () => {
|
||||
expect(readPasskeySlots()).toEqual([slot])
|
||||
})
|
||||
|
||||
it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => {
|
||||
// Given records written before schema versioning
|
||||
it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => {
|
||||
// Given records written before schema versioning and a saved material
|
||||
// whose owner marker was never proven (the post-restore form)
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
|
||||
)
|
||||
removeSavedOwnerMarker()
|
||||
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}])
|
||||
|
||||
// When migration is attempted before and after the saved key proves ownership
|
||||
// When migration is attempted before and after the saved material proves ownership
|
||||
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner')
|
||||
const provenKey = getPlainLinkingKey()
|
||||
if (provenKey === null) throw new Error('expected the compatible plaintext key')
|
||||
ensureSavedKeyOwner(provenKey)
|
||||
await migrateLegacyPasskeySlots(provenKey)
|
||||
const provenMaterial = getPlainWalletMaterial()
|
||||
if (provenMaterial === null) throw new Error('expected the compatible plaintext material')
|
||||
ensureSavedWalletMaterialOwner(provenMaterial)
|
||||
await migrateLegacyPasskeySlots(LINKING_KEY)
|
||||
|
||||
// Then both compatible legacy forms become current records
|
||||
expect(savedKeyOwnerId()).toBe(ownerId)
|
||||
expect(savedWalletMaterialOwnerId()).toBe(ownerId)
|
||||
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
|
||||
{...SLOT_BASE, ownerId, version: 2},
|
||||
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2},
|
||||
@@ -85,10 +89,6 @@ describe('passkey-slot schema version', () => {
|
||||
it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => {
|
||||
// Given an otherwise valid slot carrying unrecognized metadata
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
writeRawSlots([{...SLOT_BASE, ownerId, version}])
|
||||
|
||||
// When slots are parsed
|
||||
@@ -98,11 +98,6 @@ describe('passkey-slot schema version', () => {
|
||||
|
||||
it('hides a foreign version 1 slot from the current owner', () => {
|
||||
// Given the saved owner and slot owner differ
|
||||
const ownerId = linkingPubKeyHex(LINKING_KEY)
|
||||
localStorage.setItem(
|
||||
KEY_STORAGE,
|
||||
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
|
||||
)
|
||||
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}])
|
||||
|
||||
// When the current wallet reads passkeys
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// Mutators must not let a still-running old tab overwrite namespaces after a
|
||||
// successor has installed its saved-key owner marker. Ownerless data may only
|
||||
// move through explicit migration APIs after their own proof checks succeed.
|
||||
//
|
||||
// The canonical owner lives on the v2 wallet-material record. The legacy
|
||||
// linking-key record is accepted as a fallback so engine-level consumers and
|
||||
// their harnesses keep working while the lifecycle cuts over; the cut-over
|
||||
// lifecycle itself never writes that record and deletes it at install,
|
||||
// reset, backup-restore, and teardown time, so in the shipped app only the
|
||||
// v2 branch can ever match.
|
||||
|
||||
import {savedKeyOwnerId} from '../keys'
|
||||
import {savedWalletMaterialOwnerId} from '../walletMaterialStorage'
|
||||
|
||||
export class WalletOwnerMismatchError extends Error {
|
||||
override readonly name = 'WalletOwnerMismatchError'
|
||||
@@ -12,7 +20,7 @@ export class WalletOwnerMismatchError extends Error {
|
||||
}
|
||||
|
||||
export const savedKeyOwnerAllows = (ownerId: string): boolean => {
|
||||
return savedKeyOwnerId() === ownerId
|
||||
return savedWalletMaterialOwnerId() === ownerId || savedKeyOwnerId() === ownerId
|
||||
}
|
||||
|
||||
export const assertSavedKeyOwner = (ownerId: string): void => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// parsed before use; ownerless v0 records remain hidden until an already
|
||||
// proven saved wallet explicitly migrates them.
|
||||
|
||||
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys'
|
||||
import {linkingPubKeyHex} from '../keys'
|
||||
import {
|
||||
clearNwcEnabledForOwner,
|
||||
clearUnownedNwcEnabled,
|
||||
@@ -177,7 +177,7 @@ export type NwcLegacyMigrationResult = {
|
||||
|
||||
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
|
||||
const ownerId = linkingPubKeyHex(linkingKey)
|
||||
if (savedKeyOwnerId() !== ownerId) {
|
||||
if (!savedKeyOwnerAllows(ownerId)) {
|
||||
throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
removeStoredTrustedMintsForOwner,
|
||||
resetStoredTrustedMints,
|
||||
} from './trustedMintsRepository'
|
||||
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
|
||||
import {linkingPubKeyHex} from './keys'
|
||||
import {savedKeyOwnerAllows} from './storage/currentOwner'
|
||||
import {
|
||||
addMint,
|
||||
cacheMintNodeInfo,
|
||||
@@ -259,7 +260,7 @@ export const clearTrustedMints = (ownerId?: string): Promise<void> =>
|
||||
|
||||
export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise<number> => {
|
||||
const ownerId = linkingPubKeyHex(linkingKey)
|
||||
if (savedKeyOwnerId() !== ownerId) {
|
||||
if (!savedKeyOwnerAllows(ownerId)) {
|
||||
throw new Error('Legacy trusted-mint migration requires a proven owner.')
|
||||
}
|
||||
return adoptLegacyStoredTrustedMints(ownerId)
|
||||
|
||||
@@ -249,7 +249,7 @@ const doEnableBiometric = async () => {
|
||||
biometricBusy.value = true;
|
||||
banner.value = '';
|
||||
try {
|
||||
await enableBiometricUnlock(wallet.requireLinkingKey());
|
||||
await enableBiometricUnlock(wallet.requireWalletMaterial());
|
||||
biometricEnrolled.value = true;
|
||||
toast('positive', 'Biometric unlock enabled.');
|
||||
} catch (err) {
|
||||
@@ -289,7 +289,7 @@ const doRegister = async () => {
|
||||
banner.value = '';
|
||||
try {
|
||||
const name = registerName.value.trim();
|
||||
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {});
|
||||
await registerPasskey(wallet.requireWalletMaterial(), name ? { name } : {});
|
||||
slots.value = readPasskeySlots();
|
||||
registering.value = false;
|
||||
toast('positive', 'Passkey added.');
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createPinia, disposePinia, setActivePinia } from 'pinia';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import { deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial } from '@/lnurlcash/keys';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import { useMintsStore } from './mints';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
const LINKING_KEY_HEX = '07'.repeat(32);
|
||||
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
|
||||
const MATERIAL = {
|
||||
...deriveWalletMaterial(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
),
|
||||
linkingKeyHex: bytesToHex(new Uint8Array(32).fill(7)),
|
||||
};
|
||||
const MINT_KEY = '02' + 'aa'.repeat(32);
|
||||
let testPinia: ReturnType<typeof createPinia>;
|
||||
|
||||
@@ -34,10 +40,7 @@ describe('mints store storage-event convergence', () => {
|
||||
// Given an unlocked wallet and its mounted mints store
|
||||
const events = new EventTarget();
|
||||
vi.stubGlobal('window', events);
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({ enc: false, value: LINKING_KEY_HEX, ownerId: OWNER_ID, version: 1 }),
|
||||
);
|
||||
await saveWalletMaterial(MATERIAL);
|
||||
const wallet = useWalletStore();
|
||||
await wallet.init();
|
||||
const mints = useMintsStore();
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
OWNER_ID,
|
||||
PASSWORD,
|
||||
deferred,
|
||||
installLegacyEncryptedWallet,
|
||||
installLegacyOwnerlessResidue,
|
||||
installOwnerlessEncryptedWalletMaterial,
|
||||
mocks,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { savedWalletMaterialExists, savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||
import { readNwcConnections } from '@/lnurlcash/nwc';
|
||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
@@ -17,8 +17,8 @@ import { useWalletStore } from './wallet';
|
||||
|
||||
describe('serialized wallet activation', () => {
|
||||
it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
|
||||
// Given an encrypted legacy wallet with ownerless authorization residue
|
||||
await installLegacyEncryptedWallet();
|
||||
// Given a restored (ownerless) v2 wallet with ownerless authorization residue
|
||||
await installOwnerlessEncryptedWalletMaterial();
|
||||
installLegacyOwnerlessResidue();
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
@@ -27,7 +27,7 @@ describe('serialized wallet activation', () => {
|
||||
await wallet.unlock(PASSWORD);
|
||||
|
||||
// Then every legacy namespace belongs to the proven owner before startup
|
||||
expect(savedKeyOwnerId()).toBe(OWNER_ID);
|
||||
expect(savedWalletMaterialOwnerId()).toBe(OWNER_ID);
|
||||
expect(readPasskeySlots()).toHaveLength(1);
|
||||
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
||||
expect(readTrustedMints(OWNER_ID)).toHaveLength(1);
|
||||
@@ -50,7 +50,7 @@ describe('serialized wallet activation', () => {
|
||||
|
||||
// Then the successor cannot install until the old owner drain completes,
|
||||
// and the session keeps its commit capability while the drain runs
|
||||
expect(savedKeyExists()).toBe(true);
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
drain.resolve();
|
||||
await forgetting;
|
||||
@@ -70,9 +70,10 @@ describe('serialized wallet activation', () => {
|
||||
// When a valid file restore starts
|
||||
const restoring = wallet.restoreFromBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
version: 2,
|
||||
createdAt: 1,
|
||||
bearers: [],
|
||||
nextByHost: {},
|
||||
});
|
||||
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import {
|
||||
MINT_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
PASSWORD,
|
||||
encryptedLinkingKeyRecord,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { savedKeyExists, savedWalletMaterialExists } from '@/lnurlcash/keys';
|
||||
import { addTrustedMint } from '@/lnurlcash/trustedMints';
|
||||
import { useNwcStore } from './nwc';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('hostile file backup owner', () => {
|
||||
it('drops file trust until the restored encrypted key proves its actual owner', async () => {
|
||||
it('never installs or namespaces trust from a file-carried legacy key', async () => {
|
||||
// Given a fresh device and an encrypted backup whose valid owner claim is foreign
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
const result = await wallet.restoreFromBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
version: 2,
|
||||
createdAt: 1,
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID },
|
||||
bearers: [],
|
||||
nextByHost: {},
|
||||
trustedMints: [
|
||||
{
|
||||
server: 'file-mint.example',
|
||||
@@ -34,24 +34,32 @@ describe('hostile file backup owner', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const registryBeforeProof: unknown = JSON.parse(
|
||||
localStorage.getItem('sattle_trusted_mints') ?? 'null',
|
||||
);
|
||||
|
||||
// Then the merge reports what the file carried, but the v2 lifecycle
|
||||
// cannot activate a linking-key-only record (the cash root is
|
||||
// unrecoverable from it): the residue is removed instead of leaving a
|
||||
// half-installed locked wallet behind
|
||||
expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 });
|
||||
expect(savedKeyOwnerId()).toBeNull();
|
||||
expect(registryBeforeProof).toBeNull();
|
||||
expect(savedKeyExists()).toBe(false);
|
||||
expect(savedWalletMaterialExists()).toBe(false);
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||
|
||||
// When password proof activates the restored linking key
|
||||
await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined();
|
||||
// And no unlock can ever succeed against the file's claim
|
||||
await expect(wallet.unlock(PASSWORD)).rejects.toThrow();
|
||||
expect(wallet.state).toBe('none');
|
||||
|
||||
// Then the file claim left no residue and only the derived owner can initialize trust
|
||||
expect(readTrustedMints(OTHER_OWNER_ID)).toEqual([]);
|
||||
expect(readTrustedMints(OWNER_ID)).toEqual([]);
|
||||
// When the holder installs their own wallet afterward
|
||||
await wallet.create(PASSWORD);
|
||||
const ownerId = wallet.pubkey;
|
||||
if (ownerId === null) throw new Error('Expected an unlocked owner.');
|
||||
|
||||
// Then only the device's own proven wallet can initialize trust
|
||||
await expect(
|
||||
addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId: OWNER_ID }),
|
||||
addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId }),
|
||||
).resolves.toBe('added');
|
||||
expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual(
|
||||
expect.objectContaining({ ownerId: OWNER_ID }),
|
||||
expect.objectContaining({ ownerId }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { MINT_KEY, mocks, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness';
|
||||
import {
|
||||
MINT_KEY,
|
||||
OTHER_MATERIAL,
|
||||
OTHER_OWNER_ID,
|
||||
PASSWORD,
|
||||
mocks,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { saveWalletMaterial, savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||
import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc';
|
||||
import type { NwcConnectionRecord } from '@/lnurlcash/nwc';
|
||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
@@ -19,20 +25,18 @@ describe('cross-tab owner invalidation', () => {
|
||||
if (oldOwner === null) throw new Error('Expected an unlocked old owner.');
|
||||
|
||||
// When another tab has already recreated the wallet and a delayed event arrives
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
|
||||
);
|
||||
await saveWalletMaterial(OTHER_MATERIAL);
|
||||
events.dispatchEvent(
|
||||
Object.defineProperties(new Event('storage'), {
|
||||
key: { value: 'sattle_linking_key' },
|
||||
key: { value: 'sattle_wallet_material_v2' },
|
||||
// a stale payload on purpose: the monitor must re-read storage
|
||||
newValue: { value: JSON.stringify({ ownerId: oldOwner }) },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
||||
|
||||
// Then the stale runtime has no usable key and cannot recreate old-owner state
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
// Then the stale runtime has no usable material and cannot recreate old-owner state
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
await expect(addTrustedMint('stale.example', MINT_KEY, { ownerId: oldOwner })).rejects.toThrow(
|
||||
/owner/i,
|
||||
);
|
||||
@@ -54,20 +58,17 @@ describe('cross-tab owner invalidation', () => {
|
||||
await nwc.setEnabled(true);
|
||||
|
||||
// When another tab replaces the saved wallet owner
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }),
|
||||
);
|
||||
await saveWalletMaterial(OTHER_MATERIAL);
|
||||
events.dispatchEvent(
|
||||
Object.defineProperties(new Event('storage'), {
|
||||
key: { value: 'sattle_linking_key' },
|
||||
key: { value: 'sattle_wallet_material_v2' },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(wallet.state).toBe('locked'));
|
||||
|
||||
// Then the stale runtime is cleared and the queue surfaces the drain failure
|
||||
expect(wallet.lifecycleError).toMatch(/stale drain failed/i);
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
expect(nwc.running).toBe(false);
|
||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -90,7 +91,7 @@ describe('cross-tab owner invalidation', () => {
|
||||
|
||||
// When A is forgotten before successor B is installed
|
||||
await wallet.forgetWallet();
|
||||
expect(savedKeyOwnerId()).toBeNull();
|
||||
expect(savedWalletMaterialOwnerId()).toBeNull();
|
||||
|
||||
// Then no stale normal mutation can recreate A-owned state in the gap
|
||||
await expect(
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateSeedPhrase, savedKeyExists } from '@/lnurlcash/keys';
|
||||
import {
|
||||
generateSeedPhrase,
|
||||
savedWalletMaterialExists,
|
||||
walletMaterialHash,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc';
|
||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
@@ -48,7 +52,7 @@ describe('foreign wallet isolation', () => {
|
||||
// Then it fails explicitly and does not install a partial wallet
|
||||
await expect(restoring).rejects.toThrow(/valid sattle backup/i);
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(savedKeyExists()).toBe(false);
|
||||
expect(savedWalletMaterialExists()).toBe(false);
|
||||
});
|
||||
|
||||
it('tears down the installed owner before a foreign seed restore', async () => {
|
||||
@@ -65,9 +69,11 @@ describe('foreign wallet isolation', () => {
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
|
||||
wrappedMaterial: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
ownerId: oldOwner,
|
||||
version: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
LEGACY_BEARERS_KEY,
|
||||
LEGACY_LINKING_KEY,
|
||||
MATERIAL,
|
||||
OWNER_ID,
|
||||
WALLET_MATERIAL_KEY,
|
||||
installLegacyOwnerlessResidue,
|
||||
installLegacyWalletState,
|
||||
mocks,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { saveWalletMaterial, savedWalletMaterialExists } from '@/lnurlcash/keys';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('unsupported legacy install reset', () => {
|
||||
it('resets legacy saved-key and bearer state before routing', async () => {
|
||||
// Given an alpha install: the linking-key-only record, the legacy bearer
|
||||
// namespace, and ownerless authorization residue
|
||||
installLegacyWalletState();
|
||||
installLegacyOwnerlessResidue();
|
||||
|
||||
// When the store is constructed, it must not advertise the legacy wallet
|
||||
const wallet = useWalletStore();
|
||||
expect(wallet.state).toBe('none');
|
||||
|
||||
// When startup initialization runs
|
||||
await wallet.init();
|
||||
|
||||
// Then every legacy namespace is gone and nothing was activated
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||
expect(localStorage.getItem(WALLET_MATERIAL_KEY)).toBeNull();
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(mocks.disableBiometricUnlock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets on a corrupt legacy record without parsing it', async () => {
|
||||
// Given a legacy entry that no parser can read
|
||||
localStorage.setItem(LEGACY_LINKING_KEY, '{{{not json');
|
||||
const wallet = useWalletStore();
|
||||
expect(wallet.state).toBe('none');
|
||||
|
||||
// When startup initialization runs
|
||||
await wallet.init();
|
||||
|
||||
// Then the reset keyed on raw presence, not on a successful parse
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
expect(wallet.state).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps a v2 install and only removes stray legacy key residue', async () => {
|
||||
// Given a current plaintext v2 wallet plus a stray legacy record (an old
|
||||
// tab or a restored backup left it behind)
|
||||
await saveWalletMaterial(MATERIAL);
|
||||
localStorage.setItem(
|
||||
LEGACY_LINKING_KEY,
|
||||
JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: 'ab'.repeat(33), version: 1 }),
|
||||
);
|
||||
const wallet = useWalletStore();
|
||||
expect(wallet.state).toBe('locked');
|
||||
|
||||
// When startup initialization runs
|
||||
await wallet.init();
|
||||
|
||||
// Then the v2 wallet activated and only the legacy residue is gone
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
expect(wallet.pubkey).toBe(OWNER_ID);
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves legacy state intact and retryable when native biometric deletion fails', async () => {
|
||||
// Given an alpha install whose secure-storage deletion rejects
|
||||
installLegacyWalletState();
|
||||
const wallet = useWalletStore();
|
||||
mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed'));
|
||||
|
||||
// When startup initialization runs
|
||||
await wallet.init();
|
||||
|
||||
// Then the failure is surfaced, nothing was half-wiped, and the legacy
|
||||
// state is still all there for the next attempt
|
||||
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull();
|
||||
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).not.toBeNull();
|
||||
|
||||
// When initialization runs again with native deletion succeeding
|
||||
await wallet.init();
|
||||
|
||||
// Then the reset completes
|
||||
expect(wallet.lifecycleError).toBe('');
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('retries the legacy reset before installing a successor wallet', async () => {
|
||||
// Given an alpha install whose first reset attempt failed at native
|
||||
// biometric deletion
|
||||
installLegacyWalletState();
|
||||
const wallet = useWalletStore();
|
||||
mocks.disableBiometricUnlock.mockRejectedValueOnce(new Error('secure delete failed'));
|
||||
await wallet.init();
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).not.toBeNull();
|
||||
|
||||
// When the holder installs a fresh wallet
|
||||
const phrase = await wallet.create();
|
||||
|
||||
// Then the pending reset ran first: no legacy residue survives into the
|
||||
// successor installation
|
||||
expect(phrase.split(' ')).toHaveLength(12);
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(LEGACY_BEARERS_KEY)).toBeNull();
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
LEGACY_LINKING_KEY,
|
||||
MATERIAL,
|
||||
MINT_KEY,
|
||||
OTHER_MATERIAL,
|
||||
PASSWORD,
|
||||
WALLET_MATERIAL_KEY,
|
||||
deferredLocks,
|
||||
installEncryptedWalletMaterial,
|
||||
installLegacyOwnerlessResidue,
|
||||
installOwnerlessEncryptedWalletMaterial,
|
||||
mocks,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
|
||||
import {
|
||||
deriveBearerAesKey,
|
||||
encryptRecord,
|
||||
saveWalletMaterial,
|
||||
savedWalletMaterialExists,
|
||||
savedWalletMaterialHash,
|
||||
savedWalletMaterialOwnerId,
|
||||
walletMaterialHash,
|
||||
walletMaterialLinkingKey,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('complete material activation', () => {
|
||||
it('persists v2 material on create and activates the same value on every unlock factor', async () => {
|
||||
// Given a wallet created through the lifecycle
|
||||
const wallet = useWalletStore();
|
||||
const phrase = await wallet.create(PASSWORD);
|
||||
expect(phrase.split(' ')).toHaveLength(12);
|
||||
|
||||
// Then the v2 envelope is the only saved secret record
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
expect(localStorage.getItem(LEGACY_LINKING_KEY)).toBeNull();
|
||||
const active = wallet.requireWalletMaterial();
|
||||
expect(savedWalletMaterialHash()).toBe(walletMaterialHash(active));
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
|
||||
// When the wallet is unlocked through each factor in turn
|
||||
await wallet.lock();
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
await wallet.unlock(PASSWORD);
|
||||
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||
|
||||
await wallet.lock();
|
||||
mocks.unlockWalletMaterialWithPasskey.mockResolvedValue(active);
|
||||
await wallet.unlockWithPasskey();
|
||||
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||
|
||||
await wallet.lock();
|
||||
mocks.unlockWalletMaterialWithBiometrics.mockResolvedValue(active);
|
||||
await wallet.unlockWithBiometric();
|
||||
|
||||
// Then every factor activated the exact same complete material
|
||||
expect(wallet.requireWalletMaterial()).toEqual(active);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
});
|
||||
|
||||
it('keeps the material private and memory-only while unlocked', async () => {
|
||||
// Given an encrypted wallet
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create(PASSWORD);
|
||||
|
||||
// Then the persisted record holds only the wrap, never plaintext material
|
||||
const stored = JSON.parse(localStorage.getItem(WALLET_MATERIAL_KEY) ?? '{}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(stored.enc).toBe(true);
|
||||
expect(stored.value).toBeUndefined();
|
||||
|
||||
// And locking drops every runtime reference to it
|
||||
await wallet.lock();
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
expect(wallet.state).toBe('locked');
|
||||
});
|
||||
|
||||
it('fences activation when the saved owner is replaced during deferred loading', async () => {
|
||||
// Given a restored (ownerless) wallet whose activation pauses inside the
|
||||
// legacy-adoption storage lock
|
||||
await installOwnerlessEncryptedWalletMaterial();
|
||||
installLegacyOwnerlessResidue();
|
||||
const deferred = deferredLocks();
|
||||
vi.stubGlobal('navigator', { locks: deferred.locks });
|
||||
const wallet = useWalletStore();
|
||||
|
||||
// When the saved material is replaced by a different owner mid-activation
|
||||
const unlocking = wallet.unlock(PASSWORD);
|
||||
await vi.waitFor(() => expect(deferred.requests).toHaveLength(1));
|
||||
await saveWalletMaterial(OTHER_MATERIAL);
|
||||
await deferred.releaseNext();
|
||||
|
||||
// Then activation rejects, no runtime material survives, and the
|
||||
// replacement's locked record is what remains
|
||||
await expect(unlocking).rejects.toThrow();
|
||||
expect(wallet.state).toBe('locked');
|
||||
expect(savedWalletMaterialOwnerId()).not.toBeNull();
|
||||
expect(wallet.pubkey).toBeNull();
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
expect(wallet.bearers).toEqual([]);
|
||||
});
|
||||
|
||||
it('fences activation on a final pre-unlocked owner re-read after deferred trust work', async () => {
|
||||
// Given an owned wallet holding a mint-pinned bearer whose trust
|
||||
// restoration pauses inside the trusted-mints storage lock
|
||||
await installEncryptedWalletMaterial();
|
||||
const aesKey = await deriveBearerAesKey(walletMaterialLinkingKey(MATERIAL));
|
||||
const bearer = {
|
||||
url: buildNoteUrl('https://mint.example/w', 'bb'.repeat(32), 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
mintPubkey: MINT_KEY,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const record = await encryptRecord(aesKey, bearer);
|
||||
localStorage.setItem(
|
||||
FUNDS_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
bearers: [{ id: 'b1', ...record }],
|
||||
pending: [],
|
||||
nextByHost: {},
|
||||
revision: 0,
|
||||
}),
|
||||
);
|
||||
const deferred = deferredLocks();
|
||||
vi.stubGlobal('navigator', { locks: deferred.locks });
|
||||
const wallet = useWalletStore();
|
||||
|
||||
// When the saved owner is replaced while trust restoration is suspended
|
||||
const unlocking = wallet.unlock(PASSWORD);
|
||||
await vi.waitFor(() => expect(deferred.requests).toHaveLength(1));
|
||||
await saveWalletMaterial(OTHER_MATERIAL);
|
||||
await deferred.releaseNext();
|
||||
|
||||
// Then the pre-unlocked re-read rejects the stale activation before the
|
||||
// wallet ever exposes an unlocked state
|
||||
await expect(unlocking).rejects.toThrow();
|
||||
expect(wallet.state).toBe('locked');
|
||||
expect(wallet.pubkey).toBeNull();
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
expect(wallet.bearers).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves no half-installed wallet when the material write fails during create', async () => {
|
||||
// Given a store whose material write hits a full disk
|
||||
const wallet = useWalletStore();
|
||||
const setItem = localStorage.setItem.bind(localStorage);
|
||||
localStorage.setItem = (key: string, value: string): void => {
|
||||
if (key === WALLET_MATERIAL_KEY) throw new Error('quota exceeded');
|
||||
setItem(key, value);
|
||||
};
|
||||
|
||||
// When create cannot persist the complete material
|
||||
await expect(wallet.create(PASSWORD)).rejects.toThrow(/quota exceeded/);
|
||||
|
||||
// Then nothing is installed and no runtime material lingers
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(savedWalletMaterialExists()).toBe(false);
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
|
||||
// And the transition is retryable once storage works again
|
||||
localStorage.setItem = setItem;
|
||||
const phrase = await wallet.create(PASSWORD);
|
||||
expect(phrase.split(' ')).toHaveLength(12);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
});
|
||||
|
||||
it('rejects unlock without saved material and stays at none', async () => {
|
||||
// Given no wallet on this device
|
||||
const wallet = useWalletStore();
|
||||
|
||||
// When an unlock is attempted anyway
|
||||
await expect(wallet.unlock(PASSWORD)).rejects.toThrow();
|
||||
|
||||
// Then the device stays uninstalled and retryable
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MINT_KEY, PASSWORD, deferred, mocks } from './wallet.lifecycle.testHarness';
|
||||
import { MINT_KEY, PASSWORD, WALLET_MATERIAL_KEY, deferred, mocks } from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { savedKeyExists } from '@/lnurlcash/keys';
|
||||
import { savedWalletMaterialExists, walletMaterialHash } from '@/lnurlcash/keys';
|
||||
import { addTrustedMint } from '@/lnurlcash/trustedMints';
|
||||
import { useNwcStore } from './nwc';
|
||||
import { useWalletStore } from './wallet';
|
||||
@@ -24,7 +24,7 @@ describe('complete owner teardown', () => {
|
||||
await expect(locking).rejects.toThrow('NWC lock drain failed');
|
||||
expect(wallet.state).toBe('locked');
|
||||
expect(wallet.lifecycleError).toMatch(/NWC lock drain failed/i);
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
expect(wallet.bearers).toEqual([]);
|
||||
expect(nwc.running).toBe(false);
|
||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||
@@ -58,9 +58,11 @@ describe('complete owner teardown', () => {
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
materialHash: walletMaterialHash(wallet.requireWalletMaterial()),
|
||||
wrappedMaterial: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
ownerId,
|
||||
version: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -79,19 +81,19 @@ describe('complete owner teardown', () => {
|
||||
// Then completion and destructive storage removal wait for the drain,
|
||||
// and the session stays commit-capable (fence and keys live) until it
|
||||
// finishes so accepted NWC work can still reach its durable outcome
|
||||
expect(savedKeyExists()).toBe(true);
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
drain.resolve();
|
||||
await forgetting;
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(savedKeyExists()).toBe(false);
|
||||
expect(savedWalletMaterialExists()).toBe(false);
|
||||
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_nwc_connections')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_nwc_enabled')).toBeNull();
|
||||
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
|
||||
expect([...listeners.values()].every((registered) => registered.size === 0)).toBe(true);
|
||||
expect(cleanupOrder.indexOf('listener:scroll')).toBeLessThan(
|
||||
cleanupOrder.indexOf('storage:sattle_linking_key'),
|
||||
cleanupOrder.indexOf(`storage:${WALLET_MATERIAL_KEY}`),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,12 +107,12 @@ describe('complete owner teardown', () => {
|
||||
// When forget reaches biometric teardown
|
||||
const forgetting = wallet.forgetWallet();
|
||||
|
||||
// Then the caller sees failure and the saved key is not falsely removed
|
||||
// Then the caller sees failure and the saved material is not falsely removed
|
||||
await expect(forgetting).rejects.toThrow('secure delete failed');
|
||||
expect(wallet.state).toBe('locked');
|
||||
expect(wallet.lifecycleError).toMatch(/secure delete failed/i);
|
||||
expect(savedKeyExists()).toBe(true);
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
});
|
||||
|
||||
it('surfaces NWC drain failure without removing the saved owner', async () => {
|
||||
@@ -127,8 +129,8 @@ describe('complete owner teardown', () => {
|
||||
await expect(forgetting).rejects.toThrow('NWC drain failed');
|
||||
expect(wallet.state).toBe('locked');
|
||||
expect(wallet.lifecycleError).toMatch(/NWC drain failed/i);
|
||||
expect(savedKeyExists()).toBe(true);
|
||||
expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.');
|
||||
expect(savedWalletMaterialExists()).toBe(true);
|
||||
expect(() => wallet.requireWalletMaterial()).toThrow('Wallet is locked.');
|
||||
});
|
||||
|
||||
it('completes teardown on retry after a transient biometric deletion failure', async () => {
|
||||
@@ -146,6 +148,6 @@ describe('complete owner teardown', () => {
|
||||
// Then teardown completes and the error surface clears
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(wallet.lifecycleError).toBe('');
|
||||
expect(savedKeyExists()).toBe(false);
|
||||
expect(savedWalletMaterialExists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import './wallet.lifecycle.activation.cases';
|
||||
import './wallet.lifecycle.material.cases';
|
||||
import './wallet.lifecycle.legacyReset.cases';
|
||||
import './wallet.lifecycle.teardown.cases';
|
||||
import './wallet.lifecycle.lockFailure.cases';
|
||||
import './wallet.lifecycle.isolation.cases';
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
import { encryptSecretParts, linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import {
|
||||
deriveWalletMaterial,
|
||||
encryptSecretParts,
|
||||
linkingPubKeyHex,
|
||||
serializeWalletMaterial,
|
||||
walletMaterialHash,
|
||||
} from '@/lnurlcash/keys';
|
||||
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||
import type * as NwcExports from '@/lnurlcash/nwc';
|
||||
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup';
|
||||
import type * as PasskeysExports from '@/lnurlcash/passkeys';
|
||||
import { WALLET_MATERIAL_STORAGE_KEY } from '@/lnurlcash/storage/walletOwnerEvents';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import { lifecycleMocks } from './wallet.lifecycle.testMocks';
|
||||
|
||||
@@ -13,7 +23,16 @@ vi.mock('@/capabilities/biometricUnlock', async () => {
|
||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||
return {
|
||||
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock,
|
||||
unlockWithBiometrics: vi.fn(),
|
||||
unlockWalletMaterialWithBiometrics: lifecycleMocks.unlockWalletMaterialWithBiometrics,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lnurlcash/passkeys', async (importOriginal) => {
|
||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||
const actual = await importOriginal<typeof PasskeysExports>();
|
||||
return {
|
||||
...actual,
|
||||
unlockWalletMaterialWithPasskey: lifecycleMocks.unlockWalletMaterialWithPasskey,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,19 +50,71 @@ vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => {
|
||||
|
||||
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||
export const MATERIAL: WalletMaterialV2 = {
|
||||
...deriveWalletMaterial(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
),
|
||||
linkingKeyHex: bytesToHex(LINKING_KEY),
|
||||
};
|
||||
export const OTHER_MATERIAL: WalletMaterialV2 = {
|
||||
...MATERIAL,
|
||||
linkingKeyHex: bytesToHex(OTHER_LINKING_KEY),
|
||||
};
|
||||
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
||||
export const PASSWORD = 'correct horse battery staple';
|
||||
export const MINT_KEY = '02' + 'aa'.repeat(32);
|
||||
|
||||
export const encryptedLinkingKeyRecord = async () => {
|
||||
const parts = await encryptSecretParts(
|
||||
Array.from(LINKING_KEY, (byte) => byte.toString(16).padStart(2, '0')).join(''),
|
||||
PASSWORD,
|
||||
export const WALLET_MATERIAL_KEY = WALLET_MATERIAL_STORAGE_KEY;
|
||||
export const LEGACY_LINKING_KEY = 'sattle_linking_key';
|
||||
// the frozen pre-v2 bearer namespace: current storage never writes it, so
|
||||
// its presence means an alpha install
|
||||
export const LEGACY_BEARERS_KEY = 'sattle_bearers';
|
||||
|
||||
export const encryptedWalletMaterialRecord = async (
|
||||
material: WalletMaterialV2 = MATERIAL,
|
||||
password: string = PASSWORD,
|
||||
) => {
|
||||
const parts = await encryptSecretParts(serializeWalletMaterial(material), password);
|
||||
return { enc: true as const, ...parts, materialHash: walletMaterialHash(material) };
|
||||
};
|
||||
|
||||
// a v2 install whose owner marker is already proven (the steady state after
|
||||
// any successful unlock)
|
||||
export const installEncryptedWalletMaterial = async (): Promise<void> => {
|
||||
const record = await encryptedWalletMaterialRecord();
|
||||
localStorage.setItem(
|
||||
WALLET_MATERIAL_KEY,
|
||||
JSON.stringify({ ...record, ownerId: OWNER_ID, version: 2 }),
|
||||
);
|
||||
};
|
||||
|
||||
// a v2 install whose owner marker was stripped on restore - usable but not
|
||||
// yet proven, the state a backup/relay restore leaves behind
|
||||
export const installOwnerlessEncryptedWalletMaterial = async (): Promise<void> => {
|
||||
localStorage.setItem(
|
||||
WALLET_MATERIAL_KEY,
|
||||
JSON.stringify(await encryptedWalletMaterialRecord()),
|
||||
);
|
||||
};
|
||||
|
||||
// the pre-v2 record shape a backup file can still carry - the lifecycle must
|
||||
// never activate it, only clear it
|
||||
export const encryptedLinkingKeyRecord = async () => {
|
||||
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
|
||||
return { enc: true as const, ...parts };
|
||||
};
|
||||
|
||||
// unsupported alpha state: the linking-key-only record and the legacy bearer
|
||||
// namespace, never written by the v2 lifecycle
|
||||
export const installLegacyWalletState = (): void => {
|
||||
localStorage.setItem(
|
||||
LEGACY_LINKING_KEY,
|
||||
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId: OWNER_ID, version: 1 }),
|
||||
);
|
||||
localStorage.setItem(LEGACY_BEARERS_KEY, JSON.stringify([]));
|
||||
};
|
||||
|
||||
type Deferred = {
|
||||
readonly promise: Promise<void>;
|
||||
readonly resolve: () => void;
|
||||
@@ -60,6 +131,38 @@ export const deferred = (): Deferred => {
|
||||
};
|
||||
};
|
||||
|
||||
type LockRequest = {
|
||||
readonly callback: () => unknown;
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
// a navigator.locks stand-in whose requests queue up until the test releases
|
||||
// them one at a time - the deferral hook for mid-activation interruptions.
|
||||
// A callback failure rejects the lock promise (the real LockManager settles
|
||||
// the caller's promise with the callback outcome)
|
||||
export const deferredLocks = () => {
|
||||
const requests: LockRequest[] = [];
|
||||
return {
|
||||
requests,
|
||||
locks: {
|
||||
request: (_name: string, callback: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
requests.push({ callback, resolve, reject });
|
||||
}),
|
||||
},
|
||||
releaseNext: async (): Promise<void> => {
|
||||
const request = requests.shift();
|
||||
if (!request) throw new Error('Expected a queued lock request.');
|
||||
try {
|
||||
request.resolve(await request.callback());
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const installLegacyOwnerlessResidue = (): void => {
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
@@ -68,7 +171,8 @@ export const installLegacyOwnerlessResidue = (): void => {
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
materialHash: walletMaterialHash(MATERIAL),
|
||||
wrappedMaterial: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
@@ -99,16 +203,18 @@ export const installLegacyOwnerlessResidue = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
export const installLegacyEncryptedWallet = async (): Promise<void> => {
|
||||
localStorage.setItem('sattle_linking_key', JSON.stringify(await encryptedLinkingKeyRecord()));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
lifecycleMocks.disableBiometricUnlock.mockResolvedValue();
|
||||
lifecycleMocks.unlockWalletMaterialWithBiometrics.mockRejectedValue(
|
||||
new Error('Biometric unlock is not set up on this device.'),
|
||||
);
|
||||
lifecycleMocks.unlockWalletMaterialWithPasskey.mockRejectedValue(
|
||||
new Error('No passkeys registered on this device.'),
|
||||
);
|
||||
lifecycleMocks.restoreFromNostr.mockResolvedValue({
|
||||
added: 0,
|
||||
skipped: 0,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { vi } from 'vitest';
|
||||
|
||||
export const lifecycleMocks = {
|
||||
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
|
||||
unlockWalletMaterialWithBiometrics: vi.fn(),
|
||||
unlockWalletMaterialWithPasskey: vi.fn(),
|
||||
restoreFromNostr: vi.fn(),
|
||||
startService: vi.fn(),
|
||||
};
|
||||
|
||||
+106
-56
@@ -1,13 +1,18 @@
|
||||
// allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface.
|
||||
import { computed, onScopeDispose, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import {
|
||||
deriveBearerAesKey,
|
||||
savedKeyExists,
|
||||
savedKeyIsEncrypted,
|
||||
savedKeyOwnerId,
|
||||
clearSavedLinkingKey,
|
||||
clearSavedWalletMaterial,
|
||||
deriveBearerAesKey,
|
||||
savedWalletMaterialExists,
|
||||
savedWalletMaterialIsEncrypted,
|
||||
savedWalletMaterialOwnerId,
|
||||
walletMaterialLinkingKey,
|
||||
} from '@/lnurlcash/keys';
|
||||
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||
import {
|
||||
loadBearers,
|
||||
clearAllBearers,
|
||||
@@ -32,7 +37,7 @@ import {
|
||||
createSeedInstaller,
|
||||
createWalletTransitionQueue,
|
||||
migrateProvenLegacyOwner,
|
||||
ownerOf,
|
||||
resetUnsupportedLegacyWalletState,
|
||||
stopWalletNwcSession,
|
||||
WalletLifecycleError,
|
||||
} from './walletLifecycle';
|
||||
@@ -40,23 +45,25 @@ import {
|
||||
export { TrustedMintPostCommitError } from './walletFunds';
|
||||
|
||||
// 'none': no wallet on this device yet -> setup
|
||||
// 'locked': linking key present but password-encrypted -> unlock
|
||||
// 'unlocked': linking key (and thus the bearer AES key) in memory
|
||||
// 'locked': wallet material present but password-encrypted -> unlock
|
||||
// 'unlocked': the complete material (linking key + cash root) is in memory
|
||||
export type { WalletState } from './walletOwnerFence';
|
||||
|
||||
// a plaintext-stored key also starts 'locked' - init() unlocks it
|
||||
// immediately without a password, keeping a single code path for deriving
|
||||
// a plaintext-stored material record also starts 'locked' - init() unlocks
|
||||
// it immediately without a password, keeping a single code path for deriving
|
||||
// the AES key and loading bearers
|
||||
export const useWalletStore = defineStore('wallet', () => {
|
||||
const state = ref<WalletState>(savedKeyExists() ? 'locked' : 'none');
|
||||
const state = ref<WalletState>(savedWalletMaterialExists() ? 'locked' : 'none');
|
||||
const pubkey = ref<string | null>(null);
|
||||
const auxiliaryError = ref('');
|
||||
const lifecycleError = ref('');
|
||||
let aesKey: CryptoKey | null = null;
|
||||
// the linking key itself, only while unlocked - needed by backup/passkey
|
||||
// operations (nostrBackup derives the backup key from it, passkey
|
||||
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
||||
let currentLinkingKey: Uint8Array | null = null;
|
||||
// the complete v2 wallet material, only while unlocked - the cash root
|
||||
// funds every BIP-32 secret derivation and the linking key inside it backs
|
||||
// backup/passkey operations (nostrBackup derives the backup key from it,
|
||||
// passkey and biometric enrollment wrap the whole value). Never exposed
|
||||
// reactively; cleared on lock/forget/failed activation
|
||||
let currentMaterial: WalletMaterialV2 | null = null;
|
||||
let lifecycleToken = 0;
|
||||
let acceptingOwnerWork = false;
|
||||
|
||||
@@ -68,7 +75,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
},
|
||||
}).run;
|
||||
|
||||
const encrypted = computed(() => savedKeyIsEncrypted());
|
||||
const encrypted = computed(() => savedWalletMaterialIsEncrypted());
|
||||
|
||||
const ownerFence = createWalletOwnerFence({
|
||||
state: () => state.value,
|
||||
@@ -79,7 +86,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
|
||||
const clearRuntime = (): void => {
|
||||
aesKey = null;
|
||||
currentLinkingKey = null;
|
||||
currentMaterial = null;
|
||||
pubkey.value = null;
|
||||
funds.clear();
|
||||
useActivityStore().unload();
|
||||
@@ -124,12 +131,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
|
||||
const lock = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
if (!savedKeyIsEncrypted()) return;
|
||||
if (!savedWalletMaterialIsEncrypted()) return;
|
||||
await deactivateSession();
|
||||
});
|
||||
|
||||
const idleWatch = createWalletIdleWatch({
|
||||
isEncrypted: savedKeyIsEncrypted,
|
||||
isEncrypted: savedWalletMaterialIsEncrypted,
|
||||
isUnlocked: () => state.value === 'unlocked',
|
||||
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
|
||||
lock,
|
||||
@@ -138,30 +145,53 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise<void> => {
|
||||
// Activation enters the unlocked state for exactly the saved material.
|
||||
// The owner monitor is subscribed BEFORE the first async gap and the saved
|
||||
// owner is re-read immediately before exposing 'unlocked': a replacement
|
||||
// that landed during deferred loading must fence this activation instead
|
||||
// of letting a stale owner through
|
||||
const activate = async (material: WalletMaterialV2, ownerWasMissing: boolean): Promise<void> => {
|
||||
auxiliaryError.value = '';
|
||||
await migrateProvenLegacyOwner(linkingKey, ownerWasMissing);
|
||||
const key = await deriveBearerAesKey(linkingKey);
|
||||
const loaded = await loadBearers(key);
|
||||
const activity = useActivityStore();
|
||||
await activity.loadFor(key);
|
||||
const ownerId = ownerOf(linkingKey);
|
||||
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
||||
auxiliaryError.value = message;
|
||||
});
|
||||
aesKey = key;
|
||||
currentLinkingKey = linkingKey;
|
||||
lifecycleToken += 1;
|
||||
const ownerId = walletMaterialOwnerId(material);
|
||||
observeOwnerChanges();
|
||||
pubkey.value = ownerId;
|
||||
funds.replace(loaded);
|
||||
acceptingOwnerWork = true;
|
||||
state.value = 'unlocked';
|
||||
idleWatch.start();
|
||||
try {
|
||||
await migrateProvenLegacyOwner(material, ownerWasMissing);
|
||||
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
||||
}
|
||||
const key = await deriveBearerAesKey(walletMaterialLinkingKey(material));
|
||||
const loaded = await loadBearers(key);
|
||||
const activity = useActivityStore();
|
||||
await activity.loadFor(key);
|
||||
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
||||
auxiliaryError.value = message;
|
||||
});
|
||||
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
||||
}
|
||||
aesKey = key;
|
||||
currentMaterial = material;
|
||||
lifecycleToken += 1;
|
||||
pubkey.value = ownerId;
|
||||
funds.replace(loaded);
|
||||
acceptingOwnerWork = true;
|
||||
state.value = 'unlocked';
|
||||
idleWatch.start();
|
||||
} catch (error) {
|
||||
stopOwnerChanges();
|
||||
acceptingOwnerWork = false;
|
||||
lifecycleToken += 1;
|
||||
pubkey.value = null;
|
||||
clearRuntime();
|
||||
// a failed activation is locked when a wallet record exists (retry via
|
||||
// unlock) and uninstalled when none does - never half-installed
|
||||
state.value = savedWalletMaterialExists() ? 'locked' : 'none';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
|
||||
const ownerId = savedKeyOwnerId() ?? pubkey.value;
|
||||
const ownerId = savedWalletMaterialOwnerId() ?? pubkey.value;
|
||||
acceptingOwnerWork = false;
|
||||
stopOwnerChanges();
|
||||
idleWatch.stop();
|
||||
@@ -178,7 +208,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
clearAllBearers();
|
||||
useActivityStore().unloadAndClear();
|
||||
clearSettings();
|
||||
// invalidate the legacy saved-key entry too: old tabs watching it must
|
||||
// fence themselves, and the record is residue under the v2 lifecycle
|
||||
clearSavedLinkingKey();
|
||||
// the saved material goes last: a failure above leaves the complete
|
||||
// enrollment (and this record) in place for a safe retry
|
||||
clearSavedWalletMaterial();
|
||||
state.value = 'none';
|
||||
} finally {
|
||||
// a failed teardown still ends the session: 'locked' must never hold
|
||||
@@ -189,12 +224,16 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
};
|
||||
|
||||
const prepareInstallation = async (nextOwnerId: string): Promise<void> => {
|
||||
const installedOwner = savedKeyOwnerId() ?? pubkey.value;
|
||||
if (savedKeyExists() && installedOwner === nextOwnerId) {
|
||||
// the install path re-runs the alpha reset, so a boot-time reset that
|
||||
// failed (e.g. native biometric deletion) is retried before any
|
||||
// successor is installed
|
||||
await resetUnsupportedLegacyWalletState();
|
||||
const installedOwner = savedWalletMaterialOwnerId() ?? pubkey.value;
|
||||
if (savedWalletMaterialExists() && installedOwner === nextOwnerId) {
|
||||
if (state.value === 'unlocked') await deactivateSession();
|
||||
return;
|
||||
}
|
||||
if (savedKeyExists() || state.value === 'unlocked') {
|
||||
if (savedWalletMaterialExists() || state.value === 'unlocked') {
|
||||
await teardownCurrentOwner(true);
|
||||
}
|
||||
await clearUnownedAuthorizations();
|
||||
@@ -202,7 +241,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
|
||||
const installSeed = createSeedInstaller({
|
||||
prepareInstallation,
|
||||
activate: (linkingKey) => activate(linkingKey, false),
|
||||
activate: (material) => activate(material, false),
|
||||
});
|
||||
|
||||
const access = createWalletAccess({
|
||||
@@ -210,19 +249,25 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
installSeed,
|
||||
activate,
|
||||
canInit: () => state.value === 'locked',
|
||||
onResetError: (error) => {
|
||||
lifecycleError.value = error instanceof Error ? error.message : 'Wallet reset failed.';
|
||||
},
|
||||
});
|
||||
|
||||
const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
|
||||
runTransition(async () => {
|
||||
const backup = parseBackupFile(data);
|
||||
const hadSavedKey = savedKeyExists();
|
||||
const hadSavedMaterial = savedWalletMaterialExists();
|
||||
const activeOwner = pubkey.value;
|
||||
const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null;
|
||||
if (activeKey !== null) await deactivateSession();
|
||||
if (!hadSavedKey) await clearUnownedAuthorizations();
|
||||
const activeMaterial = state.value === 'unlocked' ? requireWalletMaterial() : null;
|
||||
if (activeMaterial !== null) await deactivateSession();
|
||||
if (!hadSavedMaterial) await clearUnownedAuthorizations();
|
||||
const result = await applyBackup(backup, activeOwner ?? undefined);
|
||||
if (activeKey !== null) await activate(activeKey, false);
|
||||
else if (result.linkingKeyRestored) state.value = 'locked';
|
||||
// a file-carried legacy linking key can never become v2 material (the
|
||||
// cash root is unrecoverable from it): the record applyBackup just
|
||||
// installed is residue here, and removing it keeps old tabs fenced
|
||||
clearSavedLinkingKey();
|
||||
if (activeMaterial !== null) await activate(activeMaterial, false);
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -232,21 +277,21 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
password?: string,
|
||||
): Promise<void> =>
|
||||
runTransition(() =>
|
||||
installSeed(seedPhrase, password, async (linkingKey) => {
|
||||
await restoreFromNostrEngine(linkingKey, relays);
|
||||
installSeed(seedPhrase, password, async (material) => {
|
||||
await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
||||
}),
|
||||
);
|
||||
|
||||
const restoreCurrentFromNostr = (relays: string[]) =>
|
||||
runTransition(async () => {
|
||||
const linkingKey = requireLinkingKey();
|
||||
const material = requireWalletMaterial();
|
||||
await deactivateSession();
|
||||
const result = await restoreFromNostrEngine(linkingKey, relays);
|
||||
await activate(linkingKey, false);
|
||||
const result = await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
||||
await activate(material, false);
|
||||
return result;
|
||||
});
|
||||
|
||||
// wipes this wallet from the device entirely - the linking key, every
|
||||
// wipes this wallet from the device entirely - the wallet material, every
|
||||
// bearer record, the activity log, and the non-secret registries that
|
||||
// would otherwise linger as a fingerprint of it. Not recoverable by
|
||||
// restoring the same seed afterward (the ciphertexts themselves are
|
||||
@@ -267,9 +312,13 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
// narrow accessor for the operations that need the key material itself
|
||||
// (nostr backup key derivation, passkey registration) - never reactive,
|
||||
// throws when locked, so callers can't accidentally hold a stale key
|
||||
const requireLinkingKey = (): Uint8Array => {
|
||||
if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.');
|
||||
return currentLinkingKey;
|
||||
const requireLinkingKey = (): Uint8Array => walletMaterialLinkingKey(requireWalletMaterial());
|
||||
|
||||
// the complete material for the enrollment flows that wrap it (passkey,
|
||||
// biometric) - same access rules as requireLinkingKey
|
||||
const requireWalletMaterial = (): WalletMaterialV2 => {
|
||||
if (!acceptingOwnerWork || !currentMaterial) throw new Error('Wallet is locked.');
|
||||
return currentMaterial;
|
||||
};
|
||||
|
||||
const funds = createWalletFunds({
|
||||
@@ -301,6 +350,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
forgetWallet,
|
||||
postponeLock: idleWatch.postpone,
|
||||
requireLinkingKey,
|
||||
requireWalletMaterial,
|
||||
captureOwnerFence: ownerFence.capture,
|
||||
};
|
||||
});
|
||||
|
||||
+32
-18
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
decryptSavedLinkingKey,
|
||||
decryptSavedWalletMaterial,
|
||||
generateSeedPhrase,
|
||||
getPlainLinkingKey,
|
||||
savedKeyIsEncrypted,
|
||||
savedKeyOwnerId,
|
||||
getPlainWalletMaterial,
|
||||
savedWalletMaterialIsEncrypted,
|
||||
savedWalletMaterialOwnerId,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { unlockWithPasskey } from '@/lnurlcash/passkeys';
|
||||
import { unlockWithBiometrics } from '@/capabilities/biometricUnlock';
|
||||
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||
import { unlockWalletMaterialWithPasskey } from '@/lnurlcash/passkeys';
|
||||
import { unlockWalletMaterialWithBiometrics } from '@/capabilities/biometricUnlock';
|
||||
|
||||
import { resetUnsupportedLegacyWalletState } from './walletLifecycle';
|
||||
|
||||
type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>;
|
||||
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>;
|
||||
type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise<void>;
|
||||
type Activate = (material: WalletMaterialV2, ownerWasMissing: boolean) => Promise<void>;
|
||||
|
||||
type WalletAccessOptions = Readonly<{
|
||||
runTransition: RunTransition;
|
||||
installSeed: InstallSeed;
|
||||
activate: Activate;
|
||||
canInit: () => boolean;
|
||||
// reset failures must surface without killing boot: routing proceeds to
|
||||
// the uninstalled state and the next install attempt retries the reset
|
||||
onResetError: (error: unknown) => void;
|
||||
}>;
|
||||
|
||||
export const createWalletAccess = ({
|
||||
@@ -24,10 +30,11 @@ export const createWalletAccess = ({
|
||||
installSeed,
|
||||
activate,
|
||||
canInit,
|
||||
onResetError,
|
||||
}: WalletAccessOptions) => {
|
||||
const activateSavedKey = async (linkingKey: Uint8Array | null): Promise<void> => {
|
||||
if (!linkingKey) throw new Error('No wallet on this device.');
|
||||
await activate(linkingKey, savedKeyOwnerId() === null);
|
||||
const activateSavedMaterial = async (material: WalletMaterialV2 | null): Promise<void> => {
|
||||
if (!material) throw new Error('No wallet on this device.');
|
||||
await activate(material, savedWalletMaterialOwnerId() === null);
|
||||
};
|
||||
const create = (password?: string): Promise<string> =>
|
||||
runTransition(async () => {
|
||||
@@ -39,23 +46,30 @@ export const createWalletAccess = ({
|
||||
runTransition(() => installSeed(seedPhrase, password));
|
||||
const unlock = (password?: string): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
const linkingKey = savedKeyIsEncrypted()
|
||||
? await decryptSavedLinkingKey(password || '')
|
||||
: getPlainLinkingKey();
|
||||
await activateSavedKey(linkingKey);
|
||||
const material = savedWalletMaterialIsEncrypted()
|
||||
? await decryptSavedWalletMaterial(password || '')
|
||||
: getPlainWalletMaterial();
|
||||
await activateSavedMaterial(material);
|
||||
});
|
||||
const unlockWithPasskeyCredential = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
await activate(await unlockWithPasskey(), false);
|
||||
await activate(await unlockWalletMaterialWithPasskey(), false);
|
||||
});
|
||||
const unlockWithBiometric = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
await activateSavedKey(await unlockWithBiometrics());
|
||||
await activateSavedMaterial(await unlockWalletMaterialWithBiometrics());
|
||||
});
|
||||
const init = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
if (!canInit() || savedKeyIsEncrypted()) return;
|
||||
await activateSavedKey(getPlainLinkingKey());
|
||||
// alpha reset runs before any routing decision can expose the wallet:
|
||||
// an unsupported legacy install is wiped, never migrated
|
||||
try {
|
||||
await resetUnsupportedLegacyWalletState();
|
||||
} catch (error) {
|
||||
onResetError(error);
|
||||
}
|
||||
if (!canInit() || savedWalletMaterialIsEncrypted()) return;
|
||||
await activateSavedMaterial(getPlainWalletMaterial());
|
||||
});
|
||||
return {
|
||||
create,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
deriveWalletLinkingKey,
|
||||
ensureSavedKeyOwner,
|
||||
deriveWalletMaterial,
|
||||
ensureSavedWalletMaterialOwner,
|
||||
isValidSeedPhrase,
|
||||
linkingPubKeyHex,
|
||||
saveLinkingKey,
|
||||
saveWalletMaterial,
|
||||
savedWalletMaterialExists,
|
||||
walletMaterialLinkingKey,
|
||||
} from '@/lnurlcash/keys';
|
||||
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||
import { disableBiometricUnlock } from '@/capabilities/biometricUnlock';
|
||||
import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { clearAllActivity, clearSettings } from '@/lnurlcash/storage';
|
||||
import {
|
||||
clearPasskeySlotsForOwner,
|
||||
clearUnownedPasskeySlots,
|
||||
@@ -17,6 +21,11 @@ import {
|
||||
migrateLegacyNwcStorage,
|
||||
} from '@/lnurlcash/storage/nwcConnections';
|
||||
import { withStorageLock } from '@/lnurlcash/storageLock';
|
||||
import {
|
||||
LINKING_KEY_STORAGE_KEY,
|
||||
WALLET_MATERIAL_STORAGE_KEY,
|
||||
} from '@/lnurlcash/storage/walletOwnerEvents';
|
||||
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||
import {
|
||||
migrateLegacyTrustedMints,
|
||||
removeTrustedMintsForOwner,
|
||||
@@ -75,11 +84,12 @@ export const stopWalletNwcSession = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
export const migrateProvenLegacyOwner = async (
|
||||
linkingKey: Uint8Array,
|
||||
material: WalletMaterialV2,
|
||||
ownerWasMissing: boolean,
|
||||
): Promise<void> => {
|
||||
ensureSavedKeyOwner(linkingKey);
|
||||
ensureSavedWalletMaterialOwner(material);
|
||||
if (!ownerWasMissing) return;
|
||||
const linkingKey = walletMaterialLinkingKey(material);
|
||||
await migrateLegacyPasskeySlots(linkingKey);
|
||||
migrateLegacyNwcStorage(linkingKey);
|
||||
await migrateLegacyTrustedMints(linkingKey);
|
||||
@@ -103,26 +113,72 @@ export const clearUnownedAuthorizations = async (): Promise<void> => {
|
||||
await resetTrustedMintsForReplacement();
|
||||
};
|
||||
|
||||
export const ownerOf = (linkingKey: Uint8Array): string => linkingPubKeyHex(linkingKey);
|
||||
// ---- unsupported legacy (alpha) install reset ----
|
||||
|
||||
// Frozen namespaces the v2 lifecycle never writes: the linking-key-only
|
||||
// record and the pre-funds-document bearer store. Their raw presence (even
|
||||
// corrupt or unparseable) means this device holds an alpha install, which
|
||||
// cannot be migrated - the cash root is unrecoverable from a linking key -
|
||||
// so it is reset, never upgraded.
|
||||
const LEGACY_BEARERS_STORAGE_KEY = 'sattle_bearers';
|
||||
// current owner-bound namespaces that a full reset removes outright; the
|
||||
// key strings are duplicated here because the reset deletes whole
|
||||
// namespaces by name rather than going through owner-scoped APIs
|
||||
const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections';
|
||||
const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled';
|
||||
|
||||
export const hasUnsupportedLegacyWalletState = (): boolean =>
|
||||
!savedWalletMaterialExists() &&
|
||||
(localStorage.getItem(LINKING_KEY_STORAGE_KEY) !== null ||
|
||||
localStorage.getItem(LEGACY_BEARERS_STORAGE_KEY) !== null);
|
||||
|
||||
// Alpha reset. No migration: every namespace the alpha wallet could have
|
||||
// touched is removed and the device returns to the uninstalled state. The
|
||||
// native biometric secret is deleted FIRST (same ordering as forget): if
|
||||
// that fails, nothing local has been wiped yet, so the next attempt retries
|
||||
// the whole reset instead of leaving a half-wiped install. When a v2
|
||||
// install is present instead, only stray legacy-key residue is removed - the
|
||||
// live wallet (including its bearer store) is untouched.
|
||||
export const resetUnsupportedLegacyWalletState = async (): Promise<void> => {
|
||||
if (savedWalletMaterialExists()) {
|
||||
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
if (!hasUnsupportedLegacyWalletState()) return;
|
||||
await disableBiometricUnlock();
|
||||
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||
localStorage.removeItem(LEGACY_BEARERS_STORAGE_KEY);
|
||||
localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY);
|
||||
localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY);
|
||||
localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY);
|
||||
localStorage.removeItem(NWC_ENABLED_STORAGE_KEY);
|
||||
clearAllActivity();
|
||||
clearSettings();
|
||||
await resetTrustedMintsForReplacement();
|
||||
};
|
||||
|
||||
type SeedInstallerOptions = {
|
||||
readonly prepareInstallation: (ownerId: string) => Promise<void>;
|
||||
readonly activate: (linkingKey: Uint8Array) => Promise<void>;
|
||||
readonly activate: (material: WalletMaterialV2) => Promise<void>;
|
||||
};
|
||||
|
||||
export type SeedInstaller = (
|
||||
seedPhrase: string,
|
||||
password?: string,
|
||||
restore?: (linkingKey: Uint8Array) => Promise<void>,
|
||||
restore?: (material: WalletMaterialV2) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export const createSeedInstaller =
|
||||
(options: SeedInstallerOptions): SeedInstaller =>
|
||||
async (seedPhrase, password, restore) => {
|
||||
if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.');
|
||||
const linkingKey = deriveWalletLinkingKey(seedPhrase);
|
||||
await options.prepareInstallation(ownerOf(linkingKey));
|
||||
if (restore) await restore(linkingKey);
|
||||
await saveLinkingKey(linkingKey, password);
|
||||
await options.activate(linkingKey);
|
||||
const material = deriveWalletMaterial(seedPhrase);
|
||||
await options.prepareInstallation(walletMaterialOwnerId(material));
|
||||
if (restore) await restore(material);
|
||||
// a restore engine may have installed a legacy linking-key record from
|
||||
// the backup payload: the v2 lifecycle never activates it, so it is
|
||||
// residue - removing it here also fences old tabs still watching that key
|
||||
localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
|
||||
await saveWalletMaterial(material, password);
|
||||
await options.activate(material);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import { linkingPubKeyHex, saveWalletMaterial, walletMaterialHash } from '@/lnurlcash/keys';
|
||||
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
||||
import { deriveWalletMaterial } from '@/lnurlcash/keys';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
|
||||
|
||||
const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7));
|
||||
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9));
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||
const MATERIAL: WalletMaterialV2 = {
|
||||
...deriveWalletMaterial(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
),
|
||||
linkingKeyHex: bytesToHex(LINKING_KEY),
|
||||
};
|
||||
const OTHER_MATERIAL: WalletMaterialV2 = {
|
||||
...MATERIAL,
|
||||
linkingKeyHex: bytesToHex(OTHER_LINKING_KEY),
|
||||
};
|
||||
const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||
|
||||
describe('wallet owner monitor', () => {
|
||||
beforeEach(() => {
|
||||
@@ -13,19 +27,12 @@ describe('wallet owner monitor', () => {
|
||||
stubLocalStorage();
|
||||
});
|
||||
|
||||
it('consumes a background transition rejection after an owner replacement', () => {
|
||||
it('consumes a background transition rejection after an owner replacement', async () => {
|
||||
// Given an unlocked stale owner and a transition promise observed by the queue
|
||||
const events = new EventTarget();
|
||||
vi.stubGlobal('window', events);
|
||||
localStorage.setItem(
|
||||
'sattle_linking_key',
|
||||
JSON.stringify({
|
||||
enc: false,
|
||||
value: '09'.repeat(32),
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
await saveWalletMaterial(OTHER_MATERIAL);
|
||||
expect(walletMaterialHash(OTHER_MATERIAL)).toBeTruthy();
|
||||
const transition = Promise.resolve();
|
||||
const catchRejection = vi.spyOn(transition, 'catch');
|
||||
startWalletOwnerMonitor({
|
||||
@@ -34,14 +41,39 @@ describe('wallet owner monitor', () => {
|
||||
runTransition: vi.fn().mockReturnValue(transition),
|
||||
});
|
||||
|
||||
// When the browser reports that the saved owner changed
|
||||
// When the browser reports that the saved material changed
|
||||
events.dispatchEvent(
|
||||
Object.defineProperties(new Event('storage'), {
|
||||
key: { value: 'sattle_linking_key' },
|
||||
key: { value: 'sattle_wallet_material_v2' },
|
||||
}),
|
||||
);
|
||||
|
||||
// Then the fire-and-forget transition has a rejection consumer
|
||||
expect(catchRejection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ignores legacy-key wakeups while the saved v2 owner is intact', async () => {
|
||||
// Given an unlocked owner whose v2 record is untouched
|
||||
const events = new EventTarget();
|
||||
vi.stubGlobal('window', events);
|
||||
await saveWalletMaterial(MATERIAL);
|
||||
const deactivate = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = startWalletOwnerMonitor({
|
||||
snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }),
|
||||
deactivate,
|
||||
runTransition: vi.fn((transition: () => Promise<void>) => transition()),
|
||||
});
|
||||
|
||||
// When a legacy-key storage event arrives (an old tab clearing residue)
|
||||
events.dispatchEvent(
|
||||
Object.defineProperties(new Event('storage'), {
|
||||
key: { value: 'sattle_linking_key' },
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
// Then the session stays active: the v2 owner never changed
|
||||
expect(deactivate).not.toHaveBeenCalled();
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { savedWalletMaterialOwnerId } from '@/lnurlcash/keys';
|
||||
import { onSavedKeyStorageChange } from '@/lnurlcash/storage/walletOwnerEvents';
|
||||
import type { WalletState } from './walletOwnerFence';
|
||||
|
||||
@@ -14,13 +14,16 @@ type WalletOwnerMonitor = Readonly<{
|
||||
runTransition: (transition: () => Promise<void>) => Promise<void>;
|
||||
}>;
|
||||
|
||||
// Wakes on any saved-key storage event (the v2 material record, the legacy
|
||||
// linking-key entry, or a clear) and re-reads the CURRENT v2 owner: payload
|
||||
// and legacy-entry changes are only wakeups, never trusted state
|
||||
export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => void) =>
|
||||
onSavedKeyStorageChange(() => {
|
||||
const expected = monitor.snapshot();
|
||||
if (
|
||||
expected.state !== 'unlocked' ||
|
||||
expected.ownerId === null ||
|
||||
savedKeyOwnerId() === expected.ownerId
|
||||
savedWalletMaterialOwnerId() === expected.ownerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -31,7 +34,7 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi
|
||||
current.token !== expected.token ||
|
||||
current.state !== 'unlocked' ||
|
||||
current.ownerId !== expected.ownerId ||
|
||||
savedKeyOwnerId() === expected.ownerId
|
||||
savedWalletMaterialOwnerId() === expected.ownerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user