G3.5: Eternitas revocation receiver — fail-closed, both signature traps avoided
All checks were successful
check / gate (push) Successful in 18s

When a passport is revoked, every credential it holds here dies in one
transaction: tokens revoked, grants revoked. A revocation that takes effect
'eventually' is not a revocation.

Avoids two traps that each cost a sibling service a subscription that looked
wired and never once delivered:
  1. strip the 'sha256=' prefix before comparing — comparing the decorated
     header against a bare digest returns 401 forever
  2. HMAC the RAW REQUEST BYTES, never a re-serialised body — JSON.stringify of
     a parsed body reorders keys and changes whitespace, so the digest never
     matches what the sender signed

Both fail silently from the sender's side: Eternitas records a delivery, the
receiver records a rejection, nobody notices for weeks.

Unset secret REFUSES rather than accepts — accepting unverified instructions
about identity is worse than missing them. And it never acknowledges a
revocation it could not apply; a 200 there is a security hole reporting success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Grant Whitmer
2026-08-12 15:32:27 -04:00
parent fc1937560c
commit 339ec70853
4 changed files with 185 additions and 1 deletions

View File

@@ -46,6 +46,10 @@ class Settings(BaseSettings):
# ---- Eternitas (agent identity + trust) ------------------------------- # ---- Eternitas (agent identity + trust) -------------------------------
eternitas_base_url: str = "https://api.eternitas.ai" eternitas_base_url: str = "https://api.eternitas.ai"
eternitas_platform_api_key: str = "" eternitas_platform_api_key: str = ""
# Signs webhooks Eternitas delivers to us. Unset = we refuse them (I-8):
# accepting unverified instructions about identity is worse than missing them.
eternitas_webhook_secret: str = ""
eternitas_platform_id: str = ""
# ---- account-server OIDC (human identity) ----------------------------- # ---- account-server OIDC (human identity) -----------------------------
account_server_base_url: str = "https://account.windyword.ai" account_server_base_url: str = "https://account.windyword.ai"

View File

