godcrm/backend/services/audit/auditChain.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

190 lines
6.8 KiB
JavaScript

// backend/services/audit/auditChain.js
//
// ADR-0066-A — Tamper-evident hash chain over `public.audit_log`.
//
// This module is the FROZEN core of the chain: the canonical
// serialization and the digest function. It is pure (no DB, no I/O) so
// it can be exercised by a golden-vector unit test (Risk R5).
//
// ⚠️ DO NOT change `canonicalizeAuditRow`, `computeEntryHash`, the field
// list, the domain-separation prefix, or the normalization rules
// without bumping CHAIN_VERSION. Any change silently invalidates
// every previously-written `entry_hash`. The golden-vector test
// pins the current format; if you must change it, bump the version,
// regenerate the vector, and document a re-hash/cutover plan.
import crypto from 'crypto';
/**
* Hash-format version. Folded into the digest as a domain-separation
* prefix (see computeEntryHash) so a format change produces different
* hashes on purpose rather than a silent break (Risk R5).
*/
export const CHAIN_VERSION = 1;
/**
* Advisory-lock key that serializes chain appends so concurrent audit
* writes cannot fork the chain (ADR-0066-A §B). Transaction-scoped
* (`pg_advisory_xact_lock`), released on COMMIT/ROLLBACK. Arbitrary but
* fixed 63-bit constant; unique to the audit chain.
*/
export const AUDIT_CHAIN_LOCK = 660066n;
// Semantic columns hashed into each entry, in the FROZEN order from
// ADR-0066-A §A. The two hash columns are intentionally excluded from
// their own input. `id` and `created_at` are DB-assigned and therefore
// only known after INSERT ... RETURNING (see writeAudit).
const CANONICAL_FIELDS = [
'id',
'user_id',
'acting_as',
'action',
'entity_type',
'entity_id',
'details',
'request_id',
'space_id',
'ip_addr',
'created_at',
];
/**
* Normalize `created_at` (a pg Date, an ISO string, or epoch) to epoch
* milliseconds. This drops sub-millisecond precision on BOTH the write
* and verify paths identically, so it cannot cause a Date-vs-string /
* micro-vs-milli mismatch between hashing and re-hashing.
*/
function toEpochMs(value) {
if (value == null) return null;
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
return Number.isNaN(t) ? null : t;
}
function normNum(value) {
return value == null ? null : Number(value);
}
function normStr(value) {
return value == null ? null : String(value);
}
/**
* Deterministic, byte-stable serialization of an audit row's semantic
* columns. Fixed field order, primitive-only values, no incidental
* whitespace. `details` is kept as its stored JSON text (or null) — we
* hash exactly what is persisted, not a re-serialized object.
*
* @param {object} row - keys: id, user_id, acting_as, action,
* entity_type, entity_id, details (string|null), request_id,
* space_id, ip_addr, created_at (Date|string|null)
* @returns {string} canonical JSON string
*/
export function canonicalizeAuditRow(row) {
const out = {};
for (const f of CANONICAL_FIELDS) {
switch (f) {
case 'id':
case 'user_id':
case 'acting_as':
case 'space_id':
out[f] = normNum(row[f]);
break;
case 'created_at':
out[f] = toEpochMs(row[f]);
break;
default:
// action, entity_type, entity_id, details, request_id, ip_addr
out[f] = normStr(row[f]);
}
}
return JSON.stringify(out);
}
/**
* entry_hash = sha256( versionPrefix ‖ prev_hash ‖ canonical(row) ).
*
* @param {Buffer|null} prevHash - predecessor row's entry_hash (Buffer),
* or null for the genesis row.
* @param {object} row - the row to hash (see canonicalizeAuditRow).
* @returns {Buffer} 32-byte sha256 digest.
*/
export function computeEntryHash(prevHash, row) {
const h = crypto.createHash('sha256');
// Domain separation + version marker (Risk R5): bumping CHAIN_VERSION
// changes every digest on purpose.
h.update(`adr0066a:v${CHAIN_VERSION}\n`, 'utf8');
h.update(prevHash && prevHash.length ? prevHash : Buffer.alloc(0));
h.update(canonicalizeAuditRow(row), 'utf8');
return h.digest();
}
/**
* Buffer/null-safe equality for two hash values.
* @param {Buffer|null} a
* @param {Buffer|null} b
* @returns {boolean}
*/
export function hashEquals(a, b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return Buffer.isBuffer(a) && Buffer.isBuffer(b) && Buffer.compare(a, b) === 0;
}
/**
* Pure verifier over an already-fetched, ascending-by-id slice of the
* CHAINED subsequence (rows with a non-null entry_hash, id > cutover_id).
* Factored out of DB access so the tamper logic is unit-testable with
* golden vectors (the DB wrapper is verifyAuditChain.js).
*
* IMPORTANT: callers MUST pass only chained rows (`entry_hash IS NOT
* NULL`). Legacy producers still INSERT into audit_log directly and
* carry a NULL hash until ADR-0066 P5; feeding those here would compare
* a recomputed digest against NULL and read as a false tamper. The chain
* is defined over the hashed subsequence, not every raw id.
*
* Two independent checks per row (ADR-0066-A §C):
* 1. Self-consistency — recompute entry_hash from the row's OWN stored
* prev_hash + canonical(row); must equal the stored entry_hash.
* Catches any edit to the row's semantic content.
* 2. Linkage — the stored prev_hash must equal the previous walked
* row's entry_hash (or the anchor, or NULL at genesis). Catches
* deletion, reordering, and re-linking.
*
* @param {Array<object>} rows - ascending by id; each carries the
* canonical fields plus prev_hash (Buffer|null) and entry_hash (Buffer).
* @param {object} [opts]
* @param {Buffer} [opts.anchorHash] - entry_hash of the chained row
* immediately preceding this window (partial verify). Omit for a
* full-from-genesis verify, where the first row must have prev_hash NULL.
* @returns {{ok: boolean, checked: number, break_at_id: (number|null)}}
*/
export function verifyChainRows(rows, opts = {}) {
const hasAnchor = Object.prototype.hasOwnProperty.call(opts, 'anchorHash');
let prevWalkedHash = hasAnchor ? opts.anchorHash : null;
let checked = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Check 1 — self-consistency of this row's digest.
const expected = computeEntryHash(row.prev_hash, row);
if (!hashEquals(expected, row.entry_hash)) {
return { ok: false, checked, break_at_id: row.id };
}
// Check 2 — linkage to the predecessor.
if (i === 0 && !hasAnchor) {
// Full verify: first chained row is genesis → prev_hash must be NULL.
if (row.prev_hash != null) {
return { ok: false, checked, break_at_id: row.id };
}
} else if (!hashEquals(row.prev_hash, prevWalkedHash)) {
return { ok: false, checked, break_at_id: row.id };
}
prevWalkedHash = row.entry_hash;
checked++;
}
return { ok: true, checked, break_at_id: null };
}