From 12e4ec9199bfd78b741ae38d0fd49135594d85eb Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:54:49 +0200 Subject: [PATCH] feat: scope passkey slots to proven owners --- src/lnurlcash/passkeyOwnership.ts | 15 ++ src/lnurlcash/passkeys.ownership-a.cases.ts | 243 ++++++++++++++++++++ src/lnurlcash/passkeys.ownership-b.cases.ts | 228 ++++++++++++++++++ src/lnurlcash/passkeys.storage.cases.ts | 156 +++++++++++++ src/lnurlcash/passkeys.version.cases.ts | 96 ++++++++ src/lnurlcash/storage/passkeySlots.ts | 218 ++++++++++++++---- 6 files changed, 916 insertions(+), 40 deletions(-) create mode 100644 src/lnurlcash/passkeyOwnership.ts create mode 100644 src/lnurlcash/passkeys.ownership-a.cases.ts create mode 100644 src/lnurlcash/passkeys.ownership-b.cases.ts create mode 100644 src/lnurlcash/passkeys.storage.cases.ts create mode 100644 src/lnurlcash/passkeys.version.cases.ts diff --git a/src/lnurlcash/passkeyOwnership.ts b/src/lnurlcash/passkeyOwnership.ts new file mode 100644 index 0000000..58337e2 --- /dev/null +++ b/src/lnurlcash/passkeyOwnership.ts @@ -0,0 +1,15 @@ +// Legacy passkey slots may be adopted only after another unlock path has +// proven and stamped the saved wallet owner. The linking key is checked +// against that marker before markerless slots are changed under the lock. + +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots' +import {withStorageLock} from './storageLock' + +export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Legacy passkey migration requires a proven owner.') + } + return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId)) +} diff --git a/src/lnurlcash/passkeys.ownership-a.cases.ts b/src/lnurlcash/passkeys.ownership-a.cases.ts new file mode 100644 index 0000000..e9c1ac5 --- /dev/null +++ b/src/lnurlcash/passkeys.ownership-a.cases.ts @@ -0,0 +1,243 @@ +// 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('slot ownership', () => { + it('binds a new slot to the proven saved wallet owner', async () => { + // Given the saved wallet has a canonical owner marker + const auth = new FakeAuthenticator() + + // When its linking key registers a passkey + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + + // Then the slot carries that same canonical owner + expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('filters foreign, malformed, and unowned slots from reads and availability', async () => { + // Given one valid current-owner slot plus copies with untrusted owners + const auth = new FakeAuthenticator() + const current = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = { + ...current, + credentialId: '11'.repeat(16), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + } + const malformed = { + ...current, + credentialId: '22'.repeat(16), + ownerId: 'not-an-owner', + } + const unowned = { + credentialId: '33'.repeat(16), + hkdfSalt: current.hkdfSalt, + iv: current.iv, + wrappedKey: current.wrappedKey, + createdAt: current.createdAt, + } + writeRawSlots([foreign, malformed, unowned]) + + // When the current wallet asks for its slots + const slots = readPasskeySlots() + + // Then no foreign or unproven slot is exposed + expect(slots).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + }) + + it('does not offer markerless slots for passkey-first unlock', async () => { + // Given a legacy slot and a saved key with no proven owner marker + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + removeSavedOwnerMarker() + + // When passkey unlock is attempted before another proof path + const attempt = unlockWithPasskey({credentials: auth}) + + // Then it fails before asking the authenticator + await expect(attempt).rejects.toThrow('No passkeys') + expect(auth.getCalls).toBe(0) + expect(hasPasskeySlots()).toBe(false) + }) + + it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => { + // Given markerless residue from the old wallet + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + + // When a different wallet is installed with its canonical owner + await saveLinkingKey(OTHER_LINKING_KEY) + + // Then the residue stays unowned and unavailable to the new wallet + expect(readPasskeySlots()).toEqual([]) + expect(hasPasskeySlots()).toBe(false) + expect(readRawSlots()).toEqual(legacy) + }) + + it('adopts legacy slots only after the saved wallet owner is proven', async () => { + // Given a legacy encrypted wallet and its markerless passkey slot + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const legacy = readRawSlots() + delete legacy[0]?.ownerId + delete legacy[0]?.version + writeRawSlots(legacy) + await saveLinkingKey(LINKING_KEY, 'correct horse') + removeSavedOwnerMarker() + + // When migration is attempted before and then after password proof + await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') + const provenKey = await decryptSavedLinkingKey('correct horse') + ensureSavedKeyOwner(provenKey) + await migrateLegacyPasskeySlots(provenKey) + + // Then the same slot is stamped once for that proven owner and unlocks + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(readPasskeySlots()).toHaveLength(1) + expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + await expect(unlockWithPasskey({credentials: auth})).resolves.toEqual(LINKING_KEY) + }) +}) diff --git a/src/lnurlcash/passkeys.ownership-b.cases.ts b/src/lnurlcash/passkeys.ownership-b.cases.ts new file mode 100644 index 0000000..6df9811 --- /dev/null +++ b/src/lnurlcash/passkeys.ownership-b.cases.ts @@ -0,0 +1,228 @@ +// 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('slot ownership (continued)', () => { + it('rejects an unwrapped key that does not match the saved proven owner', async () => { + // Given a current-owner slot whose authenticated wrap was replaced with + // a valid wrap of another wallet key + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { + credentials: auth, + }) + const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY) + writeRawSlots([{...slot, ...foreignWrap}]) + + // When the authenticator successfully unwraps that foreign key + const attempt = unlockWithPasskey({credentials: auth}) + + // Then owner validation rejects it before activation can receive it + await expect(attempt).rejects.toThrow('different wallet') + }) + + it('rejects a stale unlock when the saved owner changes during the ceremony', async () => { + // Given an authenticator that replaces the saved wallet before returning + const auth = new FakeAuthenticator() + await registerPasskey(LINKING_KEY, {credentials: auth}) + const stale: PasskeyCredentials = { + create: auth.create, + get: async (options) => { + const credential = await auth.get(options) + await saveLinkingKey(OTHER_LINKING_KEY) + return credential + }, + } + + // When the old wallet's ceremony completes after replacement + const attempt = unlockWithPasskey({credentials: stale}) + + // Then the old linking key is never returned for activation + await expect(attempt).rejects.toThrow('different wallet') + }) + + it('does not adopt a slot carrying a malformed owner marker', async () => { + // Given an otherwise valid slot whose owner claim is malformed + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const malformed = {...slot, ownerId: 'not-an-owner'} + writeRawSlots([malformed]) + + // When the current owner performs the legacy migration + await migrateLegacyPasskeySlots(LINKING_KEY) + + // Then only truly markerless legacy slots are eligible + expect(readPasskeySlots()).toEqual([]) + expect(readRawSlots()).toEqual([malformed]) + }) + + it('cannot remove a foreign-owner slot', async () => { + // Given a slot owned by another wallet remains in shared storage + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)} + writeRawSlots([foreign]) + + // When the current owner asks to remove that credential id + const removed = await removePasskey(slot.credentialId) + + // Then the foreign record is untouched + expect(removed).toBe(false) + expect(readRawSlots()).toEqual([foreign]) + }) + + it('rewraps only current-owner slots and preserves foreign slots', async () => { + // Given current and foreign slots share storage + const auth = new FakeAuthenticator() + const slot = await registerPasskey(LINKING_KEY, {credentials: auth}) + const foreign = { + ...slot, + credentialId: '44'.repeat(16), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + } + writeRawSlots([slot, foreign]) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { + credentials: auth, + }) + + // When the current wallet rewraps its slots + await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]])) + + // Then the foreign slot did not require output and remains byte-identical + expect(readRawSlots()).toContainEqual(foreign) + }) +}) diff --git a/src/lnurlcash/passkeys.storage.cases.ts b/src/lnurlcash/passkeys.storage.cases.ts new file mode 100644 index 0000000..3d6f3ca --- /dev/null +++ b/src/lnurlcash/passkeys.storage.cases.ts @@ -0,0 +1,156 @@ +// 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 {parseJsonArray, 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('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 = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') + 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) + }) +}) diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts new file mode 100644 index 0000000..afd8b0e --- /dev/null +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -0,0 +1,96 @@ +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' +import type {PasskeySlot} from './passkeys' +import {parseJsonObjectArray, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const KEY_STORAGE = 'sattle_linking_key' +const SLOT_STORAGE = 'sattle_passkey_slots' + +const SLOT_BASE = { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, +} as const + +const writeRawSlots = (slots: readonly Record[]): void => { + localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots)) +} + +describe('passkey-slot schema version', () => { + beforeEach(() => { + stubLocalStorage() + }) + it('reads a current version 1 slot for the exact saved owner', () => { + // Given a current saved-key marker and passkey record + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 1} + writeRawSlots([slot]) + + // When current-owner slots are read + // Then the recognized version is exposed unchanged + expect(readPasskeySlots()).toEqual([slot]) + }) + + it('upgrades markerless and unversioned same-owner slots only after saved-key proof', async () => { + // Given records written before schema versioning + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}), + ) + writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}]) + + // When migration is attempted before and after the saved key proves ownership + await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') + const provenKey = getPlainLinkingKey() + if (provenKey === null) throw new Error('expected the compatible plaintext key') + ensureSavedKeyOwner(provenKey) + await migrateLegacyPasskeySlots(provenKey) + + // Then both compatible legacy forms become current records + expect(savedKeyOwnerId()).toBe(ownerId) + expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ + {...SLOT_BASE, ownerId, version: 1}, + {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1}, + ]) + }) + + it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => { + // Given an otherwise valid slot carrying unrecognized metadata + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + writeRawSlots([{...SLOT_BASE, ownerId, version}]) + + // When slots are parsed + // Then future or malformed records are unavailable and not downgraded to legacy + expect(readPasskeySlots()).toEqual([]) + }) + + it('hides a foreign version 1 slot from the current owner', () => { + // Given the saved owner and slot owner differ + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + KEY_STORAGE, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), + ) + writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}]) + + // When the current wallet reads passkeys + // Then the foreign credential is unavailable + expect(readPasskeySlots()).toEqual([]) + }) +}) diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 4d63c8a..3b43cef 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -1,64 +1,202 @@ -// 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. +// Passkey-slot persistence: every new slot is bound to the canonical owner +// of the saved linking key. localStorage remains hostile input, so owner +// markers are parsed strictly and reads expose only slots belonging to the +// currently proven saved owner. Mutations preserve every other record. + +import {savedKeyOwnerId} from '../keys' +import {isJsonObject} from '../jsonParsing' +import {isWalletOwnerId} from './walletOwner' -// 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 + readonly hkdfSalt: string + readonly iv: string + readonly wrappedKey: string } +export const PASSKEY_SLOT_VERSION = 1 as const + export type PasskeySlot = PasskeyWrap & { - credentialId: string // hex of the raw WebAuthn credential id - createdAt: number - name?: string // optional holder label ('laptop', 'phone', ...) + readonly credentialId: string + readonly createdAt: number + readonly name?: string + readonly ownerId: string + readonly version: typeof PASSKEY_SLOT_VERSION +} + +type StoredPasskeySlot = PasskeyWrap & { + readonly credentialId: string + readonly createdAt: number + readonly name?: string + readonly ownerId?: unknown + readonly version?: unknown +} + +type ParsedPasskeySlot = { + readonly record: StoredPasskeySlot + readonly claimedOwnerId: string | null + readonly isCurrent: boolean } 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') - ) +const SLOT_KEYS: readonly string[] = [ + 'credentialId', + 'hkdfSalt', + 'iv', + 'wrappedKey', + 'createdAt', + 'name', + 'ownerId', + 'version', +] + +const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { + if (!isJsonObject(slot)) return null + if ( + typeof slot.credentialId === 'string' && + slot.credentialId.length > 0 && + slot.credentialId.length % 2 === 0 && + /^[0-9a-f]+$/i.test(slot.credentialId) && + typeof slot.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/i.test(slot.hkdfSalt) && + typeof slot.iv === 'string' && + /^[0-9a-f]{24}$/i.test(slot.iv) && + typeof slot.wrappedKey === 'string' && + slot.wrappedKey.length > 0 && + slot.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/i.test(slot.wrappedKey) && + typeof slot.createdAt === 'number' && + (slot.name === undefined || typeof slot.name === 'string') && + Object.keys(slot).every((key) => SLOT_KEYS.includes(key)) + ) { + const record: StoredPasskeySlot = { + credentialId: slot.credentialId, + hkdfSalt: slot.hkdfSalt, + iv: slot.iv, + wrappedKey: slot.wrappedKey, + createdAt: slot.createdAt, + ...(slot.name !== undefined ? {name: slot.name} : {}), + } + const hasOwner = Object.hasOwn(slot, 'ownerId') + const hasVersion = Object.hasOwn(slot, 'version') + if (!hasOwner && !hasVersion) return {record, claimedOwnerId: null, isCurrent: false} + if (!isWalletOwnerId(slot.ownerId)) return null + if (!hasVersion) { + return { + record: {...record, ownerId: slot.ownerId}, + claimedOwnerId: slot.ownerId, + isCurrent: false, + } + } + if (slot.version !== PASSKEY_SLOT_VERSION) return null + return { + record: {...record, ownerId: slot.ownerId, version: PASSKEY_SLOT_VERSION}, + claimedOwnerId: slot.ownerId, + isCurrent: true, + } + } + return null } -// malformed entries are dropped, not thrown on - one corrupted slot must -// not take the remaining passkeys down with it -export const readPasskeySlots = (): PasskeySlot[] => { +const readStoredPasskeySlots = (): ParsedPasskeySlot[] => { 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) : [] + if (!Array.isArray(parsed)) return [] + const slots: ParsedPasskeySlot[] = [] + for (const value of parsed) { + const slot = parseStoredPasskeySlot(value) + if (slot !== null) slots.push(slot) + } + return slots } catch { return [] } } +const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | null => { + if (!stored.isCurrent || stored.claimedOwnerId !== ownerId) { + return null + } + const record = stored.record + return { + credentialId: record.credentialId, + hkdfSalt: record.hkdfSalt, + iv: record.iv, + wrappedKey: record.wrappedKey, + createdAt: record.createdAt, + ...(record.name !== undefined ? {name: record.name} : {}), + ownerId, + version: PASSKEY_SLOT_VERSION, + } +} + +export const readPasskeySlots = (): PasskeySlot[] => { + const ownerId = savedKeyOwnerId() + if (ownerId === null) return [] + return readStoredPasskeySlots() + .map((slot) => asOwnedSlot(slot, ownerId)) + .filter((slot): slot is PasskeySlot => slot !== null) +} + export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 -export const writePasskeySlots = (slots: PasskeySlot[]): void => { +export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Passkey slots require the proven saved wallet owner.') + } + if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { + throw new Error('Refusing to write a passkey slot for a different wallet.') + } + const preserved = readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== ownerId) + .map((slot) => slot.record) + localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify([...preserved, ...slots])) +} + +export const adoptLegacyPasskeySlots = (ownerId: string): number => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy passkey migration requires a proven owner.') + } + const stored = readStoredPasskeySlots() + let adopted = 0 + const migrated = stored.map((slot) => { + if (slot.isCurrent || (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)) { + return slot.record + } + adopted += 1 + return {...slot.record, ownerId, version: PASSKEY_SLOT_VERSION} + }) + if (adopted > 0) { + localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(migrated)) + } + return adopted +} + +const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => { + if (slots.length === 0) { + localStorage.removeItem(PASSKEY_SLOTS_STORAGE_KEY) + return + } localStorage.setItem(PASSKEY_SLOTS_STORAGE_KEY, JSON.stringify(slots)) } + +export const clearPasskeySlotsForOwner = (ownerId: string): void => { + if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + throw new Error('Passkey teardown requires the proven saved wallet owner.') + } + persistStoredPasskeySlots( + readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== ownerId) + .map((slot) => slot.record), + ) +} + +export const clearUnownedPasskeySlots = (): void => { + persistStoredPasskeySlots( + readStoredPasskeySlots() + .filter((slot) => slot.claimedOwnerId !== null) + .map((slot) => slot.record), + ) +}