verifyCanary, DEK safeguards
This commit is contained in:
parent
893cebc35f
commit
0b59ff6e07
5
.env
5
.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
|
||||
IMG_TAG=1d50efe-202508081233
|
||||
|
||||
ENV_NAME=dev
|
||||
PROJECT=aptivaai-dev
|
@ -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]
|
||||
|
@ -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"]
|
@ -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"]
|
@ -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"]
|
||||
|
||||
|
@ -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 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(
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -1,42 +1,67 @@
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
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 { 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 ALGO = 'aes-256-gcm';
|
||||
const IV_LEN = 12; // 96‑bit nonce
|
||||
const TAG_LEN = 16; // 128‑bit auth‑tag
|
||||
const MAGIC = 'gcm:'; // prefix to mark ciphertext
|
||||
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/<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; // 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;
|
||||
|
||||
@ -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 ⇒ we’re 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); // 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');
|
||||
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 fire‑and‑forget
|
||||
.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)); // back‑off 100 ms
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
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; // 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)
|
||||
|
@ -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
12
docker-entrypoint.sh
Normal 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. Chain‑exec as the unprivileged user
|
||||
exec gosu node "$@"
|
@ -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
13
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user