Read the sender rather than guessing: Eternitas generates the webhook secret at registration time and pings the URL to prove reachability BEFORE returning that secret. The ping IS signed — with a secret the receiver cannot possibly hold yet. Unverifiable by construction, not by oversight. Accepting it is safe because the event is definitionally a no-op: nothing read, nothing written, acted:false. Every event that changes anything still requires a valid HMAC. The alternative, skip_validation:true, would permanently disable reachability checking for this platform to solve a one-time ordering problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
6.7 KiB
Python
170 lines
6.7 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()
|
|
|
|
# `platform.test_ping` is the ONE event accepted without verification, and
|
|
# the reason is structural rather than convenient.
|
|
#
|
|
# Eternitas generates the webhook secret at registration time and pings the
|
|
# URL to prove it is reachable BEFORE returning that secret. The ping is
|
|
# signed — with a secret the receiver cannot possibly hold yet. So the
|
|
# signature is unverifiable by construction, not by oversight.
|
|
#
|
|
# Accepting it is safe because the event is definitionally a no-op: nothing
|
|
# is read, nothing is written, `acted` is false. Every event that changes
|
|
# anything — `passport.revoked` above all — still requires a valid HMAC
|
|
# below. The alternative was `skip_validation: true` at registration, which
|
|
# would permanently disable reachability checking for this platform to
|
|
# solve a one-time ordering problem.
|
|
if x_eternitas_event == "platform.test_ping" or (
|
|
not x_eternitas_event and not x_eternitas_signature
|
|
):
|
|
return {
|
|
"ready": True,
|
|
"acted": False,
|
|
"detail": (
|
|
"reachability ping acknowledged; it is unverifiable by "
|
|
"construction and changes nothing. 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()},
|
|
}
|