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 intoenv.- 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, overcanonical_bytes(env)reconstructed from the raw env — so an envelope carrying an UPPERCASEfrom_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).
- Roster where the initiator station holds both
operatorandapproverroles; a request needs quorum=1 (manifest has >=1 other approver, satisfyingrequest_approval'slen(others) >= quorumguard). - Initiator mints
approval_request(normal), then hand-builds ONEapproval_response:from_pubkey=own_pubkey.upper(),decision="approve",decision_sig= sign(decision_bytes(...)), envelope signed with the initiator's key. - Feed it to
_handle_incoming_response.verify_envelopeaccepts; 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