← Community
bugopen

rotate-key archives the old public key so pre-rotation receipts still verify, but nothing ever reads the archive — verify refuses them and b

ShwetaShweta#127d ago · 101 views
affected: station-v0.99

railcall rotate-key mints a fresh signing keypair and archives the outgoing
public doc to signing_pubkey.prev-<ts>.json. The CLI states the archive's
purpose three times:

help text: "railcall rotate-key mint a fresh Ed25519 signing keypair;
archive the old public key (signing_pubkey.prev-<ts>.json)
so pre-rotation receipts still verify"

rotate-key header comment: "ARCHIVES the outgoing public doc to
signing_pubkey.prev-<ts>.json so receipts signed BEFORE the
rotation still verify … Studio receipts carry a key_id that
verify already matches against the archived doc."

inline: "archive the outgoing PUBLIC doc FIRST so pre-rotation receipts
stay verifiable"

Rotation even refuses to proceed if it cannot write the archive, because
"rotating without an archive would strand receipts signed by the old key."

Nothing reads it. _install_pubkey() — the trust root a Studio receipt is
checked against when --key is absent — opens only signing_pubkey.json from its
trusted directories. There is no prev- lookup and no glob for one, in the CLI or
anywhere in the station tree. The claim that "verify already matches against the
archived doc" is not implemented.

The operator-visible result is worse than a plain failure. After a rotation, an
untouched receipt this machine produced is refused with:

Studio receipt signed by a DIFFERENT key than this install.
receipt key_id <old> vs this install <new>
Verify it on the machine that built it — a receipt's key can't be trusted…

This IS the machine that built it, and the key that verifies it is sitting in
the same directory. The message names neither the archive nor --key.

Reproduction steps:

  1. Point RAILCALL_WS at a scratch workspace (both _keys_workspace and

_install_pubkey honour it).

  1. Publish a signing_pubkey.json for key A and mint a Studio-shaped receipt

whose signature block signs its integrity_hash with A, carrying A's key_id.

  1. railcall verify receipt.json — confirm it passes.
  2. Rotate: archive A's doc to signing_pubkey.prev-<ts>.json and publish a new

signing_pubkey.json for key B. (This is exactly what cmd_rotate_key writes:
the archive, then the new public doc, then the new private seed.)

  1. railcall verify receipt.json again — the same untouched file.
  2. railcall verify receipt.json --key signing_pubkey.prev-<ts>.json.
  3. Check what the default trust root resolves to: _install_pubkey()["key_id"].

Expected:

A receipt this install produced continues to verify after this install rotates
its own key — that is the stated reason the archive is written, and the reason
rotation aborts when it cannot be written. At minimum, when the receipt's
key_id matches an archived doc present in the same workspace, verify should say
so and use it, rather than refusing and directing the operator to a different
machine.

Actual:

STEP 1 pinned key_id 9f5f87bf920ee822 / receipt key_id 9f5f87bf920ee822
verdict: VALID (exit 0)

STEP 2 archived old : signing_pubkey.prev-20260814T154142Z.json
new pinned : 9ee9246dc903bf61
workspace : ['signing_pubkey.json',
'signing_pubkey.prev-20260814T154142Z.json']

STEP 3 $ railcall verify receipt.json
verdict: REFUSED (key mismatch) (exit 1)
│ Studio receipt signed by a DIFFERENT key than this install.
│ receipt key_id <old> vs this install <new>
│ Verify it on the machine that built it — a receipt's key
│ can't be trusted…
[ ✗ Failed ]

STEP 4 $ railcall verify receipt.json --key signing_pubkey.prev-<ts>.json
verdict: VALID (exit 0)
_install_pubkey() -> the NEW key id; it never opens a prev- archive

The archive is usable — passing it explicitly verifies the receipt — it is
simply never consulted, and nothing tells the operator it exists.

