Audit evidence
What the audit log proves, against whom, and what has to be exported for the proof to survive outside the installation. The design decisions behind it are in ADR-006: Audit logging, ADR-023: Audit hash chain HMAC consideration and ADR-024: Audit hash payload covers forensic fields.
What the log proves — and against whom
| Claim | Holds against | Does not hold against |
|---|---|---|
| A recorded access happened, by that actor, at that time | Anyone without database write access; and, from epoch 1, anyone with database write access but without the master key. | Someone holding the master key. They can recompute the chain. |
| No recorded row was edited | A database writer: recomputing an HMAC needs the derived key. | Nothing further — this is the chain's core property. |
| No row was deleted | A database writer: gaps in the uid sequence are reported as
UID_GAP. | A writer who also rewrites uid values and holds the key. |
| The chain is the same chain as before | A writer limited to tx_nrvault_audit_log: the in-database tip
anchor in sys_registry still names a row that is gone. A
writer who reaches sys_registry too: only if an external
anchor exists. | A truncate-and-rebuild by a writer who deletes both anchors, with no external anchor published. The rebuilt chain is perfectly self-consistent. |
| The protection level was never lowered | A database writer relabelling hmac_key_epoch: caught per row,
at chain level by the epoch floor, and against the anchored epoch. | A chain that is genuinely still at epoch 0 and was never migrated. |
Tamper-evident, not tamper-proof. Every row of that table is detection. None of it is prevention.
Epochs: what each one binds
hmac_key_epoch is a per-row algorithm selector. Verification dispatches on
it row by row, so a chain may legitimately span epochs at a migration
boundary. The default for new installations is auditHmacEpoch = 3.
| Epoch | Algorithm | Bound into the hash |
|---|---|---|
| 0 | SHA-256, keyless | uid, secret identifier, action, actor uid, crdate,
previous_hash. Verifiable by anyone — and therefore forgeable
by anyone who can write the table. Legacy only. |
| 1 | HMAC-SHA256 | The same identity fields, now keyed. A database writer without the master key can no longer re-sign a row. |
| 2 | HMAC-SHA256 | Adds the forensic payload: success, error_message,
reason, ip_address, user_agent, hash_before,
hash_after, context. Before this, a row's outcome could
be flipped without breaking the chain. |
| 3 | HMAC-SHA256 | Adds hmac_key_epoch itself — closing the downgrade path, since
flipping the selector now invalidates the hash — plus the
human-readable attribution fields actor_type,
actor_username, actor_role and request_id. Before
this, blame could be reassigned on any row without breaking the
chain. |
The HMAC key is never stored. It is derived per request as
hash_, giving
the chain cryptographic separation from encryption key material.
Epoch 0 chains carry no keyed evidence. Migrate them with
vault:audit-migrate-hmac (see vault:audit-migrate-hmac).
Chain verification
Audit walks the chain and checks, per row,
that the stored previous_hash matches the predecessor's entry_hash and
that the stored entry_hash recomputes — both with
hash_. It reports missing uids as structured data alongside
the per-row errors.
Two epoch checks sit on top:
- Per-row. A decrease between consecutive rows is a downgrade finding. An increase is a legitimate migration boundary and is reported as a warning, not an error.
- Chain level. The chain's highest observed epoch must reach the
configured floor (
auditHmacEpoch). This catches the uniform case — a downgrade of every row to keyless epoch 0, which no per-row comparison would notice. The floor is only applied to a full-chain verification; a ranged verification may legitimately exclude the higher-epoch rows.
Anchoring
Two anchors record the chain tip, at different distances from an attacker. They are complementary: the in-database one works with no sink configured at all, the external one survives an attacker who owns the whole database.
In-database anchor
A MAC-signed assertion in the core table sys_registry, namespace
tx_nrvault_audit_anchor, under a key HKDF-derived from the master key —
which is not in the database. It advances inside the same transaction as the
audit row it anchors. Full design in
ADR-034: Audit chain tip anchor; the operator-facing summary is
Tip anchor (truncation detection).
vault:audit --verify reports its verdict on a Tip anchor: line, and
vault:doctor as audit.db_anchor:
ok- The anchored row exists and its hash still matches.
NOT ARMED- No anchor recorded yet. A warning by default — an installation that has never written an audit entry is indistinguishable from one whose anchor was deleted. auditAnchorRequired turns this into a critical finding, and because that setting lives in the extension configuration rather than in a table, a database-write attacker cannot silence the control by deleting the row. A backend administrator still can, from the Settings module.
VIOLATED- The anchored entry is gone or was replaced. This is the truncation case.
UNREADABLE- Malformed value or an invalid MAC — a tampered anchor, or a master key
changed without a re-seal. Critical regardless of
auditAnchorRequired.
After a legitimate wipe of the audit log, vault:audit --reset-anchor
clears the anchor and records the reset inside the chain. Without it a
deliberately truncated log reports a violation forever.
External anchor
An anchor records, outside the database, that sequence N once carried entry hash H under HMAC epoch E:
{"type":"anchor","source":"nr-vault","anchor":{"sequence":4821,"chainTip":"…","timestamp":1753900000,"hmacEpoch":3}}
Verification then checks three things the database alone cannot answer:
- Shrinkage. The chain is append-only, so its highest uid can never go
down.
currentSequence < anchoredSequenceis aTABLE_RESET. - Substitution. The row at the anchored sequence must still exist and
still hash to the anchored tip — also
TABLE_RESET. - Epoch regression. That row's epoch must not be below the anchored one
—
EPOCH_DOWNGRADE. The in-chain check only sees relabelling relative to other rows; the anchor sees it relative to the level actually in force.
Two properties of the reader are load-bearing. It takes the highest anchored sequence rather than the last line, so an attacker cannot weaken the baseline by appending a low anchor — they must rewrite or truncate the file. And a corrupt or truncated line is skipped rather than aborting the scan, so one bad line does not cost the verification its whole baseline.
Warning
An anchor is only as trustworthy as its storage. An anchor file on a host the attacker controls can be truncated. Ship anchors off-host — syslog to a collector, or a webhook to a SIEM — for the reset-detection property to actually hold.
Sinks
Sinks mirror three record kinds — entries, anchors and alerts — outside the database. Fan-out happens after the transaction commits and after the advisory audit lock is released, so a slow collector cannot serialise vault operations, and a delivery failure never fails the audited operation.
| Sink | Setting | Behaviour |
|---|---|---|
syslog | auditSinkSyslogEnabled,
auditSinkSyslogIdent | RFC 5424 structured data at facility LOG_LOCAL0 (fixed —
the conventional slot for application audit streams). Severity:
LOG_INFO for a successful entry, LOG_WARNING for a failed
one, LOG_NOTICE for an anchor, LOG_CRIT for tamper
evidence, LOG_ERR for a delivery failure. The cheapest useful
sink: any host with a log shipper gets the chain off the database. |
file | auditSinkFileEnabled,
auditSinkFilePath,
auditSinkAnchorPath | Append-only NDJSON, one JSON object per line, written under an
exclusive flock(). Files are created 0600 and directories
0700; a path under a public root makes the sink report itself
disabled rather than writing anyway. Entries go to the entry
path; anchors and alerts go to the anchor path, which is also
what
Anchor reads. |
webhook | auditSinkWebhookEnabled,
auditSinkWebhookUrl | One JSON POST per record with a type discriminator
(entry / anchor / alert) and a source marker, so a
single collector endpoint routes all three. Built on the hardened
HTTP client, so private and loopback targets need an entry in
$GLOBALS — see
Monitoring and alerting. |
Failure handling is uniform: each sink call is wrapped individually, so one
broken destination does not blind the others; failures are logged, counted per
sink, and raised as SINK_FAILURE. A sink whose own enablement probe throws
is treated as disabled rather than being allowed to take the audited operation
down. An enabled-but-unconfigured webhook reports itself disabled, because
claiming to be external evidence while delivering nothing is precisely the
false confidence the hardened check exists to catch.
Custom destinations are a tagged service: implement
Audit and tag it nr_vault.audit_sink.
Alert reason codes
Per-code definitions are in the command reference: Reason codes. What matters for evidence, rather than for reading CLI output:
- They are external contract. The strings appear in webhook payloads,
syslog structured data and the NDJSON stream, and
vault:audit-verifyprints and exits on them. Treat them as stable API, not as labels — a SIEM rule switching onTABLE_RESETmust keep working across releases. - Four are tamper evidence, three are not.
HASH_MISMATCH,UID_GAP,TABLE_RESETandEPOCH_DOWNGRADEindicate manipulation;SINK_FAILUREandNO_EXTERNAL_SINKare availability and configuration findings, andBREAK_GLASSis reserved for the emergency-access flow.Auditis the intended switch between "page someone now" and "log it" — see What to page on.Integrity Reason:: is Tamper Evidence () - One finding is raised per reason code, not per erroring row. A broken chain commonly fails every row after the break, and ten thousand identical alerts would bury the signal; the affected row count travels in the finding context instead.
- A delivery finding still has integrity consequences.
SINK_FAILUREis not tamper evidence, but while it holds you have no independent copy of the entries written during the outage.
Findings are dispatched as
Audit, so listeners fire
even when nobody reads the CLI output. A throwing listener costs neither the
remaining findings nor the report.
What an auditor should export
The chain is only evidence if the tip can be tied to something outside the installation. Export both:
- The entry sequence — the audit rows themselves, over the period under
review, including
uid,previous_hash,entry_hashandhmac_key_epoch. Without the hash columns the export is a log, not evidence. - The anchored tip — the anchor records covering the same period, taken from the external store rather than from the installation, plus the output of a verification run.
An export leaves the tamper-evident store behind: the downloaded copy has no
hash chain of its own, no retention policy and no further access control.
That is why audit.export is a separate permission from audit.view, and
why the export should itself be treated as sensitive material.
Evidence collection gives the exact commands and the full artefact list.