Audit Chain Primitives

Low-level audit-chain hash + verify primitives — b.audit composes on top of these so operators rarely call them directly. Every audit row carries prevHash + rowHash + nonce and the chain math is:

rowHash = SHA3-512( prevHash || canonicalize(row-fields-except-hash) || nonce )

Each row's prevHash equals the previous row's rowHash in monotonic-counter order. The first row uses ZERO_HASH as the anchor. verifyChain walks every row forward, recomputing each hash; any mismatch returns { ok: false, reason, breakAt, ... } and the caller (audit boot, b.cli verify-chain, restore-rollback, forensic snapshot) decides whether to refuse-to-boot or just log.

Checkpoint signing (SLH-DSA-SHAKE-256f over (atRow || atRowHash)) lives in b.auditSign. This module owns the chain hash math only; verification is O(n) over audit_log rows.

Operators reach for b.auditChain.verifyChain directly when restoring from backup (verify the restored DB before promoting it), when running a forensic offline check, or when extending the chain primitive into a custom append-only table. Day-to-day appends go through b.audit.record / b.audit.safeEmit.

b.auditChain.purgeAnchorPayload(anchor, opts?) #

stable0.18.58
{
  layout: string,  // default: "current". Also "no-checkpoint-digest", "no-digest" and "no-range", which reproduce the payload as it stood before each field joined it — for VERIFYING an anchor written by an earlier build, never for signing a new one
}

Canonical signed bytes for a purge anchor. Every field a reader acts on is covered: the boundary counter and row hash the chain walk adopts, the archive identifier an operator would follow to recover the deleted rows, the time it happened, and the scope, so an anchor cannot be lifted into a different scope's row.

Throws TypeError if a string field contains a newline — see b.auditChain.verifyPurgeAnchor for why that is refused rather than escaped.

var sig = b.auditSign.sign(b.auditChain.purgeAnchorPayload(anchor));

b.auditChain.purgeAnchorSignatureVerifies(anchor, signature, publicKeyPem) #

stable0.18.58

Does signature cover this purge anchor under publicKeyPem? True when it verifies against the current signed payload, or against the payload as it stood before a field joined it — each of those retries gated on the field it omits still being empty, which is the only state an anchor predating that field can be in.

Whether an anchor is GENUINE is a narrower question than whether it may be BELIEVED: this says only that the signature covers the fields, and answers nothing about which key was authorized, whether the named archive still exists, or whether the boundary is contiguous. Use b.auditChain.verifyPurgeAnchor to decide an anchor; use this when the key to check against is already settled — repairing an anchor signed under a rotated-out key, for instance.

var ok = b.auditChain.purgeAnchorSignatureVerifies(
  anchorRow, anchorRow.signature, pemOfTheKeyItNames);
// → true when the signature covers the anchor's own fields

b.auditChain.verifyPurgeAnchor(row, opts?) #

stable0.18.58
{
  resolvePublicKey: function, // (fingerprint) => PEM | null — offline key source
  resolveArchive:   function, // (archiveBundleId, { firstCounter, lastCounter, lastRowHash }) => truthy if the archive can still be produced; an anchor naming one that cannot is refused. Several slices can share the checkpoint that covers them, so the id alone does not name one bundle — match the range too
  allowUnsignedPurgeAnchor:  boolean, // default: false — accept an anchor written before signing existed
  allowUncheckedPurgeAnchor: boolean, // default: false — accept an anchor whose signature could not be checked at all; only for a caller with no signing posture, and the result still reports signatureVerified: false
  allowUnsigned:    boolean,  // default: false — accept a pre-signing anchor
}

Decide whether a purge-anchor row may be believed. Returns a status rather than a boolean, because the three ways an anchor can fail to verify call for three different reactions and collapsing them produces either a false alarm on a healthy volume or a silent pass on a tampered one:

- absent — no anchor row. Nothing was purged; verify from ZERO_HASH. - valid — the signature covers the fields, under a key on record. - unsigned — no signature. Refused unless the caller acknowledges it (see allowUnsigned), because an unsigned anchor is what the attack writes AND what an installation purged by an older version already has. - forged — a signature that does not verify, or names a key nothing on this volume knows. Someone edited the anchor. - unchecked — a signature that could not be checked at all, because this process holds no signing key. A verifier opening a database file directly is in exactly this position, and reporting its anchor as forged would raise an alarm about a volume nobody touched. - corrupt — the fields are not the right shape to act on.

Pass resolvePublicKey(fingerprint) when the caller holds the key material itself, which offline verification does; without it the anchor is checked against b.auditSign's own keys and public-key history.

var v = b.auditChain.verifyPurgeAnchor(row);
if (v.status !== "valid") throw new Error(v.reason);

b.auditChain.canonicalize(row, excludeKeys) #

0.6.67

