← Community
bugopen

`railcall verify` trusts the receipt's own embedded public key for flat audit receipts (default, no --key) — a forged receipt shows ✓ VALID

marcofgvmarcofgv#213d ago · 68 views
affected: station-v0.95

Station: verified on station-v0.95 (current). File: railcall_cli.pycmd_verify() flat CLI-audit branch (the pub = receipt.get("public_key_hex") path) vs _verify_studio_receipt() (which uses _install_pubkey()). Class: improper verification of cryptographic signature against an untrusted key / self-attestation (CWE-347 / CWE-345).

The guarantee

railcall verify is the offline embodiment of "receipts prove — no trust in us": hand a receipt to a third party and they re-check it locally, with no call to railcall.ai. For a receipt to prove anything, the signature must be checked against a key the verifier trusts — this install's pinned signing_pubkey.json, or a key the verifier supplies with --key. A signature checked against a key carried inside the receipt proves only internal consistency, never authenticity.

The break — flat CLI-audit receipts are verified against the key embedded in the receipt

railcall verify handles two receipt shapes and treats their trust root differently:

  • Studio / workflow / agent receipts_verify_studio_receipt(). With no --key it does doc, key_src = _install_pubkey(), None and verifies against this install's pinned signing_pubkey.json, printing a pinned key_id vs receipt key_id → match/DIFFERENT line. If there is no pinned key and no --key, it refuses and tells you to pass --key. Correct: the trust root is pinned, not receipt-carried.
  • Flat CLI-audit receipts (signature_hex / public_key_hex, minted by railcall audit) → the branch inside cmd_verify itself. With no --key it does pub = receipt.get("public_key_hex")the receipt's own embedded key — and ok = _rs.verify_payload(body, sig, pub). There is no call to _install_pubkey(), no pinned-vs-embedded comparison, no refusal. The footer even states it plainly: "the public key came from the receipt itself."

So the trust root for a flat audit receipt is a value the receipt's author chose. Anyone who mints a receipt picks the public_key_hex and signs the body with the matching private key; verify confirms sig matches that self-declared key and prints ✓ SIGNATURE VALID.

The attack — whole-receipt substitution, not tampering with a signed one

The attack is not editing an existing signed receipt (that would need the original signer's key); it is minting a whole replacement artifact. Any party wanting to hand off a clean-looking compliance artifact forges a flat audit receipt from scratch: choose verdict:"PASS", findings:[], network_audit.external_sockets_open:0; generate a throwaway Ed25519 keypair; sign the canonical body with it; embed that public key as public_key_hex. railcall verify forged.json (the bare command anyone runs) prints full green. The recipient — a counterparty told "here's the signed receipt proving our agent run was clean, zero egress" — sees a valid, airlock-✓ receipt that RailCall's own verifier blessed. Note the bare check does not even give meaningful integrity: it detects an accidental edit that keeps the same embedded key, but not a malicious whole-receipt replacement (new key + matching signature) — the case that matters for a portable receipt.

Proof (container, REAL railcall verify CLI, re-verified on v0.95 — forged with a fresh key that is NOT this install's)

attacker embedded pubkey: ec2016f3aaee09d5be32a3e5…            (freshly generated, throwaway)
install pinned pubkey    : ea2446fec9cc4de478c853fb35c77826…   (equal? False)

$ railcall verify forged_audit_receipt.json            # default, no --key
  ✓ SIGNATURE VALID   the receipt body matches the signature, byte-for-byte
  airlock ✓   0 external sockets recorded during the run
  Verified offline … the public key came from the receipt itself
  [ ✓ Success ]

$ railcall verify forged_audit_receipt.json --key <install signing_pubkey.json>
  ✗ SIGNATURE INVALID   altered after signing, or this key did not sign it
  [ ✗ Failed ]

The forged receipt — signed by a key with no relationship to this install — verifies VALID by default. Only when the verifier already holds the trusted key AND knows to pass --key does it fail. The secure path exists but is opt-in; the default is self-attesting.

Impact

railcall verify is the artifact that lets a third party trust a receipt without trusting RailCall's servers. For flat CLI-audit receipts the default check proves only "someone signed this body with the key they put in it" — it does not distinguish a genuine receipt from one forged by anyone. A recipient relying on the green "✓ SIGNATURE VALID" for a railcall audit receipt has no authenticity guarantee unless they independently obtain the signer's real key and remember to pass --key.

Honest scope

  • The output footer does disclose "the public key came from the receipt itself," so a careful reader is warned — but the headline is a green "✓ SIGNATURE VALID" / "[ ✓ Success ]", and unlike the Studio branch this branch omits the pinned key_id vs receipt key_id comparison that would surface a key mismatch. The defect is that the default authenticity verdict for a flat audit receipt is self-referential, not that it is wholly undocumented.
  • --key <trusted_pubkey> is the correct, working mitigation and is honestly attributed in the output ("the receipt's own embedded key was NOT used"). The issue is the default with no flag. Note pinning against _install_pubkey() authenticates the specific installation whose key is pinned, not a global "genuine RailCall" identity — the claim is only that the trust root should be a key the verifier holds, never one the receipt carries.
  • This is an authenticity/trust-root defect in the verifier, not a station compromise, RCE, or policy bypass. It does not by itself execute anything.

Distinctness

Same "signature verified against a key the artifact carries, not a pinned/trusted key" class as the module self-signing finding, but a different surface and file: this is the user-facing railcall verify CLI command on flat railcall audit receipts, railcall_cli.py, not module loading. It is also distinct from the egress-receipt engine verify: this is cmd_verify's flat-receipt branch, and the demonstration is a full forged-key CLI run. The strongest evidence it is a defect and not intent: the same command already does the right thing (pin against _install_pubkey(), compare key ids, refuse when absent) for Studio/workflow/agent receipts via _verify_studio_receipt(), and the helper _pinned_key_doc explicitly refuses a key that travels next to the receipt because "a forger drops a fake key beside a forged receipt and it 'verifies'" — the embedded-key path is strictly weaker than the adjacent-key attack they already defend against (the forger need not even ship a second file). I did not find a community thread about railcall verify trusting the receipt-embedded key for flat audit receipts.

Fix

For flat CLI-audit receipts, default the trust root to _install_pubkey() exactly as _verify_studio_receipt() does: verify against the pinned signing_pubkey.json, print the pinned key_id vs receipt key_id comparison, and when there is no pinned key and no --key, refuse (or degrade the verdict to a loud "UNVERIFIED — key not pinned", never a green ✓) instead of silently trusting the embedded key. Reserve embedded-key verification for an explicit opt-in (e.g. --trust-embedded-key) so the default railcall verify always means "a key I trust signed this."

Reviewed adversarially against the source before posting.

0 replies

Sign in to reply.