From 9f6bcad706760cd428e462ae28557f28362fddcf Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:23 +0200 Subject: [PATCH] feat: scope NWC storage to wallet owners --- src/lnurlcash/nwc.ownership.cases.ts | 159 +++++++++++++++ src/lnurlcash/nwc.storage.cases.ts | 173 +++++++++++++++++ src/lnurlcash/storage/nwcConnections.ts | 248 +++++++++++++++++------- src/lnurlcash/storage/nwcEnabled.ts | 87 +++++++++ 4 files changed, 602 insertions(+), 65 deletions(-) create mode 100644 src/lnurlcash/nwc.ownership.cases.ts create mode 100644 src/lnurlcash/nwc.storage.cases.ts create mode 100644 src/lnurlcash/storage/nwcEnabled.ts diff --git a/src/lnurlcash/nwc.ownership.cases.ts b/src/lnurlcash/nwc.ownership.cases.ts new file mode 100644 index 0000000..8b1f154 --- /dev/null +++ b/src/lnurlcash/nwc.ownership.cases.ts @@ -0,0 +1,159 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service ownership', () => { + it('subscribes only current-owner records and leaves foreign budgets untouched', async () => { + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 0, + }) + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) + storeForeignConnection(foreign.record) + const relay = createFakeRelay() + + const service = await startService(LINKING_KEY, { + assertCurrentOwner: () => undefined, + getBearers: () => [], + getDefaultMint: () => null, + applyChangeset: () => Promise.resolve(), + transport: relay.transport, + nowSeconds, + }) + + expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) + expect(relay.subscriptionCount()).toBe(1) + expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) + expect(service.connections).not.toContainEqual(foreign) + await service.stop() + }) + + it('ignores foreign records handed in through the records snapshot', async () => { + // the injected-records path bypasses storage, so the service's own + // owner filter is the only boundary here (stale-ownership probe: a + // snapshot from a previous wallet must not be served) + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 0, + }) + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) + const relay = createFakeRelay() + + const service = await startService( + LINKING_KEY, + { + assertCurrentOwner: () => undefined, + getBearers: () => [], + getDefaultMint: () => null, + applyChangeset: () => Promise.resolve(), + transport: relay.transport, + nowSeconds, + }, + [current.record, foreign.record], + ) + + expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) + expect(relay.subscriptionCount()).toBe(1) + // the foreign snapshot record must not be persisted for the new owner + expect(readNwcConnections(OWNER_ID)).toEqual([current.record]) + await service.stop() + }) +}) + +describe('service: info and balance', () => { + it('publishes a kind-13194 info event on startup', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND)) + expect(info.pubkey).toBe(walletServicePubkey) + expect(info.content).toContain('pay_invoice') + expect(info.content).toContain('make_invoice') + expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']) + await stop() + }) + + it('answers get_info with the connection identity and method list', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'get_info', {}) + expect(response.error).toBeNull() + expect(response.result_type).toBe('get_info') + expect(response.result).toMatchObject({ + alias: 'sattle', + pubkey: walletServicePubkey, + methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'], + }) + await stop() + }) + + it('answers get_balance with the spendable total only', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [ + await makeBearer(m, 'aa'.repeat(32), 21_000), + await makeBearer(m, 'bb'.repeat(32), 5_000), + {...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true}, + ] + const response = await call(relay, walletServicePubkey, 'get_balance', {}) + expect(response.error).toBeNull() + expect(response.result).toEqual({balance: 26_000}) + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.storage.cases.ts b/src/lnurlcash/nwc.storage.cases.ts new file mode 100644 index 0000000..ebe3954 --- /dev/null +++ b/src/lnurlcash/nwc.storage.cases.ts @@ -0,0 +1,173 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('storage validation', () => { + it('drops malformed records instead of throwing', () => { + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + {clientPubkey: 'nope'}, + { + version: 1, + ownerId: OWNER_ID, + clientPubkey: CLIENT_PUBKEY, + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 0, + }, + ]), + ) + expect(readNwcConnections(OWNER_ID)).toHaveLength(1) + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY) + }) + + it('returns nothing for garbage json', () => { + localStorage.setItem('sattle_nwc_connections', '{{{') + expect(readNwcConnections(OWNER_ID)).toEqual([]) + }) + + it('returns only strictly parsed records belonging to the requested owner', () => { + const current = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + clientSecret: CLIENT_SECRET, + now: 10, + }).record + const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record + const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') + if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + ...raw, + foreign, + {...current, ownerId: 'malformed'}, + { + clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))), + relays: RELAYS, + budget: {maxMsat: 3000, periodMs: 3000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 30, + }, + ]), + ) + + expect(readNwcConnections(OWNER_ID)).toEqual([current]) + expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]) + }) + + it('adopts ownerless connections and enabled state only after owner proof', async () => { + await saveLinkingKey(LINKING_KEY) + const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}') + if (typeof saved !== 'object' || saved === null) { + throw new TypeError('Expected a saved linking-key record') + } + Reflect.deleteProperty(saved, 'ownerId') + Reflect.deleteProperty(saved, 'version') + localStorage.setItem('sattle_linking_key', JSON.stringify(saved)) + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + { + clientPubkey: CLIENT_PUBKEY, + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 0, + }, + ]), + ) + localStorage.setItem('sattle_nwc_enabled', 'true') + + expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow() + expect(readNwcConnections(OWNER_ID)).toEqual([]) + expect(readNwcEnabled(OWNER_ID)).toBe(false) + + ensureSavedKeyOwner(LINKING_KEY) + expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ + connections: 1, + enabled: true, + }) + expect(readNwcConnections(OWNER_ID)).toHaveLength(1) + expect(readNwcEnabled(OWNER_ID)).toBe(true) + expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ + connections: 0, + enabled: false, + }) + }) + + it('does not expose one wallet enabled state to another owner', () => { + writeNwcEnabled(OWNER_ID, true) + + expect(readNwcEnabled(OWNER_ID)).toBe(true) + expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false) + }) + + it('treats malformed owner-bearing enabled records as disabled', () => { + for (const value of [ + {version: 1, ownerId: 'malformed', enabled: true}, + {version: 2, ownerId: OWNER_ID, enabled: true}, + {version: 1, ownerId: OWNER_ID, enabled: 'true'}, + ]) { + localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value)) + expect(readNwcEnabled(OWNER_ID)).toBe(false) + } + }) +}) diff --git a/src/lnurlcash/storage/nwcConnections.ts b/src/lnurlcash/storage/nwcConnections.ts index 582ac5b..b2b8b5a 100644 --- a/src/lnurlcash/storage/nwcConnections.ts +++ b/src/lnurlcash/storage/nwcConnections.ts @@ -1,28 +1,34 @@ -// NWC connection persistence: one localStorage record holding every NIP-47 -// connection this wallet serves (see nwc.ts). A record is public metadata -// only - the wallet-service key is re-derived from the linking key and the -// client pubkey (nwc/connection.ts's deriveNwcWalletKey), and the CLIENT -// secret is never stored at all (NIP-47: the wallet service should not -// store the secret it generates for the client). The budget spend counter -// lives here so a restart doesn't reset a client's allowance. +// NWC connection persistence. Each record and the service-enabled setting +// belong to one canonical wallet owner, so local residue from another wallet +// is never served, edited, revoked, or charged. Hostile localStorage input is +// parsed before use; ownerless v0 records remain hidden until an already +// proven saved wallet explicitly migrates them. + +import {linkingPubKeyHex, savedKeyOwnerId} from '../keys' +import { + clearNwcEnabledForOwner, + clearUnownedNwcEnabled, + readLegacyNwcEnabled, + writeNwcEnabled, +} from './nwcEnabled' +import {savedKeyOwnerAllows} from './currentOwner' +import {isWalletOwnerId} from './walletOwner' export type NwcBudget = { - // the most this connection may pay per period, msat maxMsat: number - // the period length in milliseconds (e.g. 86_400_000 for daily) periodMs: number } -// spend within the current period; rolls over once periodStart is more -// than budget.periodMs in the past export type NwcBudgetSpend = { periodStart: number msat: number } +const NWC_RECORD_VERSION = 1 + export type NwcConnectionRecord = { - // the authorized client's pubkey (the pubkey of the client secret that - // was handed out in the connection string, once, at creation time) + version: typeof NWC_RECORD_VERSION + ownerId: string clientPubkey: string relays: string[] budget: NwcBudget @@ -30,80 +36,192 @@ export type NwcConnectionRecord = { createdAt: number } +type LegacyNwcConnectionRecord = Omit + +type StoredNwcConnection = + {kind: 'owned'; record: NwcConnectionRecord} | {kind: 'legacy'; record: LegacyNwcConnectionRecord} + const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections' -const HEX_64 = /^[0-9a-f]{64}$/i +const HEX_64 = /^[0-9a-f]{64}$/ -// strict shape check, same spirit as passkeySlots.ts: localStorage content -// is not trustworthy input, so records are validated before use -const isValidNwcConnectionRecord = ( - record: unknown -): record is NwcConnectionRecord => { - if (typeof record !== 'object' || record === null) return false - const r = record as Record - const budget = r.budget as Record | null - const spent = r.spent as Record | null - return ( - typeof r.clientPubkey === 'string' && - HEX_64.test(r.clientPubkey) && - Array.isArray(r.relays) && - r.relays.length > 0 && - r.relays.every( - relay => typeof relay === 'string' && /^wss?:\/\//.test(relay) - ) && - typeof budget === 'object' && - budget !== null && - typeof budget.maxMsat === 'number' && - Number.isInteger(budget.maxMsat) && - budget.maxMsat > 0 && - typeof budget.periodMs === 'number' && - budget.periodMs > 0 && - typeof spent === 'object' && - spent !== null && - typeof spent.periodStart === 'number' && - typeof spent.msat === 'number' && - spent.msat >= 0 && - typeof r.createdAt === 'number' - ) +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const isPositiveInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0 + +const isNonNegativeInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + +const isRelay = (value: unknown): value is string => { + if (typeof value !== 'string') return false + try { + const url = new URL(value) + return url.protocol === 'wss:' || url.protocol === 'ws:' + } catch { + return false + } } -// malformed entries are dropped, not thrown on - one corrupted record must -// not take the remaining connections down with it -export const readNwcConnections = (): NwcConnectionRecord[] => { +const parseStoredConnection = (value: unknown): StoredNwcConnection | null => { + if (!isRecord(value)) return null + const {clientPubkey, relays, budget, spent, createdAt} = value + if ( + typeof clientPubkey !== 'string' || + !HEX_64.test(clientPubkey) || + !Array.isArray(relays) || + relays.length === 0 || + !relays.every(isRelay) || + !isRecord(budget) || + !isPositiveInteger(budget.maxMsat) || + !isPositiveInteger(budget.periodMs) || + !isRecord(spent) || + !isNonNegativeInteger(spent.periodStart) || + !isNonNegativeInteger(spent.msat) || + !isNonNegativeInteger(createdAt) + ) { + return null + } + const base: LegacyNwcConnectionRecord = { + clientPubkey, + relays, + budget: {maxMsat: budget.maxMsat, periodMs: budget.periodMs}, + spent: {periodStart: spent.periodStart, msat: spent.msat}, + createdAt, + } + if (!Object.hasOwn(value, 'version') && !Object.hasOwn(value, 'ownerId')) { + return {kind: 'legacy', record: base} + } + if (value.version !== NWC_RECORD_VERSION || !isWalletOwnerId(value.ownerId)) { + return null + } + return { + kind: 'owned', + record: {...base, version: NWC_RECORD_VERSION, ownerId: value.ownerId}, + } +} + +const readStoredConnections = (): StoredNwcConnection[] => { const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY) - if (!raw) return [] + if (raw === null) return [] try { const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) - ? parsed.filter(isValidNwcConnectionRecord) - : [] + if (!Array.isArray(parsed)) return [] + return parsed + .map(parseStoredConnection) + .filter((entry): entry is StoredNwcConnection => entry !== null) } catch { return [] } } -export const writeNwcConnections = ( - records: NwcConnectionRecord[] -): void => { - localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records)) +const storedValue = (entry: StoredNwcConnection): NwcConnectionRecord | LegacyNwcConnectionRecord => + entry.record + +export const readNwcConnections = (ownerId: unknown): NwcConnectionRecord[] => { + if (!isWalletOwnerId(ownerId)) return [] + return readStoredConnections() + .filter( + (entry): entry is Extract => + entry.kind === 'owned' && entry.record.ownerId === ownerId, + ) + .map((entry) => entry.record) +} + +export const writeNwcConnections = (ownerId: unknown, records: NwcConnectionRecord[]): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC connections require a valid wallet owner.') + } + const canonical: NwcConnectionRecord[] = [] + for (const record of records) { + const parsed = parseStoredConnection(record) + if (parsed?.kind !== 'owned' || parsed.record.ownerId !== ownerId) { + throw new Error('Refusing to write an invalid or foreign NWC connection.') + } + canonical.push(parsed.record) + } + const preserved = readStoredConnections() + .filter((entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId) + .map(storedValue) + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify([...preserved, ...canonical])) } -// upsert by client pubkey; returns the stored record. Callers serialize -// read-modify-write cycles themselves (the NWC service serializes per -// connection through its request queue) export const persistNwcConnection = ( - record: NwcConnectionRecord + ownerId: unknown, + record: NwcConnectionRecord, ): NwcConnectionRecord => { - const records = readNwcConnections() - const index = records.findIndex(r => r.clientPubkey === record.clientPubkey) + if (!isWalletOwnerId(ownerId) || record.ownerId !== ownerId) { + throw new Error('NWC connection writes require a valid wallet owner.') + } + const records = readNwcConnections(ownerId) + const index = records.findIndex((stored) => stored.clientPubkey === record.clientPubkey) if (index >= 0) records[index] = record else records.push(record) - writeNwcConnections(records) + writeNwcConnections(ownerId, records) return record } -export const removeNwcConnection = (clientPubkey: string): void => { +export const removeNwcConnection = (ownerId: unknown, clientPubkey: string): void => { + if (!isWalletOwnerId(ownerId)) return writeNwcConnections( - readNwcConnections().filter(r => r.clientPubkey !== clientPubkey) + ownerId, + readNwcConnections(ownerId).filter((record) => record.clientPubkey !== clientPubkey), ) } + +export type NwcLegacyMigrationResult = { + connections: number + enabled: boolean +} + +export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => { + const ownerId = linkingPubKeyHex(linkingKey) + if (savedKeyOwnerId() !== ownerId) { + throw new Error('Legacy NWC migration requires a proven saved wallet owner.') + } + + const stored = readStoredConnections() + let connections = 0 + const migrated = stored.map((entry) => { + if (entry.kind === 'owned') return entry.record + connections += 1 + return { + ...entry.record, + version: NWC_RECORD_VERSION, + ownerId, + } satisfies NwcConnectionRecord + }) + if (connections > 0) { + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(migrated)) + } + + const legacyEnabled = readLegacyNwcEnabled() + if (legacyEnabled !== null) writeNwcEnabled(ownerId, legacyEnabled) + return {connections, enabled: legacyEnabled !== null} +} + +const persistStoredConnections = (entries: StoredNwcConnection[]): void => { + if (entries.length === 0) { + localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY) + return + } + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(entries.map(storedValue))) +} + +export const clearNwcStorageForOwner = (ownerId: unknown): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC teardown requires a valid wallet owner.') + } + persistStoredConnections( + readStoredConnections().filter( + (entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId, + ), + ) + clearNwcEnabledForOwner(ownerId) +} + +export const clearUnownedNwcStorage = (): void => { + persistStoredConnections(readStoredConnections().filter((entry) => entry.kind === 'owned')) + clearUnownedNwcEnabled() +} diff --git a/src/lnurlcash/storage/nwcEnabled.ts b/src/lnurlcash/storage/nwcEnabled.ts new file mode 100644 index 0000000..df9b4fe --- /dev/null +++ b/src/lnurlcash/storage/nwcEnabled.ts @@ -0,0 +1,87 @@ +// NWC service-enabled persistence: one owner-bearing record for whether the +// wallet service should run. Split from nwcConnections.ts (size ceiling) - +// the enabled flag and the connection records are independent storage keys +// with the same ownership rules: hostile input is parsed before use, and a +// legacy global 'true'/'false' string counts as ownerless residue until an +// already proven saved wallet migrates it. + +import {isWalletOwnerId} from './walletOwner' +import {savedKeyOwnerAllows} from './currentOwner' + +const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled' +const NWC_ENABLED_VERSION = 1 + +type NwcEnabledRecord = { + version: typeof NWC_ENABLED_VERSION + ownerId: string + enabled: boolean +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const readNwcEnabledRecord = (): NwcEnabledRecord | null => { + const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY) + if (raw === null) return null + try { + const parsed: unknown = JSON.parse(raw) + if ( + !isRecord(parsed) || + parsed.version !== NWC_ENABLED_VERSION || + !isWalletOwnerId(parsed.ownerId) || + typeof parsed.enabled !== 'boolean' + ) { + return null + } + return { + version: NWC_ENABLED_VERSION, + ownerId: parsed.ownerId, + enabled: parsed.enabled, + } + } catch { + return null + } +} + +export const readNwcEnabled = (ownerId: unknown): boolean => { + if (!isWalletOwnerId(ownerId)) return false + const record = readNwcEnabledRecord() + return record?.ownerId === ownerId && record.enabled +} + +export const writeNwcEnabled = (ownerId: unknown, enabled: boolean): void => { + if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) { + throw new Error('NWC enabled state requires a valid wallet owner.') + } + localStorage.setItem( + NWC_ENABLED_STORAGE_KEY, + JSON.stringify({version: NWC_ENABLED_VERSION, ownerId, enabled}), + ) +} + +// the pre-owner storage form was a bare 'true'/'false' string - returns it +// when present so legacy migration can re-home the value under the proven +// owner, null for anything else (absent, junk, or an owned envelope) +export const readLegacyNwcEnabled = (): boolean | null => { + const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY) + if (raw === 'true') return true + if (raw === 'false') return false + return null +} + +// teardown of one owner's enabled state - any other owner's record (or a +// legacy string) is left exactly as found +export const clearNwcEnabledForOwner = (ownerId: unknown): void => { + if (!isWalletOwnerId(ownerId)) return + if (readNwcEnabledRecord()?.ownerId === ownerId) { + localStorage.removeItem(NWC_ENABLED_STORAGE_KEY) + } +} + +// install-time residue sweep: only the legacy string form is unowned; an +// owned envelope stays (it is inert for every other owner) +export const clearUnownedNwcEnabled = (): void => { + if (readLegacyNwcEnabled() !== null) { + localStorage.removeItem(NWC_ENABLED_STORAGE_KEY) + } +}