mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(unlock): bind factors to wallet material
This commit is contained in:
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deriveWalletMaterial,
|
deriveWalletMaterial,
|
||||||
serializeWalletMaterial,
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
type WalletMaterialV2,
|
type WalletMaterialV2,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
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 OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||||
const MATERIAL = deriveWalletMaterial(SEED);
|
const MATERIAL = deriveWalletMaterial(SEED);
|
||||||
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_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_SECRET = new Uint8Array(32).fill(3);
|
||||||
const WRAP_RECORD_KEY = 'sattle_biometric_wrap';
|
const WRAP_RECORD_KEY = 'sattle_biometric_wrap';
|
||||||
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
||||||
@@ -90,6 +96,7 @@ const deriveFixtureWrapKey = async (
|
|||||||
const storePayload = async (
|
const storePayload = async (
|
||||||
plaintext: Uint8Array,
|
plaintext: Uint8Array,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
|
materialHash: string,
|
||||||
secret = WRAP_SECRET,
|
secret = WRAP_SECRET,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const hkdfSalt = new Uint8Array(16).fill(4);
|
const hkdfSalt = new Uint8Array(16).fill(4);
|
||||||
@@ -106,6 +113,7 @@ const storePayload = async (
|
|||||||
iv: bytesToHex(iv),
|
iv: bytesToHex(iv),
|
||||||
wrappedKey: bytesToHex(ciphertext),
|
wrappedKey: bytesToHex(ciphertext),
|
||||||
pubkey,
|
pubkey,
|
||||||
|
materialHash,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -115,7 +123,10 @@ const storePayload = async (
|
|||||||
const storeMaterialPayload = async (
|
const storeMaterialPayload = async (
|
||||||
material: WalletMaterialV2,
|
material: WalletMaterialV2,
|
||||||
pubkey = walletMaterialOwnerId(material),
|
pubkey = walletMaterialOwnerId(material),
|
||||||
): Promise<void> => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey);
|
secret = WRAP_SECRET,
|
||||||
|
materialHash = walletMaterialHash(material),
|
||||||
|
): Promise<void> =>
|
||||||
|
storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -194,7 +205,11 @@ describe('biometric wallet material', () => {
|
|||||||
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
|
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
|
||||||
// Given an authenticated non-canonical payload bound to the saved owner
|
// Given an authenticated non-canonical payload bound to the saved owner
|
||||||
await saveWalletMaterial(MATERIAL);
|
await saveWalletMaterial(MATERIAL);
|
||||||
await storePayload(payload, walletMaterialOwnerId(MATERIAL));
|
await storePayload(
|
||||||
|
payload,
|
||||||
|
walletMaterialOwnerId(MATERIAL),
|
||||||
|
walletMaterialHash(MATERIAL),
|
||||||
|
);
|
||||||
const before = persistedState();
|
const before = persistedState();
|
||||||
|
|
||||||
// When biometric unlock decrypts the payload
|
// 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_nwc_connections', 'legacy-nwc-residue');
|
||||||
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
|
localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue');
|
||||||
localStorage.setItem('sattle_trusted_mints', 'legacy-trust-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();
|
const before = persistedState();
|
||||||
|
|
||||||
// When biometric unlock checks the decrypted linking-key owner
|
// 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<typeof persistedState> | 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<typeof persistedState> | 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', () => {
|
describe('biometric deletion ordering', () => {
|
||||||
it('keeps native and local state retryable when secure-secret deletion fails', async () => {
|
it('keeps native and local state retryable when secure-secret deletion fails', async () => {
|
||||||
// Given a complete enrolled biometric wrap
|
// Given a complete enrolled biometric wrap
|
||||||
|
|||||||
@@ -13,10 +13,13 @@
|
|||||||
// storage (Android Keystore-backed AES-GCM via
|
// storage (Android Keystore-backed AES-GCM via
|
||||||
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
|
// @aparajita/capacitor-secure-storage). The wrapped blob stays in
|
||||||
// localStorage as a versioned AES-GCM record, 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
|
// pubkey as an identity check AND the SHA-256 commitment of the canonical
|
||||||
// wrap behind, and unlocking with it must fail loudly (never activate the
|
// saved material: restoring a DIFFERENT seed leaves a stale wrap behind,
|
||||||
// old wallet silently), so unlock verifies the unwrapped material owner against the
|
// and so does replacing only the saved cash root with same-owner material.
|
||||||
// recorded pubkey and tells the holder to re-enroll.
|
// 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
|
// The biometric gate is app-level (a BiometricPrompt before the secure
|
||||||
// read), not a keystore key invalidated on biometric re-enrollment - 04
|
// 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 { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getPlainWalletMaterial,
|
||||||
parseWalletMaterial,
|
parseWalletMaterial,
|
||||||
|
savedWalletMaterialHash,
|
||||||
savedWalletMaterialOwnerId,
|
savedWalletMaterialOwnerId,
|
||||||
serializeWalletMaterial,
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
walletMaterialLinkingKey,
|
walletMaterialLinkingKey,
|
||||||
type WalletMaterialV2,
|
type WalletMaterialV2,
|
||||||
} from '@/lnurlcash/keys';
|
} from '@/lnurlcash/keys';
|
||||||
@@ -55,6 +61,7 @@ type BiometricWrapRecord = {
|
|||||||
readonly iv: string;
|
readonly iv: string;
|
||||||
readonly wrappedKey: string;
|
readonly wrappedKey: string;
|
||||||
readonly pubkey: string;
|
readonly pubkey: string;
|
||||||
|
readonly materialHash: string;
|
||||||
readonly createdAt: number;
|
readonly createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +77,7 @@ const WRAP_RECORD_KEYS = [
|
|||||||
'iv',
|
'iv',
|
||||||
'wrappedKey',
|
'wrappedKey',
|
||||||
'pubkey',
|
'pubkey',
|
||||||
|
'materialHash',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -110,6 +118,8 @@ const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
|
|||||||
/^[0-9a-f]+$/.test(record.wrappedKey) &&
|
/^[0-9a-f]+$/.test(record.wrappedKey) &&
|
||||||
typeof record.pubkey === 'string' &&
|
typeof record.pubkey === 'string' &&
|
||||||
/^[0-9a-f]{66}$/.test(record.pubkey) &&
|
/^[0-9a-f]{66}$/.test(record.pubkey) &&
|
||||||
|
typeof record.materialHash === 'string' &&
|
||||||
|
/^[0-9a-f]{64}$/.test(record.materialHash) &&
|
||||||
typeof record.createdAt === 'number' &&
|
typeof record.createdAt === 'number' &&
|
||||||
Number.isSafeInteger(record.createdAt) &&
|
Number.isSafeInteger(record.createdAt) &&
|
||||||
record.createdAt >= 0
|
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
|
// sync on purpose (same convention as hasPasskeySlots): the unlock form and
|
||||||
// the security page ask this during render/setup
|
// the security page ask this during render/setup
|
||||||
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
|
export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null;
|
||||||
@@ -179,12 +201,27 @@ export const enableBiometricUnlock = async (
|
|||||||
if (savedWalletMaterialOwnerId() !== ownerId) {
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||||
throw new Error('Biometric enrollment requires the proven saved wallet owner.');
|
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();
|
const biometry = await BiometricAuth.checkBiometry();
|
||||||
if (!biometry.isAvailable) {
|
if (!biometry.isAvailable) {
|
||||||
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
|
throw new Error(biometry.reason || 'No biometric unlock is set up on this device.');
|
||||||
}
|
}
|
||||||
// prove presence before storing anything under the biometric gate
|
// prove presence before storing anything under the biometric gate
|
||||||
await authenticateOrThrow('Set up biometric unlock for your wallet');
|
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 secret = crypto.getRandomValues(new Uint8Array(32));
|
||||||
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
|
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
@@ -205,6 +242,7 @@ export const enableBiometricUnlock = async (
|
|||||||
iv: bytesToHex(iv),
|
iv: bytesToHex(iv),
|
||||||
wrappedKey: bytesToHex(ciphertext),
|
wrappedKey: bytesToHex(ciphertext),
|
||||||
pubkey: ownerId,
|
pubkey: ownerId,
|
||||||
|
materialHash,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
||||||
@@ -221,7 +259,24 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMateri
|
|||||||
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
|
'Biometric unlock belongs to a different wallet - set it up again in Settings > 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');
|
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);
|
const secretHex = await SecureStorage.get(SECURE_SECRET_KEY);
|
||||||
if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) {
|
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.');
|
throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.');
|
||||||
@@ -242,6 +297,12 @@ export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMateri
|
|||||||
'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.',
|
'Biometric unlock belongs to a different wallet - set it up again in Settings > 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;
|
return material;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user