All checks were successful
check / gate (push) Successful in 18s
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>
162 lines
6.4 KiB
Python
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()},
|
|
}
|