import { hmac } from '@noble/hashes/hmac.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; import { beforeEach, describe, expect, it } from 'vitest'; import { deriveWalletMaterial, saveWalletMaterial } from './keys'; import { getPasskeyPrfOutput, registerPasskey, rewrapAllSlots, unlockWalletMaterialWithPasskey, } from './passkeys'; import type { CeremonyCredential, PasskeyCredentials } from './passkeys'; import { stubLocalStorage } from './test-utils'; const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; const MATERIAL = { ...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32) }; const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC); const ALTERED_ROOT = { ...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex }; const SLOT_STORAGE_KEY = 'sattle_passkey_slots'; const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer ? new Uint8Array(source) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); class FakeAuthenticator implements PasskeyCredentials { private readonly credentialId = new Uint8Array(16).fill(12); private readonly secret = new Uint8Array(32).fill(13); onCreate?: () => Promise; onGet?: () => Promise; create = async (options?: CredentialCreationOptions): Promise => { await this.onCreate?.(); return this.credential(options?.publicKey?.extensions?.prf?.eval?.first); }; get = async (options?: CredentialRequestOptions): Promise => { await this.onGet?.(); const allowed = options?.publicKey?.allowCredentials ?? []; return allowed.some( (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), ) ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) : null; }; private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ type: 'public-key', rawId: this.credentialId, getClientExtensionResults: () => ({ prf: salt ? { enabled: true, results: { first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt))) }, } : {}, }), }); } const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? ''; beforeEach(async () => { stubLocalStorage(); await saveWalletMaterial(MATERIAL); }); describe('exact wallet material passkey commitment', () => { it('rejects a wrong old PRF before changing any slot bytes', async () => { const authenticator = new FakeAuthenticator(); const slot = await registerPasskey(MATERIAL, { credentials: authenticator }); const before = rawSlots(); const wrongPrf = new Uint8Array(32).fill(99); await expect( rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])), ).rejects.toThrow(); expect(rawSlots()).toBe(before); }); it('rejects same-owner altered-root registration against encrypted saved material', async () => { const authenticator = new FakeAuthenticator(); await saveWalletMaterial(MATERIAL, 'password'); await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow( 'saved wallet material', ); expect(rawSlots()).toBe(''); }); it('rejects registration when same-owner saved material changes during the ceremony', async () => { const authenticator = new FakeAuthenticator(); authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT); await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow( 'saved wallet material', ); expect(rawSlots()).toBe(''); }); it('rejects unlock after same-owner saved material was replaced', async () => { const authenticator = new FakeAuthenticator(); await registerPasskey(MATERIAL, { credentials: authenticator }); await saveWalletMaterial(ALTERED_ROOT, 'password'); await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow(); }); it('rejects same-owner saved-material replacement during the unlock ceremony', async () => { const authenticator = new FakeAuthenticator(); await registerPasskey(MATERIAL, { credentials: authenticator }); authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT); await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow( 'changed', ); }); it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => { const authenticator = new FakeAuthenticator(); const slot = await registerPasskey(MATERIAL, { credentials: authenticator }); const oldPrf = await getPasskeyPrfOutput(slot.credentialId, { credentials: authenticator }); await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]])); await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual( MATERIAL, ); }); });