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
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:
parent
44f0a080ff
commit
9b5152d81f
5 changed files with 183 additions and 8 deletions
|
|
@ -22,6 +22,7 @@ WireGuard functionality is disabled by default and enabled via the `WG_ENABLED`
|
||||||
### Backend Map (Node.js)
|
### Backend Map (Node.js)
|
||||||
If you need to edit WireGuard logic, check these files:
|
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/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/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/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.
|
- **`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.
|
- **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.
|
- **Sysctls**: `--sysctl net.ipv4.ip_forward=1` must be applied to the container.
|
||||||
- **Volumes**: Volume `/etc/letsencrypt` is severely required by original NPM core.
|
- **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.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { global as logger } from "../logger.js";
|
||||||
import * as wgHelpers from "../lib/wg-helpers.js";
|
import * as wgHelpers from "../lib/wg-helpers.js";
|
||||||
import internalWireguardFs from "./wireguard-fs.js";
|
import internalWireguardFs from "./wireguard-fs.js";
|
||||||
import internalAuditLog from "./audit-log.js";
|
import internalAuditLog from "./audit-log.js";
|
||||||
|
import { encrypt, decrypt } from "../lib/crypto.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ const internalWireguard = {
|
||||||
// Seed a default config if it doesn't exist
|
// Seed a default config if it doesn't exist
|
||||||
const insertData = {
|
const insertData = {
|
||||||
name: "wg0",
|
name: "wg0",
|
||||||
private_key: privateKey,
|
private_key: encrypt(privateKey),
|
||||||
public_key: publicKey,
|
public_key: publicKey,
|
||||||
listen_port: 51820,
|
listen_port: 51820,
|
||||||
ipv4_cidr: "10.0.0.1/24",
|
ipv4_cidr: "10.0.0.1/24",
|
||||||
|
|
@ -120,7 +121,7 @@ const internalWireguard = {
|
||||||
const serverAddress = `${parsed.firstHost}/${parsed.prefix}`;
|
const serverAddress = `${parsed.firstHost}/${parsed.prefix}`;
|
||||||
|
|
||||||
let configContent = wgHelpers.generateServerInterface({
|
let configContent = wgHelpers.generateServerInterface({
|
||||||
privateKey: iface.private_key,
|
privateKey: decrypt(iface.private_key),
|
||||||
address: serverAddress,
|
address: serverAddress,
|
||||||
listenPort: iface.listen_port,
|
listenPort: iface.listen_port,
|
||||||
mtu: iface.mtu,
|
mtu: iface.mtu,
|
||||||
|
|
@ -134,7 +135,7 @@ const internalWireguard = {
|
||||||
for (const client of ifaceClients) {
|
for (const client of ifaceClients) {
|
||||||
configContent += "\n\n" + wgHelpers.generateServerPeer({
|
configContent += "\n\n" + wgHelpers.generateServerPeer({
|
||||||
publicKey: client.public_key,
|
publicKey: client.public_key,
|
||||||
preSharedKey: client.pre_shared_key,
|
preSharedKey: decrypt(client.pre_shared_key),
|
||||||
allowedIps: `${client.ipv4_address}/32`,
|
allowedIps: `${client.ipv4_address}/32`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -317,9 +318,9 @@ const internalWireguard = {
|
||||||
name: data.name || "Unnamed Client",
|
name: data.name || "Unnamed Client",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
ipv4_address: ipv4Address,
|
ipv4_address: ipv4Address,
|
||||||
private_key: privateKey,
|
private_key: encrypt(privateKey),
|
||||||
public_key: publicKey,
|
public_key: publicKey,
|
||||||
pre_shared_key: preSharedKey,
|
pre_shared_key: encrypt(preSharedKey),
|
||||||
allowed_ips: data.allowed_ips || WG_DEFAULT_ALLOWED_IPS,
|
allowed_ips: data.allowed_ips || WG_DEFAULT_ALLOWED_IPS,
|
||||||
persistent_keepalive: data.persistent_keepalive || WG_DEFAULT_PERSISTENT_KEEPALIVE,
|
persistent_keepalive: data.persistent_keepalive || WG_DEFAULT_PERSISTENT_KEEPALIVE,
|
||||||
expires_at: data.expires_at || null,
|
expires_at: data.expires_at || null,
|
||||||
|
|
@ -426,12 +427,12 @@ const internalWireguard = {
|
||||||
const endpoint = `${iface.host || "YOUR_SERVER_IP"}:${iface.listen_port}`;
|
const endpoint = `${iface.host || "YOUR_SERVER_IP"}:${iface.listen_port}`;
|
||||||
|
|
||||||
return wgHelpers.generateClientConfig({
|
return wgHelpers.generateClientConfig({
|
||||||
clientPrivateKey: client.private_key,
|
clientPrivateKey: decrypt(client.private_key),
|
||||||
clientAddress: `${client.ipv4_address}/32`,
|
clientAddress: `${client.ipv4_address}/32`,
|
||||||
dns: iface.dns,
|
dns: iface.dns,
|
||||||
mtu: iface.mtu,
|
mtu: iface.mtu,
|
||||||
serverPublicKey: iface.public_key,
|
serverPublicKey: iface.public_key,
|
||||||
preSharedKey: client.pre_shared_key,
|
preSharedKey: decrypt(client.pre_shared_key),
|
||||||
allowedIps: client.allowed_ips,
|
allowedIps: client.allowed_ips,
|
||||||
persistentKeepalive: client.persistent_keepalive,
|
persistentKeepalive: client.persistent_keepalive,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
|
@ -475,7 +476,7 @@ const internalWireguard = {
|
||||||
|
|
||||||
const insertData = {
|
const insertData = {
|
||||||
name,
|
name,
|
||||||
private_key: privateKey,
|
private_key: encrypt(privateKey),
|
||||||
public_key: publicKey,
|
public_key: publicKey,
|
||||||
listen_port,
|
listen_port,
|
||||||
ipv4_cidr,
|
ipv4_cidr,
|
||||||
|
|
|
||||||
96
backend/lib/crypto.js
Normal file
96
backend/lib/crypto.js
Normal 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");
|
||||||
|
}
|
||||||
61
backend/migrations/20260319000000_wireguard_encrypt_keys.js
Normal file
61
backend/migrations/20260319000000_wireguard_encrypt_keys.js
Normal 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.`);
|
||||||
|
}
|
||||||
15
install.sh
15
install.sh
|
|
@ -210,6 +210,8 @@ $(echo -e "$custom_ports_block" | sed '/^$/d') volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./letsencrypt:/etc/letsencrypt
|
- ./letsencrypt:/etc/letsencrypt
|
||||||
- ./wireguard:/etc/wireguard
|
- ./wireguard:/etc/wireguard
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
WG_HOST: "${host}"
|
WG_HOST: "${host}"
|
||||||
${network_block}
|
${network_block}
|
||||||
|
|
@ -304,6 +306,19 @@ do_install() {
|
||||||
# --- Write docker-compose.yml ---
|
# --- Write docker-compose.yml ---
|
||||||
generate_docker_compose "$wg_host"
|
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 ---
|
# --- Pull & Start ---
|
||||||
log_step "Pulling Docker image (this may take a few minutes)..."
|
log_step "Pulling Docker image (this may take a few minutes)..."
|
||||||
local dc
|
local dc
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue