diff --git a/src/lib/mail-crypto.server.ts b/src/lib/mail-crypto.server.ts new file mode 100644 index 0000000..a56283d --- /dev/null +++ b/src/lib/mail-crypto.server.ts @@ -0,0 +1,55 @@ +// Sealing for mailbox passwords. Server-only: never import from a route or a +// *.functions.ts top level, both of which ship to the client bundle. +// +// AES-256-GCM so the stored value is both encrypted and tamper-evident — a +// modified ciphertext fails the auth tag rather than decrypting to garbage that +// then gets sent to an IMAP server. +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +export type Sealed = { ciphertext: string; iv: string; tag: string }; + +const KEY_ENV = "MAIL_CRED_KEY"; + +// 32 bytes, supplied base64 or hex. Generate with: +// openssl rand -base64 32 +function key(): Buffer { + const raw = process.env[KEY_ENV]; + if (!raw) { + throw new Error( + `${KEY_ENV} is not set. Add a 32-byte key to .env.secret (openssl rand -base64 32) before using mail.`, + ); + } + const buf = /^[0-9a-fA-F]{64}$/.test(raw.trim()) + ? Buffer.from(raw.trim(), "hex") + : Buffer.from(raw.trim(), "base64"); + if (buf.length !== 32) { + throw new Error(`${KEY_ENV} must decode to exactly 32 bytes (got ${buf.length}).`); + } + return buf; +} + +export function seal(plaintext: string): Sealed { + const iv = randomBytes(12); // 96-bit nonce, the GCM standard + const c = createCipheriv("aes-256-gcm", key(), iv); + const ct = Buffer.concat([c.update(plaintext, "utf8"), c.final()]); + return { + ciphertext: ct.toString("base64"), + iv: iv.toString("base64"), + tag: c.getAuthTag().toString("base64"), + }; +} + +export function open(s: Sealed): string { + const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(s.iv, "base64")); + d.setAuthTag(Buffer.from(s.tag, "base64")); + return Buffer.concat([d.update(Buffer.from(s.ciphertext, "base64")), d.final()]).toString("utf8"); +} + +export function mailKeyConfigured(): boolean { + try { + key(); + return true; + } catch { + return false; + } +}