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
SERVER2_PORT=5001
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; \
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,9 +88,9 @@ 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"'
@ -97,6 +98,15 @@ steps:
- 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]

View File

@ -1,4 +1,7 @@
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"]

View File

@ -1,4 +1,7 @@
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"]

View File

@ -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/
RUN npm ci --unsafe-perm
COPY . .
RUN mkdir -p /run/secrets && chown -R app:app /run/secrets
USER app
CMD ["node", "backend/server3.js"]

View File

@ -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 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 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) ──────── */
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(

View File

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

View File

@ -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 users stripe_customer_id (or null) given req.id.
* Creates a new Stripe Customer & saves it if missing.

View File

@ -1,42 +1,67 @@
/*
AESGCM fieldlevel 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 { 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; // 96bit nonce for GCM
const ALGO = 'aes-256-gcm';
const IV_LEN = 12; // 96bit nonce
const TAG_LEN = 16; // 128bit authtag
const MAGIC = 'gcm:'; // prefix to mark ciphertext
const MAGIC = 'gcm:'; // ciphertext prefix
/* ── env config (injected via dockercompose) ──────────────── */
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 (dockercompose) ────────────────────────────── */
const KMS_KEY = (process.env.KMS_KEY_NAME || '').trim();
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) {
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; // inmemory dataencryptionkey
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})`);
}
/*
Onetime DEK unwrap (or generate + wrap)
Onetime DEK unwrap / generate + fingerprint validation
*/
export async function initEncryption () {
/* fast path ─ already done in this process */
if (dek) return;
if (initPromise) return initPromise;
@ -44,100 +69,110 @@ export async function initEncryption () {
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
}
/* 2 ── file does not exist ⇒ were in a race to create it */
/* 2  first container wins: create DEK & wrap */
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);
try {
dek = randomBytes(32); // 256bit 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');
dek = randomBytes(32);
const [resp] = await kms.encrypt({ name: KMS_KEY, plaintext: dek });
const wrapped = resp.ciphertext; // Buffer
await fh.writeFile(wrapped); // primary copy
backupToSecretManager(wrapped) // async fireandforget
.catch(e => console.error('[ENCRYPT] backup failed:', e.message));
} 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 */
} 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;
}
}
/* 3 ── wait until the winner finishes writing, then read */
const maxRetries = 30; // ~3 s total
for (let i = 0; i < maxRetries; i++) {
/* 3  fingerprint validation */
const fp = createHash('sha256').update(dek).digest('hex').slice(0, 16);
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;
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;
await new Promise(r => setTimeout(r, 100)); // backoff 100ms
if (err.code !== 'ENOENT') throw err; // real error
/* 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;
}
/*
Symmetric encryption helpers
*/
export function encrypt (plain) {
if (plain == null) return null; // leave NULLs untouched
if (isEncrypted(plain)) return plain; // already encrypted
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 || 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();
return MAGIC + Buffer.concat([iv, tag, ct]).toString('base64');
}
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 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 lookups, optional)
*/
export function hashForLookup (plain) {
if (plain == null) return null;
const h = createHmac('sha256', dek).update(String(plain)).digest('hex');
return h; // 64char hex
return createHmac('sha256', dek).update(String(plain)).digest('hex');
}
/*
Utility: is this value an AESGCM ciphertext we created?
*/
export function isEncrypted (val) {
return typeof val === 'string'
&& val.startsWith(MAGIC)

View File

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

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 desired_retirement_income_monthly VARCHAR(128);
encryption_canary(id INTEGER PRIMARY KEY, value TEXT)
/* ────────────────────────────────────────────────────────────────
college_profiles migrate for encrypted VARCHAR columns

13
package-lock.json generated
View File

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

View File

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