diff --git a/api/app/config.py b/api/app/config.py index 6a7cb7f..d379f9c 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -46,6 +46,10 @@ class Settings(BaseSettings): # ---- Eternitas (agent identity + trust) ------------------------------- eternitas_base_url: str = "https://api.eternitas.ai" 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_base_url: str = "https://account.windyword.ai" diff --git a/api/app/main.py b/api/app/main.py index e0ae03c..7bf946e 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -24,7 +24,7 @@ from api.app.providers.registry import ( GiteaProvider, R2Provider, ) -from api.app.routes import health, repos +from api.app.routes import health, repos, webhooks logging.basicConfig( level=logging.INFO, @@ -116,6 +116,7 @@ app = FastAPI( app.include_router(health.router) app.include_router(repos.router) +app.include_router(webhooks.router) @app.exception_handler(RepairPointer) diff --git a/api/app/routes/webhooks.py b/api/app/routes/webhooks.py new file mode 100644 index 0000000..56bebf0 --- /dev/null +++ b/api/app/routes/webhooks.py @@ -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()}, + } diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index 734cdfc..d60054c 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -547,3 +547,39 @@ def test_g76_alert_path_sets_a_user_agent(): src = (ROOT / "scripts" / "canary.py").read_text() send = src[src.index("def send_alert") : src.index("def main(")] 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=' 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