godcrm/scripts/encrypt-totp-secrets.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

130 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
/**
* ADR-156 iter-5 Task 1 — one-shot migration: encrypt plaintext TOTP secrets.
*
* Finds `bdd_criteria` rows where data.totp.active_secret exists AND
* data.totp.secret_enc is absent, encrypts the secret with AES-256-GCM using
* BDD_TOTP_KEY, writes back secret_enc, and strips plaintext active_secret.
*
* Idempotent: re-running against rows that already have secret_enc is a
* no-op. Safe to run multiple times.
*
* Usage (from project root):
* source .env && node scripts/encrypt-totp-secrets.js
*
* Env:
* BDD_TOTP_KEY required, 32-byte base64 AES key
* POSTGRES_* standard CRM connection (host, user, db, password, port)
*
* Exit codes:
* 0 — success (possibly 0 rows updated)
* 1 — config/connection error
* 2 — partial failure (see logs)
*/
import 'dotenv/config';
import crypto from 'node:crypto';
import pg from 'pg';
const BDD_SPACE_ID = 11;
function getKey() {
const b64 = process.env.BDD_TOTP_KEY;
if (!b64) {
console.error('FATAL: BDD_TOTP_KEY env var is required (32-byte base64)');
process.exit(1);
}
const key = Buffer.from(b64, 'base64');
if (key.length !== 32) {
console.error('FATAL: BDD_TOTP_KEY must decode to 32 bytes, got', key.length);
process.exit(1);
}
return key;
}
function encryptSecret(plaintext, key) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}:${ct.toString('base64')}:${tag.toString('base64')}`;
}
async function main() {
const key = getKey();
const client = new pg.Client({
host: process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
database: process.env.POSTGRES_DB || 'godcrm_prod',
user: process.env.POSTGRES_USER || 'godcrm',
password: process.env.POSTGRES_PASSWORD,
});
await client.connect();
try {
// Resolve bdd_criteria table id within space 11.
const tRes = await client.query(`
SELECT ut.id
FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = $1 AND ut.name = $2
ORDER BY ut.id ASC
LIMIT 1
`, [BDD_SPACE_ID, 'bdd_criteria']);
if (tRes.rows.length === 0) {
console.error('FATAL: bdd_criteria logical table not found in space', BDD_SPACE_ID);
process.exit(1);
}
const tableId = tRes.rows[0].id;
console.log(`[migration] bdd_criteria table_id = ${tableId}`);
// Find rows with plaintext active_secret but no secret_enc.
const rowsRes = await client.query(`
SELECT id, data
FROM table_rows
WHERE table_id = $1
AND data->'totp'->>'active_secret' IS NOT NULL
AND (data->'totp'->>'secret_enc' IS NULL OR data->'totp'->>'secret_enc' = '')
`, [tableId]);
console.log(`[migration] candidate rows: ${rowsRes.rows.length}`);
let ok = 0, fail = 0;
for (const row of rowsRes.rows) {
const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
const totp = data.totp || {};
const plain = totp.active_secret;
if (!plain) { console.log(` row ${row.id}: no active_secret (skip)`); continue; }
try {
const enc = encryptSecret(plain, key);
// Patch: add secret_enc, remove active_secret.
const newTotp = { ...totp, secret_enc: enc };
delete newTotp.active_secret;
// Full replacement of totp key to strip plaintext.
const patch = { totp: newTotp };
await client.query(`
UPDATE table_rows
SET data = COALESCE(data,'{}'::jsonb) || $1::jsonb,
updated_at = NOW()
WHERE id = $2
`, [JSON.stringify(patch), row.id]);
console.log(` row ${row.id}: migrated (enc=${enc.slice(0, 16)}...)`);
ok++;
} catch (e) {
console.error(` row ${row.id}: FAIL ${e.message}`);
fail++;
}
}
console.log(`[migration] done: ok=${ok} fail=${fail} total=${rowsRes.rows.length}`);
if (fail > 0) process.exit(2);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error('FATAL:', e.message, e.stack);
process.exit(1);
});