godcrm/backend/scripts/rotate-secrets-master-key.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

114 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* ADR-0040 / Constitution v4.0 §10 (invariant 5) — rotate SECRETS_MASTER_KEY.
*
* The KZ fork inherited .128's master key byte-for-byte (shared key =
* invariant-5 violation). Re-key = decrypt every `_secrets` row under the OLD
* key, re-encrypt under a NEW key, atomically UPDATE in one transaction.
*
* A naive key-swap would NOT work: seed-secrets-from-env.js is idempotent-skip
* (never overwrites existing rows), so rows would stay encrypted under the OLD
* key and silently fail to decrypt at runtime — while verify-secrets-migration.js
* (existence-only) still prints [OK]. This script closes that gap and self-checks
* decryptability, which verify cannot.
*
* Modes:
* (default) DRY-RUN: decrypt(OLD)+roundtrip(NEW) every row, no writes.
* --commit Same validation, then atomic UPDATE of all rows under NEW key.
* --probe Decrypt every row using process.env.SECRETS_MASTER_KEY ONLY,
* report pass/fail. Run AFTER the .env flip to prove the new key
* decrypts the whole vault. (Ignores NEW_SECRETS_MASTER_KEY.)
*
* Keys:
* OLD = process.env.SECRETS_MASTER_KEY (from .env)
* NEW = process.env.NEW_SECRETS_MASTER_KEY (passed inline for the run)
* Accepts 64-hex or 32-byte-base64, matching SecretsVault.decodeMasterKey.
*
* Exit: 0 ok · 1 failure (nothing committed) · 2 bad/missing key args.
*/
import dotenv from 'dotenv';
dotenv.config();
import crypto from 'crypto';
import { getAdapter, withTransactionAsync } from '../database/connection.js';
const KEY_VERSION = 1, IV_BYTES = 12, KEY_BYTES = 32, TABLE = '_secrets';
function decodeKey(raw) {
if (!raw) return null;
const t = String(raw).trim();
if (/^[0-9a-fA-F]{64}$/.test(t)) return Buffer.from(t, 'hex');
if (/^[A-Za-z0-9+/]+=*$/.test(t)) {
try { const b = Buffer.from(t, 'base64'); if (b.length === KEY_BYTES) return b; } catch { /* */ }
}
return null;
}
function dec(key, blob) {
const o = typeof blob === 'string' ? JSON.parse(blob) : blob;
if (!o || o.v !== KEY_VERSION) throw new Error(`unsupported payload v=${o?.v}`);
const iv = Buffer.from(o.iv, 'base64'), tag = Buffer.from(o.tag, 'base64'), ct = Buffer.from(o.ct, 'base64');
const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
}
function encrypt(key, plain) {
const iv = crypto.randomBytes(IV_BYTES);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return { v: KEY_VERSION, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
}
async function main() {
const COMMIT = process.argv.includes('--commit');
const PROBE = process.argv.includes('--probe');
const adapter = await getAdapter();
const rows = (await adapter.query(`SELECT key, encrypted_payload FROM ${TABLE} ORDER BY key`)).rows;
if (PROBE) {
const key = decodeKey(process.env.SECRETS_MASTER_KEY);
if (!key) { console.error('SECRETS_MASTER_KEY missing/invalid'); process.exit(2); }
let ok = 0, bad = 0;
for (const r of rows) {
try { const p = dec(key, r.encrypted_payload); console.log(`[decrypt-ok] ${r.key} (len=${p.length})`); ok++; }
catch (e) { console.error(`[DECRYPT-FAIL] ${r.key}: ${e.message}`); bad++; }
}
console.log(`\nprobe: ${ok}/${rows.length} decrypt OK, ${bad} failed`);
process.exit(bad > 0 ? 1 : 0);
}
const oldKey = decodeKey(process.env.SECRETS_MASTER_KEY);
const newKey = decodeKey(process.env.NEW_SECRETS_MASTER_KEY);
if (!oldKey) { console.error('OLD SECRETS_MASTER_KEY missing/invalid'); process.exit(2); }
if (!newKey) { console.error('NEW_SECRETS_MASTER_KEY missing/invalid (pass inline)'); process.exit(2); }
if (oldKey.equals(newKey)) { console.error('NEW == OLD — refusing (that is the whole point).'); process.exit(2); }
console.log(`rows=${rows.length} mode=${COMMIT ? 'COMMIT' : 'DRY-RUN'}`);
const reenc = [];
for (const r of rows) {
let plain;
try { plain = dec(oldKey, r.encrypted_payload); }
catch (e) { console.error(`[ABORT] decrypt(OLD) failed for ${r.key}: ${e.message} — no writes made`); process.exit(1); }
const blob = encrypt(newKey, plain);
if (dec(newKey, blob) !== plain) { console.error(`[ABORT] roundtrip(NEW) mismatch for ${r.key}`); process.exit(1); }
reenc.push({ key: r.key, blob });
console.log(`[ok] ${r.key} — decrypt(OLD)+reencrypt(NEW) verified`);
}
if (!COMMIT) {
console.log('\nDRY-RUN ok: every row decrypts under OLD and roundtrips under NEW. Re-run with --commit.');
process.exit(0);
}
await withTransactionAsync(async (trx) => {
for (const x of reenc) {
const res = await trx.query(
`UPDATE ${TABLE} SET encrypted_payload = $1, updated_at = NOW() WHERE key = $2`,
[JSON.stringify(x.blob), x.key]
);
if (res.rowCount !== 1) throw new Error(`UPDATE touched ${res.rowCount} rows for ${x.key}`);
}
});
console.log(`\n[COMMITTED] ${reenc.length} rows re-encrypted under NEW key (atomic).`);
console.log('Next: flip SECRETS_MASTER_KEY in .env → NEW, then pm2 restart godcrm, then run with --probe.');
process.exit(0);
}
main().catch((e) => { console.error('rotate-secrets-master-key: fatal:', e); process.exit(1); });