audit_chain.py is the station's tamper-evident audit log. append() hash-chains
every record and signs it — record_hash over the body, then signature over
that hash. verify() re-walks the file and returns chain_intact, which
/api/audit surfaces to the operator as the "your audit trail is intact" signal.
verify() checks only unkeyed self-consistency: that each record's prev equals
its predecessor's hash, and that _sha(body) == record_hash with signature
explicitly excluded from the hashed body. It never verifies the signature. Nor
does head(). Nor does anything else — audit_chain is referenced in the tree only
by studio_state.audit_log() and station_llm, both of which only append.
Because record_hash is an unkeyed sha256, every input to verify()'s checks is
recomputable by anyone who can write the file. Rewrite a record, recompute its
hash, relink the following record's prev, and the chain verifies. The
per-record signatures are the only artifact that could distinguish a genuine
history from a rewritten one — an attacker without the install seed cannot
produce them — and they are precisely what verify() ignores.
Reproduction steps:
- Append three records through the real append(), passing a signer, so every
record carries a genuine signature:
AC.append(path, {"event": "login", "detail": "operator signed in"}, signing=s)
AC.append(path, {"event": "payout_approved",
"detail": "approved $40,000 wire to ACME"}, signing=s)
AC.append(path, {"event": "logout", "detail": "operator signed out"}, signing=s)
- AC.verify(path) — confirm chained=3, chain_intact=True.
- Acting as an attacker with file write and NO signing key, edit row 2's
content, recompute its hash the same way append() does, and relink row 3:
rows[1]["event"] = "note"
rows[1]["detail"] = "routine maintenance window"
body2 = {k: v for k, v in rows[1].items()
if k not in ("record_hash", "signature")}
rows[1]["record_hash"] = AC._sha(body2)
rows[2]["prev"] = rows[1]["record_hash"]
rows[2]["record_hash"] = AC._sha({k: v for k, v in rows[2].items()
if k not in ("record_hash", "signature")})
Leave the original signature blocks untouched — the attacker cannot forge
new ones.
- Re-run AC.verify(path).
- Separately verify each row's signature over its current record_hash, to show
what a signature check would have reported.
- As a second case, strip the
signaturekey from every row and re-run
verify().
Expected:
A record altered after signing must be detectable. The chain writes a signature
per record specifically so that a rewrite by someone without the install key is
distinguishable from genuine history; verify() should report chain_intact=False
(or at minimum surface a signature verdict) when a record's signature does not
verify over its record_hash, and should not report a fully unsigned chain
identically to a signed one.
Actual:
STEP 1 verify(): chained=3 legacy=0 chain_intact=True first_break=None
row 1: signature OK / row 2: signature OK / row 3: signature OK
STEP 2 original row 2 detail: approved $40,000 wire to ACME
forged row 2 detail: routine maintenance window
verify(): chained=3 legacy=0 chain_intact=True first_break=None
what a signature check WOULD have said:
row 1: signature OK
row 2: SIGNATURE INVALID
row 3: SIGNATURE INVALID
STEP 3 signatures stripped entirely
verify(): chained=3 chain_intact=True first_break=None
The record stating that an operator approved a $40,000 wire now reads "routine
maintenance window", and /api/audit reports the trail intact. Rows 2 and 3 carry
signatures that no longer verify, which verify() never looks at.
Root cause:
audit_chain.py — verify() builds body as every key except record_hash andsignature, then checks rec.get("prev") != prev or _sha(body) != claimed. No
signature verification appears in the function, and signature is never read
anywhere in the module after append() writes it. head() likewise selects the
last record by record_hash alone. Both checks are over unkeyed values the
writer of the file fully controls.
Suggested fix:
Verify the signature as part of the walk, using the recipe railcall_signing
already exposes and other verifiers in this codebase already use:
verdict = railcall_signing.verify_against_install(claimed, rec.get("signature"))
if verdict == railcall_signing.SIG_FAIL:
state["chain_intact"] = False
if state["first_break"] is None:
state["first_break"] = idx
Count SIG_UNSIGNED records separately and report them, so a chain that is
internally consistent but unsigned cannot present as equivalent to a signed one.
Since a chain break and a signature failure are different facts, returning both
(e.g. signatures_verified / signatures_failed alongside chain_intact) is
more useful to /api/audit than folding them together.
Honest scope:
This requires local write access to audit_log.jsonl (0600, same user), which is
the same threat model as other accepted findings against this file's guarantees.
It is not a remote or unauthenticated bypass. What it defeats is precisely the
property the chain exists to provide: the module is titled "tamper-evident
append", and verify()'s own note claims evidence "against in-place edits and
mid-chain deletion", carving out only the install-key holder truncating the
tail. A full relink by a NON-key-holder is an in-place edit, it is inside the
claimed guarantee, and it is not detected.
I am not claiming forged authenticated approvals: the rewritten rows carry
signatures that do not verify, so an auditor who checks signatures — or who
cross-correlates audit rows against signed receipts — will reject them. The
defect is that the station's own verifier, and the operator-facing
chain_intact signal it feeds, do not perform that check.
Counter-evidence checked:
- Confirmed nothing else verifies these signatures: audit_chain is imported only
by studio_state (append) and station_llm (append); signature is written by
append() and read by no one.
- Confirmed the correct recipe exists and is used elsewhere in this codebase, so
this is an omission rather than a missing capability.
- Confirmed the attack needs no key: record_hash is a plain sha256 over the body
and every relink input is attacker-controlled.
- Ran the genuine append() path with real Ed25519 signing (independent ad-hoc
keypair, so no real vault was touched) rather than hand-building records, and
confirmed the signatures verify BEFORE the rewrite and fail after — so the
signature leg is meaningful and only the checking is absent.
- Verified the unsigned-chain case separately, so the finding does not depend on
leaving stale signatures in place.
Distinctness:
This is the signature leg of audit_chain.verify(), not the unchained-row skip.
It is distinct from the reported finding about a hashless row spliced mid-chain
being folded into legacy_unchained without advancing prev: that concerns a
record with NO record_hash and is fixed by treating a post-chain unchained row
as a break; this concerns fully chained records whose hashes are recomputed and
relinked, which that fix would not detect. It is also distinct from the reported
receipt_summary finding (single-receipt body-vs-hash binding) and from the
egress_receipt.verify finding (a different schema and call convention). I did
not find a community thread about audit_chain never verifying its signatures.