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
|
## 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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user