Verifying integrity¶
Verification proves: at a chosen moment in time: that no row in a chain has been altered, removed or inserted since it was written.
The service¶
Drupal\audit_trail\AuditTrailVerifier walks a chain two ways:
verifyChain() from genesis and verifyChainIncremental() from
the last signed checkpoint. Both take a VerificationDepth
saying how much signature verification to perform, which defaults
to Strict. Which chains exist is Chain\ChainRepository's
question, not the verifier's.
$verifier = \Drupal::service('audit_trail.verifier');
// Discover. The chain repository enumerates from the tables, so a
// chain whose config entity is gone still appears.
$chain_ids = \Drupal::service('audit_trail.chain_repository')->listChains();
// returns ['notarial', 'webdav', 'finance', …]
// Per chain, full walk from genesis.
$result = $verifier->verifyChain('notarial');
// All chains: incremental by default, pass full: TRUE for cold.
$all = $verifier->verifyAll();
// returns ['notarial' => […], 'webdav' => […], …]
Every verdict carries the same keys, always present, so a consumer
needs no ?? at a read site:
| key | meaning |
|---|---|
ok |
the verdict, structurally_ok and authenticated_ok combined |
structurally_ok |
the public hash chain reconstructs |
authenticated_ok |
the HMAC layer verifies; NULL means it was not checked |
count |
rows examined |
message_parts |
the verdict as the sentences it is built from, each a TranslatableMarkup. What a surface showing the verdict to an operator should render, because it is translated in the language of whoever is reading it rather than the language of whoever produced it. A part that is a plain string is the forensic detail of a broken row, which is still in the module's own words |
message |
the human-readable verdict: those same sentences cast and joined with a space. Kept for drush audit_trail:verify and for any consumer written against it, and derived from message_parts so the two cannot drift |
broken_ranges |
every contiguous failed range, not only the first |
acknowledgments |
ranges an operator acknowledgment covered |
archives |
purged ranges bridged by a segment's anchors |
unauthenticated_attestations |
segments whose purge attestation was accepted without being checked: at Tolerant because the secret that signed it would not resolve, at None because no signature was read at all. Ordinarily empty at Strict, which fills it only where a segment's own signing secret will not resolve, and a chain still holding that segment's lifecycle events is reporting a break of its own by then |
spine |
the segment spine's verdict: ok, height, head, attested_height, error. ok is NULL when no walk replayed it, the same way authenticated_ok is. The one key that does not vary with depth, because nothing in it reads a secret |
verifyChainIncremental() adds checkpoint_minted,
checkpoint_forged and checkpoint_stale, which only it computes,
and walked_from_id: its walk read the rows above that id and none
at or below it, 0 meaning it started at genesis.
verifyChain walks the chain in id order. For each row it
runs five checks, in order. The first three are the layers the
verdict's structurally_ok / authenticated_ok split is about:
- Chain link. Confirms
row.previous_hashmatches the previous row'shashcolumn. Catches inserted, removed, or reordered rows. - Public hash. Recomputes
SHA-256(CanonicalJson::encode(payload))and compares withrow.hashin constant time. The canonical payload is built from the row'schannel,chain,severity,action,resource,context_permanent,context_transient_hash,created,secret_id, andprevious_hashcolumns: the rawcontext_transientis not in the canonical (only its write-time SHA-256 hash is), which is what lets the cron purge worker NULL the transient column at retention without breaking verification. This layer is publicly verifiable: no secret is required, anyone with read access to the row can reproduce the check. - Operator HMAC. Loads the secret keyed by
row.secret_idfrom the configuredSecretRepository(Key-module backend), recomputesHMAC-SHA-256(row.hash, secret)and compares withrow.hmacin constant time (hash_equals). Catches rows inserted directly into the DB by an attacker who has table write access but lacks the signing secret.
The two-layer split is deliberate: layer 2 surfaces tampering even when the row's secret is unavailable (rotated out, key deletion); layer 3 detects unsigned forgeries. A mismatch at layer 1 or 2 surfaces as a structural break; a mismatch at layer 3 (or a missing secret) surfaces as an authentication break. The verifier reports both flags independently on the verdict.
Two more are checked on every row, and neither needs a secret.
- Segment-event cross reference. A row whose
actionstartssegment_is a lifecycle attestation, and it names the segment it is about inresource. That segment has to be in the index and on this chain, and for the four transitions the segment records against a column of its own (segment_transient_purged,segment_archived,segment_live_purged,segment_file_purged) it has to point back at this row through the matching*_event_id. The other three are checked differently and deliberately:segment_createdIS the segment, so its ownaudit_trail.idis the id and the range it recorded is compared against the range the index holds;segment_restoredandsegment_compactedhave no column to point back through and are accepted on the identity check alone. Between them these catch a segment row rolled back to a pre-transition state, an event forged on a chain that has no such segment, an event naming a segment on another chain, and a segment whose range moved after it was recorded. - Transient column. An empty
context_transientwhose signed write-time hash says the bucket held something has to be accounted for by a segment carryingtransient_purged_atorarchived_at. A column emptied with no segment claiming it at all is a break at every depth.
Whether that segment's stamps are the operator's is an HMAC
question, and it is one of the two things the three depths differ
about. The other is check 3 itself: None does not run it, and
resolves no secret to run it with, so on that depth the HMAC
layer is not failed but unasked. What follows is the first
difference, the one about the attestation behind an emptied
column. Strict treats a secret that will not resolve as a finding:
the caller said they expect every secret to resolve here, so one
that does not is about the key rather than about them.
Tolerant re-derives the signature whenever it can, and reports
a segment whose signature does not hold as a break, so a
fabricated attestation is still caught on any host with a copy of
the key; where the secret cannot be resolved it accepts the claim
and names the segment in unauthenticated_attestations, because
the alternative is to call a healthy chain broken. None reads no
signature, so it names every claiming segment and calls none of
them forged: it has no basis to.
Run any of the three from the command line with
drush audit_trail:verify --full --depth=strict|tolerant|none.
The option requires --full, because the incremental walk reads
a signed checkpoint and mints another and both need the secret,
and AuditTrailVerifier::verifyAll() refuses the same pairing
rather than ignoring the argument. The run prints the depth it
walked at beside the verdicts, so an OK from a walk that read
no signature cannot be mistaken for an OK from one that read
every signature.
One consequence is worth knowing before running either of the looser walks on a long-lived install. The bridge across a gap left by an archive and live-purge rests on the segment's anchors, and a walk that cannot read the signatures over them falls back to the spine, which takes no secret. That crossing succeeds only where the spine replays and a surviving lifecycle event still corroborates a head at or past the segment's height; where it does not, the gap reads as a broken link. The chain is not at fault and the verdict is not wrong; the walk is answering about the evidence it was given.
None of the three claims more than its evidence supports. A
segment accepted without being checked is listed rather than
believed silently, authenticated_ok is NULL whenever anything
was skipped rather than checked, and the message says so on a
clean verdict and a broken one alike. A Tolerant walk whose
keyring turned out to be whole reports TRUE: the flag says what
the walk did, not what it was asked for.
The walk does not stop at the first break. It records the broken
range and keeps going, so a single call reports every contiguous
failed range on the chain in broken_ranges, and the message
names the first one and says how many more there are. That
matters to an operator: acknowledging the first range and
assuming the rest is clean is the mistake the count exists to
prevent. See the multi-tamper walk in
architecture.
Per-row secret_id dispatch means a single chain can span any
number of rotated secrets without breaking verification: older
rows verify under their original signing secret, newer rows
under the current one. A row referencing a secret id that no
longer exists (a deleted secret, or a forged value) is reported
at the row's id as a secret that is not available, which the
operator investigates as either a legitimate retirement
(cross-check WORM archive) or a forgery attempt.
The id is computed from the secret's bytes, so a Key whose bytes changed behind its secret gives another id. The status report checks that for every secret, retired ones included, and names the Key: that is the explanation to look for when a range the secret signed reads as tampered.
The segment spine¶
Every other answer in this verdict is conditional on a
signature, which means conditional on the operator. The spine
is not. It replays the chain's archived history from the
segment table, deriving each segment's digest from that
segment's own fields, and compares the head it reaches against
the head the chain recorded at the time. The one exception is
a consolidated row left by compaction, whose head is adopted
rather than derived, for the reason given at the end of this
section. No secret goes in, so
the same answer comes back from Strict and from None, and
a host holding no signing material gets the full verdict on
it.
Four things it reports:
ok, whether every step folded as recorded.height, the greatest height the replay reached. Not a count of rows in the table: a consolidated row stands at the height of the highest segment it absorbed, so one row can advance the height by many.head, where the chain's archived history currently stands.attested_height, the greatest height a survivingsegment_archivedorsegment_compactedevent still corroborates. Both carry a head: an ordinary segment's is written when it is folded in, and a consolidated row's when compaction gives it the height it adopted. The compaction event is the newer of the two, so on a chain old enough to have compacted, it outlives the archive events it replaced and is what still corroborates that stretch.
That last one is the honest part, and it is worth reading
before trusting the first. A replay that agrees with itself
proves nothing: the spine lives in the same database as the
segments, so anyone able to rewrite them can recompute it end
to end. What makes it evidence is the comparison against a
head recorded inside a chain event, where the public hash
layer covers it and an attacker without the signing secret
cannot follow. attested_height of 0 means nothing left on
this chain corroborates the replay, and the verdict says so
in its message rather than reporting arithmetic as proof.
A broken spine makes ok FALSE and leaves structurally_ok
alone. The rows are not what broke: a chain whose every row
links and hashes can still have a segment table that no longer
folds to the heads the chain recorded, and those are two
different findings an operator needs to tell apart. But the
cron integration below alerts on ok, and a finding nothing
alerts on is a finding nobody reads.
Bridging a purged range without a secret¶
A candidate whose signatures this walk did not read, because the secret would not resolve or because the depth reads none, is accepted when the spine covers it and the chain still corroborates a head at or past its height. At or past, rather than exactly at, because the spine is a chain: a head corroborated at height 40 is reached by folding every digest below it, so an event surviving there vouches for the thirty-nine segments before it as surely as for its own. That is what keeps this usable on a chain whose early lifecycle events were purged long ago.
It is what makes the three depths agree about a chain retention has run on. Without it a keyless walk reports the row after every purged range as a broken link while an operator's walk of the same chain reports it verified, which inverts the ladder: the depth an operator runs before handing a copy to an auditor is the one that calls a healthy chain broken.
Two states the spine cannot carry, and both still read as a
broken link on a walk that read no signature, and cross at
Strict where the secrets resolve. A segment that compaction
has folded into a consolidated row is covered by nothing: its
head is adopted rather than derived from its own fields. And a
chain whose lifecycle events have all aged out has nothing
left to corroborate the replay, which attested_height
reports as 0.
What the spine does NOT cover is what has since been done to a segment. The purge stamps are re-signed on every transition, and a transient-purge months after the archive must not move a value the chain has already recorded, so they stay outside the digest. A bridge resting on the spine therefore has the range and the anchors on cryptographic footing and the purge claim on none, which is the same position every other unauthenticated attestation is in, and it is reported the same way rather than quietly upgraded.
It also never excuses a purge claim that was checked and failed. The spine carries a signature a walk could not READ; a lifecycle HMAC that re-derives under a resolvable secret and does not match is a forgery, and a missing secret elsewhere on the same row is no reason to stop reporting it. Only an unreadable signature falls through to the spine.
Nor does it cover a consolidated row's own fields. Such a row
records the head of the highest segment it absorbed, and a
replay adopts that head rather than deriving it, because the
rows it would derive from were deleted by the same
transaction. Its own range and anchors are values compaction
restated, so editing them moves nothing here. A consolidated
row is never reported as spine-covered for that reason, and a
bridge over a compacted range goes back to needing its
signatures. The segment_compacted event is what accounts for
it, and drush audit_trail:reindex-segments compares the two.
What a clean walk proves¶
- No row between the genesis row and the chain head has been
edited (column tampering would change the recomputed
hash, and so would the HMAC layer over that hash). - No row has been inserted in the middle (a new row's
previous_hashwould mismatch the surrounding rows). - No row has been removed from the middle of the chain (the row
after the deletion would have a
previous_hashpointing to a vanishedhash). Removal from the END is a different case: see below.
Verification does NOT prove:
- That a row at the chain head wasn't WRITTEN by a forger who has the secret. (The signature is symmetric; possession of the secret + database write access lets you produce a chain that validates.) Mitigation: external WORM export, RFC 3161 timestamps on batch boundaries: see security.
- That every audit-worthy action made it into the chain. (A
bug in the consumer that silently drops calls, or a code path
that reaches neither
AuditTrail::record()nor a chained\Drupal::logger()call, is invisible toAuditTrailVerifier.) - That the chain still reaches as far as it once did, if the
records of how far it reached were removed too. Deleting the
most recent rows leaves a shorter chain with no dangling
previous_hashanywhere, because there is no row after the deletion. What catches it is the signed checkpoint naming a row that is now missing, with no segment attesting a live-purge over it, and the verifier reports that as broken. A checkpoint is a row in the same database, so an attacker who deletes those as well leaves nothing to compare against. Mitigation: TSA timestamps on chain heads whose response is retained off-box, and a shortarchive_afterso fewer rows sit outside an archive; see the truncation non-goal in threat-model.
Segment-event cross-reference¶
For each segment_* event the verifier walks
(segment_archived, segment_transient_purged,
segment_live_purged, segment_file_purged), it confirms a
mutual reference with the segment row the event names:
- The chain event's
resourcefield must besegment:<id>, exceptsegment_created, which carriessegment:<from>-<to>: the id it would name is the id of the row carrying it, so the verifier reads it off the row instead. - The matching
audit_trail_segment.<transition>_event_idcolumn must point back at the event'sid.
A mismatch surfaces as segment row may have been rolled back
to a pre-transition state in broken_ranges. This catches
the canonical rollback tamper: an attacker with DB write
access but no operator secret who tries to undo a lifecycle
transition by clearing <transition>_event_id back to 0.
Live-purge supersession exemption. Restore is a legitimate
reversal of a prior live-purge (see
architecture.md).
When the verifier sees a segment_live_purged event whose id
doesn't match the segment's live_purged_event_id, it does one
extra O(1) primary-key lookup on the value the segment points
at. The mismatch is accepted only when the referenced event:
- Exists in
audit_trail(rules out rollback-to-zero). - Lives on the same chain as the current walk (rules out cross-chain pointer forging).
- Has
resource = 'segment:<same_id>'(rules out pointers at events for a different segment). - Has action
segment_live_purgedORsegment_restored(rules out pointers at archive / file-purge / unrelated events). - Has an
idstrictly greater than the event under verification (rules out pointers at an older live-purge, which would let an attacker hide a more recent purge).
Forged segment_restored events can't exploit this exemption at
the depths that read signatures, though not because anything
gates the lookup: checkRow() returns early on the recomputed
hash and on nothing else, so the cross-reference check runs
whatever the HMAC check returned. What closes it is that the
forged event is itself a row on the chain. Writing one without
the operator secret leaves a row whose HMAC does not hold, and
the verdict is already FALSE on that row before its
compacted_from or its action is consulted.
At VerificationDepth::None the HMAC check does not run, so
nothing here distinguishes a forged segment_restored from a
real one. That is the stated limit of that depth rather than a
gap in the exemption: None reads no signature and has no basis
to call anything forged, which is why its verdict names what it
accepted without checking instead of vouching for it. A reader
who needs the exemption to mean something has to walk at
Tolerant or Strict.
Other segment transitions (segment_archived,
segment_file_purged, segment_transient_purged) are not
reversible by restore, so their strict-equality check stays in
force: any rollback tamper on those columns is still
surfaced.
Mid-restore transitional states verify cleanly. Restore is
not atomic: the segment_restored chain event commits in
Step 1 BEFORE the rows are replayed (Step 2) and BEFORE the
segment row is updated (Step 3). The verifier accepts every
intermediate state without special-casing:
- Between Step 1 and Step 2: the chain has a
segment_restoredevent referencing the segment, but no rows have re-appeared in[from_id, to_id]. The verifier walks visible rows + the archive bridge as it would for any fully-purged segment;segment_restoredis non-cross-checkable so the event passes through. Thesegment_live_purgedevent still strict-matchessegment.live_purged_event_id(Step 3 has not moved the pointer yet). - Between Step 2 and Step 3: rows are now back in the live
table. The verifier walks them in id order and validates each
row's
previous_hashlinkage; the archive bridge does not fire because there is no gap. Thesegment_live_purgedevent still strict-matchessegment.live_purged_event_id. - After Step 3:
segment.live_purged_event_idnow references thesegment_restoredevent id. The strict-equality check on thesegment_live_purgedevent fails; the supersession exemption above accepts the mismatch.
The transitional acceptance is property of the existing rules, not a separate exemption: no rule was added or relaxed to support the narrowed restore design.
A range purged while it did not verify¶
Retention moves evidence, and both stages that move it check what they are moving first.
At archive. The rows in the segment's range are walked, and
a break nothing explains is written into the
segment_archived event's permanent bucket and logged as an
error. The archive is written anyway: the archive window is a
legal deadline, and stalling it until somebody attends to a
break would trade a dated obligation for an undated one.
Nothing is lost by proceeding, because the rows are still live
and the chain still reports the break on every run.
At live-purge. This is the moment the evidence goes, so the
range is checked again. A range whose signing secret can no
longer be resolved counts as failing here, the way the walk
counts it: nobody can authenticate those rows, and that has to
be said out loud rather than passed over on the way to deleting
them. If the range still does not verify and no
acknowledgment covers it, the rows are deleted on schedule and
the segment is marked: the fact goes into the
segment_live_purged event's permanent bucket, and
audit_trail_segment.verification_failed_at_purge is set and
signed into lifecycle_hmac beside the purge stamp it belongs
to.
The mark is what the walk reads. A marked segment does not bridge the gap silently: the range is reported as purged-and-unexplained and the chain does not read as verified, every run, until somebody explains it. Without it the verdict went green on its own, because a segment's anchors are separate columns from the row content: an edit to a row between them leaves both ends matching, so the bridge fits and the walk resumes as if nothing had happened.
A marked segment is also left out of compaction, which otherwise folds a run of file-purged segments into one row covering all of them. The range is what an acknowledgment anchors to, so widening it would stop an explanation already recorded from covering the segment, and re-open a finding an operator had closed.
Compaction leaves the walk with references to segment rows that
no longer exist: every lifecycle event a folded segment emitted
still names its id. Those resolve through the segment_compacted
event, whose compacted_from payload lists the ids the
consolidated row absorbed, and a reference the list covers reads
as compaction-superseded rather than as a missing segment. The
event is an ordinary chained row, so its own HMAC is checked by
the walk that is reading it: a forged compacted_from is a
forged row, and the chain verdict is already FALSE before the
list is consulted. There is no side table; the chain is the
record of what compaction did.
The operator's window is the interval between
archive_after and live_purge_after, which needs no setting
of its own and on a real site is months. Acknowledging a range
resolves its anchors from the live rows, so it works right up
until the live-purge deletes them.
Explaining one afterwards. The mark is permanent and is not clearable: "this range was purged while it did not verify" is a fact about what happened, and a fact an operator can delete is worth nothing. The column is only ever set, never recomputed, so a second live-purge after a restore can add it but cannot take it away. What an acknowledgment changes is the verdict. The range goes on reporting as failed and the verdict names the reason somebody gave for it:
before: rows 2-4 were purged and did not verify, unexplained
after: rows 2-4 were purged and did not verify, explained:
"secret lost in the 2026 migration"
The same holds for the row just before an acknowledged range.
Retention takes ranges oldest first, so that row goes before the
range an operator explained does, and its hash is the one the
acknowledgment pinned as anchor_before. It is read off the
segment that took it, for the same reason and from the same
place, so an explanation keeps covering its range as the chain
ages out around it.
Such an acknowledgment covers the whole segment, and only the
whole segment. Recording one reads two chain hashes, and for a
purged range they come off the segment, which holds the same
pair: row N's previous_hash IS row N-1's hash, and the
segment's copy is signed into its identity HMAC while a live
row's is not. That is the finest anchor left once the rows are
gone, so excusing one bad row means excusing the range the
segment covers. Before the purge an operator can name exactly
the rows that are broken; the granularity degrading with time
is the incentive to attend to a break while there is still
something to attend to.
Running verification periodically¶
Drupal's own cron already runs the walk when auto-verify is on,
and says so when the answer changes: a chain that stops
verifying is logged critical on the audit_trail channel,
naming the chain and its first broken range, and a chain that
verifies again is logged notice. Once per change, not once
per tick, because a break stands until it is acknowledged and
a line per run would bury the one that mattered. A break a
full walk found is still reported by the incremental walks after
it, which do not read the rows below their checkpoint, so "verifies
again" waits for a full walk that finds the chain clean. A strict
full walk run by hand counts: drush audit_trail:verify --full is
recorded, and logged, the same way as cron's.
That is deliberately a log entry rather than a notification channel of its own: whatever the site already ships logs with carries it, and sites that want no more than this need configure nothing.
The pattern below is for the rest: an exit code to hang an external monitor on, or a verdict to hand to something like PagerDuty.
The standard pattern is a cron-driven verification job that
calls drush audit_trail:verify and alerts (via Drupal
watchdog to external monitor, or via email, or via a status
report block) on any non-ok result. The drush command exits
non-zero when any chain breaks, so a one-liner crontab covers
the integration:
0 * * * * drush audit_trail:verify \
|| mail -s "audit_trail alert" admin@example.test
For embedded use (e.g., a custom alerting script that talks to PagerDuty), call the verifier service directly:
$results = \Drupal::service('audit_trail.verifier')->verifyAll();
$bad = array_filter($results, fn ($r) => !$r['ok']);
if ($bad !== []) {
$msg = "AUDIT_TRAIL INTEGRITY BREAK:\n";
foreach ($bad as $chain => $r) {
$msg .= " - {$chain}: {$r['message']}\n";
}
fwrite(STDERR, $msg);
exit(1);
}
echo "All chains verified.\n";
Performance: incremental verification + checkpoints¶
A full walk costs O(chain length) in time. Its memory does not follow the chain length: the walk reads the chain in bounded batches rather than in one statement. Core's MySQL driver runs buffered queries, so a single statement over the whole chain hands every row to PHP, both payload columns included, before the first one is examined. A chain large enough to make the walk slow is a chain large enough to make it run out of memory first.
Measured on MySQL, walking a chain of 12,000 rows each carrying a 4 KB payload: the batched walk raised peak memory by 5.5 MB, and reading the same chain in one statement raised it by 63 MB. The batched figure is the size of one batch and stays there; the other grows with the chain, which at a few million rows is tens of gigabytes.
One caveat on "does not follow the chain length": a verdict also accumulates the archive and acknowledgment ranges it crossed, so a chain with a very long retention history carries a little more. Those are per segment, not per row.
The time is what checkpoints are for: fine for hundreds of entries, intolerable for the multi-million-entry chains a long-lived audit-worthy install accumulates. The module keeps verification cheap with per-chain checkpoints:
- Every time
verifyChainIncremental()walks a chain cleanly to its current head, it mints a row inaudit_trail_checkpointrecording(chain, last_id, last_hash, created)plus anhmaccolumn signing the tuple. - The next call reads the most recent checkpoint for that chain,
starts the walk after
last_id, expectinglast_hashas the genesis-equivalent ofprevious_hash.
A typical operational pattern:
- Cron hourly:
verifyAll()(default: incremental). Each chain walks only the rows since its last checkpoint: minutes-to-hours of activity, hundreds to thousands of entries at most. Sub-second. - Cron weekly (or on-demand):
verifyAll(full: TRUE). Full cold walk from genesis to head. Useful as a belt-and-braces check even though incremental walks already validate checkpoint signatures (see below): a full walk re-derives every HMAC from the secret rather than resuming from a checkpoint. It still reads the checkpoint first, because that is the only record of how far the chain once reached: a walk from genesis over a chain whose end was deleted covers rows that are all present and correct, and would otherwise report clean.
Checkpoints are themselves signed with the row's signing
secret: each row carries an hmac column over (chain ||
last_id || last_hash || created), keyed by the secret_id that
signed the audit row at last_id. verifyChainIncremental()
validates the checkpoint's signature before trusting it; a
forged or modified checkpoint fails the check, the verifier
falls back to a full walk from genesis, and the result is
flagged with checkpoint_forged => TRUE plus a warning in the
message so operators can investigate the forgery itself as a
security event.
Checkpoints are an optimization for the common case (cron polling), and for everything the chain itself records the chain is the authoritative source: lose all checkpoints and a full walk still verifies every surviving row end-to-end.
They are not only an optimization, though. A checkpoint is the only record of how far a chain once reached, and the rows cannot supply that: deleting the most recent ones leaves a shorter chain that is genuinely self-consistent. So losing every checkpoint for a chain costs the ability to notice that its end was removed. See the truncation non-goal in threat-model.
For chains expected to outgrow what a single-process walk handles even with weekly full verification (multi-million row, multi-year archives), the roadmap plans for chain rotation (yearly closure + fresh chain) and external WORM export + qualified TSA timestamping: at which point old chains live in a write-once archive and are verified once, against their qualified timestamp, rather than re-walked from the DB.
API¶
$verifier = \Drupal::service('audit_trail.verifier');
// Incremental: fast, default.
$verifier->verifyChainIncremental('notarial');
// returns ['ok' => TRUE, 'count' => 73, 'checkpoint_minted' => TRUE,
// 'message' => 'Chain "notarial" verified incrementally:
// 73 new entries since the last checkpoint,
// at id 4754. Checkpoint refreshed.']
// Checkpoints are minted BY a clean incremental walk, under the
// throttle, the lock and the active-secret check it enforces.
// `mintCheckpoint()` itself is @internal and takes the walked
// last id and hash; production callers go through the walk.
// Full cold walk: slow, on demand.
$verifier->verifyChain('notarial');
// All chains: incremental by default, pass full: TRUE for cold.
$verifier->verifyAll();
$verifier->verifyAll(full: TRUE);
What a broken chain means¶
If verifyChain returns ok => FALSE, treat it as a security
incident:
- The break could be benign: a developer ran an ad-hoc SQL UPDATE in dev to fix a typo, a database migration altered the table. Verify the timestamp on the broken row against the local change log.
- The break could be adversarial: someone with database access edited a row to cover an unauthorized action. Treat as a compromise: rotate the master secret, audit other systems, follow the incident response plan for the deployment.
Either way the chain stays usable for new entries from the
break onwards (previous_hash of the next row records the
broken row's hash so the chain "heals" from there). But every
row from the break to the verification point is now considered
unverified: annotate the incident in your audit log, preferably
in the same chain (with an audit_trail: TRUE event explaining
the discrepancy), so the trace stays self-describing.