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