Tamper runbook
When Undercroft raises PalaceTamperDetected (or the Palace Monitor’s
ambulance beacon lights, or undercroft verify reports a non-zero hmac failures count), a stored record failed its HMAC integrity tag on read. Treat
it as on-disk tampering until proven otherwise. This page is what the
alert’s runbook_url points to.
Integrity is cryptographic, not advisory: every drawer, KG triple, tunnel, and vault manifest carries an HMAC-SHA256 tag, and every write joins a tamper-evident audit chain. A verify failure means the bytes on disk no longer match what Undercroft sealed.
The whole procedure at a glance — each step is detailed below:
flowchart TB
alert["PalaceTamperDetected<br/><i>alert / monitor beacon / verify count</i>"] --> loc["1 · Where?<br/><i>surface label on the counter,<br/>vault · id · wing · room on the event</i>"]
loc --> conf["2 · Confirm + pinpoint<br/><i>undercroft verify --vault —<br/>names the exact record(s), chain state</i>"]
conf --> mit["3 · Mitigate<br/><i>preserve evidence copy FIRST ·<br/>freeze writes (--read-only) · isolate vault</i>"]
mit --> fix{"4 · Fix — verbatim restore,<br/>never repair-in-place"}
fix -- "known-good backup" --> restore["backup restore →<br/>verify must report 0 failures"]
fix -- "single MINED record,<br/>source document available" --> refile["re-file it —<br/><i>source-derived id ⇒ idempotent re-seal</i>"]
restore --> clean["repair (housekeeping) →<br/>read-write only once verify is clean"]
refile --> clean
clean --> prev["5 · Prevent<br/><i>scheduled backups ·<br/>owner-only permissions ·<br/>OS-level FIM · alerting on ·<br/>per-vault assertions</i>"]
1. Where did it happen?
The alert carries two labels that localize the failure:
surface— which structure failed:drawer,kg,tunnel, ormanifest.instance— which server process counted it.
There is no vault label on this alert: the integrity counter is emitted
with surface alone. (One rule does carry one — AuditChainHeightHigh, whose
expression is an unaggregated gauge, and gauges have always been per-vault.
This sentence said “or any other” until ROADMAP O250 added it.) The vault is
on the live event stream
instead — the hmac-fail frame the Palace Monitor reads names it, beside the
id, wing and room the failing row claims (marked unverified, since
that row has just failed its own HMAC). The stream is live only, so it names
the vault only to a subscriber connected when the failure happened; otherwise
run step 2’s verify against each vault the process serves.
In Grafana, the “Tamper by surface” panel and the HMAC verify failures
stat show the same signal; the Logs panel shows the
integrity failure — HMAC verification failed on <surface> line.
2. Confirm and pinpoint the record
Run a full verification of the affected vault — it re-checks every record’s HMAC and replays the audit chain, naming the exact bad record(s):
undercroft verify --vault <vault>
# records checked: 1284
# hmac failures: 1
# TAMPERED: 5a2fc91d…
# audit chain: ok
# orphan labels: 0
# mirror drift: 0
# policy drift: 0
# version replay: 0
# VERIFY FAILED
The named id is the tampered record, and VERIFY FAILED exits 2. Expect
audit chain: ok beside it: the chain is replayed from the audit trail’s own
tags against the committed head and the manifest anchor, so editing the
tampered record’s bytes does not move it. audit chain: BROKEN is a separate
finding — the audit trail itself was edited or truncated, or the database was
rolled back relative to the anchor.
A broken chain also stops work before you get here, and that is deliberate
(ROADMAP O237). Every read that DECIDES from an audit label — a
trust-floored search, trust list, retention list, a retention sweep,
forget, and the version check every returning read rides — refuses with
the audit chain does not authenticate its own labels, exit 2 and 409
class: "integrity", rather than acting on a relabelled record until
somebody runs this command. So an operator usually arrives here because a
read refused, not because an alert fired. The remedy is the same: preserve
the evidence, then restore a backup that verifies. A vault this binary has
not yet opened writable keeps serving, because its labels were never bound
to the chain.
The next four lines are further legs, and a non-zero count on any of them fails the verdict too; a vault holding supersession links or fact receipts prints a line for each of those legs as well, where only a tampered count fails.
3. Mitigate now (stop the bleeding)
-
Preserve evidence first. Copy the vault directory before anything else touches it — the DB, its
-wal/-shm,vault.json, andvault.json.nextif one is there:cp -a "$UNDERCROFT_HOME/vaults/<vault>" "/tmp/<vault>.evidence.$(date +%s)"Since 1.2.1 a read-only open (see step 2) writes nothing to the database,
vault.json,vault.json.nextor a hot-wal. In a writable directory it may still create the-shmwal-index and a zero-length-wal— SQLite’s scaffolding for reading a WAL database, carrying no database content. From 1.0.0 through 1.2.0 an embedder lookup ran before the posture took effect — on a/v1request from 1.0.0, and on the CLI’s own read-only open from 1.2.0 — and could create a missing database or checkpoint a crashed writer’s hot-walinto it (ROADMAP O91), so on those versions the copy has to come before any process opens the vault. Take the copy on every version: it is the only thing that survives a writable process someone else starts, and a forensic copy costs seconds. -
Freeze writes. Restart the server read-only so nothing new is written on top of a compromised store while you investigate:
undercroft serve-http --read-only …--read-onlyis a posture on the whole process, not a filter on one port: both stores the server opens take it, the gate sits in front of route dispatch and fails closed (everything is a mutation unless explicitly named otherwise), and the read-audit record and the embedder migration — both writes — are suppressed.POST …/verifyis allowed and is a genuine read: it walks every record’s HMAC and replays the chain, and it does not fast-forward the manifest anchor (an earlier version of this step said it did).The open is a read too — the open itself since 1.0.0, and the embedder lookup that ran ahead of it since 1.2.1 (step 1). It used to be the one write
--read-onlydid not bound, and the worst of it ran on the very path this step recommends: rotation reconciliation happened before the read-only/read-write split, so the first request against a cold handle either promoted a stagedvault.json.nextovervault.json— adopting a new key generation — or deleted it outright, with an fsync. That was potential evidence destruction on the path chosen to avoid touching the vault (ROADMAP R4/A32). Now the connection itself is openedSQLITE_OPEN_READ_ONLYunderPRAGMA query_only=ON, the schema is checked rather than created, the anchor is reported rather than healed, a staged rotation is honoured in memory only and its file left exactly where it is, and a prefilter loads an index but never builds one. What the open declined to repair is printed as a warning and readable afterwards onundercroft stats(andGET /v1/vaults/{id}/stats) asunhealed— during an incident, read it: “a tornvault.json.nextwas left in place” tells you a rotation was in flight when the incident began; and since 1.7.0 (ROADMAP O246) a WRITABLE open reports on the same list the manifest anchor heal it performed and how far behind the anchor was — a crash is the ordinary cause, and a genuine oldervault.jsonrestored beside a current database looks identical, so treat it as evidence to read during an incident rather than as an alarm. Since 1.2.0 the admin console atGET /uishows it too, as an UNHEALED panel that appears only when there is something to say, beside a POSTURE gauge naming the role the handle was opened under. Before that the console showed a clean, complete- looking stats page for a replica with both conditions live.Two conditions refuse instead, both 409, because serving through them would answer a question wrongly rather than partially: a manifest whose
vault.dbis absent (a half-copied backup or a snapshot taken mid-write — “empty” is not “absent”, and this one exits 2, an integrity verdict), and a schema this build would have had to migrate (open it once with a writable process, then retry).The database is
vault.dbsince 1.5.0, besidevault.json; before that it waspalace.db. A vault created earlier keeps that name until its first WRITABLE open, which checkpoints the WAL and renames it in place; a read-only open serves the file where it is and reports the pending rename onunhealed, so a replica of a not-yet-migrated primary says so rather than failing. A directory holding BOTH files is refused on either posture (409, exit 2 — one of them is a stray copy, and an open that picked one would serve the wrong vault silently): move the stray aside and reopen. Any script of yours that names the file — backups, the tamper demo in the observability README, a monitor — must namevault.db, or check for both.If your incident needs a byte-frozen vault, stop the server rather than restarting it. If the vault lives on a write-protected mount or a snapshot, the read-only open escalates to SQLite’s
immutable=1mode and says so in a warning — correct there, and wrong if anything is still writing, which is why it is reached only after the ordinary open has failed. -
Isolate. If this is a multi-tenant server, the vault named by the
hmac-failevent (or byverify) scopes the blast radius — other vaults have independent HKDF-derived keys, so one vault falling tells an attacker nothing about its siblings.
4. Fix (restore verbatim)
Undercroft never lossily transforms your data, so the fix is a verbatim restore, not a repair-in-place of forged bytes:
- Restore from the most recent good backup.
backuprefuses to run if the source failed verification, so a listed backup is known-good at capture time:undercroft backup list # names are <vault>-<stamp> undercroft backup restore <vault>-<stamp> --force # --force to overwrite the live vault undercroft verify --vault <vault> # must now report 0 hmac failures, chain ok - If a single mined record was hit and you have the source document,
re-mine it with the same path,
--wingand--modeit was mined with: a mined drawer’s id is derived from (wing, room, source, chunk index, normalize version), so re-mining rewrites that row under a fresh seal. Re-verify afterwards. This does not hold for drawers filed bysweep, which skips any message whose content fingerprint is already stored — and an edit to the content bytes leaves the fingerprint column in place, so a re-sweep counts the tampered message as already filed and never rewrites it. Nor does it hold for drawers written throughremember/ the API, which have no source and carry a unique append index instead — re-saving those creates a new drawer beside the tampered one rather than replacing it. Restore from backup is the only verbatim fix for both. - Housekeeping after a clean restore:
undercroft repair --vault <vault> # backfill fingerprints, re-embed every drawer + drop PQ/IVF (a served embedder receives the corpus, recorded as egress/embed/repair), vacuum, re-verify
Only return the server to read-write once verify is clean.
5. Prevent (before the next time)
- Back up on a schedule.
undercroft backup create --vault <vault>is the recovery path above; without a good backup, a verbatim restore isn’t possible. Only the ten most recent snapshots per vault are kept — older ones are pruned on each create, so a schedule needs its own off-box retention. - Lock down the store.
master.keyshould be0600and the vault directory0700(owner-only). Anything that can write the vault DB out-of-band can tamper; anything that can readmaster.keycan forge. - Back up the key material too.
backup createcopies the vault, notmaster.keyorkdf.salt, and a backup opens only under this installation’s key. Never delete either file to silence a message: the engine refuses a key source the installation contradicts rather than writing a new key (ROADMAP O204), and an installation an older release left holding both files may have vaults under each. - Add OS-level file-integrity monitoring (auditd / a tripwire) on the vault directory — Undercroft catches tamper on read; FIM catches the write.
- Keep telemetry alerting on.
PalaceTamperDetectedfires within a scrape interval — that early signal is the point. - Use per-vault assertions for multi-tenant deployments so a compromised client can’t reach another tenant’s vault.
- Witness the chain off-machine, on a cadence (ROADMAP O245). The one
rollback the anchor cannot see is a genuine earlier
(vault.db, vault.json)pair restored together —verifyis green on it by construction.undercroft witness emit --out <file>(orGET /v1/vaults/{id}/witnesson a served vault) writes a small document that binds the audit row count and an unkeyed digest over the rows’ preserved bytes; keep it somewhere the machine cannot write (a commit in another repository, an append-only log, mail to yourself — never under the data directory, which a backup restores with the vault), one file per emit, and check before you emit:undercroft witness check <file>(orPOST …/witnesswith the document as the body) answersWITNESS OKwhen the chain extends it andWITNESS FAILED, exit 2, when the vault has been rolled back below it. A key rotation does not fail the check — it retires the head’s corroboration and the check says so. Sign with--signand thebundle sign-keygenidentity if others can write to the witness store, and keep that key off the machine too, since it defaults to living besidemaster.key.
The guarantee
Tamper-evidence only works if the alarm is trustworthy — so Undercroft only ever
raises it on a real HMAC-verify failure. There are no synthetic or demo
tamper alarms anywhere in the system: metrics, the live event stream, and the
Palace Monitor beacon all read the same hmac_verify_failures signal.