// Encryption at rest for order data (Shopify protected customer data, Level 1). // - hashId(): keyed HMAC-SHA256, deterministic, so order ids can still be counted and de-duplicated // without storing them readably. // - encrypt()/decrypt(): AES-256-GCM for values we need to show again (order number, order id for links). // Key: DATA_ENCRYPTION_KEY = 64 hex characters (openssl rand -hex 32). Keep it out of Git; losing it makes // existing analytics rows unreadable (they still count). import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; let keys: { enc: Buffer; mac: Buffer } | null = null; function getKeys() { if (keys) return keys; const hex = process.env.DATA_ENCRYPTION_KEY || ""; if (!/^[0-9a-fA-F]{64}$/.test(hex)) { throw new Error("DATA_ENCRYPTION_KEY must be 64 hex characters (openssl rand -hex 32)"); } const master = Buffer.from(hex, "hex"); keys = { enc: createHash("sha256").update(Buffer.concat([master, Buffer.from("offerlyst:enc:v1")])).digest(), mac: createHash("sha256").update(Buffer.concat([master, Buffer.from("offerlyst:mac:v1")])).digest(), }; return keys; } export function hashId(value: string) { return createHmac("sha256", getKeys().mac).update(value).digest("hex"); } export function encrypt(value: string | null | undefined): string | null { if (value == null || value === "") return null; const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", getKeys().enc, iv); const data = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); return `v1:${Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64")}`; } export function decrypt(value: string | null | undefined): string | null { if (!value) return null; if (!value.startsWith("v1:")) return value; // not encrypted (should not happen) try { const raw = Buffer.from(value.slice(3), "base64"); const decipher = createDecipheriv("aes-256-gcm", getKeys().enc, raw.subarray(0, 12)); decipher.setAuthTag(raw.subarray(12, 28)); return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8"); } catch { return null; } }