ADR-034: Audit chain tip anchor 

Status 

Accepted

Date 

2026-07-30

Context 

AuditLogService::verifyHashChain() walks the rows that are present in tx_nrvault_audit_log and checks that each one links to its predecessor. Every one of its checks — UID-gap detection, per-row previous_hash and entry_hash verification, the epoch-downgrade checks — is a statement about rows that still exist.

That leaves one whole class of tampering invisible. A single statement:

DELETE FROM tx_nrvault_audit_log WHERE uid > 4711;
Copied!

removes the tail of the log. The remaining rows still form a perfect chain: no gap, every link intact, every hash correct. A TRUNCATE is the same case taken to its limit — an empty table is a valid chain of length zero. Every tamper-evidence control in the extension reported VALID for both.

The chain proves that what is there was not altered. Nothing in the database proved how much should be there, because any counter kept inside the same table is deleted along with the rows.

Decision 

Store one MAC-signed assertion about the chain's tip outside tx_nrvault_audit_log:

Audit row uid = A still exists and its entry_hash is still H.

Not a count and not a max(uid) 

An aggregate has to be compared against something the verifier observes, and a concurrent append changes it between the observation and the anchor read. An earlier attempt stored a row count and reported "truncation detected" on a perfectly intact chain whenever an append landed mid-verification.

An existence-and-equality claim about one already-committed row has no such failure mode. An append only adds a row with a higher uid; it never deletes row A and never rewrites row A's entry_hash. No interleaving — before, during or after the walk — can change the answer. Correctness does not depend on timing at all.

The hash is load-bearing 

Anchoring the uid alone is not enough. After DELETE ... WHERE uid > N the auto-increment counter is reused on two of the three supported platforms:

  • SQLite: rowid without AUTOINCREMENT is max(rowid) + 1, effective immediately.
  • MariaDB/MySQL (InnoDB): AUTO_INCREMENT is re-derived as max(uid) + 1 on server start.

Ordinary appends then refill uids N+1 … A, so the anchored uid exists again and the surviving chain re-links correctly from row N's genuine hash. Only the hash comparison catches it: the refilled row A carries a different entry_hash than the anchored H.

sys_registry — without the Registry API 

The anchor lives in the core table sys_registry (entry_namespace = 'tx_nrvault_audit_anchor', entry_key = 'auditChainTip'), reached exclusively through our own Doctrine QueryBuilder in Classes/Audit/AuditChainAnchorStore.php.

It gets its own namespace, never tx_nrvault. The anchor value is deliberately raw rather than PHP-serialized, while core's Registry::get() unserializes every row of a namespace it loads. Other extension state — the break-glass session, the per-sink delivery state — lives in tx_nrvault via the core Registry API, so sharing a namespace with the raw anchor would make every one of those reads throw a DeserializerException.

Two alternatives were considered and rejected, both of which created a new table tx_nrvault_audit_anchor in ext_tables.sql:

  • Fail closed while the table is missing. Between "extension files updated" and "database:updateschema run", the anchor write throws from log(). VaultService::retrieve() logs without a try/catch and read logging is on by default, so every vault read — including an unauthenticated frontend render through TypoScriptVaultListener — returns a 500 for the whole upgrade window. That is a site-down availability regression traded for a tamper-evidence gap.
  • Fail open while the table is missing. That makes the control attacker-selectable: DROP TABLE tx_nrvault_audit_anchor returns the installation to the old silent-truncation behaviour on every subsequent append, not just on the verify.

sys_registry removes the dilemma instead of resolving it. It ships in core's own ext_tables.sql, so it exists in every installation before nr_vault is installed: no schema change, therefore no upgrade window, therefore no fail-closed/fail-open trade at all. ext_tables.sql is deliberately untouched by this change.

TYPO3\CMS\Core\Registry itself is off limits. Its get() routes through loadEntriesByNamespace(), which unserialize()s every row of a namespace with no allowed_classes — an object-injection sink fed by bytes a database-write attacker controls. Our read path is one SELECT, one anchored preg_match() and one hash_equals(); a regex cannot emit a PHP object. ArchitectureTest::testAuditAnchorNeverUsesCoreRegistry() (PHPat, same shape as ADR-028: PHPat architectural lock for HTTP client construction) turns any reintroduction into a build failure.

Value format and MAC 

