85 lines
2.5 KiB
TypeScript
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");
|
|
}
|