Allow dedicated Redis for OCSP publication
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user