The same omission reaches the compliance backup. railcall backup builds its
archive from an explicit allowlist — the receipt directories plus
approval_policy.json, approval_policy_history.jsonl and signing_pubkey.json —
and its own docstring gives the reason for that last entry: "the public signing
key (so receipts verify offline after restore)". No prev- archive is on that
list. So a backup taken after a rotation carries pre-rotation receipts together
with only the post-rotation public key, and none of those receipts can be
verified from the archive by the recipient it was assembled for.

Root cause:

railcall_cli.py — cmd_rotate_key() writes signing_pubkey.prev-<ts>.json;
_install_pubkey() resolves the default trust root by opening only
"signing_pubkey.json" in each trusted directory, and _backup_members() lists
only "signing_pubkey.json" among its wanted files. No code path in the CLI or
the station reads or propagates a prev- archive, so the documented behaviour
("verify already matches against the archived doc") does not exist.

Suggested fix:

Add signing_pubkey.prev-*.json to _backup_members()'s wanted files, so a
compliance archive can verify the receipts it contains.

When a Studio receipt's key_id does not match the pinned key, look for an
archived doc in the SAME trusted directory whose key_id matches, and verify
against it — attributing the trust explicitly in the output, e.g. "verified
against this install's archived key <key_id>, rotated <rotated_at>". This keeps
the existing security property that a key travelling NEXT TO the receipt is
never trusted: a prev- archive is not attacker-supplied, it lives in the same
0600 workspace as the pinned key.

If automatic fallback is not wanted, then the failure message and the help text
must stop promising it: say that pre-rotation receipts require
--key <signing_pubkey.prev-*.json>, and have the mismatch panel name the
archived file it can see in the workspace instead of advising the operator to
go to another machine.

Honest scope:

Not a security bypass and not a signature forgery — if anything this errs
closed. Nothing verifies that should not; the defect is that genuine receipts
stop verifying, and the archive built to prevent that is inert.

The impact is operational and lands at the worst moment: key rotation is what
an operator does after a suspected seed compromise, and it is exactly then that
the full receipt history must remain checkable. After rotating, every
pre-rotation receipt reports failure by default, and the message actively
misdirects — it says to verify on the machine that built it, which is the
machine already being used.

An operator who reads the rotate-key header comment can recover by passing
--key with the archived doc, and I have shown that works. The gap is that the
default path does not, the help says it does, and the failure message does not
mention the option.

Counter-evidence checked:

  • Confirmed the archive is written and that rotation refuses to proceed without

it, so it is intended to be load-bearing, not incidental.

  • Confirmed by grep across BOTH repos that no code reads a prev- archive: the

only mentions in the CLI are the write and the three descriptions of its
purpose, and the station tree has none at all, including no glob or listdir
over signing_pubkey*.

  • Ran the real cmd_verify() from the real CLI (with its companion daemon module

present) rather than reimplementing verification, and captured the actual
operator-facing panel.

  • Verified the archive genuinely verifies the receipt when passed via --key, so

the file's contents are correct and only the lookup is missing.

  • Confirmed the receipt is byte-identical across steps 3 and 4 — the only

variable is which key the verifier chose.

  • Confirmed _backup_members() uses a strict allowlist naming only

"signing_pubkey.json", so the backup omission is by construction and not an
accident of globbing. (Checked the related claim in the same function —
"Secrets are NEVER included: keys.local.json / the signing seed are excluded
by construction" — and it HOLDS: the allowlist genuinely cannot pick up the
vault.)

Distinctness:

This is the rotate-key archive never being consulted by verify. It is distinct
from the reported finding that flat CLI-audit receipts are verified against the
key embedded in the receipt: that concerns the flat branch trusting a
receipt-carried key (too permissive); this concerns the Studio branch ignoring
an archived install key that is not receipt-carried (too restrictive, plus a
documented behaviour that does not exist). The two fixes do not overlap. I did
not find a community thread about the prev- archive.

0 replies

Sign in to reply.