Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
93 lines
3.2 KiB
JavaScript
93 lines
3.2 KiB
JavaScript
// backend/services/audit/verifyAuditChain.js
|
|
//
|
|
// ADR-0066-A §C — walk the audit_log hash chain and detect tampering.
|
|
//
|
|
// Recompute each post-cutover row's entry_hash and assert it matches the
|
|
// stored value AND that its prev_hash links to the predecessor's
|
|
// entry_hash. First mismatch is the tamper point.
|
|
//
|
|
// A row that predates the chain (id <= cutover_id) is skipped: re-hashing
|
|
// history it never carried proves nothing (ADR-0066-A §E).
|
|
|
|
import { dbAll, dbGet } from '../../database/connection.js';
|
|
import { verifyChainRows } from './auditChain.js';
|
|
|
|
const CANONICAL_SELECT = `
|
|
id, user_id, acting_as, action, entity_type, entity_id, details,
|
|
request_id, space_id, ip_addr, created_at, prev_hash, entry_hash
|
|
`;
|
|
|
|
/**
|
|
* Cutover id: chain covers strictly id > cutover_id. 0 if the meta row is
|
|
* absent (chain never initialized — treat everything as pre-chain).
|
|
* @returns {Promise<number>}
|
|
*/
|
|
export async function getCutoverId() {
|
|
const row = await dbGet(
|
|
`SELECT value FROM audit_chain_meta WHERE key = 'cutover_id'`
|
|
);
|
|
return row?.value != null ? Number(row.value) : 0;
|
|
}
|
|
|
|
function toHex(buf) {
|
|
return buf && Buffer.isBuffer(buf) ? buf.toString('hex') : null;
|
|
}
|
|
|
|
/**
|
|
* Verify the chain.
|
|
*
|
|
* @param {number} [fromId] - optional: only re-check rows with id >=
|
|
* fromId (seeded from the predecessor's entry_hash). Defaults to the
|
|
* full post-cutover chain. Never verifies below cutover.
|
|
* @returns {Promise<{ok:boolean, checked:number, break_at_id:number|null,
|
|
* cutover_id:number, head:{max_id:number, entry_hash:string}|null}>}
|
|
*/
|
|
export async function verifyAuditChain(fromId) {
|
|
const cutover = await getCutoverId();
|
|
// First row id to (re)check. Chain is id > cutover; from_id can start a
|
|
// suffix but never dips at/below cutover.
|
|
const start =
|
|
fromId != null && Number(fromId) > cutover ? Number(fromId) : cutover + 1;
|
|
|
|
// Seed prev with the last chained row strictly before `start`. Genesis
|
|
// (start = cutover+1, nothing chained below) → null.
|
|
const seedRow = await dbGet(
|
|
`SELECT entry_hash FROM audit_log
|
|
WHERE id < ? AND entry_hash IS NOT NULL
|
|
ORDER BY id DESC LIMIT 1`,
|
|
[start]
|
|
);
|
|
let prev = seedRow?.entry_hash ?? null; // Buffer | null
|
|
|
|
const rows = await dbAll(
|
|
`SELECT ${CANONICAL_SELECT} FROM audit_log
|
|
WHERE id >= ? ORDER BY id ASC`,
|
|
[start]
|
|
);
|
|
|
|
// Delegate the tamper logic to the pure, golden-vector-tested verifier.
|
|
// Full-from-genesis omits the anchor (first row must have prev_hash
|
|
// NULL); a partial verify seeds the anchor from the predecessor's
|
|
// entry_hash so the first checked row's linkage is still validated.
|
|
const isPartial = start > cutover + 1;
|
|
const result = isPartial
|
|
? verifyChainRows(rows, { anchorHash: prev })
|
|
: verifyChainRows(rows);
|
|
|
|
// Head is the true chain head (max id row), regardless of the window.
|
|
const last = rows.length ? rows[rows.length - 1] : null;
|
|
const head =
|
|
last && last.entry_hash
|
|
? { max_id: Number(last.id), entry_hash: toHex(last.entry_hash) }
|
|
: null;
|
|
|
|
return {
|
|
ok: result.ok,
|
|
checked: result.checked,
|
|
break_at_id: result.break_at_id != null ? Number(result.break_at_id) : null,
|
|
cutover_id: cutover,
|
|
head,
|
|
};
|
|
}
|
|
|
|
export default verifyAuditChain;
|