security hardening
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
Reference in New Issue
Block a user