// Server-side client for the main process's key service (electron/key-service.ts). // // Asks for an account's index key over the inherited fd only when a job needs // it, and drops it as soon as the job finishes. There is deliberately no cache: // a resident plaintext key in a long-lived process is exactly the thing the OS // keychain exists to avoid, and a keychain round trip costs microseconds // against a job that makes network calls. import net from 'node:net'; /** Set by electron/main.ts alongside VNCMAIL_DESKTOP_STORE_DIR. */ export const KEY_FD_ENV = 'VNCMAIL_DESKTOP_KEY_FD'; const REQUEST_TIMEOUT_MS = 10_000; export type KeyErrorCode = | 'no-channel' | 'no-secure-storage' | 'key-io-failed' | 'key-unreadable' | 'bad-request' | 'timeout'; export class IndexKeyError extends Error { code: KeyErrorCode; constructor(code: KeyErrorCode, message: string) { super(message); this.name = 'IndexKeyError'; this.code = code; } } interface Pending { resolve: (value: { key?: string }) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } /** * Channel state lives on `globalThis`, NOT in module scope. * * A file descriptor can be adopted as a socket exactly ONCE per process: a * second `new net.Socket({ fd })` for an fd this process already owns throws * `EEXIST` from libuv's uv_pipe_open. Module scope is not once-per-process - * Next re-evaluates route modules (dev HMR, and separate module instances * across route bundles), so a module-scoped `let socket` produced exactly that * crash: `Could not open fd 3: Error: open EEXIST`, found by the integration * test rather than by reading the code. * * A Symbol key on globalThis is the one place in a Node process that survives * module re-evaluation, so adoption genuinely happens once. */ interface ChannelState { socket: net.Socket | null; nextId: number; pending: Map; readBuffer: string; } const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel'); function state(): ChannelState { const holder = globalThis as unknown as Record; const existing = holder[STATE_KEY]; if (existing) return existing; const created: ChannelState = { socket: null, nextId: 1, pending: new Map(), readBuffer: '' }; holder[STATE_KEY] = created; return created; } function failAll(s: ChannelState, error: Error): void { for (const [, p] of s.pending) { clearTimeout(p.timer); p.reject(error); } s.pending.clear(); } function getSocket(): net.Socket { const s = state(); if (s.socket && !s.socket.destroyed) return s.socket; const raw = process.env[KEY_FD_ENV]?.trim(); const fd = raw ? Number(raw) : NaN; if (!Number.isInteger(fd) || fd < 3) { throw new IndexKeyError( 'no-channel', `${KEY_FD_ENV} is not a usable file descriptor (got ${JSON.stringify(raw)}). ` + `The local index only works inside the Electron desktop shell.`, ); } let created: net.Socket; try { created = new net.Socket({ fd, readable: true, writable: true }); } catch (error) { throw new IndexKeyError('no-channel', `Could not open fd ${fd}: ${String(error)}`); } // The channel outlives every individual request; don't let it hold the event // loop open on its own. created.unref(); created.on('data', (chunk: Buffer) => { s.readBuffer += chunk.toString('utf8'); if (s.readBuffer.length > 64 * 1024) s.readBuffer = ''; let newline: number; while ((newline = s.readBuffer.indexOf('\n')) >= 0) { const line = s.readBuffer.slice(0, newline); s.readBuffer = s.readBuffer.slice(newline + 1); if (!line.trim()) continue; let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown }; try { msg = JSON.parse(line); } catch { continue; } const id = typeof msg.id === 'number' ? msg.id : null; if (id === null) continue; const p = s.pending.get(id); if (!p) continue; s.pending.delete(id); clearTimeout(p.timer); if (msg.ok === true) { p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined }); } else { const code = typeof msg.code === 'string' ? (msg.code as KeyErrorCode) : 'key-io-failed'; p.reject(new IndexKeyError(code, typeof msg.error === 'string' ? msg.error : 'Key request failed')); } } }); const onGone = (error?: Error) => { s.socket = null; s.readBuffer = ''; failAll(s, error ?? new IndexKeyError('no-channel', 'Key service channel closed')); }; created.on('close', () => onGone()); created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error)))); s.socket = created; return created; } function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> { const sock = getSocket(); const s = state(); const id = s.nextId++; return new Promise<{ key?: string }>((resolve, reject) => { const timer = setTimeout(() => { s.pending.delete(id); reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`)); }, REQUEST_TIMEOUT_MS); // Don't let a pending key request keep the process alive either. timer.unref?.(); s.pending.set(id, { resolve, reject, timer }); try { sock.write(`${JSON.stringify({ id, op, accountId })}\n`); } catch (error) { s.pending.delete(id); clearTimeout(timer); reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`)); } }); } /** * Runs `fn` with the account's raw index key, then zeroes the buffer. * * Zeroing a Buffer is genuine (unlike a JS string, which cannot be scrubbed) - * which is why the key crosses the boundary as hex and is converted to a Buffer * exactly once, here. `store.ts` puts the hex into a `PRAGMA` string, so a copy * does briefly exist in the JS heap; the buffer wipe bounds how long the * long-lived copy lives, it does not pretend to eliminate every trace. */ export async function withIndexKey( accountId: string, fn: (key: Buffer) => Promise | T, ): Promise { const { key: hex } = await request('getIndexKey', accountId); if (!hex) throw new IndexKeyError('key-io-failed', 'Key service returned no key'); const key = Buffer.from(hex, 'hex'); if (key.length !== 32) { key.fill(0); throw new IndexKeyError('key-io-failed', `Key service returned ${key.length} bytes, expected 32`); } try { return await fn(key); } finally { key.fill(0); } } /** Used when purging an account: the key goes FIRST, so an interrupted purge leaves unreadable data. */ export async function deleteIndexKey(accountId: string): Promise { await request('deleteIndexKey', accountId); } /** True when this process has a key channel at all (i.e. is the desktop shell's server). */ export function hasKeyChannel(): boolean { const raw = process.env[KEY_FD_ENV]?.trim(); const fd = raw ? Number(raw) : NaN; return Number.isInteger(fd) && fd >= 3; }