From 058b10ddf16cf34b0fdceee08f6fef17aeb565b6 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:54:48 +0200 Subject: [PATCH] refactor: isolate passkey wrapping crypto --- src/lnurlcash/passkeyWrap.ts | 35 ++--- src/lnurlcash/passkeys.crypto.cases.ts | 173 +++++++++++++++++++++++ src/lnurlcash/passkeys.rewrap.cases.ts | 188 +++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 src/lnurlcash/passkeys.crypto.cases.ts create mode 100644 src/lnurlcash/passkeys.rewrap.cases.ts diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index abdb9c6..c59f1de 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -16,15 +16,11 @@ const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1' export const derivePasskeyWrapKey = async ( prfOutput: Uint8Array, - hkdfSalt: Uint8Array + hkdfSalt: Uint8Array, ): Promise => { - const baseKey = await crypto.subtle.importKey( - 'raw', - new Uint8Array(prfOutput), - 'HKDF', - false, - ['deriveKey'] - ) + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(prfOutput), 'HKDF', false, [ + 'deriveKey', + ]) return crypto.subtle.deriveKey( // the copies pin the TS type to Uint8Array - hexToBytes // returns Uint8Array, which BufferSource rejects @@ -32,33 +28,29 @@ export const derivePasskeyWrapKey = async ( name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(hkdfSalt), - info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)) + info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)), }, baseKey, {name: 'AES-GCM', length: 256}, false, - ['encrypt', 'decrypt'] + ['encrypt', 'decrypt'], ) } export const wrapLinkingKeyWithPrf = async ( prfOutput: Uint8Array, - linkingKey: Uint8Array + linkingKey: Uint8Array, ): Promise => { const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - {name: 'AES-GCM', iv}, - wrapKey, - new Uint8Array(linkingKey) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, new Uint8Array(linkingKey)), ) return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - wrappedKey: bytesToHex(ciphertext) + wrappedKey: bytesToHex(ciphertext), } } @@ -66,16 +58,13 @@ export const wrapLinkingKeyWithPrf = async ( // i.e. a different passkey than the one that created the slot export const unwrapLinkingKeyWithPrf = async ( prfOutput: Uint8Array, - wrap: PasskeyWrap + wrap: PasskeyWrap, ): Promise => { - const wrapKey = await derivePasskeyWrapKey( - prfOutput, - hexToBytes(wrap.hkdfSalt) - ) + const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt)) const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))}, wrapKey, - new Uint8Array(hexToBytes(wrap.wrappedKey)) + new Uint8Array(hexToBytes(wrap.wrappedKey)), ) return new Uint8Array(plaintext) } diff --git a/src/lnurlcash/passkeys.crypto.cases.ts b/src/lnurlcash/passkeys.crypto.cases.ts new file mode 100644 index 0000000..5cbd799 --- /dev/null +++ b/src/lnurlcash/passkeys.crypto.cases.ts @@ -0,0 +1,173 @@ +// 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, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} 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 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: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + 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 => { + 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 => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): 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) +}) + +describe('pure wrap crypto', () => { + it('round-trips a linking key through a PRF-derived wrap', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + }) + + it('rejects unwrap with a different PRF output', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() + }) + + it('rejects unwrap with a tampered HKDF salt', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + await expect( + unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}), + ).rejects.toThrow() + }) + + it('rejects unwrap with a tampered ciphertext', async () => { + const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const flipped = `${wrap.wrappedKey.slice(0, -2)}${wrap.wrappedKey.endsWith('00') ? '01' : '00'}` + await expect( + unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: 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}) + }) +}) diff --git a/src/lnurlcash/passkeys.rewrap.cases.ts b/src/lnurlcash/passkeys.rewrap.cases.ts new file mode 100644 index 0000000..db5e833 --- /dev/null +++ b/src/lnurlcash/passkeys.rewrap.cases.ts @@ -0,0 +1,188 @@ +// 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, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf, +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + ensureSavedKeyOwner, + encryptRecord, + linkingPubKeyHex, + savedKeyOwnerId, + saveLinkingKey, +} 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 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: rawId must satisfy BufferSource + private held = new Map; secret: Uint8Array}>() + supportsPrf = true + prfResultsOnCreate = true + prfResultsOnGet = true + createCalls = 0 + getCalls = 0 + + create = async (options?: CredentialCreationOptions): Promise => { + 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 => { + 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 => { + // set into a fresh array: hmac returns Uint8Array, + // which BufferSource rejects + const out = new Uint8Array(32) + out.set(hmac(sha256, secret, toBytes(salt))) + return out + } +} + +const readRawSlots = (): Array> => + parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + +const writeRawSlots = (slots: Array>): 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) +}) + +describe('rewrap for the current owner', () => { + it('refreshes every current-owner wrap all-or-nothing', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop, + }) + const phoneSlot = await registerPasskey(LINKING_KEY, { + credentials: phone, + }) + + // partial coverage aborts before writing + const partial = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop, + }), + ], + ]) + await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap') + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + + // full coverage refreshes both wraps around the same proven owner key + const fresh = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop, + }), + ], + [ + phoneSlot.credentialId, + await getPasskeyPrfOutput(phoneSlot.credentialId, { + credentials: phone, + }), + ], + ]) + await rewrapAllSlots(LINKING_KEY, fresh) + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) + expect(readPasskeySlots()[0]?.wrappedKey).not.toBe(laptopSlot.wrappedKey) + // credential ids and labels survive the re-wrap + expect( + readPasskeySlots() + .map((s) => s.credentialId) + .sort(), + ).toEqual([laptopSlot.credentialId, phoneSlot.credentialId].sort()) + }) +})