diff --git a/api/app/auth.py b/api/app/auth.py index c75932e..9bca0b0 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -159,10 +159,37 @@ async def get_caller( token = authorization.split(" ", 1)[1].strip() # --- agent (Eternitas EPT) -------------------------------------------- - # An EPT names its passport; the trust API is the authority on whether that - # passport may act. We never read a band out of the token itself. 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: + 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, + ) + band, actions = await resolve_passport(settings, passport) if band.lower() == "untrusted": raise RepairPointer( diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index be9a8df..ac02c66 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -10,12 +10,16 @@ happened somewhere in this ecosystem and cost real time. from __future__ import annotations +import base64 as _b64 +import json as _json import re import subprocess import sys +import types as _types from pathlib import Path import pytest +import pytest as _pytest ROOT = Path(__file__).resolve().parents[2] @@ -633,3 +637,53 @@ def test_g09_backup_fails_loudly(): src = (ROOT / "scripts" / "backup.sh").read_text() assert "COMPLETED WITH FAILURES" in src assert "refusing to report a backup that did not happen" in src + + +# -------------------------------------------------------------------------- +# SECURITY (behavioral, not string-grep): the agent path must not authenticate +# an unverified token. Regression guard for the 2026-08-13 forged-token bypass. +# -------------------------------------------------------------------------- + + +def _forged_bearer(passport: str) -> str: + def seg(d): + return _b64.urlsafe_b64encode(_json.dumps(d).encode()).rstrip(b"=").decode() + return f"{seg({'alg':'none','typ':'JWT'})}.{seg({'passport':passport})}.not-a-signature" + + +def _fake_request(settings): + app = _types.SimpleNamespace(state=_types.SimpleNamespace(settings=settings)) + return _types.SimpleNamespace(app=app) + + +@_pytest.mark.asyncio +async def test_security_forged_agent_token_is_refused_in_production(): + """A token with alg:none naming a real passport must NOT authenticate. + This is the exploit that returned HTTP 200 on 2026-08-13, exercised through + the real get_caller decision rather than by grepping for a string.""" + from api.app.auth import get_caller + from api.app.config import Settings + from api.app.errors import RepairPointer + + settings = Settings(environment="production", require_verified_jwt=True, + eternitas_platform_api_key="x", eternitas_base_url="https://api.eternitas.ai") + req = _fake_request(settings) + + with _pytest.raises(RepairPointer) as exc: + await get_caller(req, authorization=f"Bearer {_forged_bearer('ET26-1EF9-VJAN')}", + x_service_token=None) + # Must be refused, and must be refused BEFORE any trust lookup could seat it. + assert exc.value.status_code in (401, 503) + assert exc.value.code == "agent_signin_not_ready" + + +@_pytest.mark.asyncio +async def test_security_no_bearer_is_still_401(): + from api.app.auth import get_caller + from api.app.config import Settings + from api.app.errors import RepairPointer + + req = _fake_request(Settings(environment="production")) + with _pytest.raises(RepairPointer) as exc: + await get_caller(req, authorization=None, x_service_token=None) + assert exc.value.status_code == 401