Skip to content

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=ID restricts the run to one chain.
  • --full walks every chain from genesis, ignoring checkpoints. At the default strict depth 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 the audit_trail channel.
  • --depth=strict|tolerant|none says how much signature verification the walk performs, defaulting to strict. 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, --to bound the range.
  • --reason is 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.

  • --chain limits it to one chain. Omit it to cover every chain that has rows.
  • --dry-run reports 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.

  • --chain limits it to one chain. Omit it to cover every chain that has rows.
  • --dry-run reports 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_NAME retires 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=ID restricts 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, --to bound the range.
  • --directory overrides 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=N names the segment.
  • --dry-run reports 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.

  • --chain names 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 of now would have made the shortest invocation the widest one.
  • --dry-run prints which segments would be folded and touches nothing.
  • A segment carrying verification_failed_at_purge is 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.

  • --id names 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 || mail wrapper 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=N names the segment, --path points 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.

  • --path is the file.
  • --restore also brings the rows back in the same run.
  • --allow-missing-secret accepts 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=N names the segment, --path overrides the recorded file location, --allow-missing-secret behaves 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=ID for one chain, --all for 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=N for one row, --chain=ID for 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.