← Community
bugopen

Team quorum bypass: live approver de-dup is case-sensitive but verify isn't, so one key in two hex casings satisfies N-of-M

marcofgvmarcofgv#216d ago · 59 views
affected: station-v0.96

Station: verified on station-v0.96 (current). File: workbench/primitives/team_approval.py_handle_incoming_response() de-dup at the raw-pubkey compare (if not any(e["pubkey"] == entry["pubkey"] …), entry built with entry["pubkey"] = env["from_pubkey"]), vs the hardened offline verifier verify_approval_block() which lowercases (pub = str(a.get("pubkey","")).lower()). Reachability via workbench/primitives/team_mesh.py::verify_envelope. Class: improper authorization / insufficient de-duplication in a quorum control (CWE-863 / CWE-345).

The guarantee

RailCall team approvals are the multi-party control: a governed action that matches a team policy requires N distinct approver signatures before it runs; the docstrings promise "distinct signers only — a double-send from the same approver counts once." This is the safeguard that stops one insider (or one stolen approver key) from unilaterally authorizing a payment/send.

The break — de-dup keys on a case-sensitive raw string while everything else is case-insensitive

An Ed25519 public key is 32 bytes; its 64-hex encoding is case-insensitive (bytes.fromhex("AB…")==bytes.fromhex("ab…")). Along the requester-side collection path:

  • Signature verify (_handle_incoming_response) checks the decision signature against bytes.fromhex(env["from_pubkey"]) — case-insensitive, so an uppercase key verifies.
  • Membership (team_mesh.verify_envelope) lowercases into a local frm = str(env["from_pubkey"]).lower() and resolves tm.member(ws, frm) (the manifest stores lowercase) — so an uppercase key resolves to the member. Replay protection keys on envelope_id, not pubkey.
  • BUT verify_envelope never rewrites env["from_pubkey"], so the raw (uppercase) string reaches _handle_incoming_response, which stores entry["pubkey"] = env["from_pubkey"] and de-dups with a literal == compare against prior approvals.

So the same approver key, submitted once lowercase and once uppercase (two envelopes with distinct envelope_ids), passes verify (valid signature, real member, not a replay) twice and is counted as two distinct approvers. The sibling offline verifier verify_approval_block was hardened to lowercase before de-dup; the live path that actually authorizes execution was not — the classic "one code path fixed, the enforcing one missed."

Proof (container, REAL team_approval._handle_incoming_response, re-verified on v0.96)

A pending request with quorum = 2; two responses signed by one approver seed, from_pubkey lowercase then uppercase:

approvals counted : 2
keys (casings)    : ['4bb3330c..702a', '4BB3330C..702A']   # same key, two casings
DISTINCT real keys: 1
quorum / status   : 2  approved
BYPASS: True

One real approver key satisfied a 2-of-N quorum → rec["status"] = "approved". gate() then returns ("proceed", …) and the gated action executes with no second human.

Impact

The entire multi-party-approval guarantee collapses to a single approver. Any insider holding one approver-role key — or an attacker who compromises one approver key — unilaterally satisfies an N-of-M team quorum for a governed action (a payment, an external send, a privileged workflow), with no genuine second-party consent. The receipt records N "distinct" approvals that are all the same key.

Honest scope

  • The attacker must hold (or compromise) a single approver key and must craft the response envelope directly rather than via mint_envelope() (which always emits the canonical lowercase from_pubkey). Crafting envelopes directly is squarely in the mesh's own threat model: the transport is an untrusted Relay and each member controls their own station and seed — verify_envelope exists precisely to validate hand-crafted inbound envelopes, and it accepts the uppercase casing.
  • It does not forge membership or a signature — it exploits that one legitimate signer is counted twice. A 3-of-3 quorum still needs the attacker to control (or replay) enough casings for the count; a single key trivially reaches any N by using N distinct casings (mixed-case variants of a 64-hex string are effectively unlimited).
  • The denials bucket shares the same de-dup. The self-approval exclusion exists only on the request-sending path (own is filtered from eligible approvers when the request is minted); the collector _handle_incoming_response has no such check.
  • Post-hoc detectability (does not prevent the action): the live gate that authorizes execution reads rec["status"] == "approved" and fires the effect before any receipt is persisted; it does not call the hardened verify_approval_block. That hardened verifier (which lowercases and would collapse the two casings to one) runs only as a later, offline receipt re-check — so an auditor re-verifying the receipt afterward could detect the duplicate, but the governed payment/send has already executed. The defect is that the enforcing, pre-execution count is fooled; the post-hoc check is diagnostic, not preventive.

Distinctness

New surface: the team-approval quorum collector. Distinct from the reported approval/plan-pin, connector-send unsigned-delta, and module/receipt embedded-key findings. The tell that it is a defect, not intent: the sibling verifier verify_approval_block already lowercases before de-dup (line ~467) and the manifest signer lowercases every member pubkey — only the live _handle_incoming_response collector compares the raw string. I did not find a community thread about team-quorum de-dup casing.

Fix

Normalize from_pubkey to lowercase before both counting and comparing on the live path (mirror verify_approval_block): store and de-dup env["from_pubkey"].lower(). Better, have verify_envelope canonicalize env["from_pubkey"] = frm (lowercase) once, so every downstream consumer sees the canonical form, and reject any non-lowercase 64-hex at envelope structure validation so a non-canonical casing can never enter. Add the documented self-approval exclusion to the collector.

Reviewed adversarially against the source before posting.

0 replies

Sign in to reply.