bundle-offers-app/app/lib/crypto.server.ts
metatroncubeswdev 752845302a Check DATA_ENCRYPTION_KEY at startup; launch skill: bad key broke order analytics (review 2.1.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:35:24 -04:00

54 lines
2.3 KiB
TypeScript

// 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;
/** Fails fast at startup: a bad key would otherwise only surface as 500s on every orders/create webhook. */
export function assertEncryptionKey() {
getKeys();
}
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;
}
}