Publish OCSP snapshots through Redis

This commit is contained in:
2026-10-28 20:27:07 +00:00
parent a7cfda4167
commit df98ada177
24 changed files with 634 additions and 2460 deletions
+7 -13
View File
@@ -66,21 +66,15 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) --------------------------------------
# When a bucket is configured, KCA publishes one full JSON status snapshot
# per CA. The standalone OCSP responder reads these objects. Uses the normal
# AWS credential chain (AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY, role, instance profile, ...).
#KCA_OCSP_S3_BUCKET=kca-ocsp
KCA_OCSP_S3_PREFIX=ocsp
#KCA_OCSP_S3_REGION=eu-central-1
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000
KCA_OCSP_S3_FORCE_PATH_STYLE=false
# When REDIS_URL is configured, KCA publishes one full JSON status snapshot
# per CA to the same Redis instance and logical database used for sessions.
# Keys use a separate prefix and expire unless refreshed by the heartbeat.
KCA_OCSP_REDIS_PREFIX=ocsp
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots as a liveness heartbeat. Each object receives
# an Expires header; the responder rejects it after that timestamp. Keep the
# heartbeat comfortably below the object expiry.
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
# comfortably below the key expiry.
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
KCA_OCSP_OBJECT_EXPIRY_SECONDS=60
KCA_OCSP_REDIS_EXPIRY_SECONDS=60
# --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS=
+2 -2
View File
@@ -49,11 +49,11 @@ COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/kca-core/pyproject.toml packages/kca-core/
COPY server/packages/kca-app/pyproject.toml packages/kca-app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package kca-app --extra otel --extra s3 --no-dev
uv sync --frozen --no-install-workspace --package kca-app --extra otel --no-dev
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package kca-app --extra otel --extra s3 --no-dev \
uv sync --locked --no-editable --package kca-app --extra otel --no-dev \
--reinstall-package kca-core --reinstall-package kca-app
COPY server/migrations ./migrations
-3
View File
@@ -31,9 +31,6 @@ dependencies = [
]
[project.optional-dependencies]
s3 = [
"aioboto3>=13.0",
]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
+8 -16
View File
@@ -190,14 +190,10 @@ 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_redis_prefix: str
ocsp_publish_interval_seconds: int
ocsp_heartbeat_interval_seconds: int
ocsp_object_expiry_seconds: int
ocsp_redis_expiry_seconds: int
@staticmethod
def from_env() -> "Settings":
@@ -207,12 +203,12 @@ class Settings:
ocsp_heartbeat_interval_seconds = max(
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
)
ocsp_object_expiry_seconds = max(
1, int(_env("KCA_OCSP_OBJECT_EXPIRY_SECONDS", "60"))
ocsp_redis_expiry_seconds = max(
1, int(_env("KCA_OCSP_REDIS_EXPIRY_SECONDS", "60"))
)
if ocsp_object_expiry_seconds <= ocsp_heartbeat_interval_seconds:
if ocsp_redis_expiry_seconds <= ocsp_heartbeat_interval_seconds:
raise RuntimeError(
"KCA_OCSP_OBJECT_EXPIRY_SECONDS must be greater than "
"KCA_OCSP_REDIS_EXPIRY_SECONDS must be greater than "
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS"
)
return Settings(
@@ -255,16 +251,12 @@ 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_redis_prefix=_env("KCA_OCSP_REDIS_PREFIX", "ocsp").strip("/:"),
ocsp_publish_interval_seconds=max(
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
),
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds,
ocsp_object_expiry_seconds=ocsp_object_expiry_seconds,
ocsp_redis_expiry_seconds=ocsp_redis_expiry_seconds,
)
+50 -59
View File
@@ -1,8 +1,8 @@
"""Per-CA OCSP status snapshots and S3 publication.
"""Per-CA OCSP status snapshots and Redis 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
KCA is the source of truth for certificate status. This module builds a
compact JSON document per CA and stores it in the same Redis instance used for
sessions, so the standalone OCSP responder never needs database access or a CA
private key.
"""
from __future__ import annotations
@@ -15,6 +15,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID
from kaya.core import KayaApp, KayaMixin
from redis.asyncio import Redis
from tortoise.expressions import Q
from .config import Settings, settings
@@ -33,11 +34,11 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
return value.astimezone(timezone.utc).isoformat()
def object_key(ca_id: str) -> str:
"""The S3 key for one CA database row; the responder uses the same CA ID."""
prefix = settings.ocsp_s3_prefix.strip("/")
name = f"{ca_id.lower()}.json"
return f"{prefix}/{name}" if prefix else name
def redis_key(ca_id: str) -> str:
"""The Redis key for one CA database row; the responder uses the same key."""
prefix = settings.ocsp_redis_prefix.strip("/:")
name = ca_id.lower()
return f"{prefix}:{name}" if prefix else name
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
@@ -83,61 +84,49 @@ def cert_subject(ca: Certificate) -> str:
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
def _s3_client() -> Any:
import aioboto3
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"})
# Only pass region and endpoint values that are explicitly configured.
kwargs = {key: value for key, value in kwargs.items() if value is not None}
return aioboto3.Session().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")
async def publish_snapshot(
ca: Certificate,
snapshot: Optional[Dict[str, Any]] = None,
client: Optional[Redis] = None,
) -> str:
"""Store one CA snapshot with an expiry and return its Redis key."""
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(str(ca.id))
key = redis_key(str(ca.id))
async with _s3_client() as client:
await client.put_object(
Bucket=settings.ocsp_s3_bucket,
Key=key,
Body=body,
ContentType="application/json",
CacheControl="no-store",
Expires=datetime.now(timezone.utc)
+ timedelta(seconds=settings.ocsp_object_expiry_seconds),
)
owned_client = None
if client is None:
if not settings.redis_url:
raise RuntimeError("REDIS_URL is not configured")
client = Redis.from_url(settings.redis_url)
owned_client = client
try:
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
finally:
if owned_client is not None:
await owned_client.aclose()
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:
"""Mark one issuing CA's status snapshot as needing a republish."""
if not settings.redis_url:
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
# Always save, even when already dirty: updated_at is the publisher's
# optimistic-concurrency token for changes made during an upload.
# optimistic-concurrency token for changes made during a write.
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:
if not settings.redis_url:
return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id)
@@ -151,10 +140,13 @@ class OcspPublisherMixin(KayaMixin):
interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]],
heartbeat_interval_seconds: int = 30,
redis_client: Optional[Redis] = None,
) -> None:
self._interval = max(1, interval_seconds)
self._heartbeat_interval = max(1, heartbeat_interval_seconds)
self._ensure_context = ensure_context
self._redis_client = redis_client
self._owns_redis_client = False
self._task: Optional[asyncio.Task] = None
self._bootstrapped = False
@@ -163,21 +155,16 @@ class OcspPublisherMixin(KayaMixin):
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)")
if not settings.redis_url:
log.info("OCSP Redis publication disabled (REDIS_URL unset)")
return
try:
import aioboto3 # noqa: F401
except ImportError as exc:
raise RuntimeError(
"KCA_OCSP_S3_BUCKET is set but aioboto3 is not installed; "
"install kca-app with the 's3' extra"
) from exc
if self._redis_client is None:
self._redis_client = Redis.from_url(settings.redis_url)
self._owns_redis_client = True
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)",
"OCSP Redis publication enabled: prefix=%s interval=%ss",
settings.ocsp_redis_prefix or "(root)",
self._interval,
)
@@ -185,6 +172,10 @@ class OcspPublisherMixin(KayaMixin):
if self._task is not None:
self._task.cancel()
self._task = None
if self._owns_redis_client and self._redis_client is not None:
loop.create_task(self._redis_client.aclose())
self._redis_client = None
self._owns_redis_client = False
async def _poll(self) -> None:
# Let the Tortoise mixin establish its database context before the
@@ -194,7 +185,7 @@ class OcspPublisherMixin(KayaMixin):
try:
if not self._bootstrapped:
# Existing CAs may predate the publisher or have lost
# their S3 objects; publish one snapshot for every CA on
# their Redis keys; publish one snapshot for every CA on
# the first successful tick.
await self._ensure_context()
for ca in await Certificate.filter(is_ca=True):
@@ -228,7 +219,7 @@ class OcspPublisherMixin(KayaMixin):
continue
try:
snapshot = await snapshot_for_ca(ca)
key = await publish_snapshot(ca, snapshot)
key = await publish_snapshot(ca, snapshot, client=self._redis_client)
published_at = datetime.now(timezone.utc)
cleared = await OcspSnapshot.filter(
ca_id=row.ca_id,
@@ -258,7 +249,7 @@ def publisher_mixin_from_settings(
settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]:
if not settings_obj.ocsp_s3_bucket:
if not settings_obj.redis_url:
return None
return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds,
@@ -272,8 +263,8 @@ __all__ = [
"SCHEMA",
"mark_all_cas_dirty",
"mark_ocsp_dirty",
"object_key",
"publish_snapshot",
"publisher_mixin_from_settings",
"redis_key",
"snapshot_for_ca",
]
@@ -155,10 +155,9 @@ def register(app: KayaApp, kca: "Kca") -> None:
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),
"enabled": bool(settings.redis_url),
"prefix": settings.ocsp_redis_prefix,
"dirty": row.dirty if row else bool(settings.redis_url),
"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,
+2 -2
View File
@@ -13,14 +13,14 @@ class OcspExpirySettingsTest(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env()
self.assertEqual(30, settings.ocsp_heartbeat_interval_seconds)
self.assertEqual(60, settings.ocsp_object_expiry_seconds)
self.assertEqual(60, settings.ocsp_redis_expiry_seconds)
def test_expiry_must_exceed_heartbeat(self) -> None:
with patch.dict(
os.environ,
{
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30",
"KCA_OCSP_OBJECT_EXPIRY_SECONDS": "30",
"KCA_OCSP_REDIS_EXPIRY_SECONDS": "30",
},
clear=True,
):
+34 -49
View File
@@ -1073,7 +1073,7 @@ class OcspSnapshotTests(unittest.TestCase):
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
leaf_key = await _key(client, vault_id, "dirty leaf")
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
with patch.object(ocsp, "settings", configured):
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1100,7 +1100,7 @@ class OcspSnapshotTests(unittest.TestCase):
leaf = await _cert(
client, vault_id, leaf_key["id"], "Existing Dirty Leaf", signer_id=ca["id"]
)
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
with patch.object(ocsp, "settings", configured):
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1129,7 +1129,7 @@ class OcspSnapshotTests(unittest.TestCase):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "manual dirty ca")
ca = await _cert(client, vault_id, ca_key["id"], "Manual Dirty CA", ca=True)
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
with patch.object(ocsp, "settings", configured):
await OcspSnapshot.create(ca_id=ca["id"], dirty=False)
response = await client.post(
@@ -1177,7 +1177,7 @@ class OcspSnapshotTests(unittest.TestCase):
)
published = []
async def fake_publish(publish_ca, snapshot):
async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot)
if len(published) == 1:
await CertificateRevocation.create(
@@ -1187,9 +1187,9 @@ class OcspSnapshotTests(unittest.TestCase):
reason="keyCompromise",
)
await ocsp.mark_ocsp_dirty(publish_ca.id)
return ocsp.object_key(str(publish_ca.id))
return ocsp.redis_key(str(publish_ca.id))
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
configured = replace(ocsp.settings, redis_url="redis://localhost:6379/0")
with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "publish_snapshot", fake_publish
):
@@ -1222,20 +1222,14 @@ class OcspSnapshotTests(unittest.TestCase):
from kca import ocsp
from kca.models import OcspSnapshot
class FakeS3:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return None
class FakeRedis:
def __init__(self):
self.calls = []
async def put_object(self, **kwargs):
self.calls.append(kwargs)
async def set(self, name, value, ex=None):
self.calls.append({"name": name, "value": value, "ex": ex})
fake = FakeS3()
fake = FakeRedis()
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
@@ -1243,28 +1237,24 @@ class OcspSnapshotTests(unittest.TestCase):
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
configured = replace(
ocsp.settings,
ocsp_s3_bucket="test-bucket",
ocsp_s3_prefix="ocsp",
redis_url="redis://localhost:6379/0",
ocsp_redis_prefix="ocsp",
)
with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "_s3_client", lambda: fake
):
with patch.object(ocsp, "settings", configured):
await ocsp.mark_ocsp_dirty(ca["id"])
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is True
async def ensure_context():
return None
mixin = ocsp.OcspPublisherMixin(1, ensure_context)
mixin = ocsp.OcspPublisherMixin(1, ensure_context, redis_client=fake)
await mixin.run_once()
assert len(fake.calls) == 1
call = fake.calls[0]
assert call["Bucket"] == "test-bucket"
assert call["Key"] == f"ocsp/{ca['id']}.json"
assert b"kca-ocsp-status/v2" in call["Body"]
assert b"generated_at" not in call["Body"]
expires_in = (call["Expires"] - datetime.now(timezone.utc)).total_seconds()
assert 50 < expires_in <= 60
assert call["name"] == f"ocsp:{ca['id']}"
assert b"kca-ocsp-status/v2" in call["value"]
assert b"generated_at" not in call["value"]
assert call["ex"] == 60
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False
assert row.published_at is not None
@@ -1289,7 +1279,7 @@ class OcspSnapshotTests(unittest.TestCase):
)
published = []
async def fake_publish(publish_ca, snapshot):
async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot)
return "key"
@@ -1327,7 +1317,7 @@ class OcspSnapshotTests(unittest.TestCase):
)
published = []
async def fake_publish(publish_ca, snapshot):
async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot)
return "key"
@@ -1351,20 +1341,16 @@ class OcspSnapshotTests(unittest.TestCase):
from kca import ocsp
from kca.models import Certificate, OcspSnapshot
class FakeS3:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return None
class FakeRedis:
def __init__(self):
self.objects = {}
self.expiries = {}
async def put_object(self, **kwargs):
self.objects[kwargs["Key"]] = kwargs["Body"]
async def set(self, name, value, ex=None):
self.objects[name] = value
self.expiries[name] = ex
fake = FakeS3()
fake = FakeRedis()
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
@@ -1388,14 +1374,12 @@ class OcspSnapshotTests(unittest.TestCase):
configured = replace(
ocsp.settings,
ocsp_s3_bucket="test-bucket",
ocsp_s3_prefix="ocsp",
redis_url="redis://localhost:6379/0",
ocsp_redis_prefix="ocsp",
)
with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "_s3_client", lambda: fake
):
with patch.object(ocsp, "settings", configured):
target = await Certificate.get(id=ca["id"])
target_key = await ocsp.publish_snapshot(target)
target_key = await ocsp.publish_snapshot(target, client=fake)
with oidc_user("bob"):
attacker_vault_id = await _vault(client)
@@ -1423,12 +1407,13 @@ class OcspSnapshotTests(unittest.TestCase):
assert len(response.json()["skipped"]) == 1
row = await OcspSnapshot.get(ca_id=imported.id)
assert row.dirty is True
imported_key = await ocsp.publish_snapshot(imported)
imported_key = await ocsp.publish_snapshot(imported, client=fake)
assert target_key == f"ocsp/{ca['id']}.json"
assert imported_key == f"ocsp/{imported.id}.json"
assert target_key == f"ocsp:{ca['id']}"
assert imported_key == f"ocsp:{imported.id}"
assert imported_key != target_key
assert set(fake.objects) == {target_key, imported_key}
assert fake.expiries == {target_key: 60, imported_key: 60}
target_snapshot = json.loads(fake.objects[target_key])
target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]}
assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked"
+92 -999
View File
File diff suppressed because it is too large Load Diff