Files
Gerhard Scheikl 01b4734477 security hardening
2026-05-31 09:35:31 +02:00

85 lines
2.5 KiB
TypeScript

/**
* Field-level encryption at rest using AES-256-GCM.
*
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
*
* `decryptField` is backward-compatible: values that do not carry the
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
* so an existing (dev) database keeps working without a data migration.
*/
import crypto from "node:crypto";
import { requireEnv } from "../config/env.server";
const PREFIX = "enc:v1:";
const IV_BYTES = 12;
const KEY_BYTES = 32;
let cachedKey: Buffer | null = null;
/**
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
*/
function getKey(): Buffer {
if (cachedKey) return cachedKey;
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
let key: Buffer;
try {
key = Buffer.from(b64, "base64");
} catch {
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
}
if (key.length !== KEY_BYTES) {
throw new Error(
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
);
}
cachedKey = key;
return key;
}
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
export function encryptField(plaintext: string): string {
const key = getKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return (
PREFIX +
iv.toString("base64") +
":" +
tag.toString("base64") +
":" +
ciphertext.toString("base64")
);
}
/**
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
* assumed to be legacy plaintext and returned unchanged.
*/
export function decryptField(value: string): string {
if (!value.startsWith(PREFIX)) return value;
const parts = value.slice(PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
}
const [ivB64, tagB64, dataB64] = parts;
const key = getKey();
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const ciphertext = Buffer.from(dataB64, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString("utf8");
}