Files
windy-git/api/app/ept.py
Grant Whitmer a0ed4a5ec0
All checks were successful
check / gate (push) Successful in 20s
SECURITY: make EPT routing independent of signature well-formedness
looks_like_ept used jwt.get_unverified_header, which validates the WHOLE token
and therefore rejects anything with a malformed signature segment. Routing
consequently depended on signature well-formedness: an EPT-shaped token with a
bad signature fell through to the HUMAN path, where it was refused for the wrong
reason and — with require_verified_jwt off (dev) — could have been read as a
human identity via its `sub` claim.

Now the header segment is decoded directly, so routing depends only on what the
token CLAIMS to be; whether it is authentic remains verify_ept's job.

Also routes alg:none to the EPT verifier regardless of typ, since a `none`
token is never valid for any caller. Both forged shapes now return 401
ept_invalid — the honest code — instead of 503 "feature not ready".

Found by noticing a forged EPT returned 503 where the verifier should have
answered 401, rather than accepting "it was refused, close enough".

80 tests green.

Co-Authored-By: Claude (Fable 5) <noreply@anthropic.com>
2026-08-13 23:23:50 -04:00

158 lines
6.0 KiB
Python

"""EPT signature verification (G3.2 / G9.1) — the gate that makes an agent an agent.
Trust is not authentication. A trust lookup answers *"is this passport
reputable?"*; only a signature answers *"does this caller actually hold it?"*.
Skipping the second question was a live impersonation bypass on 2026-08-13 — a
forged `alg:none` token naming a passport read out of the logs returned HTTP 200.
What this module refuses, deliberately and by construction:
* **`alg: none`** — the original exploit. `algorithms=["ES256"]` makes it
unrepresentable rather than merely unlikely.
* **Algorithm confusion.** Only ES256 is accepted. If an attacker presents an
HS256 token, PyJWT will not try to use an EC public key as an HMAC secret,
which is the classic way "verified" JWTs get forged.
* **An unknown `kid`.** The key must be one Eternitas currently publishes.
* **A wrong issuer or an expired token** — checked by the library, not by us.
What it deliberately does NOT decide: whether the agent is *allowed* to act.
The EPT carries `rev` and `tru` claims baked in at issuance, and these tokens
live for a year (observed `exp` ≈ 365 days). A year-old `rev: false` is not
evidence of anything. **Revocation and trust must come from a live lookup**, so
this module returns only identity and the caller re-checks standing.
"""
from __future__ import annotations
import base64
import json
import logging
import time
from dataclasses import dataclass
import httpx
import jwt
from jwt import PyJWKClient
log = logging.getLogger(__name__)
ISSUER = "eternitas.ai"
ALGORITHMS = ["ES256"] # exactly one. Never widen this list.
_jwks_client: PyJWKClient | None = None
_jwks_url: str | None = None
class EptInvalid(Exception):
"""The token is not a valid, currently-signed Eternitas EPT."""
@dataclass(frozen=True)
class VerifiedEpt:
passport: str
operator: str | None
bot_name: str | None
issued_at: int | None
expires_at: int | None
def _client(base_url: str) -> PyJWKClient:
"""One cached JWKS client. PyJWKClient caches keys and refetches on an
unknown kid, so a key rotation heals itself without a redeploy."""
global _jwks_client, _jwks_url
url = f"{base_url.rstrip('/')}/.well-known/eternitas-keys"
if _jwks_client is None or _jwks_url != url:
_jwks_client = PyJWKClient(url, cache_keys=True, lifespan=300)
_jwks_url = url
return _jwks_client
def verify_ept(token: str, eternitas_base_url: str) -> VerifiedEpt:
"""Verify an EPT's signature and claims. Raises EptInvalid on ANY doubt.
There is no partial success and no "probably fine" path: every failure mode
below produces the same refusal, because a caller that cannot prove
possession is indistinguishable from an attacker.
"""
try:
signing_key = _client(eternitas_base_url).get_signing_key_from_jwt(token)
except Exception as exc: # noqa: BLE001 - unknown kid, unreachable JWKS, malformed
raise EptInvalid(f"no usable signing key: {type(exc).__name__}: {exc}") from exc
try:
claims = jwt.decode(
token,
signing_key.key,
algorithms=ALGORITHMS, # ES256 only — closes alg:none and alg confusion
issuer=ISSUER,
options={
"require": ["sub", "iss", "exp"],
"verify_signature": True,
"verify_exp": True,
"verify_iss": True,
},
)
except jwt.PyJWTError as exc:
raise EptInvalid(f"{type(exc).__name__}: {exc}") from exc
# The REAL claim name. Eternitas puts the passport in `sub`; the previous
# code looked for `passport` / `sub_passport`, which no genuine EPT carries —
# so real agents were never recognised and only forged tokens ever "worked".
passport = claims.get("sub")
if not isinstance(passport, str) or not passport.strip():
raise EptInvalid("EPT carried no passport in `sub`")
return VerifiedEpt(
passport=passport,
operator=claims.get("ope"),
bot_name=claims.get("bot"),
issued_at=claims.get("iat"),
expires_at=claims.get("exp"),
)
def looks_like_ept(token: str) -> bool:
"""Cheap, unauthenticated triage: is this token even *claiming* to be an EPT?
Used ONLY to route a token to the right verifier. It decides nothing about
trust — an attacker controls every byte it reads.
Decodes the header segment directly rather than via
`jwt.get_unverified_header`, which validates the whole token structure and
therefore rejects anything with a malformed SIGNATURE. That made routing
depend on signature well-formedness: an EPT-shaped token with a bad
signature fell through to the human path, where it was refused for the wrong
reason ("signing in isn't switched on") and — with `require_verified_jwt`
off — could have been read as a human identity via its `sub` claim.
Routing must depend only on what the token claims to be. Whether it is
authentic is `verify_ept`'s job, and it says no.
"""
try:
head_b64 = token.split(".", 1)[0]
head_b64 += "=" * (-len(head_b64) % 4)
header = json.loads(base64.urlsafe_b64decode(head_b64))
except Exception: # noqa: BLE001
return False
if not isinstance(header, dict):
return False
return header.get("typ") == "EPT" or header.get("alg") in ("ES256", "none")
async def eternitas_reachable(base_url: str) -> bool:
"""Whether the key set can be fetched at all. Used by /health/full so an
unreachable JWKS is reported rather than discovered during an outage."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as c:
r = await c.get(
f"{base_url.rstrip('/')}/.well-known/eternitas-keys",
headers={"User-Agent": "windy-git/1.0"},
)
return r.status_code == 200 and "keys" in r.json()
except Exception: # noqa: BLE001
return False
def seconds_until_expiry(ept: VerifiedEpt) -> int | None:
return None if ept.expires_at is None else int(ept.expires_at - time.time())