From 0b59ff6e07534a77e7c18659b9dd1c98b43b1074 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 8 Aug 2025 12:51:57 +0000 Subject: [PATCH] verifyCanary, DEK safeguards --- .env | 5 +- .woodpecker.yml | 24 +++- Dockerfile.nginx | 2 +- Dockerfile.server1 | 9 +- Dockerfile.server2 | 8 +- Dockerfile.server3 | 21 +-- backend/server1.js | 91 +++++++++---- backend/server2.js | 16 ++- backend/server3.js | 20 ++- backend/shared/crypto/encryption.js | 203 ++++++++++++++++------------ docker-compose.yml | 43 +++++- docker-entrypoint.sh | 12 ++ migrate_encrypted_columns.sql | 2 + package-lock.json | 13 ++ package.json | 1 + 15 files changed, 325 insertions(+), 145 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/.env b/.env index 52a51b9..275e223 100644 --- a/.env +++ b/.env @@ -2,4 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http:// SERVER1_PORT=5000 SERVER2_PORT=5001 SERVER3_PORT=5002 -IMG_TAG=16e01ab-202508071457 \ No newline at end of file +IMG_TAG=1d50efe-202508081233 + +ENV_NAME=dev +PROJECT=aptivaai-dev \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml index 2e3c6ac..79f128d 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -34,6 +34,7 @@ steps: 'set -euo pipefail; \ PROJECT=aptivaai-dev; \ ENV=staging; \ + ENV_NAME=staging; \ IMG_TAG=$(gcloud secrets versions access latest --secret=IMG_TAG --project=$PROJECT); \ export IMG_TAG; \ JWT_SECRET=$(gcloud secrets versions access latest --secret=JWT_SECRET_$ENV --project=$PROJECT); \ @@ -87,16 +88,25 @@ steps: export FROM_SECRETS_MANAGER=true; \ cd /home/jcoakley/aptiva-staging-app; \ - sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH \ + sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,ENV,ENV_NAME,PROJECT \ \ docker compose pull; \ - sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH \ + sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,ENV,ENV_NAME,PROJECT \ \ docker compose up -d --force-recreate --remove-orphans; \ echo "✅ Staging stack refreshed with tag $IMG_TAG"' - + secrets: - - STAGING_SSH_KEY - - STAGING_KNOWN_HOSTS + - STAGING_SSH_KEY + - STAGING_KNOWN_HOSTS + + - name: security-scan + image: aquasec/trivy:0.52 + commands: + - | + REG=us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo + trivy image --exit-code 1 --severity CRITICAL $REG/server1:$IMG_TAG + trivy image --exit-code 1 --severity CRITICAL $REG/server2:$IMG_TAG + trivy image --exit-code 1 --severity CRITICAL $REG/server3:$IMG_TAG + trivy image --exit-code 1 --severity CRITICAL $REG/nginx:$IMG_TAG when: - event: - - push + event: [push] diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 057db9a..1038e26 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -7,4 +7,4 @@ RUN rm /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf # Copy React build output into staging app's build folder -COPY build/ /usr/share/nginx/html +COPY build/ /usr/share/nginx/html \ No newline at end of file diff --git a/Dockerfile.server1 b/Dockerfile.server1 index 39634b6..8765f2b 100644 --- a/Dockerfile.server1 +++ b/Dockerfile.server1 @@ -1,4 +1,7 @@ -FROM node:20-bullseye AS base +FROM node:20-bullseye AS base + +RUN groupadd -r app && useradd -r -g app app + WORKDIR /app # ---- native build deps ---- @@ -13,4 +16,8 @@ COPY public/ /app/public/ RUN npm ci --unsafe-perm COPY . . +RUN mkdir -p /run/secrets && chown -R app:app /run/secrets + +USER app + CMD ["node", "backend/server1.js"] \ No newline at end of file diff --git a/Dockerfile.server2 b/Dockerfile.server2 index 91af5c1..9328a54 100644 --- a/Dockerfile.server2 +++ b/Dockerfile.server2 @@ -1,4 +1,7 @@ -FROM node:20-bullseye AS base +FROM node:20-bullseye AS base + +RUN groupadd -r app && useradd -r -g app app + WORKDIR /app # ---- native build deps ---- @@ -13,4 +16,7 @@ COPY public/ /app/public/ RUN npm ci --unsafe-perm COPY . . +RUN mkdir -p /run/secrets && chown -R app:app /run/secrets + +USER app CMD ["node", "backend/server2.js"] \ No newline at end of file diff --git a/Dockerfile.server3 b/Dockerfile.server3 index 7a23b54..652713b 100644 --- a/Dockerfile.server3 +++ b/Dockerfile.server3 @@ -1,20 +1,23 @@ -# ---- Dockerfile.server3 (fixed) ------------------------------ -FROM node:20-bullseye +FROM node:20-bullseye AS base + +RUN groupadd -r app && useradd -r -g app app + WORKDIR /app -# 1. native build dependencies + curl +# ---- native build deps ---- RUN apt-get update -y && \ apt-get install -y --no-install-recommends \ - build-essential python3 pkg-config curl && \ + build-essential python3 pkg-config && \ rm -rf /var/lib/apt/lists/* +# --------------------------- -# 2. node deps COPY package*.json ./ -RUN npm ci --omit=dev --unsafe-perm - -# 3. static assets & source -COPY public/ /app/public/ +COPY public/ /app/public/ +RUN npm ci --unsafe-perm COPY . . +RUN mkdir -p /run/secrets && chown -R app:app /run/secrets + +USER app CMD ["node", "backend/server3.js"] diff --git a/backend/server1.js b/backend/server1.js index 09ddcfd..494dfca 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -1,61 +1,88 @@ import express from 'express'; -import axios from 'axios'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; import { fileURLToPath } from 'url'; -import fs from 'fs'; import path from 'path'; import bodyParser from 'body-parser'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; // For token-based authentication -import { initEncryption } from './shared/crypto/encryption.js'; +import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import pool from './config/mysqlPool.js'; // adjust path if needed import sqlite3 from 'sqlite3'; + +const CANARY_SQL = ` + CREATE TABLE IF NOT EXISTS encryption_canary ( + id TINYINT NOT NULL PRIMARY KEY, + value TEXT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, '..'); // Up one level -const env = process.env.NODE_ENV?.trim() || 'development'; -const stage = env === 'staging' ? 'development' : env; +const env = process.env.NODE_ENV?.trim() || 'development'; const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath }); // Load .env file // Grab secrets and config from ENV -const JWT_SECRET = process.env.JWT_SECRET; -const DB_HOST = process.env.DB_HOST || '127.0.0.1'; -const DB_PORT = process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306; -const DB_USER = process.env.DB_USER || 'sqluser'; -const DB_PASSWORD = process.env.DB_PASSWORD || ''; -const DB_NAME = process.env.DB_NAME || 'user_profile_db'; +const { + JWT_SECRET, + CORS_ALLOWED_ORIGINS, + SERVER1_PORT = 5000 +} = process.env; if (!JWT_SECRET) { console.error('FATAL: JWT_SECRET missing – aborting startup'); process.exit(1); // container exits, Docker marks it unhealthy } -await initEncryption(); -// Test a quick query (optional) -try { - const [rows] = await pool.query('SELECT 1'); - console.log('Connected to MySQL user_profile_db'); -} catch (err) { - console.error('Error connecting to MySQL user_profile_db:', err.message); -} - - -const app = express(); -const PORT = process.env.SERVER1_PORT || 5000; - -/* ─── Require critical env vars ───────────────────────────────── */ -if (!process.env.CORS_ALLOWED_ORIGINS) { - console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line +if (!CORS_ALLOWED_ORIGINS) { + console.error('FATAL: CORS_ALLOWED_ORIGINS missing – aborting startup'); process.exit(1); } +/* ─── unwrap / verify DEK before we serve requests ────────────── */ +await initEncryption(); + +try { + /* quick connectivity smoke‑test (optional) */ + await pool.query('SELECT 1'); + + /* ① ensure table exists */ + await pool.query(CANARY_SQL); + + /* ② insert sentinel on first run */ + await pool.query( + 'INSERT IGNORE INTO encryption_canary (id, value) VALUES (1, ?)', + [encrypt(SENTINEL)] + ); + + /* ③ read back & verify */ + const [rows] = await pool.query( + 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1' + ); + + const plaintext = decrypt(rows[0]?.value || ''); + if (plaintext !== SENTINEL) { + throw new Error('DEK mismatch with database sentinel'); + } + + console.log('[ENCRYPT] DEK verified against canary – proceeding'); +} catch (err) { + console.error('FATAL:', err.message || err); + process.exit(1); // container restarts → alert +} + +/* ──────────────────────────────────────────────────────────────── + Express app & middleware + ---------------------------------------------------------------- */ +const app = express(); +const PORT = process.env.SERVER1_PORT || 5000; + /* ─── Allowed origins for CORS (comma-separated in env) ──────── */ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS .split(',') @@ -73,7 +100,15 @@ app.use( }) ); -app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content +app.get('/healthz', async (req, res) => { + try { + await verifyCanary(pool); // throws if bad + return res.type('text').send('OK'); // cheap 200 OK + } catch (e) { + console.error('[HEALTHZ]', e.message); + return res.status(500).type('text').send('FAIL'); + } +}); // Enable CORS with dynamic origin checking app.use( diff --git a/backend/server2.js b/backend/server2.js index cfa5da2..becc295 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -12,6 +12,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { open } from 'sqlite'; import sqlite3 from 'sqlite3'; +import pool from './config/mysqlPool.js'; // adjust path if needed import fs from 'fs'; import readline from 'readline'; import chatFreeEndpoint from "./utils/chatFreeEndpoint.js"; @@ -19,7 +20,8 @@ import { OpenAI } from 'openai'; import rateLimit from 'express-rate-limit'; import authenticateUser from './utils/authenticateUser.js'; import { vectorSearch } from "./utils/vectorSearch.js"; -import { initEncryption } from './shared/crypto/encryption.js'; +import { initEncryption, verifyCanary } from './shared/crypto/encryption.js'; + // --- Basic file init --- @@ -58,13 +60,23 @@ const chatLimiter = rateLimit({ const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8')); await initEncryption(); +await pool.query('SELECT 1'); +await verifyCanary(pool); // Create Express app const app = express(); const PORT = process.env.SERVER2_PORT || 5001; // at top of backend/server.js (do once per server codebase) -app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content +app.get('/healthz', async (req, res) => { + try { + await verifyCanary(pool); // << just pass the pool + return res.type('text').send('OK'); + } catch (e) { + console.error('[HEALTHZ]', e.message); + return res.status(500).type('text').send('FAIL'); + } +}); /************************************************** * DB connections diff --git a/backend/server3.js b/backend/server3.js index 91de710..f252c15 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -15,17 +15,20 @@ import mammoth from 'mammoth'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import pkg from 'pdfjs-dist'; -import db from './config/mysqlPool.js'; +import pool from './config/mysqlPool.js'; // adjust path if needed import OpenAI from 'openai'; import Fuse from 'fuse.js'; import Stripe from 'stripe'; import { createReminder } from './utils/smsService.js'; -import { initEncryption } from './shared/crypto/encryption.js'; +import { initEncryption, verifyCanary } from './shared/crypto/encryption.js'; import { hashForLookup } from './shared/crypto/encryption.js'; await initEncryption(); +await pool.query('SELECT 1'); +await verifyCanary(pool); + import './jobs/reminderCron.js'; import { cacheSummary } from "./utils/ctxCache.js"; @@ -69,7 +72,15 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { }); // at top of backend/server.js (do once per server codebase) -app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content +app.get('/healthz', async (req, res) => { + try { + await verifyCanary(pool); // << just pass the pool + return res.type('text').send('OK'); + } catch (e) { + console.error('[HEALTHZ]', e.message); + return res.status(500).type('text').send('FAIL'); + } +}); function internalFetch(req, urlPath, opts = {}) { return fetch(`${API_BASE}${urlPath}`, { @@ -256,9 +267,6 @@ const authenticatePremiumUser = (req, res, next) => { } }; -const pool = db; - - /** ------------------------------------------------------------------ * Returns the user’s stripe_customer_id (or null) given req.id. * Creates a new Stripe Customer & saves it if missing. diff --git a/backend/shared/crypto/encryption.js b/backend/shared/crypto/encryption.js index 66a7b80..0e0a04a 100644 --- a/backend/shared/crypto/encryption.js +++ b/backend/shared/crypto/encryption.js @@ -1,143 +1,178 @@ /* ──────────────────────────────────────────────────────────────── AES‑GCM field‑level encryption helper backed by Google Cloud KMS ---------------------------------------------------------------- */ - import { - randomBytes, - createCipheriv, - createDecipheriv, - createHmac + randomBytes, createCipheriv, createDecipheriv, + createHmac, createHash, } from 'crypto'; -import { KeyManagementServiceClient } from '@google-cloud/kms'; -import { open as fsOpen, readFile, mkdir, writeFile } from 'fs/promises'; +import { KeyManagementServiceClient } from '@google-cloud/kms'; +import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; +import { + open as fsOpen, readFile, mkdir, +} from 'fs/promises'; import { constants as FS } from 'fs'; - import path from 'path'; /* ── constants ─────────────────────────────────────────────── */ -const ALGO = 'aes-256-gcm'; // symmetric cipher -const IV_LEN = 12; // 96‑bit nonce for GCM -const TAG_LEN = 16; // 128‑bit auth‑tag -const MAGIC = 'gcm:'; // prefix to mark ciphertext +const ALGO = 'aes-256-gcm'; +const IV_LEN = 12; // 96‑bit nonce +const TAG_LEN = 16; // 128‑bit auth‑tag +const MAGIC = 'gcm:'; // ciphertext prefix -/* ── env config (injected via docker‑compose) ──────────────── */ -const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim(); // projects/*/locations/*/keyRings/*/cryptoKeys/* -const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // e.g. /run/secrets/dev/dek.enc +/* ── env config (docker‑compose) ────────────────────────────── */ +const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim(); +const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // /run/secrets//dek.enc +const ENV_NAME = (process.env.ENV_NAME || 'dev').trim(); // dev | staging | prod +const PROJECT = (process.env.PROJECT || 'aptivaai-dev').trim(); if (!EDEK_PATH) { console.error('FATAL: DEK_PATH env var is unset — check deploy script'); process.exit(1); } +const FPR_PATH = path.join(path.dirname(EDEK_PATH), 'dek.fpr'); +const SM_SECRET = `WRAPPED_DEK_${ENV_NAME.toUpperCase()}`; -let dek; // in‑memory data‑encryption‑key -let initPromise = null; +let dek; // plaintext DEK in memory +let initPromise; // so parallel callers wait only once + +/* ── helper: upload a backup copy to Secret Manager ─────────── */ +async function backupToSecretManager (ciphertext /* Buffer */) { + const sm = new SecretManagerServiceClient(); + + /* ensure the secret exists – idempotent */ + try { + await sm.getSecret({ name: `projects/${PROJECT}/secrets/${SM_SECRET}` }); + } catch { + await sm.createSecret({ + parent : `projects/${PROJECT}`, + secretId : SM_SECRET, + secret : { replication: { automatic: {} } }, + }); + } + + /* add a new version (binary) */ + await sm.addSecretVersion({ + parent : `projects/${PROJECT}/secrets/${SM_SECRET}`, + payload : { data: ciphertext }, + }); + console.log(`[ENCRYPT] ⬆️ copied dek.enc to Secret Manager (${SM_SECRET})`); +} /* ───────────────────────────────────────────────────────────── - One‑time DEK unwrap (or generate + wrap) + One‑time DEK unwrap / generate + fingerprint validation ──────────────────────────────────────────────────────────── */ export async function initEncryption () { - /* fast path ─ already done in this process */ - if (dek) return; - if (initPromise) return initPromise; + if (dek) return; + if (initPromise) return initPromise; initPromise = (async () => { const kms = new KeyManagementServiceClient(); const dir = path.dirname(EDEK_PATH); - /* 1 ── try the happy path: read existing wrapped key */ + /* 1 – optimistic: use existing wrapped key */ try { - const edek = await readFile(EDEK_PATH); - const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); - dek = resp.plaintext; - process.env.DEBUG_ENCRYPTION === 'true' - && console.log('[ENCRYPT] DEK unwrapped from KMS'); - return; + const wrapped = await readFile(EDEK_PATH); + dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext; } catch (err) { - if (err.code !== 'ENOENT') throw err; // genuine failure - } + if (err.code !== 'ENOENT') throw err; // genuine failure - /* 2 ── file does not exist ⇒ we’re in a race to create it */ - await mkdir(dir, { recursive: true }); - - try { - /* exclusive create – succeeds for exactly ONE container */ - const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600); + /* 2 – first container wins: create DEK & wrap */ + await mkdir(dir, { recursive: true }); try { - dek = randomBytes(32); // 256‑bit AES key - const [wrapResp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek }); - await fh.writeFile(wrapResp.ciphertext); - process.env.DEBUG_ENCRYPTION === 'true' - && console.log('[ENCRYPT] New DEK generated & wrapped'); - } finally { - await fh.close(); - } - return; // we are the winner - } catch (err) { - if (err.code !== 'EEXIST') throw err; // unexpected error - /* another container won – fall through to retry loop */ - } + const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600); + try { + dek = randomBytes(32); + const [resp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek }); + const wrapped = resp.ciphertext; // Buffer - /* 3 ── wait until the winner finishes writing, then read */ - const maxRetries = 30; // ~3 s total - for (let i = 0; i < maxRetries; i++) { - try { - const edek = await readFile(EDEK_PATH); - const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); - dek = resp.plaintext; - process.env.DEBUG_ENCRYPTION === 'true' - && console.log('[ENCRYPT] DEK unwrapped after retry'); - return; - } catch (err) { - if (err.code !== 'ENOENT') throw err; - await new Promise(r => setTimeout(r, 100)); // back‑off 100 ms + await fh.writeFile(wrapped); // primary copy + backupToSecretManager(wrapped) // async fire‑and‑forget + .catch(e => console.error('[ENCRYPT] backup failed:', e.message)); + } finally { + await fh.close(); + } + } catch (race) { + if (race.code !== 'EEXIST') throw race; // unexpected + /* another container already wrote the file */ + const wrapped = await readFile(EDEK_PATH); + dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext; } } - throw new Error(`Timed out waiting for ${EDEK_PATH} to appear`); + + /* 3 – fingerprint validation */ + const fp = createHash('sha256').update(dek).digest('hex').slice(0, 16); + try { + const onDisk = (await readFile(FPR_PATH, 'utf8')).trim(); + if (onDisk !== fp) throw new Error(`fingerprint mismatch: ${onDisk} ≠ ${fp}`); + } catch (err) { + if (err.code !== 'ENOENT') throw err; // real error + + /* write fingerprint (first‑writer wins) */ + await fsOpen(FPR_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o444) + .then(fh => fh.writeFile(fp + '\n').finally(() => fh.close())) + .catch(async (dup) => { + if (dup.code !== 'EEXIST') throw dup; + const onDisk = (await readFile(FPR_PATH, 'utf8')).trim(); + if (onDisk !== fp) throw new Error(`fingerprint mismatch: ${onDisk} ≠ ${fp}`); + }); + } + + process.env.DEBUG_ENCRYPTION === 'true' + && console.log('[ENCRYPT] DEK ready – fp', fp); })(); return initPromise; } -/* ───────────────────────────────────────────────────────────── - Symmetric encryption helpers - ──────────────────────────────────────────────────────────── */ +export const SENTINEL = 'aptiva-canary-v1'; + +/* ── optional DB sentinel helper ────────────────────────────── */ +export async function verifyCanary(pool) { + // nothing to verify yet → first ever launch before server1 seeded it + const [rows] = await pool.query( + 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1' + ); + if (!rows || rows.length === 0) return; + + const val = rows[0].value; + let plain; + try { + plain = decrypt(val); + } catch { + throw new Error('DEK cannot decrypt sentinel row'); + } + + if (plain !== SENTINEL) { + throw new Error('DEK mismatch: sentinel value differs'); + } +} + +/* ── crypto helpers (encrypt / decrypt / hash) ─────────────── */ export function encrypt (plain) { - if (plain == null) return null; // leave NULLs untouched - if (isEncrypted(plain)) return plain; // already encrypted - - const iv = randomBytes(IV_LEN); + if (plain == null || isEncrypted(plain)) return plain; + const iv = randomBytes(IV_LEN); const cipher = createCipheriv(ALGO, dek, iv); - const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - + const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); return MAGIC + Buffer.concat([iv, tag, ct]).toString('base64'); } export function decrypt (val) { - if (!isEncrypted(val)) return val; // fast‑exit for plain text - + if (!isEncrypted(val)) return val; const buf = Buffer.from(val.slice(MAGIC.length), 'base64'); const iv = buf.subarray(0, IV_LEN); const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); const ct = buf.subarray(IV_LEN + TAG_LEN); - const decipher = createDecipheriv(ALGO, dek, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); } -/* ───────────────────────────────────────────────────────────── - Deterministic HMAC (for indexed look‑ups, optional) - ──────────────────────────────────────────────────────────── */ export function hashForLookup (plain) { if (plain == null) return null; - const h = createHmac('sha256', dek).update(String(plain)).digest('hex'); - return h; // 64‑char hex + return createHmac('sha256', dek).update(String(plain)).digest('hex'); } -/* ───────────────────────────────────────────────────────────── - Utility: is this value an AES‑GCM ciphertext we created? - ──────────────────────────────────────────────────────────── */ export function isEncrypted (val) { return typeof val === 'string' && val.startsWith(MAGIC) diff --git a/docker-compose.yml b/docker-compose.yml index 16fa005..482fdbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,18 @@ services: server1: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG} + user: "1000:1000" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL expose: ["${SERVER1_PORT}"] environment: + ENV_NAME: ${ENV_NAME} + PROJECT: ${PROJECT} KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} JWT_SECRET: ${JWT_SECRET} @@ -31,7 +41,7 @@ services: volumes: - ./salary_info.db:/app/salary_info.db:ro - ./user_profile.db:/app/user_profile.db - - dek-vol:/run/secrets + - dek-vol:/run/secrets:rw healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"] interval: 30s @@ -42,8 +52,18 @@ services: server2: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG} + user: "1000:1000" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL expose: ["${SERVER2_PORT}"] environment: + ENV_NAME: ${ENV_NAME} + PROJECT: ${PROJECT} KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} ONET_USERNAME: ${ONET_USERNAME} @@ -55,6 +75,9 @@ services: DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} DB_NAME: ${DB_NAME} + DB_SSL_CERT: ${DB_SSL_CERT} + DB_SSL_KEY: ${DB_SSL_KEY} + DB_SSL_CA: ${DB_SSL_CA} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SALARY_DB_PATH: /app/salary_info.db FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} @@ -62,7 +85,7 @@ services: - ./public:/app/public:ro - ./salary_info.db:/app/salary_info.db:ro - ./user_profile.db:/app/user_profile.db - - dek-vol:/run/secrets + - dek-vol:/run/secrets:ro healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:${SERVER2_PORT}/healthz || exit 1"] interval: 30s @@ -73,8 +96,18 @@ services: server3: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG} + user: "1000:1000" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL expose: ["${SERVER3_PORT}"] environment: + ENV_NAME: ${ENV_NAME} + PROJECT: ${PROJECT} KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} JWT_SECRET: ${JWT_SECRET} @@ -103,7 +136,7 @@ services: volumes: - ./salary_info.db:/app/salary_info.db:ro - ./user_profile.db:/app/user_profile.db - - dek-vol:/run/secrets + - dek-vol:/run/secrets:ro healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"] interval: 30s @@ -113,7 +146,7 @@ services: # ───────────────────────────── nginx ─────────────────────────────── nginx: <<: *with-env - image: nginx:1.25-alpine + image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/nginx:${IMG_TAG} command: ["nginx", "-g", "daemon off;"] depends_on: [server1, server2, server3] networks: [default, aptiva-shared] @@ -131,6 +164,6 @@ networks: volumes: dek-vol: - name: + name: aptiva_dek_dev driver: local diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..cd96873 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 1. Derive the directory from DEK_PATH (e.g. /run/secrets/dev/dek.enc ⇒ /run/secrets/dev) +EDEK_DIR="$(dirname "${DEK_PATH}")" + +# 2. Make sure it exists and is owned by UID 1000 (the “node” user in the official image) +mkdir -p "${EDEK_DIR}" +chown -R 1000:1000 "${EDEK_DIR}" + +# 3. Chain‑exec as the unprivileged user +exec gosu node "$@" diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql index ae7a404..bf1de6a 100644 --- a/migrate_encrypted_columns.sql +++ b/migrate_encrypted_columns.sql @@ -36,6 +36,8 @@ ALTER TABLE career_profiles MODIFY career_goals MEDIUMTEXT, MODIFY desired_retirement_income_monthly VARCHAR(128); +encryption_canary(id INTEGER PRIMARY KEY, value TEXT) + /* ──────────────────────────────────────────────────────────────── college_profiles – migrate for encrypted VARCHAR columns ──────────────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index c8bddf0..b9b9b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@google-cloud/kms": "^5.1.0", + "@google-cloud/secret-manager": "^6.1.0", "@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-progress": "^1.1.2", @@ -2511,6 +2512,18 @@ "node": ">=18" } }, + "node_modules/@google-cloud/secret-manager": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.0.tgz", + "integrity": "sha512-IrXjT1z2yW98htydkopcxdhNVh4rpAzO3oTVzKymfdnzmzCKWVxhfwYWWvIor1bzgQN4sa21Wv0CMDokJyTt7A==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", diff --git a/package.json b/package.json index b885daa..0800d93 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@google-cloud/kms": "^5.1.0", + "@google-cloud/secret-manager": "^6.1.0", "@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-progress": "^1.1.2",