desktop/keychain.ts
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Readable } from "node:stream";
export const CURRENT_NAME = "Emc²";
export const HELPER_FLAG = "--emc2-keychain-helper";
export const MAX_PAYLOAD = 16 * 1024;
export const MIGRATION_ERROR = "The protected mail key migration failed. Your mail store was not opened.";
const digestPattern = /^[a-f0-9]{64}$/;
export type CurrentRecord = { version: 1; name: typeof CURRENT_NAME; sourceProtectedKeySha256: string; wrappedKey: string };
type Request = { version: 1; nonce: string; sourceProtectedKeySha256: string; key: string };
type Response = Omit<Request, "key"> & { wrappedKey: string; mac: string };
function exact(value: unknown, fields: string[]): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).sort().join(",") === [...fields].sort().join(",");
}
export function canonicalBytes(value: unknown): Buffer {
if (typeof value !== "string" || !value || value.length > MAX_PAYLOAD) throw new Error(MIGRATION_ERROR);
const bytes = Buffer.from(value, "base64");
if (!bytes.length || bytes.toString("base64") !== value) { bytes.fill(0); throw new Error(MIGRATION_ERROR); }
return bytes;
}
export function canonicalKey(value: unknown): Buffer {
const bytes = canonicalBytes(value);
if (bytes.length !== 32) { bytes.fill(0); throw new Error(MIGRATION_ERROR); }
return bytes;
}
export function protectedDigest(mailPath: string): string {
return createHash("sha256").update(fs.readFileSync(path.join(mailPath, "key.protected"))).digest("hex");
}
export function parseCurrentRecord(value: unknown): CurrentRecord {
if (!exact(value, ["version", "name", "sourceProtectedKeySha256", "wrappedKey"]) ||
value.version !== 1 || value.name !== CURRENT_NAME ||
typeof value.sourceProtectedKeySha256 !== "string" || !digestPattern.test(value.sourceProtectedKeySha256))
throw new Error(MIGRATION_ERROR);
canonicalBytes(value.wrappedKey).fill(0);
return value as CurrentRecord;
}
export function readCurrentRecord(mailPath: string): CurrentRecord | undefined {
const file = path.join(mailPath, "key.current.json");
let fd: number | undefined;
let present = false;
try {
// Only ENOENT for the record itself means absence. A broken link is corruption.
const stat = fs.lstatSync(file);
present = true;
if (!stat.isFile() || stat.size > MAX_PAYLOAD || !stat.size) throw new Error(MIGRATION_ERROR);
fd = fs.openSync(file, "r");
const bytes = Buffer.alloc(MAX_PAYLOAD + 1);
try {
const length = fs.readSync(fd, bytes, 0, bytes.length, 0);
if (!length || length > MAX_PAYLOAD) throw new Error(MIGRATION_ERROR);
const record = parseCurrentRecord(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length))));
if (record.sourceProtectedKeySha256 !== protectedDigest(mailPath)) throw new Error(MIGRATION_ERROR);
return record;
} finally { bytes.fill(0); }
} catch (error) {
if (!present && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw new Error(MIGRATION_ERROR);
} finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { throw new Error(MIGRATION_ERROR); } } }
}
export const commitFiles = { openSync: fs.openSync, writeFileSync: fs.writeFileSync, fsyncSync: fs.fsyncSync,
closeSync: fs.closeSync, renameSync: fs.renameSync, readFileSync: fs.readFileSync };
export function commitCurrentRecord(mailPath: string, record: CurrentRecord, io = commitFiles): void {
const file = path.join(mailPath, "key.current.json");
const temporary = `${file}.${randomBytes(12).toString("hex")}.tmp`;
const text = JSON.stringify(parseCurrentRecord(record));
let fd: number | undefined;
try {
if (Buffer.byteLength(text) > MAX_PAYLOAD) throw new Error(MIGRATION_ERROR);
fd = io.openSync(temporary, "wx", 0o600);
io.writeFileSync(fd, text);
io.fsyncSync(fd);
io.closeSync(fd); fd = undefined;
if (protectedDigest(mailPath) !== record.sourceProtectedKeySha256) throw new Error(MIGRATION_ERROR);
try { fs.lstatSync(file); throw new Error(MIGRATION_ERROR); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
io.renameSync(temporary, file);
fd = io.openSync(mailPath, "r");
io.fsyncSync(fd);
io.closeSync(fd); fd = undefined;
if (io.readFileSync(file, "utf8") !== text || readCurrentRecord(mailPath)?.wrappedKey !== record.wrappedKey)
throw new Error(MIGRATION_ERROR);
} catch { throw new Error(MIGRATION_ERROR); }
finally {
if (fd !== undefined) { try { io.closeSync(fd); } catch { /* Reported by the failed commit. */ } }
try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch { throw new Error(MIGRATION_ERROR); }
}
}
export function encodeFrame(value: unknown): Buffer {
const bytes = Buffer.from(JSON.stringify(value), "utf8");
if (!bytes.length || bytes.length > MAX_PAYLOAD) throw new Error(MIGRATION_ERROR);
const frame = Buffer.alloc(4 + bytes.length);
frame.writeUInt32BE(bytes.length); bytes.copy(frame, 4); bytes.fill(0);
return frame;
}
export function readFrame(stream: Readable): Promise<unknown> {
return new Promise((resolve, reject) => {
// Fixed storage bounds even an adversarial stream; data is accepted only at EOF.
const bytes = Buffer.alloc(MAX_PAYLOAD + 4);
let used = 0, failed = false, ended = false;
const fail = () => { if (!failed) { failed = true; bytes.fill(0); reject(new Error(MIGRATION_ERROR)); } };
stream.on("data", (chunk: Buffer) => {
if (failed) return;
if (used + chunk.length > bytes.length) { fail(); return; }
chunk.copy(bytes, used); used += chunk.length;
if (used >= 4) {
const length = bytes.readUInt32BE(0);
if (!length || length > MAX_PAYLOAD || used > length + 4) fail();
}
});
stream.once("error", fail);
stream.once("close", () => { if (!ended) fail(); });
stream.once("end", () => {
ended = true;
if (failed) return;
try {
if (used < 4 || used !== bytes.readUInt32BE(0) + 4) throw new Error(MIGRATION_ERROR);
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(4, used));
resolve(JSON.parse(text));
} catch { fail(); }
finally { bytes.fill(0); }
});
});
}
export function parseRequest(value: unknown): Request {
if (!exact(value, ["version", "nonce", "sourceProtectedKeySha256", "key"]) || value.version !== 1 ||
typeof value.nonce !== "string" || !digestPattern.test(value.nonce) ||
typeof value.sourceProtectedKeySha256 !== "string" || !digestPattern.test(value.sourceProtectedKeySha256))
throw new Error(MIGRATION_ERROR);
canonicalKey(value.key).fill(0);
return value as Request;
}
function authentication(key: Buffer, nonce: string, digest: string, wrappedKey: string): string {
return createHmac("sha256", key).update(JSON.stringify([1, nonce, digest, wrappedKey])).digest("hex");
}
export function wrapRequest(value: unknown, storage: { isEncryptionAvailable(): boolean; encryptString(key: string): Buffer; decryptString(bytes: Buffer): string }): Response {
const request = parseRequest(value), key = canonicalKey(request.key);
let roundtrip: Buffer | undefined, wrapped: Buffer | undefined;
try {
if (!storage.isEncryptionAvailable()) throw new Error(MIGRATION_ERROR);
wrapped = storage.encryptString(request.key);
const wrappedKey = wrapped.toString("base64");
canonicalBytes(wrappedKey).fill(0);
roundtrip = canonicalKey(storage.decryptString(wrapped));
if (!timingSafeEqual(key, roundtrip)) throw new Error(MIGRATION_ERROR);
return { version: 1, nonce: request.nonce, sourceProtectedKeySha256: request.sourceProtectedKeySha256,
wrappedKey, mac: authentication(roundtrip, request.nonce, request.sourceProtectedKeySha256, wrappedKey) };
} finally { key.fill(0); roundtrip?.fill(0); wrapped?.fill(0); request.key = ""; }
}
function verifyResponse(value: unknown, request: Request, key: Buffer): CurrentRecord {
if (!exact(value, ["version", "nonce", "sourceProtectedKeySha256", "wrappedKey", "mac"]) ||
value.version !== 1 || value.nonce !== request.nonce || value.sourceProtectedKeySha256 !== request.sourceProtectedKeySha256 ||
typeof value.mac !== "string" || !digestPattern.test(value.mac)) throw new Error(MIGRATION_ERROR);
canonicalBytes(value.wrappedKey).fill(0);
const expected = authentication(key, request.nonce, request.sourceProtectedKeySha256, value.wrappedKey as string);
if (!timingSafeEqual(Buffer.from(value.mac, "hex"), Buffer.from(expected, "hex"))) throw new Error(MIGRATION_ERROR);
return parseCurrentRecord({ version: 1, name: CURRENT_NAME, sourceProtectedKeySha256: request.sourceProtectedKeySha256, wrappedKey: value.wrappedKey });
}
export function helperEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const name of ["HOME", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "__CF_USER_TEXT_ENCODING"])
if (env[name] !== undefined) result[name] = env[name];
return result;
}
export function createHelperProfile(): string {
const parent = fs.mkdtempSync(path.join(fs.realpathSync(tmpdir()), "emc2-keychain-"));
fs.chmodSync(parent, 0o700);
const profile = path.join(parent, "profile");
fs.mkdirSync(profile, { mode: 0o700 });
return profile;
}
export function validateHelperProfile(args: string[], effective: string, tty: boolean): string {
if (process.platform !== "darwin" || tty || args.filter(arg => arg === HELPER_FLAG).length !== 1) throw new Error(MIGRATION_ERROR);
const switches = args.filter(arg => arg === "--user-data-dir" || arg.startsWith("--user-data-dir="));
if (switches.length !== 1 || !switches[0].startsWith("--user-data-dir=")) throw new Error(MIGRATION_ERROR);
const profile = switches[0].slice("--user-data-dir=".length), parent = path.dirname(profile);
const terminator = args.indexOf("--");
if ((terminator >= 0 && args.indexOf(switches[0]) > terminator) || !path.isAbsolute(profile) ||
profile !== effective || fs.realpathSync(profile) !== profile || fs.realpathSync(parent) !== parent ||
path.dirname(parent) !== fs.realpathSync(tmpdir()) || !/^emc2-keychain-[A-Za-z0-9]+$/.test(path.basename(parent)) ||
path.basename(profile) !== "profile") throw new Error(MIGRATION_ERROR);
for (const directory of [parent, profile]) {
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700)
throw new Error(MIGRATION_ERROR);
}
if (fs.readdirSync(profile).length || fs.readdirSync(parent).join() !== "profile") throw new Error(MIGRATION_ERROR);
return profile;
}
type Lifecycle = { isPackaged: boolean; getAppPath(): string; on(event: "before-quit", handler: (event: { preventDefault(): void }) => void): unknown;
removeListener(event: "before-quit", handler: (event: { preventDefault(): void }) => void): unknown; quit(): void; exit(code: number): void };
export async function migrateLegacyKey(options: { mailPath: string; key: string; app: Lifecycle; deadlineMs?: number; spawnChild?: typeof spawn }): Promise<void> {
let profile: string | undefined, child: ReturnType<typeof spawn> | undefined;
const key = canonicalKey(options.key);
let closed = false;
let priorUncaught: NodeJS.UncaughtExceptionListener[] | undefined;
let priorRejections: NodeJS.UnhandledRejectionListener[] | undefined;
const cleanup = () => {
if (profile && (!child || closed)) {
try { fs.rmSync(path.dirname(profile), { recursive: true, force: true }); }
catch { throw new Error(MIGRATION_ERROR); }
}
};
let cancel: (() => Promise<void>) | undefined;
const quit = (event: { preventDefault(): void }) => { event.preventDefault(); void cancel?.().then(() => options.app.quit()); };
const fatal = () => { void cancel?.().then(() => options.app.exit(1), () => options.app.exit(1)); };
const signal = () => fatal();
try {
const request: Request = { version: 1, nonce: randomBytes(32).toString("hex"), sourceProtectedKeySha256: protectedDigest(options.mailPath), key: options.key };
profile = createHelperProfile();
const args = [...(options.app.isPackaged ? [] : [options.app.getAppPath()]), HELPER_FLAG, `--user-data-dir=${profile}`];
const owned = (options.spawnChild ?? spawn)(process.execPath, args, { stdio: ["pipe", "pipe", "ignore"], env: helperEnvironment(process.env) });
child = owned;
let failed = false;
const exit = new Promise<void>((resolve) => owned.once("close", (code, signal) => {
closed = true;
if (code !== 0 || signal) failed = true;
resolve();
}));
cancel = async () => {
failed = true;
if (!closed) owned.kill("SIGKILL");
await exit;
options.app.removeListener("before-quit", quit);
key.fill(0);
cleanup();
};
options.app.on("before-quit", quit);
priorUncaught = process.listeners("uncaughtException");
priorRejections = process.listeners("unhandledRejection");
// The existing Electron listener would otherwise display the same raw error.
process.removeAllListeners("uncaughtException"); process.removeAllListeners("unhandledRejection");
process.on("uncaughtException", fatal); process.on("unhandledRejection", fatal);
process.on("SIGTERM", signal); process.on("SIGINT", signal);
const timer = setTimeout(() => { void cancel?.(); }, options.deadlineMs ?? 5 * 60 * 1000);
try {
if (!owned.stdin || !owned.stdout) { await cancel(); throw new Error(MIGRATION_ERROR); }
owned.once("error", () => { void cancel?.(); });
owned.stdin.on("error", () => { void cancel?.(); });
const response = readFrame(owned.stdout).catch(async () => { await cancel?.(); return undefined; });
const frame = encodeFrame(request); request.key = "";
owned.stdin.end(frame, () => frame.fill(0));
const value = await response;
await exit;
if (failed) throw new Error(MIGRATION_ERROR);
const record = verifyResponse(value, request, key);
commitCurrentRecord(options.mailPath, record);
} finally { clearTimeout(timer); }
} catch {
if (cancel && !closed) await cancel();
throw new Error(MIGRATION_ERROR);
} finally {
options.app.removeListener("before-quit", quit);
process.removeListener("uncaughtException", fatal); process.removeListener("unhandledRejection", fatal);
process.removeListener("SIGTERM", signal); process.removeListener("SIGINT", signal);
if (priorUncaught) for (const handler of priorUncaught) process.on("uncaughtException", handler);
if (priorRejections) for (const handler of priorRejections) process.on("unhandledRejection", handler);
key.fill(0);
// A hard-killed parent cannot run this cleanup, especially during a native prompt.
cleanup();
}
}