Allow dedicated Redis for OCSP publication

This commit is contained in:
2026-10-31 20:26:37 +00:00
parent ef64c4a532
commit a101182fdd
10 changed files with 93 additions and 33 deletions
+10 -8
View File
@@ -193,9 +193,10 @@ only as SHA-256 digests, and cannot mint/revoke tokens.
## OCSP responder ## OCSP responder
KCA publishes full per-CA certificate-status snapshots to the same Redis KCA publishes full per-CA certificate-status snapshots to Redis and ships a
instance and logical database used for sessions, and ships a standalone standalone Rust/Axum responder on port `2560`. By default, snapshots use the
Rust/Axum responder on port `2560`. same Redis instance and logical database as sessions; setting
`KCA_OCSP_REDIS_URL` selects a dedicated OCSP instance instead.
- KCA publishes `ocsp:<KCA CA UUID>` whenever certificates are issued, renewed, - KCA publishes `ocsp:<KCA CA UUID>` whenever certificates are issued, renewed,
revoked, deleted or imported. Revocation reasons are included; this is a full revoked, deleted or imported. Revocation reasons are included; this is a full
@@ -217,16 +218,17 @@ Rust/Axum responder on port `2560`.
`release/*` tags as the server image. The responder image is a statically `release/*` tags as the server image. The responder image is a statically
linked musl executable running `FROM scratch` with only the CA bundle. linked musl executable running `FROM scratch` with only the CA bundle.
Configure `REDIS_URL` and the other `KCA_OCSP_*` variables in Configure `REDIS_URL`, optional `KCA_OCSP_REDIS_URL`, and the other
`server/.env.example`. Run the responder with a configuration derived from `KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
`ocsp-responder/responder.example.yaml`, setting each issuer's `ca_id` to its configuration derived from `ocsp-responder/responder.example.yaml`, setting
CA certificate UUID in KCA and using the same Redis URL, or with Compose: each issuer's `ca_id` to its CA certificate UUID in KCA and using the OCSP
Redis URL, or with Compose:
```sh ```sh
docker compose --profile ocsp up --build docker compose --profile ocsp up --build
``` ```
See `ocsp-responder/README.md` for the JSON layout, signer requirements and See `ocsp-responder/README.md` for the CBOR layout, signer requirements and
`openssl ocsp` verification. `openssl ocsp` verification.
## REST API ## REST API
+3 -1
View File
@@ -1,7 +1,9 @@
# KCA — Kubernetes deployment sketch (namespace: kca). # KCA — Kubernetes deployment sketch (namespace: kca).
# #
# Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis # Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis
# stores disposable sessions and expiring OCSP snapshots. KCA_MASTER_KEY is the AES key # stores disposable sessions and expiring OCSP snapshots. Set
# KCA_OCSP_REDIS_URL to publish OCSP snapshots to a separate instance instead.
# KCA_MASTER_KEY is the AES key
# protecting private keys at rest: generate it once and never lose it. # protecting private keys at rest: generate it once and never lose it.
# #
# kubectl apply -f deploy/k8s/kca.yaml # kubectl apply -f deploy/k8s/kca.yaml
+5 -2
View File
@@ -105,7 +105,9 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health} OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health}
# OCSP snapshots use the same Redis instance as sessions, under ocsp:*. # OCSP snapshots default to the session Redis. Set KCA_OCSP_REDIS_URL to
# use a dedicated instance instead.
KCA_OCSP_REDIS_URL: ${KCA_OCSP_REDIS_URL:-}
KCA_OCSP_REDIS_PREFIX: ${KCA_OCSP_REDIS_PREFIX:-ocsp} KCA_OCSP_REDIS_PREFIX: ${KCA_OCSP_REDIS_PREFIX:-ocsp}
KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1} KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1}
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30} KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30}
@@ -114,7 +116,8 @@ services:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
# Optional standalone OCSP responder. It has no database or KCA master-key # Optional standalone OCSP responder. It has no database or KCA master-key
# environment, but reads OCSP keys from the same Redis instance as sessions. # environment, but reads OCSP keys from the configured Redis (the session
# Redis by default).
# Enable with: # Enable with:
# docker compose --profile ocsp up --build # docker compose --profile ocsp up --build
ocsp: ocsp:
+9 -6
View File
@@ -6,16 +6,17 @@ OCSP responses with a delegated responder certificate.
It has **no KCA database access** and no KCA master key. It only needs: It has **no KCA database access** and no KCA master key. It only needs:
- read access to OCSP keys in the same Redis instance and logical database KCA - read access to OCSP keys in the Redis instance selected by KCA (the session
uses for sessions; Redis by default, or a dedicated instance when `KCA_OCSP_REDIS_URL` is set);
- the CA certificate and its KCA CA UUID for each issuer; - the CA certificate and its KCA CA UUID for each issuer;
- a delegated OCSP signer certificate and private key for each CA. The - a delegated OCSP signer certificate and private key for each CA. The
responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8, responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
PKCS#1 RSA, or SEC1 EC PEM form. PKCS#1 RSA, or SEC1 EC PEM form.
Where Redis ACLs are available, restrict the responder's credentials to the Where Redis ACLs are available, restrict the responder's credentials to the
OCSP prefix so it cannot read session entries. KCA needs access to both OCSP prefix so it cannot read session entries. When sharing an instance, KCA
`ocsp:*` and `kaya:session:*`. needs access to both `ocsp:*` and `kaya:session:*`; a dedicated OCSP instance
can use separate credentials.
## Snapshot layout ## Snapshot layout
@@ -30,7 +31,8 @@ its CA certificate row in KCA. This isolates snapshots when the same public CA
certificate exists in multiple vaults. The responder validates both this ID and certificate exists in multiple vaults. The responder validates both this ID and
the CA certificate fingerprint against every downloaded snapshot. the CA certificate fingerprint against every downloaded snapshot.
KCA publishes these keys when `REDIS_URL` is configured. The KCA publishes these keys when either `REDIS_URL` or `KCA_OCSP_REDIS_URL` is
configured, preferring the dedicated OCSP URL when both are present. The
`kca-ocsp-status/v2` payload is encoded with CBOR and contains all certificates `kca-ocsp-status/v2` payload is encoded with CBOR and contains all certificates
issued by that CA, both valid and revoked, without a generation timestamp. issued by that CA, both valid and revoked, without a generation timestamp.
Freshness is carried by the Redis key TTL. The responder never infers status Freshness is carried by the Redis key TTL. The responder never infers status
@@ -41,7 +43,8 @@ coordinated because legacy JSON payloads are rejected until republished.
1. Create a config based on `responder.example.yaml`, setting each issuer's 1. Create a config based on `responder.example.yaml`, setting each issuer's
`ca_id` to its CA certificate UUID in KCA. `ca_id` to its CA certificate UUID in KCA.
2. Set `redis.url` or `OCSP_REDIS_URL` to the same Redis URL used by KCA. 2. Set `redis.url` or `OCSP_REDIS_URL` to the OCSP Redis URL used by KCA (the
session `REDIS_URL` unless `KCA_OCSP_REDIS_URL` overrides it).
3. Put your CA certificates and delegated signer files where the config points. 3. Put your CA certificates and delegated signer files where the config points.
4. Make the signer key readable by the container user (`65532:65532` in the 4. Make the signer key readable by the container user (`65532:65532` in the
published scratch image), or mount it via a Docker secret. published scratch image), or mount it via a Docker secret.
+1 -1
View File
@@ -4,7 +4,7 @@ listen: 0.0.0.0:2560
next_update_seconds: 10 next_update_seconds: 10
redis: redis:
# Same Redis instance and logical database used by KCA sessions. # OCSP Redis used by KCA (the session Redis unless KCA_OCSP_REDIS_URL is set).
# OCSP_REDIS_URL overrides this value at runtime. # OCSP_REDIS_URL overrides this value at runtime.
url: redis://redis:6379/0 url: redis://redis:6379/0
prefix: ocsp prefix: ocsp
+4 -2
View File
@@ -66,9 +66,11 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15 #OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) -------------------------------------- # --- OCSP status publisher (optional) --------------------------------------
# When REDIS_URL is configured, KCA publishes one full CBOR status snapshot # KCA publishes one full CBOR status snapshot per CA. By default it uses the
# per CA to the same Redis instance and logical database used for sessions. # same Redis instance and logical database as sessions. Set KCA_OCSP_REDIS_URL
# to use a dedicated instance instead (also works when REDIS_URL is unset).
# Keys use a separate prefix and expire unless refreshed by the heartbeat. # Keys use a separate prefix and expire unless refreshed by the heartbeat.
#KCA_OCSP_REDIS_URL=redis://localhost:6380/0
KCA_OCSP_REDIS_PREFIX=ocsp KCA_OCSP_REDIS_PREFIX=ocsp
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1 KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat # Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
@@ -167,6 +167,7 @@ class Settings:
app_host: str app_host: str
app_port: int app_port: int
redis_url: Optional[str] redis_url: Optional[str]
ocsp_redis_url: Optional[str]
static_dir: str static_dir: str
logging_config: Optional[str] logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest. #: AES-256-GCM master key protecting private keys at rest.
@@ -195,6 +196,11 @@ class Settings:
ocsp_heartbeat_interval_seconds: int ocsp_heartbeat_interval_seconds: int
ocsp_redis_expiry_seconds: int ocsp_redis_expiry_seconds: int
@property
def ocsp_effective_redis_url(self) -> Optional[str]:
"""Dedicated OCSP Redis when configured, otherwise the session Redis."""
return self.ocsp_redis_url or self.redis_url
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY") raw_master = os.environ.get("KCA_MASTER_KEY")
@@ -226,6 +232,7 @@ class Settings:
app_host=_env("APP_HOST", "0.0.0.0"), app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")), app_port=int(_env("APP_PORT", "8080")),
redis_url=os.environ.get("REDIS_URL") or None, redis_url=os.environ.get("REDIS_URL") or None,
ocsp_redis_url=os.environ.get("KCA_OCSP_REDIS_URL") or None,
static_dir=_env("STATIC_DIR", "web/dist"), static_dir=_env("STATIC_DIR", "web/dist"),
logging_config=os.environ.get("LOGGING_CONFIG") or None, logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key, master_key=master_key,
+13 -11
View File
@@ -1,9 +1,8 @@
"""Per-CA OCSP status snapshots and Redis publication. """Per-CA OCSP status snapshots and Redis publication.
KCA is the source of truth for certificate status. This module builds a KCA is the source of truth for certificate status. This module builds a
compact CBOR document per CA and stores it in the same Redis instance used for compact CBOR document per CA and stores it in the selected Redis instance, so
sessions, so the standalone OCSP responder never needs database access or a CA the standalone OCSP responder never needs database access or a CA private key.
private key.
""" """
from __future__ import annotations from __future__ import annotations
@@ -97,9 +96,9 @@ async def publish_snapshot(
owned_client = None owned_client = None
if client is None: if client is None:
if not settings.redis_url: if not settings.ocsp_effective_redis_url:
raise RuntimeError("REDIS_URL is not configured") raise RuntimeError("REDIS_URL is not configured")
client = Redis.from_url(settings.redis_url) client = Redis.from_url(settings.ocsp_effective_redis_url)
owned_client = client owned_client = client
try: try:
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds) await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
@@ -112,7 +111,7 @@ async def publish_snapshot(
async def mark_ocsp_dirty(ca_id: UUID) -> None: async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status snapshot as needing a republish.""" """Mark one issuing CA's status snapshot as needing a republish."""
if not settings.redis_url: if not settings.ocsp_effective_redis_url:
return return
row = await OcspSnapshot.get_or_none(ca_id=ca_id) row = await OcspSnapshot.get_or_none(ca_id=ca_id)
if row is None: if row is None:
@@ -126,7 +125,7 @@ async def mark_ocsp_dirty(ca_id: UUID) -> None:
async def mark_all_cas_dirty(vault: Vault) -> None: async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.redis_url: if not settings.ocsp_effective_redis_url:
return return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True): for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id) await mark_ocsp_dirty(ca.id)
@@ -155,11 +154,14 @@ class OcspPublisherMixin(KayaMixin):
return None return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None: def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.redis_url: if not settings.ocsp_effective_redis_url:
log.info("OCSP Redis publication disabled (REDIS_URL unset)") log.info(
"OCSP Redis publication disabled "
"(REDIS_URL and KCA_OCSP_REDIS_URL unset)"
)
return return
if self._redis_client is None: if self._redis_client is None:
self._redis_client = Redis.from_url(settings.redis_url) self._redis_client = Redis.from_url(settings.ocsp_effective_redis_url)
self._owns_redis_client = True self._owns_redis_client = True
self._task = loop.create_task(self._poll()) self._task = loop.create_task(self._poll())
log.info( log.info(
@@ -249,7 +251,7 @@ def publisher_mixin_from_settings(
settings_obj: Settings, settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]], ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]: ) -> Optional[OcspPublisherMixin]:
if not settings_obj.redis_url: if not settings_obj.ocsp_effective_redis_url:
return None return None
return OcspPublisherMixin( return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds, settings_obj.ocsp_publish_interval_seconds,
@@ -155,9 +155,9 @@ def register(app: KayaApp, kca: "Kca") -> None:
row = await OcspSnapshot.get_or_none(ca_id=cert.id) row = await OcspSnapshot.get_or_none(ca_id=cert.id)
snapshot = await snapshot_for_ca(cert) snapshot = await snapshot_for_ca(cert)
await send_json(ctx, 200, { await send_json(ctx, 200, {
"enabled": bool(settings.redis_url), "enabled": bool(settings.ocsp_effective_redis_url),
"prefix": settings.ocsp_redis_prefix, "prefix": settings.ocsp_redis_prefix,
"dirty": row.dirty if row else bool(settings.redis_url), "dirty": row.dirty if row else bool(settings.ocsp_effective_redis_url),
"published_at": row.published_at.isoformat() if row and row.published_at else None, "published_at": row.published_at.isoformat() if row and row.published_at else None,
"last_error": row.last_error if row else None, "last_error": row.last_error if row else None,
"snapshot": snapshot, "snapshot": snapshot,
+39
View File
@@ -28,5 +28,44 @@ class OcspExpirySettingsTest(unittest.TestCase):
Settings.from_env() Settings.from_env()
class OcspRedisSettingsTest(unittest.TestCase):
def test_defaults_to_disabled(self) -> None:
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertIsNone(settings.ocsp_redis_url)
self.assertIsNone(settings.ocsp_effective_redis_url)
def test_falls_back_to_session_redis(self) -> None:
with patch.dict(
os.environ, {"REDIS_URL": "redis://sessions:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertEqual(
"redis://sessions:6379/0", settings.ocsp_effective_redis_url
)
def test_uses_dedicated_ocsp_redis(self) -> None:
with patch.dict(
os.environ,
{
"REDIS_URL": "redis://sessions:6379/0",
"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0",
},
clear=True,
):
settings = Settings.from_env()
self.assertEqual("redis://sessions:6379/0", settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
def test_dedicated_ocsp_redis_without_session_redis(self) -> None:
with patch.dict(
os.environ, {"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()