Adds integration/tests/12-electron-mail-index.spec.ts (3 tests, all passing
against the real Stalwart fixture) and fixes what running it exposed. None of
these were visible from reading the code.
1. JMAP session fetch never followed a redirect. Stalwart 307-redirects
/.well-known/jmap to /jmap/session, and fetchJmapSession used
`redirect: 'manual'` and treated any non-2xx as failure - so every reindex
died with "JMAP session fetch failed (307)". Now follows up to 3 hops and
REFUSES to follow off-origin, because the user's credentials ride on every
hop; a blind `redirect: 'follow'` would hand the Authorization header to
whatever host a misconfigured session pointed at. Same bound and same
reasoning as lib/auth/verify-jmap-auth.ts.
2. The fd-3 key channel could only be adopted once per process, but its state
was module-scoped. Next re-evaluates route modules, so a second instance hit
`Could not open fd 3: Error: open EEXIST` from libuv. State moved to a
Symbol on globalThis - the one place in a Node process that survives module
re-evaluation.
3. Next's output file tracing does NOT carry @signalapp/sqlcipher's prebuilds/
into .next/standalone. It traced the package's JS and its node-gyp-build
dependency, but node-gyp-build resolves the .node binary by scanning a
directory at runtime, which no static tracer can follow - so `require()`
would have failed in every packaged build. scripts/assemble-standalone.mjs
now copies it, alongside the public/ and .next/static copies it already does
for the same "standalone output omits things" reason. All six platform/arch
prebuilds are copied, not just this host's, because electron-builder
cross-builds the x64 and arm64 macOS targets from one runner.
The three tests, and why it takes three - two constraints made a single
configuration impossible, and both were measured rather than assumed:
* The renderer cannot reach this fixture from a production build. Its CSP
pins connect-src to `'self' https: wss:` and the fixture's Stalwart is
plain HTTP. NODE_ENV=development at RUNTIME does not help: `next build`
INLINES process.env.NODE_ENV into the compiled middleware, so proxy.ts's
`isDev` is frozen at build time (observed: a standalone server started with
NODE_ENV=development still served the production CSP).
* The fd-3 channel cannot survive `next dev`, which forks its server with an
IPC channel that claims fd 3 (EEXIST); fd 4 there is not a pipe either
(ENOTTY).
So: PIPELINE drives the real standalone server over HTTP from Node with a
real fd-3 key channel (no browser, so no CSP) and asserts a real SMTP
delivery is findable by a word from its BODY, with a real snippet and
contextBlock, idempotent catch-up, working type filters, and - reading the
raw bytes of the .db AND its -wal - that nothing is recoverable in cleartext.
TRIGGER proves the event-driven wiring: a real delivery makes the renderer
POST /api/offline/reindex off its live push. WIRING launches the real shell
with no ELECTRON_LOAD_URL and asserts the routes are reachable (401, not 404
or 503) with real safeStorage behind them.
Each test now gets its own --user-data-dir. That is load-bearing, not hygiene:
Electron reuses one profile across launches, and a leftover jmap_stalwart_ctx
cookie from an earlier run made the WIRING test's 401 assertion pass as a 200.
Verified: typecheck clean; unit suite 2379 tests with the SAME 3 pre-existing
failures as the base commit b15098a6 (2 builtin-themes, 1 jmap-client-
resilience) and 48 net new passing; both `docker build`s succeed; the
hosted-deployment gate returns 404 with an empty body and materialises no file
in the production image; e2e/electron-smoke 4/4; 11-electron-notification
still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
7.0 KiB
TypeScript
204 lines
7.0 KiB
TypeScript
// 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<number, Pending>;
|
|
readBuffer: string;
|
|
}
|
|
|
|
const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel');
|
|
|
|
function state(): ChannelState {
|
|
const holder = globalThis as unknown as Record<symbol, ChannelState | undefined>;
|
|
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<T>(
|
|
accountId: string,
|
|
fn: (key: Buffer) => Promise<T> | T,
|
|
): Promise<T> {
|
|
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<void> {
|
|
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;
|
|
}
|