mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(biometrics): unlock complete wallet material
This commit is contained in:
@@ -1,101 +1,317 @@
|
|||||||
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
||||||
|
|
||||||
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys';
|
import {
|
||||||
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
|
deriveWalletMaterial,
|
||||||
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots';
|
serializeWalletMaterial,
|
||||||
|
type WalletMaterialV2,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
|
import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage';
|
||||||
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
|
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
|
||||||
const pluginMocks = vi.hoisted(() => ({
|
const pluginMocks = vi.hoisted(() => ({
|
||||||
authenticate: vi.fn<() => Promise<void>>(),
|
authenticate: vi.fn<() => Promise<void>>(),
|
||||||
|
checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(),
|
||||||
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
|
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
|
||||||
|
secureRemove: vi.fn<(key: string) => Promise<void>>(),
|
||||||
|
secureSet: vi.fn<(key: string, value: string) => Promise<void>>(),
|
||||||
|
secureValues: new Map<string, string>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
|
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
|
||||||
AndroidBiometryStrength: { weak: 'weak' },
|
AndroidBiometryStrength: { weak: 'weak' },
|
||||||
BiometricAuth: { authenticate: pluginMocks.authenticate },
|
BiometricAuth: {
|
||||||
|
authenticate: pluginMocks.authenticate,
|
||||||
|
checkBiometry: pluginMocks.checkBiometry,
|
||||||
|
},
|
||||||
BiometryError: class BiometryError extends Error {},
|
BiometryError: class BiometryError extends Error {},
|
||||||
BiometryErrorType: { userCancel: 'userCancel' },
|
BiometryErrorType: { userCancel: 'userCancel' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@aparajita/capacitor-secure-storage', () => ({
|
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 }));
|
vi.mock('./platform', () => ({ isNative: () => true }));
|
||||||
|
|
||||||
import { unlockWithBiometrics } from './biometricUnlock';
|
import {
|
||||||
|
disableBiometricUnlock,
|
||||||
|
enableBiometricUnlock,
|
||||||
|
unlockWalletMaterialWithBiometrics,
|
||||||
|
} from './biometricUnlock';
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7);
|
const SEED =
|
||||||
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
'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_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<CryptoKey> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
stubLocalStorage();
|
stubLocalStorage();
|
||||||
|
pluginMocks.secureValues.clear();
|
||||||
pluginMocks.authenticate.mockResolvedValue();
|
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', () => {
|
describe('biometric wallet material', () => {
|
||||||
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => {
|
it('rejects foreign enrollment before writing either biometric store', async () => {
|
||||||
// Given an ownerless legacy wallet and credential residue plus a biometric wrap
|
// Given one proven saved owner and complete material for another owner
|
||||||
// whose claimed pubkey does not match the key it unwraps
|
await saveWalletMaterial(MATERIAL);
|
||||||
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) };
|
const before = persistedState();
|
||||||
const legacySlot = {
|
|
||||||
credentialId: '11'.repeat(16),
|
// When enrollment is attempted with the foreign material
|
||||||
hkdfSalt: '22'.repeat(16),
|
const attempt = enableBiometricUnlock(OTHER_MATERIAL);
|
||||||
iv: '33'.repeat(12),
|
|
||||||
wrappedKey: '44'.repeat(48),
|
// Then ownership fails before native or local persistence changes
|
||||||
createdAt: 1,
|
await expect(attempt).rejects.toThrow('proven saved wallet owner');
|
||||||
};
|
expect(persistedState()).toEqual(before);
|
||||||
const legacyNwc = [
|
expect(pluginMocks.secureSet).not.toHaveBeenCalled();
|
||||||
{
|
});
|
||||||
clientPubkey: '55'.repeat(32),
|
|
||||||
relays: ['wss://relay.example'],
|
it('does not create a local wrap when secure-secret persistence fails', async () => {
|
||||||
budget: { maxMsat: 1000, periodMs: 60_000 },
|
// Given valid material and a rejecting native secure store
|
||||||
spent: { periodStart: 0, msat: 0 },
|
await saveWalletMaterial(MATERIAL);
|
||||||
createdAt: 1,
|
const before = persistedState();
|
||||||
},
|
pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed'));
|
||||||
];
|
|
||||||
const legacyTrust = [
|
// When enrollment reaches native secret persistence
|
||||||
{
|
const attempt = enableBiometricUnlock(MATERIAL);
|
||||||
server: 'legacy.example',
|
|
||||||
mintPubkey: '02' + 'aa'.repeat(32),
|
// Then failure is reported without a misleading local enrollment record
|
||||||
addedAt: 1,
|
await expect(attempt).rejects.toThrow('secure write failed');
|
||||||
locked: false,
|
expect(persistedState()).toEqual(before);
|
||||||
},
|
});
|
||||||
];
|
|
||||||
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey));
|
it('round-trips both key parts when enrollment belongs to the saved owner', async () => {
|
||||||
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot]));
|
// Given complete canonical material persisted for its proven owner
|
||||||
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc));
|
await saveWalletMaterial(MATERIAL);
|
||||||
localStorage.setItem('sattle_nwc_enabled', 'true');
|
|
||||||
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust));
|
// When the capability enrolls and unlocks it through the native adapter
|
||||||
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY);
|
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(
|
localStorage.setItem(
|
||||||
'sattle_biometric_wrap',
|
WRAP_RECORD_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...wrap,
|
...record,
|
||||||
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY),
|
wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`,
|
||||||
createdAt: 1,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const before = new Map([
|
const before = persistedState();
|
||||||
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
|
|
||||||
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')],
|
// When biometric unlock verifies the AES-GCM tag
|
||||||
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')],
|
const attempt = unlockWalletMaterialWithBiometrics();
|
||||||
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
|
|
||||||
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')],
|
// Then neither key part returns and persistence remains untouched
|
||||||
]);
|
await expect(attempt).rejects.toThrow();
|
||||||
|
expect(persistedState()).toEqual(before);
|
||||||
// When biometric unwrap reaches the pubkey proof check
|
});
|
||||||
const attempt = unlockWithBiometrics();
|
|
||||||
|
it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => {
|
||||||
// Then no key crosses the capability boundary and no legacy namespace is adopted
|
// 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');
|
await expect(attempt).rejects.toThrow('different wallet');
|
||||||
expect(savedKeyOwnerId()).toBeNull();
|
expect(persistedState()).toEqual(before);
|
||||||
expect(readPasskeySlots()).toEqual([]);
|
});
|
||||||
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
|
});
|
||||||
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value);
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
// Native biometric unlock: a THIRD wrap of the same linking key, alongside
|
// Native biometric unlock: a THIRD wrap of the same canonical wallet material,
|
||||||
// the password wrap (keys.ts) and the passkey slots (passkeys.ts).
|
// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts).
|
||||||
//
|
//
|
||||||
// Why native: Android WebView has no usable WebAuthn platform authenticator
|
// Why native: Android WebView has no usable WebAuthn platform authenticator
|
||||||
// for this app (passkeys need Play Services / Digital Asset Links wiring we
|
// 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
|
// cannot rely on), so the biometric path on native is a device-credential
|
||||||
// prompt instead of a passkey ceremony.
|
// prompt instead of a passkey ceremony.
|
||||||
//
|
//
|
||||||
// Design (mirrors the passkey-slot construction, reusing its wrap
|
// Design (mirrors the passkey-slot construction with a biometric-specific
|
||||||
// primitives verbatim): on enrollment a random 32-byte wrap secret is
|
// HKDF domain): on enrollment a random 32-byte wrap secret is generated,
|
||||||
// generated, the linking key is AES-GCM-wrapped under an HKDF of that
|
// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret,
|
||||||
// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32
|
// and only the SECRET goes into biometric-gated secure
|
||||||
// bytes of IKM), and only the SECRET goes into biometric-gated secure
|
|
||||||
// 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 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
|
// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale
|
||||||
// wrap behind, and unlocking with it must fail loudly (never activate the
|
// 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.
|
// recorded pubkey 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
|
||||||
@@ -36,39 +35,84 @@ import {
|
|||||||
BiometryErrorType,
|
BiometryErrorType,
|
||||||
} from '@aparajita/capacitor-biometric-auth';
|
} from '@aparajita/capacitor-biometric-auth';
|
||||||
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
|
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 {
|
||||||
import type { PasskeyWrap } from '@/lnurlcash/passkeys';
|
parseWalletMaterial,
|
||||||
import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
|
savedWalletMaterialOwnerId,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
type WalletMaterialV2,
|
||||||
|
} from '@/lnurlcash/keys';
|
||||||
|
import { isJsonObject } from '@/lnurlcash/jsonParsing';
|
||||||
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
||||||
|
|
||||||
import { isNative } from './platform';
|
import { isNative } from './platform';
|
||||||
|
|
||||||
type BiometricWrapRecord = PasskeyWrap & {
|
type BiometricWrapRecord = {
|
||||||
pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps
|
readonly version: 2;
|
||||||
createdAt: number;
|
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 wrapped blob is useless without the secure-storage secret, so (like
|
||||||
// the passkey slots) this record sits in plain localStorage
|
// the passkey slots) this record sits in plain localStorage
|
||||||
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
|
const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap';
|
||||||
const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret';
|
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<CryptoKey> => {
|
||||||
|
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 => {
|
const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => {
|
||||||
if (typeof record !== 'object' || record === null) return false;
|
if (!isJsonObject(record)) return false;
|
||||||
const r = record as Record<string, unknown>;
|
|
||||||
return (
|
return (
|
||||||
typeof r.hkdfSalt === 'string' &&
|
Object.keys(record).length === WRAP_RECORD_KEYS.length &&
|
||||||
/^[0-9a-f]{32}$/i.test(r.hkdfSalt) &&
|
Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) &&
|
||||||
typeof r.iv === 'string' &&
|
record.version === BIOMETRIC_WRAP_VERSION &&
|
||||||
/^[0-9a-f]{24}$/i.test(r.iv) &&
|
typeof record.hkdfSalt === 'string' &&
|
||||||
typeof r.wrappedKey === 'string' &&
|
/^[0-9a-f]{32}$/.test(record.hkdfSalt) &&
|
||||||
r.wrappedKey.length > 0 &&
|
typeof record.iv === 'string' &&
|
||||||
r.wrappedKey.length % 2 === 0 &&
|
/^[0-9a-f]{24}$/.test(record.iv) &&
|
||||||
/^[0-9a-f]+$/i.test(r.wrappedKey) &&
|
typeof record.wrappedKey === 'string' &&
|
||||||
typeof r.pubkey === 'string' &&
|
record.wrappedKey.length > 0 &&
|
||||||
/^[0-9a-f]{66}$/i.test(r.pubkey) &&
|
record.wrappedKey.length % 2 === 0 &&
|
||||||
typeof r.createdAt === 'number'
|
/^[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<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<void> => {
|
export const enableBiometricUnlock = async (
|
||||||
|
material: WalletMaterialV2 | Uint8Array,
|
||||||
|
): Promise<void> => {
|
||||||
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
|
||||||
|
if (material instanceof Uint8Array) {
|
||||||
|
throw new Error('Biometric enrollment requires complete v2 wallet material.');
|
||||||
|
}
|
||||||
|
const serialized = serializeWalletMaterial(material);
|
||||||
|
const 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();
|
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.');
|
||||||
@@ -130,43 +186,73 @@ export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise<voi
|
|||||||
// 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');
|
||||||
const secret = crypto.getRandomValues(new Uint8Array(32));
|
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||||
const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey);
|
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const wrapKey = await deriveBiometricWrapKey(secret, hkdfSalt);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
wrapKey,
|
||||||
|
new Uint8Array(utf8ToBytes(serialized)),
|
||||||
|
),
|
||||||
|
);
|
||||||
// secure storage first: if it fails, no record is written and the wallet
|
// secure storage first: if it fails, no record is written and the wallet
|
||||||
// simply stays unenrolled instead of carrying an unwrap-able-nothing
|
// simply stays unenrolled instead of carrying an unwrap-able-nothing
|
||||||
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
|
await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret));
|
||||||
const record: BiometricWrapRecord = {
|
const record: BiometricWrapRecord = {
|
||||||
...wrap,
|
version: BIOMETRIC_WRAP_VERSION,
|
||||||
pubkey: linkingPubKeyHex(linkingKey),
|
hkdfSalt: bytesToHex(hkdfSalt),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
wrappedKey: bytesToHex(ciphertext),
|
||||||
|
pubkey: ownerId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unlockWithBiometrics = async (): Promise<Uint8Array> => {
|
export const unlockWalletMaterialWithBiometrics = async (): Promise<WalletMaterialV2> => {
|
||||||
const record = readWrapRecord();
|
const record = readWrapRecord();
|
||||||
if (!isNative() || !record) {
|
if (!isNative() || !record) {
|
||||||
throw new Error('Biometric unlock is not set up on this device.');
|
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');
|
await authenticateOrThrow('Unlock your sattle wallet');
|
||||||
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.');
|
||||||
}
|
}
|
||||||
const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record);
|
const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt));
|
||||||
if (linkingPubKeyHex(linkingKey) !== record.pubkey) {
|
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(
|
throw new Error(
|
||||||
'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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return linkingKey;
|
return material;
|
||||||
};
|
};
|
||||||
|
|
||||||
// the localStorage record goes first and synchronously: even if the caller
|
export const unlockWithBiometrics = async (): Promise<Uint8Array> =>
|
||||||
// fire-and-forgets this (forgetWallet), a later unlock attempt can never
|
walletMaterialLinkingKey(await unlockWalletMaterialWithBiometrics());
|
||||||
// reach the secure secret with a stale record
|
|
||||||
|
// 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> => {
|
export const disableBiometricUnlock = async (): Promise<void> => {
|
||||||
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
|
|
||||||
if (isNative()) {
|
if (isNative()) {
|
||||||
await SecureStorage.remove(SECURE_SECRET_KEY);
|
await SecureStorage.remove(SECURE_SECRET_KEY);
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem(WRAP_RECORD_STORAGE_KEY);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user