Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
130 lines
4.2 KiB
JavaScript
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);
|
|
});
|