---
title: "Audit evidence"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:security-audit-evidence@1.0"
source: "Security/AuditEvidence.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Audit evidence {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-006-audit-logging@1.0), [ADR-023: Audit hash chain HMAC consideration](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-023-audit-hash-chain-hmac@1.0) and
[ADR-024: Audit hash payload covers forensic fields](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-024-audit-hash-forensic-fields@1.0).

## What the log proves — and against whom {#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 {#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_hkdf('sha256', $masterKey, 32, 'nr-vault-audit-hmac-v1')`, 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](https://docs.typo3.org/permalink/netresearch/nr-vault:command-audit-migrate-hmac@1.0)).

## Chain verification {#chain-verification}

`AuditLogService::verifyHashChain()` 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_equals()`. 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 {#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 {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-034-audit-chain-tip-anchor@1.0); the operator-facing summary is
[Tip anchor (truncation detection)](https://docs.typo3.org/permalink/netresearch/nr-vault:security-audit-chain-anchor@1.0).

`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](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-auditanchorrequired@1.0) 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 {#external-anchor}

An anchor records, outside the database, that sequence *N* once carried entry
hash *H* under HMAC epoch *E*:

**One anchor record, as written to the NDJSON stream**

```json
{"type":"anchor","source":"nr-vault","anchor":{"sequence":4821,"chainTip":"…","timestamp":1753900000,"hmacEpoch":3}}
```

Verification then checks three things the database alone cannot answer:

1.  **Shrinkage.** The chain is append-only, so its highest uid can never go
    down. `currentSequence < anchoredSequence` is a `TABLE_RESET`.
1.  **Substitution.** The row at the anchored sequence must still exist and
    still hash to the anchored tip — also `TABLE_RESET`.
1.  **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}

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 `AnchorFileReader` 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['TYPO3_CONF_VARS']['HTTP']['allowed_hosts']` — see [Monitoring and alerting](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-and-alerting@1.0). |

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
`AuditSinkInterface` and tag it `nr_vault.audit_sink`.

## Alert reason codes {#alert-reason-codes}

Per-code definitions are in the command reference:
[Reason codes](https://docs.typo3.org/permalink/netresearch/nr-vault:command-audit-verify-reason-codes@1.0). 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-verify`
    prints and exits on them. Treat them as stable API, not as labels — a SIEM
    rule switching on `TABLE_RESET` must keep working across releases.
-   **Four are tamper evidence, three are not.**
    `HASH_MISMATCH`, `UID_GAP`, `TABLE_RESET` and `EPOCH_DOWNGRADE`
    indicate manipulation; `SINK_FAILURE` and `NO_EXTERNAL_SINK` are
    availability and configuration findings, and `BREAK_GLASS` is reserved
    for the emergency-access flow. `AuditIntegrityReason::isTamperEvidence()`
    is the intended switch between "page someone now" and "log it" — see
    [What to page on](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-what-to-page@1.0).
-   **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_FAILURE`
    is not tamper evidence, but while it holds you have no independent copy of
    the entries written during the outage.

Findings are dispatched as `AuditIntegrityAlertEvent`, 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 {#what-an-auditor-should-export}

The chain is only evidence if the tip can be tied to something outside the
installation. Export **both**:

1.  **The entry sequence** — the audit rows themselves, over the period under
    review, including `uid`, `previous_hash`, `entry_hash` and
    `hmac_key_epoch`. Without the hash columns the export is a log, not
    evidence.
1.  **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](https://docs.typo3.org/permalink/netresearch/nr-vault:auditor-evidence-collection@1.0) gives the exact commands and the full
artefact list.