entry_value is plain ASCII, bound as Connection::PARAM_LOB on write (as core's own Registry::set() does, which is what makes the mediumblob / bytea column work on PostgreSQL):

nrvault-audit-tip.v1|<uid>|<entry_hash>|<tstamp>|<mac>
Copied!

Parsed with one anchored pattern and nothing else accepted. The MAC is HMAC-SHA256 over a canonical JSON payload, under a key derived from the master key with a distinct HKDF info string nr-vault-audit-anchor-v1 — separate from the chain key's nr-vault-audit-hmac-v1, so an anchor MAC and a row hash are not interchangeable. The key is zeroed with sodium_memzero() in a finally.

Anchor exists ⇒ same connection 

Every mutator is given the caller's Connection and refuses to write unless getConnectionForTable('sys_registry') is identical to it — the same precondition style VaultRotateMasterKeyCommand already uses when tx_nrvault_secret and tx_nrvault_audit_log are split. Same connection means the upsert runs inside the caller's already-open transaction and commits or rolls back with the audit row; the store therefore never resolves its own connection for a write, and can never end up ahead of a rolled-back audit write. A split connection is reported as a warning and nothing is written.

Monotone advance 

advance() moves the anchor only forward in uid, and only while the anchor's own assertion still holds. A violated or unparseable anchor is left in place — it must not be repaired or overtaken by ordinary traffic, or an attacker could truncate the log, wait for it to regrow past the anchored uid, and get a clean verdict back. Rewriting an existing row's hash is reseal()'s job.

Re-sealing is constrained too 

reseal() cannot use the guard above, because rewriting the anchored row's hash is exactly what it does. It therefore asserts the part a re-seal does not change: the anchored row must still exist, and the new tip's uid must not be below the stored one. All three legitimate re-seal paths (master-key rotation, the HMAC upgrade wizard, vault:audit-migrate-hmac) rewrite entry_hash/previous_hash WHERE uid = … and preserve every uid, so neither condition can false-alarm on them — and both fail on a truncated chain.

Without it, the re-seal paths launder a truncation. The gate in front of them, verifyChainForReseal(), lets a chain through when no row carries hmac_key_epoch >= 1, on the grounds that a keyless epoch-0 chain has no tamper evidence in its rows. That premise is attacker-selectable: UPDATE tx_nrvault_audit_log SET hmac_key_epoch = 0 is one statement and needs no master key, and it also makes the upgrade wizard advertise itself as pending in the Install Tool, so a routine administrator click finishes the job. The anchor's validity does not depend on any row's epoch, so the gate still runs the anchor check on that path; the row walk is what it skips.

Arming is not the same operation as advancing 

The guards above all concern an anchor that is there. An anchor that is gone takes none of them, and creating one from nothing is the strongest thing this code can do: it signs a claim that the current tip is genuine. An attacker with database write access and no master key gets that for free unless arming is constrained — delete the sys_registry row (or blank its entry_value), truncate the log, let one ordinary audited read append a row, and the next advance() mints a fresh, correctly signed anchor on the shortened chain. The installation then reports Ok permanently: silent, as before this ADR, plus a MAC-attested statement that the truncated tip is authentic. That is worse than not having the control.

Two rules constrain it:

  • A present row with no usable value is not an absent anchor. readRaw() reads the ROW (fetchAssociative()), not the value, so UPDATE sys_registry SET entry_value = NULL yields "row present, unreadable" — the corrupted-anchor branch, which writes nothing and reports Unreadable (an error). Reading it as "no anchor" would also make the upsert attempt an INSERT against the entry_identifier unique key and break every subsequent audit write.
  • With auditAnchorRequired on, an absent anchor is refused unconditionally. The flag is the operator's assertion that this installation is already anchored, and it is the only available disambiguation between "never anchored" and "anchor deleted", which database state alone cannot provide. It lives in a settings file, out of a database-write attacker's reach. advance() and reseal() both consult it; neither creates an anchor that is not there once it is set.

    The refusal deliberately does not probe the audit table first. An earlier revision refused only when a row still sat below the tip being anchored, so that a genuinely fresh installation could still bootstrap under the flag. That probe cannot work: at the moment advance() runs, the audit insert has just written the one row the chain has, so a log emptied with DELETE FROM tx_nrvault_audit_log — no WHERE — is indistinguishable from a fresh installation. The uid gap does not substitute for it either, because the walk starts at previousUid = -1 and a chain that now begins at uid 7 has no leading gap. Any emptiness probe therefore rewards the attacker for deleting more rows, which is cheaper than deleting some. The state the probe must observe is exactly the state the attacker deletes, so there is nothing to observe.

Nothing legitimate needs implicit arming while the flag is set: the flag means "this installation is already anchored", and its documented enable point is "after the first audit write following the upgrade". Bootstrap therefore stays possible in exactly two shapes, so the flag can never wedge an installation: with the flag off (the shipped default, and the documented upgrade path — let the next audit write arm the anchor, then enable the flag); and explicitly through AuditChainAnchorStoreInterface::arm(), reachable only from vault:audit --reset-anchor, which records the fact in the chain itself.

Double-read stability 

The three legitimate re-seal paths (master-key rotation, the HMAC upgrade wizard, vault:audit-migrate-hmac) rewrite row hashes and re-record the anchor in one transaction. The verifier therefore reads anchor V1 → row → anchor V2:

  • V1 !== V2 (byte comparison of the raw stored value) means a re-seal committed mid-check ⇒ retry once, then report InFlight as a warning, never an error.
  • V1 === V2 means no re-seal commit landed in that window, so a hash mismatch is genuine.

This needs no transaction, no lock and no isolation level, so it behaves identically on SQLite, MariaDB/MySQL and PostgreSQL. A "consistent snapshot" read transaction would not have worked: PostgreSQL's default READ COMMITTED gives each statement a new snapshot.

Retention purges must be head-first 

There is no DELETE against tx_nrvault_audit_log anywhere in Classes/ today. If a retention purge is ever added it must delete oldest-first, which never touches the tip. A purge that removed the newest rows would be indistinguishable from the attack this control exists to detect.

Escape hatch 

After a legitimate full wipe the anchor can never advance again, so vault:audit --reset-anchor clears it — and writes an audit entry (AuditAction::AuditAnchorReset) recording the reset in the same transaction, so the reset cannot be performed invisibly. It then arms the anchor on that very entry through arm(), rather than relying on the audit write to do it: with auditAnchorRequired on, advance() deliberately refuses to, so the escape hatch would otherwise stop working on exactly the installations that hardened themselves.

Consequences 

Positive 

  • Tail truncation, deletion of the last row, and a full wipe of the audit log are now detected — against an attacker with database write access but without the master key.
  • No schema change, so no upgrade window and no new failure mode during deployment.
  • The verification check is read-only.

Cost 

Not free, and measurably more than "one lookup". Per audit write, inside the lock and transaction that log() already opens, advance() runs up to three statements against indexed columns:

  1. the sys_registry row read (unique key entry_identifier);
  2. the anchored row's entry_hash by primary key, when an anchor is present;
  3. the INSERT or UPDATE of the anchor row, skipped whenever a guard declines.

An absent anchor costs the registry read alone: with auditAnchorRequired on, advance() returns immediately rather than probing the chain, because it refuses to arm implicitly either way.

A full-chain verification adds the sys_registry read, one primary-key lookup, and — only when the tip hash mismatches — a second sys_registry read for the double-read stability rule.

Negative / accepted limits 

These are the stated limit of the control, not oversights.

R1 — with the flag off, anchor deletion degrades to the old behaviour

An attacker with database write access can delete the sys_registry row and then truncate. With auditAnchorRequired off — the shipped default — the result is Unanchored plus a warning until the next audit write, which arms a fresh anchor on the truncated chain and returns the installation to Ok. That is the pre-anchor behaviour, and it is the price of a default that must not make every not-yet-anchored installation report an invalid chain: "never anchored" and "anchor deleted" are indistinguishable from database state alone.

auditAnchorRequired = 1 is what closes it, and it closes it in both places: verification reports the missing anchor as an error, and advance()/reseal() refuse to create an absent anchor at all — on a populated chain, on a truncated one, and on an emptied one alike — so the state cannot be laundered back to Ok by ordinary traffic. Only vault:audit --reset-anchor — an operator action written into the chain — arms it again, and that includes the first arming of an installation that enables the flag before its anchor exists. Enable the flag after the first audit write following the upgrade. A pure warning-level flag would have been useless here: it would have gone quiet again seconds later, as soon as one audited read landed.

R2 — anchor replay
A captured older anchor value can be restored and its MAC still verifies. This narrows the undetectable window down to the captured tip but does not close it. Defeating replay requires state outside the database and is deliberately out of scope.
R3 — consistent whole-database rollback
Anchor and chain move together, so restoring a consistent snapshot of the whole database reports Ok. Detecting that needs off-box evidence (log shipping, WORM export) — deployment guidance, not a code fix.
R4 — anchor corruption is a denial of service on rotation
Corrupting the sys_registry row yields a permanent Unreadable error, which blocks master-key rotation and both re-seal paths until an operator runs vault:audit --reset-anchor. This is no new capability: the same attacker can already produce a false alarm by editing any audit row.
R5 — a dormant installation stays unanchored
The anchor arms on the next append (any read at the default auditReads = 1, any write) or when the HMAC wizard / migrate command runs. Until then the installation keeps the old behaviour, with the warning visible.
R6 — audit HMAC epoch 0 gets nothing
By design. At epoch 0 the chain is keyless and carries no tamper evidence to protect; arming the anchor there would add a brand-new master-key dependency to the audit write path. The verifier reports Disabled.
R7 — deployments that wipe sys_registry
Some dump/restore pipelines treat sys_registry as disposable. Those land in Unanchored and re-arm on the next append — same shape as R5.

What an attacker with database write access and no master key still cannot do: forge or advance the anchor, or make a truncation or a wipe report Ok while leaving the anchor in place. The one-statement invisible truncation becomes a two-target attack whose second target cannot be forged, only destroyed — and destroying it changes the reported verdict.

References