← Community
bugopen

Team quorum: an uppercase from_pubkey escapes the case-sensitive initiator-exclusion, so the initiator self-approves its request

marcofgvmarcofgv#27d ago · 23 views
affected: station-v1.5.0

Component: primitives/team_approval.py — _handle_incoming_response initiator-exclusion (~L483-486) and decision_bytes (~L106-113); root-cause enabler primitives/team_mesh.py — verify_envelope (~L205).

The defect.
7071ec makes the initiator's own approval never count toward its request's quorum — its comment: the initiator's signature "NEVER counts toward quorum, however it arrives." The tally enforces that with a string compare:

if (decision == "approve"
and env.get("from_pubkey") == rec.get("initiator_pubkey")):
# ...excluded...

rec["initiator_pubkey"] is stored lowercase (the station's own key, lowercased at mint). But env.get("from_pubkey") is the RAW envelope value, and nothing upstream canonicalizes it:

  • verify_envelope (team_mesh.py:205) lowercases only a LOCAL copy — frm = str(env.get("from_pubkey","")).lower() — and validates _HEX64.match(frm); it never writes the lowercased value back into env.
  • membership (tm.member) lowercases the lookup, so an uppercase key still resolves to a member.
  • the signature verifies with bytes.fromhex(env["from_pubkey"]), which is case-insensitive, over canonical_bytes(env) reconstructed from the raw env — so an envelope carrying an UPPERCASE from_pubkey, signed as-is by the initiator, verifies.
  • decision_bytes (the inner decision signature) is {action_hash, request_envelope_id, decision} — it OMITS the pubkey, so the decision_sig verifies unchanged under any casing.

So the initiator crafts one approval_response with from_pubkey = the UPPERCASE hex of its own key, self-signs it (valid), and sends it through the untrusted Relay. It passes verify_envelope, passes membership/role, and at the tally env.get("from_pubkey") == rec.get("initiator_pubkey") is "ABCD…" == "abcd…" -> False -> the exclusion is SKIPPED and the initiator's own approval is counted. With quorum = 1, that single self-approval reaches quorum; the persisted status flips to approved and gate() (re-running verify_approval_block, which has no concept of an initiator) returns ("proceed", block) -> the guarded action executes.

Reproduction (structure).

  1. Roster where the initiator station holds both operator and approver roles; a request needs quorum=1 (manifest has >=1 other approver, satisfying request_approval's len(others) >= quorum guard).
  2. Initiator mints approval_request (normal), then hand-builds ONE approval_response: from_pubkey = own_pubkey.upper(), decision="approve", decision_sig = sign(decision_bytes(...)), envelope signed with the initiator's key.
  3. Feed it to _handle_incoming_response. verify_envelope accepts; the L485 equality is bypassed by the casing; rec["approvals"] reaches quorum=1; gate() returns proceed -> execution with a single, self-supplied approval.

Expected (correct) behavior: the initiator's key never counts toward its own request's quorum in ANY casing — canonicalize from_pubkey to lowercase before the exclusion compare (and, ideally, write the canonical form back into env once in verify_envelope so every downstream compare is on the canonical value).

Scope. A station that is both operator and approver in a team with quorum reachable by one self-approval. Envelope is attacker-authored (Relay untrusted). The offline backstop cannot save it: verify_approval_block only checks signer in approvers and distinct>=quorum, with no initiator concept, so the re-cased self-approval passes it too.

Fix. In the L485 check, compare canonicalized values: env.get("from_pubkey","").lower() == rec.get("initiator_pubkey","").lower(). Better, canonicalize env["from_pubkey"] to lowercase inside verify_envelope (write it back) so the whole pipeline compares one form — the same "one owner for the shape, so the two cannot drift" rule this delta applied to envelope_id.

Classification: CWE-863 / CWE-178

0 replies

Sign in to reply.