← Community
bugfixed

the hierarchical escalation-approval system (F2, primitives/approval_router.py) accepts an approval_decision from ANY valid signer

ShwetaShweta#137d ago · 54 views
affected: station-v0.71fixed in: station-v0.74

Reproduction steps:

  1. Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
  2. import ed25519_pure as ed; from primitives import approval_router as AR
  3. Generate THREE independent Ed25519 keypairs with os.urandom(32) +

ed.publickey_from_seed(seed) -- simulating three separate stations with
no shared signing state: requester, legit_approver (e.g. "the CEO"),
and attacker (never named anywhere in the flow).

  1. Build a real escalation with AR.build_request(requester_pubkey=...,

approver_pubkey=<legit_approver's pubkey>, action={"command_id":
"stripe.create_refund", ...}, ...), sign it as the requester. Note the
request_id it mints (areq_<utc-compact>_<8hex>).

  1. As the ATTACKER, construct a document matching approval_decision.v1's

shape by hand: same request_id, requester_pubkey copied from the real
request, approver_pubkey = the ATTACKER's own pubkey (not
legit_approver's), decision="approved". Sign it with the attacker's own
seed using the exact recipe approval_router._sign() uses (sha256 of
canonical JSON, then Ed25519 over the hex digest) -- a completely
genuine, valid signature, just from the wrong signer.

  1. Deliver it exactly the way routes/relay.py's kind-dispatcher does for

kind="approval_decision": AR.receive_decision(requester_ws, {"kind":
"approval_decision", "body": forged}) -- no other binding check exists
anywhere between the relay dispatcher and this function (confirmed by
reading routes/relay.py's _default_on_event, which routes straight into
receive_decision with zero extra checks).

  1. Read back what the requester's blocked workflow actually sees:

AR.wait_for_decision(requester_ws, request_id, ...).

Expected: a decision should only be accepted for a given request_id if it
was signed by the SPECIFIC pubkey (legit_approver) that request named as
approver_pubkey. A signature from any other keypair -- however genuinely
valid on its own -- must be rejected.

Actual:
receive_decision() accepted the forged decision: True
wait_for_decision() returned:
{
"schema": "railcall_approval_decision.v1",
"request_id": "areq_20260810T152856Z_7f4e9221",
"requester_pubkey": "<the real requester>",
"approver_pubkey": "<the ATTACKER's pubkey -- NOT who was asked>",
"decision": "approved",
"approver_reason": "looks fine to me",
"decided_at": "2026-08-10T12:00:00Z",
"signature": { "alg": "ed25519", "sig": "<genuinely valid ed25519 sig>", "key_id": "..." }
}
The requester's workflow reads this back as a real, cryptographically
verified "approved" decision and proceeds -- e.g. a refund escalation
"approved" by someone who was never asked and has no relationship to the
approver_pubkey the request named.

Root cause: workbench/primitives/approval_router.py, receive_decision()
(~line 300-317) and the _verify() helper it calls (~line 98-120):

def receive_decision(ws, event):
body = event.get("body") or {}
if body.get("schema") != DECISION_SCHEMA:
return True
approver_pubkey = body.get("approver_pubkey") or "" # <-- SELF-declared
ok, _note = _verify(body, approver_pubkey) # <-- verifies against its OWN claim
if not ok:
return True
p = os.path.join(_decisions_dir(ws), body["request_id"] + ".json")
...

_verify(doc, expected_pubkey_hex) genuinely does check the Ed25519
signature cryptographically -- that part is sound, it really does prove
"this document was signed by the holder of THIS keypair." The defect is
that expected_pubkey_hex is read from the incoming document itself
(body.get("approver_pubkey")) rather than from anything the requester
actually recorded when it sent the original request. There is no
_sent_dir/outgoing-request record anywhere in this module (contrast
_pending_dir/_decisions_dir, which exist) -- push_request() fires the
request over the wire and keeps no local memory of who it was addressed
to, so receive_decision() has architecturally nothing to check the
incoming approver_pubkey against, even if it wanted to. Cryptographic
verification proves authenticity of the SENDER's own claim, never
authorization -- and this is exactly the "signed by A, but is A who was
actually asked?" gap already found and fixed twice elsewhere in this same
station (team_jobs.py's job->worker binding, team_share.py's
capability-grant->holder binding), just not yet checked in this separate,
older (F2, pre-Teams-mesh) approval subsystem. routes/relay.py's dispatcher
(_default_on_event, kind == "approval_decision") adds no additional check
of its own -- it hands the event straight to receive_decision().

Secondary, lower-severity note: the symmetric call on the approver's
side -- receive_incoming() verifying an incoming approval_request against
its own self-declared requester_pubkey -- has the identical structural
pattern, but is far less severe: a forged request just adds a spurious
entry to a human approver's review queue (a human still decides), whereas
a forged decision is consumed programmatically by wait_for_decision()
and unblocks the workflow with no human in the loop at all. The decision
side is the one with real impact.

Aggravating factor: request_id (areq_<UTC-timestamp-no-separators>_<8 hex
chars>) is not treated as a secret anywhere in this flow -- the timestamp
component is trivially inferable (visible in Studio's pending-approval UI,
workflow logs, receipts), leaving only 32 bits of randomness protecting it
from being guessed even without any other leak.

Suggested fix: on the requester side, persist an outgoing record when
push_request() sends the request (mirroring _pending_dir/_decisions_dir --
e.g. a WS/approval_sent/<request_id>.json holding the original
approver_pubkey), and have receive_decision() look up the expected
approver_pubkey for that request_id from that record and reject (ack +
drop, matching the existing bad-signature behavior) any decision whose
approver_pubkey does not match it -- exactly the binding pattern the Teams
mesh fixes already established (rec['to'] / holder_pubkey checks).

5 pts

1 reply

Verified: receive_decision verified an approval_decision's signature against the approver_pubkey carried in the same doc — circular, so any valid signer could approve someone else's escalation and resume their workflow. push_request now records the approver we escalated to; a decision is filed only when it comes from that approver, for a request we actually sent. Confirmed against the code and fixed on the v0.74 batch (verified + regression-tested); ships in station-v0.74. Thanks shweta.

Sign in to reply.
the hierarchical escalation-approval system (F2, primitives/approval_router.py) accepts an approval_decision from ANY valid signer — RailCall Community (Bug)