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)
|
||||
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.**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
- ./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
|
||||
|
|
|
|||
Loading…
Reference in a new issue