diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts new file mode 100644 index 0000000..abdb9c6 --- /dev/null +++ b/src/lnurlcash/passkeyWrap.ts @@ -0,0 +1,81 @@ +// The pure crypto core of passkey unlock (passkeys.ts): HKDF-SHA256 from a +// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of the +// linking key under it. No WebAuthn, no storage - a PRF output is just 32 +// bytes, so everything here is unit-testable in plain Node. +// +// A per-slot random HKDF salt separates the wrap keys of different passkeys +// even though the ceremony-side PRF salt is fixed (see passkeys.ts); the +// info string domain-separates these keys from any other key ever derived +// from the same PRF output. + +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' + +import type {PasskeyWrap} from './storage/passkeySlots' + +const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1' + +export const derivePasskeyWrapKey = async ( + prfOutput: Uint8Array, + hkdfSalt: Uint8Array +): Promise => { + 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 + { + 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'] + ) +} + +export const wrapLinkingKeyWithPrf = async ( + prfOutput: 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) + ) + ) + return { + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + wrappedKey: bytesToHex(ciphertext) + } +} + +// rejects (WebCrypto's own auth-tag check) if the PRF output is wrong - +// i.e. a different passkey than the one that created the slot +export const unwrapLinkingKeyWithPrf = async ( + prfOutput: Uint8Array, + wrap: PasskeyWrap +): Promise => { + 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)) + ) + return new Uint8Array(plaintext) +} diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts new file mode 100644 index 0000000..8796981 --- /dev/null +++ b/src/lnurlcash/passkeys.test.ts @@ -0,0 +1,458 @@ +// 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, + passkeySupported, + readPasskeySlots, + registerPasskey, + removePasskey, + rewrapAllSlots, + unlockWithPasskey, + unwrapLinkingKeyWithPrf, + wrapLinkingKeyWithPrf +} from './passkeys' +import { + decryptRecord, + decryptSavedLinkingKey, + deriveBearerAesKey, + encryptRecord, + saveLinkingKey +} from './keys' +import {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< + string, + {id: Uint8Array; 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 + } +} + +beforeEach(() => { + stubLocalStorage() +}) + +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}) + }) +}) + +describe('registration and unlock', () => { + it('registers a passkey and unlocks the same linking key', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, { + credentials: auth, + name: 'laptop' + }) + expect(slot.name).toBe('laptop') + expect(readPasskeySlots()).toEqual([slot]) + expect(hasPasskeySlots()).toBe(true) + + const unwrapped = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + }) + + it('never stores the linking key in the clear', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const raw = localStorage.getItem('sattle_passkey_slots') + expect(raw).toBeTruthy() + expect(raw).not.toContain(bytesToHex(LINKING_KEY)) + }) + + it('yields the same key material unlock(password) yields', async () => { + const linkingKey = crypto.getRandomValues(new Uint8Array(32)) + await saveLinkingKey(linkingKey, 'correct horse') + const auth = new FakeAuthenticator() + await registerPasskey(linkingKey, {credentials: auth}) + + const viaPassword = await decryptSavedLinkingKey('correct horse') + const viaPasskey = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword)) + + // and the practical consequence: a bearer record encrypted after a + // password unlock decrypts after a passkey unlock + const passwordAes = await deriveBearerAesKey(viaPassword) + const record = await encryptRecord(passwordAes, {note: 'still readable'}) + const passkeyAes = await deriveBearerAesKey(viaPasskey) + await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ + note: 'still readable' + }) + }) + + it('falls back to a get ceremony when create only reports prf.enabled', async () => { + const auth = new FakeAuthenticator() + auth.prfResultsOnCreate = false + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + expect(auth.getCalls).toBe(1) + const unwrapped = await unlockWithPasskey({credentials: auth}) + expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId) + }) + + it('refuses registration when the authenticator has no PRF support', async () => { + const auth = new FakeAuthenticator() + auth.supportsPrf = false + await expect( + registerPasskey(LINKING_KEY, {credentials: auth}) + ).rejects.toThrow('PRF') + expect(hasPasskeySlots()).toBe(false) + }) + + it('throws on a cancelled registration ceremony', async () => { + const cancelled: PasskeyCredentials = { + create: async () => null, + get: async () => null + } + await expect( + registerPasskey(LINKING_KEY, {credentials: cancelled}) + ).rejects.toThrow('cancelled') + expect(hasPasskeySlots()).toBe(false) + }) + + it('throws before any ceremony when no passkeys are registered', async () => { + const auth = new FakeAuthenticator() + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow( + 'No passkeys' + ) + expect(auth.getCalls).toBe(0) + }) + + it('rejects unlock when the passkey returns no PRF secret', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + auth.prfResultsOnGet = false + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow( + 'PRF secret' + ) + }) + + it('rejects unlock when the ceremony yields an unregistered credential', async () => { + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const rogue: PasskeyCredentials = { + create: async () => null, + get: async () => ({ + type: 'public-key', + rawId: crypto.getRandomValues(new Uint8Array(16)), + getClientExtensionResults: () => ({ + prf: {enabled: true, results: {first: new Uint8Array(32)}} + }) + }) + } + await expect(unlockWithPasskey({credentials: rogue})).rejects.toThrow( + 'not registered' + ) + }) + + it('rejects unlock after the authenticator secret changed underneath the slot', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + auth.rotateSecret(slot.credentialId) + await expect(unlockWithPasskey({credentials: auth})).rejects.toThrow() + }) +}) + +describe('multiple passkeys', () => { + it('keeps slots independent: each passkey unlocks the same key', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop, + name: 'laptop' + }) + const phoneSlot = await registerPasskey(LINKING_KEY, { + credentials: phone, + name: 'phone' + }) + expect(readPasskeySlots()).toHaveLength(2) + // independent wrap keys: same plaintext, different salts and ciphertexts + expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) + expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey) + + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( + bytesToHex(LINKING_KEY) + ) + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( + bytesToHex(LINKING_KEY) + ) + }) + + it('removePasskey drops exactly one slot and leaves the rest working', async () => { + const laptop = new FakeAuthenticator() + const phone = new FakeAuthenticator() + const laptopSlot = await registerPasskey(LINKING_KEY, { + credentials: laptop + }) + await registerPasskey(LINKING_KEY, {credentials: phone}) + + await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true) + expect(readPasskeySlots()).toHaveLength(1) + + // the removed passkey no longer matches any offered credential + await expect(unlockWithPasskey({credentials: laptop})).rejects.toThrow( + 'cancelled' + ) + // the survivor is unaffected + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( + bytesToHex(LINKING_KEY) + ) + // removing again is a no-op + await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false) + }) +}) + +describe('rewrap on linking-key rotation', () => { + it('re-wraps every slot onto the new key, 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: both slots still unwrap the + // OLD key afterwards + const partial = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop + }) + ] + ]) + await expect(rewrapAllSlots(OTHER_LINKING_KEY, partial)).rejects.toThrow( + 'partial re-wrap' + ) + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( + bytesToHex(LINKING_KEY) + ) + + // full coverage: both slots now unwrap the NEW key + const fresh = new Map([ + [ + laptopSlot.credentialId, + await getPasskeyPrfOutput(laptopSlot.credentialId, { + credentials: laptop + }) + ], + [ + phoneSlot.credentialId, + await getPasskeyPrfOutput(phoneSlot.credentialId, { + credentials: phone + }) + ] + ]) + await rewrapAllSlots(OTHER_LINKING_KEY, fresh) + expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe( + bytesToHex(OTHER_LINKING_KEY) + ) + expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe( + bytesToHex(OTHER_LINKING_KEY) + ) + // credential ids and labels survive the re-wrap + expect(readPasskeySlots().map(s => s.credentialId).sort()).toEqual( + [laptopSlot.credentialId, phoneSlot.credentialId].sort() + ) + }) +}) + +describe('slot storage hygiene', () => { + it('drops malformed entries instead of throwing', async () => { + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const stored: unknown[] = JSON.parse( + localStorage.getItem('sattle_passkey_slots') ?? '[]' + ) as unknown[] + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([...stored, {credentialId: 'zz', hkdfSalt: 1}, 'garbage', null]) + ) + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('treats unparseable storage as empty', () => { + localStorage.setItem('sattle_passkey_slots', '{not json') + expect(readPasskeySlots()).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + }) +}) + +describe('passkeySupported', () => { + it('is false without a PublicKeyCredential probe', async () => { + // node test env has no PublicKeyCredential global: the default lookup + // finds nothing + await expect(passkeySupported()).resolves.toBe(false) + }) + + it('is false without a user-verifying platform authenticator', async () => { + await expect( + passkeySupported({ + isUserVerifyingPlatformAuthenticatorAvailable: async () => false, + getClientCapabilities: async () => ({'extension:prf': true}) + }) + ).resolves.toBe(false) + }) + + it('checks extension:prf when client capabilities are available', async () => { + const platform = { + isUserVerifyingPlatformAuthenticatorAvailable: async () => true + } + await expect( + passkeySupported({ + ...platform, + getClientCapabilities: async () => ({'extension:prf': true}) + }) + ).resolves.toBe(true) + await expect( + passkeySupported({ + ...platform, + getClientCapabilities: async () => ({'extension:prf': false}) + }) + ).resolves.toBe(false) + }) + + it('is optimistic when capabilities cannot be pre-detected', async () => { + await expect( + passkeySupported({ + isUserVerifyingPlatformAuthenticatorAvailable: async () => true + }) + ).resolves.toBe(true) + }) +}) diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts new file mode 100644 index 0000000..a213497 --- /dev/null +++ b/src/lnurlcash/passkeys.ts @@ -0,0 +1,315 @@ +// Passkey (WebAuthn PRF) unlock: an ALTERNATIVE wrap of the same linking +// key the password path protects (keys.ts) - never a second key, so notes +// encrypted under a password unlock stay readable after a passkey unlock +// and vice versa (both yield the identical linking key, from which the +// bearer AES key derives). +// +// No master-key indirection is introduced: unlike Bitwarden, this wallet +// persists exactly one secret - the seed-derived linking key - and the +// bearer-encryption key is derived from it (not wrapped by it), so a random +// master key would only ever encrypt that one 32-byte value while forcing a +// migration of every existing store. The linking key IS the "master key" +// here: the password wrap (keys.ts) and each passkey slot below are +// independent wraps of the same key material. +// +// The module is split into a pure-crypto core (passkeyWrap.ts: HKDF from a +// PRF output to an AES-GCM wrap key, slot wrap/unwrap - fully unit-tested) +// and a thin WebAuthn glue layer whose credentials container is injected, +// so tests drive the ceremonies with a fake authenticator. Slot records +// live in storage/passkeySlots.ts. +// +// PRF salt strategy: one FIXED 32-byte salt for every slot. A get() +// ceremony can evaluate only one prf.eval input for whichever credential +// the authenticator ends up using, and per-credential evalByCredential is +// not widely implemented - a shared salt keeps multi-passkey unlock a +// single ceremony. The salt is not a secret: the PRF output is HMAC over +// the authenticator's per-credential secret, so each passkey still yields +// an independent, unguessable wrap secret. A per-slot random HKDF salt then +// separates the actual wrap keys. + +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' + +import {withStorageLock} from './storageLock' +import type {PasskeySlot} from './storage/passkeySlots' +import { + PASSKEY_SLOTS_STORAGE_KEY, + readPasskeySlots, + writePasskeySlots +} from './storage/passkeySlots' +import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap' + +export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' +export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' +export { + derivePasskeyWrapKey, + wrapLinkingKeyWithPrf, + unwrapLinkingKeyWithPrf +} from './passkeyWrap' + +// 32 bytes, fixed - the authenticator requires exactly 32 +const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) + +// ---- WebAuthn glue (browser-only; credentials container injected) ---- + +// the structural slice of a PublicKeyCredential the engine consumes - a +// fake authenticator in tests implements exactly this +export type CeremonyCredential = { + type: string + rawId: BufferSource + getClientExtensionResults(): AuthenticationExtensionsClientOutputs +} + +// the slice of navigator.credentials the ceremonies need +export type PasskeyCredentials = { + create( + options?: CredentialCreationOptions + ): Promise + get(options?: CredentialRequestOptions): Promise +} + +export type PasskeySupportProbe = { + isUserVerifyingPlatformAuthenticatorAvailable(): Promise + getClientCapabilities?(): Promise> +} + +// the one runtime narrow at the browser boundary: navigator.credentials +// resolves to the Credential supertype, but a publicKey ceremony always +// produces a PublicKeyCredential +const asCeremonyCredential = ( + credential: Credential | null +): CeremonyCredential | null => { + if (!credential || credential.type !== 'public-key') return null + if (!('rawId' in credential)) return null + if (!('getClientExtensionResults' in credential)) return null + return credential as unknown as CeremonyCredential +} + +const defaultCredentials = (): PasskeyCredentials => { + if (typeof navigator === 'undefined' || !navigator.credentials) { + throw new Error('WebAuthn is not available in this environment.') + } + const container = navigator.credentials + return { + create: options => container.create(options).then(asCeremonyCredential), + get: options => container.get(options).then(asCeremonyCredential) + } +} + +// Feature detection: a user-verifying platform authenticator (Touch ID, +// Windows Hello, Android biometrics) plus the PRF extension. PRF has no +// direct pre-flight check on older clients - where getClientCapabilities +// exists we can ask for it, elsewhere this returns true optimistically and +// registration itself fails with a clear error. +export const passkeySupported = async ( + probe?: PasskeySupportProbe +): Promise => { + const p = + probe ?? + (typeof PublicKeyCredential !== 'undefined' + ? PublicKeyCredential + : undefined) + if (!p) return false + if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false + if (p.getClientCapabilities) { + const capabilities = await p.getClientCapabilities() + return capabilities['extension:prf'] === true + } + return true +} + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +// pulls the evaluated PRF secret out of a ceremony result; null when the +// authenticator did not evaluate the extension (no hmac-secret support) +const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => { + const first = credential.getClientExtensionResults().prf?.results?.first + return first ? toBytes(first) : null +} + +// one get() ceremony against a single known credential, returning its fresh +// PRF output - the building block for re-wrap ceremonies during linking-key +// rotation +export const getPasskeyPrfOutput = async ( + credentialId: string, + options: {credentials?: PasskeyCredentials} = {} +): Promise => { + const credentials = options.credentials ?? defaultCredentials() + const assertion = await credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [ + {type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))} + ], + userVerification: 'required', + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} + } + }) + if (!assertion) throw new Error('Passkey ceremony was cancelled.') + const prfOutput = prfOutputOf(assertion) + if (!prfOutput) { + throw new Error( + 'This passkey did not return a PRF secret - it cannot unlock this wallet.' + ) + } + return prfOutput +} + +export type RegisterPasskeyOptions = { + credentials?: PasskeyCredentials + name?: string + authenticatorAttachment?: AuthenticatorAttachment +} + +// Registers a new passkey and persists a slot wrapping the given linking +// key. The caller supplies the linking key from the currently unlocked +// wallet; the ceremony is navigator.credentials.create with the PRF +// extension evaluated on creation. Some authenticators only report +// prf.enabled during create and evaluate the secret on the first get - +// those get a follow-up get() against the fresh credential. +export const registerPasskey = async ( + linkingKey: Uint8Array, + options: RegisterPasskeyOptions = {} +): Promise => { + const credentials = options.credentials ?? defaultCredentials() + const credential = await credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: {name: 'sattle'}, + user: { + // random per registration: slots address credentials by id, no + // discoverable-credential login is used + id: crypto.getRandomValues(new Uint8Array(16)), + name: 'sattle wallet', + displayName: 'sattle wallet' + }, + pubKeyCredParams: [ + {type: 'public-key', alg: -7}, // ES256 + {type: 'public-key', alg: -257} // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: options.authenticatorAttachment ?? 'platform', + residentKey: 'preferred', + userVerification: 'required' + }, + attestation: 'none', + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} + } + }) + if (!credential) throw new Error('Passkey registration was cancelled.') + const credentialId = bytesToHex(toBytes(credential.rawId)) + let prfOutput = prfOutputOf(credential) + if (!prfOutput) { + if (credential.getClientExtensionResults().prf?.enabled !== true) { + throw new Error( + 'This authenticator does not support the WebAuthn PRF extension.' + ) + } + prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) + } + const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey) + const slot: PasskeySlot = { + credentialId, + ...wrap, + createdAt: Date.now(), + ...(options.name !== undefined ? {name: options.name} : {}) + } + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { + const slots = readPasskeySlots().filter( + s => s.credentialId !== credentialId + ) + slots.push(slot) + writePasskeySlots(slots) + }) + return slot +} + +// Unlocks via any registered passkey: one get() ceremony offering every +// slot's credential, then unwrap. Yields the exact same linking key +// unlock(password) yields - the caller activates the wallet with it. +export const unlockWithPasskey = async ( + options: {credentials?: PasskeyCredentials} = {} +): Promise => { + const slots = readPasskeySlots() + if (slots.length === 0) { + throw new Error('No passkeys registered on this device.') + } + const credentials = options.credentials ?? defaultCredentials() + const assertion = await credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: slots.map(slot => ({ + type: 'public-key', + id: new Uint8Array(hexToBytes(slot.credentialId)) + })), + userVerification: 'required', + extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} + } + }) + if (!assertion) throw new Error('Passkey ceremony was cancelled.') + const credentialId = bytesToHex(toBytes(assertion.rawId)) + const slot = slots.find(s => s.credentialId === credentialId) + if (!slot) { + throw new Error('The passkey used is not registered with this wallet.') + } + const prfOutput = prfOutputOf(assertion) + if (!prfOutput) { + throw new Error( + 'This passkey did not return a PRF secret - it cannot unlock this wallet.' + ) + } + return unwrapLinkingKeyWithPrf(prfOutput, slot) +} + +// Removes the slot only: WebAuthn has no API to delete the credential from +// the authenticator - an orphaned passkey simply finds nothing to unwrap. +// Returns whether a slot was actually removed. +export const removePasskey = async ( + credentialId: string +): Promise => { + let removed = false + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { + const slots = readPasskeySlots() + const kept = slots.filter(s => s.credentialId !== credentialId) + removed = kept.length !== slots.length + if (removed) writePasskeySlots(kept) + }) + return removed +} + +// Re-wraps every slot around NEW key material - needed on linking-key +// rotation (restoring a different seed while keeping the passkeys). Each +// slot's wrap secret lives only inside its authenticator, so the caller +// must supply a fresh PRF output per credential (one getPasskeyPrfOutput +// ceremony each). All-or-nothing: a slot without a PRF output aborts the +// whole re-wrap before anything is written, since a half-rewrapped set +// would keep unlocking the OLD key with the uncovered passkeys. +// +// A password change does NOT need this: the password wrap (keys.ts) and the +// passkey slots wrap the same linking key independently, so re-encrypting +// the stored key under a new password leaves every slot valid. +export const rewrapAllSlots = async ( + linkingKey: Uint8Array, + prfOutputs: ReadonlyMap +): Promise => { + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { + const slots = readPasskeySlots() + const rewrapped: PasskeySlot[] = [] + for (const slot of slots) { + const prfOutput = prfOutputs.get(slot.credentialId) + if (!prfOutput) { + throw new Error( + 'Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.' + ) + } + rewrapped.push({ + ...slot, + ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)) + }) + } + writePasskeySlots(rewrapped) + }) +} diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts new file mode 100644 index 0000000..4d63c8a --- /dev/null +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -0,0 +1,64 @@ +// Passkey-slot persistence: one localStorage record holding every passkey +// wrap of the linking key (see passkeys.ts). Slots are public metadata plus +// AES-GCM wrapped keys - a wrapped blob is useless without the passkey's +// authenticator, so this sits next to the plaintext registries. Read/write +// are exported bare; callers serialize read-modify-write cycles with +// withStorageLock, same convention as bearers.ts. + +// the encrypted half of a slot: the linking key under a passkey wrap key +export type PasskeyWrap = { + hkdfSalt: string // hex, 16 bytes - per-slot HKDF salt + iv: string // hex, 12 bytes + wrappedKey: string // hex, AES-GCM ciphertext of the 32-byte linking key +} + +export type PasskeySlot = PasskeyWrap & { + credentialId: string // hex of the raw WebAuthn credential id + createdAt: number + name?: string // optional holder label ('laptop', 'phone', ...) +} + +export const PASSKEY_SLOTS_STORAGE_KEY = 'sattle_passkey_slots' + +// strict shape check, same spirit as keys.ts's isValidStoredSecret: +// localStorage content is not trustworthy input (hand-edited, restored +// backups), so slots are validated before use +const isValidPasskeySlot = (slot: unknown): slot is PasskeySlot => { + if (typeof slot !== 'object' || slot === null) return false + const s = slot as Record + return ( + typeof s.credentialId === 'string' && + s.credentialId.length > 0 && + s.credentialId.length % 2 === 0 && + /^[0-9a-f]+$/i.test(s.credentialId) && + typeof s.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/i.test(s.hkdfSalt) && + typeof s.iv === 'string' && + /^[0-9a-f]{24}$/i.test(s.iv) && + typeof s.wrappedKey === 'string' && + s.wrappedKey.length > 0 && + s.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/i.test(s.wrappedKey) && + typeof s.createdAt === 'number' && + (s.name === undefined || typeof s.name === 'string') + ) +} + +// malformed entries are dropped, not thrown on - one corrupted slot must +// not take the remaining passkeys down with it +export const readPasskeySlots = (): PasskeySlot[] => { + const raw = localStorage.getItem(PASSKEY_SLOTS_STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter(isValidPasskeySlot) : [] + } catch { + return [] + } +} + +export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 + +export const writePasskeySlots = (slots: PasskeySlot[]): void => { + localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots)) +}