G3.2/G3.4: real EPT verification + wire the throttle, reopening agent auth
REOPENS the agent path — but only because possession is now actually proven. EPT verification (api/app/ept.py): ES256 against Eternitas's published key set at /.well-known/eternitas-keys. algorithms=["ES256"] makes alg:none and algorithm confusion unrepresentable rather than merely unlikely; issuer and exp are enforced by the library; an unknown kid is refused. Order is deliberate: signature FIRST, trust lookup second. These EPTs live ~365 days and carry rev/tru baked in at issuance, so a year-old "rev: false" proves nothing — revocation and band still come from a live lookup on every request. Found while building it: real EPTs put the passport in the "sub" claim. The old code read "passport"/"sub_passport", which no genuine EPT carries — so real agents were never recognised and ONLY forged tokens ever authenticated. The bypass was not just a hole, it was the only thing that worked. Throttle (api/app/throttle.py): BAND_MULTIPLIER and rate_*_per_day were defined and read by nothing. Now enforced on repo.create and grant.create, counted against agent_actions (one source of truth, not a private counter that drifts from the audit log). Fails CLOSED — a limiter that fails open protects you until the moment something is wrong. Untrusted band is 403 read-only, not 429, because "slow down" would be a lie. Tests: 14 behavioral, signing real ES256 tokens with a locally-generated key so they exercise the crypto path with no network dependency — genuine tokens accepted, and alg:none / foreign key / tampered payload / expired / wrong issuer / unknown kid / missing claims all refused. 74 green. Co-Authored-By: Claude (Fable 5) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import httpx
|
||||
from fastapi import Header, Request
|
||||
|
||||
from api.app.config import Settings
|
||||
from api.app.ept import EptInvalid, looks_like_ept, verify_ept
|
||||
from api.app.errors import RepairPointer, passport_unresolvable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -159,49 +160,37 @@ async def get_caller(
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
|
||||
# --- agent (Eternitas EPT) --------------------------------------------
|
||||
passport = _unverified_claim(token, "passport") or _unverified_claim(token, "sub_passport")
|
||||
if passport:
|
||||
# ⚠️ SECURITY — trust is not authentication.
|
||||
#
|
||||
# A trust lookup answers "is this passport reputable?". It does NOT
|
||||
# answer "does this caller actually hold this passport?". Skipping the
|
||||
# second question is an authentication bypass: anyone who knows a
|
||||
# passport number (they appear in logs, the lockbox and revocation
|
||||
# messages) could present an UNSIGNED token naming it and be treated as
|
||||
# that agent. Verified live 2026-08-13 — a forged `alg:none` token
|
||||
# returned HTTP 200.
|
||||
#
|
||||
# ES256/JWKS verification of the EPT against Eternitas is not built yet
|
||||
# (the G3.2/G9.1 verifier). Until it is, the agent path FAILS CLOSED in
|
||||
# production — exactly as the human path below already does. This is not
|
||||
# a downgrade of the "agents are citizens" design; it is refusing to
|
||||
# seat a citizen whose ID we cannot yet check. It reopens automatically
|
||||
# the moment `verify_ept_signature` exists and this gate consults it.
|
||||
if settings.is_production and settings.require_verified_jwt:
|
||||
# Possession FIRST, reputation second. The signature proves the caller holds
|
||||
# this passport; the trust lookup then says what it may do. Doing only the
|
||||
# second was the 2026-08-13 impersonation bypass.
|
||||
if looks_like_ept(token):
|
||||
try:
|
||||
verified = verify_ept(token, settings.eternitas_base_url)
|
||||
except EptInvalid as exc:
|
||||
raise RepairPointer(
|
||||
status_code=503,
|
||||
code="agent_signin_not_ready",
|
||||
speak="Helper sign-in isn't switched on yet. Nothing you have is affected.",
|
||||
machine_cause=(
|
||||
"EPT signature verification (G3.2/G9.1) is not implemented; "
|
||||
"refusing an unverified agent token in production. A trust "
|
||||
"lookup proves reputation, not possession."
|
||||
),
|
||||
remediation_tool=None,
|
||||
)
|
||||
status_code=401,
|
||||
code="ept_invalid",
|
||||
speak="We couldn't confirm that helper's ID, so we didn't let it in.",
|
||||
machine_cause=f"EPT verification failed: {exc}",
|
||||
remediation_tool="windy_git.reissue_agent_token",
|
||||
) from exc
|
||||
|
||||
band, actions = await resolve_passport(settings, passport)
|
||||
# The token is authentic. It is NOT evidence of current standing: these
|
||||
# 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)
|
||||
if band.lower() == "untrusted":
|
||||
raise RepairPointer(
|
||||
status_code=403,
|
||||
code="agent_read_only",
|
||||
speak="That helper can look, but it isn't allowed to make changes yet.",
|
||||
machine_cause=f"passport {passport} band=untrusted is read-only",
|
||||
machine_cause=f"passport {verified.passport} band=untrusted is read-only",
|
||||
remediation_tool=None,
|
||||
)
|
||||
return Caller(
|
||||
actor_type=ActorType.agent,
|
||||
passport=passport,
|
||||
passport=verified.passport,
|
||||
band=band,
|
||||
allowed_actions=actions,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user