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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-07 09:32:50 -05:00
commit 2273303b13
4 changed files with 56 additions and 10 deletions

View file

@ -100,16 +100,17 @@ ipcMain.handle('get-version', () => {
/** /**
* Get runtime configuration from environment variables * Get runtime configuration from environment variables
* This allows configuration to be set at runtime (not baked in at build time) * 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', () => { ipcMain.handle('get-config', () => {
return { return {
// Lightning.Pub connection // Lightning.Pub connection (public info only)
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '', lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '',
lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776', lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776',
extensionApiUrl: process.env.VITE_EXTENSION_API_URL || 'http://localhost:1777', 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 || '', appId: process.env.VITE_APP_ID || '',
// Hardware configuration // 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 // State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))

View file

@ -8,16 +8,16 @@
import { contextBridge, ipcRenderer } from 'electron' 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) * These values are read from environment variables at runtime (not build time)
*
* SECURITY: Secrets are NOT included here. Use getAtmSecrets() instead.
*/ */
export interface RuntimeConfig { export interface RuntimeConfig {
relayUrl: string relayUrl: string
lightningPubPubkey: string lightningPubPubkey: string
lightningPubApiUrl: string lightningPubApiUrl: string
extensionApiUrl: string extensionApiUrl: string
atmPrivateKey: string
adminToken: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -27,6 +27,14 @@ export interface RuntimeConfig {
allowMockFallback: boolean 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 // Expose protected methods to renderer
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// Get app version // Get app version
@ -35,6 +43,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get runtime configuration (read from environment at runtime) // Get runtime configuration (read from environment at runtime)
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'), getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
// Get ATM secrets (one-shot: returns secrets once, then empty)
getAtmSecrets: (): Promise<AtmSecrets> => ipcRenderer.invoke('get-atm-secrets'),
// State persistence // State persistence
loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'), loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'),
setCassettes: (cassettes: { denomination: number; count: number }[]) => setCassettes: (cassettes: { denomination: number; count: number }[]) =>
@ -85,6 +96,7 @@ declare global {
electronAPI: { electronAPI: {
getVersion: () => Promise<string> getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig> getConfig: () => Promise<RuntimeConfig>
getAtmSecrets: () => Promise<AtmSecrets>
loadCassettes: () => Promise<{ denomination: number; count: number }[]> loadCassettes: () => Promise<{ denomination: number; count: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>> getInventory: () => Promise<Record<number, number>>

View file

@ -65,6 +65,10 @@ interface LightningConfig {
/** /**
* Load configuration - async to support Electron IPC * 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<LightningConfig> { async function loadLightningConfig(): Promise<LightningConfig> {
// Development defaults (local Docker infrastructure) // Development defaults (local Docker infrastructure)
@ -82,13 +86,15 @@ async function loadLightningConfig(): Promise<LightningConfig> {
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
try { try {
const runtimeConfig = await window.electronAPI.getConfig() const runtimeConfig = await window.electronAPI.getConfig()
// Secrets come from a separate one-shot IPC handler
const secrets = await window.electronAPI.getAtmSecrets()
return { return {
relayUrl: runtimeConfig.relayUrl || defaults.relayUrl, relayUrl: runtimeConfig.relayUrl || defaults.relayUrl,
lightningPubPubkey: runtimeConfig.lightningPubPubkey || defaults.lightningPubPubkey, lightningPubPubkey: runtimeConfig.lightningPubPubkey || defaults.lightningPubPubkey,
lightningPubApiUrl: runtimeConfig.lightningPubApiUrl || defaults.lightningPubApiUrl, lightningPubApiUrl: runtimeConfig.lightningPubApiUrl || defaults.lightningPubApiUrl,
extensionApiUrl: runtimeConfig.extensionApiUrl || defaults.extensionApiUrl, extensionApiUrl: runtimeConfig.extensionApiUrl || defaults.extensionApiUrl,
adminToken: runtimeConfig.adminToken || defaults.adminToken, adminToken: secrets.adminToken || defaults.adminToken,
atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
appId: runtimeConfig.appId || defaults.appId, appId: runtimeConfig.appId || defaults.appId,
} }
} catch (e) { } catch (e) {

View file

@ -7,8 +7,6 @@ export interface RuntimeConfig {
lightningPubPubkey: string lightningPubPubkey: string
lightningPubApiUrl: string lightningPubApiUrl: string
extensionApiUrl: string extensionApiUrl: string
atmPrivateKey: string
adminToken: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -18,11 +16,17 @@ export interface RuntimeConfig {
allowMockFallback: boolean allowMockFallback: boolean
} }
export interface AtmSecrets {
atmPrivateKey: string
adminToken: string
}
declare global { declare global {
interface Window { interface Window {
electronAPI?: { electronAPI?: {
getVersion: () => Promise<string> getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig> getConfig: () => Promise<RuntimeConfig>
getAtmSecrets: () => Promise<AtmSecrets>
loadCassettes: () => Promise<{ denomination: number; count: number }[]> loadCassettes: () => Promise<{ denomination: number; count: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void> setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>> getInventory: () => Promise<Record<number, number>>