G3.5: Eternitas revocation receiver — fail-closed, both signature traps avoided
All checks were successful
check / gate (push) Successful in 18s
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:
143
api/app/routes/webhooks.py
Normal file
143
api/app/routes/webhooks.py
Normal 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()},
|
||||
}
|
||||
Reference in New Issue
Block a user