ADR-034: Audit chain tip anchor
Table of contents
Status
Accepted
Date
2026-07-30
Context
Audit walks the rows that are present in
tx_ and checks that each one links to its predecessor.
Every one of its checks — UID-gap detection, per-row previous_ and
entry_ 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;
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.
Note
Two anchors exist and they are different mechanisms. This ADR covers the
in-database anchor in sys_, driven by `vault: and reported as the
``audit.ChainTipAnchorService`, driven by
vault: and vault: and reported as
audit.. The in-database anchor still works when no sink is
configured; the external one survives an attacker who owns the whole
database. Neither replaces the other.
Decision
Store one MAC-signed assertion about the chain's tip outside
tx_:
Audit row
uid = Astill exists and itsentry_is stillhash 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_. 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:
rowidwithoutAUTOINCREMENTismax, effective immediately.(rowid) + 1 - MariaDB/MySQL (InnoDB):
AUTO_is re-derived asINCREMENT maxon server start.(uid) + 1
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_ than the anchored H.
sys_registry — without the Registry API
The anchor lives in the core table sys_
(entry_,
entry_), reached exclusively through our own
Doctrine QueryBuilder in Classes/.
It gets its own namespace, never tx_. The anchor value is
deliberately raw rather than PHP-serialized, while core's Registry::
unserializes every row of a namespace it loads. Other extension state — the
break-glass session, the per-sink delivery state — lives in tx_
via the core Registry API, so sharing a namespace with the raw anchor would
make every one of those reads throw a Deserializer.
Two alternatives were considered and rejected, both of which created a new table
tx_ in ext_:
- Fail closed while the table is missing. Between "extension files updated"
and "
database:run", the anchor write throws fromupdateschema log.() Vaultlogs without a try/catch and read logging is on by default, so every vault read — including an unauthenticated frontend render throughService:: retrieve () Typo— returns a 500 for the whole upgrade window. That is a site-down availability regression traded for a tamper-evidence gap.Script Vault Listener - Fail open while the table is missing. That makes the control
attacker-selectable:
DROP TABLE tx_returns the installation to the old silent-truncation behaviour on every subsequent append, not just on the verify.nrvault_ audit_ anchor
sys_ removes the dilemma instead of resolving it. It ships in core's
own ext_, 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_ is deliberately
untouched by this change.
TYPO3\ itself is off limits. Its get routes through
load, which unserializes every row of a namespace
with no allowed_ — an object-injection sink fed by bytes a
database-write attacker controls. Our read path is one SELECT, one anchored
preg_ and one hash_; a regex cannot emit a PHP object.
Architecture (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_ is plain ASCII, bound as Connection:: on write (as
core's own Registry:: does, which is what makes the mediumblob /
bytea column work on PostgreSQL):
nrvault-audit-tip.v1|<uid>|<entry_hash>|<tstamp>|<mac>
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- — separate
from the chain key's nr-, so an anchor MAC and a row hash
are not interchangeable. The key is zeroed with sodium_ in a
finally.
Anchor exists ⇒ same connection
Every mutator is given the caller's Connection and refuses to write unless
get is identical to it — the same
precondition style Vault already uses when
tx_ and tx_ 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:) rewrite
entry_/previous_ WHERE uid = … and preserve every uid, so
neither condition can false-alarm on them — and both fail on a truncated chain.
Both assertions need an authenticated stored anchor to check against, and
master-key rotation is the one path that cannot authenticate it under the key
it signs with: it passes the NEW key, while the stored anchor still carries
the current key's MAC. Parsing under the new key therefore always fails, which
originally skipped the guard on exactly that path (#283). reseal falls
back to the provider's current key to authenticate the stored anchor before
re-signing, so the rotation path takes the same guard as the two migration
paths.
Without it, the re-seal paths launder a truncation. The gate in front of them,
verify, lets a chain through when no row carries
hmac_, on the grounds that a keyless epoch-0 chain has no
tamper evidence in its rows. That premise is attacker-selectable:
UPDATE tx_ 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_ row (or blank its
entry_), 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.
readreads the ROW (Raw () fetch), not the value, soAssociative () UPDATE sys_yields "row present, unreadable" — the corrupted-anchor branch, which writes nothing and reportsregistry SET entry_ value = NULL Unreadable(an error). Reading it as "no anchor" would also make the upsert attempt an INSERT against theentry_unique key and break every subsequent audit write.identifier -
With
auditon, 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.Anchor Required advanceand() resealboth 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
advanceruns, the audit insert has just written the one row the chain has, so a log emptied with() DELETE FROM tx_— nonrvault_ audit_ log WHERE— is indistinguishable from a fresh installation. The uid gap does not substitute for it either, because the walk starts atpreviousand 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.Uid = - 1
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 Audit, reachable only from
vault:, 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:) 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 reportInas a warning, never an error.Flight V1 === V2means 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_ 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: clears it — and writes an audit entry
(Audit) 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
audit 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:
- the
sys_row read (unique keyregistry entry_);identifier - the anchored row's
entry_by primary key, when an anchor is present;hash - the INSERT or UPDATE of the anchor row, skipped whenever a guard declines.
An absent anchor costs the registry read alone: with audit
on, advance returns immediately rather than probing the chain, because
it refuses to arm implicitly either way.
A full-chain verification adds the sys_ read, one primary-key
lookup, and — only when the tip hash mismatches — a second sys_ 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_row and then truncate. Withregistry auditoff — the shipped default — the result isAnchor Required Unanchoredplus a warning until the next audit write, which arms a fresh anchor on the truncated chain and returns the installation toOk. 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.auditis what closes it, and it closes it in both places: verification reports the missing anchor as an error, andAnchor Required = 1 advance/() resealrefuse 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() Okby ordinary traffic. Onlyvault:— 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.audit -- reset- anchor - 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_row yields a permanentregistry Unreadableerror, which blocks master-key rotation and both re-seal paths until an operator runsvault:. This is no new capability: the same attacker can already produce a false alarm by editing any audit row.audit -- reset- anchor - R5 — a dormant installation stays unanchored
- The anchor arms on the next append (any read at the default
audit, any write) or when the HMAC wizard / migrate command runs. Until then the installation keeps the old behaviour, with the warning visible.Reads = 1 - 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_as disposable. Those land inregistry Unanchoredand 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
- ADR-023: Audit hash chain HMAC consideration — the HMAC-keyed chain this anchors
- ADR-024: Audit hash payload covers forensic fields — the forensic payload
- ADR-028: PHPat architectural lock for HTTP client construction — the architectural-lock precedent
Classes/Audit/ Audit Chain Anchor Store. php Tests/Functional/ Audit/ Audit Chain Anchor Test. php