Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
134 lines
4.8 KiB
JavaScript
134 lines
4.8 KiB
JavaScript
/**
|
|
* ADR-156 Phase 5D — TOTP enrollment routes for bdd_criteria.
|
|
*
|
|
* POST /bdd/criteria/:id/enroll-start (generate TOTP secret + QR URI)
|
|
* POST /bdd/criteria/:id/enroll-confirm (verify first code, issue recovery)
|
|
*
|
|
* TOTP secrets are stored on the criterion row itself under `data.totp`. During
|
|
* the iter-5 transition window, enroll-confirm writes BOTH secret_enc (new,
|
|
* AES-256-GCM) and active_secret (legacy plaintext). See shared.js.
|
|
*/
|
|
|
|
import crypto from 'node:crypto';
|
|
import speakeasy from 'speakeasy';
|
|
import QRCode from 'qrcode';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, error, notFound, badRequest } from '../../../utils/response.js';
|
|
import {
|
|
criteriaTableId,
|
|
getCriterionRow,
|
|
patchLogicalRow,
|
|
encryptSecret,
|
|
} from './shared.js';
|
|
|
|
export default function registerEnrollmentRoutes(router) {
|
|
/* ------------------- POST /bdd/criteria/:id/enroll-start ------------------- */
|
|
router.post('/criteria/:id/enroll-start', async (req, res) => {
|
|
try {
|
|
const critId = parseInt(req.params.id, 10);
|
|
if (!Number.isFinite(critId)) return badRequest(res, 'Invalid criterion id');
|
|
const crit = await getCriterionRow(critId);
|
|
if (!crit) return notFound(res, 'bdd_criteria row');
|
|
|
|
const label = crit.data?.code || `criterion-${critId}`;
|
|
const secret = speakeasy.generateSecret({
|
|
length: 20,
|
|
name: `GodCRM:${label}`,
|
|
issuer: 'GodCRM-BDD',
|
|
});
|
|
|
|
const tid = await criteriaTableId();
|
|
const totpPatch = {
|
|
...(crit.data?.totp || {}),
|
|
pending_secret: secret.base32,
|
|
pending_at: new Date().toISOString(),
|
|
};
|
|
await patchLogicalRow(tid, critId, { totp: totpPatch });
|
|
|
|
let qrDataUrl = null;
|
|
try {
|
|
qrDataUrl = await QRCode.toDataURL(secret.otpauth_url, { margin: 1, width: 240 });
|
|
} catch (e) {
|
|
apiLogger.warn({ err: e.message }, 'QR data URL generation failed (non-fatal)');
|
|
}
|
|
|
|
return success(res, {
|
|
criterion_id: critId,
|
|
provisioning_uri: secret.otpauth_url,
|
|
qr_data_url: qrDataUrl,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err, critId: req.params.id }, 'POST /bdd/criteria/:id/enroll-start failed');
|
|
return error(res, 'BDD_ENROLL_START_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/* ------------------- POST /bdd/criteria/:id/enroll-confirm ------------------- */
|
|
router.post('/criteria/:id/enroll-confirm', async (req, res) => {
|
|
try {
|
|
const critId = parseInt(req.params.id, 10);
|
|
if (!Number.isFinite(critId)) return badRequest(res, 'Invalid criterion id');
|
|
const { totp_code } = req.body || {};
|
|
if (!totp_code || !/^\d{6}$/.test(String(totp_code))) {
|
|
return badRequest(res, 'totp_code (6 digits) is required');
|
|
}
|
|
const crit = await getCriterionRow(critId);
|
|
if (!crit) return notFound(res, 'bdd_criteria row');
|
|
|
|
const totp = crit.data?.totp || {};
|
|
if (!totp.pending_secret) {
|
|
return res.status(412).json({
|
|
success: false,
|
|
error: { code: 'NO_PENDING_ENROLLMENT', message: 'Call enroll-start first' },
|
|
});
|
|
}
|
|
|
|
const ok = speakeasy.totp.verify({
|
|
secret: totp.pending_secret,
|
|
encoding: 'base32',
|
|
token: String(totp_code),
|
|
window: 1,
|
|
});
|
|
if (!ok) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: { code: 'TOTP_INVALID', message: 'Wrong code' },
|
|
});
|
|
}
|
|
|
|
// Issue recovery code (shown once, stored as SHA-256 hash)
|
|
const recoveryCode = crypto.randomBytes(10).toString('hex').toUpperCase().slice(0, 16);
|
|
const recoveryHash = crypto.createHash('sha256').update(recoveryCode).digest('hex');
|
|
|
|
const tid = await criteriaTableId();
|
|
// ADR-156 iter-5 Task 1: write encrypted secret_enc AND plaintext
|
|
// active_secret during transition. Migration script will drop plaintext
|
|
// once all rows have secret_enc.
|
|
let secretEnc = null;
|
|
try {
|
|
secretEnc = encryptSecret(totp.pending_secret);
|
|
} catch (e) {
|
|
apiLogger.error({ err: e.message }, 'BDD TOTP: enroll-confirm encrypt failed (falling back to plaintext-only)');
|
|
}
|
|
await patchLogicalRow(tid, critId, {
|
|
totp: {
|
|
secret_enc: secretEnc,
|
|
active_secret: totp.pending_secret,
|
|
recovery_hash: recoveryHash,
|
|
failed_attempts: 0,
|
|
locked_until: null,
|
|
enrolled_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
|
|
return success(res, {
|
|
criterion_id: critId,
|
|
success: true,
|
|
recovery_code: recoveryCode,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err, critId: req.params.id }, 'POST /bdd/criteria/:id/enroll-confirm failed');
|
|
return error(res, 'BDD_ENROLL_CONFIRM_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
}
|