diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index 150777d..4f5fc8d 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -1,101 +1,317 @@ +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { bytesToHex } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys'; -import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; -import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots'; +import { + deriveWalletMaterial, + serializeWalletMaterial, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; +import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage'; import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils'; const pluginMocks = vi.hoisted(() => ({ authenticate: vi.fn<() => Promise>(), + checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(), secureGet: vi.fn<(key: string) => Promise>(), + secureRemove: vi.fn<(key: string) => Promise>(), + secureSet: vi.fn<(key: string, value: string) => Promise>(), + secureValues: new Map(), })); vi.mock('@aparajita/capacitor-biometric-auth', () => ({ AndroidBiometryStrength: { weak: 'weak' }, - BiometricAuth: { authenticate: pluginMocks.authenticate }, + BiometricAuth: { + authenticate: pluginMocks.authenticate, + checkBiometry: pluginMocks.checkBiometry, + }, BiometryError: class BiometryError extends Error {}, BiometryErrorType: { userCancel: 'userCancel' }, })); vi.mock('@aparajita/capacitor-secure-storage', () => ({ - SecureStorage: { get: pluginMocks.secureGet }, + SecureStorage: { + get: pluginMocks.secureGet, + remove: pluginMocks.secureRemove, + set: pluginMocks.secureSet, + }, })); vi.mock('./platform', () => ({ isNative: () => true })); -import { unlockWithBiometrics } from './biometricUnlock'; +import { + disableBiometricUnlock, + enableBiometricUnlock, + unlockWalletMaterialWithBiometrics, +} from './biometricUnlock'; -const LINKING_KEY = new Uint8Array(32).fill(7); -const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const SEED = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; +const MATERIAL = deriveWalletMaterial(SEED); +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED); const WRAP_SECRET = new Uint8Array(32).fill(3); +const WRAP_RECORD_KEY = 'sattle_biometric_wrap'; +const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2'; +const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2'); + +const persistedState = () => ({ + legacyKey: localStorage.getItem('sattle_linking_key'), + legacyNwc: localStorage.getItem('sattle_nwc_connections'), + legacyPasskeys: localStorage.getItem('sattle_passkey_slots'), + legacyTrust: localStorage.getItem('sattle_trusted_mints'), + material: localStorage.getItem(MATERIAL_STORAGE_KEY), + secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null, + wrap: localStorage.getItem(WRAP_RECORD_KEY), +}); + +const deriveFixtureWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(WRAP_KEY_HKDF_INFO), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +const storePayload = async ( + plaintext: Uint8Array, + pubkey: string, + secret = WRAP_SECRET, +): Promise => { + const hkdfSalt = new Uint8Array(16).fill(4); + const iv = new Uint8Array(12).fill(5); + const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)), + ); + localStorage.setItem( + WRAP_RECORD_KEY, + JSON.stringify({ + version: 2, + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + wrappedKey: bytesToHex(ciphertext), + pubkey, + createdAt: 1, + }), + ); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret)); +}; + +const storeMaterialPayload = async ( + material: WalletMaterialV2, + pubkey = walletMaterialOwnerId(material), +): Promise => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey); beforeEach(() => { vi.clearAllMocks(); stubLocalStorage(); + pluginMocks.secureValues.clear(); pluginMocks.authenticate.mockResolvedValue(); - pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); + pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true }); + pluginMocks.secureGet.mockImplementation((key) => + Promise.resolve(pluginMocks.secureValues.get(key) ?? null), + ); + pluginMocks.secureSet.mockImplementation((key, value) => { + pluginMocks.secureValues.set(key, value); + return Promise.resolve(); + }); + pluginMocks.secureRemove.mockImplementation((key) => { + pluginMocks.secureValues.delete(key); + return Promise.resolve(); + }); }); -describe('biometric unlock owner proof', () => { - it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => { - // Given an ownerless legacy wallet and credential residue plus a biometric wrap - // whose claimed pubkey does not match the key it unwraps - const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) }; - const legacySlot = { - credentialId: '11'.repeat(16), - hkdfSalt: '22'.repeat(16), - iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), - createdAt: 1, - }; - const legacyNwc = [ - { - clientPubkey: '55'.repeat(32), - relays: ['wss://relay.example'], - budget: { maxMsat: 1000, periodMs: 60_000 }, - spent: { periodStart: 0, msat: 0 }, - createdAt: 1, - }, - ]; - const legacyTrust = [ - { - server: 'legacy.example', - mintPubkey: '02' + 'aa'.repeat(32), - addedAt: 1, - locked: false, - }, - ]; - localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey)); - localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot])); - localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc)); - localStorage.setItem('sattle_nwc_enabled', 'true'); - localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust)); - const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY); +describe('biometric wallet material', () => { + it('rejects foreign enrollment before writing either biometric store', async () => { + // Given one proven saved owner and complete material for another owner + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the foreign material + const attempt = enableBiometricUnlock(OTHER_MATERIAL); + + // Then ownership fails before native or local persistence changes + await expect(attempt).rejects.toThrow('proven saved wallet owner'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('does not create a local wrap when secure-secret persistence fails', async () => { + // Given valid material and a rejecting native secure store + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed')); + + // When enrollment reaches native secret persistence + const attempt = enableBiometricUnlock(MATERIAL); + + // Then failure is reported without a misleading local enrollment record + await expect(attempt).rejects.toThrow('secure write failed'); + expect(persistedState()).toEqual(before); + }); + + it('round-trips both key parts when enrollment belongs to the saved owner', async () => { + // Given complete canonical material persisted for its proven owner + await saveWalletMaterial(MATERIAL); + + // When the capability enrolls and unlocks it through the native adapter + await enableBiometricUnlock(MATERIAL); + const beforeUnlock = persistedState(); + const unlocked = await unlockWalletMaterialWithBiometrics(); + + // Then both key parts return together and unlock does not rewrite persistence + expect(unlocked).toEqual(MATERIAL); + expect(persistedState()).toEqual(beforeUnlock); + }); + + it.each([ + ['malformed JSON', utf8ToBytes('{')], + ['legacy key-only payload', new Uint8Array(32).fill(7)], + [ + 'altered root', + utf8ToBytes( + JSON.stringify({ + ...MATERIAL, + cashRootHex: '00'.repeat(64), + }), + ), + ], + ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { + // Given an authenticated non-canonical payload bound to the saved owner + await saveWalletMaterial(MATERIAL); + await storePayload(payload, walletMaterialOwnerId(MATERIAL)); + const before = persistedState(); + + // When biometric unlock decrypts the payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part crosses the boundary and persistence is byte-identical + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for a wrong secure secret and preserves state', async () => { + // Given a valid wrap whose secure secret has been replaced + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32)); + const before = persistedState(); + + // When biometric unlock authenticates with the wrong wrapping secret + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then authenticated decryption fails without changing either store + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for altered ciphertext and preserves state', async () => { + // Given a valid wrap with one ciphertext nibble altered + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}'); + const wrappedKey = record.wrappedKey; + if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext'); localStorage.setItem( - 'sattle_biometric_wrap', + WRAP_RECORD_KEY, JSON.stringify({ - ...wrap, - pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), - createdAt: 1, + ...record, + wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`, }), ); - const before = new Map([ - ['sattle_linking_key', localStorage.getItem('sattle_linking_key')], - ['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')], - ['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')], - ['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')], - ['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')], - ]); - - // When biometric unwrap reaches the pubkey proof check - const attempt = unlockWithBiometrics(); - - // Then no key crosses the capability boundary and no legacy namespace is adopted + const before = persistedState(); + + // When biometric unlock verifies the AES-GCM tag + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part returns and persistence remains untouched + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => { + // Given material wrapped for one owner and saved material replaced by another + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + await saveWalletMaterial(OTHER_MATERIAL); + const before = persistedState(); + + // When the stale biometric record is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then no old material returns and both stores remain byte-identical + await expect(attempt).rejects.toThrow('different wallet'); + expect(persistedState()).toEqual(before); + }); + + it('rejects authenticated material owned by another linking key', async () => { + // Given the saved owner and wrap claim owner A but authenticated material owner B + await saveWalletMaterial(MATERIAL); + localStorage.setItem('sattle_linking_key', 'legacy-key-residue'); + localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue'); + localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue'); + localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue'); + await storeMaterialPayload(OTHER_MATERIAL, walletMaterialOwnerId(MATERIAL)); + const before = persistedState(); + + // When biometric unlock checks the decrypted linking-key owner + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither foreign key part returns and persistence is unchanged await expect(attempt).rejects.toThrow('different wallet'); - expect(savedKeyOwnerId()).toBeNull(); - expect(readPasskeySlots()).toEqual([]); - expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey); - for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value); + expect(persistedState()).toEqual(before); + }); +}); + +describe('biometric deletion ordering', () => { + it('keeps native and local state retryable when secure-secret deletion fails', async () => { + // Given a complete enrolled biometric wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed')); + + // When native secure-secret deletion fails + const attempt = disableBiometricUnlock(); + + // Then failure is reported before either persisted part is removed + await expect(attempt).rejects.toThrow('native deletion failed'); + expect(persistedState()).toEqual(before); + }); + + it('supports repeated delete interruption followed by a successful retry', async () => { + // Given two interrupted deletion attempts for an enrolled wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption')); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption')); + + // When deletion is retried until native removal succeeds + await expect(disableBiometricUnlock()).rejects.toThrow('first interruption'); + expect(persistedState()).toEqual(before); + await expect(disableBiometricUnlock()).rejects.toThrow('second interruption'); + expect(persistedState()).toEqual(before); + await disableBiometricUnlock(); + + // Then native state is gone before the local wrap is removed + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material); }); }); diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index f911eb1..e6b83bd 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -1,22 +1,21 @@ -// Native biometric unlock: a THIRD wrap of the same linking key, alongside -// the password wrap (keys.ts) and the passkey slots (passkeys.ts). +// Native biometric unlock: a THIRD wrap of the same canonical wallet material, +// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts). // // Why native: Android WebView has no usable WebAuthn platform authenticator // for this app (passkeys need Play Services / Digital Asset Links wiring we // cannot rely on), so the biometric path on native is a device-credential // prompt instead of a passkey ceremony. // -// Design (mirrors the passkey-slot construction, reusing its wrap -// primitives verbatim): on enrollment a random 32-byte wrap secret is -// generated, the linking key is AES-GCM-wrapped under an HKDF of that -// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32 -// bytes of IKM), and only the SECRET goes into biometric-gated secure +// Design (mirrors the passkey-slot construction with a biometric-specific +// HKDF domain): on enrollment a random 32-byte wrap secret is generated, +// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret, +// and only the SECRET goes into biometric-gated secure // storage (Android Keystore-backed AES-GCM via // @aparajita/capacitor-secure-storage). The wrapped blob stays in -// localStorage as a record shaped like a passkey slot, plus the linking +// localStorage as a versioned AES-GCM record, plus the linking // pubkey as an identity check: restoring a DIFFERENT seed leaves a stale // wrap behind, and unlocking with it must fail loudly (never activate the -// old wallet silently), so unlock verifies the unwrapped key against the +// old wallet silently), so unlock verifies the unwrapped material owner against the // recorded pubkey and tells the holder to re-enroll. // // The biometric gate is app-level (a BiometricPrompt before the secure @@ -36,39 +35,84 @@ import { BiometryErrorType, } from '@aparajita/capacitor-biometric-auth'; import { SecureStorage } from '@aparajita/capacitor-secure-storage'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex } from '@/lnurlcash/keys'; -import type { PasskeyWrap } from '@/lnurlcash/passkeys'; -import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; +import { + parseWalletMaterial, + savedWalletMaterialOwnerId, + serializeWalletMaterial, + walletMaterialLinkingKey, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { isJsonObject } from '@/lnurlcash/jsonParsing'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; import { isNative } from './platform'; -type BiometricWrapRecord = PasskeyWrap & { - pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps - createdAt: number; +type BiometricWrapRecord = { + readonly version: 2; + readonly hkdfSalt: string; + readonly iv: string; + readonly wrappedKey: string; + readonly pubkey: string; + readonly createdAt: number; }; // the wrapped blob is useless without the secure-storage secret, so (like // the passkey slots) this record sits in plain localStorage const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap'; const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const BIOMETRIC_WRAP_VERSION = 2 as const; +const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2'; +const WRAP_RECORD_KEYS = [ + 'version', + 'hkdfSalt', + 'iv', + 'wrappedKey', + 'pubkey', + 'createdAt', +] as const; + +const deriveBiometricWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { - if (typeof record !== 'object' || record === null) return false; - const r = record as Record; + if (!isJsonObject(record)) return false; return ( - typeof r.hkdfSalt === 'string' && - /^[0-9a-f]{32}$/i.test(r.hkdfSalt) && - typeof r.iv === 'string' && - /^[0-9a-f]{24}$/i.test(r.iv) && - typeof r.wrappedKey === 'string' && - r.wrappedKey.length > 0 && - r.wrappedKey.length % 2 === 0 && - /^[0-9a-f]+$/i.test(r.wrappedKey) && - typeof r.pubkey === 'string' && - /^[0-9a-f]{66}$/i.test(r.pubkey) && - typeof r.createdAt === 'number' + Object.keys(record).length === WRAP_RECORD_KEYS.length && + Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) && + record.version === BIOMETRIC_WRAP_VERSION && + typeof record.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/.test(record.hkdfSalt) && + typeof record.iv === 'string' && + /^[0-9a-f]{24}$/.test(record.iv) && + typeof record.wrappedKey === 'string' && + record.wrappedKey.length > 0 && + record.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/.test(record.wrappedKey) && + typeof record.pubkey === 'string' && + /^[0-9a-f]{66}$/.test(record.pubkey) && + typeof record.createdAt === 'number' && + Number.isSafeInteger(record.createdAt) && + record.createdAt >= 0 ); }; @@ -121,8 +165,20 @@ const authenticateOrThrow = async (reason: string): Promise => { } }; -export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { +export const enableBiometricUnlock = async ( + material: WalletMaterialV2 | Uint8Array, +): Promise => { if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); + if (material instanceof Uint8Array) { + throw new Error('Biometric enrollment requires complete v2 wallet material.'); + } + const serialized = serializeWalletMaterial(material); + const canonicalMaterial = parseWalletMaterial(serialized); + if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.'); + const ownerId = walletMaterialOwnerId(canonicalMaterial); + if (savedWalletMaterialOwnerId() !== ownerId) { + throw new Error('Biometric enrollment requires the proven saved wallet owner.'); + } const biometry = await BiometricAuth.checkBiometry(); if (!biometry.isAvailable) { throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); @@ -130,43 +186,73 @@ export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { +export const unlockWalletMaterialWithBiometrics = async (): Promise => { const record = readWrapRecord(); if (!isNative() || !record) { throw new Error('Biometric unlock is not set up on this device.'); } + const ownerId = savedWalletMaterialOwnerId(); + if (ownerId === null || ownerId !== record.pubkey) { + throw new Error( + 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', + ); + } await authenticateOrThrow('Unlock your sattle wallet'); const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); } - const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record); - if (linkingPubKeyHex(linkingKey) !== record.pubkey) { + const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt)); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) }, + wrapKey, + new Uint8Array(hexToBytes(record.wrappedKey)), + ); + const material = parseWalletMaterial(new TextDecoder().decode(plaintext)); + if ( + material === null || + savedWalletMaterialOwnerId() !== ownerId || + walletMaterialOwnerId(material) !== ownerId + ) { throw new Error( 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', ); } - return linkingKey; + return material; }; -// the localStorage record goes first and synchronously: even if the caller -// fire-and-forgets this (forgetWallet), a later unlock attempt can never -// reach the secure secret with a stale record +export const unlockWithBiometrics = async (): Promise => + walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics()); + +// Native deletion goes first so a failure leaves the complete enrollment +// available for the locked lifecycle to retry safely. export const disableBiometricUnlock = async (): Promise => { - localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); if (isNative()) { await SecureStorage.remove(SECURE_SECRET_KEY); } + localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); };