From 587fb05265d189c60fcdf0dafb466c0f85105923 Mon Sep 17 00:00:00 2001 From: Grant Whitmer Date: Thu, 13 Aug 2026 23:18:16 -0400 Subject: [PATCH] =?UTF-8?q?SECURITY:=20enforce=20passport=20status=20?= =?UTF-8?q?=E2=80=94=20revocation=20now=20takes=20effect=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A revoked passport returns HTTP 200, status=revoked, band=unproven, allowed_actions=[] (verified live 2026-08-13). resolve_passport keyed refusal only on HTTP 4xx and band=="untrusted", so it returned band 'unproven' and the agent was seated. Revocation was NOT enforced on the live auth path at all — and now that agent auth actually works, a revoked agent could authenticate and act. Extracts decide_trust(body) -> (band, actions) | raise. Only status=="active" is allowed; revoked/suspended/frozen/unknown all refuse, fail-closed on the field that carries the most consequential fact about an identity. The agent call site turns that into a clean 403 passport_revoked. This is the REAL revocation gate — the token cannot be un-issued, but its standing is re-checked on every request, so revocation takes effect on the next call with no webhook required. The Eternitas webhook remains useful for invalidating locally-issued credentials/grants (G6.3, not built yet), but it was never the primary gate and its being unwired is no longer a live exposure. Behavioral tests: revoked body refused, active accepted, unknown/missing status fails closed. Co-Authored-By: Claude (Fable 5) --- api/app/auth.py | 50 ++++++++++++++++++++++++++++-- api/tests/test_ept_and_throttle.py | 29 +++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/api/app/auth.py b/api/app/auth.py index 2703381..44cbf19 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -73,6 +73,36 @@ BAND_MULTIPLIER: dict[str, float] = { } +class PassportNotInGoodStanding(Exception): + """The passport resolved, but Eternitas does not list it as active + (revoked / suspended / frozen / unknown status).""" + + def __init__(self, passport: str, status: str) -> None: + self.passport = passport + self.status = status + super().__init__(f"{passport} status={status!r}") + + +def decide_trust(body: dict, passport: str) -> tuple[str, tuple[str, ...]]: + """The trust body -> (band, allowed_actions), or refuse. + + THE GATE THAT WAS MISSING. A revoked passport returns HTTP 200 with + `status: revoked`, `band: unproven`, `allowed_actions: []` — verified live + 2026-08-13. The previous code keyed refusal only on HTTP 4xx and on + band=="untrusted", so a revoked agent (200, band unproven) authenticated and + acted normally. Revocation was not enforced on the live path at all; the + webhook that was supposed to be the backup was never the primary gate. + + Only `status == "active"` is allowed. Anything else — including a status + Eternitas invents tomorrow — refuses. Fail-closed on the field that carries + the most consequential fact about an identity. + """ + status = str(body.get("status", "")).lower() + if status != "active": + raise PassportNotInGoodStanding(passport, status or "missing") + return body.get("band", "unproven"), tuple(body.get("allowed_actions", ())) + + async def resolve_passport(settings: Settings, passport: str) -> tuple[str, tuple[str, ...]]: """G3.6 — THE STATUS-CODE LAW. @@ -106,8 +136,9 @@ async def resolve_passport(settings: Settings, passport: str) -> tuple[str, tupl continue last_status = r.status_code if r.status_code == 200: - body = r.json() - return body.get("band", "unproven"), tuple(body.get("allowed_actions", [])) + # decide_trust raises PassportNotInGoodStanding on a non-active + # status; that propagates past the retry loop as a hard refusal. + return decide_trust(r.json(), passport) if r.status_code in (400, 404): # Malformed or not-issued. Refuse immediately — retrying cannot help # and pretending it might is how a soft-allow gets written. @@ -179,7 +210,20 @@ async def get_caller( # EPTs live ~365 days and carry `rev`/`tru` baked in at issuance, so a # year-old `rev: false` proves nothing. Revocation and band come from a # live lookup, every time. - band, actions = await resolve_passport(settings, verified.passport) + try: + band, actions = await resolve_passport(settings, verified.passport) + except PassportNotInGoodStanding as exc: + # The signature is authentic, but the identity is no longer good. + # Revocation takes effect here, live, on the next request — no + # webhook required. That is the honest place for it: the token can't + # be un-issued, but its standing is checked every time. + raise RepairPointer( + status_code=403, + code="passport_revoked", + speak="That helper's access has been turned off.", + machine_cause=f"eternitas status for {verified.passport} is {exc.status!r}, not active", + remediation_tool=None, + ) from exc if band.lower() == "untrusted": raise RepairPointer( status_code=403, diff --git a/api/tests/test_ept_and_throttle.py b/api/tests/test_ept_and_throttle.py index eb9ab80..0c0db09 100644 --- a/api/tests/test_ept_and_throttle.py +++ b/api/tests/test_ept_and_throttle.py @@ -168,3 +168,32 @@ def test_every_throttled_action_has_a_configured_base(): s = Settings() for action, field in ACTION_BASE.items(): assert getattr(s, field) > 0, f"{action} has no positive base rate" + + +# ---- revocation enforced on the live trust path (not just the webhook) ---- +def test_revoked_passport_is_refused_by_trust_decision(): + """A revoked passport returns HTTP 200, status=revoked, band=unproven, + allowed=[] (verified live 2026-08-13). The decision must refuse it — the + old code returned band 'unproven' and seated the agent.""" + from api.app.auth import PassportNotInGoodStanding, decide_trust + + revoked = {"status": "revoked", "band": "unproven", "allowed_actions": []} + with pytest.raises(PassportNotInGoodStanding): + decide_trust(revoked, "ET26-NJQT-QMR0") + + +def test_active_passport_is_accepted_by_trust_decision(): + from api.app.auth import decide_trust + + active = {"status": "active", "band": "gold", "allowed_actions": ["read", "send"]} + band, actions = decide_trust(active, "ET26-1EF9-VJAN") + assert band == "gold" and actions == ("read", "send") + + +def test_unknown_or_missing_status_fails_closed(): + from api.app.auth import PassportNotInGoodStanding, decide_trust + + for body in ({"band": "gold"}, {"status": "suspended"}, {"status": "frozen"}, + {"status": ""}, {}): + with pytest.raises(PassportNotInGoodStanding): + decide_trust(body, "ET26-X")