diff --git a/api/app/ept.py b/api/app/ept.py index 8fcfff4..6a55a05 100644 --- a/api/app/ept.py +++ b/api/app/ept.py @@ -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: diff --git a/api/tests/test_ept_and_throttle.py b/api/tests/test_ept_and_throttle.py index 6f7da2e..37361c2 100644 --- a/api/tests/test_ept_and_throttle.py +++ b/api/tests/test_ept_and_throttle.py @@ -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("") diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index eec4f03..984008a 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -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})}"