Files
sattle/src/lnurlcash/passkeys.crypto.cases.ts
T
2026-09-05 18:30:35 +02:00

182 lines
6.7 KiB
TypeScript

// Passkey engine tests. The WebAuthn ceremony is faked by an injected
// authenticator whose PRF output is HMAC-SHA256(credential secret, salt) -
// the real extension's exact contract: deterministic per credential+salt,
// unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here.
import { beforeEach, describe, expect, it } from 'vitest';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
hasPasskeySlots,
migrateLegacyPasskeySlots,
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
rewrapAllSlots,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys';
import {
decryptRecord,
decryptSavedLinkingKey,
deriveWalletMaterial,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys';
import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
};
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true;
prfResultsOnCreate = true;
prfResultsOnGet = true;
createCalls = 0;
getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), { id, secret });
return {
type: 'public-key',
rawId: id,
getClientExtensionResults: () => ({
prf:
this.supportsPrf && salt
? {
enabled: true,
...(this.prfResultsOnCreate ? { results: { first: this.prf(secret, salt) } } : {}),
}
: {},
}),
};
};
// answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1;
const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined;
if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first;
return {
type: 'public-key',
rawId: held.id,
getClientExtensionResults: () => ({
prf:
salt && this.prfResultsOnGet
? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {},
}),
};
};
// simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
};
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects
const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt)));
return out;
};
}
const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
};
const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId;
delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
};
beforeEach(async () => {
stubLocalStorage();
await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL);
});
describe('pure wrap crypto', () => {
it('round-trips a linking key through a PRF-derived wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap);
expect(unwrapped).toEqual(MATERIAL);
});
it('rejects unwrap with a different PRF output', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow();
});
it('rejects unwrap with a tampered HKDF salt', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
await expect(
unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, hkdfSalt: 'ab'.repeat(16) }),
).rejects.toThrow();
});
it('rejects unwrap with a tampered ciphertext', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}`;
await expect(
unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, wrappedMaterial: flipped }),
).rejects.toThrow();
});
it('derives wrap keys deterministically from the same PRF output and salt', async () => {
const salt = new Uint8Array(16).fill(1);
const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt);
const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt);
const record = await encryptRecord(a, { v: 1 });
await expect(decryptRecord(b, record)).resolves.toEqual({ v: 1 });
});
});