- 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>
54 lines
2.3 KiB
TypeScript
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");
|
|
}
|