diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts new file mode 100644 index 0000000..150777d --- /dev/null +++ b/src/capabilities/biometricUnlock.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils.js'; + +import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys'; +import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; +import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots'; +import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils'; + +const pluginMocks = vi.hoisted(() => ({ + authenticate: vi.fn<() => Promise>(), + secureGet: vi.fn<(key: string) => Promise>(), +})); + +vi.mock('@aparajita/capacitor-biometric-auth', () => ({ + AndroidBiometryStrength: { weak: 'weak' }, + BiometricAuth: { authenticate: pluginMocks.authenticate }, + BiometryError: class BiometryError extends Error {}, + BiometryErrorType: { userCancel: 'userCancel' }, +})); + +vi.mock('@aparajita/capacitor-secure-storage', () => ({ + SecureStorage: { get: pluginMocks.secureGet }, +})); + +vi.mock('./platform', () => ({ isNative: () => true })); + +import { unlockWithBiometrics } from './biometricUnlock'; + +const LINKING_KEY = new Uint8Array(32).fill(7); +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const WRAP_SECRET = new Uint8Array(32).fill(3); + +beforeEach(() => { + vi.clearAllMocks(); + stubLocalStorage(); + pluginMocks.authenticate.mockResolvedValue(); + pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); +}); + +describe('biometric unlock owner proof', () => { + it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => { + // Given an ownerless legacy wallet and credential residue plus a biometric wrap + // whose claimed pubkey does not match the key it unwraps + const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) }; + const legacySlot = { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, + }; + const legacyNwc = [ + { + clientPubkey: '55'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 1000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + ]; + const legacyTrust = [ + { + server: 'legacy.example', + mintPubkey: '02' + 'aa'.repeat(32), + addedAt: 1, + locked: false, + }, + ]; + localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey)); + localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot])); + localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc)); + localStorage.setItem('sattle_nwc_enabled', 'true'); + localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust)); + const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY); + localStorage.setItem( + 'sattle_biometric_wrap', + JSON.stringify({ + ...wrap, + pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), + createdAt: 1, + }), + ); + const before = new Map([ + ['sattle_linking_key', localStorage.getItem('sattle_linking_key')], + ['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')], + ['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')], + ['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')], + ['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')], + ]); + + // When biometric unwrap reaches the pubkey proof check + const attempt = unlockWithBiometrics(); + + // Then no key crosses the capability boundary and no legacy namespace is adopted + await expect(attempt).rejects.toThrow('different wallet'); + expect(savedKeyOwnerId()).toBeNull(); + expect(readPasskeySlots()).toEqual([]); + expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey); + for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value); + }); +}); diff --git a/src/lnurlcash/keys.test.ts b/src/lnurlcash/keys.test.ts new file mode 100644 index 0000000..97f5e3a --- /dev/null +++ b/src/lnurlcash/keys.test.ts @@ -0,0 +1,316 @@ +// Saved linking-key record tests. The baseline describes pin the observable +// behavior of saveLinkingKey/decryptSavedLinkingKey/getPlainLinkingKey/ +// restoreLinkingKeyStored as it existed before the owner marker (they must +// keep passing unchanged); the owner-marker describes cover the ownerId +// field that binds the saved key to its one proven wallet identity. +// Node env: in-memory localStorage stub, native WebCrypto. + +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import { + decryptSavedLinkingKey, + encryptSecretParts, + ensureSavedKeyOwner, + getPlainLinkingKey, + linkingPubKeyHex, + restoreLinkingKeyStored, + savedKeyExists, + savedKeyIsEncrypted, + savedKeyOwnerMatches, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {isWalletOwnerId} from './storage/walletOwner' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +import './keys.version.cases' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const PASSWORD = 'hunter2' + +const STORAGE_KEY = 'sattle_linking_key' + +const readRawRecord = (): Record => { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) throw new Error('expected a saved linking-key record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +// hand-written records in the pre-owner-marker shape - what every wallet +// created before this change has on disk +const saveLegacyPlaintext = (key: Uint8Array): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({enc: false, value: bytesToHex(key)})) +} + +const saveLegacyEncrypted = async ( + key: Uint8Array, + password: string, +): Promise> => { + const parts = await encryptSecretParts(bytesToHex(key), password) + const record: Record = {enc: true, ...parts} + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + return record +} + +describe('baseline: saved-key record behavior', () => { + it('saves a plaintext key and reads it back', async () => { + await saveLinkingKey(LINKING_KEY) + + expect(savedKeyExists()).toBe(true) + expect(savedKeyIsEncrypted()).toBe(false) + expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)}) + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + }) + + it('saves a password-encrypted key and decrypts it with the password', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + + expect(savedKeyIsEncrypted()).toBe(true) + const record = readRawRecord() + expect(record.enc).toBe(true) + expect(record.value).toBeUndefined() + // an encrypted record never reads through the plaintext path + expect(getPlainLinkingKey()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('rejects the wrong password via the GCM auth tag', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow() + }) + + it('throws when asked to decrypt a plaintext record', async () => { + await saveLinkingKey(LINKING_KEY) + await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow( + 'No encrypted linking key saved.', + ) + }) + + it('restores an ownerless record verbatim and reads it back', async () => { + const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) + const record = {enc: true as const, ...parts} + restoreLinkingKeyStored(record) + + expect(readRawRecord()).toEqual(record) + expect(savedKeyIsEncrypted()).toBe(true) + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('drops a malformed stored record instead of trusting it', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({enc: true, salt: 'zz', iv: '00', ciphertext: ''}), + ) + expect(savedKeyExists()).toBe(false) + localStorage.setItem(STORAGE_KEY, 'not json') + expect(savedKeyExists()).toBe(false) + expect(getPlainLinkingKey()).toBeNull() + }) +}) + +describe('owner marker on new writes', () => { + it('matches the canonical owner derived from the saved linking key', async () => { + // Given a newly saved owner-bearing key + await saveLinkingKey(LINKING_KEY) + + // When its freshly derived linking key is compared + const matches = savedKeyOwnerMatches(LINKING_KEY) + + // Then the saved owner matches + expect(matches).toBe(true) + }) + + it('does not match a different linking key', async () => { + // Given a key owned by this wallet + await saveLinkingKey(LINKING_KEY) + + // When a foreign freshly derived linking key is compared + const matches = savedKeyOwnerMatches(OTHER_KEY) + + // Then the foreign key is rejected + expect(matches).toBe(false) + }) + + it('stamps the same canonical owner on plaintext and encrypted saves', async () => { + await saveLinkingKey(LINKING_KEY) + const plainOwner = savedKeyOwnerId() + expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY)) + + await saveLinkingKey(LINKING_KEY, PASSWORD) + expect(savedKeyOwnerId()).toBe(plainOwner) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + }) + + it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => { + await saveLinkingKey(LINKING_KEY) + expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/) + }) +}) + +describe('owner marker on legacy records', () => { + it('reads a legacy record without ownerId as ownerless', async () => { + saveLegacyPlaintext(LINKING_KEY) + expect(savedKeyOwnerId()).toBeNull() + // the record itself stays a fully valid saved key + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + + await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + expect(savedKeyOwnerId()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) + + it('rejects malformed owner-bearing records instead of treating them as legacy', () => { + const real = linkingPubKeyHex(LINKING_KEY) + const junk: unknown[] = [ + real.slice(1), // wrong length (65) + real + '00', // wrong length (68) + real.toUpperCase(), // uppercase hex + 'zz' + real.slice(2), // non-hex + '04' + real.slice(2), // not a compressed-pubkey prefix + '02' + 'ff'.repeat(32), // hex of the right length, not a curve point + 42, // wrong type + null, + {pubkey: real}, + '', + ] + for (const ownerId of junk) { + saveLegacyPlaintext(LINKING_KEY) + localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId})) + // the junk marker never reads as an owner... + expect(savedKeyOwnerId()).toBeNull() + // ...or downgrades to an adoptable ownerless legacy record + expect(savedKeyExists()).toBe(false) + expect(getPlainLinkingKey()).toBeNull() + } + }) + + it('stamps the owner after a password unlock without touching the ciphertext', async () => { + // Given a legacy encrypted record with no owner marker + const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + + // When ownership is proven by a successful unlock and then stamped + const linkingKey = await decryptSavedLinkingKey(PASSWORD) + ensureSavedKeyOwner(linkingKey) + + // Then the marker names the proven key and every ciphertext byte is + // preserved + const after = readRawRecord() + expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) + expect(after.ciphertext).toBe(before.ciphertext) + expect(after.salt).toBe(before.salt) + expect(after.iv).toBe(before.iv) + }) + + it('stamps a plaintext legacy record after a plaintext unlock', () => { + saveLegacyPlaintext(LINKING_KEY) + const linkingKey = getPlainLinkingKey() + if (linkingKey === null) throw new Error('expected a plaintext key') + ensureSavedKeyOwner(linkingKey) + + expect(readRawRecord()).toEqual({ + enc: false, + value: bytesToHex(LINKING_KEY), + version: 1, + ownerId: linkingPubKeyHex(LINKING_KEY), + }) + }) + + it('is idempotent - stamping twice leaves storage untouched after the first write', async () => { + const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) + const linkingKey = await decryptSavedLinkingKey(PASSWORD) + + ensureSavedKeyOwner(linkingKey) + const afterFirst = localStorage.getItem(STORAGE_KEY) + ensureSavedKeyOwner(linkingKey) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst) + expect(readRawRecord().ciphertext).toBe(before.ciphertext) + }) + + it('writes nothing when a new-format record is already correctly stamped', async () => { + await saveLinkingKey(LINKING_KEY, PASSWORD) + const raw = localStorage.getItem(STORAGE_KEY) + ensureSavedKeyOwner(LINKING_KEY) + expect(localStorage.getItem(STORAGE_KEY)).toBe(raw) + }) + + it('refuses to restamp a record owned by a different wallet', async () => { + await saveLinkingKey(OTHER_KEY, PASSWORD) + const before = localStorage.getItem(STORAGE_KEY) + + expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() + // the failed stamp leaves the record - marker included - untouched + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY)) + }) + + it('refuses to stamp a key that contradicts a plaintext record', () => { + saveLegacyPlaintext(OTHER_KEY) + expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() + expect(savedKeyOwnerId()).toBeNull() + }) + + it('does not adopt a record carrying a junk owner marker', () => { + saveLegacyPlaintext(LINKING_KEY) + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({...readRawRecord(), ownerId: 'obviously junk'}), + ) + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyOwnerId()).toBeNull() + expect(savedKeyExists()).toBe(false) + }) + + it('is a no-op when no record is saved at all', () => { + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyExists()).toBe(false) + }) +}) + +describe('owner marker on restore', () => { + it('strips the unproven ownerId a restored record arrives with', async () => { + // a backup file can claim any marker - only a freshly derived key may + // establish ownership, so restore installs the secret parts alone + restoreLinkingKeyStored({ + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(OTHER_KEY), + }) + expect(savedKeyOwnerId()).toBeNull() + expect(getPlainLinkingKey()).toEqual(LINKING_KEY) + + // the first proven unlock then establishes the true owner + ensureSavedKeyOwner(LINKING_KEY) + expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) + }) + + it('strips a junk ownerId from a restored encrypted record', async () => { + const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) + restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42}) + expect(savedKeyOwnerId()).toBeNull() + expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) + }) +}) + +describe('isWalletOwnerId', () => { + it('accepts exactly what linkingPubKeyHex produces', () => { + expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true) + expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true) + }) + + it('rejects everything else', () => { + const real = linkingPubKeyHex(LINKING_KEY) + expect(isWalletOwnerId(real.toUpperCase())).toBe(false) + expect(isWalletOwnerId(real.slice(0, 64))).toBe(false) + expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false) + expect(isWalletOwnerId(66)).toBe(false) + expect(isWalletOwnerId(undefined)).toBe(false) + expect(isWalletOwnerId(null)).toBe(false) + }) +}) diff --git a/src/lnurlcash/keys.ts b/src/lnurlcash/keys.ts index b901df9..604553c 100644 --- a/src/lnurlcash/keys.ts +++ b/src/lnurlcash/keys.ts @@ -1,8 +1,4 @@ -import { - mnemonicToSeedSync, - generateMnemonic, - validateMnemonic -} from '@scure/bip39' +import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39' import {wordlist} from '@scure/bip39/wordlists/english.js' import {HDKey, HARDENED_OFFSET} from '@scure/bip32' import {hmac} from '@noble/hashes/hmac.js' @@ -10,6 +6,20 @@ import {sha256} from '@noble/hashes/sha2.js' import {secp256k1} from '@noble/curves/secp256k1.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import { + parseStoredSecret, + stampStoredSecretOwner, + storedSecretClaimedOwnerId, + storedSecretOwnerId, + stripStoredSecretOwner, + STORED_SECRET_VERSION, + type StoredSecret, +} from './storage/storedSecret' +import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' + +export {isValidStoredSecret} from './storage/storedSecret' +export type {StoredSecret} from './storage/storedSecret' + // The wallet's identity is derived against this fixed domain rather than // window.location.hostname, so the same seed phrase always yields the same // linking key (and thus decrypts the same bearer tokens) no matter where @@ -30,16 +40,12 @@ 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 => { +export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => { const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) const master = HDKey.fromMasterSeed(seed) const hashingKeyNode = master.derive("m/138'/0") - if (!hashingKeyNode.privateKey) - throw new Error('Could not derive hashing key') + if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key') const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain) // path suffix longs are raw BIP32 child indices: whether each level ends up @@ -55,12 +61,9 @@ export const deriveLud05LinkingKey = ( // 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 -export const lud05PathSuffix = ( - hashingKey: Uint8Array, - domain: string -): number[] => { +export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => { const material = hmac(sha256, hashingKey, utf8ToBytes(domain)) - return [0, 4, 8, 12].map(i => readUint32BE(material, i)) + return [0, 4, 8, 12].map((i) => readUint32BE(material, i)) } export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => @@ -75,56 +78,21 @@ export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string => // GCM's auth tag doubles as the "wrong password" check on decrypt. const PBKDF2_ITERATIONS = 210_000 -export type StoredSecret = - | {enc: false; value: string} - | {enc: true; salt: string; iv: string; ciphertext: string} - -// strict shape check on a StoredSecret - a plaintext form must be exactly a -// 32-byte hex key, an encrypted form must carry hex salt/iv/ciphertext of -// the sizes encryptSecretParts produces. Guards the backup-restore path -// (storage.ts's applyBackup), where a crafted file would otherwise get an -// arbitrary "linking key" installed verbatim. -export const isValidStoredSecret = ( - stored: unknown -): stored is StoredSecret => { - if (typeof stored !== 'object' || stored === null) return false - const s = stored as Record - if (s.enc === false) { - return typeof s.value === 'string' && /^[0-9a-f]{64}$/i.test(s.value) - } - if (s.enc === true) { - return ( - typeof s.salt === 'string' && - /^[0-9a-f]{32}$/i.test(s.salt) && - typeof s.iv === 'string' && - /^[0-9a-f]{24}$/i.test(s.iv) && - typeof s.ciphertext === 'string' && - s.ciphertext.length > 0 && - s.ciphertext.length % 2 === 0 && - /^[0-9a-f]+$/i.test(s.ciphertext) - ) - } - return false -} - const readSecret = (storageKey: string): StoredSecret | null => { const raw = localStorage.getItem(storageKey) if (!raw) return null try { const parsed: unknown = JSON.parse(raw) - return isValidStoredSecret(parsed) ? parsed : null + return parseStoredSecret(parsed)?.secret ?? null } catch { return null } } -const deriveAesKeyFromPassword = ( - password: string, - salt: Uint8Array -): Promise => +const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise => crypto.subtle .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) - .then(baseKey => + .then((baseKey) => crypto.subtle.deriveKey( // the copy pins the TS type to Uint8Array - hexToBytes // returns Uint8Array, which BufferSource rejects @@ -132,13 +100,13 @@ const deriveAesKeyFromPassword = ( name: 'PBKDF2', salt: new Uint8Array(salt), iterations: PBKDF2_ITERATIONS, - hash: 'SHA-256' + hash: 'SHA-256', }, baseKey, {name: 'AES-GCM', length: 256}, false, - ['encrypt', 'decrypt'] - ) + ['encrypt', 'decrypt'], + ), ) export type EncryptedSecretParts = { @@ -149,29 +117,25 @@ export type EncryptedSecretParts = { export const encryptSecretParts = async ( value: string, - password: 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) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)), ) return { salt: bytesToHex(salt), iv: bytesToHex(iv), - ciphertext: bytesToHex(ciphertext) + ciphertext: bytesToHex(ciphertext), } } // rejects (WebCrypto's own auth-tag check) if the password is wrong export const decryptSecretParts = async ( parts: EncryptedSecretParts, - password: string + password: string, ): Promise => { const salt = hexToBytes(parts.salt) const iv = hexToBytes(parts.iv) @@ -179,7 +143,7 @@ export const decryptSecretParts = async ( const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv}, aesKey, - hexToBytes(parts.ciphertext) + hexToBytes(parts.ciphertext), ) return new TextDecoder().decode(plaintext) } @@ -188,17 +152,64 @@ export const decryptSecretParts = async ( // 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. -const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' +// +// The record also carries an ownerId marker: the lowercase compressed +// pubkey hex of the key itself (storage/walletOwner.ts), binding every +// other wallet-owned record (passkeys, NWC, trusted mints) to this exact +// identity. Failure modes of the marker API: +// - new writes always carry the marker derived from the key being saved; +// - a record restored from a backup/relay is installed OWNERLESS - its +// file-carried marker is an unproven claim and is stripped on restore; +// - an ownerless legacy record stays usable but cannot establish ownership; +// malformed or unsupported owner-bearing metadata rejects the whole record; +// - ensureSavedKeyOwner stamps the marker after the caller proved the key +// (successful password/plaintext unlock or matching biometric unwrap), +// preserving ciphertext byte-for-byte; it refuses to restamp a record +// already owned by a different valid owner, and refuses a key that +// contradicts a plaintext record. +export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null -export const savedKeyExists = (): boolean => - readSecret(LINKING_KEY_STORAGE_KEY) !== null - -export const savedKeyIsEncrypted = (): boolean => - readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true +export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true export const getSavedLinkingKeyStored = (): StoredSecret | null => readSecret(LINKING_KEY_STORAGE_KEY) +// the proven owner of the saved key, or null when there is no record or it +// carries no current version-1 marker (ownerless or compatible unversioned) +export const savedKeyOwnerId = (): string | null => { + const stored = readSecret(LINKING_KEY_STORAGE_KEY) + return stored === null ? null : storedSecretOwnerId(stored) +} + +// Compares only against an owner freshly derived from a linking key. An +// ownerless or unversioned saved marker never matches. +export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean => + savedKeyOwnerId() === linkingPubKeyHex(linkingKey) + +// Stamps the owner marker onto the existing record. Call ONLY with the key +// just proven against this record (decryptSavedLinkingKey / a plaintext +// read / a biometric unwrap whose stored pubkey matched) - the marker is +// derived from that key, never from a stored claim. No saved record or an +// already-correct marker: no write. A DIFFERENT valid owner, or a key that +// contradicts a plaintext record, throws and leaves storage untouched. +export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => { + const stored = readSecret(LINKING_KEY_STORAGE_KEY) + if (stored === null) return + const ownerId = linkingPubKeyHex(linkingKey) + if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) { + throw new Error('Proven key does not match the saved wallet key.') + } + const claimedOwnerId = storedSecretClaimedOwnerId(stored) + if (storedSecretOwnerId(stored) === ownerId) return + if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { + throw new Error('Saved wallet key is owned by a different wallet.') + } + localStorage.setItem( + LINKING_KEY_STORAGE_KEY, + JSON.stringify(stampStoredSecretOwner(stored, ownerId)), + ) +} + export const getPlainLinkingKey = (): Uint8Array | null => { const stored = readSecret(LINKING_KEY_STORAGE_KEY) if (stored === null || stored.enc === true) return null @@ -207,30 +218,33 @@ export const getPlainLinkingKey = (): Uint8Array | null => { export const saveLinkingKey = async ( linkingPrivKey: Uint8Array, - password?: string + password?: string, ): Promise => { const hex = bytesToHex(linkingPrivKey) + const ownerId = linkingPubKeyHex(linkingPrivKey) if (!password) { localStorage.setItem( LINKING_KEY_STORAGE_KEY, - JSON.stringify({enc: false, value: hex}) + JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}), ) return } const parts = await encryptSecretParts(hex, password) localStorage.setItem( LINKING_KEY_STORAGE_KEY, - JSON.stringify({enc: true, ...parts}) + JSON.stringify({enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION}), ) } +// installs a record from a backup/relay. Any ownerId it carries is an +// unproven claim by whoever produced that file, so the marker is stripped +// here - the first proven unlock re-establishes it (see the failure-model +// comment above) export const restoreLinkingKeyStored = (stored: StoredSecret): void => { - localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored)) + localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored))) } -export const decryptSavedLinkingKey = async ( - password: string -): Promise => { +export const decryptSavedLinkingKey = async (password: string): Promise => { const stored = readSecret(LINKING_KEY_STORAGE_KEY) if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.') return hexToBytes(await decryptSecretParts(stored, password)) @@ -246,43 +260,32 @@ export const clearSavedLinkingKey = (): void => { // a fresh device and every previously exported ciphertext decrypts again. const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1' -export const deriveBearerAesKey = ( - linkingPrivKey: Uint8Array -): Promise => { - const material = sha256( - new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]) - ) - return crypto.subtle.importKey('raw', material, 'AES-GCM', false, [ - 'encrypt', - 'decrypt' - ]) +export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise => { + const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])) + return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']) } export type EncryptedRecordParts = {iv: string; ciphertext: string} export const encryptRecord = async ( aesKey: CryptoKey, - value: object + value: object, ): Promise => { const iv = crypto.getRandomValues(new Uint8Array(12)) const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - {name: 'AES-GCM', iv}, - aesKey, - utf8ToBytes(JSON.stringify(value)) - ) + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))), ) return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)} } -export const decryptRecord = async ( +export const decryptRecord = async ( aesKey: CryptoKey, - parts: EncryptedRecordParts -): Promise => { + parts: EncryptedRecordParts, +): Promise => { const plaintext = await crypto.subtle.decrypt( {name: 'AES-GCM', iv: hexToBytes(parts.iv)}, aesKey, - hexToBytes(parts.ciphertext) + hexToBytes(parts.ciphertext), ) - return JSON.parse(new TextDecoder().decode(plaintext)) as T + return JSON.parse(new TextDecoder().decode(plaintext)) } diff --git a/src/lnurlcash/keys.version.cases.ts b/src/lnurlcash/keys.version.cases.ts new file mode 100644 index 0000000..fd686f5 --- /dev/null +++ b/src/lnurlcash/keys.version.cases.ts @@ -0,0 +1,102 @@ +import {beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import { + ensureSavedKeyOwner, + getPlainLinkingKey, + isValidStoredSecret, + linkingPubKeyHex, + savedKeyExists, + savedKeyOwnerId, + saveLinkingKey, +} from './keys' +import {parseJsonObject, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const STORAGE_KEY = 'sattle_linking_key' + +const readRawRecord = (): Record => { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) throw new Error('expected a saved linking-key record') + return parseJsonObject(raw) +} + +beforeEach(() => { + stubLocalStorage() +}) + +describe('saved-key schema version', () => { + it('writes version 1 on current plaintext and encrypted records', async () => { + // Given a linking key saved through each current persistence path + await saveLinkingKey(LINKING_KEY) + const plaintext = readRawRecord() + await saveLinkingKey(LINKING_KEY, 'correct horse') + const encrypted = readRawRecord() + + // When the persisted schema metadata is inspected + // Then both owner-bearing records carry the recognized discriminator + expect(plaintext.version).toBe(1) + expect(encrypted.version).toBe(1) + }) + + it('upgrades an unversioned same-owner record only after key proof', () => { + // Given the valid owner-bearing shape written before schema versioning + const ownerId = linkingPubKeyHex(LINKING_KEY) + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}), + ) + + // When it is read before and then stamped after the plaintext key proves ownership + expect(savedKeyOwnerId()).toBeNull() + const provenKey = getPlainLinkingKey() + if (provenKey === null) throw new Error('expected the compatible plaintext key') + ensureSavedKeyOwner(provenKey) + + // Then it becomes an explicitly versioned current record + expect(readRawRecord()).toEqual({ + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId, + version: 1, + }) + }) + + it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => { + // Given an otherwise valid owner-bearing record with unrecognized metadata + const record = { + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(LINKING_KEY), + version, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + + // When the saved-key boundary parses it + // Then the record is neither usable nor eligible for legacy adoption + expect(isValidStoredSecret(record)).toBe(false) + expect(savedKeyExists()).toBe(false) + expect(savedKeyOwnerId()).toBeNull() + expect(getPlainLinkingKey()).toBeNull() + }) + + it('rejects a foreign current owner at the proven-key stamping boundary', () => { + // Given a versioned record whose owner conflicts with its plaintext key + const record = { + enc: false, + value: bytesToHex(LINKING_KEY), + ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), + version: 1, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) + const before = localStorage.getItem(STORAGE_KEY) + + // When the actual key is proven + const stamp = () => ensureSavedKeyOwner(LINKING_KEY) + + // Then the foreign claim fails closed without rewriting storage + expect(stamp).toThrow('different wallet') + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + }) +}) diff --git a/src/lnurlcash/storage/storedSecret.ts b/src/lnurlcash/storage/storedSecret.ts new file mode 100644 index 0000000..a053f1c --- /dev/null +++ b/src/lnurlcash/storage/storedSecret.ts @@ -0,0 +1,130 @@ +// 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. + +import {isJsonObject} from '../jsonParsing' +import {isWalletOwnerId} from './walletOwner' + +export const STORED_SECRET_VERSION = 1 as const + +type PlainStoredSecret = { + readonly enc: false + readonly value: string + readonly ownerId?: unknown + readonly version?: unknown +} + +type EncryptedStoredSecret = { + readonly enc: true + readonly salt: string + readonly iv: string + readonly ciphertext: string + readonly ownerId?: unknown + readonly version?: unknown +} + +export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret + +type ParsedStoredSecret = { + readonly secret: StoredSecret + readonly claimedOwnerId: string | null + readonly isCurrent: boolean +} + +const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const +const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] 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 => { + 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) || + !hasOnlyKeys(stored, PLAIN_KEYS) + ) { + return null + } + secret = {enc: false, value: stored.value} + } else if (stored.enc === true) { + if ( + typeof stored.salt !== 'string' || + !/^[0-9a-f]{32}$/i.test(stored.salt) || + typeof stored.iv !== 'string' || + !/^[0-9a-f]{24}$/i.test(stored.iv) || + typeof stored.ciphertext !== 'string' || + stored.ciphertext.length === 0 || + stored.ciphertext.length % 2 !== 0 || + !/^[0-9a-f]+$/i.test(stored.ciphertext) || + !hasOnlyKeys(stored, ENCRYPTED_KEYS) + ) { + return null + } + secret = { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + } + } else { + return null + } + + const hasOwner = Object.hasOwn(stored, 'ownerId') + const hasVersion = Object.hasOwn(stored, 'version') + if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false} + if (!isWalletOwnerId(stored.ownerId)) return null + if (!hasVersion) { + return { + secret: {...secret, ownerId: stored.ownerId}, + claimedOwnerId: stored.ownerId, + isCurrent: false, + } + } + if (stored.version !== STORED_SECRET_VERSION) return null + return { + secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION}, + claimedOwnerId: stored.ownerId, + isCurrent: true, + } +} + +export const isValidStoredSecret = (stored: unknown): stored is StoredSecret => + parseStoredSecret(stored) !== null + +export const storedSecretOwnerId = (stored: StoredSecret): string | null => { + const parsed = parseStoredSecret(stored) + return parsed?.isCurrent === true ? parsed.claimedOwnerId : null +} + +export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null => + parseStoredSecret(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} + } + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + ownerId, + version: STORED_SECRET_VERSION, + } +} + +export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => { + if (stored.enc === false) return {enc: false, value: stored.value} + return { + enc: true, + salt: stored.salt, + iv: stored.iv, + ciphertext: stored.ciphertext, + } +} diff --git a/src/lnurlcash/storage/walletOwner.ts b/src/lnurlcash/storage/walletOwner.ts new file mode 100644 index 0000000..d430b60 --- /dev/null +++ b/src/lnurlcash/storage/walletOwner.ts @@ -0,0 +1,36 @@ +// Wallet owner marker. The saved linking-key record (and, in later work, +// every credential-ish record: passkey slots, NWC connections, the +// trusted-mint registry) carries an ownerId binding it to exactly one +// wallet identity, so a restored or foreign wallet can never inherit +// residue from a previous one and a stale tab cannot act for a replaced +// owner. +// +// The canonical ownerId is linkingPubKeyHex(linkingKey): the lowercase +// 66-char compressed secp256k1 pubkey hex of the wallet's LUD-05 linking +// key. It is a PUBLIC value - knowing it proves nothing. That is why a +// marker may only be WRITTEN from a freshly derived or freshly proven key +// (a new save, or ensureSavedKeyOwner after a successful unlock) and never +// copied from a backup file, a credential id, or any other stored claim. +// +// Failure model: localStorage is hand-editable and backups are hostile +// input, so a marker is never trusted on presence alone. Anything that is +// not byte-exactly a valid compressed-pubkey hex - wrong length, wrong +// case, non-hex, off-curve, non-string - is rejected by its owning schema. +// Only a record with no ownership metadata at all is legacy ownerless data; +// malformed or future metadata must never be downgraded into that adoptable +// path. + +import {secp256k1} from '@noble/curves/secp256k1.js' + +// strict shape AND curve check: exactly what linkingPubKeyHex can produce +export const isWalletOwnerId = (ownerId: unknown): ownerId is string => { + if (typeof ownerId !== 'string' || !/^0[23][0-9a-f]{64}$/.test(ownerId)) { + return false + } + try { + secp256k1.Point.fromHex(ownerId) + return true + } catch { + return false + } +}