feat(security): AES-256-GCM encryption for WireGuard private keys in DB
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
This commit is contained in:
xtcnet 2026-03-18 23:21:00 +07:00
parent 44f0a080ff
commit 9b5152d81f
5 changed files with 183 additions and 8 deletions

View file

@ -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.**
---

View file

@ -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,

96
backend/lib/crypto.js Normal file
View file

@ -0,0 +1,96 @@
/**
* 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");
}

View file

@ -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.`);
}

View file

@ -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