verifyCanary, DEK safeguards

This commit is contained in:
Josh 2025-08-08 12:51:57 +00:00
parent 893cebc35f
commit 0b59ff6e07
15 changed files with 325 additions and 145 deletions

5
.env
View File

@ -2,4 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
SERVER1_PORT=5000 SERVER1_PORT=5000
SERVER2_PORT=5001 SERVER2_PORT=5001
SERVER3_PORT=5002 SERVER3_PORT=5002
IMG_TAG=16e01ab-202508071457 IMG_TAG=1d50efe-202508081233
ENV_NAME=dev
PROJECT=aptivaai-dev

View File

@ -34,6 +34,7 @@ steps:
'set -euo pipefail; \ 'set -euo pipefail; \
PROJECT=aptivaai-dev; \ PROJECT=aptivaai-dev; \
ENV=staging; \ ENV=staging; \
ENV_NAME=staging; \
IMG_TAG=$(gcloud secrets versions access latest --secret=IMG_TAG --project=$PROJECT); \ IMG_TAG=$(gcloud secrets versions access latest --secret=IMG_TAG --project=$PROJECT); \
export IMG_TAG; \ export IMG_TAG; \
JWT_SECRET=$(gcloud secrets versions access latest --secret=JWT_SECRET_$ENV --project=$PROJECT); \ JWT_SECRET=$(gcloud secrets versions access latest --secret=JWT_SECRET_$ENV --project=$PROJECT); \
@ -87,9 +88,9 @@ steps:
export FROM_SECRETS_MANAGER=true; \ export FROM_SECRETS_MANAGER=true; \
cd /home/jcoakley/aptiva-staging-app; \ 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; \ 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; \ docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"' echo "✅ Staging stack refreshed with tag $IMG_TAG"'
@ -97,6 +98,15 @@ steps:
- STAGING_SSH_KEY - STAGING_SSH_KEY
- STAGING_KNOWN_HOSTS - 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: when:
event: event: [push]
- push

View File

@ -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 WORKDIR /app
# ---- native build deps ---- # ---- native build deps ----
@ -13,4 +16,8 @@ COPY public/ /app/public/
RUN npm ci --unsafe-perm RUN npm ci --unsafe-perm
COPY . . COPY . .
RUN mkdir -p /run/secrets && chown -R app:app /run/secrets
USER app
CMD ["node", "backend/server1.js"] CMD ["node", "backend/server1.js"]

View File

@ -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 WORKDIR /app
# ---- native build deps ---- # ---- native build deps ----
@ -13,4 +16,7 @@ COPY public/ /app/public/
RUN npm ci --unsafe-perm RUN npm ci --unsafe-perm
COPY . . COPY . .
RUN mkdir -p /run/secrets && chown -R app:app /run/secrets
USER app
CMD ["node", "backend/server2.js"] CMD ["node", "backend/server2.js"]

View File

@ -1,20 +1,23 @@
# ---- Dockerfile.server3 (fixed) ------------------------------ FROM node:20-bullseye AS base
FROM node:20-bullseye
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app WORKDIR /app
# 1. native build dependencies + curl # ---- native build deps ----
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential python3 pkg-config curl && \ build-essential python3 pkg-config && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# ---------------------------
# 2. node deps
COPY package*.json ./ 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 . . COPY . .
RUN mkdir -p /run/secrets && chown -R app:app /run/secrets
USER app
CMD ["node", "backend/server3.js"] CMD ["node", "backend/server3.js"]

View File

