diff --git a/src/lnurlcash/passkeys.multiple.cases.ts b/src/lnurlcash/passkeys.multiple.cases.ts new file mode 100644 index 0000000..f948226 --- /dev/null +++ b/src/lnurlcash/passkeys.multiple.cases.ts @@ -0,0 +1,178 @@ +// 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('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) + }) +}) diff --git a/src/lnurlcash/passkeys.registration.cases.ts b/src/lnurlcash/passkeys.registration.cases.ts new file mode 100644 index 0000000..8e7065d --- /dev/null +++ b/src/lnurlcash/passkeys.registration.cases.ts @@ -0,0 +1,245 @@ +// 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('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() + }) +}) diff --git a/src/lnurlcash/passkeys.support.cases.ts b/src/lnurlcash/passkeys.support.cases.ts new file mode 100644 index 0000000..4ee6a30 --- /dev/null +++ b/src/lnurlcash/passkeys.support.cases.ts @@ -0,0 +1,180 @@ +// 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('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.test.ts b/src/lnurlcash/passkeys.test.ts index 8796981..ab091b1 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,458 +1,9 @@ -// 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) - }) -}) +import './passkeys.crypto.cases' +import './passkeys.registration.cases' +import './passkeys.ownership-a.cases' +import './passkeys.ownership-b.cases' +import './passkeys.multiple.cases' +import './passkeys.rewrap.cases' +import './passkeys.storage.cases' +import './passkeys.support.cases' +import './passkeys.version.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index a213497..e37270f 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -30,22 +30,21 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' import {withStorageLock} from './storageLock' import type {PasskeySlot} from './storage/passkeySlots' import { PASSKEY_SLOTS_STORAGE_KEY, + PASSKEY_SLOT_VERSION, readPasskeySlots, - writePasskeySlots + 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' +export {migrateLegacyPasskeySlots} from './passkeyOwnership' +export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap' // 32 bytes, fixed - the authenticator requires exactly 32 const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) @@ -62,9 +61,7 @@ export type CeremonyCredential = { // the slice of navigator.credentials the ceremonies need export type PasskeyCredentials = { - create( - options?: CredentialCreationOptions - ): Promise + create(options?: CredentialCreationOptions): Promise get(options?: CredentialRequestOptions): Promise } @@ -76,13 +73,11 @@ export type PasskeySupportProbe = { // 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 asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => { + if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) { + return null + } + return credential } const defaultCredentials = (): PasskeyCredentials => { @@ -91,8 +86,8 @@ const defaultCredentials = (): PasskeyCredentials => { } const container = navigator.credentials return { - create: options => container.create(options).then(asCeremonyCredential), - get: options => container.get(options).then(asCeremonyCredential) + create: (options) => container.create(options).then(asCeremonyCredential), + get: (options) => container.get(options).then(asCeremonyCredential), } } @@ -101,14 +96,8 @@ const defaultCredentials = (): PasskeyCredentials => { // 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) +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) { @@ -135,25 +124,21 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => { // rotation export const getPasskeyPrfOutput = async ( credentialId: string, - options: {credentials?: PasskeyCredentials} = {} + 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))} - ], + allowCredentials: [{type: 'public-key', id: new Uint8Array(hexToBytes(credentialId))}], userVerification: 'required', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + 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.' - ) + throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } return prfOutput } @@ -172,8 +157,12 @@ export type RegisterPasskeyOptions = { // those get a follow-up get() against the fresh credential. export const registerPasskey = async ( linkingKey: Uint8Array, - options: RegisterPasskeyOptions = {} + options: RegisterPasskeyOptions = {}, ): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Passkey registration requires the proven saved wallet owner.') + } const credentials = options.credentials ?? defaultCredentials() const credential = await credentials.create({ publicKey: { @@ -184,29 +173,27 @@ export const registerPasskey = async ( // discoverable-credential login is used id: crypto.getRandomValues(new Uint8Array(16)), name: 'sattle wallet', - displayName: 'sattle wallet' + displayName: 'sattle wallet', }, pubKeyCredParams: [ {type: 'public-key', alg: -7}, // ES256 - {type: 'public-key', alg: -257} // RS256 + {type: 'public-key', alg: -257}, // RS256 ], authenticatorSelection: { authenticatorAttachment: options.authenticatorAttachment ?? 'platform', residentKey: 'preferred', - userVerification: 'required' + userVerification: 'required', }, attestation: 'none', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + 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.' - ) + throw new Error('This authenticator does not support the WebAuthn PRF extension.') } prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) } @@ -215,14 +202,14 @@ export const registerPasskey = async ( credentialId, ...wrap, createdAt: Date.now(), - ...(options.name !== undefined ? {name: options.name} : {}) + ...(options.name !== undefined ? {name: options.name} : {}), + ownerId, + version: PASSKEY_SLOT_VERSION, } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { - const slots = readPasskeySlots().filter( - s => s.credentialId !== credentialId - ) + const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) slots.push(slot) - writePasskeySlots(slots) + writePasskeySlots(ownerId, slots) }) return slot } @@ -231,85 +218,88 @@ export const registerPasskey = async ( // 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} = {} + options: {credentials?: PasskeyCredentials} = {}, ): Promise => { + const ownerId = savedKeyOwnerId() const slots = readPasskeySlots() - if (slots.length === 0) { + if (ownerId === null || 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 => ({ + allowCredentials: slots.map((slot) => ({ type: 'public-key', - id: new Uint8Array(hexToBytes(slot.credentialId)) + id: new Uint8Array(hexToBytes(slot.credentialId)), })), userVerification: 'required', - extensions: {prf: {eval: {first: PASSKEY_PRF_SALT}}} - } + 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) + 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.' - ) + throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } - return unwrapLinkingKeyWithPrf(prfOutput, slot) + const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot) + if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('This passkey belongs to a different wallet.') + } + return linkingKey } // 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 => { +export const removePasskey = async (credentialId: string): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null) return false let removed = false await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { const slots = readPasskeySlots() - const kept = slots.filter(s => s.credentialId !== credentialId) + const kept = slots.filter((s) => s.credentialId !== credentialId) removed = kept.length !== slots.length - if (removed) writePasskeySlots(kept) + if (removed) writePasskeySlots(ownerId, 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 +// Refreshes every current-owner slot around the same proven key material. +// 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. +// whole refresh before anything is written. // // 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 + prfOutputs: ReadonlyMap, ): Promise => { + const ownerId = savedKeyOwnerId() + if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { + throw new Error('Passkey re-wrap requires the proven saved wallet owner.') + } 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.' - ) + throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.') } rewrapped.push({ ...slot, - ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)) + ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)), }) } - writePasskeySlots(rewrapped) + writePasskeySlots(ownerId, rewrapped) }) }