D3V-Server/backend/lib/crypto.js
xtcnet 9b5152d81f
Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m4s
feat(security): AES-256-GCM encryption for WireGuard private keys in DB
- Add backend/lib/crypto.js: transparent encrypt/decrypt with DB_ENCRYPTION_KEY env var
- Add migration 20260319000000: idempotent data migration encrypts existing plaintext keys
- Patch wireguard.js: encrypt on write (3 points), decrypt on read (4 points)
- install.sh: auto-generate DB_ENCRYPTION_KEY via openssl, save to .env (chmod 600)
- AI_CONTEXT.md: document crypto.js and DB_ENCRYPTION_KEY requirement
2026-03-18 23:21:00 +07:00

96 lines
2.9 KiB
JavaScript

/**
* Application-layer AES-256-GCM encryption for sensitive DB fields.
*
* Encrypted format stored in DB:
* enc:<base64(iv)>:<base64(authTag)>:<base64(ciphertext)>
*
* Set DB_ENCRYPTION_KEY to a 64-char hex string (32 bytes):
* export DB_ENCRYPTION_KEY=$(openssl rand -hex 32)
*
* If DB_ENCRYPTION_KEY is not set, values are stored as plaintext (with warning).
*/
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
const ENC_PREFIX = "enc:";
let _key = null;
let _warned = false;
function getKey() {
if (_key) return _key;
const hex = process.env.DB_ENCRYPTION_KEY;
if (!hex) {
if (!_warned) {
console.warn("[crypto] WARNING: DB_ENCRYPTION_KEY is not set. WireGuard private keys are stored as PLAINTEXT in the database.");
_warned = true;
}
return null;
}
if (hex.length !== 64) {
throw new Error("DB_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Generate with: openssl rand -hex 32");
}
_key = Buffer.from(hex, "hex");
return _key;
}
/**
* Encrypt a plaintext string.
* Returns the encrypted string with "enc:" prefix, or the original value if no key is set.
* @param {string} plaintext
* @returns {string}
*/
export function encrypt(plaintext) {
if (!plaintext) return plaintext;
// Already encrypted — idempotent
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
const key = getKey();
if (!key) return plaintext; // passthrough if no key configured
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${ENC_PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
}
/**
* Decrypt an encrypted string.
* Returns plaintext, or the original value if it is not encrypted.
* @param {string} value
* @returns {string}
*/
export function decrypt(value) {
if (!value) return value;
// Not encrypted — return as-is (backward compat with plaintext rows)
if (!value.startsWith(ENC_PREFIX)) return value;
const key = getKey();
if (!key) {
throw new Error("DB_ENCRYPTION_KEY is required to decrypt WireGuard keys but is not set.");
}
const parts = value.slice(ENC_PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted value format in database.");
}
const [ivB64, tagB64, dataB64] = parts;
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const data = Buffer.from(dataB64,"base64");
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
}