From 2273303b138530cd6d9d3e768f3ab3fd1df0d6c1 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 7 Mar 2026 09:32:50 -0500 Subject: [PATCH] security(C1): remove private key from get-config IPC response Move atmPrivateKey and adminToken out of the general get-config IPC handler into a dedicated one-shot get-atm-secrets handler that returns secrets only once per app lifecycle. Subsequent calls return empty strings. This prevents XSS or DevTools from repeatedly querying getConfig() to steal the ATM's Nostr private key. TODO: Move signing/encryption to main process entirely (Phase 2) so the private key never crosses the IPC boundary. Co-Authored-By: Claude Opus 4.6 --- apps/machine/electron/main.ts | 30 +++++++++++++++++++++++--- apps/machine/electron/preload.ts | 18 +++++++++++++--- apps/machine/src/services/lightning.ts | 10 +++++++-- apps/machine/src/types/electron.d.ts | 8 +++++-- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index e6230de..00b0ecc 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -100,16 +100,17 @@ ipcMain.handle('get-version', () => { /** * Get runtime configuration from environment variables * This allows configuration to be set at runtime (not baked in at build time) + * + * SECURITY: Secrets (private key, admin token) are NOT included here. + * Use 'get-atm-secrets' for secrets — it's a one-shot handler. */ ipcMain.handle('get-config', () => { return { - // Lightning.Pub connection + // Lightning.Pub connection (public info only) relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '', lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776', extensionApiUrl: process.env.VITE_EXTENSION_API_URL || 'http://localhost:1777', - atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', - adminToken: process.env.VITE_ADMIN_TOKEN || '', appId: process.env.VITE_APP_ID || '', // Hardware configuration @@ -122,6 +123,29 @@ ipcMain.handle('get-config', () => { } }) +/** + * One-shot secrets handler. + * + * Returns ATM private key and admin token ONCE during initialization, + * then refuses all subsequent calls. This limits the window for XSS + * or compromised dependencies to steal secrets via IPC. + * + * TODO: Move signing/encryption to main process entirely (Phase 2) + * so the private key never crosses the IPC boundary. + */ +let secretsConsumed = false +ipcMain.handle('get-atm-secrets', () => { + if (secretsConsumed) { + console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed') + return { atmPrivateKey: '', adminToken: '' } + } + secretsConsumed = true + return { + atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', + adminToken: process.env.VITE_ADMIN_TOKEN || '', + } +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index db4ce4b..f121e85 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -8,16 +8,16 @@ import { contextBridge, ipcRenderer } from 'electron' /** - * Runtime configuration interface + * Runtime configuration interface (public info only) * These values are read from environment variables at runtime (not build time) + * + * SECURITY: Secrets are NOT included here. Use getAtmSecrets() instead. */ export interface RuntimeConfig { relayUrl: string lightningPubPubkey: string lightningPubApiUrl: string extensionApiUrl: string - atmPrivateKey: string - adminToken: string appId: string machineModel: string fiatCode: string @@ -27,6 +27,14 @@ export interface RuntimeConfig { allowMockFallback: boolean } +/** + * ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls. + */ +export interface AtmSecrets { + atmPrivateKey: string + adminToken: string +} + // Expose protected methods to renderer contextBridge.exposeInMainWorld('electronAPI', { // Get app version @@ -35,6 +43,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Get runtime configuration (read from environment at runtime) getConfig: (): Promise => ipcRenderer.invoke('get-config'), + // Get ATM secrets (one-shot: returns secrets once, then empty) + getAtmSecrets: (): Promise => ipcRenderer.invoke('get-atm-secrets'), + // State persistence loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'), setCassettes: (cassettes: { denomination: number; count: number }[]) => @@ -85,6 +96,7 @@ declare global { electronAPI: { getVersion: () => Promise getConfig: () => Promise + getAtmSecrets: () => Promise loadCassettes: () => Promise<{ denomination: number; count: number }[]> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise getInventory: () => Promise> diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 3ffa868..e471bc8 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -65,6 +65,10 @@ interface LightningConfig { /** * Load configuration - async to support Electron IPC + * + * SECURITY: Public config comes from get-config IPC. + * Secrets (private key, admin token) come from the one-shot get-atm-secrets IPC, + * which returns secrets only once per app lifecycle. */ async function loadLightningConfig(): Promise { // Development defaults (local Docker infrastructure) @@ -82,13 +86,15 @@ async function loadLightningConfig(): Promise { if (isElectron && window.electronAPI) { try { const runtimeConfig = await window.electronAPI.getConfig() + // Secrets come from a separate one-shot IPC handler + const secrets = await window.electronAPI.getAtmSecrets() return { relayUrl: runtimeConfig.relayUrl || defaults.relayUrl, lightningPubPubkey: runtimeConfig.lightningPubPubkey || defaults.lightningPubPubkey, lightningPubApiUrl: runtimeConfig.lightningPubApiUrl || defaults.lightningPubApiUrl, extensionApiUrl: runtimeConfig.extensionApiUrl || defaults.extensionApiUrl, - adminToken: runtimeConfig.adminToken || defaults.adminToken, - atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, + adminToken: secrets.adminToken || defaults.adminToken, + atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey, appId: runtimeConfig.appId || defaults.appId, } } catch (e) { diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index db728ad..d9e077c 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -7,8 +7,6 @@ export interface RuntimeConfig { lightningPubPubkey: string lightningPubApiUrl: string extensionApiUrl: string - atmPrivateKey: string - adminToken: string appId: string machineModel: string fiatCode: string @@ -18,11 +16,17 @@ export interface RuntimeConfig { allowMockFallback: boolean } +export interface AtmSecrets { + atmPrivateKey: string + adminToken: string +} + declare global { interface Window { electronAPI?: { getVersion: () => Promise getConfig: () => Promise + getAtmSecrets: () => Promise loadCassettes: () => Promise<{ denomination: number; count: number }[]> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise getInventory: () => Promise>