diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md new file mode 100644 index 0000000..29e8b5a --- /dev/null +++ b/docs/SECURITY_REVIEW.md @@ -0,0 +1,251 @@ +# Security and Performance Review + +**Project**: bsv-desktop (Electron + React desktop wallet for BSV) +**Review Date**: 2026-06-18 +**Scope**: Static analysis of source code for security and performance issues + +> This document captures findings from a code review focused on security and performance risks. It is not a substitute for a professional audit, dynamic testing, or penetration testing. + +--- + +## Remediation Status (2026-06-18) + +Patched in this pass (non-breaking hardening): + +- **#2 Command injection** — `sslCert.ts` and `main.ts` now use `execFile` with argument arrays instead of shell-interpolated `exec`. The remaining `osascript ... with administrator privileges` call (which must run a shell string) JSON-quotes the path, and the restored bundle identifier is validated against `^[A-Za-z0-9.\-]+$`. +- **#3 Arbitrary storage dispatch** — `callStorageMethod` now rejects any method not in an explicit allow-list (`ALLOWED_STORAGE_METHODS` in `electron/storage.ts`), kept in sync with `StorageElectronIPC`. +- **#4 XSS in release notes** — release notes are sanitized with `DOMPurify` before `dangerouslySetInnerHTML`. +- **#6 / #10 Navigation controls** — `setWindowOpenHandler` / `will-navigate` / `will-redirect` now allow only `http:`/`https:` to reach `shell.openExternal`; `javascript:`, `file:`, `data:` and custom protocols are blocked, and `will-redirect` is handled. +- **#9 Manifest proxy redirects** — the resolved URL after redirects is re-validated for HTTPS + `/manifest.json`. +- **#5 Plaintext secrets at rest** — `snap`, `primaryKeyHex`, and `mnemonic12` were moved out of renderer `localStorage` into a main-process file (`userData/secrets.dat`) encrypted with Electron `safeStorage` (key held in the OS keychain — macOS Keychain / Windows DPAPI / Linux libsecret/kwallet — not beside the data). The toolbox "encrypted" snapshot offered no at-rest protection because it stores its AES key inside the same blob. Legacy plaintext is migrated and cleared on first launch; `logout()` clears the store. Falls back to a clearly-warned plaintext mode only when no OS keyring is available (`secretStore.ts` / renderer `secrets.ts`). Limit: protects against other apps/users, backups, and disk theft — **not** same-user malware on Windows/Linux (per-user keys). The passphrase upgrade is tracked separately. + +Still open (require larger / potentially breaking changes — tracked, not yet done): + +- **#1 Unauthenticated local HTTP server** — intentionally open to local browser clients (the BRC-100 model). The per-origin permission prompt is the security boundary; transport auth can't distinguish a browser from a local process without breaking the web use case. Hardening tracked: ensure all read methods are permissioned; optional burst rate limiting. +- **#7 `sandbox: false` / dev web-security disabled**, **#8 `removeAllListeners`**, **#11–#16 performance/robustness** — see below. + +--- + +## Critical Security Issues + +### 1. Unauthenticated Local HTTP Server (BRC-100 Interface) — Highest Risk + +**Files**: +- [electron/httpServer.ts](../electron/httpServer.ts) +- [src/onWalletReady.ts](../src/onWalletReady.ts) + +**Details**: +- The server listens on `127.0.0.1:3321` (HTTP) and `2121` (HTTPS) with **no authentication** at the transport layer. +- `app.all('*', ...)` forwards every request to the renderer. +- CORS is fully permissive: + ```js + app.use(cors({ + origin: '*', + methods: '*', + allowedHeaders: '*', + exposedHeaders: '*', + credentials: false, + preflightContinue: true + })); + ``` +- Body size limit: `50mb` for both JSON and text. +- Origin is parsed in the renderer from `origin` or `originator` headers and passed as the `originator` parameter to wallet methods. This value is easily spoofed by non-browser local clients (any process on the machine). +- **Impact**: Any local process (malware, another app, browser extension exploiting a localhost origin, etc.) can invoke `createAction`, `signAction`, `internalizeAction`, `listOutputs`, etc. The in-app permission prompts are the only remaining control — and they are not mandatory for all operations or configurations. + +### 2. Command Injection via `exec` + +**Files**: +- [electron/sslCert.ts](../electron/sslCert.ts) (lines ~140, 209, 213) +- [electron/main.ts](../electron/main.ts) (focus handling, lines ~156, 222) + +**Examples**: +```js +// sslCert.ts +await execAsync(`security verify-cert -c "${certPath}" -p ssl -s localhost`); +await execAsync(`security add-trusted-cert -d -r trustRoot -k ... "${certPath}"`); +await execAsync(`osascript -e 'do shell script "security ... \\"${certPath}\\" ..." with administrator privileges'`); +``` + +```js +// main.ts (macOS focus) +await execPromise(`osascript -e 'tell application "System Events" to get the bundle identifier ...'`); +await execPromise(`osascript -e 'tell application "System Events" to set frontmost of ... "${target}" ...'`); +``` + +- `certPath` is derived from user data directory but is directly interpolated. +- `prevBundleId` and `target` come from prior `exec` stdout without sanitization. +- Shell metacharacters are not escaped. + +### 3. Arbitrary Method Dispatch on Storage Backend + +**Files**: +- [electron/main.ts](../electron/main.ts) (IPC handler at ~419) +- [electron/storage.ts](../electron/storage.ts) (`callStorageMethod`) + +**Details**: +```ts +ipcMain.handle('storage:call-method', async (_event, identityKey, chain, method, args) => { + ... + const result = await storageAny[method](...args); +}); +``` + +The renderer can invoke **any** method on the `StorageKnex` instance with arbitrary arguments via IPC. A compromised renderer process (XSS, malicious third-party code, supply-chain compromise in `@bsv/*` packages) gains full control over the wallet database. + +### 4. XSS via `dangerouslySetInnerHTML` on Update Release Notes + +**File**: [src/lib/components/UpdateNotification.tsx](../src/lib/components/UpdateNotification.tsx) (~191) + +```tsx +
+``` + +`releaseNotes` is populated from: +- `electron-updater` update metadata. +- In dev mode, directly from `https://api.github.com/repos/bsv-blockchain/bsv-desktop/releases/latest` (body field). + +No sanitization is applied. + +### 5. Sensitive Data Stored in Plaintext in localStorage + +- `localStorage.snap` (Version 3) contains the full wallet snapshot (including key material) encoded as base64. See [src/lib/services/WalletService.ts](../src/lib/services/WalletService.ts) (~630, ~664). +- In direct-key mode, `primaryKeyHex` and `mnemonic12` are also stored in localStorage. +- Renderer `localStorage` is accessible to any script executing in the renderer context. + +--- + +## High / Medium Security Issues + +### 6. Weak Navigation and `shell.openExternal` Controls + +**File**: [electron/main.ts](../electron/main.ts) (111–130) + +- `setWindowOpenHandler` and `will-navigate` only test for `http://` or `https://` prefixes. +- No protection against `javascript:`, `file:`, `data:`, or custom protocol handlers. +- `will-redirect` event is **not** handled. + +### 7. `sandbox: false` + Dev-Mode Web Security Disabled + +**File**: [electron/main.ts](../electron/main.ts) (~84–88, 491–496) + +```ts +webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false +} +if (isDev) { + '--disable-web-security', + '--ignore-certificate-errors' +} +``` + +### 8. Overly Broad `removeAllListeners` + +**File**: [electron/preload.ts](../electron/preload.ts) (32–34, 66–71) + +- `removeHttpRequestListener()` does `ipcRenderer.removeAllListeners('http-request')`. +- Similar pattern for all update channels. +- Calling this (or having compromised code call it) can globally disable HTTP request handling or update notifications. + +### 9. Proxy Manifest Fetch Allows Redirects to Arbitrary Final Hosts + +**File**: [electron/main.ts](../electron/main.ts) (~346–384) + +- Input URL is validated to be `https` and end in `/manifest.json`. +- Then `node-fetch` is called with `redirect: 'follow'`. +- The final response body/headers are returned without validating the *resolved* URL. + +### 10. Missing `will-redirect` Handler + +Leaves a small bypass opportunity for navigation controls. + +--- + +## Performance and Robustness Issues + +### 11. Permanent Listeners with No Cleanup + +- `onWalletReady.ts` registers the HTTP IPC listener once (module-level flag) with the comment "No cleanup — listener is permanent". +- Multiple `useEffect` hooks lack cleanup functions. +- `ipcRenderer.on(...)` registrations in preload are not removed on window lifecycle events in a scoped manner. + +### 12. Lack of Rate Limiting or Guards on the Local HTTP Server + +- 50 MB body limit with no per-origin, per-client, or global rate limiting. +- Every call is fully deserialized and dispatched in the renderer. +- No concurrency or backpressure controls for external BRC-100 callers. + +### 13. Storage Instantiation and Connection Patterns + +`getOrCreateStorage` is called from several IPC paths. While a Map cache exists, multiple entry points (makeAvailable, initializeServices, callMethod) can trigger creation and migration under load. + +### 14. Concurrent Database Access (Monitor Worker + Main) + +Both the main process and the monitor worker hold database connections to the same SQLite files (WAL mode helps but does not eliminate all contention risks during heavy sync or monitor activity). + +### 15. Full Snapshot Loading on Startup and Recovery Paths + +The entire wallet snapshot is loaded from `localStorage` (base64) synchronously during several flows. No streaming or size guards. + +### 16. HTTP Response Listener Tied to Specific Window Instance + +In [electron/httpServer.ts](../electron/httpServer.ts) (~89), responses are listened for via: + +```ts +mainWindow.webContents.on('ipc-message', ...) +``` + +If the main window is closed and recreated, the listener is orphaned and new responses may be dropped. + +--- + +## Other Notable Items + +- Exported mnemonic/private key files use `chmod 0o400` but are written with predictable timestamped names into `~/.bsv-desktop/`. +- `fetchAppData` and manifest parsing make network requests to arbitrary third-party domains (recent apps, user data) with limited timeout enforcement in some paths. +- `ADMIN_ORIGINATOR = 'admin.com'` is used for internal privileged operations. While external callers should not be able to claim it, it is a well-known string. + +--- + +## Recommendations (Prioritized) + +### Must Fix (high value target — real funds at risk) + +1. **Add authentication or strong gating** to the localhost HTTP API (3321/2121). Consider a capability token, mutual auth, or binding to a Unix domain socket instead of TCP. +2. Do **not** rely solely on the spoofable `originator` header for high-value actions. Keep or strengthen user approval requirements. +3. Eliminate or harden all `exec` / shell interpolation for certificate paths and bundle identifiers. Prefer non-shell `child_process` APIs. +4. Remove or sanitize `dangerouslySetInnerHTML` usage for release notes. Use a restricted Markdown renderer. +5. Protect the wallet snapshot and key material at rest (encrypt the snapshot, avoid plaintext `localStorage` for secrets where possible). +6. Add `will-redirect` handling and tighten `shell.openExternal` controls. + +### Strongly Recommended + +- Add rate limiting and reduce body size limits on the BRC-100 HTTP server. +- Replace or restrict the raw `storage:call-method` IPC (use an explicit allow-list of permitted operations). +- Ship a Content Security Policy in production builds. +- Re-evaluate `sandbox: false`. Enable if native module constraints can be resolved. +- Ensure every `ipcRenderer.on` has a matching removal and avoid blanket `removeAllListeners`. +- Validate the final URL after redirects in the manifest proxy. + +### Performance / Robustness + +- Add debouncing/throttling for recent apps tracking and app metadata fetches. +- Audit all `useEffect` for missing cleanup. +- Introduce backpressure or bounded concurrency for the HTTP → renderer dispatch path. +- Clarify DB connection ownership between monitor worker and main process. +- Consider incremental/safe snapshot handling for very large wallets. + +--- + +## Disclaimer + +This review is based on static source analysis. It should be followed by: + +- Dynamic testing / fuzzing of the HTTP interface from local processes. +- Supply-chain analysis of the `@bsv/sdk`, `@bsv/wallet-toolbox`, and related packages. +- Professional security audit and penetration testing before handling significant value. + +--- + +*End of review* diff --git a/docs/superpowers/plans/2026-06-19-encrypt-secrets-at-rest.md b/docs/superpowers/plans/2026-06-19-encrypt-secrets-at-rest.md new file mode 100644 index 0000000..c23b301 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-encrypt-secrets-at-rest.md @@ -0,0 +1,764 @@ +# Encrypt Wallet Secrets at Rest Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move `snap`, `primaryKeyHex`, and `mnemonic12` out of renderer `localStorage` into a main-process file encrypted with Electron `safeStorage`. + +**Architecture:** A main-process `secretStore` module reads/writes `userData/secrets.dat` (per-entry `safeStorage` ciphertext, plaintext fallback when OS encryption is unavailable). It is reached over three IPC channels. A renderer `secrets` module is a synchronous facade over an in-memory cache hydrated once at startup; it migrates legacy plaintext `localStorage` values on first run. All ~30 existing secret call sites route through the facade. + +**Tech Stack:** Electron 41 (`safeStorage`, `app`), TypeScript, Vitest (node env, `vi.mock`), React. + +## Global Constraints + +- Only secret names permitted anywhere: `snap`, `primaryKeyHex`, `mnemonic12`. Any other name throws (main) — the IPC channel is not a general oracle. +- Fallback when `!safeStorage.isEncryptionAvailable()` OR `safeStorage.getSelectedStorageBackend() === 'basic_text'`: store base64 plaintext with `enc:false` and log a one-time warning. Never brick login. +- Secrets must end up out of `localStorage` after migration. +- `secrets.dat` lives in `app.getPath('userData')`, written atomically (`.tmp` then rename), mode `0o600`. +- Tests: `npx vitest run --config vitest.config.electron.ts `. Test files live in `test/`, node env, mock `electron`/`window` then dynamic-import the module under test (see `test/onWalletReady.test.ts`). +- Electron typecheck: `npx tsc -p tsconfig.electron.json --noEmit`. Renderer typecheck: `npx tsc --noEmit` (note: 2 pre-existing `WalletService.ts` `@bsv/sdk` nested-version errors are unrelated baseline noise — count must not exceed 2). +- Commit messages end with: `Co-Authored-By: Claude Opus 4.8 (1M context) ` + +--- + +### Task 1: Main-process `secretStore` + +**Files:** +- Create: `electron/secretStore.ts` +- Test: `test/secretStore.test.ts` + +**Interfaces:** +- Consumes: `electron` (`app.getPath`, `safeStorage`), `fs`, `path`. +- Produces: + - `getSecret(name: string): string | null` + - `setSecret(name: string, value: string): void` + - `deleteSecret(name: string): void` + - `getAll(): Record` + +- [ ] **Step 1: Write the failing test** + +Create `test/secretStore.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest' +import os from 'os' +import path from 'path' +import fs from 'fs' + +// Per-run temp userData dir +const TMP = path.join(os.tmpdir(), `secretstore-test-${process.pid}-${Date.now()}`) + +let encAvailable = true +let backend = 'keychain' + +vi.mock('electron', () => ({ + app: { getPath: () => TMP }, + safeStorage: { + isEncryptionAvailable: () => encAvailable, + getSelectedStorageBackend: () => backend, + // reversible stand-in for real OS crypto + encryptString: (s: string) => Buffer.from('ENC:' + s, 'utf8'), + decryptString: (b: Buffer) => b.toString('utf8').replace(/^ENC:/, ''), + }, +})) + +let store: typeof import('../electron/secretStore') + +describe('secretStore', () => { + beforeEach(async () => { + encAvailable = true + backend = 'keychain' + fs.rmSync(TMP, { recursive: true, force: true }) + fs.mkdirSync(TMP, { recursive: true }) + vi.resetModules() + store = await import('../electron/secretStore') + }) + + it('round-trips an encrypted secret and persists ciphertext (not plaintext)', () => { + store.setSecret('snap', 'hello-snapshot') + expect(store.getSecret('snap')).toBe('hello-snapshot') + const raw = fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8') + expect(raw).not.toContain('hello-snapshot') + const parsed = JSON.parse(raw) + expect(parsed.entries.snap.enc).toBe(true) + }) + + it('falls back to plaintext when encryption unavailable', () => { + encAvailable = false + store.setSecret('snap', 'plain-value') + expect(store.getSecret('snap')).toBe('plain-value') + const parsed = JSON.parse(fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8')) + expect(parsed.entries.snap.enc).toBe(false) + }) + + it('treats basic_text backend as unencrypted fallback', () => { + backend = 'basic_text' + store.setSecret('snap', 'v') + const parsed = JSON.parse(fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8')) + expect(parsed.entries.snap.enc).toBe(false) + }) + + it('reads each entry per its own enc flag', () => { + store.setSecret('snap', 'encrypted-one') + encAvailable = false + store.setSecret('primaryKeyHex', 'plain-one') + encAvailable = true + const all = store.getAll() + expect(all.snap).toBe('encrypted-one') + expect(all.primaryKeyHex).toBe('plain-one') + }) + + it('rejects names not on the allow-list', () => { + expect(() => store.setSecret('evil', 'x')).toThrow(/not permitted/) + expect(() => store.getSecret('evil')).toThrow(/not permitted/) + }) + + it('getAll on missing file returns empty object', () => { + expect(store.getAll()).toEqual({}) + }) + + it('deleteSecret removes only that entry', () => { + store.setSecret('snap', 'a') + store.setSecret('mnemonic12', 'b') + store.deleteSecret('snap') + expect(store.getSecret('snap')).toBeNull() + expect(store.getSecret('mnemonic12')).toBe('b') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --config vitest.config.electron.ts test/secretStore.test.ts` +Expected: FAIL — cannot resolve `../electron/secretStore`. + +- [ ] **Step 3: Write minimal implementation** + +Create `electron/secretStore.ts`: + +```ts +import { app, safeStorage } from 'electron' +import path from 'path' +import fs from 'fs' + +/** Only these secret names may be stored or read. */ +const ALLOWED: ReadonlySet = new Set(['snap', 'primaryKeyHex', 'mnemonic12']) + +interface Entry { enc: boolean; data: string } +interface SecretFile { version: number; entries: Record } + +function assertAllowed(name: string): void { + if (!ALLOWED.has(name)) throw new Error(`secretStore: name not permitted: ${name}`) +} + +function filePath(): string { + return path.join(app.getPath('userData'), 'secrets.dat') +} + +/** True only when the OS provides real encryption (not Linux basic_text). */ +function encAvailable(): boolean { + try { + if (!safeStorage.isEncryptionAvailable()) return false + const getBackend = (safeStorage as any).getSelectedStorageBackend + if (typeof getBackend === 'function' && getBackend.call(safeStorage) === 'basic_text') return false + return true + } catch { + return false + } +} + +function readFile(): SecretFile { + try { + const parsed = JSON.parse(fs.readFileSync(filePath(), 'utf8')) + if (parsed && typeof parsed === 'object' && parsed.entries) return parsed as SecretFile + } catch { + // missing or corrupt file -> empty store + } + return { version: 1, entries: {} } +} + +function writeFile(data: SecretFile): void { + const tmp = filePath() + '.tmp' + fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }) + fs.renameSync(tmp, filePath()) +} + +let warned = false +function warnFallbackOnce(): void { + if (warned) return + warned = true + console.warn( + '[secretStore] OS encryption unavailable — wallet secrets stored UNENCRYPTED on disk. ' + + 'Install/enable a keyring (libsecret or kwallet) for at-rest protection.' + ) +} + +function decodeEntry(entry: Entry): string { + return entry.enc + ? safeStorage.decryptString(Buffer.from(entry.data, 'base64')) + : Buffer.from(entry.data, 'base64').toString('utf8') +} + +export function getSecret(name: string): string | null { + assertAllowed(name) + const entry = readFile().entries[name] + return entry ? decodeEntry(entry) : null +} + +export function setSecret(name: string, value: string): void { + assertAllowed(name) + const file = readFile() + if (encAvailable()) { + file.entries[name] = { enc: true, data: safeStorage.encryptString(value).toString('base64') } + } else { + warnFallbackOnce() + file.entries[name] = { enc: false, data: Buffer.from(value, 'utf8').toString('base64') } + } + writeFile(file) +} + +export function deleteSecret(name: string): void { + assertAllowed(name) + const file = readFile() + if (file.entries[name]) { + delete file.entries[name] + writeFile(file) + } +} + +export function getAll(): Record { + const file = readFile() + const out: Record = {} + for (const name of Object.keys(file.entries)) { + if (!ALLOWED.has(name)) continue + try { + out[name] = decodeEntry(file.entries[name]) + } catch (err) { + console.error(`[secretStore] failed to read secret ${name}:`, err) + } + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --config vitest.config.electron.ts test/secretStore.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Typecheck electron** + +Run: `npx tsc -p tsconfig.electron.json --noEmit` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add electron/secretStore.ts test/secretStore.test.ts +git commit -m "feat(secrets): main-process safeStorage-backed secret store + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: IPC handlers + preload exposure + +**Files:** +- Modify: `electron/main.ts` (add handlers near the other `ipcMain.handle` blocks, ~line 440) +- Modify: `electron/preload.ts` (add `secrets` to exposed API + `ElectronAPI` interface) +- Modify: `src/global.d.ts` (add `secrets` to the window API type) + +**Interfaces:** +- Consumes: `electron/secretStore` (`getAll`, `setSecret`, `deleteSecret`). +- Produces: `window.electronAPI.secrets`: + - `getAll(): Promise>` + - `set(name: string, value: string): Promise` + - `delete(name: string): Promise` + +- [ ] **Step 1: Add IPC handlers in `electron/main.ts`** + +Add a lazy loader near the other lazy loaders (after `getUpdaterModule`, ~line 27): + +```ts +// Lazy load secret store to avoid loading it before app is ready +let secretStoreModule: typeof import('./secretStore.js') | null = null +async function getSecretStore() { + if (!secretStoreModule) { + secretStoreModule = await import('./secretStore.js') + } + return secretStoreModule +} +``` + +Add handlers in the IPC section (after the storage handlers, before the auto-update handlers, ~line 441): + +```ts +// ===== Secret Store IPC Handlers ===== + +ipcMain.handle('secrets:get-all', async () => { + const store = await getSecretStore() + return store.getAll() +}) + +ipcMain.handle('secrets:set', async (_event, name: string, value: string) => { + const store = await getSecretStore() + store.setSecret(name, value) +}) + +ipcMain.handle('secrets:delete', async (_event, name: string) => { + const store = await getSecretStore() + store.deleteSecret(name) +}) +``` + +- [ ] **Step 2: Expose in `electron/preload.ts`** + +Add to the `exposeInMainWorld('electronAPI', { ... })` object (after the `storage` block, ~line 46): + +```ts + // Secret store (encrypted at rest in the main process) + secrets: { + getAll: (): Promise> => + ipcRenderer.invoke('secrets:get-all'), + set: (name: string, value: string): Promise => + ipcRenderer.invoke('secrets:set', name, value), + delete: (name: string): Promise => + ipcRenderer.invoke('secrets:delete', name), + }, +``` + +Add to the `ElectronAPI` interface (after the `storage` block, ~line 93): + +```ts + secrets: { + getAll: () => Promise>; + set: (name: string, value: string) => Promise; + delete: (name: string) => Promise; + }; +``` + +- [ ] **Step 3: Add to `src/global.d.ts`** + +Open `src/global.d.ts`, find the `electronAPI` window type with the `storage` block, and add immediately after it: + +```ts + secrets: { + getAll: () => Promise>; + set: (name: string, value: string) => Promise; + delete: (name: string) => Promise; + }; +``` + +(Match the existing indentation/style in that file — confirm by reading it first.) + +- [ ] **Step 4: Typecheck both projects** + +Run: `npx tsc -p tsconfig.electron.json --noEmit` +Expected: exit 0. +Run: `npx tsc --noEmit 2>&1 | grep -c "error TS"` +Expected: `2` (the pre-existing WalletService baseline; no new errors). + +- [ ] **Step 5: Commit** + +```bash +git add electron/main.ts electron/preload.ts src/global.d.ts +git commit -m "feat(secrets): expose secret store over IPC + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: Renderer `secrets` facade + migration + +**Files:** +- Create: `src/lib/services/secrets.ts` +- Test: `test/secrets.renderer.test.ts` + +**Interfaces:** +- Consumes: `window.electronAPI.secrets` (Task 2), `window.localStorage`. +- Produces (all synchronous except `hydrate`): + - `hydrate(): Promise` + - `getSnapshot(): string | null`, `setSnapshot(v: string): void`, `clearSnapshot(): void` + - `getKeyHex(): string | null`, `setKeyHex(v: string): void`, `clearKeyHex(): void` + - `getMnemonic(): string | null`, `setMnemonic(v: string): void`, `clearMnemonic(): void` + - `_reset(): void` (test-only) + +- [ ] **Step 1: Write the failing test** + +Create `test/secrets.renderer.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const backing = new Map() +const mockGetAll = vi.fn(async () => Object.fromEntries(backing)) +const mockSet = vi.fn(async (name: string, value: string) => { backing.set(name, value) }) +const mockDelete = vi.fn(async (name: string) => { backing.delete(name) }) + +const localStore = new Map() +;(globalThis as any).window = { + electronAPI: { secrets: { getAll: mockGetAll, set: mockSet, delete: mockDelete } }, +} +;(globalThis as any).localStorage = { + getItem: (k: string) => (localStore.has(k) ? localStore.get(k)! : null), + setItem: (k: string, v: string) => { localStore.set(k, v) }, + removeItem: (k: string) => { localStore.delete(k) }, +} + +let secrets: typeof import('../src/lib/services/secrets') + +describe('renderer secrets facade', () => { + beforeEach(async () => { + backing.clear(); localStore.clear() + mockGetAll.mockClear(); mockSet.mockClear(); mockDelete.mockClear() + vi.resetModules() + secrets = await import('../src/lib/services/secrets') + secrets._reset() + }) + + it('hydrates from the main store into the sync cache', async () => { + backing.set('snap', 'S'); backing.set('primaryKeyHex', 'K') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('S') + expect(secrets.getKeyHex()).toBe('K') + expect(secrets.getMnemonic()).toBeNull() + }) + + it('migrates legacy localStorage values into the store and clears them', async () => { + localStore.set('snap', 'legacy-snap') + localStore.set('mnemonic12', 'legacy-mn') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('legacy-snap') + expect(secrets.getMnemonic()).toBe('legacy-mn') + expect(mockSet).toHaveBeenCalledWith('snap', 'legacy-snap') + expect(mockSet).toHaveBeenCalledWith('mnemonic12', 'legacy-mn') + expect(localStore.has('snap')).toBe(false) + expect(localStore.has('mnemonic12')).toBe(false) + }) + + it('prefers the main store over legacy localStorage', async () => { + backing.set('snap', 'from-store') + localStore.set('snap', 'from-legacy') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('from-store') + expect(localStore.get('snap')).toBe('from-legacy') // not migrated/cleared + }) + + it('setSnapshot updates cache synchronously and persists async', async () => { + await secrets.hydrate() + secrets.setSnapshot('new-snap') + expect(secrets.getSnapshot()).toBe('new-snap') + await Promise.resolve() + expect(mockSet).toHaveBeenCalledWith('snap', 'new-snap') + }) + + it('clearSnapshot removes from cache and store', async () => { + backing.set('snap', 'S') + await secrets.hydrate() + secrets.clearSnapshot() + expect(secrets.getSnapshot()).toBeNull() + await Promise.resolve() + expect(mockDelete).toHaveBeenCalledWith('snap') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --config vitest.config.electron.ts test/secrets.renderer.test.ts` +Expected: FAIL — cannot resolve `../src/lib/services/secrets`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/services/secrets.ts`: + +```ts +/** + * Renderer-side synchronous facade over the encrypted main-process secret + * store (electron/secretStore.ts). Holds an in-memory cache hydrated once at + * startup so existing synchronous call sites keep working; writes update the + * cache immediately and persist asynchronously over IPC. + */ +const SNAP = 'snap' +const KEY = 'primaryKeyHex' +const MN = 'mnemonic12' +const NAMES = [SNAP, KEY, MN] as const + +const cache = new Map() +let hydrated = false + +export async function hydrate(): Promise { + if (hydrated) return + const stored = await window.electronAPI.secrets.getAll() + for (const name of NAMES) { + if (stored[name] != null) { + cache.set(name, stored[name]) + continue + } + // One-time migration of legacy plaintext localStorage values. + const legacy = localStorage.getItem(name) + if (legacy != null) { + cache.set(name, legacy) + try { + await window.electronAPI.secrets.set(name, legacy) + localStorage.removeItem(name) + } catch (err) { + console.error(`[secrets] migration of ${name} failed:`, err) + } + } + } + hydrated = true +} + +function get(name: string): string | null { + return cache.has(name) ? cache.get(name)! : null +} + +function set(name: string, value: string): void { + cache.set(name, value) + void window.electronAPI.secrets + .set(name, value) + .catch((err) => console.error(`[secrets] persist ${name} failed:`, err)) +} + +function clear(name: string): void { + cache.delete(name) + void window.electronAPI.secrets + .delete(name) + .catch((err) => console.error(`[secrets] delete ${name} failed:`, err)) +} + +export const getSnapshot = (): string | null => get(SNAP) +export const setSnapshot = (v: string): void => set(SNAP, v) +export const clearSnapshot = (): void => clear(SNAP) + +export const getKeyHex = (): string | null => get(KEY) +export const setKeyHex = (v: string): void => set(KEY, v) +export const clearKeyHex = (): void => clear(KEY) + +export const getMnemonic = (): string | null => get(MN) +export const setMnemonic = (v: string): void => set(MN, v) +export const clearMnemonic = (): void => clear(MN) + +/** Test-only: reset module state. */ +export function _reset(): void { + cache.clear() + hydrated = false +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --config vitest.config.electron.ts test/secrets.renderer.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/services/secrets.ts test/secrets.renderer.test.ts +git commit -m "feat(secrets): renderer sync facade + legacy migration + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 4: Hydrate at boot + migrate all call sites + +**Files:** +- Modify: `src/main.tsx` (await `hydrate()` before render) +- Modify: `src/lib/utils/keyMaterial.ts` (`primaryKeyHex`/`mnemonic12`) +- Modify: `src/lib/services/WalletService.ts` (`snap`, `primaryKeyHex`) +- Modify (snapshot writes): `src/lib/navigation/Menu.tsx`, `src/lib/pages/Greeter/index.tsx`, `src/lib/pages/Recovery/LostPassword.tsx`, `src/lib/pages/Recovery/RecoverPassword.tsx`, `src/lib/pages/Recovery/LostPhone.tsx`, `src/lib/pages/Recovery/RecoverPresentationKey.tsx`, `src/lib/pages/Dashboard/Settings/Password/index.tsx`, `src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx` + +**Interfaces:** +- Consumes: `src/lib/services/secrets` (Task 3) — `hydrate`, `getSnapshot/setSnapshot/clearSnapshot`, `getKeyHex/setKeyHex/clearKeyHex`, `getMnemonic/setMnemonic/clearMnemonic`. +- Produces: no new exports; behavior change only. + +- [ ] **Step 1: Hydrate before render in `src/main.tsx`** + +Replace the render block so hydration completes first. Add the import at top: + +```ts +import { hydrate as hydrateSecrets } from './lib/services/secrets'; +``` + +Wrap the existing `root.render(...)` call: + +```ts +const rootElement = document.getElementById('root'); +if (rootElement) { + const root = createRoot(rootElement); + // Load (and migrate) encrypted secrets into the sync cache before the wallet + // tree mounts, so snapshot/key reads during init see hydrated values. + hydrateSecrets() + .catch((err) => console.error('[main] secret hydration failed:', err)) + .finally(() => { + root.render( + + + + ); + }); +} +``` + +- [ ] **Step 2: Migrate `src/lib/utils/keyMaterial.ts`** + +Add import at top: + +```ts +import * as secrets from '../services/secrets' +``` + +Replace `persistKeyMaterial` body lines that touch localStorage: + +```ts +export const persistKeyMaterial = (keyHex: string, mnemonic?: string): string => { + const phrase = mnemonic ? normalizeMnemonic(mnemonic) : mnemonicFromKeyHex(keyHex) + secrets.setKeyHex(keyHex) + secrets.setMnemonic(phrase) + return phrase +} +``` + +In `reconcileStoredKeyMaterial`, replace the reads and the error-path removal: + +```ts + const storedMnemonic = (secrets.getMnemonic() || '').trim() + const storedHex = (secrets.getKeyHex() || '').trim() +``` + +and the catch branch `localStorage.removeItem('mnemonic12')` becomes: + +```ts + secrets.clearMnemonic() +``` + +(Leave the `typeof window === 'undefined'` guard as-is.) + +- [ ] **Step 3: Migrate `src/lib/services/WalletService.ts`** + +Add import near the top of the file (match existing import style): + +```ts +import * as secrets from './secrets' +``` + +Apply these replacements (every occurrence): +- `localStorage.snap` (read) → `secrets.getSnapshot()` +- `!localStorage.snap` → `!secrets.getSnapshot()` +- `localStorage.snap = ` → `secrets.setSnapshot()` +- `localStorage.getItem('primaryKeyHex')` → `secrets.getKeyHex()` +- `localStorage.removeItem('snap')` → `secrets.clearSnapshot()` + +Note line 294/666 use the value: `Utils.toArray(localStorage.snap, 'base64')` → assign to a local first to satisfy the null check, e.g.: + +```ts + const snap = secrets.getSnapshot() + if (!snap) return + const snapArr = Utils.toArray(snap, 'base64') +``` + +and line 423–424: + +```ts + if (directKeyMode && secrets.getSnapshot() && secrets.getKeyHex()) { + const storedHex = secrets.getKeyHex()!.trim() +``` + +and line 540: + +```ts + this._snapshotLoaded = !!secrets.getSnapshot() && !!this._managers.walletManager +``` + +- [ ] **Step 4: Migrate the snapshot-write pages** + +In each of these files, add (if not present) `import * as secrets from '/lib/services/secrets'` and replace `localStorage.snap = saveEnhancedSnapshot()` with `secrets.setSnapshot(saveEnhancedSnapshot())`: +- `src/lib/navigation/Menu.tsx:269` +- `src/lib/pages/Greeter/index.tsx:717,769` +- `src/lib/pages/Recovery/LostPassword.tsx:102` +- `src/lib/pages/Recovery/RecoverPassword.tsx:143,166` +- `src/lib/pages/Recovery/LostPhone.tsx:69` +- `src/lib/pages/Recovery/RecoverPresentationKey.tsx:74` +- `src/lib/pages/Dashboard/Settings/Password/index.tsx:34` +- `src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx:57` + +(Compute the relative path per file, e.g. from `src/lib/navigation/` it is `../services/secrets`; from `src/lib/pages/Recovery/` it is `../../services/secrets`.) + +- [ ] **Step 5: Verify no secret left in localStorage** + +Run: `grep -rn "localStorage\(\.\| *\.getItem(\| *\.setItem(\| *\.removeItem(\)['\"]*\(snap\|primaryKeyHex\|mnemonic12\)" src/` +Expected: no output (zero matches). + +- [ ] **Step 6: Typecheck** + +Run: `npx tsc --noEmit 2>&1 | grep -c "error TS"` +Expected: `2` (pre-existing baseline only). +Run: `npx tsc -p tsconfig.electron.json --noEmit` +Expected: exit 0. + +- [ ] **Step 7: Run full test suite** + +Run: `npx vitest run --config vitest.config.electron.ts` +Expected: all test files pass (including new `secretStore` and `secrets.renderer`). + +- [ ] **Step 8: Commit** + +```bash +git add src/main.tsx src/lib/utils/keyMaterial.ts src/lib/services/WalletService.ts src/lib/navigation/Menu.tsx src/lib/pages +git commit -m "feat(secrets): route all secret access through encrypted store + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 5: Renderer build verification + doc update + +**Files:** +- Modify: `docs/SECURITY_REVIEW.md` (move #5 to remediated) + +- [ ] **Step 1: Full renderer build** + +Run: `npm run build:renderer` +Expected: build succeeds (the 2 baseline `WalletService.ts` type errors are emitted by `tsc` but, per current repo state, do not fail the build differently than before — if the build was green before this work, it must stay green; if `tsc` blocks the build, confirm only the 2 known baseline errors are present and unchanged). + +- [ ] **Step 2: Electron build** + +Run: `npm run build:electron` +Expected: succeeds (includes `secretStore.ts` compilation + the cp step). + +- [ ] **Step 3: Update `docs/SECURITY_REVIEW.md`** + +In the "Remediation Status" section, move #5 from "Still open" into "Patched": + +```markdown +- **#5 Plaintext secrets at rest** — `snap`, `primaryKeyHex`, `mnemonic12` moved out of renderer `localStorage` into a main-process file encrypted with Electron `safeStorage` (key held in the OS keychain, not beside the data). Legacy plaintext is migrated and cleared on first launch. Falls back to a clearly-warned plaintext mode only when no OS keyring is available. Note: protects against other apps/users/backups/disk theft, not same-user malware on Win/Linux (passphrase upgrade tracked separately). +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/SECURITY_REVIEW.md +git commit -m "docs: mark security review #5 (secrets at rest) remediated + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Manual verification checklist (post-implementation, run the app) + +- Fresh install: log in, confirm `userData/secrets.dat` is created and contains `enc:true` entries; confirm `localStorage` has no `snap`/`primaryKeyHex`/`mnemonic12`. +- Upgrade path: start from a build/profile that has plaintext `localStorage.snap` (and direct-key `primaryKeyHex`/`mnemonic12`); launch, confirm values migrate into `secrets.dat` and are removed from `localStorage`, wallet still loads. +- Snapshot mutations (add/remove backup, change password/recovery) persist across restart. +- Linux without a keyring: confirm warning logged, `enc:false` entries written, login still works. diff --git a/docs/superpowers/specs/2026-06-19-encrypt-secrets-at-rest-design.md b/docs/superpowers/specs/2026-06-19-encrypt-secrets-at-rest-design.md new file mode 100644 index 0000000..51bdb13 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-encrypt-secrets-at-rest-design.md @@ -0,0 +1,178 @@ +# Encrypt Wallet Secrets at Rest (Security Review #5) + +**Date**: 2026-06-19 +**Status**: Approved design +**Scope**: bsv-desktop (Electron + React) +**Related**: `docs/SECURITY_REVIEW.md` item #5 + +## Problem + +Wallet key material is stored in renderer `localStorage` with no real +at-rest protection: + +- `localStorage.snap` — full wallet snapshot, base64. The toolbox "encrypts" + it but stores the AES key **inside the same blob**: + - `SimpleWalletManager`: `[ snapshotKey(32) + encryptedPayload ]` + - `CWIStyleWalletManager` v2: `[ version=2 ][ snapshotKey(32) ][ activeProfileId(16) ][ encryptedPayload ]` + Anyone who reads the blob off disk strips the first 32 bytes and decrypts. + This applies to **all** login modes, not just direct-key. +- `localStorage.primaryKeyHex`, `localStorage.mnemonic12` — raw plaintext + (direct-key mode), written in `src/lib/utils/keyMaterial.ts`. + +`localStorage` persists to the Chromium LevelDB on disk in cleartext, readable +by any other process/user, captured by backups and disk theft. + +## Goal + +No usable key material on disk in cleartext. Move `snap`, `primaryKeyHex`, +and `mnemonic12` out of renderer `localStorage` into a main-process file +encrypted with OS-backed `safeStorage` (Electron 41). + +## Non-goals / honest limits (must be documented in code) + +- `safeStorage` keys are per-user (Windows DPAPI, Linux libsecret/kwallet), + so this protects against other apps/users, backups, and disk theft — **not** + same-user malware on Windows/Linux. macOS Keychain ACL is stronger. The + passphrase upgrade (below) is the future answer to same-user malware. +- Plaintext key material still lives in **renderer memory** while the wallet + runs. Unavoidable for a hot wallet; out of scope for at-rest protection. +- This does not protect against a compromised renderer (XSS): the renderer can + still call the `secrets` IPC for the values it is allowed to read. #5 is about + data at rest, not renderer compromise. + +## Decisions (locked) + +1. **Key source**: Electron `safeStorage` now (transparent, no UX change). + A user-passphrase (KDF → AES-GCM) upgrade is left as a future opt-in setting. +2. **Storage location**: a main-process encrypted file (`userData/secrets.dat`). + Secrets leave `localStorage` entirely. +3. **Fallback** when `safeStorage` is unavailable: store plaintext with a clear + warning (no brick, no regression vs today). + +## Architecture + +### 1. Main process — `electron/secretStore.ts` + +Backed by `safeStorage`. Persists `userData/secrets.dat`: + +```jsonc +{ + "version": 1, + "entries": { + "snap": { "enc": true, "data": "" }, + "primaryKeyHex":{ "enc": true, "data": "" }, + "mnemonic12": { "enc": true, "data": "" } + } +} +``` + +API (all synchronous in main; file is small): + +- `getSecret(name): string | null` +- `setSecret(name, value): void` +- `deleteSecret(name): void` +- `getAll(): Record` — decrypts every entry for startup hydration + +Encryption per entry: +- Available: `data = safeStorage.encryptString(value).toString('base64')`, `enc: true`. +- Decrypt: `safeStorage.decryptString(Buffer.from(data, 'base64'))`. +- **Fallback** (`!safeStorage.isEncryptionAvailable()` OR + `safeStorage.getSelectedStorageBackend() === 'basic_text'`): + `data = Buffer.from(value).toString('base64')`, `enc: false`, and log a clear + one-time warning. The read path branches on each entry's `enc` flag, so a file + written in fallback mode still reads correctly if encryption later becomes + available (and is re-encrypted on next write). + +**Name allow-list**: only `snap`, `primaryKeyHex`, `mnemonic12` are accepted. +Any other name throws — the IPC channel is not a general read/write oracle +(same philosophy as the storage allow-list fix for #3). + +Writes are atomic: write to `secrets.dat.tmp`, then rename over `secrets.dat`. + +### 2. IPC + preload + +`electron/main.ts` handlers (lazy-import `secretStore`): +- `secrets:get-all` → `getAll()` +- `secrets:set` (name, value) → `setSecret` +- `secrets:delete` (name) → `deleteSecret` + +`electron/preload.ts` exposes `window.electronAPI.secrets`: +- `getAll(): Promise>` +- `set(name, value): Promise` +- `delete(name): Promise` + +Types added to `preload.ts` `ElectronAPI` and `src/global.d.ts`. + +### 3. Renderer — `src/lib/services/secrets.ts` + +Sync facade over an in-memory cache, so the ~30 existing call sites stay +synchronous (they currently do `localStorage.snap = …` and `getItem`). + +- `async hydrate(): Promise` — called once at startup. Pulls `getAll()` + into the cache and runs migration (below). +- Snapshot: `getSnapshot(): string | null`, `setSnapshot(v: string): void`, + `clearSnapshot(): void`. +- Key material: `getKeyHex()`, `getMnemonic()`, `setKeyMaterial(hex, mnemonic)`, + `clearKeyMaterial()`. +- Writes update the cache immediately and fire `window.electronAPI.secrets.set` + (persist) without blocking the caller; deletes call `secrets.delete`. + +### Data flow + +- **Boot** (`src/main.tsx`): wrap render in an async IIFE; `await secrets.hydrate()` + before `root.render(...)`, so `WalletService.restoreConfigFromSnapshot()` and + `initialize()` see hydrated values via the sync facade. +- **Read**: call sites call `secrets.getSnapshot()` etc. → cache. +- **Write**: `secrets.setSnapshot(v)` → cache updated → async encrypted persist. + +### Migration (inside `hydrate`) + +For each of `snap`, `primaryKeyHex`, `mnemonic12`: +1. If main `getAll()` returned it → load into cache. +2. Else if legacy `localStorage[name]` exists → load into cache, + `await secrets.set(name, value)` (now encrypted), then + `localStorage.removeItem(name)`. + +One-time, automatic, no user action. After migration the main store is the +source of truth; `localStorage` holds no secrets. + +## Call sites to migrate + +- `src/lib/utils/keyMaterial.ts` — `persistKeyMaterial`, `reconcileStoredKeyMaterial` + (`primaryKeyHex`, `mnemonic12`). +- `src/lib/services/WalletService.ts` — `localStorage.snap` reads/writes, + `localStorage.getItem('primaryKeyHex')`, `removeItem('snap')`. +- ~10 `localStorage.snap = saveEnhancedSnapshot()` writes across pages: + `navigation/Menu.tsx`, `pages/Greeter/index.tsx`, + `pages/Recovery/{LostPassword,RecoverPassword,LostPhone,RecoverPresentationKey}.tsx`, + `pages/Dashboard/Settings/{Password,RecoveryKey}/index.tsx`. + +`reconcileStoredKeyMaterial` is sync today and used at module/render time; it +keeps working against the hydrated cache. Confirm during planning that all such +readers run after `hydrate()` (they render inside the React tree, which mounts +after the boot await). + +## Error handling + +- `secretStore` read/parse failure → treat as empty store, log; do not crash. + (Matches current `localStorage.removeItem('snap')` on bad snapshot.) +- IPC `secrets:*` errors surface as rejected promises; renderer writes are + best-effort with a console error + toast on failure (snapshot persistence + failure already toasts in `WalletService`). +- Atomic temp-file write prevents a torn `secrets.dat`. + +## Testing + +- **Unit — `secretStore`**: round-trip encrypted; round-trip fallback (`enc:false`); + per-entry `enc` honored on read; name allow-list rejects unknown names; atomic + write leaves no partial file on simulated failure. +- **Unit — renderer `secrets`**: cache get/set/clear; migration moves legacy + `localStorage` secrets into the store and clears `localStorage`. +- **Manual**: (a) fresh install creates encrypted `secrets.dat`; (b) upgrade from + an existing plaintext install migrates and clears `localStorage`; (c) Linux with + no keyring falls back to plaintext with a warning and still logs in. + +## Out of scope (future) + +- Passphrase-derived encryption (defeats same-user malware) as an opt-in setting. +- Encrypting the SQLite wallet DB (`~/.bsv-desktop/wallet.db`) — separate item. diff --git a/electron/main.ts b/electron/main.ts index 73e9984..036d80c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,15 @@ function getUpdaterModule() { return updaterModule; } +// Lazy load secret store to avoid loading it before app is ready +let secretStoreModule: typeof import('./secretStore.js') | null = null; +async function getSecretStore() { + if (!secretStoreModule) { + secretStoreModule = await import('./secretStore.js'); + } + return secretStoreModule; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -109,25 +118,46 @@ function createWindow() { // Open external links in the default browser mainWindow.webContents.setWindowOpenHandler(({ url }) => { - // If the URL is external (not our app), open it in the default browser - if (url.startsWith('http://') || url.startsWith('https://')) { + // Only ever hand http(s) URLs to the OS; deny everything else + // (javascript:, file:, data:, custom protocol handlers, etc.). + if (isSafeExternalUrl(url)) { shell.openExternal(url); - return { action: 'deny' }; // Prevent Electron from opening a new window } - return { action: 'allow' }; + return { action: 'deny' }; // Never let the renderer open a new Electron window }); - // Handle navigation attempts (like clicking links) - mainWindow.webContents.on('will-navigate', (event, url) => { - // Allow navigation within our app, but open external URLs in browser - if (url.startsWith('http://') || url.startsWith('https://')) { - const appUrl = isDev ? 'http://localhost:5173' : 'file://'; - if (!url.startsWith(appUrl)) { - event.preventDefault(); - shell.openExternal(url); - } + // Handle navigation attempts (like clicking links). Cover both will-navigate + // and will-redirect — the latter fires on server-side redirects and was + // previously unguarded. + const handleNavigation = (event: Electron.Event, url: string) => { + if (isAppUrl(url)) { + return; // in-app navigation is fine } - }); + // Anything else leaves the app: block it and only forward safe URLs to the OS. + event.preventDefault(); + if (isSafeExternalUrl(url)) { + shell.openExternal(url); + } + }; + mainWindow.webContents.on('will-navigate', handleNavigation); + mainWindow.webContents.on('will-redirect', handleNavigation); +} + +// True for URLs that belong to the app itself (dev server or packaged file://). +function isAppUrl(url: string): boolean { + const appUrl = isDev ? 'http://localhost:5173' : 'file://'; + return url.startsWith(appUrl); +} + +// Only http(s) URLs may be handed to shell.openExternal. This blocks +// javascript:, file:, data: and arbitrary custom-protocol handlers. +function isSafeExternalUrl(url: string): boolean { + try { + const { protocol } = new URL(url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } } // ===== IPC Handlers ===== @@ -146,15 +176,16 @@ ipcMain.handle('request-focus', async () => { if (process.platform === 'darwin') { // macOS specific focus handling - const { exec } = await import('child_process'); + const { execFile } = await import('child_process'); const util = await import('util'); - const execPromise = util.promisify(exec); + const execFilePromise = util.promisify(execFile); try { // Capture currently focused app before we steal focus - const { stdout } = await execPromise( - 'osascript -e \'tell application "System Events" to get the bundle identifier of the first process whose frontmost is true\'' - ); + const { stdout } = await execFilePromise('osascript', [ + '-e', + 'tell application "System Events" to get the bundle identifier of the first process whose frontmost is true' + ]); const captured = stdout.trim(); // Don't record ourselves — can happen if focus is called while already active if (captured && captured !== OWN_BUNDLE_ID) { @@ -210,16 +241,25 @@ ipcMain.handle('relinquish-focus', async () => { // macOS: try to restore previous app if (prevBundleId && prevBundleId !== 'com.apple.finder' && prevBundleId !== OWN_BUNDLE_ID) { const util = await import('util'); - const { exec } = await import('child_process'); - const execPromise = util.promisify(exec); + const { execFile } = await import('child_process'); + const execFilePromise = util.promisify(execFile); const target = prevBundleId; prevBundleId = null; + // Bundle identifiers are reverse-DNS strings; reject anything with + // characters that could alter the AppleScript expression before use. + if (!/^[A-Za-z0-9.\-]+$/.test(target)) { + console.error('Refusing to restore focus: invalid bundle identifier'); + return; + } try { // Blur our window first so macOS doesn't fight the activation mainWindow.blur(); // Note: 'tell application id "..." to activate' is ignored by macOS 26 // when called from a subprocess. 'set frontmost' via System Events works. - await execPromise(`osascript -e 'tell application "System Events" to set frontmost of (first process whose bundle identifier is "${target}") to true'`); + await execFilePromise('osascript', [ + '-e', + `tell application "System Events" to set frontmost of (first process whose bundle identifier is "${target}") to true` + ]); } catch (error) { console.error('Failed to restore previous app:', error); } @@ -366,6 +406,18 @@ ipcMain.handle('proxy-fetch-manifest', async (_event, url: string) => { redirect: 'follow' }); + // Re-validate the *resolved* URL after any redirects. A server could 302 to + // http://, a non-manifest path, or an internal address; enforce the same + // constraints we applied to the input. + const finalUrl = new URL(response.url || url); + if (finalUrl.protocol !== 'https:') { + throw new Error('Redirected to a non-HTTPS URL'); + } + const finalPath = finalUrl.pathname.toLowerCase(); + if (!finalPath.endsWith('/manifest.json') && finalPath !== '/manifest.json') { + throw new Error('Redirected to a non-manifest URL'); + } + const headers: [string, string][] = []; response.headers.forEach((value, key) => { headers.push([key, value]); @@ -439,6 +491,23 @@ ipcMain.handle('storage:initialize-services', async (_event, identityKey: string } }); +// ===== Secret Store IPC Handlers ===== + +ipcMain.handle('secrets:get-all', async () => { + const store = await getSecretStore(); + return store.getAll(); +}); + +ipcMain.handle('secrets:set', async (_event, name: string, value: string) => { + const store = await getSecretStore(); + store.setSecret(name, value); +}); + +ipcMain.handle('secrets:delete', async (_event, name: string) => { + const store = await getSecretStore(); + store.deleteSecret(name); +}); + // ===== Auto-Update IPC Handlers ===== ipcMain.handle('update:check', async () => { diff --git a/electron/preload.ts b/electron/preload.ts index 6cbebcf..af09a72 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -45,6 +45,16 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('storage:call-method', identityKey, chain, method, args) }, + // Secret store (encrypted at rest in the main process) + secrets: { + getAll: (): Promise> => + ipcRenderer.invoke('secrets:get-all'), + set: (name: string, value: string): Promise => + ipcRenderer.invoke('secrets:set', name, value), + delete: (name: string): Promise => + ipcRenderer.invoke('secrets:delete', name), + }, + // Auto-update operations updates: { check: () => ipcRenderer.invoke('update:check'), @@ -91,6 +101,11 @@ export interface ElectronAPI { initializeServices: (identityKey: string, chain: 'main' | 'test') => Promise<{ success: boolean; error?: string }>; callMethod: (identityKey: string, chain: 'main' | 'test', method: string, args: any[]) => Promise<{ success: boolean; result?: any; error?: string }>; }; + secrets: { + getAll: () => Promise>; + set: (name: string, value: string) => Promise; + delete: (name: string) => Promise; + }; updates: { check: () => Promise<{ success: boolean; updateInfo?: any; error?: string }>; download: () => Promise<{ success: boolean; error?: string }>; diff --git a/electron/secretStore.ts b/electron/secretStore.ts new file mode 100644 index 0000000..d2d8140 --- /dev/null +++ b/electron/secretStore.ts @@ -0,0 +1,102 @@ +import { app, safeStorage } from 'electron' +import path from 'path' +import fs from 'fs' + +/** Only these secret names may be stored or read. */ +const ALLOWED: ReadonlySet = new Set(['snap', 'primaryKeyHex', 'mnemonic12']) + +interface Entry { enc: boolean; data: string } +interface SecretFile { version: number; entries: Record } + +function assertAllowed(name: string): void { + if (!ALLOWED.has(name)) throw new Error(`secretStore: name not permitted: ${name}`) +} + +function filePath(): string { + return path.join(app.getPath('userData'), 'secrets.dat') +} + +/** True only when the OS provides real encryption (not Linux basic_text). */ +function encAvailable(): boolean { + try { + if (!safeStorage.isEncryptionAvailable()) return false + const getBackend = (safeStorage as any).getSelectedStorageBackend + if (typeof getBackend === 'function' && getBackend.call(safeStorage) === 'basic_text') return false + return true + } catch { + return false + } +} + +function readFile(): SecretFile { + try { + const parsed = JSON.parse(fs.readFileSync(filePath(), 'utf8')) + if (parsed && typeof parsed === 'object' && parsed.entries) return parsed as SecretFile + } catch { + // missing or corrupt file -> empty store + } + return { version: 1, entries: {} } +} + +function writeFile(data: SecretFile): void { + const tmp = filePath() + '.tmp' + fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }) + fs.renameSync(tmp, filePath()) +} + +let warned = false +function warnFallbackOnce(): void { + if (warned) return + warned = true + console.warn( + '[secretStore] OS encryption unavailable — wallet secrets stored UNENCRYPTED on disk. ' + + 'Install/enable a keyring (libsecret or kwallet) for at-rest protection.' + ) +} + +function decodeEntry(entry: Entry): string { + return entry.enc + ? safeStorage.decryptString(Buffer.from(entry.data, 'base64')) + : Buffer.from(entry.data, 'base64').toString('utf8') +} + +export function getSecret(name: string): string | null { + assertAllowed(name) + const entry = readFile().entries[name] + return entry ? decodeEntry(entry) : null +} + +export function setSecret(name: string, value: string): void { + assertAllowed(name) + const file = readFile() + if (encAvailable()) { + file.entries[name] = { enc: true, data: safeStorage.encryptString(value).toString('base64') } + } else { + warnFallbackOnce() + file.entries[name] = { enc: false, data: Buffer.from(value, 'utf8').toString('base64') } + } + writeFile(file) +} + +export function deleteSecret(name: string): void { + assertAllowed(name) + const file = readFile() + if (file.entries[name]) { + delete file.entries[name] + writeFile(file) + } +} + +export function getAll(): Record { + const file = readFile() + const out: Record = {} + for (const name of Object.keys(file.entries)) { + if (!ALLOWED.has(name)) continue + try { + out[name] = decodeEntry(file.entries[name]) + } catch (err) { + console.error(`[secretStore] failed to read secret ${name}:`, err) + } + } + return out +} diff --git a/electron/sslCert.ts b/electron/sslCert.ts index a0903e2..e3a0f2e 100644 --- a/electron/sslCert.ts +++ b/electron/sslCert.ts @@ -1,11 +1,12 @@ import { app, dialog } from 'electron'; import path from 'path'; import fs from 'fs'; +import os from 'os'; import forge from 'node-forge'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); interface CertificateKeyPair { cert: string; @@ -135,13 +136,14 @@ async function isCertTrusted(certPath: string): Promise { try { if (process.platform === 'darwin') { // macOS: Check if cert is in user keychain (CN is "localhost", not org name) - await execAsync(`security find-certificate -c "localhost" -p ~/Library/Keychains/login.keychain-db`); + const loginKeychain = path.join(os.homedir(), 'Library/Keychains/login.keychain-db'); + await execFileAsync('security', ['find-certificate', '-c', 'localhost', '-p', loginKeychain]); // Also verify it's actually trusted, not just present - await execAsync(`security verify-cert -c "${certPath}" -p ssl -s localhost`); + await execFileAsync('security', ['verify-cert', '-c', certPath, '-p', 'ssl', '-s', 'localhost']); return true; } else if (process.platform === 'win32') { // Windows: Check if cert is in trusted root store - const { stdout } = await execAsync(`certutil -user -verifystore Root "BSV Desktop"`); + const { stdout } = await execFileAsync('certutil', ['-user', '-verifystore', 'Root', 'BSV Desktop']); return stdout.includes('BSV Desktop'); } else { // Linux: Various cert stores, hard to check reliably @@ -205,18 +207,28 @@ Certificate location: ${certPath}`; if (platform === 'darwin') { // macOS: add-trusted-cert with -d flag adds to admin trust settings (needs auth prompt) // First try without sudo — works if user has keychain access + const loginKeychain = path.join(os.homedir(), 'Library/Keychains/login.keychain-db'); try { - await execAsync(`security add-trusted-cert -d -r trustRoot -k ~/Library/Keychains/login.keychain-db "${certPath}"`); + await execFileAsync('security', [ + 'add-trusted-cert', '-d', '-r', 'trustRoot', '-k', loginKeychain, certPath + ]); } catch (firstErr) { console.log('Direct trust failed, trying with osascript admin prompt...'); - // Use osascript to prompt for admin password - await execAsync(`osascript -e 'do shell script "security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \\"${certPath}\\"" with administrator privileges'`); + // Use osascript to prompt for admin password. The inner shell script runs + // `security` with administrator privileges; build it from a JSON-quoted + // path so shell metacharacters in certPath cannot break out of the string. + const innerCmd = + 'security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ' + + JSON.stringify(certPath); + await execFileAsync('osascript', [ + '-e', `do shell script ${JSON.stringify(innerCmd)} with administrator privileges` + ]); } return true; } else if (platform === 'win32') { // Windows: Import to Trusted Root store - await execAsync(`certutil -addstore -user Root "${certPath}"`); + await execFileAsync('certutil', ['-addstore', '-user', 'Root', certPath]); // await dialog.showMessageBox({ // type: 'info', diff --git a/electron/storage.ts b/electron/storage.ts index 161d336..246118e 100644 --- a/electron/storage.ts +++ b/electron/storage.ts @@ -24,6 +24,46 @@ const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +/** + * Allow-list of StorageKnex methods the renderer is permitted to invoke through + * the `storage:call-method` IPC channel. Must stay in sync with the methods + * exposed by src/lib/StorageElectronIPC.ts. Anything not listed here is rejected. + */ +const ALLOWED_STORAGE_METHODS: ReadonlySet = new Set([ + // Certificates + 'insertCertificate', 'updateCertificate', 'findCertificates', 'deleteCertificate', + 'insertCertificateAuth', 'relinquishCertificate', 'findCertificatesAuth', 'listCertificates', + // Outputs + 'insertOutput', 'updateOutput', 'findOutputs', 'deleteOutput', + 'relinquishOutput', 'findOutputsAuth', 'listOutputs', + // Transactions + 'insertTransaction', 'updateTransaction', 'findTransactions', 'deleteTransaction', + // Commissions + 'insertCommission', 'findCommissions', + // Output baskets + 'insertOutputBasket', 'updateOutputBasket', 'findOutputBaskets', 'deleteOutputBasket', + 'findOutputBasketsAuth', + // Proven transactions + 'insertProvenTx', 'updateProvenTx', 'findProvenTxs', 'deleteProvenTx', + 'insertProvenTxReq', 'updateProvenTxReq', 'findProvenTxReqs', 'deleteProvenTxReq', + // Labels & tags + 'insertTxLabel', 'findTxLabels', 'deleteTxLabel', + 'insertOutputTag', 'findOutputTags', 'deleteOutputTag', + // Counterparties + 'insertCounterparty', 'updateCounterparty', 'findCounterparties', 'deleteCounterparty', + // Sync + 'processSyncChunk', 'requestSyncChunk', 'getSyncChunk', 'findOrInsertSyncStateAuth', + // Wallet / chain status + 'getWalletStatus', 'getHeight', 'updateHeight', + // Permissions + 'findPermissions', 'insertPermission', 'updatePermission', 'deletePermission', + // Settings + 'findSettings', 'insertSetting', 'updateSetting', 'deleteSetting', + // Lifecycle & actions + 'destroy', 'migrate', 'findOrInsertUser', 'setActive', + 'abortAction', 'createAction', 'processAction', 'internalizeAction', 'listActions', +]); + // Lazy-load knex to avoid loading better-sqlite3 until actually needed let createKnex: any = null; function getCreateKnex() { @@ -352,6 +392,13 @@ class StorageManager { method: string, args: any[] ): Promise { + // Only methods the renderer's StorageElectronIPC wrapper actually calls are + // permitted. This prevents a compromised renderer from invoking arbitrary + // methods (or prototype members) on the StorageKnex instance via IPC. + if (!ALLOWED_STORAGE_METHODS.has(method)) { + throw new Error(`Storage method not permitted: ${method}`); + } + const storage = await this.getOrCreateStorage(identityKey, chain); // Type assertion to access storage methods dynamically diff --git a/package-lock.json b/package-lock.json index e2f29c7..5df112c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,10 @@ "@bsv/btms-permission-module-ui": "^1.0.0", "@bsv/identity-react": "^1.1.14", "@bsv/message-box-client": "^2.1.2", - "@bsv/sdk": "^2.1.3", + "@bsv/sdk": "^2.1.6", "@bsv/uhrp-react": "^1.0.6", - "@bsv/wallet-toolbox": "^2.1.28", - "@bsv/wallet-toolbox-client": "^2.1.28", + "@bsv/wallet-toolbox": "^2.1.30", + "@bsv/wallet-toolbox-client": "^2.1.30", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.4.8", @@ -27,6 +27,7 @@ "better-sqlite3": "^12.8.0", "cors": "^2.8.6", "date-fns": "^4.1.0", + "dompurify": "^3.4.11", "dotenv": "^17.2.4", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", @@ -68,12 +69,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -82,9 +83,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -92,21 +93,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -130,13 +131,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -146,14 +147,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -163,37 +164,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -213,27 +214,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -241,26 +242,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -311,31 +312,31 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -343,13 +344,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -375,6 +376,12 @@ "express": "^5.1.0" } }, + "node_modules/@bsv/auth-express-middleware/node_modules/@bsv/sdk": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.1.3.tgz", + "integrity": "sha512-nmni2Q762/TeWz6MfbHdWRSTwzPV8o5t34789/u8MA+kPNnoZfs/BQOw4c5FtlXYcu3Soxbhup3+02zdrP+drg==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/@bsv/auth-express-middleware/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -789,6 +796,12 @@ "express": "^5.1.0" } }, + "node_modules/@bsv/payment-express-middleware/node_modules/@bsv/sdk": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.1.3.tgz", + "integrity": "sha512-nmni2Q762/TeWz6MfbHdWRSTwzPV8o5t34789/u8MA+kPNnoZfs/BQOw4c5FtlXYcu3Soxbhup3+02zdrP+drg==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/@bsv/payment-express-middleware/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1113,9 +1126,9 @@ } }, "node_modules/@bsv/sdk": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.1.3.tgz", - "integrity": "sha512-nmni2Q762/TeWz6MfbHdWRSTwzPV8o5t34789/u8MA+kPNnoZfs/BQOw4c5FtlXYcu3Soxbhup3+02zdrP+drg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.1.6.tgz", + "integrity": "sha512-Szm1KM9BbA0Yc091gCW5NkFu9lhcykaDK9yXab/B/6f18h05uys3HWKaxHO05pKl0fYIpvwFrSScOefy0RT3GQ==", "license": "SEE LICENSE IN LICENSE.txt" }, "node_modules/@bsv/uhrp-react": { @@ -1132,39 +1145,369 @@ } }, "node_modules/@bsv/wallet-toolbox": { - "version": "2.1.28", - "resolved": "https://registry.npmjs.org/@bsv/wallet-toolbox/-/wallet-toolbox-2.1.28.tgz", - "integrity": "sha512-9pkuOVJUQYEUTfn916hYFRedMRXYzlkLDiMmovC+FmezrFzWkITnxsTadhrbI3wCbOhd0aOfqxngqdbJ14GbKw==", + "version": "2.1.30", + "resolved": "https://registry.npmjs.org/@bsv/wallet-toolbox/-/wallet-toolbox-2.1.30.tgz", + "integrity": "sha512-uREWUHoQ8pxfK9VONI5lHlVApBdCsLARsJxPOalCHTBlrpFohAVvGXBE/D1XX8ZfFZD/nRfZiSVPP3v75BiT0w==", "license": "SEE LICENSE IN license.md", "dependencies": { - "@bsv/auth-express-middleware": "2.0.6", - "@bsv/payment-express-middleware": "2.0.3", - "@bsv/sdk": "2.1.3", - "better-sqlite3": "^12.6.2", - "express": "^4.21.2", + "@bsv/auth-express-middleware": "^2.0.6", + "@bsv/payment-express-middleware": "^2.0.3", + "@bsv/sdk": "^2.1.3", + "better-sqlite3": "^12.10.1", + "express": "^5.2.1", "hash-wasm": "^4.12.0", "idb": "^8.0.2", - "knex": "^3.1.0", - "mysql2": "^3.12.0", - "ws": "^8.18.3" + "knex": "^3.2.10", + "mysql2": "^3.22.5", + "ws": "^8.21.0" } }, "node_modules/@bsv/wallet-toolbox-client": { - "version": "2.1.28", - "resolved": "https://registry.npmjs.org/@bsv/wallet-toolbox-client/-/wallet-toolbox-client-2.1.28.tgz", - "integrity": "sha512-kxkQXQxff0xk9IfWjSJW5xq1orlGfoG0ZYG3MFOmGXykMeTruYp++79ESIt0M9xtusMuB78ktaJvcdeuY1T5mg==", + "version": "2.1.30", + "resolved": "https://registry.npmjs.org/@bsv/wallet-toolbox-client/-/wallet-toolbox-client-2.1.30.tgz", + "integrity": "sha512-PaFXZWHfX8brz0feooGDmNUCi54MzGO0ua010vt3FFLGDKrLeQCR/Tf/nkuBM70gDe/VdDXa+J6HSRPLHNmoIg==", "license": "SEE LICENSE IN license.md", "dependencies": { - "@bsv/sdk": "2.1.2", + "@bsv/sdk": "^2.1.3", "hash-wasm": "^4.12.0", "idb": "^8.0.2" } }, - "node_modules/@bsv/wallet-toolbox-client/node_modules/@bsv/sdk": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.1.2.tgz", - "integrity": "sha512-648RLJEkbH+vFt5Q65orm3jdmZDnmLdt8YUpyQcctxVYwHvhEN/7RxMXjA+OnNLHZV1IOstqwXQ+4ar89KUs7Q==", - "license": "SEE LICENSE IN LICENSE.txt" + "node_modules/@bsv/wallet-toolbox/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@bsv/wallet-toolbox/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/@develar/schema-utils": { "version": "2.6.5", @@ -3904,6 +4247,13 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -3960,15 +4310,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -3977,13 +4327,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -4004,9 +4354,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { @@ -4017,13 +4367,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -4032,13 +4382,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -4047,9 +4397,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4060,13 +4410,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -4508,19 +4858,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -4528,7 +4881,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { @@ -4552,9 +4905,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -4565,7 +4918,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -4664,9 +5017,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -4684,11 +5037,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4901,9 +5254,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001746", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", - "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -5169,15 +5522,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -5659,6 +6012,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -5920,9 +6282,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.228", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz", - "integrity": "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -6081,15 +6443,15 @@ } }, "node_modules/engine.io-client": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", - "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3", + "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, @@ -6329,14 +6691,14 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -6355,7 +6717,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -6607,17 +6969,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -7005,9 +7367,9 @@ "license": "MIT" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7591,9 +7953,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7766,9 +8138,9 @@ } }, "node_modules/knex": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", - "integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==", + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.2.10.tgz", + "integrity": "sha512-oypTHfrc9i72iyxaUQBKHOxhcr0xM65MPf6FpN02nimsftXwzXprIkLjfXdubvhbu4PMWLp023q8o8CYvHSuZw==", "license": "MIT", "dependencies": { "colorette": "2.0.19", @@ -7779,7 +8151,7 @@ "get-package-type": "^0.1.0", "getopts": "2.3.0", "interpret": "^2.2.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "pg-connection-string": "2.6.2", "rechoir": "^0.8.0", "resolve-from": "^5.0.0", @@ -7792,6 +8164,9 @@ "engines": { "node": ">=16" }, + "peerDependencies": { + "pg-query-stream": "^4.14.0" + }, "peerDependenciesMeta": { "better-sqlite3": { "optional": true @@ -7808,6 +8183,9 @@ "pg-native": { "optional": true }, + "pg-query-stream": { + "optional": true + }, "sqlite3": { "optional": true }, @@ -7957,9 +8335,9 @@ } }, "node_modules/lru.min": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", - "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -8426,9 +8804,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -8542,29 +8920,31 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz", - "integrity": "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg==", + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", "license": "MIT", "dependencies": { - "aws-ssl-profiles": "^1.1.1", + "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" }, "engines": { "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" } }, "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8578,24 +8958,15 @@ } }, "node_modules/named-placeholders": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", - "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", "license": "MIT", "dependencies": { - "lru-cache": "^7.14.1" + "lru.min": "^1.1.0" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/named-placeholders/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=8.0.0" } }, "node_modules/nanoid": { @@ -8735,11 +9106,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "8.1.0", @@ -9323,9 +9697,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -9964,11 +10338,6 @@ "node": ">=4" } }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -10031,9 +10400,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -10327,13 +10696,19 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, "node_modules/stack-utils": { @@ -10534,9 +10909,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10819,9 +11194,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -10965,9 +11340,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -11077,9 +11452,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -11190,20 +11565,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -11233,8 +11608,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -11367,9 +11742,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index a41976b..21dc437 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,10 @@ "@bsv/btms-permission-module-ui": "^1.0.0", "@bsv/identity-react": "^1.1.14", "@bsv/message-box-client": "^2.1.2", - "@bsv/sdk": "^2.1.3", + "@bsv/sdk": "^2.1.6", "@bsv/uhrp-react": "^1.0.6", - "@bsv/wallet-toolbox": "^2.1.28", - "@bsv/wallet-toolbox-client": "^2.1.28", + "@bsv/wallet-toolbox": "^2.1.30", + "@bsv/wallet-toolbox-client": "^2.1.30", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.4.8", @@ -41,6 +41,7 @@ "better-sqlite3": "^12.8.0", "cors": "^2.8.6", "date-fns": "^4.1.0", + "dompurify": "^3.4.11", "dotenv": "^17.2.4", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", diff --git a/src/global.d.ts b/src/global.d.ts index c8aef47..43b8f07 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -18,6 +18,11 @@ export interface ElectronAPI { callMethod: (identityKey: string, chain: 'main' | 'test', method: string, args: any[]) => Promise<{ success: boolean; result?: any; error?: string }>; initializeServices: (identityKey: string, chain: 'main' | 'test') => Promise<{ success: boolean; error?: string }>; }; + secrets: { + getAll: () => Promise>; + set: (name: string, value: string) => Promise; + delete: (name: string) => Promise; + }; updates: { check: () => Promise<{ success: boolean; updateInfo?: any; error?: string }>; download: () => Promise<{ success: boolean; error?: string }>; diff --git a/src/lib/components/UpdateNotification.tsx b/src/lib/components/UpdateNotification.tsx index ae87d5c..1c8c00e 100644 --- a/src/lib/components/UpdateNotification.tsx +++ b/src/lib/components/UpdateNotification.tsx @@ -11,6 +11,7 @@ import { Box } from '@mui/material'; import { toast } from 'react-toastify'; +import DOMPurify from 'dompurify'; interface UpdateInfo { version: string; @@ -189,7 +190,10 @@ export const UpdateNotification: React.FC = ({ >
{ throw new Error('Passwords do not match.') } await managers.walletManager.changePassword(password) - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.dark('Password changed!') setPassword('') setConfirmPassword('') diff --git a/src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx b/src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx index 5534b70..3e9c5ff 100644 --- a/src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx +++ b/src/lib/pages/Dashboard/Settings/RecoveryKey/index.tsx @@ -1,4 +1,5 @@ import { useState, useContext, useCallback } from 'react' +import * as secrets from '../../../../services/secrets'; import { useTranslation } from 'react-i18next' import { Typography, Button, CircularProgress @@ -54,7 +55,7 @@ const RecoveryKeySettings: React.FC = ({ history, onVi setChangeLoading(true) await managers.walletManager.changeRecoveryKey() setRecoveryKey('') - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.dark('Recovery key changed!') } catch (e) { toast.error(e.message) diff --git a/src/lib/pages/Greeter/index.tsx b/src/lib/pages/Greeter/index.tsx index eaacb0a..e7b1f89 100644 --- a/src/lib/pages/Greeter/index.tsx +++ b/src/lib/pages/Greeter/index.tsx @@ -1,4 +1,5 @@ import { useContext, useState, useRef, useCallback, useEffect } from 'react' +import * as secrets from '../../services/secrets'; import { useTranslation } from 'react-i18next' import { Typography, @@ -714,7 +715,7 @@ const Greeter: React.FC = ({ history }) => { await (walletManager as any).providePassword(password) if (walletManager.authenticated) { // Save snapshot to local storage - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.success(t('password_success_authenticated')) history.push('/dashboard/apps') } else { @@ -766,7 +767,7 @@ const Greeter: React.FC = ({ history }) => { await (walletManager as any).providePrivilegedKeyManager(createDisabledPrivilegedManager()) if (walletManager.authenticated) { - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.success(t('password_success_authenticated')) history.push('/dashboard/apps') } else { diff --git a/src/lib/pages/Recovery/LostPassword.tsx b/src/lib/pages/Recovery/LostPassword.tsx index ac3cb23..e166254 100644 --- a/src/lib/pages/Recovery/LostPassword.tsx +++ b/src/lib/pages/Recovery/LostPassword.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useContext } from 'react' +import * as secrets from '../../services/secrets'; import { useTranslation } from 'react-i18next' // import 'react-phone-number-input/style.css' import { @@ -99,7 +100,7 @@ const RecoveryLostPassword: React.FC = ({ history }) => { await managers.walletManager!.provideRecoveryKey(Utils.toArray(recoveryKey, 'base64')) if (managers.walletManager!.authenticated) { setAccordianView('new-password') - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) } else { throw new Error(t('lost_password_not_authenticated')) } diff --git a/src/lib/pages/Recovery/LostPhone.tsx b/src/lib/pages/Recovery/LostPhone.tsx index cea5c60..48c4ca9 100644 --- a/src/lib/pages/Recovery/LostPhone.tsx +++ b/src/lib/pages/Recovery/LostPhone.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useContext } from 'react' +import * as secrets from '../../services/secrets'; import { useTranslation } from 'react-i18next' import style from './style.js' // import 'react-phone-number-input/style.css' @@ -66,7 +67,7 @@ const RecoveryLostPhone: React.FC = ({ history }) => { setLoading(true) await managers.walletManager!.providePassword(password) setAccordianView('new-phone') - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) } catch (e) { console.error(e) toast.error(e.message) diff --git a/src/lib/pages/Recovery/RecoverPassword.tsx b/src/lib/pages/Recovery/RecoverPassword.tsx index 3e80e6c..4edaca0 100644 --- a/src/lib/pages/Recovery/RecoverPassword.tsx +++ b/src/lib/pages/Recovery/RecoverPassword.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useContext } from 'react' +import * as secrets from '../../services/secrets'; import { useTranslation } from 'react-i18next' import style from './style.js' import { @@ -140,7 +141,7 @@ const RecoverPassword: React.FC = ({ history }) => { if (managers.walletManager!.authenticated) { setAccordianView('new-password') - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) } else { throw new Error('Not authenticated. Please check your presentation key and recovery key.') } @@ -163,7 +164,7 @@ const RecoverPassword: React.FC = ({ history }) => { try { setLoading(true) await managers.walletManager!.changePassword(password) - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.success(t('recover_password_toast_success')) history.push('/dashboard/apps') } catch (e) { diff --git a/src/lib/pages/Recovery/RecoverPresentationKey.tsx b/src/lib/pages/Recovery/RecoverPresentationKey.tsx index 7fb68bc..03a5b03 100644 --- a/src/lib/pages/Recovery/RecoverPresentationKey.tsx +++ b/src/lib/pages/Recovery/RecoverPresentationKey.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useContext } from 'react' +import * as secrets from '../../services/secrets'; import { useTranslation } from 'react-i18next' import style from './style.js' import { @@ -71,7 +72,7 @@ const RecoverPresentationKey: React.FC = ({ history }) => { await managers.walletManager!.providePassword(password) if (managers.walletManager!.authenticated) { - localStorage.snap = saveEnhancedSnapshot() + secrets.setSnapshot(saveEnhancedSnapshot()) toast.success(t('recover_key_toast_success')) history.push('/dashboard/apps') } else { diff --git a/src/lib/services/WalletService.ts b/src/lib/services/WalletService.ts index 5818bcb..ab78cfe 100644 --- a/src/lib/services/WalletService.ts +++ b/src/lib/services/WalletService.ts @@ -46,6 +46,7 @@ import { EventEmittable } from './EventEmittable' import { PermissionQueueManager } from './PermissionQueueManager' import { PeerPayManager } from './PeerPayManager' import { StorageElectronIPC } from '../StorageElectronIPC' +import * as secrets from './secrets' import { DEFAULT_CHAIN, ADMIN_ORIGINATOR, DEFAULT_USE_WAB } from '../config' import type { LoginType, WABConfig } from '../WalletContext' import type { WalletProfile } from '../types/WalletProfile' @@ -288,10 +289,11 @@ export class WalletService extends EventEmittable { * Call this once on app startup before calling initialize(). */ restoreConfigFromSnapshot() { - if (!localStorage.snap || this._lifecycle !== 'unconfigured') return + const snap = secrets.getSnapshot() + if (!snap || this._lifecycle !== 'unconfigured') return try { - const snapArr = Utils.toArray(localStorage.snap, 'base64') + const snapArr = Utils.toArray(snap, 'base64') const { config } = this._loadEnhancedSnapshot(snapArr) if (!config) return @@ -323,7 +325,7 @@ export class WalletService extends EventEmittable { * Fetch WAB server info. For new users with WAB auto-config. */ async fetchAndAutoConfig(): Promise { - if (!localStorage.snap && this._lifecycle === 'unconfigured' && this._loginType === 'wab' && this._wabUrl) { + if (!secrets.getSnapshot() && this._lifecycle === 'unconfigured' && this._loginType === 'wab' && this._wabUrl) { try { const response = await fetch(`${this._wabUrl}/info`) if (!response.ok) throw new Error(`Server responded with ${response.status}`) @@ -420,8 +422,8 @@ export class WalletService extends EventEmittable { // For direct-key returning users, auto-provide stored key. // NOTE: providePrimaryKey calls _buildWallet internally, which will advance // lifecycle to 'ready'. We must NOT overwrite that afterwards. - if (directKeyMode && localStorage.snap && localStorage.getItem('primaryKeyHex')) { - const storedHex = localStorage.getItem('primaryKeyHex')!.trim() + if (directKeyMode && secrets.getSnapshot() && secrets.getKeyHex()) { + const storedHex = secrets.getKeyHex()!.trim() if (storedHex) { try { const keyBytes = Utils.toArray(storedHex, 'hex') @@ -537,7 +539,7 @@ export class WalletService extends EventEmittable { } this._lifecycle = 'ready' - this._snapshotLoaded = !!localStorage.snap && !!this._managers.walletManager + this._snapshotLoaded = !!secrets.getSnapshot() && !!this._managers.walletManager this._emitState() return permissionsManager @@ -564,7 +566,7 @@ export class WalletService extends EventEmittable { } if (this._loginType === 'direct-key') { - const storedHex = localStorage.getItem('primaryKeyHex') + const storedHex = secrets.getKeyHex() if (storedHex) { try { const keyDeriver = new CachedKeyDeriver(new PrivateKey(Utils.toArray(storedHex.trim(), 'hex'))) @@ -661,14 +663,15 @@ export class WalletService extends EventEmittable { } private async _loadWalletSnapshot(walletManager: any) { - if (!localStorage.snap) return + const snap = secrets.getSnapshot() + if (!snap) return try { - const snapArr = Utils.toArray(localStorage.snap, 'base64') + const snapArr = Utils.toArray(snap, 'base64') const { walletSnapshot } = this._loadEnhancedSnapshot(snapArr) await walletManager.loadSnapshot(walletSnapshot) } catch (err: any) { console.error('[WalletService] Error loading snapshot:', err) - localStorage.removeItem('snap') + secrets.clearSnapshot() toast.error("Couldn't load saved data: " + err.message) } } @@ -720,7 +723,7 @@ export class WalletService extends EventEmittable { const newBackupUrls = [...this._backupStorageUrls, url] const snapshot = this.saveEnhancedSnapshot({ backupStorageUrls: newBackupUrls }) - localStorage.snap = snapshot + secrets.setSnapshot(snapshot) this._backupStorageUrls = newBackupUrls this._emitState() toast.success('Backup storage added successfully!') @@ -742,7 +745,7 @@ export class WalletService extends EventEmittable { toast.error('Failed to remove backup: could not save snapshot') throw err } - localStorage.snap = snapshot + secrets.setSnapshot(snapshot) this._backupStorageUrls = newBackupUrls this._emitState() @@ -849,7 +852,7 @@ export class WalletService extends EventEmittable { this._backupStorageUrls = newBackups const snapshot = this.saveEnhancedSnapshot() - localStorage.snap = snapshot + secrets.setSnapshot(snapshot) this._emitState() const visiblePrimaryAfter = this._useRemoteStorage ? this._selectedStorageUrl : 'LOCAL_STORAGE' @@ -874,7 +877,7 @@ export class WalletService extends EventEmittable { } const snapshot = this.saveEnhancedSnapshot({ messageBoxUrl: trimmedUrl, useMessageBox: true }) - localStorage.snap = snapshot + secrets.setSnapshot(snapshot) this._emitState() toast.success('Message Box URL configured successfully!') } @@ -885,7 +888,7 @@ export class WalletService extends EventEmittable { this._useMessageBox = false const snapshot = this.saveEnhancedSnapshot() - localStorage.snap = snapshot + secrets.setSnapshot(snapshot) this._emitState() toast.success('Message Box URL removed successfully!') } @@ -916,6 +919,13 @@ export class WalletService extends EventEmittable { localStorage.setItem(key, value) } + // Wallet secrets now live in the encrypted main-process store, not + // localStorage, so localStorage.clear() no longer forgets them. Clear + // them explicitly on logout. + secrets.clearSnapshot() + secrets.clearKeyHex() + secrets.clearMnemonic() + this._managers = {} this._wallet = undefined this._lifecycle = 'configured' diff --git a/src/lib/services/secrets.ts b/src/lib/services/secrets.ts new file mode 100644 index 0000000..68d6235 --- /dev/null +++ b/src/lib/services/secrets.ts @@ -0,0 +1,72 @@ +/** + * Renderer-side synchronous facade over the encrypted main-process secret + * store (electron/secretStore.ts). Holds an in-memory cache hydrated once at + * startup so existing synchronous call sites keep working; writes update the + * cache immediately and persist asynchronously over IPC. + */ +const SNAP = 'snap' +const KEY = 'primaryKeyHex' +const MN = 'mnemonic12' +const NAMES = [SNAP, KEY, MN] as const + +const cache = new Map() +let hydrated = false + +export async function hydrate(): Promise { + if (hydrated) return + const stored = await window.electronAPI.secrets.getAll() + for (const name of NAMES) { + if (stored[name] != null) { + cache.set(name, stored[name]) + continue + } + // One-time migration of legacy plaintext localStorage values. + const legacy = localStorage.getItem(name) + if (legacy != null) { + cache.set(name, legacy) + try { + await window.electronAPI.secrets.set(name, legacy) + localStorage.removeItem(name) + } catch (err) { + console.error(`[secrets] migration of ${name} failed:`, err) + } + } + } + hydrated = true +} + +function get(name: string): string | null { + return cache.has(name) ? cache.get(name)! : null +} + +function set(name: string, value: string): void { + cache.set(name, value) + void window.electronAPI.secrets + .set(name, value) + .catch((err) => console.error(`[secrets] persist ${name} failed:`, err)) +} + +function clear(name: string): void { + cache.delete(name) + void window.electronAPI.secrets + .delete(name) + .catch((err) => console.error(`[secrets] delete ${name} failed:`, err)) +} + +export const getSnapshot = (): string | null => get(SNAP) +export const setSnapshot = (v: string): void => set(SNAP, v) +export const clearSnapshot = (): void => clear(SNAP) + +export const getKeyHex = (): string | null => get(KEY) +export const setKeyHex = (v: string): void => set(KEY, v) +export const clearKeyHex = (): void => clear(KEY) + +export const getMnemonic = (): string | null => get(MN) +export const setMnemonic = (v: string): void => set(MN, v) +export const clearMnemonic = (): void => clear(MN) + +/** Test-only: reset module state. */ +export function _reset(): void { + cache.clear() + hydrated = false +} diff --git a/src/lib/utils/keyMaterial.ts b/src/lib/utils/keyMaterial.ts index 6bd747e..5becbb6 100644 --- a/src/lib/utils/keyMaterial.ts +++ b/src/lib/utils/keyMaterial.ts @@ -1,5 +1,6 @@ import { Utils } from '@bsv/sdk' import { Mnemonic } from '@bsv/sdk/compat' +import * as secrets from '../services/secrets' export const normalizeMnemonic = (phrase: string): string => phrase.trim().replace(/\s+/g, ' ') @@ -66,8 +67,8 @@ export const mnemonicFromKeyHex = (keyHex: string): string => { export const persistKeyMaterial = (keyHex: string, mnemonic?: string): string => { const phrase = mnemonic ? normalizeMnemonic(mnemonic) : mnemonicFromKeyHex(keyHex) - localStorage.setItem('primaryKeyHex', keyHex) - localStorage.setItem('mnemonic12', phrase) + secrets.setKeyHex(keyHex) + secrets.setMnemonic(phrase) return phrase } @@ -76,8 +77,8 @@ export const reconcileStoredKeyMaterial = (): { keyHex: string; mnemonic: string return { keyHex: '', mnemonic: '' } } - const storedMnemonic = (localStorage.getItem('mnemonic12') || '').trim() - const storedHex = (localStorage.getItem('primaryKeyHex') || '').trim() + const storedMnemonic = (secrets.getMnemonic() || '').trim() + const storedHex = (secrets.getKeyHex() || '').trim() if (storedMnemonic) { try { @@ -86,7 +87,7 @@ export const reconcileStoredKeyMaterial = (): { keyHex: string; mnemonic: string return { keyHex, mnemonic } } catch (err) { console.error('Failed to derive key from stored mnemonic', err) - localStorage.removeItem('mnemonic12') + secrets.clearMnemonic() } } diff --git a/src/main.tsx b/src/main.tsx index d5b8053..7114ae7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,21 +6,28 @@ import { onWalletReady } from './onWalletReady'; import { electronFunctions } from './electronFunctions'; import packageJson from '../package.json'; import { btmsPermissionModule } from './lib/permissionModules/btms'; +import { hydrate as hydrateSecrets } from './lib/services/secrets'; // Create the root and render const rootElement = document.getElementById('root'); if (rootElement) { const root = createRoot(rootElement); - root.render( - - - - ); + // Load (and migrate) encrypted secrets into the sync cache before the wallet + // tree mounts, so snapshot/key reads during init see hydrated values. + hydrateSecrets() + .catch((err) => console.error('[main] secret hydration failed:', err)) + .finally(() => { + root.render( + + + + ); + }); } diff --git a/test/secretStore.test.ts b/test/secretStore.test.ts new file mode 100644 index 0000000..868ff02 --- /dev/null +++ b/test/secretStore.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import os from 'os' +import path from 'path' +import fs from 'fs' + +// Per-run temp userData dir +const TMP = path.join(os.tmpdir(), `secretstore-test-${process.pid}-${Date.now()}`) + +let encAvailable = true +let backend = 'keychain' + +vi.mock('electron', () => ({ + app: { getPath: () => TMP }, + safeStorage: { + isEncryptionAvailable: () => encAvailable, + getSelectedStorageBackend: () => backend, + // reversible stand-in for real OS crypto + encryptString: (s: string) => Buffer.from('ENC:' + s, 'utf8'), + decryptString: (b: Buffer) => b.toString('utf8').replace(/^ENC:/, ''), + }, +})) + +let store: typeof import('../electron/secretStore') + +describe('secretStore', () => { + beforeEach(async () => { + encAvailable = true + backend = 'keychain' + fs.rmSync(TMP, { recursive: true, force: true }) + fs.mkdirSync(TMP, { recursive: true }) + vi.resetModules() + store = await import('../electron/secretStore') + }) + + it('round-trips an encrypted secret and persists ciphertext (not plaintext)', () => { + store.setSecret('snap', 'hello-snapshot') + expect(store.getSecret('snap')).toBe('hello-snapshot') + const raw = fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8') + expect(raw).not.toContain('hello-snapshot') + const parsed = JSON.parse(raw) + expect(parsed.entries.snap.enc).toBe(true) + }) + + it('falls back to plaintext when encryption unavailable', () => { + encAvailable = false + store.setSecret('snap', 'plain-value') + expect(store.getSecret('snap')).toBe('plain-value') + const parsed = JSON.parse(fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8')) + expect(parsed.entries.snap.enc).toBe(false) + }) + + it('treats basic_text backend as unencrypted fallback', () => { + backend = 'basic_text' + store.setSecret('snap', 'v') + const parsed = JSON.parse(fs.readFileSync(path.join(TMP, 'secrets.dat'), 'utf8')) + expect(parsed.entries.snap.enc).toBe(false) + }) + + it('reads each entry per its own enc flag', () => { + store.setSecret('snap', 'encrypted-one') + encAvailable = false + store.setSecret('primaryKeyHex', 'plain-one') + encAvailable = true + const all = store.getAll() + expect(all.snap).toBe('encrypted-one') + expect(all.primaryKeyHex).toBe('plain-one') + }) + + it('rejects names not on the allow-list', () => { + expect(() => store.setSecret('evil', 'x')).toThrow(/not permitted/) + expect(() => store.getSecret('evil')).toThrow(/not permitted/) + }) + + it('getAll on missing file returns empty object', () => { + expect(store.getAll()).toEqual({}) + }) + + it('deleteSecret removes only that entry', () => { + store.setSecret('snap', 'a') + store.setSecret('mnemonic12', 'b') + store.deleteSecret('snap') + expect(store.getSecret('snap')).toBeNull() + expect(store.getSecret('mnemonic12')).toBe('b') + }) +}) diff --git a/test/secrets.renderer.test.ts b/test/secrets.renderer.test.ts new file mode 100644 index 0000000..38140c3 --- /dev/null +++ b/test/secrets.renderer.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const backing = new Map() +const mockGetAll = vi.fn(async () => Object.fromEntries(backing)) +const mockSet = vi.fn(async (name: string, value: string) => { backing.set(name, value) }) +const mockDelete = vi.fn(async (name: string) => { backing.delete(name) }) + +const localStore = new Map() +;(globalThis as any).window = { + electronAPI: { secrets: { getAll: mockGetAll, set: mockSet, delete: mockDelete } }, +} +;(globalThis as any).localStorage = { + getItem: (k: string) => (localStore.has(k) ? localStore.get(k)! : null), + setItem: (k: string, v: string) => { localStore.set(k, v) }, + removeItem: (k: string) => { localStore.delete(k) }, +} + +let secrets: typeof import('../src/lib/services/secrets') + +describe('renderer secrets facade', () => { + beforeEach(async () => { + backing.clear(); localStore.clear() + mockGetAll.mockClear(); mockSet.mockClear(); mockDelete.mockClear() + vi.resetModules() + secrets = await import('../src/lib/services/secrets') + secrets._reset() + }) + + it('hydrates from the main store into the sync cache', async () => { + backing.set('snap', 'S'); backing.set('primaryKeyHex', 'K') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('S') + expect(secrets.getKeyHex()).toBe('K') + expect(secrets.getMnemonic()).toBeNull() + }) + + it('migrates legacy localStorage values into the store and clears them', async () => { + localStore.set('snap', 'legacy-snap') + localStore.set('mnemonic12', 'legacy-mn') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('legacy-snap') + expect(secrets.getMnemonic()).toBe('legacy-mn') + expect(mockSet).toHaveBeenCalledWith('snap', 'legacy-snap') + expect(mockSet).toHaveBeenCalledWith('mnemonic12', 'legacy-mn') + expect(localStore.has('snap')).toBe(false) + expect(localStore.has('mnemonic12')).toBe(false) + }) + + it('prefers the main store over legacy localStorage', async () => { + backing.set('snap', 'from-store') + localStore.set('snap', 'from-legacy') + await secrets.hydrate() + expect(secrets.getSnapshot()).toBe('from-store') + expect(localStore.get('snap')).toBe('from-legacy') // not migrated/cleared + }) + + it('setSnapshot updates cache synchronously and persists async', async () => { + await secrets.hydrate() + secrets.setSnapshot('new-snap') + expect(secrets.getSnapshot()).toBe('new-snap') + await Promise.resolve() + expect(mockSet).toHaveBeenCalledWith('snap', 'new-snap') + }) + + it('clearSnapshot removes from cache and store', async () => { + backing.set('snap', 'S') + await secrets.hydrate() + secrets.clearSnapshot() + expect(secrets.getSnapshot()).toBeNull() + await Promise.resolve() + expect(mockDelete).toHaveBeenCalledWith('snap') + }) +})