dev1/backend/shared/db/withEncryption.js

159 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ────────────────────────────────────────────────────────────────
Dropin replacement for mysql2 that transparently encrypts /
decrypts selected columns, using helpers above.
---------------------------------------------------------------- */
import mysql from 'mysql2/promise';
import {
encrypt,
decrypt,
isEncrypted,
initEncryption
} from '../crypto/encryption.js';
const WRITE_RE = /^\s*(insert|update|replace)\s/i;
/* ── map of columns that must be protected ─────────────────── */
const TABLE_MAP = {
user_profile : [
'username', 'firstname', 'lastname', 'email', 'phone_e164',
'zipcode', 'stripe_customer_id',
'interest_inventory_answers', 'riasec_scores',
'career_priorities', 'career_list'
],
financial_profiles : [
'current_salary','additional_income','monthly_expenses',
'monthly_debt_payments','retirement_savings','emergency_fund',
'retirement_contribution','emergency_contribution',
'extra_cash_emergency_pct','extra_cash_retirement_pct'
],
career_profiles : [
'planned_monthly_expenses','planned_monthly_debt_payments',
'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution',
'planned_surplus_emergency_pct','planned_surplus_retirement_pct',
'planned_additional_income','career_goals','desired_retirement_income_monthly',
'career_name','start_date','retirement_start_date','scenario_title'
],
college_profiles : [
'selected_school','selected_program','annual_financial_aid',
'existing_college_debt','tuition','tuition_paid','loan_deferral_until_graduation',
'loan_term','interest_rate','extra_payment','expected_salary'
],
milestones : ['title','description','date','progress'],
tasks : ['title','description','due_date'],
reminders : ['phone_e164','message_body'],
milestone_impacts : ['amount','impact_type', 'direction'],
ai_risk_analysis : ['reasoning','risk_level'],
ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'],
context_cache : ['ctx_text']
};
/* ── initialise KMS unwrap once ─────────────────────────────── */
async function ensureCryptoReady () { await initEncryption(); }
/* ── mysql connection pool (uses env injected by docker) ────── */
export const pool = mysql.createPool({
host : process.env.DB_HOST,
port : process.env.DB_PORT,
user : process.env.DB_USER,
password : process.env.DB_PASSWORD,
database : process.env.DB_NAME,
waitForConnections : true,
connectionLimit : 5,
ssl : {
ca : process.env.DB_SSL_CA,
key : process.env.DB_SSL_KEY,
cert : process.env.DB_SSL_CERT
}
});
/* ── tiny helpers to parse SQL (works for *your* queries) ───── */
function extractTables (sql) {
return [...new Set(
[...sql.matchAll(/\b(?:from|join|into|update)\s+`?([a-z0-9_]+)`?/ig)]
.map(m => m[1].toLowerCase())
)];
}
function extractColumn(sql, paramIndex) {
const s = sql.replace(/\s+/g, ' ').toLowerCase();
// INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...)
const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i);
if (ins) {
const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim());
return cols[paramIndex] || null; // only VALUES params exist here
}
// UPDATE t SET c1 = ?, c2 = ? WHERE ...
const upd = s.match(/update\s+[`\w]+\s+set\s+(.+?)(?:\s+where|\s*$)/i);
if (upd) {
const setPart = upd[1];
// Build a list of columns in the same order as the '?'s in SET only
const pairs = setPart.split(',').map(p => p.trim());
const colsForQs = [];
for (const p of pairs) {
const col = p.split('=')[0].replace(/`/g, '').trim();
const qCnt = (p.match(/\?/g) || []).length;
for (let i = 0; i < qCnt; i++) colsForQs.push(col);
}
const setQCount = colsForQs.length;
if (paramIndex < setQCount) return colsForQs[paramIndex];
// params after SET (WHERE/LIMIT/etc.) → no mapping
return null;
}
// SELECT/DELETE/etc. → we dont map WHERE/LIMIT params
return null;
}
function decryptRow (row, tables) {
for (const t of tables) {
const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase()));
for (const k of Object.keys(row)) {
if (!encSet.has(k.toLowerCase())) continue;
const val = row[k];
if (val != null && isEncrypted(val)) row[k] = decrypt(val);
}
}
}
/* ─────────────────────────────────────────────────────────────
Replacement for pool.execute / pool.query
──────────────────────────────────────────────────────────── */
export async function exec(sql, params = []) {
await ensureCryptoReady();
const isWrite = WRITE_RE.test(sql);
const tables = extractTables(sql);
const encryptNeeded = (col) =>
tables.some(t => (TABLE_MAP[t] || []).some(c =>
c.toLowerCase() === String(col || '').toLowerCase()
));
const encParams = isWrite
? params.map((v, i) => {
const col = extractColumn(sql, i);
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
? encrypt(v)
: v;
})
: params; // SELECT path: never touch params
const [rows, fields] = await pool.execute(sql, encParams);
if (Array.isArray(rows)) for (const row of rows) decryptRow(row, tables);
return [rows, fields];
}
/* ── mysqllike façade so existing code keeps working ───────── */
export const query = exec;
export const getConnection = () => pool.getConnection();
export const end = () => pool.end();
export const poolRaw = pool; // escape hatch