From 9b5152d81f33bb438e9d76166b37a5a2de0de230 Mon Sep 17 00:00:00 2001 From: xtcnet Date: Wed, 18 Mar 2026 23:21:00 +0700 Subject: [PATCH] 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 --- AI_CONTEXT.md | 2 + backend/internal/wireguard.js | 17 ++-- backend/lib/crypto.js | 96 +++++++++++++++++++ .../20260319000000_wireguard_encrypt_keys.js | 61 ++++++++++++ install.sh | 15 +++ 5 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 backend/lib/crypto.js create mode 100644 backend/migrations/20260319000000_wireguard_encrypt_keys.js diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index e1dbe59..2e7183e 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -22,6 +22,7 @@ WireGuard functionality is disabled by default and enabled via the `WG_ENABLED` ### Backend Map (Node.js) If you need to edit WireGuard logic, check these files: - **`backend/lib/wg-helpers.js`**: Shell wrappers for `wg` CLI (create keys, parse CIDR, parse `wg show` dumps, gen configurations). +- **`backend/lib/crypto.js`**: AES-256-GCM encrypt/decrypt helper for sensitive DB fields. Uses `DB_ENCRYPTION_KEY` env var. Transparent passthrough if key is not set (backward compat). - **`backend/internal/wireguard.js`**: Core business logic. Manages interface start/stop, adding/removing clients, IP allocation, and token expiration checking via cron. - **`backend/routes/wireguard.js`**: REST APIs exposing CRUD operations to the frontend. Note: Handlers use ES module export functions syntax. - **`backend/routes/main.js`**: Mounts the `/api/wireguard` routes. @@ -53,6 +54,7 @@ If you need to edit the UI/UX, check these files: - **Required capabilities**: `--cap-add=NET_ADMIN` and `--cap-add=SYS_MODULE` are required for WireGuard to manipulate interfaces. - **Sysctls**: `--sysctl net.ipv4.ip_forward=1` must be applied to the container. - **Volumes**: Volume `/etc/letsencrypt` is severely required by original NPM core. +- **DB Encryption**: `DB_ENCRYPTION_KEY` must be a 64-char hex string (`openssl rand -hex 32`). It is auto-generated by `install.sh` and saved to `/opt/d3v-npmwg/.env`. **Losing this key means WireGuard private keys in the database cannot be decrypted.** --- diff --git a/backend/internal/wireguard.js b/backend/internal/wireguard.js index 5a278de..09f9100 100644 --- a/backend/internal/wireguard.js +++ b/backend/internal/wireguard.js @@ -5,6 +5,7 @@ import { global as logger } from "../logger.js"; import * as wgHelpers from "../lib/wg-helpers.js"; import internalWireguardFs from "./wireguard-fs.js"; import internalAuditLog from "./audit-log.js"; +import { encrypt, decrypt } from "../lib/crypto.js"; const execAsync = promisify(exec); @@ -34,7 +35,7 @@ const internalWireguard = { // Seed a default config if it doesn't exist const insertData = { name: "wg0", - private_key: privateKey, + private_key: encrypt(privateKey), public_key: publicKey, listen_port: 51820, ipv4_cidr: "10.0.0.1/24", @@ -120,7 +121,7 @@ const internalWireguard = { const serverAddress = `${parsed.firstHost}/${parsed.prefix}`; let configContent = wgHelpers.generateServerInterface({ - privateKey: iface.private_key, + privateKey: decrypt(iface.private_key), address: serverAddress, listenPort: iface.listen_port, mtu: iface.mtu, @@ -134,7 +135,7 @@ const internalWireguard = { for (const client of ifaceClients) { configContent += "\n\n" + wgHelpers.generateServerPeer({ publicKey: client.public_key, - preSharedKey: client.pre_shared_key, + preSharedKey: decrypt(client.pre_shared_key), allowedIps: `${client.ipv4_address}/32`, }); } @@ -317,9 +318,9 @@ const internalWireguard = { name: data.name || "Unnamed Client", enabled: true, ipv4_address: ipv4Address, - private_key: privateKey, + private_key: encrypt(privateKey), public_key: publicKey, - pre_shared_key: preSharedKey, + pre_shared_key: encrypt(preSharedKey), allowed_ips: data.allowed_ips || WG_DEFAULT_ALLOWED_IPS, persistent_keepalive: data.persistent_keepalive || WG_DEFAULT_PERSISTENT_KEEPALIVE, expires_at: data.expires_at || null, @@ -426,12 +427,12 @@ const internalWireguard = { const endpoint = `${iface.host || "YOUR_SERVER_IP"}:${iface.listen_port}`; return wgHelpers.generateClientConfig({ - clientPrivateKey: client.private_key, + clientPrivateKey: decrypt(client.private_key), clientAddress: `${client.ipv4_address}/32`, dns: iface.dns, mtu: iface.mtu, serverPublicKey: iface.public_key, - preSharedKey: client.pre_shared_key, + preSharedKey: decrypt(client.pre_shared_key), allowedIps: client.allowed_ips, persistentKeepalive: client.persistent_keepalive, endpoint: endpoint, @@ -475,7 +476,7 @@ const internalWireguard = { const insertData = { name, - private_key: privateKey, + private_key: encrypt(privateKey), public_key: publicKey, listen_port, ipv4_cidr, diff --git a/backend/lib/crypto.js b/backend/lib/crypto.js new file mode 100644 index 0000000..a993232 --- /dev/null +++ b/backend/lib/crypto.js @@ -0,0 +1,96 @@ +/** + * Application-layer AES-256-GCM encryption for sensitive DB fields. + * + * Encrypted format stored in DB: + * enc::: + * + * 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"); +} diff --git a/backend/migrations/20260319000000_wireguard_encrypt_keys.js b/backend/migrations/20260319000000_wireguard_encrypt_keys.js new file mode 100644 index 0000000..4e50a8b --- /dev/null +++ b/backend/migrations/20260319000000_wireguard_encrypt_keys.js @@ -0,0 +1,61 @@ +/** + * Data migration: encrypts existing plaintext WireGuard private keys + * and pre-shared keys in the database. + * + * This migration is safe to run multiple times (idempotent): + * already-encrypted values (prefix "enc:") are skipped. + * + * Requires DB_ENCRYPTION_KEY to be set in the environment. + * If not set, migration logs a warning and exits without modifying data. + */ + +import { encrypt } from "../lib/crypto.js"; + +const migrate_name = "wireguard_encrypt_keys"; + +export async function up(knex) { + const key = process.env.DB_ENCRYPTION_KEY; + if (!key) { + console.warn(`[${migrate_name}] DB_ENCRYPTION_KEY not set — skipping encryption migration. Keys remain as plaintext.`); + return; + } + + console.log(`[${migrate_name}] Encrypting existing WireGuard keys...`); + + // --- wg_interface: encrypt private_key --- + const ifaces = await knex("wg_interface").select("id", "private_key"); + let ifaceCount = 0; + for (const iface of ifaces) { + if (!iface.private_key || iface.private_key.startsWith("enc:")) continue; + await knex("wg_interface").where("id", iface.id).update({ + private_key: encrypt(iface.private_key), + }); + ifaceCount++; + } + console.log(`[${migrate_name}] wg_interface: ${ifaceCount} rows encrypted.`); + + // --- wg_client: encrypt private_key + pre_shared_key --- + const clients = await knex("wg_client").select("id", "private_key", "pre_shared_key"); + let clientCount = 0; + for (const client of clients) { + const updates = {}; + if (client.private_key && !client.private_key.startsWith("enc:")) { + updates.private_key = encrypt(client.private_key); + } + if (client.pre_shared_key && !client.pre_shared_key.startsWith("enc:")) { + updates.pre_shared_key = encrypt(client.pre_shared_key); + } + if (Object.keys(updates).length > 0) { + await knex("wg_client").where("id", client.id).update(updates); + clientCount++; + } + } + console.log(`[${migrate_name}] wg_client: ${clientCount} rows encrypted.`); + console.log(`[${migrate_name}] Done.`); +} + +export async function down(knex) { + // Intentionally a no-op: decrypting back to plaintext would require the key, + // and rolling back encryption is a security risk. + console.warn(`[${migrate_name}] down() is a no-op. Keys remain encrypted.`); +} diff --git a/install.sh b/install.sh index 8df379d..3433555 100644 --- a/install.sh +++ b/install.sh @@ -210,6 +210,8 @@ $(echo -e "$custom_ports_block" | sed '/^$/d') volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt - ./wireguard:/etc/wireguard + env_file: + - .env environment: WG_HOST: "${host}" ${network_block} @@ -304,6 +306,19 @@ do_install() { # --- Write docker-compose.yml --- generate_docker_compose "$wg_host" + # --- Generate DB encryption key if not already present --- + local env_file="${INSTALL_DIR}/.env" + if [ ! -f "$env_file" ] || ! grep -q "DB_ENCRYPTION_KEY" "$env_file" 2>/dev/null; then + log_step "Generating DB_ENCRYPTION_KEY for WireGuard key encryption..." + local enc_key + enc_key=$(openssl rand -hex 32) + echo "DB_ENCRYPTION_KEY=${enc_key}" >> "$env_file" + chmod 600 "$env_file" + log_ok "DB_ENCRYPTION_KEY generated and saved to ${env_file}" + log_warn "IMPORTANT: Back up ${env_file} — losing this key means WireGuard keys cannot be decrypted!" + else + log_ok "DB_ENCRYPTION_KEY already exists in ${env_file}." + fi # --- Pull & Start --- log_step "Pulling Docker image (this may take a few minutes)..." local dc