diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts new file mode 100644 index 0000000..6df9724 --- /dev/null +++ b/src/boot/wallet.ts @@ -0,0 +1,11 @@ +import { defineBoot } from '#q-app'; +import { useWalletStore } from '@/stores/wallet'; + +// Wallet lifecycle bootstrap: reflects whatever is on this device into the +// wallet store at app start - a plaintext-stored key unlocks straight away, +// a password-encrypted one lands on 'locked' for the unlock screen, and no +// key at all lands on 'none' for onboarding. +export default defineBoot(async () => { + const wallet = useWalletStore(); + await wallet.init(); +}); diff --git a/src/stores/activity.ts b/src/stores/activity.ts new file mode 100644 index 0000000..2787609 --- /dev/null +++ b/src/stores/activity.ts @@ -0,0 +1,60 @@ +import {ref} from 'vue' +import {defineStore} from 'pinia' + +import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage' +import { + loadActivity, + persistActivityEvent, + clearAllActivity, + newActivityId, + MAX_ACTIVITY_ENTRIES +} from '@/lnurlcash/storage' + +// The activity log: append-only, encrypted at rest with the same +// bearer-AES key as the notes themselves. Loaded by the wallet store on +// unlock (loadFor) and dropped on lock (unload) - it never holds plaintext +// while the wallet is locked. +export const useActivityStore = defineStore('activity', () => { + const events = ref([]) + let aesKey: CryptoKey | null = null + + const loadFor = async (key: CryptoKey): Promise => { + aesKey = key + events.value = await loadActivity(key) + } + + const unload = (): void => { + aesKey = null + events.value = [] + } + + // both unload and wipe the stored log - part of forgetting a wallet + const unloadAndClear = (): void => { + clearAllActivity() + unload() + } + + // best-effort and silent on failure - a wallet action that already + // succeeded (the note was split/melted/whatever) must never surface an + // error just because the log entry for it couldn't be written + const log = (kind: ActivityKind, message: string): void => { + if (!aesKey) return + const event: ActivityEvent = { + id: newActivityId(), + kind, + message, + createdAt: Date.now() + } + events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES) + persistActivityEvent(aesKey, event).catch(() => { + // deliberately swallowed - see the comment above + }) + } + + const clear = (): void => { + clearAllActivity() + events.value = [] + } + + return {events, loadFor, unload, unloadAndClear, log, clear} +}) diff --git a/src/stores/mints.ts b/src/stores/mints.ts new file mode 100644 index 0000000..185b5f5 --- /dev/null +++ b/src/stores/mints.ts @@ -0,0 +1,92 @@ +import {computed, ref} from 'vue' +import {defineStore} from 'pinia' + +import type { + TrustedMint, + TrustedMintNodeInfo, + TrustKeyResult +} from '@/lnurlcash/trustedMints' +import { + PUBLIC_MINTS, + readTrustedMints, + onTrustedMintsChange, + addTrustedMint, + lockTrustedMint, + confirmTrustedMintRekey, + dismissTrustedMintRekey, + removeTrustedMint, + cacheTrustedMintNodeInfo, + isMintTrusted, + getTrustedMintPubkey +} from '@/lnurlcash/trustedMints' +import {loadSettings, persistSettings} from '@/lnurlcash/storage' + +// The trusted-mint registry as reactive state. The domain logic (pinning, +// rekey staging, backup merge rules) lives framework-free in +// src/lnurlcash/trustedMints.ts - this store mirrors it for the UI and +// exposes the actions. A mint advertising a NEW signing key is always +// staged for review (pendingRekeys), never auto-applied: a silently +// rotated key would defeat the entire pinning model. +export const useMintsStore = defineStore('mints', () => { + const mints = ref(readTrustedMints()) + onTrustedMintsChange(updated => { + mints.value = updated + }) + + // mints with a staged rekey awaiting holder review - the UI should + // surface these loudly + const pendingRekeys = computed(() => + mints.value.filter(m => m.pendingMintPubkey) + ) + + // ---- default-mint selection (onboarding quick start) ---- + const defaultMint = ref(loadSettings().defaultMint ?? null) + const setDefaultMint = (server: string | null): void => { + defaultMint.value = server + const settings = loadSettings() + if (server === null) { + persistSettings({...settings, defaultMint: undefined}) + } else { + persistSettings({...settings, defaultMint: server}) + } + } + + // manual add from the mints settings, or a user-confirmed first + // encounter - validates and throws on junk input + const trust = ( + server: string, + mintPubkey: string, + nodeInfo?: TrustedMintNodeInfo + ): TrustKeyResult => addTrustedMint(server, mintPubkey, nodeInfo) + + // the silent path: this wallet holds (or just came to hold) a bearer from + // this server - trust follows holding funds, never asks, and only ever + // STAGES a differing advertised key + const lockFromBearer = (server: string, mintPubkey: string): TrustKeyResult => + lockTrustedMint(server, mintPubkey) + + const confirmRekey = (server: string): void => confirmTrustedMintRekey(server) + const dismissRekey = (server: string): void => dismissTrustedMintRekey(server) + + // throws for a mint locked by a held bearer + const remove = (server: string): void => removeTrustedMint(server) + + const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): void => + cacheTrustedMintNodeInfo(server, nodeInfo) + + return { + mints, + pendingRekeys, + defaultMint, + setDefaultMint, + PUBLIC_MINTS, + trust, + lockFromBearer, + confirmRekey, + dismissRekey, + remove, + cacheNodeInfo, + isTrusted: isMintTrusted, + trustedPubkey: getTrustedMintPubkey + } +}) diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts new file mode 100644 index 0000000..25dd792 --- /dev/null +++ b/src/stores/wallet.ts @@ -0,0 +1,319 @@ +import {computed, ref} from 'vue' +import {defineStore} from 'pinia' +import {serverOf} from 'lnurlcash-kit' + +import { + deriveWalletLinkingKey, + deriveBearerAesKey, + saveLinkingKey, + savedKeyExists, + savedKeyIsEncrypted, + getPlainLinkingKey, + decryptSavedLinkingKey, + clearSavedLinkingKey, + generateSeedPhrase, + isValidSeedPhrase, + linkingPubKeyHex +} from '@/lnurlcash/keys' +import type {Bearer, NewBearer} from '@/lnurlcash/types' +import { + loadBearers, + persistBearer, + deleteBearerRecord, + clearAllBearers, + newBearerId, + mergeBearers +} from '@/lnurlcash/storage' +import { + grandfatherTrustedMint, + lockTrustedMint, + clearTrustedMints +} from '@/lnurlcash/trustedMints' +import {clearSettings} from '@/lnurlcash/storage' +import {msatToSats} from '@/lnurlcash/units' +import {useActivityStore} from './activity' + +// 'none': no wallet on this device yet -> setup +// 'locked': linking key present but password-encrypted -> unlock +// 'unlocked': linking key (and thus the bearer AES key) in memory +export type WalletState = 'none' | 'locked' | 'unlocked' + +// idle-timeout auto-lock: only meaningful for a password-encrypted key (see +// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in +// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead +// of that so the UI can warn, and postponeLock() is the "stay unlocked" +// hook it offers. +const AUTO_LOCK_MS = 5 * 60 * 1000 +const LOCK_WARNING_MS = 30 * 1000 + +// a plaintext-stored key also starts 'locked' - init() unlocks it +// immediately without a password, keeping a single code path for deriving +// the AES key and loading bearers +const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none') + +export const useWalletStore = defineStore('wallet', () => { + const state = ref(initialState()) + const bearers = ref([]) + const pubkey = ref(null) + let aesKey: CryptoKey | null = null + + // ---- idle auto-lock bookkeeping ---- + let lastActivity = Date.now() + let idleTimer: ReturnType | null = null + const lockWarningSecondsLeft = ref(null) + + const encrypted = computed(() => savedKeyIsEncrypted()) + + // ---- balances: protocol layer is msat; sats are a display helper ---- + const unspentBearers = computed(() => bearers.value.filter(b => !b.spent)) + const balanceMsat = computed(() => + unspentBearers.value.reduce((sum, b) => sum + b.amount, 0) + ) + const balanceSats = computed(() => msatToSats(balanceMsat.value)) + const balanceByMintMsat = computed(() => { + const byMint = new Map() + for (const b of unspentBearers.value) { + const server = serverOf(b.url) + byMint.set(server, (byMint.get(server) ?? 0) + b.amount) + } + return byMint + }) + const balanceByMintSats = computed(() => { + const byMint = new Map() + for (const [server, msat] of balanceByMintMsat.value) { + byMint.set(server, msatToSats(msat)) + } + return byMint + }) + + const stopIdleWatch = () => { + if (idleTimer) clearInterval(idleTimer) + idleTimer = null + lockWarningSecondsLeft.value = null + } + + const lock = () => { + // only meaningful for a password-encrypted key - a plaintext one would + // just auto-unlock again, so the UI only offers Lock when encrypted + if (!savedKeyIsEncrypted()) return + aesKey = null + pubkey.value = null + bearers.value = [] + useActivityStore().unload() + stopIdleWatch() + state.value = 'locked' + } + + // any real interaction restarts the 5-minute clock and clears the + // warning - the UI's "Stay unlocked" button calls this + const postponeLock = () => { + lastActivity = Date.now() + lockWarningSecondsLeft.value = null + } + + // ticks once a second while unlocked and encrypted (the only state + // auto-lock applies to), comparing wall-clock time against lastActivity + // rather than relying on a single setTimeout, since a backgrounded tab + // throttles timers but Date.now() still reflects real elapsed time + // whenever this next gets to run + const startIdleWatch = () => { + stopIdleWatch() + if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return + lastActivity = Date.now() + const registerActivity = () => { + // once the warning is up, passive activity is deliberately ignored - + // only postponeLock() dismisses it, so the "stay unlocked" button + // can't vanish out from under the pointer before the click lands + if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) { + lastActivity = Date.now() + } + } + for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) { + window.addEventListener(event, registerActivity, {passive: true}) + } + idleTimer = setInterval(() => { + if (state.value !== 'unlocked') return + const elapsed = Date.now() - lastActivity + if (elapsed >= AUTO_LOCK_MS) { + lock() + } else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) { + lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000) + } + }, 1000) + } + + const activate = async (linkingKey: Uint8Array) => { + const key = await deriveBearerAesKey(linkingKey) + aesKey = key + pubkey.value = linkingPubKeyHex(linkingKey) + const loaded = await loadBearers(key) + bearers.value = loaded + const activity = useActivityStore() + await activity.loadFor(key) + // grandfather in every mint already backing a held bearer as trusted - + // holding funds there already implied trusting it. Storage-sourced + // claims only, though: grandfathering never locks and marks new pins + // unconfirmed - both are (re-)earned by live responses during actual + // bearer operations + for (const bearer of loaded) { + if (bearer.mintPubkey) { + grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey) + } + } + state.value = 'unlocked' + startIdleWatch() + } + + // generates a fresh seed phrase, derives and saves the linking key, and + // unlocks. The phrase is returned exactly once - it is never stored, so + // the caller MUST show it to the holder before letting them move on. + const create = async (password?: string): Promise => { + const phrase = generateSeedPhrase() + await restoreFromSeed(phrase, password) + return phrase + } + + const restoreFromSeed = async ( + seedPhrase: string, + password?: string + ): Promise => { + if (!isValidSeedPhrase(seedPhrase)) { + throw new Error('Not a valid seed phrase.') + } + const linkingKey = deriveWalletLinkingKey(seedPhrase) + await saveLinkingKey(linkingKey, password) + await activate(linkingKey) + } + + const unlock = async (password?: string): Promise => { + const linkingKey = savedKeyIsEncrypted() + ? await decryptSavedLinkingKey(password || '') + : getPlainLinkingKey() + if (!linkingKey) throw new Error('No wallet on this device.') + await activate(linkingKey) + } + + // app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks + // without a password; an encrypted one waits on the unlock screen + const init = async (): Promise => { + if (state.value === 'locked' && !savedKeyIsEncrypted()) { + await unlock() + } + } + + // wipes this wallet from the device entirely - the linking key, every + // bearer record, the activity log, and the non-secret registries that + // would otherwise linger as a fingerprint of it. Not recoverable by + // restoring the same seed afterward (the ciphertexts themselves are + // gone); only a backup downloaded before this runs can bring the notes + // back - the UI should prompt for one + const forgetWallet = () => { + clearSavedLinkingKey() + clearAllBearers() + clearTrustedMints() + clearSettings() + useActivityStore().unloadAndClear() + aesKey = null + pubkey.value = null + bearers.value = [] + stopIdleWatch() + state.value = 'none' + } + + const requireKey = (): CryptoKey => { + if (!aesKey) throw new Error('Wallet is locked.') + return aesKey + } + + // the one entry point for new notes (minted, received, carved outputs): + // persists first, then updates state. Holding a bearer from a mint + // trusts it by default - this is the one path that never asks (see + // trustedMints.ts); a DIFFERENT advertised key comes back as + // 'rekey-pending' and is staged on the mints store for review, never + // auto-applied. + const addBearers = async (notes: NewBearer[]): Promise => { + const now = Date.now() + const added: Bearer[] = [] + for (const note of notes) { + const bearer: Bearer = { + id: newBearerId(), + ...note, + createdAt: now, + updatedAt: now + } + await persistBearer(requireKey(), bearer) + added.push(bearer) + } + bearers.value = [...added, ...bearers.value] + for (const bearer of added) { + if (bearer.mintPubkey) { + lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey) + } + } + return added + } + + const updateBearer = async ( + id: string, + changes: Partial> + ): Promise => { + const current = bearers.value.find(b => b.id === id) + if (!current) return + const updated: Bearer = {...current, ...changes, updatedAt: Date.now()} + await persistBearer(requireKey(), updated) + bearers.value = bearers.value.map(b => (b.id === id ? updated : b)) + if (updated.mintPubkey) { + lockTrustedMint(serverOf(updated.url), updated.mintPubkey) + } + } + + const markSpent = async (id: string, spent = true): Promise => { + await updateBearer(id, {spent}) + } + + const removeNote = async (id: string): Promise => { + bearers.value = bearers.value.filter(b => b.id !== id) + await deleteBearerRecord(id) + } + + // merges externally-produced bearers (a decrypted backup restore, later a + // nostr restore) into the live list: union by note identity, spent-wins - + // see storage.ts's mergeBearers. Persists every survivor. + const mergeExternalBearers = async (incoming: Bearer[]): Promise => { + const merged = mergeBearers(bearers.value, incoming) + for (const bearer of merged) { + await persistBearer(requireKey(), bearer) + } + bearers.value = merged + } + + const reloadBearers = async (): Promise => { + bearers.value = await loadBearers(requireKey()) + } + + return { + state, + bearers, + pubkey, + encrypted, + lockWarningSecondsLeft, + balanceMsat, + balanceSats, + balanceByMintMsat, + balanceByMintSats, + unspentBearers, + create, + restoreFromSeed, + unlock, + lock, + init, + forgetWallet, + postponeLock, + addBearers, + updateBearer, + markSpent, + removeNote, + mergeExternalBearers, + reloadBearers + } +})