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>