datewise/app/services/crypto.server.ts
metatroncubeswdev 1d2d943d54 feat(security): encrypt customer personal data at rest
- Prisma extension seals booking name/email/phone/address and waitlist contacts with
  AES-256-GCM on write and opens them on read (incl. nested reads)
- keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search,
  waitlist de-duplication); migration 0002
- tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:56:05 -04:00

54 lines
2.3 KiB
TypeScript

/**
* AES-256-GCM encryption at rest (messaging credentials and customer contact details) and keyed
* hashes ("blind indexes") for exact-match lookups on encrypted values.
* APP_ENCRYPTION_KEY = 32 random bytes, base64 (`openssl rand -base64 32`).
*/
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
let cached: { raw: string; enc: Buffer; idx: Buffer } | null = null;
function keys() {
const raw = process.env.APP_ENCRYPTION_KEY ?? "";
if (cached?.raw === raw) return cached;
const enc = Buffer.from(raw, "base64");
if (enc.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
// A separate key for hashing, derived so one secret covers both uses.
const idx = createHmac("sha256", enc).update("datewise:blind-index:v1").digest();
cached = { raw, enc, idx };
return cached;
}
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", keys().enc, iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join(".");
}
export function decrypt(blob: string): string {
const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url"));
const d = createDecipheriv("aes-256-gcm", keys().enc, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
}
/** Marks sealed column values so rows written before encryption (plain text) still read correctly. */
const PREFIX = "enc1:";
export function seal(plain: string | null | undefined): string | null | undefined {
if (plain == null || plain === "" || plain.startsWith(PREFIX)) return plain;
return PREFIX + encrypt(plain);
}
export function unseal(value: string | null | undefined): string | null {
if (value == null) return null;
return value.startsWith(PREFIX) ? decrypt(value.slice(PREFIX.length)) : value;
}
/** Keyed hash of a normalised email or phone, for exact-match queries. Never reversible. */
export function blindIndex(value: string | null | undefined, kind: "email" | "phone"): string | null {
if (!value) return null;
const norm = kind === "email" ? value.trim().toLowerCase() : value.replace(/[^\d+]/g, "");
if (!norm) return null;
return createHmac("sha256", keys().idx).update(`${kind}:${norm}`).digest("hex");
}