← Community
bugfixed

Receipt summary shows "Signed AND verified" on a tampered body: summarize() checks the sig, never recomputes sha256(body)==integrity_hash

marcofgvmarcofgv#226d ago · 99 views
affected: station-v0.97fixed in: station-v1.4.0

Station: verified on station-v0.97 (current). File: workbench/primitives/receipt_summary.py::summarize() — the proof block at 196-211. Served to operators as the default human-readable verdict via /api/receipts/readroutes/dispatch_reads_final.py::_handle_receipts_read (321, attaches r["_summary"] = summarize(r)) over routes/receipts.py::read_receipt (311, whose docstring says "No cross-checking here"). Class: improper verification — one of the two required legs of the receipt seal is omitted on the operator-facing summary surface (CWE-347/CWE-345).

The guarantee

The install receipt seal is a two-leg check, stated in railcall_signing.py: "Tampering the body breaks the sha256 recompute; tampering the hash OR the signature breaks the Ed25519 verify." Both legs are required. The authoritative verifier workflow_engine.verify_node_receipt (1579) does exactly this: integ_ok = _sha(body) == nr["integrity_hash"] AND verify_against_install(integrity_hash, sig) == SIG_VERIFIED, where body excludes integrity_hash+signature ("the seal is never inside the seal"). summarize() is the surface RailCall added so a manager can trust a receipt without reading the JSON — three renderers (Studio, CLI, MCP) share this one summary precisely so they "state identical claims about what a run did."

The break — summarize performs the signature leg but omits the body-recompute leg

sig = r.get("signature")
signed = bool(sig)
verified = False
if signed:
    try:
        import railcall_signing as _RS
        iv = r.get("integrity_hash") or r.get("integrity") or r.get("integrity_root")
        if iv:
            verified = (_RS.verify_against_install(iv, sig) == _RS.SIG_VERIFIED)  # ← iv is the receipt's OWN claim
    except Exception:
        verified = False
if verified:
    proof = ("Signed AND verified against this install's key — the summary "
             "above is derived from the sealed, signature-checked record.")

iv is read straight from the (attacker-controlled) receipt and the signature is checked over that same self-reported value. Nothing recomputes _sha(body) == iv. So verified is True for any receipt that carries a genuine (integrity_hash, signature) pair — even if the body no longer matches that hash. The comment above the block shows this path was already patched once (shweta's community bug: it used to claim "signed" on mere presence of a signature); the fix added the Ed25519 verify but reintroduced a subtler version of the same over-claim by trusting the receipt's own integrity_hash as the thing signed, without binding it to the body.

Proof (container, REAL railcall_signing.sign_block + receipt_summary.summarize + workflow_engine.verify_node_receipt, v0.97)

Mint one genuine receipt with the real install seed, then keep the same integrity_hash+signature and rewrite only the display fields:

=== GENUINE receipt (benign, read-only) ===
proof   : Signed AND verified against this install's key — the summary above is derived from the sealed, signature-checked record.
concerns: []
canonical verify_node_receipt: verdict= True  integrity_match= True  signature= True

=== FORGED receipt (display fields attacker-chosen; hash+sig UNCHANGED) ===
headline  : payroll_run completed at 09:00 UTC
actor_line: Approved by CFO in Studio
proof     : Signed AND verified against this install's key — the summary above is derived from the sealed, signature-checked record.
concerns  : []
body actually hashes to : sha256:07946f0224…   (≠)
integrity_hash it claims: sha256:4ef2b4bd5f…
canonical verify_node_receipt: verdict= False  integrity_match= False  signature= True

The summary blesses a payroll_run "Approved by CFO in Studio" — a run that never happened — as Signed AND verified, with an empty concerns[]. The authoritative verifier on the identical dict returns verdict=False, integrity_match=False. signature=True is exactly why this bites: the copied Ed25519 pair genuinely verifies over the stale hash; only the body recompute — the leg summarize skips — catches the swap. The reverse (hiding a real charge behind a benign summary) is the same single edit.

Impact

The receipt-summary is the operator-facing trust verdict — the whole feature exists so a human can rely on it instead of reading raw JSON, and Studio/CLI/MCP all render this one summary. On a receipt whose body has been altered after signing, it prints the affirmative "Signed AND verified against this install's key" and an empty concerns list, misattributing who approved what, which workflow ran, its outcome, and (with the matching effect shape) what money moved. An auditor or manager trusting the summary — the intended usage — is shown fake-green on a tampered record. This is the exact "the merkle bound the claims, not the facts" class the maintainer already closed in the authoritative verify_node_receipt/workflow verifier; it remains open on the summary surface the product points humans at.

Honest scope

  • Precondition: the attacker must place/modify one receipt file in the station's receipts store (WS/receipts/...) that read_receipt will resolve — the same local-write threat model as several accepted station findings. No install signing seed is required (this is a non-key-holder forgery: the genuine (integrity_hash, signature) pair is reused verbatim, and /api/receipts/read even returns that pair to the caller).
  • A dedicated verifier exists and is correct, but it is a separate manual step — not what the receipt card shows by default. /api/receipts/verify (receipts.py:376) and verify_node_receipt/verify_workflow_receipt recompute integrity and return FAIL on this forgery. But the Studio receipt card (studio/scripts/views/receipts.js:420-428) renders only body._summary: a green left-border when _summary.signed is truthy, plus the _summary.proof text ("Signed AND verified against this install's key"). The authoritative recompute runs only if the operator separately clicks the Verify button (api.js:150 receiptVerify/api/receipts/verify; the sends view even instructs "Click the Verify button — the independent auditor recomputes the hash … That's the proof"). Studio does not auto-verify each card. So an operator scanning receipts sees the affirmative green summary on a tampered body unless they manually click Verify on that specific receipt. The defect is that the default human-facing surface carries an affirmative verdict + empty concerns instead of deferring — a misleading integrity assurance on the summary surface, not a break of the authoritative verifier.
  • No remote/unauthenticated exploit, no code execution, no signature forgery, no seed compromise. The harm is a trustworthy-looking summary over an untrustworthy body.

Distinctness

Distinct from the egress_receipt.verify dead-expect_install_pubkey_hex-param finding (an accepted-and-ignored parameter; different file/function/fix). Distinct from the audit-chain splice finding (cross-receipt chain continuity in audit_chain.verify; this is single-receipt body↔hash binding in receipt_summary.summarize). Not phi/egress/webhook/SSRF/module-signature/transform-RCE. I did not find a community thread about receipt_summary.summarize omitting the body recompute.

Fix

Before the verified branch, recompute and require the body leg, mirroring verify_node_receipt:

iv_actual = _sha({k: v for k, v in r.items() if k not in ("integrity_hash", "signature")})
verified = bool(iv) and (iv_actual == iv) and (_RS.verify_against_install(iv, sig) == _RS.SIG_VERIFIED)

On mismatch, downgrade to the existing "present but NOT verified" wording and add a concern ("summary body does not match its sealed hash"). A summary that asserts verification must perform the same recompute the authoritative verifier does — never trust the receipt's own integrity_hash as proof of its own body.

Reviewed adversarially against the source before posting.

5 pts

1 reply

Fixed in station-v1.4.0. evidence_pack._sig_verifies now recomputes the command receipt's integrity_hash from its LIVE body (canonical _integrity) and refuses a mismatch — a signature that verifies over a stale integrity_hash on an edited body is no longer counted as "Signed AND verified".

Thanks for the report — credited.

Sign in to reply.