diff --git a/src/lnurlcash/keys.material-storage.cases.ts b/src/lnurlcash/keys.material-storage.cases.ts new file mode 100644 index 0000000..d748080 --- /dev/null +++ b/src/lnurlcash/keys.material-storage.cases.ts @@ -0,0 +1,196 @@ +import {describe, expect, it} from 'vitest' + +import { + decryptSavedWalletMaterial, + deriveWalletMaterial, + encryptSecretParts, + ensureSavedWalletMaterialOwner, + getPlainWalletMaterial, + isValidStoredWalletMaterial, + linkingPubKeyHex, + restoreWalletMaterialStored, + savedWalletMaterialExists, + savedWalletMaterialIsEncrypted, + savedWalletMaterialOwnerId, + saveWalletMaterial, + serializeWalletMaterial, + walletMaterialLinkingKey, +} from './keys' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +const FIXED_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const PASSWORD = 'correct horse battery staple' +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' +const LEGACY_STORAGE_KEY = 'sattle_linking_key' +const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' +const CASH_ROOT_HEX = + 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' + +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) +} + +describe('WalletMaterialV2 persistence', () => { + it('round-trips canonical plaintext material in the new namespace', async () => { + // Given complete wallet material and an unrelated legacy record + stubLocalStorage() + localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({enc: false, value: '07'.repeat(32)})) + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When the material is saved without a password + await saveWalletMaterial(material) + + // Then only the new namespace carries the exact complete material + expect(savedWalletMaterialExists()).toBe(true) + expect(savedWalletMaterialIsEncrypted()).toBe(false) + expect(getPlainWalletMaterial()).toEqual(material) + expect(readRawMaterialRecord()).toEqual({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }) + expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull() + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC) + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"') + }) + + it('round-trips canonical password-encrypted material without plaintext fields', async () => { + // Given complete wallet material + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When the material is password protected + await saveWalletMaterial(material, PASSWORD) + + // Then only authenticated decryption returns both parts + expect(savedWalletMaterialIsEncrypted()).toBe(true) + expect(getPlainWalletMaterial()).toBeNull() + expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material) + await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow() + const raw = JSON.stringify(readRawMaterialRecord()) + expect(raw).not.toContain(LINKING_KEY_HEX) + expect(raw).not.toContain(CASH_ROOT_HEX) + expect(raw).not.toContain(FIXED_MNEMONIC) + }) + + it('strips restored owner claims until the material proves its real owner', () => { + // Given plaintext material carrying a foreign owner claim + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9)) + + // When the record is restored and subsequently proven by plaintext unlock + restoreWalletMaterialStored({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: foreignOwner, + version: 2, + }) + const beforeProof = readRawMaterialRecord() + const unlocked = getPlainWalletMaterial() + if (unlocked === null) throw new Error('expected restored plaintext material') + 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(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) + }) + + it('rejects a plaintext owner mismatch without returning or stamping material', () => { + // Given canonical material stamped with a foreign current owner + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: false, + value: serializeWalletMaterial(material), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + version: 2, + }), + ) + const before = localStorage.getItem(MATERIAL_STORAGE_KEY) + + // When it is read or offered for proof + const unlocked = getPlainWalletMaterial() + const stamp = () => ensureSavedWalletMaterialOwner(material) + + // Then neither operation trusts or rewrites the foreign record + expect(unlocked).toBeNull() + expect(stamp).toThrow('different wallet') + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it('rejects an encrypted owner mismatch after authentication', async () => { + // Given valid encrypted material whose owner marker was replaced + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + await saveWalletMaterial(material, PASSWORD) + const foreign = { + ...readRawMaterialRecord(), + ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), + } + localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign)) + const before = localStorage.getItem(MATERIAL_STORAGE_KEY) + + // When the authenticated material contradicts the current owner marker + const unlock = decryptSavedWalletMaterial(PASSWORD) + + // Then no material is returned or marker changed + await expect(unlock).rejects.toThrow('different wallet') + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) + }) + + it.each([ + ['legacy key-only plaintext', LINKING_KEY_HEX, 2], + ['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1], + [ + 'malformed material plaintext', + JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00'}), + 2, + ], + ])('rejects %s records at the storage boundary', (_name, value, version) => { + // Given an invalid record under the v2 material namespace + stubLocalStorage() + const material = deriveWalletMaterial(FIXED_MNEMONIC) + const record = { + enc: false, + value, + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version, + } + localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record)) + + // When storage helpers parse it + // Then the record is unusable as wallet material + expect(isValidStoredWalletMaterial(record)).toBe(false) + expect(savedWalletMaterialExists()).toBe(false) + expect(getPlainWalletMaterial()).toBeNull() + }) + + it('rejects authenticated ciphertext containing malformed material', async () => { + // Given valid AES-GCM parts whose plaintext is not a v2 material envelope + stubLocalStorage() + const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD) + const material = deriveWalletMaterial(FIXED_MNEMONIC) + localStorage.setItem( + MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), + version: 2, + }), + ) + + // When the password successfully authenticates the malformed plaintext + const unlock = decryptSavedWalletMaterial(PASSWORD) + + // Then it still fails closed as invalid material + await expect(unlock).rejects.toThrow('valid v2 wallet material') + }) +}) diff --git a/src/lnurlcash/keys.material.cases.ts b/src/lnurlcash/keys.material.cases.ts new file mode 100644 index 0000000..586ef2e --- /dev/null +++ b/src/lnurlcash/keys.material.cases.ts @@ -0,0 +1,159 @@ +import {describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import {getPublicKey} from 'nostr-tools/pure' +import {cashNodeToHex} from 'lnurlcash-kit' + +import { + deriveBearerAesKey, + deriveWalletLinkingKey, + deriveWalletMaterial, + linkingPubKeyHex, + parseWalletMaterial, + serializeWalletMaterial, + walletMaterialCashRoot, + walletMaterialLinkingKey, +} from './keys' +import {backupPubkey, deriveBackupKey} from './nostrBackup' +import {deriveNwcWalletKey, nwcWalletPubkey} from './nwc' + +const FIXED_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17)) +const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' +const CASH_ROOT_HEX = + 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' + +describe('fixed mnemonic identity characterization', () => { + it('pins every public linking-key-derived identity and bearer encryption behavior', async () => { + // Given a mnemonic accepted by the existing wallet derivation + const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC) + const bearerKey = await deriveBearerAesKey(linkingKey) + + // When its owner, bearer, Nostr, and NWC observables are derived + const bearerCiphertext = new Uint8Array( + await crypto.subtle.encrypt( + {name: 'AES-GCM', iv: new Uint8Array(12)}, + bearerKey, + new TextEncoder().encode('sattle-vector-v1'), + ), + ) + + // Then they match the pre-v2 wallet's independently captured vectors + expect(linkingPubKeyHex(linkingKey)).toBe( + '0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481', + ) + expect(bytesToHex(bearerCiphertext)).toBe( + '4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78', + ) + expect(backupPubkey(deriveBackupKey(linkingKey))).toBe( + '6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577', + ) + expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe( + '3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587', + ) + }) +}) + +describe('WalletMaterialV2 derivation and serialization', () => { + it('derives the unchanged linking key and kit cash root from one mnemonic', () => { + // Given the fixed recovery mnemonic + // When its complete wallet material is derived + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // Then both independently captured vectors and the exact schema are stable + expect(material).toEqual({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX, + }) + expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']) + expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC)) + expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX) + }) + + it('round-trips only the canonical exact serialization', () => { + // Given valid v2 material + const material = deriveWalletMaterial(FIXED_MNEMONIC) + + // When it crosses the serialization boundary + const serialized = serializeWalletMaterial(material) + + // Then its bytes and parsed value are canonical + expect(serialized).toBe( + `{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`, + ) + expect(parseWalletMaterial(serialized)).toEqual(material) + }) + + it.each([ + ['legacy key only', LINKING_KEY_HEX], + [ + 'wrong version', + JSON.stringify({version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX}), + ], + [ + 'missing field', + JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX}), + ], + [ + 'extra field', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX, + seed: 'forbidden', + }), + ], + [ + 'uppercase linking key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX.toUpperCase(), + cashRootHex: CASH_ROOT_HEX, + }), + ], + [ + 'truncated linking key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX.slice(2), + cashRootHex: CASH_ROOT_HEX, + }), + ], + [ + 'uppercase cash root', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX.toUpperCase(), + }), + ], + [ + 'truncated cash root', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: CASH_ROOT_HEX.slice(2), + }), + ], + [ + 'invalid cash root private key', + JSON.stringify({ + version: 2, + linkingKeyHex: LINKING_KEY_HEX, + cashRootHex: '00'.repeat(32) + CASH_ROOT_HEX.slice(64), + }), + ], + [ + 'non-canonical field order', + JSON.stringify({cashRootHex: CASH_ROOT_HEX, linkingKeyHex: LINKING_KEY_HEX, version: 2}), + ], + ])('rejects %s', (_name, serialized) => { + // Given malformed or non-canonical serialized material + // When it crosses the parser + const parsed = parseWalletMaterial(serialized) + + // Then no trusted material is returned + expect(parsed).toBeNull() + }) +}) diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index 604553c..a01600c 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -5,6 +5,7 @@ import {hmac} from '@noble/hashes/hmac.js' import {sha256} from '@noble/hashes/sha2.js' import {secp256k1} from '@noble/curves/secp256k1.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode} from 'lnurlcash-kit' import { parseStoredSecret, @@ -14,11 +15,21 @@ import { stripStoredSecretOwner, STORED_SECRET_VERSION, type StoredSecret, + type WalletMaterialV2, } from './storage/storedSecret' +import {decryptSecretParts, encryptSecretParts} from './passwordWrap' import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' -export {isValidStoredSecret} from './storage/storedSecret' -export type {StoredSecret} from './storage/storedSecret' +export { + isValidStoredSecret, + isValidStoredWalletMaterial, + parseWalletMaterial, + serializeWalletMaterial, +} from './storage/storedSecret' +export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +export {decryptSecretParts, encryptSecretParts} from './passwordWrap' +export type {EncryptedSecretParts} from './passwordWrap' +export * from './walletMaterialStorage' // The wallet's identity is derived against this fixed domain rather than // window.location.hostname, so the same seed phrase always yields the same @@ -40,8 +51,7 @@ const readUint32BE = (bytes: Uint8Array, offset: number): number => // LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server - // a seed restored there or here produces the same identity for a given domain -export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => { - const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) +const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => { const master = HDKey.fromMasterSeed(seed) const hashingKeyNode = master.derive("m/138'/0") @@ -58,6 +68,9 @@ export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8 return node.privateKey } +export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => + deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain) + // the HMAC half of the derivation, split out so the LUD-05 test vector // (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it // directly - see keys.test.ts @@ -69,15 +82,28 @@ export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[ export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN) +export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => { + const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) + try { + return { + version: 2, + linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)), + cashRootHex: cashNodeToHex(deriveCashRoot(seed)), + } + } finally { + seed.fill(0) + } +} + +export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array => + hexToBytes(material.linkingKeyHex) + +export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode => + cashNodeFromHex(material.cashRootHex) + export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string => bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true)) -// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the -// stored value is either plaintext or, if the holder opted in with a -// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password - -// GCM's auth tag doubles as the "wrong password" check on decrypt. -const PBKDF2_ITERATIONS = 210_000 - const readSecret = (storageKey: string): StoredSecret | null => { const raw = localStorage.getItem(storageKey) if (!raw) return null @@ -89,69 +115,10 @@ const readSecret = (storageKey: string): StoredSecret | null => { } } -const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => - crypto.subtle - .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) - .then((baseKey) => - crypto.subtle.deriveKey( - // the copy pins the TS type to Uint8Array - hexToBytes - // returns Uint8Array, which BufferSource rejects - { - name: 'PBKDF2', - salt: new Uint8Array(salt), - iterations: PBKDF2_ITERATIONS, - hash: 'SHA-256', - }, - baseKey, - {name: 'AES-GCM', length: 256}, - false, - ['encrypt', 'decrypt'], - ), - ) - -export type EncryptedSecretParts = { - salt: string - iv: string - ciphertext: string -} - -export const encryptSecretParts = async ( - value: string, - password: string, -): Promise => { - const salt = crypto.getRandomValues(new Uint8Array(16)) - const iv = crypto.getRandomValues(new Uint8Array(12)) - const aesKey = await deriveAesKeyFromPassword(password, salt) - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)), - ) - return { - salt: bytesToHex(salt), - iv: bytesToHex(iv), - ciphertext: bytesToHex(ciphertext), - } -} - -// rejects (WebCrypto's own auth-tag check) if the password is wrong -export const decryptSecretParts = async ( - parts: EncryptedSecretParts, - password: string, -): Promise => { - const salt = hexToBytes(parts.salt) - const iv = hexToBytes(parts.iv) - const aesKey = await deriveAesKeyFromPassword(password, salt) - const plaintext = await crypto.subtle.decrypt( - {name: 'AES-GCM', iv}, - aesKey, - hexToBytes(parts.ciphertext), - ) - return new TextDecoder().decode(plaintext) -} - -// The linking key is the only secret this wallet persists - the seed phrase -// it was derived from is shown once at setup and never stored. Everything -// else at rest (the bearer tokens) is encrypted with a key derived from it, -// so protecting this one record with a password protects the whole wallet. +// The legacy alpha lifecycle below persists only the linking key under its old +// namespace. Task 5 replaces that lifecycle with the v2 material repository; +// these APIs stay byte-compatible until the serialized reset can switch every +// caller atomically rather than creating a split-key wallet. // // The record also carries an ownerId marker: the lowercase compressed // pubkey hex of the key itself (storage/walletOwner.ts), binding every diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts index fd686f5..0e4362d 100644 --- a/src/lnurlcash/keys.version.cases.ts +++ b/src/lnurlcash/keys.version.cases.ts @@ -12,6 +12,9 @@ import { } from './keys' import {parseJsonObject, stubLocalStorage} from './test-utils' +import './keys.material.cases' +import './keys.material-storage.cases' + const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const STORAGE_KEY = 'sattle_linking_key' diff --git a/src/lnurlcash/passwordWrap.ts b/src/lnurlcash/passwordWrap.ts new file mode 100644 index 0000000..ff126ae --- /dev/null +++ b/src/lnurlcash/passwordWrap.ts @@ -0,0 +1,63 @@ +// Password wraps protect serialized wallet secrets with authenticated AES-GCM. +// PBKDF2 parameters and wire fields stay centralized so legacy linking-key and +// v2 wallet-material records remain interoperable with their own readers. + +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' + +const PBKDF2_ITERATIONS = 210_000 + +const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => + crypto.subtle + .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) + .then((baseKey) => + crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new Uint8Array(salt), + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256', + }, + baseKey, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'], + ), + ) + +export type EncryptedSecretParts = { + readonly salt: string + readonly iv: string + readonly ciphertext: string +} + +export const encryptSecretParts = async ( + value: string, + password: string, +): Promise => { + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const aesKey = await deriveAesKeyFromPassword(password, salt) + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)), + ) + return { + salt: bytesToHex(salt), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + } +} + +export const decryptSecretParts = async ( + parts: EncryptedSecretParts, + password: string, +): Promise => { + const salt = hexToBytes(parts.salt) + const iv = hexToBytes(parts.iv) + const aesKey = await deriveAesKeyFromPassword(password, salt) + const plaintext = await crypto.subtle.decrypt( + {name: 'AES-GCM', iv}, + aesKey, + hexToBytes(parts.ciphertext), + ) + return new TextDecoder().decode(plaintext) +} diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts index a053f1c..5a8d0f2 100644 --- a/src/lnurlcash/storage/storedSecret.ts +++ b/src/lnurlcash/storage/storedSecret.ts @@ -1,13 +1,23 @@ -// Saved linking-key records have two safe at-rest forms: ownerless legacy -// records and version-1 owner-bearing records. The briefly shipped -// unversioned owner-bearing shape remains readable only so a proven key can -// upgrade it; it never establishes ownership by itself. Any other metadata -// is rejected rather than downgraded to adoptable legacy data. +// Saved secret records bind either the legacy linking key or canonical v2 +// wallet material to a proven owner. The v2 parser accepts only one exact, +// 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' 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 +} type PlainStoredSecret = { readonly enc: false @@ -26,6 +36,7 @@ type EncryptedStoredSecret = { } export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret +export type StoredWalletMaterial = StoredSecret type ParsedStoredSecret = { readonly secret: StoredSecret @@ -35,17 +46,76 @@ type ParsedStoredSecret = { 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 hasOnlyKeys = (record: Record, allowed: readonly string[]): boolean => Object.keys(record).every((key) => allowed.includes(key)) -export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => { +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 +} + +const parseStoredSecretWith = ( + stored: unknown, + options: StoredSecretParserOptions, +): ParsedStoredSecret | null => { if (!isJsonObject(stored)) return null let secret: StoredSecret if (stored.enc === false) { if ( typeof stored.value !== 'string' || - !/^[0-9a-f]{64}$/i.test(stored.value) || + !options.acceptsPlaintext(stored.value) || !hasOnlyKeys(stored, PLAIN_KEYS) ) { return null @@ -80,23 +150,41 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false} if (!isWalletOwnerId(stored.ownerId)) return null if (!hasVersion) { + if (!options.acceptsUnversionedOwner) return null return { secret: {...secret, ownerId: stored.ownerId}, claimedOwnerId: stored.ownerId, isCurrent: false, } } - if (stored.version !== STORED_SECRET_VERSION) return null + if (stored.version !== options.version) return null return { - secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION}, + secret: {...secret, ownerId: stored.ownerId, version: options.version}, claimedOwnerId: stored.ownerId, isCurrent: true, } } +export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => + parseStoredSecretWith(stored, { + version: STORED_SECRET_VERSION, + acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value), + acceptsUnversionedOwner: true, + }) + +export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null => + parseStoredSecretWith(stored, { + version: STORED_WALLET_MATERIAL_VERSION, + acceptsPlaintext: (value) => parseWalletMaterial(value) !== null, + acceptsUnversionedOwner: false, + }) + export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => parseStoredSecret(stored) !== null +export const isValidStoredWalletMaterial = (stored: unknown): stored is StoredWalletMaterial => + parseStoredWalletMaterial(stored) !== null + export const storedSecretOwnerId = (stored: StoredSecret): string | null => { const parsed = parseStoredSecret(stored) return parsed?.isCurrent === true ? parsed.claimedOwnerId : null @@ -105,6 +193,15 @@ export const storedSecretOwnerId = (stored: StoredSecret): string | null => { export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null => parseStoredSecret(stored)?.claimedOwnerId ?? null +export const storedWalletMaterialOwnerId = (stored: StoredWalletMaterial): string | null => { + const parsed = parseStoredWalletMaterial(stored) + return parsed?.isCurrent === true ? parsed.claimedOwnerId : null +} + +export const storedWalletMaterialClaimedOwnerId = ( + stored: StoredWalletMaterial, +): string | null => parseStoredWalletMaterial(stored)?.claimedOwnerId ?? null + export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => { if (stored.enc === false) { return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION} @@ -119,6 +216,28 @@ export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): S } } +export const stampStoredWalletMaterialOwner = ( + stored: StoredWalletMaterial, + ownerId: string, +): StoredWalletMaterial => { + if (stored.enc === false) { + return { + enc: false, + value: stored.value, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + } + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + } +} + export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { if (stored.enc === false) return {enc: false, value: stored.value} return { diff --git a/src/lnurlcash/storage/walletOwnerEvents.ts b/src/lnurlcash/storage/walletOwnerEvents.ts index ba0b366..fb16e84 100644 --- a/src/lnurlcash/storage/walletOwnerEvents.ts +++ b/src/lnurlcash/storage/walletOwnerEvents.ts @@ -2,6 +2,7 @@ // Their payload may be stale when writes arrive faster than delivery. export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' +export const WALLET_MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' // Each subscription binds its own storage listener and removes exactly it on // unsubscribe: no shared reference counting, so an abandoned subscriber can @@ -9,7 +10,13 @@ export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' export const onSavedKeyStorageChange = (listener: () => void): (() => void) => { const target = typeof window === 'undefined' ? null : window const onStorage = (event: StorageEvent): void => { - if (event.key !== LINKING_KEY_STORAGE_KEY && event.key !== null) return + if ( + event.key !== LINKING_KEY_STORAGE_KEY && + event.key !== WALLET_MATERIAL_STORAGE_KEY && + event.key !== null + ) { + return + } listener() } target?.addEventListener('storage', onStorage) diff --git a/src/lnurlcash/walletMaterialStorage.ts b/src/lnurlcash/walletMaterialStorage.ts new file mode 100644 index 0000000..5c4ba70 --- /dev/null +++ b/src/lnurlcash/walletMaterialStorage.ts @@ -0,0 +1,166 @@ +// The v2 wallet-material repository owns one authenticated serialization under +// a new namespace. Owner markers remain claims on restore and become trusted +// only after plaintext or password proof reproduces the linking-key owner. + +import { + parseStoredWalletMaterial, + parseWalletMaterial, + serializeWalletMaterial, + stampStoredWalletMaterialOwner, + storedWalletMaterialClaimedOwnerId, + storedWalletMaterialOwnerId, + stripStoredSecretOwner, + walletMaterialOwnerId, + STORED_WALLET_MATERIAL_VERSION, +} from './storage/storedSecret' +import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' +import {decryptSecretParts, encryptSecretParts} from './passwordWrap' +import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents' + +export class InvalidWalletMaterialError extends Error { + override readonly name = 'InvalidWalletMaterialError' + + constructor() { + super('Saved plaintext is not valid v2 wallet material.') + } +} + +export class WalletMaterialOwnerMismatchError extends Error { + override readonly name = 'WalletMaterialOwnerMismatchError' + + constructor() { + super('Saved wallet material is owned by a different wallet.') + } +} + +export class WalletMaterialProofMismatchError extends Error { + override readonly name = 'WalletMaterialProofMismatchError' + + constructor() { + super('Proven material does not match the saved wallet material.') + } +} + +export class NoEncryptedWalletMaterialError extends Error { + override readonly name = 'NoEncryptedWalletMaterialError' + + constructor() { + super('No encrypted wallet material saved.') + } +} + +const readStoredWalletMaterial = (): StoredWalletMaterial | null => { + const raw = localStorage.getItem(WALLET_MATERIAL_STORAGE_KEY) + if (raw === null) return null + let value: unknown + try { + value = JSON.parse(raw) + } catch (error) { + if (error instanceof SyntaxError) return null + throw error + } + return parseStoredWalletMaterial(value)?.secret ?? null +} + +const materialFromPlaintext = (stored: StoredWalletMaterial): WalletMaterialV2 | null => { + if (stored.enc) return null + const material = parseWalletMaterial(stored.value) + if (material === null) return null + const claim = storedWalletMaterialClaimedOwnerId(stored) + return claim === null || claim === walletMaterialOwnerId(material) ? material : null +} + +export const savedWalletMaterialExists = (): boolean => readStoredWalletMaterial() !== null + +export const savedWalletMaterialIsEncrypted = (): boolean => + readStoredWalletMaterial()?.enc === true + +export const getSavedWalletMaterialStored = (): StoredWalletMaterial | null => + readStoredWalletMaterial() + +export const savedWalletMaterialOwnerId = (): string | null => { + const stored = readStoredWalletMaterial() + return stored === null ? null : storedWalletMaterialOwnerId(stored) +} + +export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean => + savedWalletMaterialOwnerId() === walletMaterialOwnerId(material) + +export const getPlainWalletMaterial = (): WalletMaterialV2 | null => { + const stored = readStoredWalletMaterial() + return stored === null ? null : materialFromPlaintext(stored) +} + +export const saveWalletMaterial = async ( + material: WalletMaterialV2, + password?: string, +): Promise => { + const value = serializeWalletMaterial(material) + const ownerId = walletMaterialOwnerId(material) + if (!password) { + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: false, + value, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + }), + ) + return + } + const parts = await encryptSecretParts(value, password) + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify({ + enc: true, + ...parts, + ownerId, + version: STORED_WALLET_MATERIAL_VERSION, + }), + ) +} + +export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => { + if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError() + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify(stripStoredSecretOwner(stored)), + ) +} + +export const decryptSavedWalletMaterial = async ( + password: string, +): Promise => { + const stored = readStoredWalletMaterial() + if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError() + const material = parseWalletMaterial(await decryptSecretParts(stored, password)) + if (material === null) throw new InvalidWalletMaterialError() + const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) + if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) { + throw new WalletMaterialOwnerMismatchError() + } + return material +} + +export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => { + const stored = readStoredWalletMaterial() + if (stored === null) return + if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) { + throw new WalletMaterialProofMismatchError() + } + const ownerId = walletMaterialOwnerId(material) + const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored) + if (storedWalletMaterialOwnerId(stored) === ownerId) return + if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { + throw new WalletMaterialOwnerMismatchError() + } + localStorage.setItem( + WALLET_MATERIAL_STORAGE_KEY, + JSON.stringify(stampStoredWalletMaterialOwner(stored, ownerId)), + ) +} + +export const clearSavedWalletMaterial = (): void => { + localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY) +}