RFC 8785 (JSON Canonicalization Scheme) serialization of an audit row's logical fields, used as the middle slice of the row-hash preimage. Sorted keys, Buffer values rendered as hex, every other value passed through the shared lib/canonical-json walker so the four canonicalize sites in the framework (chain, audit-tools, config-drift, pagination) emit byte-identical output.

var bytes = b.auditChain.canonicalize(
  { actor: "u-42", action: "auth.login.success", recordedAt: 1700000000000 },
  ["prevHash", "rowHash", "nonce"]
);
// → '{"action":"auth.login.success","actor":"u-42","recordedAt":1700000000000}'

b.auditChain.computeRowHash(prevHash, rowFields, nonce) #

0.4.0

Compute a row's rowHash given its predecessor's hash, the row's logical fields (already excluding prevHash / rowHash / nonce), and the row's nonce buffer. The hash is SHA3-512(prevHashBytes || canonicalize(rowFields) || nonce), returned as a 128-char lowercase hex string.

prevHash must be the 128-char hex form (use b.auditChain.ZERO_HASH for the chain anchor). nonce must be a non-empty Buffer; the framework writes 16 random bytes per row.

var rowHash = b.auditChain.computeRowHash(
  b.auditChain.ZERO_HASH,
  { action: "system.boot", recordedAt: 1700000000000, outcome: "success" },
  Buffer.from("0123456789abcdef0123456789abcdef", "hex")
);
// → "<128-char SHA3-512 hex>"

b.auditChain.getChainTip(queryOneAsync, tableName, opts?) #

0.4.0
{
  chainKey:  string,   // partition column for a multi-chain table
  keyValue:  any,      // the partition value to scope the tip to (bound)
}

Read the current chain tip (last row's rowHash + monotonicCounter) for a given audit table. Empty tables return { prevHash: ZERO_HASH, counter: 0 } so callers can treat first-row insert and append uniformly. Async so operator-supplied external-db drivers can use any await-able query function of the shape async (sql, params?) -> row | null.

Pass { chainKey, keyValue } to scope the tip to one partition of a multi-chain table (one chain per account / device / tenant) — the tip read filters WHERE = ? with the value bound, never interpolated.

async function queryOne(sql) {
  var rows = await myDriver.query(sql);
  return rows[0] || null;
}
var tip = await b.auditChain.getChainTip(queryOne, "audit_log");
// → { prevHash: "<128-char hex>", counter: 4217 }

b.auditChain.verifyChain(queryAllAsync, tableName, opts) #

0.4.0
{
  maxRows:   number,   // stop after N rows per (sub-)chain (default: walk every row)
  chainKey:  string,   // partition column — verify each sub-chain independently
  maxChains: number,   // max partitions to verify under chainKey (default 100000; fails closed)
  from:      number,   // single-chain only: verify rows with monotonicCounter >= from, anchored at the predecessor's rowHash (incremental verify after a known-good checkpoint)
  to:        number,   // single-chain only: verify rows with monotonicCounter <= to
}

Walk the entire chain forward, recomputing each row's hash and comparing against the stored prevHash / rowHash. Returns { ok: true, table, rowsVerified, lastHash } on a clean walk, or { ok: false, table, rowsVerified, breakAt, breakRowId, reason, expected, actual } on the first mismatch. Callers decide how to react — b.audit.verify refuses-to-boot, b.cli verify-chain exits non-zero, b.restoreRollback blocks promotion.

For audit_log: if a _blamejs_audit_purge_anchor row exists and its signature verifies, the walk starts at lastPurgedCounter+1 with prevHash = lastPurgedRowHash. The anchor is written by b.auditTools.purge after a successful archive and lets the chain math survive deletion of historical rows without the archive bundle as source of truth.

The signature proves this framework wrote the anchor. It does not prove the archive it names still exists or holds those rows, and nothing here can check that — only the consumer knows where bundles live. Pass resolveArchive(archiveBundleId, range) to supply that check: an anchor naming an archive that cannot be produced then STOPS the verify rather than licensing the gap. The range carries the counters and row hash the anchor covers, because several archived slices can share the checkpoint that covers them and so share the identifier — matching on the identifier alone would accept an older sibling as proof the anchored bundle is still there. Without a resolver the walk proceeds and the result reports purgeAnchor.archiveResolved: false, so a caller is never told an archive was verified when none was looked for.

Pass { chainKey } to verify a MULTI-chain table partitioned by a key column (one chain per account / device / tenant): each key's sub-chain is walked independently from ZERO_HASH, and the first break in any key returns { ok:false, chainKey, breakAt, ... }. Under chainKey, maxRows is per-sub-chain and maxChains bounds the partition fan-out, failing closed when exceeded. The audit_log purge-anchor logic is single-chain-only and is skipped when a chainKey is given.

async function queryAll(sql) { return await myDriver.query(sql); }
var result = await b.auditChain.verifyChain(queryAll, "audit_log", {});
// → { ok: true, table: "audit_log", rowsVerified: 4217, lastHash: "" }

Last updated 2026-10-12T14:21:56.879Z by seeder.