Drush commands¶
Every operation the admin UI offers has a command, and a few
exist only here. Run drush <command> --help for the full option
list; the options below are the ones that decide what a command
does rather than how it reports.
Exit codes are uniform across every command here, including the
audit_trail_tsa ones:
0: the command did what it says it does.1: the audited state is bad. A chain verified broken, an archive or a timestamp failed its checks. The command worked; the data is the problem.2: the command could not run. A missing or unusable option, a chain or archive id that does not exist, a lock it could not take, an error it could not anticipate. Nothing was learned about the audited state.
That split is the whole point of wrapping the verifier in a cron
job: a typo in the crontab has to be distinguishable from a chain
that actually broke. Both are non-zero, so || mail fires either
way, and the code tells you which one to go and look at.
Verification¶
audit_trail:verify¶
Verifies the integrity of one or all chains. Incremental by default: the walk starts from the last signed checkpoint, which on a large chain is the difference between minutes and hours.
--chain=IDrestricts the run to one chain.--fullwalks every chain from genesis, ignoring checkpoints. At the defaultstrictdepth its verdict is also stored for the status report, the same way cron's full walk is: it records a break it finds, clears one it no longer finds, and logs the change on theaudit_trailchannel.--depth=strict|tolerant|nonesays how much signature verification the walk performs, defaulting tostrict. It requires--full: the incremental walk reads a signed checkpoint and mints another, and both need the signing secret. An unknown value is refused rather than falling back to one.
--depth and --full answer different questions: --full says
where the walk starts, --depth says how much of the signing it
checks. strict requires every secret to resolve and reports one
that does not as a finding. tolerant checks what the resolvable
secrets allow and names the rest, which is the walk for a keyring
known to be incomplete: a site mid-rotation whose retired key has
already been removed. none reads no signature at all, which is
exactly what a reader without the keys gets. See
verification for what each one proves.
A walk that cannot read a segment's signatures may still cross
the gap its purge left, on the segment spine. The anchors a
bridge uses are covered by the spine, which takes no secret, so
none and a tolerant walk on a host without the keys cross a
purged range when two things hold: the chain's spine replays, and
a surviving segment_archived or segment_compacted event
still corroborates a head at or past that segment's height. The segment is then listed in the
verdict's unauthenticated_attestations, because the spine
covers what a segment IS and never the purge claim on top of it.
Where either condition fails the gap is still reported as a
broken link and the run exits 1: on a chain whose lifecycle
events have all aged out, nothing corroborates the replay; and a
consolidated row left by compaction is never spine-covered, so a
range folded into one needs its signatures as before.
What those depths establish is that the rows still present link end to end and none has been edited. On a chain whose retention has never deleted a row, that covers the whole chain; on one whose retention has, it covers what survives. Read the result that way before handing a copy to an auditor, rather than as a pass or fail on the chain.
Exits 1 when any chain reports a break, which is what makes it
usable directly as a cron or CI check.
audit_trail:acknowledge-reset¶
Records an operator acknowledgment that a chain range is
unverifiable, so a known break (a secret lost in an incident, a
restore that rewrote rows) stops being reported as an open
finding. The acknowledgment is itself a signed, chained record:
it does not erase the break, it explains it. The
audit_trail_acknowledgment table is an index of those chain
records, and audit_trail:reindex-acknowledgments rebuilds it
from them.
The range has to be rows the chain still has. An acknowledgment
is anchored to the stored hashes at its bounds and has to end at
or before the chain head, so rows lost from the end of a chain,
by a restore from an older backup for example, cannot be
acknowledged: the command answers that to_id exceeds the head.
Restoring those rows from their archive segment is what clears
that finding.
--chain,--from,--tobound the range.--reasonis required, and is surfaced verbatim in the verifier output.
audit_trail:reindex-acknowledgments¶
Rebuilds the audit_trail_acknowledgment table from the chain
events that record each acknowledgment.
The table is an index, not the record. Every acknowledgment is an
acknowledgment_recorded chain event, and every change to one is
an acknowledgment_updated or acknowledgment_deleted event,
all signed and linked like any other chain row. Replaying them in
id order reproduces the table, ids included.
Reach for it when the index and the chain have come apart: a
restore from a partial backup, a DELETE against the table, a
hand-edited range. The status report says when that has happened.
Running it when nothing is wrong changes nothing, because the
rebuild is what the chain already says.
--chainlimits it to one chain. Omit it to cover every chain that has rows.--dry-runreports what disagrees and changes nothing. It exits 1 when anything disagrees, so a monitoring script can watch for a damaged index.
An acknowledgment whose own chain events have been archived and live-purged cannot be rebuilt, and does not need to be: by then the rows it covered are gone as well, and the verifier bridges that range by the archive's signed anchors without asking about acknowledgments at all.
audit_trail:reindex-segments¶
Rebuilds the audit_trail_segment table from the chain events
that record each segment.
The same story as the acknowledgment index above, on the table
that carries the retention lifecycle. A segment is recorded
before it exists: its segment_created event carries the whole
identity envelope, and the audit_trail.id that event lands on
becomes the segment's id. So the chain is the record and the
table is a reading of it, and replaying the events reproduces the
table, ids included.
Two entries on /admin/reports/status send you here, both at
error severity. Audit trail segment index says the index and
the chain disagree. Audit trail segment anchors says two
neighboring segments no longer meet over rows that are gone,
which is what a segment removed from between them looks like
long after its own events aged out, or that the rows before the
first segment are gone, which is what a removed first segment
looks like.
--chainlimits it to one chain. Omit it to cover every chain that has rows.--dry-runreports what disagrees and changes nothing. It exits 1 when anything disagrees, so a monitoring script can watch for a damaged index. Run it first: it names what is missing from the index, what is in the index but not on the chain, and what differs.
Two segments that claim the same rows, or rows gone from between two segments or before the first one, exit 1 whether or not it is a dry run. That holds when the index agrees with the chain too, as it does once the events that recorded a removed segment have aged out. Rebuilding the index repairs neither, and what explains such a range is an acknowledgment.
Unlike an acknowledgment, a segment carries signatures, so a
rebuild has to reseal them, under the secrets the chain names
rather than the active one. A segment whose secret will not
resolve here is left exactly as it is and reported as
unsealable, because writing it with a signature nobody can
verify would turn a recoverable gap into a permanent forgery
finding.
Nothing is deleted. Only what the chain describes is
rewritten. A row the replay cannot produce is not a row the chain
denies: its events may have aged out, or come back from a backup
older than the segment table beside them. --dry-run reports
such a row as being in the index but not on the chain, and
removing it stays a deliberate act.
Secrets¶
audit_trail:set-signing-secret¶
--key is required, and names the drupal/key Key entity whose
bytes the signing secret uses.
Makes the secret backed by that Key the one that signs new rows.
It does not create key material: provision the Key in whatever
provider your policy requires, and this points a secret at it,
creating the audit_trail_secret record if there is not one
already. The outgoing secret is retired in the same transaction,
so a crash part-way leaves two active secrets (harmless, and the
status report says so) rather than none, which would halt writes.
A Key that already backs the signing secret is refused: pointing at the same bytes again would read as a rotation in the trail while nothing about the signing changed. So is a Key holding the same bytes as any other secret, under whatever Key entity: the secret id is computed from the bytes, so the two would carry the same id.
audit_trail:retire-secret¶
Retires a secret without activating a replacement. When the retired secret was the only active one, chains stop accepting writes until another is activated, which is the point: it is the command for taking a compromised key out of service. Retiring a pending candidate, or one of the two actives a crashed rotation leaves behind, keeps writes running under the survivor, and the command reports which secret that is.
--id=MACHINE_NAMEretires a specific secret, named by its machine name, instead of the active one. A name no secret has stops the command and retires nothing.
Retention lifecycle¶
The six stages run from cron. These commands drive the same machinery by hand, for a one-off range or for an operator who wants to watch a stage before enabling it.
audit_trail:auto-archive¶
Runs the auto-archive lifecycle on demand, applying whatever the configured thresholds say is due right now.
--chain=IDrestricts the run to one chain.
audit_trail:archive¶
Exports a chain segment to NDJSON and records the archive row, with the file's SHA-256 captured in the same row so later tampering with the file surfaces at verify time.
--chain,--from,--tobound the range.--directoryoverrides the configured archive directory.
audit_trail:purge¶
Drops a previously-archived range from the live audit_trail
table. The segment row stays as the verifier's bridge across the
gap.
--id=Nnames the segment.--dry-runreports what would go without touching anything.
audit_trail:compact¶
Compacts contiguous file-purged segments into a single bridging row. Hygiene only: it closes the open-ended tail the lifecycle otherwise leaves behind, and it changes nothing a verifier can observe.
--chainnames the chain.--before='ISO date'is required and bounds what is eligible: only file-purged segments covering rows created strictly before it. There is no implicit cutoff, because a default ofnowwould have made the shortest invocation the widest one.--dry-runprints which segments would be folded and touches nothing.- A segment carrying
verification_failed_at_purgeis never folded, and splits any run it sits inside. Its range is what an acknowledgment has to cover to explain it, so widening the range would stop an explanation already recorded from covering the segment.
audit_trail:rewrite-archive¶
Rebuilds an archived segment's NDJSON from its still-live rows, for the case where the file has gone missing from the archive directory.
live-purge refuses such a segment, because it is about to delete the last live copy of the range and the copy meant to replace it is not there. Left alone that stalls indefinitely, which is its own problem: rows sit past the retention window they were given. The rows are untouched though, which is exactly why the refusal happened, so the file can be written again from them.
--idnames the segment.- Refused unless the rebuilt bytes match the SHA-256 recorded when the segment was archived, so it restores what was attested or nothing at all. The archive is deterministic and an archived segment's rows are frozen, so a mismatch means the rows no longer agree with what was signed: verify the chain rather than rebuilding over it.
- Also refused if the file is still there, if the segment was never archived, or if its rows have already been live-purged.
- Those refusals do not all mean the same thing, and the exit
code says which. Rows that no longer produce the digest they
were signed with exit 1: the trail is the problem, and the
message says to verify the chain. Every other refusal exits 2,
a file still on disk and an id that does not exist included,
because nothing was learned about the trail. A
|| mailwrapper should page on the first and not on the second. - Once it succeeds, live-purge proceeds on its normal schedule. Nothing is re-signed and no lifecycle stamp moves.
Archives¶
audit_trail:archive-verify¶
Verifies an archive record against its on-disk NDJSON file, recomputing the SHA-256 and the lifecycle HMAC.
--id=Nnames the segment,--pathpoints at a file that has been moved since it was written.
audit_trail:archive-import¶
Imports an NDJSON archive file as a fresh audit_trail_segment
row. This is the disaster-recovery entry point: it is how an
archive pulled back from WORM storage becomes known to a site
whose database no longer has the segment.
--pathis the file.--restorealso brings the rows back in the same run.--allow-missing-secretaccepts a file whose signing secret the site no longer holds. It is logged as a critical event, because it accepts content the site cannot cryptographically attribute.
audit_trail:archive-restore¶
Restores a previously-purged archive back into the live table.
--id=Nnames the segment,--pathoverrides the recorded file location,--allow-missing-secretbehaves as above.
Cron re-purges restored rows on its next eligible tick, since
restoring clears the segment's live-purge stamp and the
retention ceiling applies again. Pause auto-archive, or lift
live_purge_after, if the restore is for an inspection window.
The command says so on success too, so this is not something an
operator has to have read first.
A restore that used --path records that path in the
segment_restored chain event's permanent bucket. The derived
location is always reconstructed from the directory setting,
the chain and the segment id; an override is not, so without
recording it a restore from a WORM copy left no account of where
the rows came back from. It is in the permanent bucket rather
than the transient one, so retention cannot remove the
provenance later.
Timestamping¶
Both commands come from the audit_trail_tsa submodule.
audit_trail:timestamp¶
Anchors a chain head to the configured RFC-3161 TSA. One round
trip per chain; the response lands as a chained tsa_timestamp
row, so the timestamp is itself covered by the chain's HMAC.
--chain=IDfor one chain,--allfor every chain that still accepts writes.
--all is a sweep and skips an inactive chain on the chains
list, the way the cron tick does: a timestamp is a row on the
chain, and an inactive chain takes no new rows. --chain=ID
stamps one regardless, so the final state of a chain just made
inactive can still be anchored: that is an operator asking for one
timestamp rather than a timer adding one every interval.
audit_trail:verify-timestamp¶
Verifies a stored TSR via openssl ts -verify, against the
operator-supplied CA chain configured on the provider. This is
the offline check: the TSR is already HMAC-bound by being a
chain row, and this adds the authority's own signature on top,
that the timestamp covers the anchor the row claims, and that the
anchor is the chain's own hash at the row it says it anchored.
One openssl call establishes the first two; a second runs only to
explain a row that failed them.
--row-id=Nfor one row,--chain=IDfor every timestamp on a chain. The chain form exits non-zero if any of them fails.
The openssl command-line tool has to be installed on the host
running the command. Its absence is flagged on the status report
whenever timestamping is enabled.
The last of those checks is a database read, so it runs for a provider with no CA chain pinned too. Such a row exits 2, nothing having been learned about the authority's signature, unless the anchor is not the chain's own hash: that is a finding the command can make without a CA, and it exits 1.