1 Commits
Author SHA1 Message Date
woggioni a101182fdd Allow dedicated Redis for OCSP publication 2026-10-31 20:26:37 +00:00
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
KCA publishes full per-CA certificate-status snapshots to the same Redis
instance and logical database used for sessions, and ships a standalone
Rust/Axum responder on port `2560`.
KCA publishes full per-CA certificate-status snapshots to Redis and ships a
standalone Rust/Axum responder on port `2560`. By default, snapshots use the
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,
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
linked musl executable running `FROM scratch` with only the CA bundle.
Configure `REDIS_URL` and the other `KCA_OCSP_*` variables in
`server/.env.example`. Run the responder with a configuration derived from
`ocsp-responder/responder.example.yaml`, setting each issuer's `ca_id` to its
CA certificate UUID in KCA and using the same Redis URL, or with Compose:
Configure `REDIS_URL`, optional `KCA_OCSP_REDIS_URL`, and the other
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
configuration derived from `ocsp-responder/responder.example.yaml`, setting
each issuer's `ca_id` to its CA certificate UUID in KCA and using the OCSP
Redis URL, or with Compose:
```sh
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.
## REST API
+3 -1
View File
@@ -1,7 +1,9 @@
# KCA — Kubernetes deployment sketch (namespace: kca).
#
# 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.
#
# 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_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
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_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1}
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30}
@@ -114,7 +116,8 @@ services:
- "127.0.0.1:${APP_PORT:-8080}:8080"
# 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:
# docker compose --profile ocsp up --build
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:
- read access to OCSP keys in the same Redis instance and logical database KCA
uses for sessions;
- read access to OCSP keys in the Redis instance selected by KCA (the session
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;
- 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,
PKCS#1 RSA, or SEC1 EC PEM form.
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:*` and `kaya:session:*`.
OCSP prefix so it cannot read session entries. When sharing an instance, KCA
needs access to both `ocsp:*` and `kaya:session:*`; a dedicated OCSP instance
can use separate credentials.
## 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
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
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
@@ -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
`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.
4. Make the signer key readable by the container user (`65532:65532` in the
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
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.
url: redis://redis:6379/0
prefix: ocsp
+4 -2
View File
@@ -66,9 +66,11 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) --------------------------------------
# When REDIS_URL is configured, KCA publishes one full CBOR status snapshot
# per CA to the same Redis instance and logical database used for sessions.
# KCA publishes one full CBOR status snapshot per CA. By default it uses the
# 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.
#KCA_OCSP_REDIS_URL=redis://localhost:6380/0
KCA_OCSP_REDIS_PREFIX=ocsp
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
@@ -167,6 +167,7 @@ class Settings:
app_host: str
app_port: int
redis_url: Optional[str]
ocsp_redis_url: Optional[str]
static_dir: str
logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest.
@@ -195,6 +196,11 @@ class Settings:
ocsp_heartbeat_interval_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
def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY")
@@ -226,6 +232,7 @@ class Settings:
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
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"),
logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key,
+13 -11
View File
@@ -1,9 +1,8 @@
"""Per-CA OCSP status snapshots and Redis publication.
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
sessions, so the standalone OCSP responder never needs database access or a CA
private key.
compact CBOR document per CA and stores it in the selected Redis instance, so
the standalone OCSP responder never needs database access or a CA private key.
"""
from __future__ import annotations
@@ -97,9 +96,9 @@ async def publish_snapshot(
owned_client = None
if client is None:
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
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
try:
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:
"""Mark one issuing CA's status snapshot as needing a republish."""
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
return
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
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:
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id)
@@ -155,11 +154,14 @@ class OcspPublisherMixin(KayaMixin):
return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.redis_url:
log.info("OCSP Redis publication disabled (REDIS_URL unset)")
if not settings.ocsp_effective_redis_url:
log.info(
"OCSP Redis publication disabled "
"(REDIS_URL and KCA_OCSP_REDIS_URL unset)"
)
return
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._task = loop.create_task(self._poll())
log.info(
@@ -249,7 +251,7 @@ def publisher_mixin_from_settings(
settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]:
if not settings_obj.redis_url:
if not settings_obj.ocsp_effective_redis_url:
return None
return OcspPublisherMixin(
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)
snapshot = await snapshot_for_ca(cert)
await send_json(ctx, 200, {
"enabled": bool(settings.redis_url),
"enabled": bool(settings.ocsp_effective_redis_url),
"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,
"last_error": row.last_error if row else None,
"snapshot": snapshot,
+39
View File
@@ -28,5 +28,44 @@ class OcspExpirySettingsTest(unittest.TestCase):
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__":
unittest.main()