@ -1,61 +1,88 @@
import express from 'express'; import express from 'express';
import axios from 'axios';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import fs from 'fs';
import path from 'path'; import path from 'path';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken'; // For token-based authentication 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 pool from './config/mysqlPool.js'; // adjust path if needed
import sqlite3 from 'sqlite3'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, '..'); // Up one level const rootPath = path.resolve(__dirname, '..'); // Up one level
const env = process.env.NODE_ENV?.trim() || 'development'; const env = process.env.NODE_ENV?.trim() || 'development';
const stage = env === 'staging' ? 'development' : env;
const envPath = path.resolve(rootPath, `.env.${env}`); const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .env file dotenv.config({ path: envPath }); // Load .env file
// Grab secrets and config from ENV // Grab secrets and config from ENV
const JWT_SECRET = process.env.JWT_SECRET; const {
const DB_HOST = process.env.DB_HOST || '127.0.0.1'; JWT_SECRET,
const DB_PORT = process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306; CORS_ALLOWED_ORIGINS,
const DB_USER = process.env.DB_USER || 'sqluser'; SERVER1_PORT = 5000
const DB_PASSWORD = process.env.DB_PASSWORD || ''; } = process.env;
const DB_NAME = process.env.DB_NAME || 'user_profile_db';
if (!JWT_SECRET) { if (!JWT_SECRET) {
console.error('FATAL: JWT_SECRET missing aborting startup'); console.error('FATAL: JWT_SECRET missing aborting startup');
process.exit(1); // container exits, Docker marks it unhealthy process.exit(1); // container exits, Docker marks it unhealthy
} }
await initEncryption(); if (!CORS_ALLOWED_ORIGINS) {
// Test a quick query (optional) console.error('FATAL: CORS_ALLOWED_ORIGINS missing aborting startup');
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
process.exit(1); process.exit(1);
} }
/* ─── unwrap / verify DEK before we serve requests ────────────── */
await initEncryption();
try {
/* quick connectivity smoketest (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) ──────── */ /* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',') .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 // Enable CORS with dynamic origin checking
app.use( app.use(

View File

@ -12,6 +12,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { open } from 'sqlite'; import { open } from 'sqlite';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import pool from './config/mysqlPool.js'; // adjust path if needed
import fs from 'fs'; import fs from 'fs';
import readline from 'readline'; import readline from 'readline';
import chatFreeEndpoint from "./utils/chatFreeEndpoint.js"; import chatFreeEndpoint from "./utils/chatFreeEndpoint.js";
@ -19,7 +20,8 @@ import { OpenAI } from 'openai';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import authenticateUser from './utils/authenticateUser.js'; import authenticateUser from './utils/authenticateUser.js';
import { vectorSearch } from "./utils/vectorSearch.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 --- // --- Basic file init ---
@ -58,13 +60,23 @@ const chatLimiter = rateLimit({
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8')); const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8'));
await initEncryption(); await initEncryption();
await pool.query('SELECT 1');
await verifyCanary(pool);
// Create Express app // Create Express app
const app = express(); const app = express();
const PORT = process.env.SERVER2_PORT || 5001; const PORT = process.env.SERVER2_PORT || 5001;
// at top of backend/server.js (do once per server codebase) // 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 * DB connections

View File

@ -15,17 +15,20 @@ import mammoth from 'mammoth';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; 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 OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { createReminder } from './utils/smsService.js'; 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'; import { hashForLookup } from './shared/crypto/encryption.js';
await initEncryption(); await initEncryption();
await pool.query('SELECT 1');
await verifyCanary(pool);
import './jobs/reminderCron.js'; import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.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) // 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 = {}) { function internalFetch(req, urlPath, opts = {}) {
return fetch(`${API_BASE}${urlPath}`, { return fetch(`${API_BASE}${urlPath}`, {
@ -256,9 +267,6 @@ const authenticatePremiumUser = (req, res, next) => {
} }
}; };
const pool = db;
/** ------------------------------------------------------------------ /** ------------------------------------------------------------------
* Returns the users stripe_customer_id (or null) given req.id. * Returns the users stripe_customer_id (or null) given req.id.
* Creates a new Stripe Customer & saves it if missing. * Creates a new Stripe Customer & saves it if missing.

View File

@ -1,42 +1,67 @@
/* /*
AESGCM fieldlevel encryption helper backed by Google Cloud KMS AESGCM fieldlevel encryption helper backed by Google Cloud KMS
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
import { import {
randomBytes, randomBytes, createCipheriv, createDecipheriv,
createCipheriv, createHmac, createHash,
createDecipheriv,
createHmac
} from 'crypto'; } from 'crypto';
import { KeyManagementServiceClient } from '@google-cloud/kms'; import { KeyManagementServiceClient } from '@google-cloud/kms';
import { open as fsOpen, readFile, mkdir, writeFile } from 'fs/promises'; import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import {
open as fsOpen, readFile, mkdir,
} from 'fs/promises';
import { constants as FS } from 'fs'; import { constants as FS } from 'fs';
import path from 'path'; import path from 'path';
/* ── constants ─────────────────────────────────────────────── */ /* ── constants ─────────────────────────────────────────────── */
const ALGO = 'aes-256-gcm'; // symmetric cipher const ALGO = 'aes-256-gcm';
const IV_LEN = 12; // 96bit nonce for GCM const IV_LEN = 12; // 96bit nonce
const TAG_LEN = 16; // 128bit authtag const TAG_LEN = 16; // 128bit authtag
const MAGIC = 'gcm:'; // prefix to mark ciphertext const MAGIC = 'gcm:'; // ciphertext prefix
/* ── env config (injected via dockercompose) ──────────────── */ /* ── env config (dockercompose) ────────────────────────────── */
const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim(); // projects/*/locations/*/keyRings/*/cryptoKeys/* const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim();
const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // e.g. /run/secrets/dev/dek.enc const EDEK_PATH = (process.env.DEK_PATH || '').trim(); // /run/secrets/<env>/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) { if (!EDEK_PATH) {
console.error('FATAL: DEK_PATH env var is unset — check deploy script'); console.error('FATAL: DEK_PATH env var is unset — check deploy script');
process.exit(1); process.exit(1);
} }
const FPR_PATH = path.join(path.dirname(EDEK_PATH), 'dek.fpr');
const SM_SECRET = `WRAPPED_DEK_${ENV_NAME.toUpperCase()}`;
let dek; // inmemory dataencryptionkey let dek; // plaintext DEK in memory
let initPromise = null; 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})`);
}
/* /*
Onetime DEK unwrap (or generate + wrap) Onetime DEK unwrap / generate + fingerprint validation
*/ */
export async function initEncryption () { export async function initEncryption () {
/* fast path ─ already done in this process */
if (dek) return; if (dek) return;
if (initPromise) return initPromise; if (initPromise) return initPromise;
@ -44,100 +69,110 @@ export async function initEncryption () {
const kms = new KeyManagementServiceClient(); const kms = new KeyManagementServiceClient();
const dir = path.dirname(EDEK_PATH); const dir = path.dirname(EDEK_PATH);
/* 1 ── try the happy path: read existing wrapped key */ /* 1  optimistic: use existing wrapped key */
try { try {
const edek = await readFile(EDEK_PATH); const wrapped = await readFile(EDEK_PATH);
const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext;
dek = resp.plaintext;
process.env.DEBUG_ENCRYPTION === 'true'
&& console.log('[ENCRYPT] DEK unwrapped from KMS');
return;
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') throw err; // genuine failure if (err.code !== 'ENOENT') throw err; // genuine failure
}
/* 2 ── file does not exist ⇒ were in a race to create it */ /* 2  first container wins: create DEK & wrap */
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
try { try {
/* exclusive create succeeds for exactly ONE container */
const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600); const fh = await fsOpen(EDEK_PATH, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL, 0o600);
try { try {
dek = randomBytes(32); // 256bit AES key dek = randomBytes(32);
const [wrapResp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek }); const [resp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek });
await fh.writeFile(wrapResp.ciphertext); const wrapped = resp.ciphertext; // Buffer
process.env.DEBUG_ENCRYPTION === 'true'
&& console.log('[ENCRYPT] New DEK generated & wrapped'); await fh.writeFile(wrapped); // primary copy
backupToSecretManager(wrapped) // async fireandforget
.catch(e => console.error('[ENCRYPT] backup failed:', e.message));
} finally { } finally {
await fh.close(); await fh.close();
} }
return; // we are the winner } catch (race) {
} catch (err) { if (race.code !== 'EEXIST') throw race; // unexpected
if (err.code !== 'EEXIST') throw err; // unexpected error /* another container already wrote the file */
/* another container won fall through to retry loop */ const wrapped = await readFile(EDEK_PATH);
dek = (await kms.decrypt({ name: KMS_KEY, ciphertext: wrapped }))[0].plaintext;
}
} }
/* 3 ── wait until the winner finishes writing, then read */ /* 3  fingerprint validation */
const maxRetries = 30; // ~3 s total const fp = createHash('sha256').update(dek).digest('hex').slice(0, 16);
for (let i = 0; i < maxRetries; i++) {
try { try {
const edek = await readFile(EDEK_PATH); const onDisk = (await readFile(FPR_PATH, 'utf8')).trim();
const [resp] = await kms.decrypt({ name: KMS_KEY, ciphertext: edek }); if (onDisk !== fp) throw new Error(`fingerprint mismatch: ${onDisk}${fp}`);
dek = resp.plaintext;
process.env.DEBUG_ENCRYPTION === 'true'
&& console.log('[ENCRYPT] DEK unwrapped after retry');
return;
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') throw err; if (err.code !== 'ENOENT') throw err; // real error
await new Promise(r => setTimeout(r, 100)); // backoff 100ms
/* write fingerprint (firstwriter 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}`);
});
} }
}
throw new Error(`Timed out waiting for ${EDEK_PATH} to appear`); process.env.DEBUG_ENCRYPTION === 'true'
&& console.log('[ENCRYPT] DEK ready fp', fp);
})(); })();
return initPromise; return initPromise;
} }
/* export const SENTINEL = 'aptiva-canary-v1';
Symmetric encryption helpers
*/
export function encrypt (plain) {
if (plain == null) return null; // leave NULLs untouched
if (isEncrypted(plain)) return plain; // already encrypted
/* ── 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 || isEncrypted(plain)) return plain;
const iv = randomBytes(IV_LEN); const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, dek, iv); const cipher = createCipheriv(ALGO, dek, iv);
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
return MAGIC + Buffer.concat([iv, tag, ct]).toString('base64'); return MAGIC + Buffer.concat([iv, tag, ct]).toString('base64');
} }
export function decrypt (val) { export function decrypt (val) {
if (!isEncrypted(val)) return val; // fastexit for plain text if (!isEncrypted(val)) return val;
const buf = Buffer.from(val.slice(MAGIC.length), 'base64'); const buf = Buffer.from(val.slice(MAGIC.length), 'base64');
const iv = buf.subarray(0, IV_LEN); const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ct = buf.subarray(IV_LEN + TAG_LEN); const ct = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, dek, iv); const decipher = createDecipheriv(ALGO, dek, iv);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
} }
/*
Deterministic HMAC (for indexed lookups, optional)
*/
export function hashForLookup (plain) { export function hashForLookup (plain) {
if (plain == null) return null; if (plain == null) return null;
const h = createHmac('sha256', dek).update(String(plain)).digest('hex'); return createHmac('sha256', dek).update(String(plain)).digest('hex');
return h; // 64char hex
} }
/*
Utility: is this value an AESGCM ciphertext we created?
*/
export function isEncrypted (val) { export function isEncrypted (val) {
return typeof val === 'string' return typeof val === 'string'
&& val.startsWith(MAGIC) && val.startsWith(MAGIC)

View File

@ -12,8 +12,18 @@ services:
server1: server1:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG} 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}"] expose: ["${SERVER1_PORT}"]
environment: environment:
ENV_NAME: ${ENV_NAME}
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@ -31,7 +41,7 @@ services:
volumes: volumes:
- ./salary_info.db:/app/salary_info.db:ro - ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db - ./user_profile.db:/app/user_profile.db
- dek-vol:/run/secrets - dek-vol:/run/secrets:rw
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"]
interval: 30s interval: 30s
@ -42,8 +52,18 @@ services:
server2: server2:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG} 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}"] expose: ["${SERVER2_PORT}"]
environment: environment:
ENV_NAME: ${ENV_NAME}
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
ONET_USERNAME: ${ONET_USERNAME} ONET_USERNAME: ${ONET_USERNAME}
@ -55,6 +75,9 @@ services:
DB_USER: ${DB_USER} DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME} 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} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SALARY_DB_PATH: /app/salary_info.db SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
@ -62,7 +85,7 @@ services:
- ./public:/app/public:ro - ./public:/app/public:ro
- ./salary_info.db:/app/salary_info.db:ro - ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db - ./user_profile.db:/app/user_profile.db
- dek-vol:/run/secrets - dek-vol:/run/secrets:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER2_PORT}/healthz || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER2_PORT}/healthz || exit 1"]
interval: 30s interval: 30s
@ -73,8 +96,18 @@ services:
server3: server3:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG} 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}"] expose: ["${SERVER3_PORT}"]
environment: environment:
ENV_NAME: ${ENV_NAME}
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@ -103,7 +136,7 @@ services:
volumes: volumes:
- ./salary_info.db:/app/salary_info.db:ro - ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db - ./user_profile.db:/app/user_profile.db
- dek-vol:/run/secrets - dek-vol:/run/secrets:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
interval: 30s interval: 30s
@ -113,7 +146,7 @@ services:
# ───────────────────────────── nginx ─────────────────────────────── # ───────────────────────────── nginx ───────────────────────────────
nginx: nginx:
<<: *with-env <<: *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;"] command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3] depends_on: [server1, server2, server3]
networks: [default, aptiva-shared] networks: [default, aptiva-shared]
@ -131,6 +164,6 @@ networks:
volumes: volumes:
dek-vol: dek-vol:
name: name: aptiva_dek_dev
driver: local driver: local

12
docker-entrypoint.sh Normal file
View File

@ -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. Chainexec as the unprivileged user
exec gosu node "$@"

View File

@ -36,6 +36,8 @@ ALTER TABLE career_profiles
MODIFY career_goals MEDIUMTEXT, MODIFY career_goals MEDIUMTEXT,
MODIFY desired_retirement_income_monthly VARCHAR(128); MODIFY desired_retirement_income_monthly VARCHAR(128);
encryption_canary(id INTEGER PRIMARY KEY, value TEXT)
/* ──────────────────────────────────────────────────────────────── /* ────────────────────────────────────────────────────────────────
college_profiles migrate for encrypted VARCHAR columns college_profiles migrate for encrypted VARCHAR columns

13
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@google-cloud/kms": "^5.1.0", "@google-cloud/kms": "^5.1.0",
"@google-cloud/secret-manager": "^6.1.0",
"@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
@ -2511,6 +2512,18 @@
"node": ">=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": { "node_modules/@grpc/grpc-js": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",

View File

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@google-cloud/kms": "^5.1.0", "@google-cloud/kms": "^5.1.0",
"@google-cloud/secret-manager": "^6.1.0",
"@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",