diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index 4f5fc8d..b49fcc1 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -1,9 +1,10 @@ -import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveWalletMaterial, serializeWalletMaterial, + walletMaterialHash, type WalletMaterialV2, } from '@/lnurlcash/keys'; import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; @@ -50,6 +51,11 @@ const SEED = 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); +// same linking key (same owner) as MATERIAL, but a different, VALID cash root +const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = { + ...MATERIAL, + cashRootHex: OTHER_MATERIAL.cashRootHex, +}; const WRAP_SECRET = new Uint8Array(32).fill(3); const WRAP_RECORD_KEY = 'sattle_biometric_wrap'; const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; @@ -90,6 +96,7 @@ const deriveFixtureWrapKey = async ( const storePayload = async ( plaintext: Uint8Array, pubkey: string, + materialHash: string, secret = WRAP_SECRET, ): Promise => { const hkdfSalt = new Uint8Array(16).fill(4); @@ -106,6 +113,7 @@ const storePayload = async ( iv: bytesToHex(iv), wrappedKey: bytesToHex(ciphertext), pubkey, + materialHash, createdAt: 1, }), ); @@ -115,7 +123,10 @@ const storePayload = async ( const storeMaterialPayload = async ( material: WalletMaterialV2, pubkey = walletMaterialOwnerId(material), -): Promise => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey); + secret = WRAP_SECRET, + materialHash = walletMaterialHash(material), +): Promise => + storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret); beforeEach(() => { vi.clearAllMocks(); @@ -194,7 +205,11 @@ describe('biometric wallet material', () => { ])('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)); + await storePayload( + payload, + walletMaterialOwnerId(MATERIAL), + walletMaterialHash(MATERIAL), + ); const before = persistedState(); // When biometric unlock decrypts the payload @@ -266,7 +281,12 @@ describe('biometric wallet material', () => { 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)); + await storeMaterialPayload( + OTHER_MATERIAL, + walletMaterialOwnerId(MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); const before = persistedState(); // When biometric unlock checks the decrypted linking-key owner @@ -278,6 +298,143 @@ describe('biometric wallet material', () => { }); }); +describe('biometric exact-material commitment', () => { + it('rejects same-owner altered-root enrollment before writing either biometric store', async () => { + // Given one proven saved owner and well-formed material with only the cash root swapped + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the exact-material commitment fails before native or local persistence changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects same-owner altered-root enrollment against encrypted saved material', async () => { + // Given the saved material envelope is password-encrypted (no plaintext available) + await saveWalletMaterial(MATERIAL, 'password'); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the hash commitment alone rejects it before either store changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects enrollment when same-owner saved material changes during the prompt', async () => { + // Given valid enrollment input and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + }); + + // When enrollment runs the biometric prompt + const attempt = enableBiometricUnlock(MATERIAL); + + // Then the post-prompt commitment recheck fails and no biometric store is written + await expect(attempt).rejects.toThrow('changed'); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects an authenticated well-formed same-owner altered-root payload', async () => { + // Given an authenticated wrap that claims the saved commitment but whose + // decrypted material swaps only the cash root + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload( + ALTERED_ROOT_MATERIAL, + walletMaterialOwnerId(ALTERED_ROOT_MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); + const before = persistedState(); + + // When biometric unlock decrypts the well-formed same-owner payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the material commitment rejects it and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => { + // Given an authenticated wrap committed to same-owner altered-root material + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When biometric unlock inspects the record commitment + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then it rejects before any prompt and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(pluginMocks.authenticate).not.toHaveBeenCalled(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after same-owner saved material replacement', async () => { + // Given an enrolled wrap and a later saved-material swap of only the cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When the now-stale biometric wrap is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the stale commitment rejects and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects unlock when same-owner saved material changes during the prompt', async () => { + // Given an enrolled wrap and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the post-prompt commitment recheck rejects and the ceremony writes nothing + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); + + it('rejects unlock when the wrap record is replaced during the prompt', async () => { + // Given an enrolled wrap replaced mid-prompt by another valid same-owner record + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY); + if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret'); + await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret)); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the record recheck rejects and the ceremony writes nothing itself + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); +}); + describe('biometric deletion ordering', () => { it('keeps native and local state retryable when secure-secret deletion fails', async () => { // Given a complete enrolled biometric wrap diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index e6b83bd..c1878aa 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -13,10 +13,13 @@ // storage (Android Keystore-backed AES-GCM via // @aparajita/capacitor-secure-storage). The wrapped blob stays in // 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 material owner against the -// recorded pubkey and tells the holder to re-enroll. +// pubkey as an identity check AND the SHA-256 commitment of the canonical +// saved material: restoring a DIFFERENT seed leaves a stale wrap behind, +// and so does replacing only the saved cash root with same-owner material. +// Unlocking with either must fail loudly (never activate the old wallet +// silently), so unlock verifies the record commitment and the unwrapped +// material against the saved wallet material and tells the holder to +// re-enroll. // // The biometric gate is app-level (a BiometricPrompt before the secure // read), not a keystore key invalidated on biometric re-enrollment - 04 @@ -38,9 +41,12 @@ import { SecureStorage } from '@aparajita/capacitor-secure-storage'; import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { + getPlainWalletMaterial, parseWalletMaterial, + savedWalletMaterialHash, savedWalletMaterialOwnerId, serializeWalletMaterial, + walletMaterialHash, walletMaterialLinkingKey, type WalletMaterialV2, } from '@/lnurlcash/keys'; @@ -55,6 +61,7 @@ type BiometricWrapRecord = { readonly iv: string; readonly wrappedKey: string; readonly pubkey: string; + readonly materialHash: string; readonly createdAt: number; }; @@ -70,6 +77,7 @@ const WRAP_RECORD_KEYS = [ 'iv', 'wrappedKey', 'pubkey', + 'materialHash', 'createdAt', ] as const; @@ -110,6 +118,8 @@ const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { /^[0-9a-f]+$/.test(record.wrappedKey) && typeof record.pubkey === 'string' && /^[0-9a-f]{66}$/.test(record.pubkey) && + typeof record.materialHash === 'string' && + /^[0-9a-f]{64}$/.test(record.materialHash) && typeof record.createdAt === 'number' && Number.isSafeInteger(record.createdAt) && record.createdAt >= 0 @@ -127,6 +137,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => { } }; +const biometricWrapRecordsEqual = ( + left: BiometricWrapRecord, + right: BiometricWrapRecord, +): boolean => + left.version === right.version && + left.hkdfSalt === right.hkdfSalt && + left.iv === right.iv && + left.wrappedKey === right.wrappedKey && + left.pubkey === right.pubkey && + left.materialHash === right.materialHash && + left.createdAt === right.createdAt; + // sync on purpose (same convention as hasPasskeySlots): the unlock form and // the security page ask this during render/setup export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null; @@ -179,12 +201,27 @@ export const enableBiometricUnlock = async ( if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Biometric enrollment requires the proven saved wallet owner.'); } + // exact-material commitment (same check as passkey slots): the enrolled + // bytes must equal the canonical saved material, not just share its owner + const materialHash = savedWalletMaterialHash(); + const plaintext = getPlainWalletMaterial(); + if ( + materialHash === null || + walletMaterialHash(canonicalMaterial) !== materialHash || + (plaintext !== null && serializeWalletMaterial(plaintext) !== serialized) + ) { + throw new Error('Biometric enrollment requires the exact saved wallet material.'); + } const biometry = await BiometricAuth.checkBiometry(); if (!biometry.isAvailable) { throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); } // prove presence before storing anything under the biometric gate await authenticateOrThrow('Set up biometric unlock for your wallet'); + // the prompt yields time: re-verify the commitment before writing anything + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during biometric enrollment.'); + } const secret = crypto.getRandomValues(new Uint8Array(32)); const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -205,6 +242,7 @@ export const enableBiometricUnlock = async ( iv: bytesToHex(iv), wrappedKey: bytesToHex(ciphertext), pubkey: ownerId, + materialHash, createdAt: Date.now(), }; localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record)); @@ -221,7 +259,24 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise Security.', ); } + // the stored commitment must match the canonical saved material, or the + // wrap is stale (same-owner cash-root replacement) and must not unlock + const materialHash = savedWalletMaterialHash(); + if (materialHash === null || record.materialHash !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } await authenticateOrThrow('Unlock your sattle wallet'); + // the prompt yields time: re-read the record and the saved commitment + // before any decrypted plaintext can reach the caller + const currentRecord = readWrapRecord(); + if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) { + throw new Error('The biometric wrap changed during the unlock ceremony.'); + } + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.'); + } 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.'); @@ -242,6 +297,12 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise Security.', ); } + // the decrypted material itself must carry the committed cash root + if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } return material; };