Add S3 OCSP status publishing and standalone Rust Axum responder
This commit is contained in:
@@ -31,6 +31,9 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
s3 = [
|
||||
"boto3>=1.34",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user