Fix OCSP publication races for revocation changes

This commit is contained in:
2026-10-25 11:19:17 +00:00
parent 44a79f5b0c
commit 404facf8e2
4 changed files with 157 additions and 10 deletions
+21 -9
View File
@@ -130,10 +130,11 @@ async def mark_ocsp_dirty(ca_id: UUID) -> None:
if row is None:
await OcspSnapshot.create(ca_id=ca_id, dirty=True)
return
if not row.dirty:
row.dirty = True
row.last_error = None # type: ignore[assignment]
await row.save(update_fields=["dirty", "last_error", "updated_at"])
# Always save, even when already dirty: updated_at is the publisher's
# optimistic-concurrency token for changes made during an upload.
row.dirty = True
row.last_error = None # type: ignore[assignment]
await row.save(update_fields=["dirty", "last_error", "updated_at"])
async def mark_all_cas_dirty(vault: Vault) -> None:
@@ -220,12 +221,23 @@ class OcspPublisherMixin(KayaMixin):
try:
snapshot = await snapshot_for_ca(ca)
key = await publish_snapshot(ca, snapshot)
row.dirty = False
row.published_at = datetime.now(timezone.utc)
row.last_error = None # type: ignore[assignment]
await row.save(
update_fields=["dirty", "published_at", "last_error", "updated_at"]
published_at = datetime.now(timezone.utc)
cleared = await OcspSnapshot.filter(
ca_id=row.ca_id,
dirty=True,
updated_at=row.updated_at,
).update(
dirty=False,
published_at=published_at,
last_error=None,
updated_at=published_at,
)
if not cleared:
log.info(
"OCSP snapshot changed during publication; leaving dirty ca=%s",
ca.id,
)
continue
log.info("published OCSP snapshot ca=%s key=%s", ca.id, key)
except Exception as exc: # noqa: BLE001 - keep retrying the same row
row.dirty = True
@@ -20,7 +20,7 @@ from ..auth import authenticated_user, require_auth
from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, OcspSnapshot, Request, Template
from ..ocsp import snapshot_for_ca
from ..ocsp import mark_ocsp_dirty, snapshot_for_ca
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
@@ -455,6 +455,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
if not created:
await send_error(ctx, 409, "this serial is already revoked")
return
await mark_ocsp_dirty(cert.id)
await send_json(ctx, 201, await serialize_revocation(row))
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@@ -495,6 +496,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
row.reason = str(body["reason"])
await row.save()
await mark_ocsp_dirty(cert.id)
await send_json(ctx, 200, await serialize_revocation(row))
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@@ -513,6 +515,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
await send_error(ctx, 404, "revocation not found")
return
await row.delete()
await mark_ocsp_dirty(cert.id)
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/crl")
@@ -972,6 +972,7 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
raise ValidationError("the issuer has no private key")
existing = await CertificateRevocation.get_or_none(ca_id=ca.id, serial_hex=cert.serial_hex)
if existing is not None:
await mark_ocsp_dirty(ca.id)
return existing
revoked_at = parse_datetime(payload.get("revoked_at") or datetime.now(timezone.utc).isoformat(), "revoked_at")
invalidity = payload.get("invalidity_date")