Add S3 OCSP status publishing and standalone Rust Axum responder

This commit is contained in:
2026-10-23 09:18:00 +00:00
parent 4ed14b42a7
commit 0384995570
30 changed files with 5274 additions and 5 deletions
+3
View File
@@ -31,6 +31,9 @@ dependencies = [
]
[project.optional-dependencies]
s3 = [
"boto3>=1.34",
]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
+4
View File
@@ -20,6 +20,7 @@ from redis.asyncio import Redis
from .config import Settings, settings
from .logging_config import configure_logging
from .mixin import Kca, KcaMixin
from .ocsp import publisher_mixin_from_settings
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
from .telemetry import (
CountableSessionStore,
@@ -143,6 +144,9 @@ mixins: list[KayaMixin] = [
openapi_mixin,
KcaMixin(kca),
]
ocsp_publisher = publisher_mixin_from_settings(settings, tortoise_mixin.ensure_context)
if ocsp_publisher is not None:
mixins.append(ocsp_publisher)
telemetry = telemetry_from_settings(settings, session_store)
if telemetry is not None:
+14
View File
@@ -185,6 +185,12 @@ class Settings:
otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int
ocsp_s3_bucket: Optional[str]
ocsp_s3_prefix: str
ocsp_s3_region: Optional[str]
ocsp_s3_endpoint: Optional[str]
ocsp_s3_force_path_style: bool
ocsp_publish_interval_seconds: int
@staticmethod
def from_env() -> "Settings":
@@ -231,6 +237,14 @@ class Settings:
otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
),
ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None,
ocsp_s3_prefix=_env("KCA_OCSP_S3_PREFIX", "ocsp").strip("/"),
ocsp_s3_region=os.environ.get("KCA_OCSP_S3_REGION") or None,
ocsp_s3_endpoint=os.environ.get("KCA_OCSP_S3_ENDPOINT") or None,
ocsp_s3_force_path_style=_env_bool("KCA_OCSP_S3_FORCE_PATH_STYLE"),
ocsp_publish_interval_seconds=max(
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
),
)
+13
View File
@@ -298,6 +298,19 @@ class Crl(Model):
return f"Crl(name={self.name!r}, number={self.crl_number})"
class OcspSnapshot(Model):
"""Publication state for one CA's OCSP status JSON object."""
ca_id = fields.UUIDField(pk=True)
dirty = fields.BooleanField(default=True, db_index=True)
published_at = fields.DatetimeField(null=True)
last_error = fields.TextField(null=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "ocsp_snapshot"
class Template(Model):
"""An XCA-style template: subject + extension/validity defaults."""
+258
View File
@@ -0,0 +1,258 @@
"""Per-CA OCSP status snapshots and S3 publication.
KCA is the source of truth for certificate status. This module builds a
compact JSON document per CA and uploads it to an S3-compatible bucket so
that the standalone OCSP responder never needs database access or a CA
private key.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID
from kaya.core import KayaApp, KayaMixin
from .config import Settings, settings
from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
log = logging.getLogger(__name__)
SCHEMA = "kca-ocsp-status/v1"
def _iso(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def object_key(ca_fingerprint_sha256: str) -> str:
"""The S3 key for one CA; the responder derives the same key."""
prefix = settings.ocsp_s3_prefix.strip("/")
name = f"{ca_fingerprint_sha256.lower()}.json"
return f"{prefix}/{name}" if prefix else name
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
"""Build the full OCSP status snapshot for certificates issued by ``ca``."""
certs = await Certificate.filter(vault_id=ca.vault_id, issuer_id=ca.id).order_by("serial_hex") # type: ignore[attr-defined]
revocations = {
row.serial_hex.lower(): row
for row in await CertificateRevocation.filter(ca_id=ca.id)
}
entries = []
for cert in certs:
revocation = revocations.get(cert.serial_hex.lower())
entry: Dict[str, Any] = {
"serial_hex": cert.serial_hex.lower(),
"status": "revoked" if revocation is not None else "valid",
"not_after": _iso(cert.not_after),
}
if revocation is not None:
entry.update(
{
"revoked_at": _iso(revocation.revoked_at),
"reason": revocation.reason,
"invalidity_date": _iso(revocation.invalidity_date),
}
)
entries.append(entry)
return {
"schema": SCHEMA,
"issuer": {
"id": str(ca.id),
"sha256": ca.fingerprint_sha256.lower(),
"subject": cert_subject(ca),
},
"generated_at": _iso(datetime.now(timezone.utc)),
"entries": entries,
}
def cert_subject(ca: Certificate) -> str:
from kca.core import certificates as cert_core
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
def _s3_client() -> Any:
import boto3
from botocore.config import Config
kwargs: Dict[str, Any] = {
"region_name": settings.ocsp_s3_region,
"endpoint_url": settings.ocsp_s3_endpoint,
}
if settings.ocsp_s3_force_path_style:
kwargs["config"] = Config(s3={"addressing_style": "path"})
# boto3's create_client rejects an explicit None region/endpoint in some
# versions, so only pass values that are set.
kwargs = {key: value for key, value in kwargs.items() if value is not None}
return boto3.client("s3", **kwargs)
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
"""Upload one CA snapshot and return its S3 key."""
if not settings.ocsp_s3_bucket:
raise RuntimeError("KCA_OCSP_S3_BUCKET is not configured")
if snapshot is None:
snapshot = await snapshot_for_ca(ca)
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
key = object_key(ca.fingerprint_sha256)
# boto3 is synchronous and credential resolution can block; do both the
# client creation and the PUT outside the event loop.
def upload() -> None:
client = _s3_client()
client.put_object(
Bucket=settings.ocsp_s3_bucket,
Key=key,
Body=body,
ContentType="application/json",
CacheControl="no-store",
)
await asyncio.to_thread(upload)
return key
async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status object as needing a republish."""
if not settings.ocsp_s3_bucket:
return
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
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"])
async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.ocsp_s3_bucket:
return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id)
class OcspPublisherMixin(KayaMixin):
"""Background publisher for dirty OCSP snapshots."""
def __init__(
self,
interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]],
) -> None:
self._interval = max(1, interval_seconds)
self._ensure_context = ensure_context
self._task: Optional[asyncio.Task] = None
self._bootstrapped = False
def apply(self, app: KayaApp) -> None:
# This mixin only owns a background task; no request hooks.
return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.ocsp_s3_bucket:
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
return
try:
import boto3 # noqa: F401
except ImportError as exc:
raise RuntimeError(
"KCA_OCSP_S3_BUCKET is set but boto3 is not installed; "
"install kca-app with the 's3' extra"
) from exc
self._task = loop.create_task(self._poll())
log.info(
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
settings.ocsp_s3_bucket,
settings.ocsp_s3_prefix or "(root)",
self._interval,
)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
async def _poll(self) -> None:
# Let the Tortoise mixin establish its database context before the
# first query. Subsequent failures are retried on the next tick.
await asyncio.sleep(self._interval)
while True:
try:
if not self._bootstrapped:
# Existing CAs may predate the publisher or have lost
# their S3 objects; publish one snapshot for every CA on
# the first successful tick.
await self._ensure_context()
for ca in await Certificate.filter(is_ca=True):
await mark_ocsp_dirty(ca.id)
self._bootstrapped = True
await self.run_once()
except asyncio.CancelledError:
raise
except Exception:
log.exception("OCSP snapshot publisher iteration failed")
await asyncio.sleep(self._interval)
async def run_once(self) -> None:
# The publisher runs outside an HTTP request task, so it must bind
# Tortoise's per-loop context before querying models.
await self._ensure_context()
rows = await OcspSnapshot.filter(dirty=True).limit(25)
for row in rows:
ca = await Certificate.get_or_none(id=row.ca_id)
if ca is None:
row.dirty = False
row.last_error = "CA certificate not found"
await row.save(update_fields=["dirty", "last_error", "updated_at"])
continue
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"]
)
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
row.last_error = str(exc)[:2000]
await row.save(update_fields=["dirty", "last_error", "updated_at"])
log.warning("OCSP snapshot publish failed ca=%s: %s", ca.id, exc)
def publisher_mixin_from_settings(
settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]:
if not settings_obj.ocsp_s3_bucket:
return None
return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds,
ensure_context,
)
__all__ = [
"OcspPublisherMixin",
"SCHEMA",
"mark_all_cas_dirty",
"mark_ocsp_dirty",
"object_key",
"publish_snapshot",
"publisher_mixin_from_settings",
"snapshot_for_ca",
]
@@ -19,7 +19,8 @@ from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
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, Request, Template
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, OcspSnapshot, Request, Template
from ..ocsp import snapshot_for_ca
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
@@ -136,6 +137,32 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
await send_json(ctx, 200, result)
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ocsp-status")
@operation(summary="OCSP status snapshot for a CA", tags=["certificates"])
@require_auth(kca.oidc)
async def ocsp_status(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
if not cert.is_ca:
await send_error(ctx, 400, "the certificate is not a CA")
return
row = await OcspSnapshot.get_or_none(ca_id=cert.id)
snapshot = await snapshot_for_ca(cert)
await send_json(ctx, 200, {
"enabled": bool(settings.ocsp_s3_bucket),
"bucket": settings.ocsp_s3_bucket,
"prefix": settings.ocsp_s3_prefix,
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
"published_at": row.published_at.isoformat() if row and row.published_at else None,
"last_error": row.last_error if row else None,
"snapshot": snapshot,
})
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Certificate detail", tags=["certificates"])
@require_auth(kca.oidc)
@@ -22,6 +22,7 @@ from kca.core.imports import ParsedItem
from kca.core.names import DnEntry
from .config import settings
from .ocsp import mark_all_cas_dirty, mark_ocsp_dirty
from .models import (
ApiToken,
Authority,
@@ -807,14 +808,19 @@ async def issue_certificate(
request_obj.signed_at = datetime.now(timezone.utc)
await request_obj.save(update_fields=["signed", "signed_at"])
await relink_certificates(vault)
if signer is not None:
await mark_ocsp_dirty(signer.id)
return row
async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None:
issuer_id = cert.issuer_id
await Certificate.filter(issuer_id=cert.id).update(issuer_id=None)
await Crl.filter(issuer_id=cert.id).update(issuer_id=None)
await CertificateRevocation.filter(ca_id=cert.id).delete()
await cert.delete()
if issuer_id is not None:
await mark_ocsp_dirty(issuer_id)
async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
@@ -946,6 +952,8 @@ async def renew_certificate(
if payload.get("replace_old"):
await delete_certificate_cascade(vault, cert)
await relink_certificates(vault)
if signer.id != cert.id:
await mark_ocsp_dirty(signer.id)
return row
@@ -973,6 +981,7 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
invalidity_date=parse_datetime(invalidity, "invalidity_date") if invalidity else None,
reason=reason,
)
await mark_ocsp_dirty(ca.id)
return row
@@ -1369,6 +1378,7 @@ async def import_parsed_items(
"crl_number": int(crl.crl_number or 0),
},
)
await mark_all_cas_dirty(vault)
return {"imported": imported, "skipped": skipped}