SECURITY: make EPT routing independent of signature well-formedness
All checks were successful
check / gate (push) Successful in 20s

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>
This commit is contained in:
Grant Whitmer
2026-08-13 23:23:50 -04:00
parent 830ca48705
commit a0ed4a5ec0
3 changed files with 47 additions and 4 deletions

View File

@@ -24,6 +24,8 @@ 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
@@ -110,16 +112,31 @@ def verify_ept(token: str, eternitas_base_url: str) -> VerifiedEpt:
def looks_like_ept(token: str) -> bool:
"""Cheap, unauthenticated triage: is this token even claiming to be an EPT?
"""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:
header = jwt.get_unverified_header(token)
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
return header.get("typ") == "EPT" or header.get("alg") == "ES256"
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:

View File

@@ -260,3 +260,26 @@ async def test_resolve_passport_returns_band_on_active_wiring(monkeypatch):
settings = Settings(eternitas_platform_api_key="x")
band, actions = await resolve_passport(settings, "ET26-1EF9-VJAN")
assert band == "gold" and actions == ("read",)
def test_routing_does_not_depend_on_signature_wellformedness():
"""An EPT-shaped token must route to the EPT verifier even when its
signature is malformed. jwt.get_unverified_header() validates the whole
token and rejects bad signature padding, which used to push such tokens to
the human path — refused for the wrong reason, and readable as a human
identity via `sub` whenever require_verified_jwt was off."""
import base64
import json as _j
from api.app.ept import looks_like_ept
def seg(d):
return base64.urlsafe_b64encode(_j.dumps(d).encode()).rstrip(b"=").decode()
for hdr in ({"alg": "none", "typ": "EPT"}, {"alg": "ES256", "typ": "EPT"},
{"alg": "ES256"}):
tok = f"{seg(hdr)}.{seg({'sub': 'ET26-X'})}.x" # deliberately bad signature
assert looks_like_ept(tok), f"{hdr} did not route to the EPT verifier"
assert not looks_like_ept("not-a-token")
assert not looks_like_ept("")

View File

@@ -670,7 +670,10 @@ async def test_security_forged_agent_token_is_refused_in_production():
eternitas_platform_api_key="x")
req = _fake_request(settings)
for typ, expected in (("JWT", "human_signin_not_ready"), ("EPT", "ept_invalid")):
# Both shapes now route to the EPT verifier, because alg:none is never
# valid for ANY caller — so 401 "your token is bad" is the honest answer,
# not 503 "that feature isn't ready".
for typ, expected in (("JWT", "ept_invalid"), ("EPT", "ept_invalid")):
def seg(d):
return _b64.urlsafe_b64encode(_json.dumps(d).encode()).rstrip(b"=").decode()
forged = (f"{seg({'alg':'none','typ':typ})}"