Files
windy-git/api/app/routes/webhooks.py
Grant Whitmer 5dd914b8a6
All checks were successful
check / gate (push) Successful in 18s
G3.5: answer the reachability probe honestly instead of skipping validation
Eternitas verifies a webhook URL answers BEFORE issuing the secret that signs
deliveries, so the very first request can never carry a signature — refusing it
makes registration impossible. Real chicken-and-egg, not a reason to disable
validation.

A probe is a request claiming no event and carrying no signature. Answering it
200 is honest: the endpoint exists and is ready. It changes nothing (acted:
false), and anything claiming to BE an event still goes through full HMAC
verification. Registering with skip_validation:true would have permanently
disabled a safety check to solve a one-time ordering problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:33:56 -04:00

162 lines
6.4 KiB
Python

"""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()
# Reachability probe. Eternitas verifies a webhook URL answers BEFORE it
# issues the secret that signs deliveries — so the first request can never
# carry a signature, and refusing it makes registration impossible. That is
# a real chicken-and-egg, not a reason to disable validation.
#
# A probe is a request claiming to be no event and carrying no signature.
# Answering it 200 is honest: the endpoint exists and is ready. It changes
# NOTHING — `acted: false` — and anything that claims to be an event still
# goes through full verification below. The alternative, registering with
# `skip_validation: true`, would permanently disable a safety check to
# solve a one-time ordering problem.
if not x_eternitas_event and not x_eternitas_signature:
return {
"ready": True,
"acted": False,
"detail": "reachability probe acknowledged; signed events are verified",
}
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()},
}