SECURITY: close the agent-auth bypass — trust is not authentication
All checks were successful
check / gate (push) Successful in 18s
canary / probe (push) Successful in 11s

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:
Grant Whitmer
2026-08-13 22:49:45 -04:00
parent 83047def94
commit d8deffe4db
2 changed files with 83 additions and 2 deletions

View File

@@ -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(

View File

@@ -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