bundle-offers-app/app/lib/crypto.server.ts
metatroncubeswdev 38c03cd1ae Encrypt order data at rest; add terms of service with data processing terms
Protected customer data (Level 1): order ids are stored as keyed HMACs, order numbers
and ids for admin links as AES-256-GCM ciphertext (DATA_ENCRYPTION_KEY). New public
/terms page with processor terms; privacy policy links to it.

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

49 lines
2.1 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;
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;
}
}