diff --git a/src/lnurlcash/trustedMintTransitions.ts b/src/lnurlcash/trustedMintTransitions.ts new file mode 100644 index 0000000..ef3287f --- /dev/null +++ b/src/lnurlcash/trustedMintTransitions.ts @@ -0,0 +1,211 @@ +// Pure trusted-mint registry transitions. Persistence serializes these +// operations, while this module keeps pinning and backup policy auditable. + +import type {TrustedMint, TrustedMintNodeInfo, TrustKeyResult} from './trustedMints' + +const PUBKEY_PATTERN = /^[0-9a-f]{66}$/ + +export const isValidMintPubkey = (value: string): boolean => + PUBKEY_PATTERN.test(value.toLowerCase()) + +export type MintTransition = { + readonly mints: TrustedMint[] + readonly result: T + readonly changed: boolean +} + +type MintKeyInput = { + readonly server: string + readonly mintPubkey: string +} + +type AddMintInput = MintKeyInput & { + readonly nodeInfo?: TrustedMintNodeInfo +} + +const unchanged = (mints: TrustedMint[], result: T): MintTransition => ({ + mints, + result, + changed: false, +}) + +const changed = (mints: TrustedMint[], result: T): MintTransition => ({ + mints, + result, + changed: true, +}) + +export const lockMint = ( + mints: TrustedMint[], + input: MintKeyInput, +): MintTransition => { + const key = input.mintPubkey.trim().toLowerCase() + if (!input.server || !isValidMintPubkey(key)) { + return unchanged(mints, 'unchanged') + } + const existing = mints.find((mint) => mint.server === input.server) + if (!existing) { + return changed( + [ + ...mints, + { + server: input.server, + mintPubkey: key, + addedAt: Date.now(), + locked: true, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) { + if (existing.locked && !existing.unconfirmed) { + return unchanged(mints, 'unchanged') + } + return changed( + mints.map((mint) => + mint.server === input.server ? {...mint, locked: true, unconfirmed: undefined} : mint, + ), + 'unchanged', + ) + } + if (existing.pendingMintPubkey === key) { + return unchanged(mints, 'rekey-pending') + } + return changed( + mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)), + 'rekey-pending', + ) +} + +export const grandfatherMint = ( + mints: TrustedMint[], + input: MintKeyInput, +): MintTransition => { + const key = input.mintPubkey.trim().toLowerCase() + if (!input.server || !isValidMintPubkey(key)) { + return unchanged(mints, 'unchanged') + } + const existing = mints.find((mint) => mint.server === input.server) + if (!existing) { + return changed( + [ + ...mints, + { + server: input.server, + mintPubkey: key, + addedAt: Date.now(), + locked: false, + unconfirmed: true, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) return unchanged(mints, 'unchanged') + if (existing.pendingMintPubkey === key) { + return unchanged(mints, 'rekey-pending') + } + return changed( + mints.map((mint) => (mint.server === input.server ? {...mint, pendingMintPubkey: key} : mint)), + 'rekey-pending', + ) +} + +export const addMint = ( + mints: TrustedMint[], + input: AddMintInput, +): MintTransition => { + const server = input.server.trim() + const key = input.mintPubkey.trim().toLowerCase() + if (!server) throw new Error('Enter a server.') + if (!isValidMintPubkey(key)) { + throw new Error('Signing key must be a 33-byte compressed pubkey (66 hex characters).') + } + const existing = mints.find((mint) => mint.server === server) + if (!existing) { + return changed( + [ + ...mints, + { + server, + mintPubkey: key, + addedAt: Date.now(), + locked: false, + ...input.nodeInfo, + }, + ], + 'added', + ) + } + if (existing.mintPubkey === key) { + return changed( + mints.map((mint) => + mint.server === server ? {...mint, ...input.nodeInfo, unconfirmed: undefined} : mint, + ), + 'unchanged', + ) + } + return changed( + mints.map((mint) => + mint.server === server ? {...mint, pendingMintPubkey: key, ...input.nodeInfo} : mint, + ), + 'rekey-pending', + ) +} + +export const confirmMintRekey = (mints: TrustedMint[], server: string): MintTransition => { + const pending = mints.find((mint) => mint.server === server)?.pendingMintPubkey + if (!pending) return unchanged(mints, undefined) + return changed( + mints.map((mint) => + mint.server === server + ? { + ...mint, + mintPubkey: pending, + pendingMintPubkey: undefined, + unconfirmed: undefined, + } + : mint, + ), + undefined, + ) +} + +export const dismissMintRekey = (mints: TrustedMint[], server: string): MintTransition => { + if (!mints.some((mint) => mint.server === server)) { + return unchanged(mints, undefined) + } + return changed( + mints.map((mint) => (mint.server === server ? {...mint, pendingMintPubkey: undefined} : mint)), + undefined, + ) +} + +export const cacheMintNodeInfo = ( + mints: TrustedMint[], + server: string, + nodeInfo: TrustedMintNodeInfo, +): MintTransition => { + if (!mints.some((mint) => mint.server === server)) { + return unchanged(mints, undefined) + } + return changed( + mints.map((mint) => (mint.server === server ? {...mint, ...nodeInfo} : mint)), + undefined, + ) +} + +export const removeMint = (mints: TrustedMint[], server: string): MintTransition => { + const existing = mints.find((mint) => mint.server === server) + if (!existing) return unchanged(mints, undefined) + if (existing.locked) { + throw new Error("Can't remove - you hold a bearer note from this mint.") + } + return changed( + mints.filter((mint) => mint.server !== server), + undefined, + ) +} + +export const clearMints = (): MintTransition => changed([], undefined) diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts index 0d5e54f..28b88e9 100644 --- a/src/lnurlcash/trustedMints.test.ts +++ b/src/lnurlcash/trustedMints.test.ts @@ -3,11 +3,11 @@ import {beforeEach, describe, expect, it} from 'vitest' +import {linkingPubKeyHex, saveLinkingKey} from './keys' import type {TrustedMint} from './trustedMints' import { PUBLIC_MINTS, addTrustedMint, - clearTrustedMints, confirmTrustedMintRekey, dismissTrustedMintRekey, getTrustedMintPubkey, @@ -17,7 +17,7 @@ import { lockTrustedMint, mergeTrustedMints, readTrustedMints, - removeTrustedMint + removeTrustedMint, } from './trustedMints' import {stubLocalStorage} from './test-utils' @@ -25,115 +25,123 @@ const KEY_A = '02' + 'aa'.repeat(32) const KEY_B = '03' + 'bb'.repeat(32) const KEY_C = '02' + 'cc'.repeat(32) const SERVER = 'mint.example' +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) -beforeEach(() => { +beforeEach(async () => { stubLocalStorage() - clearTrustedMints() + await saveLinkingKey(LINKING_KEY) }) +const onlyMint = (): TrustedMint => { + const mint = readTrustedMints(OWNER_ID)[0] + if (!mint) throw new Error('Expected one trusted mint.') + return mint +} + describe('pinning', () => { - it('locks a mint the first time a bearer is held from it', () => { - expect(lockTrustedMint(SERVER, KEY_A)).toBe('added') - expect(isMintTrusted(SERVER)).toBe(true) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) - expect(readTrustedMints()[0]!.locked).toBe(true) + it('locks a mint the first time a bearer is held from it', async () => { + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added') + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) + expect(onlyMint().locked).toBe(true) // same key again: silent no-op - expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged') }) - it('rejects a malformed signing key without throwing', () => { - expect(lockTrustedMint(SERVER, 'not-a-key')).toBe('unchanged') - expect(isMintTrusted(SERVER)).toBe(false) + it('rejects a malformed signing key without throwing', async () => { + expect(await lockTrustedMint(SERVER, 'not-a-key', OWNER_ID)).toBe('unchanged') + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false) }) }) describe('rekey staging', () => { - it('stages a differing advertised key for review, never auto-applies it', () => { - lockTrustedMint(SERVER, KEY_A) + it('stages a differing advertised key for review, never auto-applies it', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) - expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - const mint = readTrustedMints()[0]! + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + const mint = onlyMint() // the staged candidate is visible, but the ORIGINAL pin is still // authoritative - this is the entire point of the staging model expect(mint.pendingMintPubkey).toBe(KEY_B) expect(mint.mintPubkey).toBe(KEY_A) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) // re-advertising the same pending key doesn't duplicate or escalate - expect(lockTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().mintPubkey).toBe(KEY_A) // and a THIRD key replaces the staged candidate, still not the pin - expect(lockTrustedMint(SERVER, KEY_C)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.pendingMintPubkey).toBe(KEY_C) - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + expect(await lockTrustedMint(SERVER, KEY_C, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().pendingMintPubkey).toBe(KEY_C) + expect(onlyMint().mintPubkey).toBe(KEY_A) }) - it('promotes the staged key only on explicit holder confirmation', () => { - lockTrustedMint(SERVER, KEY_A) - lockTrustedMint(SERVER, KEY_B) + it('promotes the staged key only on explicit holder confirmation', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) - confirmTrustedMintRekey(SERVER) - const mint = readTrustedMints()[0]! + await confirmTrustedMintRekey(SERVER, OWNER_ID) + const mint = onlyMint() expect(mint.mintPubkey).toBe(KEY_B) expect(mint.pendingMintPubkey).toBeUndefined() - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_B) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) }) - it('drops the staged key on dismissal, keeping the original pin', () => { - lockTrustedMint(SERVER, KEY_A) - lockTrustedMint(SERVER, KEY_B) + it('drops the staged key on dismissal, keeping the original pin', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await lockTrustedMint(SERVER, KEY_B, OWNER_ID) - dismissTrustedMintRekey(SERVER) - const mint = readTrustedMints()[0]! + await dismissTrustedMintRekey(SERVER, OWNER_ID) + const mint = onlyMint() expect(mint.pendingMintPubkey).toBeUndefined() expect(mint.mintPubkey).toBe(KEY_A) }) - it('stages a rekey even through unlock-time grandfathering', () => { - grandfatherTrustedMint(SERVER, KEY_A) - expect(grandfatherTrustedMint(SERVER, KEY_B)).toBe('rekey-pending') - expect(readTrustedMints()[0]!.mintPubkey).toBe(KEY_A) + it('stages a rekey even through unlock-time grandfathering', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(await grandfatherTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(onlyMint().mintPubkey).toBe(KEY_A) }) }) describe('grandfathering (storage-sourced claims)', () => { - it('adds an unknown server unlocked and unconfirmed', () => { - expect(grandfatherTrustedMint(SERVER, KEY_A)).toBe('added') - const mint = readTrustedMints()[0]! + it('adds an unknown server unlocked and unconfirmed', async () => { + expect(await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('added') + const mint = onlyMint() expect(mint.locked).toBe(false) expect(mint.unconfirmed).toBe(true) // unconfirmed pins stay out of offline signature verification - expect(getTrustedMintPubkey(SERVER)).toBeNull() - expect(isMintUnconfirmed(SERVER)).toBe(true) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBeNull() + expect(isMintUnconfirmed(SERVER, OWNER_ID)).toBe(true) }) - it('is corroborated and locked by a live response advertising the same key', () => { - grandfatherTrustedMint(SERVER, KEY_A) - expect(lockTrustedMint(SERVER, KEY_A)).toBe('unchanged') - const mint = readTrustedMints()[0]! + it('is corroborated and locked by a live response advertising the same key', async () => { + await grandfatherTrustedMint(SERVER, KEY_A, OWNER_ID) + expect(await lockTrustedMint(SERVER, KEY_A, OWNER_ID)).toBe('unchanged') + const mint = onlyMint() expect(mint.locked).toBe(true) expect(mint.unconfirmed).toBeUndefined() - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) }) }) describe('manual add and removal', () => { - it('validates input instead of silently no-oping', () => { - expect(() => addTrustedMint('', KEY_A)).toThrow() - expect(() => addTrustedMint(SERVER, 'junk')).toThrow() + it('validates input instead of silently no-oping', async () => { + await expect(addTrustedMint('', KEY_A, {ownerId: OWNER_ID})).rejects.toThrow() + await expect(addTrustedMint(SERVER, 'junk', {ownerId: OWNER_ID})).rejects.toThrow() }) - it('refuses to remove a mint locked by a held bearer', () => { - lockTrustedMint(SERVER, KEY_A) - expect(() => removeTrustedMint(SERVER)).toThrow(/bearer/) - expect(isMintTrusted(SERVER)).toBe(true) + it('refuses to remove a mint locked by a held bearer', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/) + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(true) }) - it('removes an unlocked mint', () => { - addTrustedMint(SERVER, KEY_A) - removeTrustedMint(SERVER) - expect(isMintTrusted(SERVER)).toBe(false) + it('removes an unlocked mint', async () => { + await addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_ID}) + await removeTrustedMint(SERVER, OWNER_ID) + expect(isMintTrusted(SERVER, OWNER_ID)).toBe(false) }) }) @@ -145,12 +153,12 @@ describe('backup merge', () => { locked: true, // must never survive a merge from a file pendingMintPubkey: KEY_C, // must never survive either nodeAlias: 'Backup Mint', - ...overrides + ...overrides, }) - it('merges unknown servers as unlocked, unconfirmed, and without staged keys', () => { - expect(mergeTrustedMints([fromFile()])).toBe(1) - const mint = readTrustedMints()[0]! + it('merges unknown servers as unlocked, unconfirmed, and without staged keys', async () => { + expect(await mergeTrustedMints([fromFile()], OWNER_ID)).toBe(1) + const mint = onlyMint() expect(mint.server).toBe('backup-mint.example') expect(mint.mintPubkey).toBe(KEY_B) expect(mint.locked).toBe(false) @@ -159,25 +167,48 @@ describe('backup merge', () => { expect(mint.nodeAlias).toBe('Backup Mint') }) - it('never overwrites a server this device already knows', () => { - lockTrustedMint(SERVER, KEY_A) - const added = mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})]) + it('never overwrites a server this device already knows', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + const added = await mergeTrustedMints([fromFile({server: SERVER, mintPubkey: KEY_B})], OWNER_ID) expect(added).toBe(0) - expect(getTrustedMintPubkey(SERVER)).toBe(KEY_A) + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) }) - it('skips malformed entries', () => { + it('skips malformed entries', async () => { // JSON round-trip: a backup file's entries are runtime data, not // compile-time TrustedMints - the merge must filter, not trust const malformed: TrustedMint[] = JSON.parse( - JSON.stringify([ - fromFile({mintPubkey: 'not-hex'}), - fromFile({server: 42}), - null - ]) + JSON.stringify([fromFile({mintPubkey: 'not-hex'}), fromFile({server: 42}), null]), ) - expect(mergeTrustedMints(malformed)).toBe(0) - expect(readTrustedMints()).toEqual([]) + expect(await mergeTrustedMints(malformed, OWNER_ID)).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + }) +}) + +describe('security policy characterization', () => { + it('keeps local pins authoritative and requires explicit rekey confirmation', async () => { + await lockTrustedMint(SERVER, KEY_A, OWNER_ID) + + expect( + await mergeTrustedMints( + [ + { + server: SERVER, + mintPubkey: KEY_C, + addedAt: 123, + locked: false, + }, + ], + OWNER_ID, + ), + ).toBe(0) + expect(await lockTrustedMint(SERVER, KEY_B, OWNER_ID)).toBe('rekey-pending') + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_A) + + await confirmTrustedMintRekey(SERVER, OWNER_ID) + + expect(getTrustedMintPubkey(SERVER, OWNER_ID)).toBe(KEY_B) + await expect(removeTrustedMint(SERVER, OWNER_ID)).rejects.toThrow(/bearer/) }) }) @@ -188,7 +219,7 @@ describe('PUBLIC_MINTS', () => { '@lnurl.21mint.me', '@mint.forgesworn.dev', '@lnurl.21linz.at', - '@minty.exe.xyz' + '@minty.exe.xyz', ]) }) }) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index f942110..6d903b5 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -1,10 +1,24 @@ import type {MintAddressInfo} from 'lnurlcash-kit' - -// allow: SIZE_OK — one indivisible registry: every operation below reads -// and writes the same pinned-key cache through the same persist/notify -// path, and the file is a deliberate verbatim-behavior port of -// lnurl-wallet's trustedMints.ts so the two wallets' pinning semantics -// stay auditable side by side. +import { + adoptLegacyStoredTrustedMints, + mutateStoredTrustedMints, + onStoredTrustedMintsChange, + readOwnedTrustedMints, + removeStoredTrustedMintsForOwner, + resetStoredTrustedMints, +} from './trustedMintsRepository' +import {linkingPubKeyHex, savedKeyOwnerId} from './keys' +import { + addMint, + cacheMintNodeInfo, + clearMints, + confirmMintRekey, + dismissMintRekey, + grandfatherMint, + lockMint, + removeMint, +} from './trustedMintTransitions' +import {mergeMints} from './trustedMintMerge' // A mint's signing key (LUD-25 Offline verification's `mintPubkey`) - not a // secret, just a public identity, so this is plain unencrypted localStorage, @@ -74,7 +88,7 @@ export type TrustedMintNodeInfo = { // resolved, not from that endpoint's response. export const mintAddressCacheInfo = ( info: MintAddressInfo | null, - username: string | null + username: string | null, ): TrustedMintNodeInfo | undefined => { if (!info && !username) return undefined return { @@ -83,7 +97,7 @@ export const mintAddressCacheInfo = ( nodeCapacityMsat: info?.nodeCapacityMsat, nodeNumChannels: info?.nodeNumChannels, nodeNumPeers: info?.nodeNumPeers, - username: username ?? undefined + username: username ?? undefined, } } @@ -99,81 +113,36 @@ export const PUBLIC_MINTS = [ '@lnurl.21mint.me', '@mint.forgesworn.dev', '@lnurl.21linz.at', - '@minty.exe.xyz' + '@minty.exe.xyz', ] -const STORAGE_KEY = 'sattle_trusted_mints' - -// 33-byte compressed secp256k1 pubkey, hex -const PUBKEY_PATTERN = /^[0-9a-f]{66}$/ - -const readStored = (): TrustedMint[] => { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return [] - try { - const parsed: unknown = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - // shape-check every entry - this is the wallet's own persisted state - // (so locked/pendingMintPubkey/unconfirmed are all kept), but a - // tampered or corrupt record must not plant junk entries - return parsed.filter( - (m): m is TrustedMint => - typeof m?.server === 'string' && - typeof m?.mintPubkey === 'string' && - PUBKEY_PATTERN.test(m.mintPubkey.toLowerCase()) && - typeof m?.addedAt === 'number' && - typeof m?.locked === 'boolean' - ) - } catch { - return [] - } -} - -// lazily initialized on first access: importing this module must not touch -// localStorage (plain-Node test environments have none until stubbed) -let cache: TrustedMint[] | null = null -const readCache = (): TrustedMint[] => { - cache ??= readStored() - return cache -} -const listeners = new Set<(mints: TrustedMint[]) => void>() - // the Pinia mints store subscribes here to mirror the registry into // reactive state; returns the unsubscribe -export const onTrustedMintsChange = ( - listener: (mints: TrustedMint[]) => void -): (() => void) => { - listeners.add(listener) - return () => listeners.delete(listener) -} +export const onTrustedMintsChange = (listener: () => void): (() => void) => + onStoredTrustedMintsChange(listener) -export const readTrustedMints = (): TrustedMint[] => readCache() +export const readTrustedMints = (ownerId?: string): TrustedMint[] => readOwnedTrustedMints(ownerId) -const persist = (mints: TrustedMint[]): void => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(mints)) - cache = mints - for (const listener of listeners) listener(mints) -} +export const isMintTrusted = (server: string, ownerId?: string): boolean => + readTrustedMints(ownerId).some((mint) => mint.server === server) -export const isMintTrusted = (server: string): boolean => - readCache().some(m => m.server === server) - -export const getTrustedMintPubkey = (server: string): string | null => - readCache().find(m => m.server === server && !m.unconfirmed)?.mintPubkey ?? null +export const getTrustedMintPubkey = (server: string, ownerId?: string): string | null => + readTrustedMints(ownerId).find((mint) => mint.server === server && !mint.unconfirmed) + ?.mintPubkey ?? null // true when a server has a pin that came from a file/storage rather than a // live response (see TrustedMint.unconfirmed) - callers should treat a // bearer's own cached mintPubkey for such a server as equally // uncorroborated -export const isMintUnconfirmed = (server: string): boolean => - readCache().some(m => m.server === server && m.unconfirmed) +export const isMintUnconfirmed = (server: string, ownerId?: string): boolean => + readTrustedMints(ownerId).some((mint) => mint.server === server && mint.unconfirmed) // this mint's self-reported node color, for tinting its notes' background - // purely cosmetic. Mint-supplied, so it's only ever handed out as a plain // hex color - anything else (a style sink can take far more than colors) is // treated as absent -export const getTrustedMintNodeColor = (server: string): string | null => { - const color = readCache().find(m => m.server === server)?.nodeColor +export const getTrustedMintNodeColor = (server: string, ownerId?: string): string | null => { + const color = readTrustedMints(ownerId).find((mint) => mint.server === server)?.nodeColor return color && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : null } @@ -182,8 +151,8 @@ export const getTrustedMintNodeColor = (server: string): string | null => { // guessing "mint@" - null for a mint with no cached username // (looked up as a bech32 LNURL, or trusted before this wallet learned to // remember one) -export const getTrustedMintAddress = (server: string): string | null => { - const username = readCache().find(m => m.server === server)?.username +export const getTrustedMintAddress = (server: string, ownerId?: string): string | null => { + const username = readTrustedMints(ownerId).find((mint) => mint.server === server)?.username return username ? `${username}@${server}` : null } @@ -193,6 +162,14 @@ export const getTrustedMintAddress = (server: string): string | null => { // silently replacing it. Callers should surface that loudly. export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending' +export type TrustedMintMutationContext = { + readonly ownerId: string +} + +export type AddTrustedMintContext = TrustedMintMutationContext & { + readonly nodeInfo?: TrustedMintNodeInfo +} + // Called whenever this wallet ends up holding (or already holds) a bearer // from `server` - minting, receiving, splitting, merging all route through // the wallet store's addBearers/updateBearer, which is where this gets @@ -204,32 +181,10 @@ export type TrustKeyResult = 'added' | 'unchanged' | 'rekey-pending' // or dismiss (see confirmTrustedMintRekey). export const lockTrustedMint = ( server: string, - mintPubkey: string -): TrustKeyResult => { - const key = mintPubkey.trim().toLowerCase() - if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' - const existing = readCache().find(m => m.server === server) - if (existing) { - if (existing.mintPubkey === key) { - if (existing.locked && !existing.unconfirmed) return 'unchanged' - // a match here is a live response from the server advertising this - // exact key - it corroborates an unconfirmed (file-sourced) pin - persist( - readCache().map(m => - m.server === server ? {...m, locked: true, unconfirmed: undefined} : m - ) - ) - return 'unchanged' - } - if (existing.pendingMintPubkey === key) return 'rekey-pending' - persist( - readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) - ) - return 'rekey-pending' - } - persist([...readCache(), {server, mintPubkey: key, addedAt: Date.now(), locked: true}]) - return 'added' -} + mintPubkey: string, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => lockMint(mints, {server, mintPubkey})) // unlock-time grandfathering of the mints behind already-stored bearers - // the key claims come from local storage, not a live response, so an @@ -240,25 +195,10 @@ export const lockTrustedMint = ( // lockTrustedMint instead, which is what corroborates and re-locks. export const grandfatherTrustedMint = ( server: string, - mintPubkey: string -): TrustKeyResult => { - const key = mintPubkey.trim().toLowerCase() - if (!server || !PUBKEY_PATTERN.test(key)) return 'unchanged' - const existing = readCache().find(m => m.server === server) - if (existing) { - if (existing.mintPubkey === key) return 'unchanged' - if (existing.pendingMintPubkey === key) return 'rekey-pending' - persist( - readCache().map(m => (m.server === server ? {...m, pendingMintPubkey: key} : m)) - ) - return 'rekey-pending' - } - persist([ - ...readCache(), - {server, mintPubkey: key, addedAt: Date.now(), locked: false, unconfirmed: true} - ]) - return 'added' -} + mintPubkey: string, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => grandfatherMint(mints, {server, mintPubkey})) // Manual add from the mints settings, or a user-confirmed first encounter - // unlocked, since no bearer necessarily backs it yet. Validates and throws @@ -270,81 +210,26 @@ export const grandfatherTrustedMint = ( export const addTrustedMint = ( server: string, mintPubkey: string, - nodeInfo?: TrustedMintNodeInfo -): TrustKeyResult => { - const trimmedServer = server.trim() - const key = mintPubkey.trim().toLowerCase() - if (!trimmedServer) { - throw new Error('Enter a server.') - } - if (!PUBKEY_PATTERN.test(key)) { - throw new Error( - 'Signing key must be a 33-byte compressed pubkey (66 hex characters).' - ) - } - const existing = readCache().find(m => m.server === trimmedServer) - if (existing) { - if (existing.mintPubkey === key) { - // a match here is a live lookup corroborating the pin - it clears an - // unconfirmed (file-sourced) flag - persist( - readCache().map(m => - m.server === trimmedServer - ? {...m, ...nodeInfo, unconfirmed: undefined} - : m - ) - ) - return 'unchanged' - } - persist( - readCache().map(m => - m.server === trimmedServer - ? {...m, pendingMintPubkey: key, ...nodeInfo} - : m - ) - ) - return 'rekey-pending' - } - persist([ - ...readCache(), - { - server: trimmedServer, - mintPubkey: key, - addedAt: Date.now(), - locked: false, - ...nodeInfo - } - ]) - return 'added' + context?: TrustedMintNodeInfo | AddTrustedMintContext, +): Promise => { + const ownerId = context && 'ownerId' in context ? context.ownerId : undefined + const nodeInfo = context && 'ownerId' in context ? context.nodeInfo : context + return mutateStoredTrustedMints(ownerId, (mints) => + addMint(mints, {server, mintPubkey, nodeInfo}), + ) } // the holder confirms a mint's advertised new signing key - the pending key // becomes the pinned one. Legitimate rotations (a mint moving to a new // node) go through here; nothing else ever replaces a pin. -export const confirmTrustedMintRekey = (server: string): void => { - const existing = readCache().find(m => m.server === server) - if (!existing?.pendingMintPubkey) return - const pending = existing.pendingMintPubkey - persist( - readCache().map(m => - m.server === server - ? {...m, mintPubkey: pending, pendingMintPubkey: undefined, unconfirmed: undefined} - : m - ) - ) -} +export const confirmTrustedMintRekey = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => confirmMintRekey(mints, server)) // the holder rejects the advertised new key - the staged candidate is // dropped, the original pin stays. Worth doing only when the change is // unexpected; the old key stays authoritative either way until confirmed. -export const dismissTrustedMintRekey = (server: string): void => { - if (!readCache().some(m => m.server === server)) return - persist( - readCache().map(m => - m.server === server ? {...m, pendingMintPubkey: undefined} : m - ) - ) -} +export const dismissTrustedMintRekey = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => dismissMintRekey(mints, server)) // refreshes just the cached display info for a server already in the list - // never touches mintPubkey/addedAt/locked, and no-ops for a server that @@ -355,32 +240,35 @@ export const dismissTrustedMintRekey = (server: string): void => { // whatever was known the moment trust was first established. export const cacheTrustedMintNodeInfo = ( server: string, - nodeInfo: TrustedMintNodeInfo -): void => { - if (!readCache().some(m => m.server === server)) return - persist(readCache().map(m => (m.server === server ? {...m, ...nodeInfo} : m))) -} + nodeInfo: TrustedMintNodeInfo, + ownerId?: string, +): Promise => + mutateStoredTrustedMints(ownerId, (mints) => cacheMintNodeInfo(mints, server, nodeInfo)) // only succeeds for entries not backed by a held bearer - see // TrustedMint.locked -export const removeTrustedMint = (server: string): void => { - const entry = readCache().find(m => m.server === server) - if (!entry) return - if (entry.locked) { - throw new Error("Can't remove - you hold a bearer note from this mint.") - } - persist(readCache().filter(m => m.server !== server)) -} +export const removeTrustedMint = (server: string, ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => removeMint(mints, server)) // wipes the whole registry - part of forgetting a wallet: nothing about a // wallet's mints (including otherwise-irremovable locked pins) should // linger on the device after it -export const clearTrustedMints = (): void => { - localStorage.removeItem(STORAGE_KEY) - cache = [] - for (const listener of listeners) listener([]) +export const clearTrustedMints = (ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, clearMints) + +export const migrateLegacyTrustedMints = (linkingKey: Uint8Array): Promise => { + const ownerId = linkingPubKeyHex(linkingKey) + if (savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy trusted-mint migration requires a proven owner.') + } + return adoptLegacyStoredTrustedMints(ownerId) } +export const removeTrustedMintsForOwner = (ownerId: string): Promise => + removeStoredTrustedMintsForOwner(ownerId) + +export const resetTrustedMintsForReplacement = (): Promise => resetStoredTrustedMints() + // merges a backup's trusted mints in by server - a server already known on // this device keeps its own current entry rather than being overwritten by // the backup's (possibly stale) copy. Three fields never come across from a @@ -391,43 +279,5 @@ export const clearTrustedMints = (): void => { // and every merged entry is marked `unconfirmed`, keeping it out of offline // signature verification until a live response from that server advertises // the same key (a crafted backup could otherwise forge "signed" badges) -export const mergeTrustedMints = (incoming: TrustedMint[]): number => { - const knownServers = new Set(readCache().map(m => m.server)) - const merged = [...readCache()] - let added = 0 - for (const mint of incoming) { - if ( - typeof mint?.server !== 'string' || - typeof mint?.mintPubkey !== 'string' || - typeof mint?.addedAt !== 'number' || - !PUBKEY_PATTERN.test(mint.mintPubkey.toLowerCase()) - ) { - continue - } - if (knownServers.has(mint.server)) continue - merged.push({ - server: mint.server, - mintPubkey: mint.mintPubkey.toLowerCase(), - addedAt: mint.addedAt, - locked: false, - unconfirmed: true, - nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined, - nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined, - nodeCapacityMsat: - typeof mint.nodeCapacityMsat === 'number' - ? mint.nodeCapacityMsat - : undefined, - nodeNumChannels: - typeof mint.nodeNumChannels === 'number' - ? mint.nodeNumChannels - : undefined, - nodeNumPeers: - typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined, - username: typeof mint.username === 'string' ? mint.username : undefined - }) - knownServers.add(mint.server) - added++ - } - if (added > 0) persist(merged) - return added -} +export const mergeTrustedMints = (incoming: unknown[], ownerId?: string): Promise => + mutateStoredTrustedMints(ownerId, (mints) => mergeMints(mints, incoming))