From 9374a87d0d64cfd70f5bf938cdf4582331cea87a Mon Sep 17 00:00:00 2001 From: protom Date: Fri, 4 Sep 2026 21:27:14 +0200 Subject: [PATCH] fix(passkeys): bind slots to wallet material --- .../keys.material-commitment.cases.ts | 202 ++++++++++++++++++ src/lnurlcash/keys.material-storage.cases.ts | 16 +- src/lnurlcash/keys.ts | 1 + src/lnurlcash/keys.version.cases.ts | 1 + src/lnurlcash/passkeyRewrap.ts | 66 ++++++ src/lnurlcash/passkeyWrap.ts | 6 +- src/lnurlcash/passkeys.commitment.cases.ts | 129 +++++++++++ src/lnurlcash/passkeys.material.cases.ts | 3 +- src/lnurlcash/passkeys.test.ts | 1 + src/lnurlcash/passkeys.ts | 50 ++--- src/lnurlcash/passkeys.version.cases.ts | 3 +- src/lnurlcash/storage/passkeySlots.ts | 34 ++- src/lnurlcash/storage/storedSecret.ts | 131 ++++++------ src/lnurlcash/storage/walletMaterial.ts | 77 +++++++ src/lnurlcash/walletMaterialStorage.ts | 27 ++- 15 files changed, 631 insertions(+), 116 deletions(-) create mode 100644 src/lnurlcash/keys.material-commitment.cases.ts create mode 100644 src/lnurlcash/passkeyRewrap.ts create mode 100644 src/lnurlcash/passkeys.commitment.cases.ts create mode 100644 src/lnurlcash/storage/walletMaterial.ts diff --git a/src/lnurlcash/keys.material-commitment.cases.ts b/src/lnurlcash/keys.material-commitment.cases.ts new file mode 100644 index 0000000..0fac6d5 --- /dev/null +++ b/src/lnurlcash/keys.material-commitment.cases.ts @@ -0,0 +1,202 @@ +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import { + decryptSavedWalletMaterial, + deriveWalletMaterial, + encryptSecretParts, + ensureSavedWalletMaterialOwner, + getPlainWalletMaterial, + isValidStoredWalletMaterial, + linkingPubKeyHex, + restoreWalletMaterialStored, + saveWalletMaterial, + serializeWalletMaterial, + savedWalletMaterialOwnerId, + walletMaterialLinkingKey, + WalletMaterialProofMismatchError, +} from './keys' +import {walletMaterialOwnerId} from './storage/storedSecret' +import {parseJsonObject, 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_STORAGE_KEY = 'sattle_wallet_material_v2' +const PASSWORD = 'correct horse battery staple' + +const materialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + +const readRawMaterialRecord = (): Record => { + const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) + if (raw === null) throw new Error('expected a saved wallet-material record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +describe('baseline: wallet material identity', () => { + it('derives identity only from the linking key when the cash root changes', () => { + const material = deriveWalletMaterial(MNEMONIC) + const other = deriveWalletMaterial(OTHER_MNEMONIC) + const alteredRoot = {...material, cashRootHex: other.cashRootHex} + + expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material)) + expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material)) + }) +}) + +describe('saved wallet material commitment', () => { + it('stores the canonical material commitment on plaintext and encrypted saves', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const expectedHash = materialHash(serializeWalletMaterial(material)) + + await saveWalletMaterial(material) + const plaintext = readRawMaterialRecord() + await saveWalletMaterial(material, PASSWORD) + const encrypted = readRawMaterialRecord() + + expect(plaintext.materialHash).toBe(expectedHash) + expect(encrypted.materialHash).toBe(expectedHash) + }) + + it('rejects authenticated encrypted material when its commitment differs', async () => { + const material = deriveWalletMaterial(MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}), + ) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( + WalletMaterialProofMismatchError, + ) + }) + + it('rejects plaintext material when its commitment differs', async () => { + const material = deriveWalletMaterial(MNEMONIC) + await saveWalletMaterial(material) + const before = JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}) + localStorage.setItem(MATERIAL_STORAGE_KEY, before) + + expect(getPlainWalletMaterial()).toBeNull() + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it('refuses to stamp same-owner material with an altered cash root', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const other = deriveWalletMaterial(OTHER_MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + + expect(() => + ensureSavedWalletMaterialOwner({...material, cashRootHex: other.cashRootHex}), + ).toThrow(WalletMaterialProofMismatchError) + }) + + it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const record = { + enc: false, + value: serialized, + materialHash: materialHash(serialized), + ownerId: walletMaterialOwnerId(material), + version: 2, + } + + expect(isValidStoredWalletMaterial(record)).toBe(true) + expect(isValidStoredWalletMaterial({...record, materialHash: record.materialHash.toUpperCase()})).toBe( + false, + ) + expect( + isValidStoredWalletMaterial({ + enc: record.enc, + value: record.value, + ownerId: record.ownerId, + version: record.version, + }), + ).toBe(false) + }) + + it('rejects malformed authenticated material after its correct commitment matches', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const malformed = JSON.stringify({...material, cashRootHex: '00'.repeat(64)}) + const parts = await encryptSecretParts(malformed, PASSWORD) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + materialHash: materialHash(malformed), + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }), + ) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow( + 'valid v2 wallet material', + ) + }) + + it('preserves a verified restored commitment while discarding its owner claim', () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + + restoreWalletMaterialStored({ + enc: false, + value: serialized, + materialHash: materialHash(serialized), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }) + + expect(readRawMaterialRecord()).toEqual({ + enc: false, + value: serialized, + materialHash: materialHash(serialized), + }) + expect(getPlainWalletMaterial()).toEqual(material) + }) + + it('proves an encrypted restored commitment before owner stamping', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const parts = await encryptSecretParts(serialized, PASSWORD) + restoreWalletMaterialStored({ + enc: true, + ...parts, + materialHash: materialHash(serialized), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }) + + expect(savedWalletMaterialOwnerId()).toBeNull() + const proven = await decryptSavedWalletMaterial(PASSWORD) + ensureSavedWalletMaterialOwner(proven) + + expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material)) + }) + + it('rejects an unproven restored hash that contradicts encrypted material', async () => { + const material = deriveWalletMaterial(MNEMONIC) + const serialized = serializeWalletMaterial(material) + const parts = await encryptSecretParts(serialized, PASSWORD) + restoreWalletMaterialStored({ + enc: true, + ...parts, + materialHash: '00'.repeat(32), + ownerId: walletMaterialOwnerId(material), + version: 2, + }) + + await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( + WalletMaterialProofMismatchError, + ) + expect(savedWalletMaterialOwnerId()).toBeNull() + }) +}) diff --git a/src/lnurlcash/keys.material-storage.cases.ts b/src/lnurlcash/keys.material-storage.cases.ts index d748080..8f7dd5e 100644 --- a/src/lnurlcash/keys.material-storage.cases.ts +++ b/src/lnurlcash/keys.material-storage.cases.ts @@ -1,3 +1,5 @@ +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import {describe, expect, it} from 'vitest' import { @@ -27,6 +29,9 @@ const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9 const CASH_ROOT_HEX = 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' +const materialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + const readRawMaterialRecord = (): Record => { const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) if (raw === null) throw new Error('expected a saved wallet-material record') @@ -50,6 +55,7 @@ describe('WalletMaterialV2 persistence', () => { expect(readRawMaterialRecord()).toEqual({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }) @@ -87,6 +93,7 @@ describe('WalletMaterialV2 persistence', () => { restoreWalletMaterialStored({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: foreignOwner, version: 2, }) @@ -96,7 +103,11 @@ describe('WalletMaterialV2 persistence', () => { ensureSavedWalletMaterialOwner(unlocked) // Then the claim was absent before proof and only the derived owner was stamped - expect(beforeProof).toEqual({enc: false, value: serializeWalletMaterial(material)}) + expect(beforeProof).toEqual({ + enc: false, + value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), + }) expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) }) @@ -109,6 +120,7 @@ describe('WalletMaterialV2 persistence', () => { JSON.stringify({ enc: false, value: serializeWalletMaterial(material), + materialHash: materialHash(serializeWalletMaterial(material)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), version: 2, }), @@ -160,6 +172,7 @@ describe('WalletMaterialV2 persistence', () => { const record = { enc: false, value, + materialHash: materialHash(value), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version, } @@ -182,6 +195,7 @@ describe('WalletMaterialV2 persistence', () => { JSON.stringify({ enc: true, ...parts, + materialHash: materialHash(LINKING_KEY_HEX), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), version: 2, }), diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index a01600c..6518bad 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -25,6 +25,7 @@ export { isValidStoredWalletMaterial, parseWalletMaterial, serializeWalletMaterial, + walletMaterialHash, } from './storage/storedSecret' export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' export {decryptSecretParts, encryptSecretParts} from './passwordWrap' diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts index 0e4362d..d779cc1 100644 --- a/src/lnurlcash/keys.version.cases.ts +++ b/src/lnurlcash/keys.version.cases.ts @@ -13,6 +13,7 @@ import { import {parseJsonObject, stubLocalStorage} from './test-utils' import './keys.material.cases' +import './keys.material-commitment.cases' import './keys.material-storage.cases' const LINKING_KEY = new Uint8Array(32).fill(7) diff --git a/src/lnurlcash/passkeyRewrap.ts b/src/lnurlcash/passkeyRewrap.ts new file mode 100644 index 0000000..2fe01aa --- /dev/null +++ b/src/lnurlcash/passkeyRewrap.ts @@ -0,0 +1,66 @@ +// Rewrap proves every existing slot before replacing any bytes. A supplied PRF +// is therefore both authority for the old wrap and input for its fresh wrap; +// one wrong credential aborts the complete in-memory batch before persistence. + +import type {WalletMaterialV2} from './storage/storedSecret' +import {serializeWalletMaterial, walletMaterialHash} from './storage/storedSecret' +import type {PasskeySlot} from './storage/passkeySlots' +import { + PASSKEY_SLOTS_STORAGE_KEY, + passkeySlotsEqual, + readPasskeySlots, + requireCurrentPasskeyMaterialOwner, + writePasskeySlots, +} from './storage/passkeySlots' +import {withStorageLock} from './storageLock' +import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' + +type ProvenSlot = { + readonly slot: PasskeySlot + readonly prfOutput: Uint8Array +} + +export const rewrapAllSlots = async ( + material: WalletMaterialV2, + prfOutputs: ReadonlyMap, +): Promise => { + const ownerId = requireCurrentPasskeyMaterialOwner(material) + const materialHash = walletMaterialHash(material) + const serialized = serializeWalletMaterial(material) + await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { + const provenSlots: ProvenSlot[] = readPasskeySlots().map((slot) => { + const prfOutput = prfOutputs.get(slot.credentialId) + if (!prfOutput) { + throw new Error('Missing old PRF output for a passkey slot - refusing a partial re-wrap.') + } + return {slot, prfOutput} + }) + for (const {slot, prfOutput} of provenSlots) { + if (slot.materialHash !== materialHash) { + throw new Error('Passkey slot does not match the saved wallet material.') + } + const current = await unwrapWalletMaterialWithPrf(prfOutput, slot) + if (serializeWalletMaterial(current) !== serialized) { + throw new Error('Passkey slot contains different wallet material.') + } + } + const rewrapped: PasskeySlot[] = [] + for (const {slot, prfOutput} of provenSlots) { + rewrapped.push({...slot, ...(await wrapWalletMaterialWithPrf(prfOutput, material))}) + } + if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) { + throw new Error('Saved wallet material changed during passkey re-wrap.') + } + const currentSlots = readPasskeySlots() + if ( + currentSlots.length !== provenSlots.length || + currentSlots.some((current, index) => { + const original = provenSlots[index]?.slot + return original === undefined || !passkeySlotsEqual(original, current) + }) + ) { + throw new Error('Passkey slots changed during re-wrap.') + } + writePasskeySlots(ownerId, rewrapped) + }) +} diff --git a/src/lnurlcash/passkeyWrap.ts b/src/lnurlcash/passkeyWrap.ts index b1246c6..8bfed74 100644 --- a/src/lnurlcash/passkeyWrap.ts +++ b/src/lnurlcash/passkeyWrap.ts @@ -9,11 +9,11 @@ // 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' +import {serializedWalletMaterialHash, walletMaterialHash} from './storage/walletMaterial' const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2' @@ -63,7 +63,7 @@ export const wrapWalletMaterialWithPrf = async ( return { hkdfSalt: bytesToHex(hkdfSalt), iv: bytesToHex(iv), - materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + materialHash: walletMaterialHash(material), wrappedMaterial: bytesToHex(ciphertext), } } @@ -81,7 +81,7 @@ export const unwrapWalletMaterialWithPrf = async ( new Uint8Array(hexToBytes(wrap.wrappedMaterial)), ) const serialized = new TextDecoder().decode(plaintext) - if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) { + if (serializedWalletMaterialHash(serialized) !== wrap.materialHash) { throw new InvalidPasskeyMaterialError() } const material = parseWalletMaterial(serialized) diff --git a/src/lnurlcash/passkeys.commitment.cases.ts b/src/lnurlcash/passkeys.commitment.cases.ts new file mode 100644 index 0000000..4ec14fe --- /dev/null +++ b/src/lnurlcash/passkeys.commitment.cases.ts @@ -0,0 +1,129 @@ +import {hmac} from '@noble/hashes/hmac.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex} from '@noble/hashes/utils.js' +import {beforeEach, describe, expect, it} from 'vitest' + +import {deriveWalletMaterial, saveWalletMaterial} from './keys' +import { + getPasskeyPrfOutput, + registerPasskey, + rewrapAllSlots, + unlockWalletMaterialWithPasskey, +} from './passkeys' +import type {CeremonyCredential, PasskeyCredentials} 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 ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} +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(12) + private readonly secret = new Uint8Array(32).fill(13) + onCreate?: () => Promise + onGet?: () => Promise + + create = async (options?: CredentialCreationOptions): Promise => { + await this.onCreate?.() + return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) + } + + get = async (options?: CredentialRequestOptions): Promise => { + await this.onGet?.() + const allowed = options?.publicKey?.allowCredentials ?? [] + return allowed.some( + (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), + ) + ? 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) ?? '' + +beforeEach(async () => { + stubLocalStorage() + await saveWalletMaterial(MATERIAL) +}) + +describe('exact wallet material passkey commitment', () => { + it('rejects a wrong old PRF before changing any slot bytes', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const before = rawSlots() + const wrongPrf = new Uint8Array(32).fill(99) + + await expect( + rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])), + ).rejects.toThrow() + expect(rawSlots()).toBe(before) + }) + + it('rejects same-owner altered-root registration against encrypted saved material', async () => { + const authenticator = new FakeAuthenticator() + await saveWalletMaterial(MATERIAL, 'password') + + await expect(registerPasskey(ALTERED_ROOT, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('rejects registration when same-owner saved material changes during the ceremony', async () => { + const authenticator = new FakeAuthenticator() + authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT) + + await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow( + 'saved wallet material', + ) + expect(rawSlots()).toBe('') + }) + + it('rejects unlock after same-owner saved material was replaced', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + await saveWalletMaterial(ALTERED_ROOT, 'password') + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow() + }) + + it('rejects same-owner saved-material replacement during the unlock ceremony', async () => { + const authenticator = new FakeAuthenticator() + await registerPasskey(MATERIAL, {credentials: authenticator}) + authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT) + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow( + 'changed', + ) + }) + + it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => { + const authenticator = new FakeAuthenticator() + const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) + const oldPrf = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) + + await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]])) + + await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual( + MATERIAL, + ) + }) +}) diff --git a/src/lnurlcash/passkeys.material.cases.ts b/src/lnurlcash/passkeys.material.cases.ts index 2d3a174..c5abd9a 100644 --- a/src/lnurlcash/passkeys.material.cases.ts +++ b/src/lnurlcash/passkeys.material.cases.ts @@ -10,7 +10,6 @@ import {beforeEach, describe, expect, it} from 'vitest' import { deriveWalletMaterial, saveWalletMaterial, - serializeWalletMaterial, } from './keys' import { derivePasskeyWrapKey, @@ -135,7 +134,7 @@ describe('v2 passkey material', () => { 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 materialHash = bytesToHex(sha256(utf8ToBytes(payload))) const wrap = await encryptPayload(prfOutput, payload, materialHash) localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}])) const before = rawSlots() diff --git a/src/lnurlcash/passkeys.test.ts b/src/lnurlcash/passkeys.test.ts index 5e81125..1c0cf7e 100644 --- a/src/lnurlcash/passkeys.test.ts +++ b/src/lnurlcash/passkeys.test.ts @@ -1,4 +1,5 @@ import './passkeys.crypto.cases' +import './passkeys.commitment.cases' import './passkeys.material.cases' import './passkeys.registration.cases' import './passkeys.ownership-a.cases' diff --git a/src/lnurlcash/passkeys.ts b/src/lnurlcash/passkeys.ts index 9242d30..9b39171 100644 --- a/src/lnurlcash/passkeys.ts +++ b/src/lnurlcash/passkeys.ts @@ -36,13 +36,14 @@ import { writePasskeySlots, } from './storage/passkeySlots' import type {WalletMaterialV2} from './storage/storedSecret' -import {walletMaterialOwnerId} from './storage/storedSecret' -import {savedWalletMaterialOwnerId} from './walletMaterialStorage' +import {walletMaterialHash, walletMaterialOwnerId} from './storage/storedSecret' +import {savedWalletMaterialHash, 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 {rewrapAllSlots} from './passkeyRewrap' export { derivePasskeyWrapKey, InvalidPasskeyMaterialError, @@ -218,6 +219,9 @@ export const registerPasskey = async ( version: PASSKEY_SLOT_VERSION, } await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { + if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) { + throw new Error('Saved wallet material changed during passkey registration.') + } const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId) slots.push(slot) writePasskeySlots(ownerId, slots) @@ -232,10 +236,14 @@ export const unlockWalletMaterialWithPasskey = async ( options: {credentials?: PasskeyCredentials} = {}, ): Promise => { const ownerId = savedWalletMaterialOwnerId() + const materialHash = savedWalletMaterialHash() const slots = readPasskeySlots() - if (ownerId === null || slots.length === 0) { + if (ownerId === null || materialHash === null || slots.length === 0) { throw new Error('No passkeys registered on this device.') } + if (slots.some((slot) => slot.materialHash !== materialHash)) { + throw new Error('Passkey slot belongs to different wallet material.') + } const credentials = options.credentials ?? defaultCredentials() const assertion = await credentials.get({ publicKey: { @@ -261,6 +269,9 @@ export const unlockWalletMaterialWithPasskey = async ( if (savedWalletMaterialOwnerId() !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } + if (savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.') + } 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.') @@ -269,6 +280,9 @@ export const unlockWalletMaterialWithPasskey = async ( if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) { throw new Error('This passkey belongs to a different wallet.') } + if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.') + } return material } @@ -294,33 +308,3 @@ export const removePasskey = async (credentialId: string): Promise => { }) return removed } - -// 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 because password and passkey slots are -// independent wraps of the same canonical material. -export const rewrapAllSlots = async ( - material: WalletMaterialV2, - prfOutputs: ReadonlyMap, -): Promise => { - const ownerId = requireCurrentPasskeyMaterialOwner(material) - await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { - const slots = readPasskeySlots() - const rewrapped: PasskeySlot[] = [] - for (const slot of slots) { - const prfOutput = prfOutputs.get(slot.credentialId) - if (!prfOutput) { - throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.') - } - rewrapped.push({ - ...slot, - ...(await wrapWalletMaterialWithPrf(prfOutput, material)), - }) - } - writePasskeySlots(ownerId, rewrapped) - }) -} diff --git a/src/lnurlcash/passkeys.version.cases.ts b/src/lnurlcash/passkeys.version.cases.ts index f8bf2fc..3924c5a 100644 --- a/src/lnurlcash/passkeys.version.cases.ts +++ b/src/lnurlcash/passkeys.version.cases.ts @@ -8,6 +8,7 @@ import { linkingPubKeyHex, savedKeyOwnerId, saveWalletMaterial, + walletMaterialHash, } from './keys' import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' import type {PasskeySlot} from './passkeys' @@ -28,7 +29,7 @@ const SLOT_BASE = { credentialId: '11'.repeat(16), hkdfSalt: '22'.repeat(16), iv: '33'.repeat(12), - materialHash: '44'.repeat(32), + materialHash: walletMaterialHash(MATERIAL), wrappedMaterial: '55'.repeat(128), createdAt: 1, } as const diff --git a/src/lnurlcash/storage/passkeySlots.ts b/src/lnurlcash/storage/passkeySlots.ts index 258b041..8eca766 100644 --- a/src/lnurlcash/storage/passkeySlots.ts +++ b/src/lnurlcash/storage/passkeySlots.ts @@ -5,8 +5,12 @@ import {isJsonObject} from '../jsonParsing' import type {WalletMaterialV2} from './storedSecret' -import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret' -import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage' +import {serializeWalletMaterial, walletMaterialHash, walletMaterialOwnerId} from './storedSecret' +import { + getPlainWalletMaterial, + savedWalletMaterialHash, + savedWalletMaterialOwnerId, +} from '../walletMaterialStorage' import {isWalletOwnerId} from './walletOwner' export type PasskeyWrap = { @@ -39,10 +43,13 @@ export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolea export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => { const ownerId = savedWalletMaterialOwnerId() + const materialHash = savedWalletMaterialHash() const plaintext = getPlainWalletMaterial() if ( ownerId === null || + materialHash === null || walletMaterialOwnerId(material) !== ownerId || + walletMaterialHash(material) !== materialHash || (plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material)) ) { throw new Error('Passkey operation requires the proven saved wallet material.') @@ -175,10 +182,22 @@ export const readPasskeySlots = (): PasskeySlot[] => { export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { - if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { + const materialHash = savedWalletMaterialHash() + if ( + !isWalletOwnerId(ownerId) || + savedWalletMaterialOwnerId() !== ownerId || + materialHash === null + ) { throw new Error('Passkey slots require the proven saved wallet owner.') } - if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { + if ( + slots.some( + (slot) => + slot.ownerId !== ownerId || + slot.version !== PASSKEY_SLOT_VERSION || + slot.materialHash !== materialHash, + ) + ) { throw new Error('Refusing to write a passkey slot for a different wallet.') } const preserved = readStoredPasskeySlots() @@ -188,13 +207,18 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void = } export const adoptLegacyPasskeySlots = (ownerId: string): number => { + const materialHash = savedWalletMaterialHash() if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) { throw new Error('Legacy passkey migration requires a proven owner.') } const stored = readStoredPasskeySlots() let adopted = 0 const migrated = stored.map((slot) => { - if (slot.isCurrent || (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)) { + if ( + slot.isCurrent || + slot.record.materialHash !== materialHash || + (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId) + ) { return slot.record } adopted += 1 diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts index 5a8d0f2..7b8c305 100644 --- a/src/lnurlcash/storage/storedSecret.ts +++ b/src/lnurlcash/storage/storedSecret.ts @@ -3,21 +3,24 @@ // canonical material encoding so hostile storage cannot smuggle a legacy key, // a future field, or a malformed BIP-32 root across the unlock boundary. -import {secp256k1} from '@noble/curves/secp256k1.js' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' -import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit' import {isJsonObject} from '../jsonParsing' import {isWalletOwnerId} from './walletOwner' +import { + parseWalletMaterial, + serializedWalletMaterialHash, + STORED_WALLET_MATERIAL_VERSION, +} from './walletMaterial' +export { + parseWalletMaterial, + serializeWalletMaterial, + walletMaterialHash, + walletMaterialOwnerId, + WALLET_MATERIAL_VERSION, +} from './walletMaterial' +export type {WalletMaterialV2} from './walletMaterial' export const STORED_SECRET_VERSION = 1 as const -export const WALLET_MATERIAL_VERSION = 2 as const -export const STORED_WALLET_MATERIAL_VERSION = 2 as const - -export type WalletMaterialV2 = { - readonly version: typeof WALLET_MATERIAL_VERSION - readonly linkingKeyHex: string - readonly cashRootHex: string -} +export {STORED_WALLET_MATERIAL_VERSION} from './walletMaterial' type PlainStoredSecret = { readonly enc: false @@ -36,7 +39,7 @@ type EncryptedStoredSecret = { } export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret -export type StoredWalletMaterial = StoredSecret +export type StoredWalletMaterial = StoredSecret & {readonly materialHash: string} type ParsedStoredSecret = { readonly secret: StoredSecret @@ -44,66 +47,24 @@ type ParsedStoredSecret = { readonly isCurrent: boolean } +type ParsedStoredWalletMaterial = Omit & { + readonly secret: StoredWalletMaterial +} + const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const -const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const +const PLAIN_MATERIAL_KEYS = [...PLAIN_KEYS, 'materialHash'] as const +const ENCRYPTED_MATERIAL_KEYS = [...ENCRYPTED_KEYS, 'materialHash'] as const const hasOnlyKeys = (record: Record, allowed: readonly string[]): boolean => Object.keys(record).every((key) => allowed.includes(key)) -const hasExactlyKeys = (record: Record, expected: readonly string[]): boolean => - Object.keys(record).length === expected.length && hasOnlyKeys(record, expected) - -export const serializeWalletMaterial = (material: WalletMaterialV2): string => - JSON.stringify({ - version: WALLET_MATERIAL_VERSION, - linkingKeyHex: material.linkingKeyHex, - cashRootHex: material.cashRootHex, - }) - -export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => { - let value: unknown - try { - value = JSON.parse(serialized) - } catch (error) { - if (error instanceof SyntaxError) return null - throw error - } - if ( - !isJsonObject(value) || - !hasExactlyKeys(value, WALLET_MATERIAL_KEYS) || - value.version !== WALLET_MATERIAL_VERSION || - typeof value.linkingKeyHex !== 'string' || - !/^[0-9a-f]{64}$/.test(value.linkingKeyHex) || - typeof value.cashRootHex !== 'string' || - !/^[0-9a-f]{128}$/.test(value.cashRootHex) - ) { - return null - } - const linkingKey = hexToBytes(value.linkingKeyHex) - const cashRoot = cashNodeFromHex(value.cashRootHex) - if ( - !secp256k1.utils.isValidSecretKey(linkingKey) || - !secp256k1.utils.isValidSecretKey(cashRoot.privateKey) || - cashNodeToHex(cashRoot) !== value.cashRootHex - ) { - return null - } - const material: WalletMaterialV2 = { - version: WALLET_MATERIAL_VERSION, - linkingKeyHex: value.linkingKeyHex, - cashRootHex: value.cashRootHex, - } - return serializeWalletMaterial(material) === serialized ? material : null -} - -export const walletMaterialOwnerId = (material: WalletMaterialV2): string => - bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true)) - type StoredSecretParserOptions = { readonly version: number readonly acceptsPlaintext: (value: string) => boolean readonly acceptsUnversionedOwner: boolean + readonly plainKeys: readonly string[] + readonly encryptedKeys: readonly string[] } const parseStoredSecretWith = ( @@ -116,7 +77,7 @@ const parseStoredSecretWith = ( if ( typeof stored.value !== 'string' || !options.acceptsPlaintext(stored.value) || - !hasOnlyKeys(stored, PLAIN_KEYS) + !hasOnlyKeys(stored, options.plainKeys) ) { return null } @@ -131,7 +92,7 @@ const parseStoredSecretWith = ( stored.ciphertext.length === 0 || stored.ciphertext.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(stored.ciphertext) || - !hasOnlyKeys(stored, ENCRYPTED_KEYS) + !hasOnlyKeys(stored, options.encryptedKeys) ) { return null } @@ -170,14 +131,35 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => version: STORED_SECRET_VERSION, acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value), acceptsUnversionedOwner: true, + plainKeys: PLAIN_KEYS, + encryptedKeys: ENCRYPTED_KEYS, }) -export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null => - parseStoredSecretWith(stored, { +export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredWalletMaterial | null => { + if ( + !isJsonObject(stored) || + typeof stored.materialHash !== 'string' || + !/^[0-9a-f]{64}$/.test(stored.materialHash) + ) { + return null + } + const materialHash = stored.materialHash + const parsed = parseStoredSecretWith(stored, { version: STORED_WALLET_MATERIAL_VERSION, acceptsPlaintext: (value) => parseWalletMaterial(value) !== null, acceptsUnversionedOwner: false, + plainKeys: PLAIN_MATERIAL_KEYS, + encryptedKeys: ENCRYPTED_MATERIAL_KEYS, }) + if ( + parsed === null || + (parsed.secret.enc === false && serializedWalletMaterialHash(parsed.secret.value) !== materialHash) + ) { + return null + } + const secret: StoredWalletMaterial = {...parsed.secret, materialHash} + return {...parsed, secret} +} export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => parseStoredSecret(stored) !== null @@ -224,6 +206,7 @@ export const stampStoredWalletMaterialOwner = ( return { enc: false, value: stored.value, + materialHash: stored.materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, } @@ -233,6 +216,7 @@ export const stampStoredWalletMaterialOwner = ( salt: stored.salt, iv: stored.iv, ciphertext: stored.ciphertext, + materialHash: stored.materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, } @@ -247,3 +231,18 @@ export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { ciphertext: stored.ciphertext, } } + +export const stripStoredWalletMaterialOwner = ( + stored: StoredWalletMaterial, +): StoredWalletMaterial => { + if (stored.enc === false) { + return {enc: false, value: stored.value, materialHash: stored.materialHash} + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + materialHash: stored.materialHash, + } +} diff --git a/src/lnurlcash/storage/walletMaterial.ts b/src/lnurlcash/storage/walletMaterial.ts new file mode 100644 index 0000000..7283ed7 --- /dev/null +++ b/src/lnurlcash/storage/walletMaterial.ts @@ -0,0 +1,77 @@ +// Canonical wallet material combines stable LUD-05 identity with the BIP-32 +// cash root. Its hash commits to equality and freshness only; wallet identity +// remains derived exclusively from the linking key. + +import {secp256k1} from '@noble/curves/secp256k1.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit' + +import {isJsonObject} from '../jsonParsing' + +export const WALLET_MATERIAL_VERSION = 2 as const +export const STORED_WALLET_MATERIAL_VERSION = 2 as const + +export type WalletMaterialV2 = { + readonly version: typeof WALLET_MATERIAL_VERSION + readonly linkingKeyHex: string + readonly cashRootHex: string +} + +const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const + +const hasExactlyKeys = (value: Record, expected: readonly string[]): boolean => + Object.keys(value).length === expected.length && + Object.keys(value).every((key) => expected.includes(key)) + +export const serializeWalletMaterial = (material: WalletMaterialV2): string => + JSON.stringify({ + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: material.linkingKeyHex, + cashRootHex: material.cashRootHex, + }) + +export const serializedWalletMaterialHash = (serialized: string): string => + bytesToHex(sha256(utf8ToBytes(serialized))) + +export const walletMaterialHash = (material: WalletMaterialV2): string => + serializedWalletMaterialHash(serializeWalletMaterial(material)) + +export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => { + let value: unknown + try { + value = JSON.parse(serialized) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + if ( + !isJsonObject(value) || + !hasExactlyKeys(value, WALLET_MATERIAL_KEYS) || + value.version !== WALLET_MATERIAL_VERSION || + typeof value.linkingKeyHex !== 'string' || + !/^[0-9a-f]{64}$/.test(value.linkingKeyHex) || + typeof value.cashRootHex !== 'string' || + !/^[0-9a-f]{128}$/.test(value.cashRootHex) + ) { + return null + } + const linkingKey = hexToBytes(value.linkingKeyHex) + const cashRoot = cashNodeFromHex(value.cashRootHex) + if ( + !secp256k1.utils.isValidSecretKey(linkingKey) || + !secp256k1.utils.isValidSecretKey(cashRoot.privateKey) || + cashNodeToHex(cashRoot) !== value.cashRootHex + ) { + return null + } + const material: WalletMaterialV2 = { + version: WALLET_MATERIAL_VERSION, + linkingKeyHex: value.linkingKeyHex, + cashRootHex: value.cashRootHex, + } + return serializeWalletMaterial(material) === serialized ? material : null +} + +export const walletMaterialOwnerId = (material: WalletMaterialV2): string => + bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true)) diff --git a/src/lnurlcash/walletMaterialStorage.ts b/src/lnurlcash/walletMaterialStorage.ts index 5c4ba70..74e1516 100644 --- a/src/lnurlcash/walletMaterialStorage.ts +++ b/src/lnurlcash/walletMaterialStorage.ts @@ -9,11 +9,13 @@ import { stampStoredWalletMaterialOwner, storedWalletMaterialClaimedOwnerId, storedWalletMaterialOwnerId, - stripStoredSecretOwner, + stripStoredWalletMaterialOwner, + walletMaterialHash, walletMaterialOwnerId, STORED_WALLET_MATERIAL_VERSION, } from './storage/storedSecret' import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +import {serializedWalletMaterialHash} from './storage/walletMaterial' import {decryptSecretParts, encryptSecretParts} from './passwordWrap' import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents' @@ -86,6 +88,9 @@ export const savedWalletMaterialOwnerId = (): string | null => { export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean => savedWalletMaterialOwnerId() === walletMaterialOwnerId(material) +export const savedWalletMaterialHash = (): string | null => + readStoredWalletMaterial()?.materialHash ?? null + export const getPlainWalletMaterial = (): WalletMaterialV2 | null => { const stored = readStoredWalletMaterial() return stored === null ? null : materialFromPlaintext(stored) @@ -96,6 +101,7 @@ export const saveWalletMaterial = async ( password?: string, ): Promise => { const value = serializeWalletMaterial(material) + const materialHash = walletMaterialHash(material) const ownerId = walletMaterialOwnerId(material) if (!password) { localStorage.setItem( @@ -103,6 +109,7 @@ export const saveWalletMaterial = async ( JSON.stringify({ enc: false, value, + materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, }), @@ -115,6 +122,7 @@ export const saveWalletMaterial = async ( JSON.stringify({ enc: true, ...parts, + materialHash, ownerId, version: STORED_WALLET_MATERIAL_VERSION, }), @@ -122,10 +130,11 @@ export const saveWalletMaterial = async ( } export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => { - if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError() + const parsed = parseStoredWalletMaterial(stored) + if (parsed === null) throw new InvalidWalletMaterialError() localStorage.setItem( WALLET_MATERIAL_STORAGE_KEY, - JSON.stringify(stripStoredSecretOwner(stored)), + JSON.stringify(stripStoredWalletMaterialOwner(parsed.secret)), ) } @@ -134,7 +143,11 @@ export const decryptSavedWalletMaterial = async ( ): Promise => { const stored = readStoredWalletMaterial() if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError() - const material = parseWalletMaterial(await decryptSecretParts(stored, password)) + const serialized = await decryptSecretParts(stored, password) + if (serializedWalletMaterialHash(serialized) !== stored.materialHash) { + throw new WalletMaterialProofMismatchError() + } + const material = parseWalletMaterial(serialized) if (material === null) throw new InvalidWalletMaterialError() const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) { @@ -146,7 +159,11 @@ export const decryptSavedWalletMaterial = async ( export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => { const stored = readStoredWalletMaterial() if (stored === null) return - if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) { + const serialized = serializeWalletMaterial(material) + if ( + walletMaterialHash(material) !== stored.materialHash || + (stored.enc === false && stored.value !== serialized) + ) { throw new WalletMaterialProofMismatchError() } const ownerId = walletMaterialOwnerId(material)