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 againstbytes.fromhex(env["from_pubkey"])— case-insensitive, so an uppercase key verifies. - Membership (
team_mesh.verify_envelope) lowercases into a localfrm = str(env["from_pubkey"]).lower()and resolvestm.member(ws, frm)(the manifest stores lowercase) — so an uppercase key resolves to the member. Replay protection keys onenvelope_id, not pubkey. - BUT
verify_envelopenever rewritesenv["from_pubkey"], so the raw (uppercase) string reaches_handle_incoming_response, which storesentry["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 lowercasefrom_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_envelopeexists 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
denialsbucket shares the same de-dup. The self-approval exclusion exists only on the request-sending path (ownis filtered from eligible approvers when the request is minted); the collector_handle_incoming_responsehas 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 hardenedverify_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.