Files
SRCmail/lib/mail-index/key.ts
T
Bernd RodlerandClaude Sonnet 5 b966d285a9 feat(mail-index): encrypted SQLite/FTS5 index over mail, calendar, contacts, files
An on-device, SQLCipher-encrypted full-text index the app can retrieve from to
feed an LLM ("prompt against"), for the Electron desktop shell only.

Shape: no persistent background worker and no resident credential. Indexing is
a normal request-scoped API route, triggered by the renderer's EXISTING live
JMAP push connection - so it reacts to each delivery/change rather than polling.

- lib/mail-index/binding.ts   guarded require of the optional native binding
- lib/mail-index/paths.ts     the VNCMAIL_DESKTOP_STORE_DIR gate + hashed paths
- lib/mail-index/store.ts     schema, upsert, FTS5 search, encryption assertion
- lib/mail-index/extract.ts   PURE JMAP-object -> document extractors
- lib/mail-index/jmap.ts      minimal stateless server-side JMAP client
- lib/mail-index/key.ts       per-job key fetch over the inherited fd
- lib/mail-index/reindex.ts   the job + slot->account resolution
- electron/key-service.ts     safeStorage wrap/unwrap, served over fd 3
- app/api/offline/reindex     POST, event-driven + catch-up
- app/api/offline/search      GET, the retrieval surface (hits + contextBlock)
- lib/mail-index-client.ts    renderer client; StateChange -> index call
- components/settings/local-index-settings.tsx  status + manual catch-up

Decisions worth knowing:

* `@signalapp/sqlcipher` is an OPTIONAL dependency with a guarded runtime
  require. It publishes six N-API prebuilds and NO build sources, and both
  Dockerfiles are node:24-alpine (musl, no matching prebuild) - as a hard
  dependency it would break the production image and the integration fixture's
  webmail container, neither of which wants this feature.

* Credentials come from the existing per-slot encrypted `jmap_stalwart_ctx`
  cookie via lib/stalwart/credentials.ts - the same helper /api/settings and
  /api/push/preview already use. It carries a ready-made header for basic AND
  bearer accounts, so the indexer never touches the OAuth refresh-token cookie;
  a server-side refresh would rotate a token into a response nobody reads and
  silently log the user out.

* The encryption key crosses main -> server over an INHERITED FILE DESCRIPTOR,
  never an environment variable: env is readable by any process running as the
  same OS user, which would defeat using the OS keychain at all. Fetched per
  job and zeroed after, so there is no long-lived key copy.

* safeStorage's Linux `basic_text` backend (no keyring) is treated as refusal,
  not degradation - it "encrypts" with a hardcoded public password, which would
  look like an encrypted mailbox while providing nothing.
  getSelectedStorageBackend() is Linux-only and platform-guarded.

* Every store open asserts `PRAGMA cipher_version` returns a non-empty STRING,
  not merely a row: a non-cipher binding returns ZERO ROWS, so a row-count check
  would pass vacuously while writing the mailbox to disk in cleartext.

* Files are indexed by name/path/date/size only - NOT by extracted content.
  Text extraction from arbitrary PDFs/office documents is a separate problem.

* Account-scoped composite keys `(jmap_account_id, content_type, id)` are kept
  even though there is one file per account: one login exposes delegated/shared
  JMAP accounts too, and JMAP ids are unique only within an account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:16:22 +02:00

175 lines
5.7 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;
}
let socket: net.Socket | null = null;
let nextId = 1;
const pending = new Map<number, Pending>();
let readBuffer = '';
function failAll(error: Error): void {
for (const [, p] of pending) {
clearTimeout(p.timer);
p.reject(error);
}
pending.clear();
}
function getSocket(): net.Socket {
if (socket && !socket.destroyed) return 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) => {
readBuffer += chunk.toString('utf8');
if (readBuffer.length > 64 * 1024) readBuffer = '';
let newline: number;
while ((newline = readBuffer.indexOf('\n')) >= 0) {
const line = readBuffer.slice(0, newline);
readBuffer = 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 = pending.get(id);
if (!p) continue;
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) => {
socket = null;
readBuffer = '';
failAll(error ?? new IndexKeyError('no-channel', 'Key service channel closed'));
};
created.on('close', () => onGone());
created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error))));
socket = created;
return created;
}
function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> {
const sock = getSocket();
const id = nextId++;
return new Promise<{ key?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
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?.();
pending.set(id, { resolve, reject, timer });
try {
sock.write(`${JSON.stringify({ id, op, accountId })}\n`);
} catch (error) {
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;
}