SECURITY: close the agent-auth bypass — trust is not authentication
Verified live 2026-08-13: a forged 'alg:none' token naming a passport lifted from the logs returned HTTP 200 as that agent. The agent path read the passport without verifying the EPT signature, asked Eternitas 'is this passport reputable?', and seated the caller on a yes. That answers reputation, not possession — anyone who knows a passport number could impersonate that agent on the public API. The human path already failed closed for exactly this reason (require_verified_jwt). The gate was on the wrong path: it sat AFTER the agent branch returned. The agent path now fails closed too, BEFORE the trust lookup, so a forged token never even reaches Eternitas. Reopens automatically when the ES256/JWKS verifier (G3.2/G9.1) exists and this gate consults it. Adds BEHAVIORAL tests (not string-grep): a forged alg:none token exercised through the real get_caller must raise, not authenticate. This is the test that would have caught the bypass; the suite had 86 source-string assertions and zero that ran the auth decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user