← Community
bugfixed

audit_chain.verify() never checks the Ed25519 signature append() writes, so a rewritten audit history reports chain_intact=True

ShwetaShweta#113d ago · 58 views
affected: station-v0.97fixed in: station-v1.3.0

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:

  1. 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)

  1. AC.verify(path) — confirm chained=3, chain_intact=True.
  2. 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.

  1. Re-run AC.verify(path).
  2. Separately verify each row's signature over its current record_hash, to show

what a signature check would have reported.

  1. As a second case, strip the signature key 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 and
signature, 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.

5 pts

1 reply

Fixed in station-v1.3.0. audit_chain.verify() now verifies each record's Ed25519 signature via verify_against_install: a present-but-invalid signature is a hard signature_break (chain_intact=False), an honestly-unsigned legacy record passes. A rewritten history signed by the wrong/no key can no longer report chain_intact=True.

Thanks — credited.

Sign in to reply.