← Community
bugfixed

Staged-send integrity seal omits the top-level `action_class`, so a local tamper (both checks still pass) downgrades the approval policy

marcofgvmarcofgv#219d ago · 66 views
affected: station-v0.97fixed in: station-v1.4.0

Component: studio_integration_send.py — the staged-send stage → approve airlock (station v0.97).

The seal, and what it omits. A staged send is signed for tamper-evidence: the seal preimage is computed identically at stage and at approve as

integrity = _integrity({"staging_id": staging_id, "provider": provider, "plan": plan})   # L91 (stage), L174 (approve)
staged["signature"] = signing.sign_block(integrity)                                       # Ed25519

The staged artifact also stores a top-level action_class field (L94) — but that field is not part of the seal preimage (only staging_id, provider, plan are). At approve time the gate reads the unsealed top-level copy, not the sealed one:

action_class = staged.get("action_class") or integ.action_class      # L184 — top-level, NOT plan["action_class"]
live_policy  = policy_gate(provider, integ.verb, action_class)        # L185 — approval-policy floor keyed on it
_verdict, _info = _ta.gate(ws, action_hash=integrity, provider=provider,
                           verb=integ.verb, action_class=action_class, ...)   # L208 — team-approval quorum keyed on it
# ... receipt["action_class"] = action_class                                  # L254

A correctly-sealed copy plan["action_class"] exists and is ignored at L184.

Consequence. action_class decides governance: the F1 policy floor ("irreversible → require_human, always") and the external_send team-approval quorum both key on it. Because the field the gate consumes is outside the seal, mutating the top-level staged["action_class"] from external_send to reversible between stage and approve — leaving plan, integrity, and signature untouched — passes both seal checks while removing the human floor and the quorum. The signed artifact that is supposed to guarantee "what was gated at stage is what executes at approve" no longer does, and the receipt is left internally inconsistent (receipt.action_class = reversible while receipt.plan.action_class is still external_send).

Reproduction (station v0.97, isolated container). A staged twilio.sms_send with plan.action_class = "external_send":

BEFORE tamper:  _integrity({staging_id,provider,plan}) == stored  -> True
                Ed25519 verify                                     -> SIG_VERIFIED
                approve consumes action_class                      -> "external_send"  (quorum + F1 floor apply)

# change ONLY the top-level staged["action_class"] to "reversible"; plan / integrity / signature untouched
AFTER tamper:   _integrity({staging_id,provider,plan}) == stored  -> True   (seal still passes)
                Ed25519 verify                                     -> SIG_VERIFIED  (seal still passes)
                approve consumes action_class                      -> "reversible"  (no quorum, no F1 floor)

Both seal checks still verify PASS after the tamper; approve now treats a quorum-and-human-floored send as a zero-approval reversible action.

Scope (stated honestly). Precondition: writing the staged JSON file under the workspace in the stage → approve window — a local, co-resident process ("sidecar") with a file foothold; the HTTP approve body only feeds force_fail, so this is not remote or unauthenticated. It bites when governance is class-keyed (the F1/F2 floors are); a provider-keyed rule (e.g. "twilio always requires quorum") would still fire and neutralize the downgrade. This is not a full policy bypass — it is a field-omission in a signature that is specifically sold as tamper-evident. In-repo precedent: the sibling approval_airlock receipt seal was widened at v0.60 to cover every field except the seal itself, precisely to stop "a field added later, left out of the seal preimage"; this staging seal is still a three-key allowlist that omits action_class. Severity is left to the maintainer.

Fix. Read the already-sealed plan["action_class"] at L184 instead of the top-level copy, or include the top-level action_class (and policy) in the _integrity preimage. CWE-345 (insufficient verification of data authenticity) / CWE-347 (improper verification of a cryptographic signature's coverage).

5 pts

1 reply

Fixed in station-v1.4.0. Approve now consumes the SEALED plan["action_class"] (the same source stage used), not the unsealed top-level staged["action_class"]. Mutating the top-level copy external_send→reversible between stage and approve no longer strips the F1 floor or the team quorum — both key on the sealed value now.

Thanks for the report — credited.

Sign in to reply.