Some checks failed
Docker Cloud Build / Build & Publish Image (push) Failing after 10m4s
- 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
96 lines
2.9 KiB
JavaScript
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");
|
|
}
|