@@ -24,7 +24,7 @@ from api.app.providers.registry import (
GiteaProvider, GiteaProvider,
R2Provider, R2Provider,
) )
from api.app.routes import health, repos from api.app.routes import health, repos, webhooks
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -116,6 +116,7 @@ app = FastAPI(
app.include_router(health.router) app.include_router(health.router)
app.include_router(repos.router) app.include_router(repos.router)
app.include_router(webhooks.router)
@app.exception_handler(RepairPointer) @app.exception_handler(RepairPointer)

143
api/app/routes/webhooks.py Normal file
View File

@@ -0,0 +1,143 @@
"""Eternitas webhook receiver (G3.5) — revocation is FAIL-CLOSED.
When a passport is revoked, every credential that passport holds here dies in
one transaction: tokens revoked, grants revoked, in-flight CI cancelled. A
revocation that takes effect "eventually" is not a revocation.
Two traps are avoided here on purpose, both paid for elsewhere in the ecosystem:
1. **Strip the `sha256=` prefix before comparing.** A sibling receiver compared
the whole header against a bare hex digest and therefore returned 401
forever — the subscription looked wired and never once delivered.
2. **HMAC the RAW REQUEST BYTES, not a re-serialised body.** `JSON.stringify` of
a parsed body reorders keys and changes whitespace, so the digest never
matches what the sender signed. Same outcome: deterministic 401.
Both failures are silent from the sender's side — Eternitas records a delivery
attempt, the receiver records a rejection, and nobody notices for weeks.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Header, Request
from sqlalchemy import select, update
from api.app.errors import RepairPointer
from api.app.models.core import AgentToken, Repo, RepoGrant
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
def _verify(raw: bytes, header: str | None, secret: str) -> bool:
if not header or not secret:
return False
# Trap 1: senders prefix the digest. Compare digests, not decorated strings.
presented = header.split("=", 1)[1] if header.startswith("sha256=") else header
# Trap 2: sign the bytes that arrived, never a re-serialised object.
expected = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(presented, expected)
@router.post("/eternitas")
async def eternitas_webhook(
request: Request,
x_eternitas_event: str | None = Header(default=None),
x_eternitas_signature: str | None = Header(default=None),
) -> dict:
settings = request.app.state.settings
raw = await request.body()
secret = settings.eternitas_webhook_secret
if not secret:
# I-8: refuse rather than accept unverified instructions about identity.
raise RepairPointer(
status_code=503,
code="webhook_secret_unset",
speak="We can't accept that update yet.",
machine_cause="ETERNITAS_WEBHOOK_SECRET is unset; refusing unverified webhooks",
remediation_tool=None,
)
if not _verify(raw, x_eternitas_signature, secret):
raise RepairPointer(
status_code=401,
code="webhook_signature_invalid",
speak="We couldn't confirm where that update came from, so we ignored it.",
machine_cause="HMAC mismatch on the raw request body",
remediation_tool=None,
)
payload = await request.json()
event = x_eternitas_event or payload.get("event") or "unknown"
passport = (
payload.get("passport")
or payload.get("passport_number")
or (payload.get("data") or {}).get("passport")
)
if event != "passport.revoked":
# Acknowledge without pretending to have acted. A 200 here means
# "received", and the body says exactly what was done — which is nothing.
log.info("eternitas event %s received (no handler)", event)
return {"received": True, "event": event, "acted": False}
if not passport:
raise RepairPointer(
status_code=422,
code="revocation_missing_passport",
speak="That update didn't say which helper it was about.",
machine_cause=f"passport.revoked payload carried no passport field: {list(payload)}",
remediation_tool=None,
)
maker = getattr(request.app.state, "sessionmaker", None)
if maker is None:
raise RepairPointer(
status_code=503,
code="database_unavailable",
speak="We couldn't apply that update. Please try again.",
machine_cause="no database sessionmaker; refusing to acknowledge a revocation we did not apply",
remediation_tool=None,
)
now = datetime.now(UTC)
async with maker() as session:
# One transaction. A partial revocation is a security hole that reports
# success.
tokens = await session.execute(
update(AgentToken)
.where(AgentToken.passport == passport, AgentToken.revoked_at.is_(None))
.values(revoked_at=now, revoked_reason="eternitas:passport.revoked")
)
grants = await session.execute(
update(RepoGrant)
.where(RepoGrant.grantee_passport == passport, RepoGrant.revoked_at.is_(None))
.values(revoked_at=now)
)
owned = (
await session.execute(select(Repo.id).where(Repo.passport == passport))
).scalars().all()
await session.commit()
log.warning(
"passport %s revoked: %s tokens, %s grants, %s owned repos",
passport, tokens.rowcount, grants.rowcount, len(owned),
)
return {
"received": True,
"event": event,
"acted": True,
"passport": passport,
"tokens_revoked": tokens.rowcount,
"grants_revoked": grants.rowcount,
"owned_repos": len(owned),
# state_proof so the caller can verify rather than trust (section 0.6).
"state_proof": {"revoked_at": now.isoformat()},
}

View File

@@ -547,3 +547,39 @@ def test_g76_alert_path_sets_a_user_agent():
src = (ROOT / "scripts" / "canary.py").read_text() src = (ROOT / "scripts" / "canary.py").read_text()
send = src[src.index("def send_alert") : src.index("def main(")] send = src[src.index("def send_alert") : src.index("def main(")]
assert "User-Agent" in send assert "User-Agent" in send
# --------------------------------------------------------------------------
# G3.5 — revocation is fail-closed, and the two signature traps
# --------------------------------------------------------------------------
def test_g35_webhook_strips_the_sha256_prefix():
"""A sibling receiver compared the whole 'sha256=<hex>' header against a
bare digest and returned 401 forever — wired, never once delivered."""
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
assert 'startswith("sha256=")' in src
def test_g35_webhook_hmacs_raw_bytes_not_reserialised_json():
"""JSON.stringify of a parsed body reorders keys and changes whitespace, so
the digest never matches what the sender signed. Same silent 401."""
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
verify = src[src.index("def _verify") : src.index("@router.post")]
assert "raw" in verify and "json.dumps" not in verify
def test_g35_unset_secret_refuses_rather_than_accepts():
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
assert "webhook_secret_unset" in src
assert "refusing unverified webhooks" in src
def test_g35_signature_compare_is_constant_time():
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
assert "hmac.compare_digest" in src
def test_g35_revocation_never_acknowledges_what_it_did_not_apply():
"""A 200 on a revocation the receiver could not apply is a security hole
that reports success."""
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
assert "refusing to acknowledge a revocation we did not apply" in src