diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index c59f1de..b1246c6 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -1,7 +1,7 @@ // 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. +// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of canonical +// wallet material under it. No WebAuthn or storage is involved, so malformed +// decrypted material is rejected at this boundary before it can reach runtime. // // 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 @@ -9,10 +9,21 @@ // from the same PRF output. import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' import type {PasskeyWrap} from './storage/passkeySlots' +import {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret' +import type {WalletMaterialV2} from './storage/storedSecret' -const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1' +const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2' + +export class InvalidPasskeyMaterialError extends Error { + override readonly name = 'InvalidPasskeyMaterialError' + + constructor() { + super('Passkey payload is not canonical v2 wallet material.') + } +} export const derivePasskeyWrapKey = async ( prfOutput: Uint8Array, @@ -37,34 +48,43 @@ export const derivePasskeyWrapKey = async ( ) } -export const wrapLinkingKeyWithPrf = async ( +export const wrapWalletMaterialWithPrf = async ( prfOutput: Uint8Array, - linkingKey: Uint8Array, + material: WalletMaterialV2, ): Promise => { const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) + const serialized = serializeWalletMaterial(material) + if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError() const ciphertext = new Uint8Array( - await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, new Uint8Array(linkingKey)), + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, utf8ToBytes(serialized)), ) return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - wrappedKey: bytesToHex(ciphertext), + materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + wrappedMaterial: 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 ( +export const unwrapWalletMaterialWithPrf = async ( prfOutput: Uint8Array, wrap: PasskeyWrap, -): Promise => { +): 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)), + new Uint8Array(hexToBytes(wrap.wrappedMaterial)), ) - return new Uint8Array(plaintext) + const serialized = new TextDecoder().decode(plaintext) + if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) { + throw new InvalidPasskeyMaterialError() + } + const material = parseWalletMaterial(serialized) + if (material === null) throw new InvalidPasskeyMaterialError() + return material } diff --git a/src/lnurlcash/passkeys.crypto.cases.ts b/src/lnurlcash/passkeys.crypto.cases.ts index 5cbd799..f4ab10f 100644 --- a/src/lnurlcash/passkeys.crypto.cases.ts +++ b/src/lnurlcash/passkeys.crypto.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, + deriveWalletMaterial, deriveBearerAesKey, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,32 +142,33 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('pure wrap crypto', () => { it('round-trips a linking key through a PRF-derived wrap', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap) - expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap) + expect(unwrapped).toEqual(MATERIAL) }) it('rejects unwrap with a different PRF output', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) - await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() }) it('rejects unwrap with a tampered HKDF salt', async () => { - const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}), + unwrapWalletMaterialWithPrf(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'}` + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}` await expect( - unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}), + unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, wrappedMaterial: flipped}), ).rejects.toThrow() }) diff --git a/src/lnurlcash/passkeys.material.cases.ts b/src/lnurlcash/passkeys.material.cases.ts new file mode 100644 index 0000000..2d3a174 --- /dev/null +++ b/src/lnurlcash/passkeys.material.cases.ts @@ -0,0 +1,272 @@ +// V2 passkey cases exercise the material boundary without exposing private +// material in assertions or diagnostics. The fake authenticator models only +// WebAuthn credential selection and deterministic PRF evaluation. + +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import { + deriveWalletMaterial, + saveWalletMaterial, + serializeWalletMaterial, +} from './keys' +import { + derivePasskeyWrapKey, + getPasskeyPrfOutput, + readPasskeySlots, + registerPasskey, + rewrapAllSlots, + unlockWalletMaterialWithPasskey, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, +} from './passkeys' +import type {CeremonyCredential, PasskeyCredentials, PasskeyWrap} from './passkeys' +import {stubLocalStorage} from './test-utils' + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' +const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)} +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) +const PRF_OUTPUT = new Uint8Array(32).fill(3) +const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const SLOT_STORAGE_KEY = 'sattle_passkey_slots' + +const toBytes = (source: BufferSource): Uint8Array => + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + +class FakeAuthenticator implements PasskeyCredentials { + private readonly credentialId = new Uint8Array(16).fill(5) + private secret = new Uint8Array(32).fill(6) + onGet?: () => Promise + cancelCreate = false + + rotateSecret = (): void => { + this.secret = new Uint8Array(32).fill(7) + } + + create = async (options?: CredentialCreationOptions): Promise => { + if (this.cancelCreate) return null + return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + } + + get = async (options?: CredentialRequestOptions): Promise => { + await this.onGet?.() + const allowed = options?.publicKey?.allowCredentials ?? [] + const selected = allowed.some( + (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), + ) + return selected + ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + : null + } + + private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ + type: 'public-key', + rawId: this.credentialId, + getClientExtensionResults: () => ({ + prf: salt + ? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} + : {}, + }), + }) +} + +const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '' +const alterLastByte = (hex: string): string => + `${hex.slice(0, -2)}${hex.endsWith('00') ? '01' : '00'}` + +const encryptPayload = async ( + prfOutput: Uint8Array, + payload: string, + materialHash: string, +): Promise => { + const hkdfSalt = new Uint8Array(16).fill(8) + const iv = new Uint8Array(12).fill(9) + const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) + const ciphertext = await crypto.subtle.encrypt( + {name: 'AES-GCM', iv}, + wrapKey, + utf8ToBytes(payload), + ) + return { + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + materialHash, + wrappedMaterial: bytesToHex(new Uint8Array(ciphertext)), + } +} + +beforeEach(async () => { + stubLocalStorage() + await saveWalletMaterial(MATERIAL) +}) + +describe('v2 passkey material', () => { + it('round-trips canonical material bytes through the PRF wrap', async () => { + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + + await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)).resolves.toEqual(MATERIAL) + }) + + it('rejects wrong PRF and altered ciphertext without changing the wrap', async () => { + const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) + const before = JSON.stringify(wrap) + const altered = { + ...wrap, + wrappedMaterial: alterLastByte(wrap.wrappedMaterial), + } + + await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() + await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, altered)).rejects.toThrow() + expect(JSON.stringify(wrap)).toBe(before) + }) + + it.each([ + ['legacy key-only payload', MATERIAL.linkingKeyHex], + [ + 'malformed root', + JSON.stringify({...MATERIAL, cashRootHex: `${MATERIAL.cashRootHex.slice(0, -2)}00`}), + ], + ])('rejects authenticated %s without changing slots or activation', async (_name, payload) => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + const materialHash = bytesToHex(sha256(utf8ToBytes(serializeWalletMaterial(MATERIAL)))) + const wrap = await encryptPayload(prfOutput, payload, materialHash) + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects a wrong runtime PRF without changing slots or activation', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + let activated = false + authenticator.rotateSecret() + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects altered stored ciphertext without rewriting it or activating', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const altered = { + ...slot, + wrappedMaterial: alterLastByte(slot.wrappedMaterial), + } + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([altered])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow() + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects same-owner altered root at registration without a ceremony or slot write', async () => { + const authenticator = new FakeAuthenticator() + const alteredRoot = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} + + await expect(registerPasskey(alteredRoot, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('registers and unlocks complete material without plaintext slot leakage', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + + const unlocked = await unlockWalletMaterialWithPasskey({credentials: authenticator}) + + expect(unlocked).toEqual(MATERIAL) + expect(slot.ownerId).not.toContain(MATERIAL.linkingKeyHex) + expect(rawSlots()).not.toContain(MATERIAL.linkingKeyHex) + expect(rawSlots()).not.toContain(MATERIAL.cashRootHex) + }) + + it('rejects authenticated foreign-owner material without changing slots or activation', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...foreignWrap}])) + const before = rawSlots() + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow('different wallet') + expect(activated).toBe(false) + expect(rawSlots()).toBe(before) + }) + + it('rejects a slot changed during its ceremony without changing storage or activation', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + let changed = '' + authenticator.onGet = async () => { + const slot = readPasskeySlots()[0] + if (slot === undefined) throw new Error('expected registered slot') + localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, createdAt: slot.createdAt + 1}])) + changed = rawSlots() + } + let activated = false + + await expect( + unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => { + activated = true + }), + ).rejects.toThrow('changed') + expect(activated).toBe(false) + expect(rawSlots()).toBe(changed) + }) + + it('rewraps every slot around byte-identical material or writes nothing', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + + await expect(rewrapAllSlots(MATERIAL, new Map())).rejects.toThrow('partial re-wrap') + expect(rawSlots()).toBe(before) + + const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]])) + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual( + MATERIAL, + ) + }) + + it('does not create a slot when registration is cancelled', async () => { + const authenticator = new FakeAuthenticator() + authenticator.cancelCreate = true + + await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow('cancelled') + expect(rawSlots()).toBe('') + }) +}) diff --git a/src/lnurlcash/passkeys.multiple.cases.ts b/src/lnurlcash/passkeys.multiple.cases.ts index f948226..fff5196 100644 --- a/src/lnurlcash/passkeys.multiple.cases.ts +++ b/src/lnurlcash/passkeys.multiple.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('multiple passkeys', () => { @@ -151,7 +160,7 @@ describe('multiple passkeys', () => { 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(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial) expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) diff --git a/src/lnurlcash/passkeys.ownership-a.cases.ts b/src/lnurlcash/passkeys.ownership-a.cases.ts index e9c1ac5..05b6fb1 100644 --- a/src/lnurlcash/passkeys.ownership-a.cases.ts +++ b/src/lnurlcash/passkeys.ownership-a.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot ownership', () => { @@ -167,7 +176,8 @@ describe('slot ownership', () => { credentialId: '33'.repeat(16), hkdfSalt: current.hkdfSalt, iv: current.iv, - wrappedKey: current.wrappedKey, + materialHash: current.materialHash, + wrappedMaterial: current.wrappedMaterial, createdAt: current.createdAt, } writeRawSlots([foreign, malformed, unowned]) @@ -210,6 +220,7 @@ describe('slot ownership', () => { // When a different wallet is installed with its canonical owner await saveLinkingKey(OTHER_LINKING_KEY) + await saveWalletMaterial({...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)}) // Then the residue stays unowned and unavailable to the new wallet expect(readPasskeySlots()).toEqual([]) diff --git a/src/lnurlcash/passkeys.ownership-b.cases.ts b/src/lnurlcash/passkeys.ownership-b.cases.ts index 6df9811..882c4cd 100644 --- a/src/lnurlcash/passkeys.ownership-b.cases.ts +++ b/src/lnurlcash/passkeys.ownership-b.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,13 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} +const OTHER_MATERIAL = {...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot ownership (continued)', () => { @@ -145,7 +155,7 @@ describe('slot ownership (continued)', () => { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: auth, }) - const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY) + const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) writeRawSlots([{...slot, ...foreignWrap}]) // When the authenticator successfully unwraps that foreign key @@ -164,6 +174,7 @@ describe('slot ownership (continued)', () => { get: async (options) => { const credential = await auth.get(options) await saveLinkingKey(OTHER_LINKING_KEY) + await saveWalletMaterial(OTHER_MATERIAL) return credential }, } @@ -220,7 +231,7 @@ describe('slot ownership (continued)', () => { }) // When the current wallet rewraps its slots - await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]])) + await rewrapAllSlots(MATERIAL, 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.registration.cases.ts b/src/lnurlcash/passkeys.registration.cases.ts index 8e7065d..ec6cd33 100644 --- a/src/lnurlcash/passkeys.registration.cases.ts +++ b/src/lnurlcash/passkeys.registration.cases.ts @@ -21,18 +21,21 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, - decryptSavedLinkingKey, + decryptSavedWalletMaterial, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, + walletMaterialLinkingKey, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +43,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('registration and unlock', () => { @@ -160,18 +170,18 @@ describe('registration and unlock', () => { }) it('yields the same key material unlock(password) yields', async () => { - const linkingKey = crypto.getRandomValues(new Uint8Array(32)) - await saveLinkingKey(linkingKey, 'correct horse') + await saveWalletMaterial(MATERIAL, 'correct horse') const auth = new FakeAuthenticator() - await registerPasskey(linkingKey, {credentials: auth}) + await registerPasskey(MATERIAL, {credentials: auth}) - const viaPassword = await decryptSavedLinkingKey('correct horse') + const viaPassword = await decryptSavedWalletMaterial('correct horse') const viaPasskey = await unlockWithPasskey({credentials: auth}) - expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword)) + const passwordLinkingKey = walletMaterialLinkingKey(viaPassword) + expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey)) // and the practical consequence: a bearer record encrypted after a // password unlock decrypts after a passkey unlock - const passwordAes = await deriveBearerAesKey(viaPassword) + const passwordAes = await deriveBearerAesKey(passwordLinkingKey) const record = await encryptRecord(passwordAes, {note: 'still readable'}) const passkeyAes = await deriveBearerAesKey(viaPasskey) await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ diff --git a/src/lnurlcash/passkeys.rewrap.cases.ts b/src/lnurlcash/passkeys.rewrap.cases.ts index db5e833..d192f10 100644 --- a/src/lnurlcash/passkeys.rewrap.cases.ts +++ b/src/lnurlcash/passkeys.rewrap.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('rewrap for the current owner', () => { @@ -156,7 +165,7 @@ describe('rewrap for the current owner', () => { }), ], ]) - await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap') + await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap') expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) // full coverage refreshes both wraps around the same proven owner key @@ -174,10 +183,10 @@ describe('rewrap for the current owner', () => { }), ], ]) - await rewrapAllSlots(LINKING_KEY, fresh) + await rewrapAllSlots(MATERIAL, fresh) expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY)) expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) - expect(readPasskeySlots()[0]?.wrappedKey).not.toBe(laptopSlot.wrappedKey) + expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial) // credential ids and labels survive the re-wrap expect( readPasskeySlots() diff --git a/src/lnurlcash/passkeys.storage.cases.ts b/src/lnurlcash/passkeys.storage.cases.ts index 3d6f3ca..6eb181b 100644 --- a/src/lnurlcash/passkeys.storage.cases.ts +++ b/src/lnurlcash/passkeys.storage.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('slot storage hygiene', () => { diff --git a/src/lnurlcash/passkeys.support.cases.ts b/src/lnurlcash/passkeys.support.cases.ts index 4ee6a30..5d0e4d8 100644 --- a/src/lnurlcash/passkeys.support.cases.ts +++ b/src/lnurlcash/passkeys.support.cases.ts @@ -21,18 +21,20 @@ import { removePasskey, rewrapAllSlots, unlockWithPasskey, - unwrapLinkingKeyWithPrf, - wrapLinkingKeyWithPrf, + unwrapWalletMaterialWithPrf, + wrapWalletMaterialWithPrf, } from './passkeys' import { decryptRecord, decryptSavedLinkingKey, deriveBearerAesKey, + deriveWalletMaterial, ensureSavedKeyOwner, encryptRecord, linkingPubKeyHex, savedKeyOwnerId, saveLinkingKey, + saveWalletMaterial, } from './keys' import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const PRF_OUTPUT = new Uint8Array(32).fill(3) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) +const MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const toBytes = (source: BufferSource): Uint8Array => source instanceof ArrayBuffer @@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => { beforeEach(async () => { stubLocalStorage() await saveLinkingKey(LINKING_KEY) + await saveWalletMaterial(MATERIAL) }) describe('passkeySupported', () => { diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index ab091b1..5e81125 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,4 +1,5 @@ import './passkeys.crypto.cases' +import './passkeys.material.cases' import './passkeys.registration.cases' import './passkeys.ownership-a.cases' import './passkeys.ownership-b.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index e37270f..9242d30 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -1,16 +1,10 @@ -// 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). +// Passkey (WebAuthn PRF) unlock is an alternative wrap of the complete +// canonical wallet material protected by the password path. Slot ownership +// remains derived only from the linking key inside that material. // // 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. +// persists one authenticated material envelope. The password wrap and each +// passkey slot are independent wraps of those same canonical bytes. // // 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) @@ -30,21 +24,31 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' -import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys' import {withStorageLock} from './storageLock' import type {PasskeySlot} from './storage/passkeySlots' import { PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOT_VERSION, + passkeySlotsEqual, readPasskeySlots, + requireCurrentPasskeyMaterialOwner, writePasskeySlots, } from './storage/passkeySlots' -import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap' +import type {WalletMaterialV2} from './storage/storedSecret' +import {walletMaterialOwnerId} from './storage/storedSecret' +import {savedWalletMaterialOwnerId} from './walletMaterialStorage' +import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export {migrateLegacyPasskeySlots} from './passkeyOwnership' -export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap' +export { + derivePasskeyWrapKey, + InvalidPasskeyMaterialError, + wrapWalletMaterialWithPrf, + unwrapWalletMaterialWithPrf, +} from './passkeyWrap' // 32 bytes, fixed - the authenticator requires exactly 32 const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) @@ -120,8 +124,7 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | 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 +// PRF output - the building block for complete-material re-wrap ceremonies export const getPasskeyPrfOutput = async ( credentialId: string, options: {credentials?: PasskeyCredentials} = {}, @@ -149,20 +152,28 @@ export type RegisterPasskeyOptions = { 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 +// Registers a new passkey and persists a slot wrapping the complete material. +// The Uint8Array input exists only until Task 5 updates the UI caller; it can +// select already-saved plaintext v2 material but can never create a key-only +// slot. 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, + material: WalletMaterialV2 | Uint8Array, options: RegisterPasskeyOptions = {}, ): Promise => { - const ownerId = savedKeyOwnerId() - if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { - throw new Error('Passkey registration requires the proven saved wallet owner.') + let walletMaterial: WalletMaterialV2 + if (material instanceof Uint8Array) { + const savedMaterial = getPlainWalletMaterial() + if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) { + throw new Error('Passkey registration requires complete v2 wallet material.') + } + walletMaterial = savedMaterial + } else { + walletMaterial = material } + const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial) const credentials = options.credentials ?? defaultCredentials() const credential = await credentials.create({ publicKey: { @@ -197,7 +208,7 @@ export const registerPasskey = async ( } prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) } - const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey) + const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial) const slot: PasskeySlot = { credentialId, ...wrap, @@ -214,13 +225,13 @@ export const registerPasskey = async ( 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 ( +// Unlocks complete material via one get() ceremony offering every current +// slot. The selected slot is re-read after the ceremony before any plaintext +// reaches the caller, rejecting owner or storage changes during user presence. +export const unlockWalletMaterialWithPasskey = async ( options: {credentials?: PasskeyCredentials} = {}, -): Promise => { - const ownerId = savedKeyOwnerId() +): Promise => { + const ownerId = savedWalletMaterialOwnerId() const slots = readPasskeySlots() if (ownerId === null || slots.length === 0) { throw new Error('No passkeys registered on this device.') @@ -247,18 +258,32 @@ export const unlockWithPasskey = async ( if (!prfOutput) { throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') } - const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot) - if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) { + if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } - return linkingKey + const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId) + if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) { + throw new Error('The passkey slot changed during the unlock ceremony.') + } + const material = await unwrapWalletMaterialWithPrf(prfOutput, currentSlot) + if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) { + throw new Error('This passkey belongs to a different wallet.') + } + return material } +// Task 5 replaces this temporary projection when lifecycle activation accepts +// complete wallet material. Slots already contain only canonical v2 material. +export const unlockWithPasskey = async ( + options: {credentials?: PasskeyCredentials} = {}, +): Promise => + walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options)) + // 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 => { - const ownerId = savedKeyOwnerId() + const ownerId = savedWalletMaterialOwnerId() if (ownerId === null) return false let removed = false await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { @@ -270,23 +295,19 @@ export const removePasskey = async (credentialId: string): Promise => { return removed } -// Refreshes every current-owner slot around the same proven key material. +// Refreshes every current-owner slot around the same proven wallet 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 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. +// A password change does not need this because password and passkey slots are +// independent wraps of the same canonical material. export const rewrapAllSlots = async ( - linkingKey: Uint8Array, + material: WalletMaterialV2, prfOutputs: ReadonlyMap, ): Promise => { - const ownerId = savedKeyOwnerId() - if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { - throw new Error('Passkey re-wrap requires the proven saved wallet owner.') - } + const ownerId = requireCurrentPasskeyMaterialOwner(material) await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { const slots = readPasskeySlots() const rewrapped: PasskeySlot[] = [] @@ -297,7 +318,7 @@ export const rewrapAllSlots = async ( } rewrapped.push({ ...slot, - ...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)), + ...(await wrapWalletMaterialWithPrf(prfOutput, material)), }) } writePasskeySlots(ownerId, rewrapped) diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts index afd8b0e..f8bf2fc 100644 --- a/src/lnurlcash/passkeys.version.cases.ts +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -1,7 +1,14 @@ import {beforeEach, describe, expect, it} from 'vitest' import {bytesToHex} from '@noble/hashes/utils.js' -import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys' +import { + deriveWalletMaterial, + ensureSavedKeyOwner, + getPlainLinkingKey, + linkingPubKeyHex, + savedKeyOwnerId, + saveWalletMaterial, +} from './keys' import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' import type {PasskeySlot} from './passkeys' import {parseJsonObjectArray, stubLocalStorage} from './test-utils' @@ -10,12 +17,19 @@ 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 MATERIAL = { + ...deriveWalletMaterial( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ), + linkingKeyHex: bytesToHex(LINKING_KEY), +} const SLOT_BASE = { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), + materialHash: '44'.repeat(32), + wrappedMaterial: '55'.repeat(128), createdAt: 1, } as const @@ -24,17 +38,18 @@ const writeRawSlots = (slots: readonly Record[]): void => { } describe('passkey-slot schema version', () => { - beforeEach(() => { + beforeEach(async () => { stubLocalStorage() + await saveWalletMaterial(MATERIAL) }) - it('reads a current version 1 slot for the exact saved owner', () => { + it('reads a current version 2 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} + const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2} writeRawSlots([slot]) // When current-owner slots are read @@ -61,12 +76,12 @@ describe('passkey-slot schema version', () => { // 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}, + {...SLOT_BASE, ownerId, version: 2}, + {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2}, ]) }) - it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => { + it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => { // Given an otherwise valid slot carrying unrecognized metadata const ownerId = linkingPubKeyHex(LINKING_KEY) localStorage.setItem( @@ -87,7 +102,7 @@ describe('passkey-slot schema version', () => { KEY_STORAGE, JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), ) - writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}]) + writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}]) // When the current wallet reads passkeys // Then the foreign credential is unavailable diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 3b43cef..258b041 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -1,19 +1,22 @@ -// Passkey-slot persistence: every new slot is bound to the canonical owner -// of the saved linking key. localStorage remains hostile input, so owner +// Passkey-slot persistence: every v2 slot is bound to the canonical owner +// of the saved wallet material. 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 type {WalletMaterialV2} from './storedSecret' +import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret' +import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage' import {isWalletOwnerId} from './walletOwner' export type PasskeyWrap = { readonly hkdfSalt: string readonly iv: string - readonly wrappedKey: string + readonly materialHash: string + readonly wrappedMaterial: string } -export const PASSKEY_SLOT_VERSION = 1 as const +export const PASSKEY_SLOT_VERSION = 2 as const export type PasskeySlot = PasskeyWrap & { readonly credentialId: string @@ -23,6 +26,30 @@ export type PasskeySlot = PasskeyWrap & { readonly version: typeof PASSKEY_SLOT_VERSION } +export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolean => + left.credentialId === right.credentialId && + left.hkdfSalt === right.hkdfSalt && + left.iv === right.iv && + left.materialHash === right.materialHash && + left.wrappedMaterial === right.wrappedMaterial && + left.createdAt === right.createdAt && + left.name === right.name && + left.ownerId === right.ownerId && + left.version === right.version + +export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => { + const ownerId = savedWalletMaterialOwnerId() + const plaintext = getPlainWalletMaterial() + if ( + ownerId === null || + walletMaterialOwnerId(material) !== ownerId || + (plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material)) + ) { + throw new Error('Passkey operation requires the proven saved wallet material.') + } + return ownerId +} + type StoredPasskeySlot = PasskeyWrap & { readonly credentialId: string readonly createdAt: number @@ -43,7 +70,8 @@ const SLOT_KEYS: readonly string[] = [ 'credentialId', 'hkdfSalt', 'iv', - 'wrappedKey', + 'materialHash', + 'wrappedMaterial', 'createdAt', 'name', 'ownerId', @@ -61,10 +89,12 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { /^[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.materialHash === 'string' && + /^[0-9a-f]{64}$/.test(slot.materialHash) && + typeof slot.wrappedMaterial === 'string' && + slot.wrappedMaterial.length > 0 && + slot.wrappedMaterial.length % 2 === 0 && + /^[0-9a-f]+$/.test(slot.wrappedMaterial) && typeof slot.createdAt === 'number' && (slot.name === undefined || typeof slot.name === 'string') && Object.keys(slot).every((key) => SLOT_KEYS.includes(key)) @@ -73,7 +103,8 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => { credentialId: slot.credentialId, hkdfSalt: slot.hkdfSalt, iv: slot.iv, - wrappedKey: slot.wrappedKey, + materialHash: slot.materialHash, + wrappedMaterial: slot.wrappedMaterial, createdAt: slot.createdAt, ...(slot.name !== undefined ? {name: slot.name} : {}), } @@ -124,7 +155,8 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | credentialId: record.credentialId, hkdfSalt: record.hkdfSalt, iv: record.iv, - wrappedKey: record.wrappedKey, + materialHash: record.materialHash, + wrappedMaterial: record.wrappedMaterial, createdAt: record.createdAt, ...(record.name !== undefined ? {name: record.name} : {}), ownerId, @@ -133,7 +165,7 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot | } export const readPasskeySlots = (): PasskeySlot[] => { - const ownerId = savedKeyOwnerId() + const ownerId = savedWalletMaterialOwnerId() if (ownerId === null) return [] return readStoredPasskeySlots() .map((slot) => asOwnedSlot(slot, ownerId)) @@ -143,7 +175,7 @@ export const readPasskeySlots = (): PasskeySlot[] => { export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Passkey slots require the proven saved wallet owner.') } if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { @@ -156,7 +188,7 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void = } export const adoptLegacyPasskeySlots = (ownerId: string): number => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Legacy passkey migration requires a proven owner.') } const stored = readStoredPasskeySlots() @@ -183,7 +215,7 @@ const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => { } export const clearPasskeySlotsForOwner = (ownerId: string): void => { - if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) { + if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Passkey teardown requires the proven saved wallet owner.') } persistStoredPasskeySlots(