Station: verified on station-v0.95 (current). File: railcall_cli.py — cmd_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--keyit doesdoc, key_src = _install_pubkey(), Noneand verifies against this install's pinned signing_pubkey.json, printing apinned key_id vs receipt key_id → match/DIFFERENTline. 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 byrailcall audit) → the branch insidecmd_verifyitself. With no--keyit doespub = receipt.get("public_key_hex")— the receipt's own embedded key — andok = _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_idcomparison 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.