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
+20 -19
View File
@@ -193,33 +193,34 @@ only as SHA-256 digests, and cannot mint/revoke tokens.
## OCSP responder ## OCSP responder
KCA can publish full per-CA certificate-status snapshots to any S3-compatible KCA publishes full per-CA certificate-status snapshots to the same Redis
bucket and ships a standalone Rust/Axum responder on port `2560`. instance and logical database used for sessions, and ships a standalone
Rust/Axum responder on port `2560`.
- KCA publishes `ocsp/<KCA CA UUID>.json` whenever certificates are issued, - KCA publishes `ocsp:<KCA CA UUID>` whenever certificates are issued, renewed,
renewed, revoked, deleted or imported. Revocation reasons are revoked, deleted or imported. Revocation reasons are included; this is a full
included; this is a full status snapshot, not a revoked-only list. status snapshot, not a revoked-only list. OCSP keys are separate from session
- The responder has no Postgres/Redis/KCA master-key access. It only reads the keys through their prefix.
configured S3 prefix and uses a delegated OCSP signing certificate/key per - The responder has no Postgres or KCA master-key access. It reads only OCSP
CA. snapshots from Redis and uses a delegated OCSP signing certificate/key per
CA. Where Redis ACLs are available, restrict the responder to `ocsp:*` so it
cannot read session entries.
- Each CA snapshot is cached for one second with a single-flight refresh, so - Each CA snapshot is cached for one second with a single-flight refresh, so
concurrent requests cause at most one S3 `HeadObject` plus conditional concurrent requests share one atomic Redis `GET` + `PTTL` lookup per CA.
`GetObject` sequence per CA per second. The ETag condition keeps the body and
expiry metadata from different object versions from being combined.
- The publisher refreshes unchanged snapshots every 30 seconds by default and - The publisher refreshes unchanged snapshots every 30 seconds by default and
sets each object's `Expires` header 60 seconds in the future. The responder sets each key's expiry to 60 seconds. A missing or expired snapshot produces
rejects expired objects even when S3 is readable and returns `tryLater` when `unknown`; Redis failures produce `tryLater` when no unexpired cached
no unexpired snapshot is available. S3 does not delete objects at `Expires`. snapshot remains.
- OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond - OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond
the snapshot's hard expiry. the snapshot's Redis expiry.
- Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same - Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same
`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.
Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other Configure `REDIS_URL` and the other `KCA_OCSP_*` variables in
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a `server/.env.example`. Run the responder with a configuration derived from
configuration derived from `ocsp-responder/responder.example.yaml`, setting `ocsp-responder/responder.example.yaml`, setting each issuer's `ca_id` to its
each issuer's `ca_id` to its CA certificate UUID in KCA, or with Compose: CA certificate UUID in KCA and using the same Redis URL, or with Compose:
```sh ```sh
docker compose --profile ocsp up --build docker compose --profile ocsp up --build
+1 -1
View File
@@ -1,7 +1,7 @@
# 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
# is included because sessions are disposable. KCA_MASTER_KEY is the AES key # stores disposable sessions and expiring OCSP snapshots. 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
+10 -18
View File
@@ -105,25 +105,17 @@ 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 status publisher (optional). The bucket must be writable by # OCSP snapshots use the same Redis instance as sessions, under ocsp:*.
# KCA; the responder later reads the same prefix with read-only access. KCA_OCSP_REDIS_PREFIX: ${KCA_OCSP_REDIS_PREFIX:-ocsp}
KCA_OCSP_S3_BUCKET: ${KCA_OCSP_S3_BUCKET:-}
KCA_OCSP_S3_PREFIX: ${KCA_OCSP_S3_PREFIX:-ocsp}
KCA_OCSP_S3_REGION: ${KCA_OCSP_S3_REGION:-}
KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-}
KCA_OCSP_S3_FORCE_PATH_STYLE: ${KCA_OCSP_S3_FORCE_PATH_STYLE:-false}
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}
KCA_OCSP_OBJECT_EXPIRY_SECONDS: ${KCA_OCSP_OBJECT_EXPIRY_SECONDS:-60} KCA_OCSP_REDIS_EXPIRY_SECONDS: ${KCA_OCSP_REDIS_EXPIRY_SECONDS:-60}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
# Optional standalone OCSP responder. It deliberately has no database, # Optional standalone OCSP responder. It has no database or KCA master-key
# Redis or KCA master-key environment. Enable with: # environment, but reads OCSP keys from the same Redis instance as sessions.
# Enable with:
# docker compose --profile ocsp up --build # docker compose --profile ocsp up --build
ocsp: ocsp:
profiles: ["ocsp"] profiles: ["ocsp"]
@@ -131,12 +123,12 @@ services:
build: build:
context: . context: .
dockerfile: ocsp-responder/Dockerfile dockerfile: ocsp-responder/Dockerfile
depends_on:
redis:
condition: service_healthy
environment: environment:
OCSP_CONFIG: /etc/ocsp/responder.yaml OCSP_CONFIG: /etc/ocsp/responder.yaml
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} OCSP_REDIS_URL: ${OCSP_REDIS_URL:-redis://redis:6379/0}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
volumes: volumes:
- ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro - ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro
- ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro - ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro
+155 -1044
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -19,7 +19,6 @@ bytes = "1"
async-trait = "0.1" async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
hex = "0.4" hex = "0.4"
httpdate = "0.3"
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
der = { version = "0.7", features = ["std"] } der = { version = "0.7", features = ["std"] }
@@ -31,14 +30,4 @@ ecdsa = { version = "0.16", features = ["der"] }
pkcs8 = { version = "0.10", features = ["pem"] } pkcs8 = { version = "0.10", features = ["pem"] }
x509-ocsp = { version = "0.2.1", features = ["builder", "std"] } x509-ocsp = { version = "0.2.1", features = ["builder", "std"] }
x509-cert = { version = "0.2", features = ["std"] } x509-cert = { version = "0.2", features = ["std"] }
aws-config = { version = "1", default-features = false, features = [ redis = { version = "1", features = ["tokio-comp", "connection-manager", "script"] }
"behavior-version-latest",
"credentials-process",
"legacy-client",
"rt-tokio",
] }
aws-sdk-s3 = { version = "1", default-features = false, features = [
"http-1x",
"legacy-https-client",
"rt-tokio",
] }
+3 -3
View File
@@ -2,9 +2,9 @@
# Standalone Rust/Axum OCSP responder. # Standalone Rust/Axum OCSP responder.
# #
# The final image is scratch: the binary is a statically linked musl # The final image is scratch: the binary is a statically linked musl
# executable and the only file besides it is the CA bundle needed for # executable and the only file besides it is the CA bundle needed for TLS
# outbound HTTPS to S3. Configuration, CA certificates and delegated signer # connections. Configuration, CA certificates and delegated signer material
# material are mounted at runtime. # are mounted at runtime.
FROM rust:1-alpine AS builder FROM rust:1-alpine AS builder
+40 -52
View File
@@ -1,58 +1,47 @@
# KCA OCSP responder # KCA OCSP responder
Standalone Rust/Axum responder. It reads one full JSON status snapshot per CA Standalone Rust/Axum responder. It reads one full JSON status snapshot per CA
from S3-compatible object storage, caches it for one second with a single-flight from Redis, caches it for one second with a single-flight refresh, and signs
refresh, and signs OCSP responses with a delegated responder certificate. OCSP responses with a delegated responder certificate.
It has **no KCA database access**, no Redis access, and no KCA master key. It It has **no KCA database access** and no KCA master key. It only needs:
only needs:
- `s3:GetObject` on the configured bucket/prefix; - read access to OCSP keys in the same Redis instance and logical database KCA
uses for sessions;
- 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
OCSP prefix so it cannot read session entries. KCA needs access to both
`ocsp:*` and `kaya:session:*`.
## Snapshot layout ## Snapshot layout
KCA publishes objects as: KCA publishes keys as:
``` ```
<prefix>/<KCA CA UUID>.json <prefix>:<KCA CA UUID>
``` ```
Set each issuer's required `ca_id` to the UUID of its CA certificate row in KCA. The default prefix is `ocsp`. Set each issuer's required `ca_id` to the UUID of
This isolates snapshots when the same public CA certificate exists in multiple its CA certificate row in KCA. This isolates snapshots when the same public CA
vaults. The responder validates both this ID and the CA certificate fingerprint certificate exists in multiple vaults. The responder validates both this ID and
against every downloaded snapshot. the CA certificate fingerprint against every downloaded snapshot.
KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The KCA publishes these keys when `REDIS_URL` is configured. The
`kca-ocsp-status/v2` JSON contains all certificates issued by that CA, both `kca-ocsp-status/v2` JSON contains all certificates issued by that CA, both
valid and revoked, without a generation timestamp. Freshness is carried by the valid and revoked, without a generation timestamp. Freshness is carried by the
S3 `Expires` header instead. The responder never infers status from a partial Redis key TTL. The responder never infers status from a partial revoked-only
revoked-only list. list.
## Upgrading from fingerprint-named snapshots
Deploy the updated responder with `ca_id` before or together with the updated
KCA publisher. The responder deliberately does not fall back to legacy
`<prefix>/<CA certificate SHA-256>.json` objects. Until KCA publishes the new
objects, OCSP requests return `tryLater`. After verifying the new snapshots,
the obsolete fingerprint-named objects can be removed.
## Upgrading from timestamped snapshots
Version 2 snapshots remove `generated_at` and require an S3 `Expires` header.
Upgrade the publisher and responder together: old objects have no `Expires` and
are rejected, while old responders cannot parse the version 2 JSON. Until the
new publisher writes a snapshot, the new responder returns `tryLater`.
## Run locally ## Run locally
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. Put your CA certificates and delegated signer files where the config points. 2. Set `redis.url` or `OCSP_REDIS_URL` to the same Redis URL used by KCA.
3. Export standard AWS credentials / endpoint variables as needed. 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.
5. Run: 5. Run:
@@ -70,12 +59,12 @@ docker run --rm -p 2560:2560 \
-v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \ -v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \
-v "$PWD/ca:/etc/ocsp/ca:ro" \ -v "$PWD/ca:/etc/ocsp/ca:ro" \
-v "$PWD/signers:/etc/ocsp/signers:ro" \ -v "$PWD/signers:/etc/ocsp/signers:ro" \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ -e OCSP_REDIS_URL \
gitea.woggioni.net/woggioni/kca-ocsp:latest gitea.woggioni.net/woggioni/kca-ocsp:latest
``` ```
It runs `FROM scratch` and contains only a statically linked musl executable It runs `FROM scratch` and contains only a statically linked musl executable
and a CA bundle for outbound S3 HTTPS. There is no shell or package manager in and a CA bundle for TLS connections. There is no shell or package manager in
the image. the image.
## Verify with OpenSSL ## Verify with OpenSSL
@@ -104,24 +93,23 @@ build a trust store containing the CA or validate with your normal PKI tooling.
## Cache and availability ## Cache and availability
- Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms). - Per-CA cache TTL: `redis.cache_ttl_ms` (default 1000 ms).
- Concurrent requests for the same CA share one refresh. Each refresh performs - Concurrent requests for the same CA share one refresh. A Redis Lua script
`HeadObject` to obtain `Expires` and the ETag, then `GetObject` with performs `GET` and `PTTL` atomically, keeping the snapshot and its expiry
`If-Match` for that ETag. This supports RustFS, which does not return the consistent.
stored `Expires` value from its local `GetObject` path, and prevents mixing - A missing or expired key produces `unknown`, as does a serial absent from an
metadata and bodies from different object versions. Concurrent overwrites existing snapshot. A key that exists without a TTL is rejected as a data
cause a bounded retry of the complete HEAD/GET sequence. error. Redis connection and command failures produce `tryLater` when no
- `Expires` is the hard snapshot expiry. Missing, malformed and expired values unexpired cached snapshot remains.
are rejected; if no unexpired cached snapshot remains, OCSP returns - If a refresh fails, a cached snapshot is served only until its Redis expiry.
`tryLater`. The header controls responder freshness only—S3 does not delete A missing key clears cached status so it cannot be revived by a later Redis
the object at that time. failure.
- If a refresh fails, a cached snapshot is served only until its own `Expires`
timestamp, regardless of how recently it was fetched.
- KCA republishes unchanged snapshots every - KCA republishes unchanged snapshots every
`KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets `Expires` `KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets each
using `KCA_OCSP_OBJECT_EXPIRY_SECONDS` (default 60 seconds). Keep the key's expiry using `KCA_OCSP_REDIS_EXPIRY_SECONDS` (default 60 seconds).
heartbeat comfortably below the object expiry. Keep the heartbeat comfortably below the key expiry.
- OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is - OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is
capped at the snapshot's hard expiry. capped at the snapshot's Redis expiry when a snapshot is present.
- With multiple CAs, the TTL is per CA. If you need a global one-request-per- - With multiple CAs, the cache TTL is per CA. If you need a global
second limit across all CAs, that requires a shared rate limiter. one-request-per-second limit across all CAs, that requires a shared rate
limiter.
+4 -6
View File
@@ -3,13 +3,11 @@
listen: 0.0.0.0:2560 listen: 0.0.0.0:2560
next_update_seconds: 10 next_update_seconds: 10
s3: redis:
bucket: kca-ocsp # Same Redis instance and logical database used by KCA sessions.
# OCSP_REDIS_URL overrides this value at runtime.
url: redis://redis:6379/0
prefix: ocsp prefix: ocsp
region: eu-central-1
# Optional for MinIO / other S3-compatible stores:
# endpoint: https://s3.example.com
# force_path_style: true
cache_ttl_ms: 1000 cache_ttl_ms: 1000
issuers: issuers:
+42 -12
View File
@@ -26,9 +26,9 @@ impl SnapshotCache {
Self::default() Self::default()
} }
/// Return a snapshot, refreshing from S3 at most once per `ttl`. /// Return a snapshot, refreshing from Redis at most once per `ttl`.
/// ///
/// A cached snapshot is never used after its S3 `Expires` timestamp, /// A cached snapshot is never used after its Redis key expires,
/// including when a refresh fails. /// including when a refresh fails.
pub async fn get<S: ObjectStore + ?Sized>( pub async fn get<S: ObjectStore + ?Sized>(
&self, &self,
@@ -60,16 +60,22 @@ impl SnapshotCache {
}; };
match store.get(key).await { match store.get(key).await {
Ok(stored) => { Ok(crate::store::StoredSnapshot::Missing) => {
let parsed = validate_expiry(stored.expires_at).and_then(|()| { // A key that expired or was deleted must not be replaced by
Snapshot::parse(&stored.body, expected_sha256, expected_ca_id) // an older cached status on a later Redis failure.
*self.cached.write().await = None;
Err(CacheError::Missing)
}
Ok(crate::store::StoredSnapshot::Present { body, expires_at }) => {
let parsed = validate_expiry(expires_at).and_then(|()| {
Snapshot::parse(&body, expected_sha256, expected_ca_id)
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
}); });
match parsed { match parsed {
Ok(snapshot) => { Ok(snapshot) => {
let cached = CachedSnapshot { let cached = CachedSnapshot {
snapshot: Arc::new(snapshot), snapshot: Arc::new(snapshot),
expires_at: stored.expires_at, expires_at,
fetched_at: Instant::now(), fetched_at: Instant::now(),
}; };
*self.cached.write().await = Some(cached.clone()); *self.cached.write().await = Some(cached.clone());
@@ -109,15 +115,14 @@ fn validate_expiry(expires_at: SystemTime) -> Result<(), String> {
if expiry_valid(expires_at) { if expiry_valid(expires_at) {
Ok(()) Ok(())
} else { } else {
Err(format!( Err(format!("snapshot Redis key expired at {expires_at:?}"))
"snapshot expired at {}",
httpdate::fmt_http_date(expires_at)
))
} }
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CacheError { pub enum CacheError {
#[error("OCSP snapshot is missing")]
Missing,
#[error("OCSP snapshot unavailable: {0}")] #[error("OCSP snapshot unavailable: {0}")]
Unavailable(String), Unavailable(String),
} }
@@ -138,6 +143,7 @@ mod tests {
expires_at: SystemTime, expires_at: SystemTime,
calls: AtomicUsize, calls: AtomicUsize,
fail: AtomicBool, fail: AtomicBool,
missing: AtomicBool,
} }
impl FakeStore { impl FakeStore {
@@ -147,6 +153,7 @@ mod tests {
expires_at: SystemTime::now() + lifetime, expires_at: SystemTime::now() + lifetime,
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
fail: AtomicBool::new(false), fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
} }
} }
@@ -156,6 +163,7 @@ mod tests {
expires_at: SystemTime::now() - Duration::from_secs(1), expires_at: SystemTime::now() - Duration::from_secs(1),
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
fail: AtomicBool::new(false), fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
} }
} }
} }
@@ -165,9 +173,12 @@ mod tests {
async fn get(&self, _key: &str) -> Result<StoredSnapshot, StoreError> { async fn get(&self, _key: &str) -> Result<StoredSnapshot, StoreError> {
self.calls.fetch_add(1, Ordering::SeqCst); self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail.load(Ordering::SeqCst) { if self.fail.load(Ordering::SeqCst) {
return Err(StoreError::S3("offline".to_string())); return Err(StoreError::Redis("offline".to_string()));
} }
Ok(StoredSnapshot { if self.missing.load(Ordering::SeqCst) {
return Ok(StoredSnapshot::Missing);
}
Ok(StoredSnapshot::Present {
body: Bytes::from(self.body.clone()), body: Bytes::from(self.body.clone()),
expires_at: self.expires_at, expires_at: self.expires_at,
}) })
@@ -243,4 +254,23 @@ mod tests {
assert!(matches!(unavailable, Err(CacheError::Unavailable(_)))); assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 3); assert_eq!(store.calls.load(Ordering::SeqCst), 3);
} }
#[tokio::test]
async fn missing_key_clears_cached_status_instead_of_reviving_it() {
let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_secs(1));
let cache = SnapshotCache::new();
let ttl = Duration::from_millis(20);
cache.get(&store, "key", &sha, "ca", ttl).await.unwrap();
store.missing.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(30)).await;
let missing = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(missing, Err(CacheError::Missing)));
store.missing.store(false, Ordering::SeqCst);
store.fail.store(true, Ordering::SeqCst);
let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
}
} }
+19 -23
View File
@@ -10,7 +10,7 @@ pub struct Config {
#[serde(default = "default_listen")] #[serde(default = "default_listen")]
pub listen: SocketAddr, pub listen: SocketAddr,
#[serde(default)] #[serde(default)]
pub s3: S3Config, pub redis: RedisConfig,
#[serde(default)] #[serde(default)]
pub issuers: Vec<IssuerConfig>, pub issuers: Vec<IssuerConfig>,
#[serde(default = "default_ocsp_next_update_seconds")] #[serde(default = "default_ocsp_next_update_seconds")]
@@ -27,39 +27,32 @@ pub struct IssuerConfig {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct S3Config { pub struct RedisConfig {
pub bucket: String, pub url: String,
#[serde(default = "default_prefix")] #[serde(default = "default_prefix")]
pub prefix: String, pub prefix: String,
pub region: Option<String>,
pub endpoint: Option<String>,
#[serde(default)]
pub force_path_style: bool,
#[serde(default = "default_cache_ttl_ms")] #[serde(default = "default_cache_ttl_ms")]
pub cache_ttl_ms: u64, pub cache_ttl_ms: u64,
} }
impl Default for S3Config { impl Default for RedisConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
bucket: String::new(), url: String::new(),
prefix: default_prefix(), prefix: default_prefix(),
region: None,
endpoint: None,
force_path_style: false,
cache_ttl_ms: default_cache_ttl_ms(), cache_ttl_ms: default_cache_ttl_ms(),
} }
} }
} }
impl S3Config { impl RedisConfig {
pub fn object_key(&self, ca_id: &str) -> String { pub fn key(&self, ca_id: &str) -> String {
let prefix = self.prefix.trim_matches('/'); let prefix = self.prefix.trim_matches(['/', ':']);
let name = format!("{}.json", ca_id.to_lowercase()); let name = ca_id.to_lowercase();
if prefix.is_empty() { if prefix.is_empty() {
name name
} else { } else {
format!("{prefix}/{name}") format!("{prefix}:{name}")
} }
} }
} }
@@ -90,8 +83,11 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
if config.issuers.is_empty() { if config.issuers.is_empty() {
return Err("configuration must contain at least one issuer".into()); return Err("configuration must contain at least one issuer".into());
} }
if config.s3.bucket.trim().is_empty() { if let Ok(url) = std::env::var("OCSP_REDIS_URL") {
return Err("s3.bucket must be configured".into()); config.redis.url = url;
}
if config.redis.url.trim().is_empty() {
return Err("redis.url must be configured".into());
} }
if let Ok(listen) = std::env::var("OCSP_LISTEN") { if let Ok(listen) = std::env::var("OCSP_LISTEN") {
config.listen = listen.parse()?; config.listen = listen.parse()?;
@@ -104,14 +100,14 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn object_key_uses_ca_id() { fn redis_key_uses_ca_id() {
let config = S3Config { let config = RedisConfig {
prefix: "ocsp".to_string(), prefix: "ocsp".to_string(),
..Default::default() ..Default::default()
}; };
assert_eq!( assert_eq!(
config.object_key("123E4567-E89B-12D3-A456-426614174000"), config.key("123E4567-E89B-12D3-A456-426614174000"),
"ocsp/123e4567-e89b-12d3-a456-426614174000.json" "ocsp:123e4567-e89b-12d3-a456-426614174000"
); );
} }
} }
+2 -2
View File
@@ -111,9 +111,9 @@ impl Issuer {
pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>( pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>(
&self, &self,
store: &S, store: &S,
config: &crate::config::S3Config, config: &crate::config::RedisConfig,
) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> { ) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> {
let key = config.object_key(&self.ca_id); let key = config.key(&self.ca_id);
self.cache self.cache
.get( .get(
store, store,
+1 -1
View File
@@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.init(); .init();
let config = config::load_config()?; let config = config::load_config()?;
let store = StatusStore::new(&config.s3).await; let store = StatusStore::new(&config.redis).await?;
let mut issuers = Vec::with_capacity(config.issuers.len()); let mut issuers = Vec::with_capacity(config.issuers.len());
for issuer_config in &config.issuers { for issuer_config in &config.issuers {
let issuer = Issuer::load(issuer_config)?; let issuer = Issuer::load(issuer_config)?;
+16 -8
View File
@@ -66,14 +66,16 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::unauthorized()); return ocsp_response(OcspResponse::unauthorized());
} }
let cached = match issuer.snapshot(&state.store, &state.config.s3).await { let (snapshot, expires_at) = match issuer.snapshot(&state.store, &state.config.redis).await {
Ok(cached) => cached, Ok(cached) => (cached.snapshot, Some(cached.expires_at)),
Err(crate::cache::CacheError::Missing) => {
(Arc::new(crate::snapshot::Snapshot::default()), None)
}
Err(error) => { Err(error) => {
tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable"); tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable");
return ocsp_response(OcspResponse::try_later()); return ocsp_response(OcspResponse::try_later());
} }
}; };
let snapshot = &cached.snapshot;
let now = SystemTime::now(); let now = SystemTime::now();
let produced_at = match OcspGeneralizedTime::try_from(now) { let produced_at = match OcspGeneralizedTime::try_from(now) {
@@ -84,7 +86,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
}; };
let (this_update, next_update) = let (this_update, next_update) =
response_update_times(cached.expires_at, state.config.next_update_seconds, now); response_update_times(expires_at, state.config.next_update_seconds, now);
let this_update = match OcspGeneralizedTime::try_from(this_update) { let this_update = match OcspGeneralizedTime::try_from(this_update) {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
@@ -139,13 +141,16 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
fn response_update_times( fn response_update_times(
expires_at: SystemTime, expires_at: Option<SystemTime>,
next_update_seconds: u64, next_update_seconds: u64,
now: SystemTime, now: SystemTime,
) -> (SystemTime, Option<SystemTime>) { ) -> (SystemTime, Option<SystemTime>) {
let next_update = now let next_update = now
.checked_add(Duration::from_secs(next_update_seconds)) .checked_add(Duration::from_secs(next_update_seconds))
.map(|next| next.min(expires_at)); .map(|next| match expires_at {
Some(expires_at) => next.min(expires_at),
None => next,
});
(now, next_update) (now, next_update)
} }
@@ -177,12 +182,15 @@ mod tests {
let expires_at = base + Duration::from_secs(60); let expires_at = base + Duration::from_secs(60);
let now = base + Duration::from_secs(5); let now = base + Duration::from_secs(5);
let (this_update, next_update) = response_update_times(expires_at, 10, now); let (this_update, next_update) = response_update_times(Some(expires_at), 10, now);
assert_eq!(this_update, now); assert_eq!(this_update, now);
assert_eq!(next_update, Some(now + Duration::from_secs(10))); assert_eq!(next_update, Some(now + Duration::from_secs(10)));
let now = base + Duration::from_secs(55); let now = base + Duration::from_secs(55);
let (_, next_update) = response_update_times(expires_at, 10, now); let (_, next_update) = response_update_times(Some(expires_at), 10, now);
assert_eq!(next_update, Some(expires_at)); assert_eq!(next_update, Some(expires_at));
let (_, next_update) = response_update_times(None, 10, now);
assert_eq!(next_update, Some(now + Duration::from_secs(10)));
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ pub struct StatusEntry {
pub reason: Option<CrlReason>, pub reason: Option<CrlReason>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub struct Snapshot { pub struct Snapshot {
pub entries: HashMap<Vec<u8>, StatusEntry>, pub entries: HashMap<Vec<u8>, StatusEntry>,
} }
+121 -111
View File
@@ -1,46 +1,40 @@
//! S3-compatible object store access. //! Redis OCSP snapshot access.
use std::time::SystemTime; use std::time::{Duration, SystemTime};
use async_trait::async_trait; use async_trait::async_trait;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::get_object::GetObjectError;
use aws_sdk_s3::Client;
use bytes::Bytes; use bytes::Bytes;
use redis::aio::ConnectionManager;
use crate::config::S3Config; use crate::config::RedisConfig;
const MAX_FETCH_ATTEMPTS: usize = 3; const FETCH_SNAPSHOT: &str = r#"
local body = redis.call('GET', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
return {body, ttl}
"#;
#[derive(Clone)] #[derive(Clone)]
pub struct StatusStore { pub struct StatusStore {
client: Client, connection: ConnectionManager,
bucket: String,
} }
impl StatusStore { impl StatusStore {
pub async fn new(config: &S3Config) -> Self { pub async fn new(config: &RedisConfig) -> Result<Self, StoreError> {
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()); let client = redis::Client::open(config.url.as_str())
if let Some(region) = config.region.as_ref() { .map_err(|error| StoreError::Redis(error.to_string()))?;
loader = loader.region(aws_config::Region::new(region.clone())); let connection = client
} .get_connection_manager()
let shared = loader.load().await; .await
let mut builder = aws_sdk_s3::config::Builder::from(&shared); .map_err(|error| StoreError::Redis(error.to_string()))?;
if let Some(endpoint) = config.endpoint.as_ref() { Ok(Self { connection })
builder = builder.endpoint_url(endpoint);
}
builder = builder.force_path_style(config.force_path_style);
Self {
client: Client::from_conf(builder.build()),
bucket: config.bucket.clone(),
}
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct StoredSnapshot { pub enum StoredSnapshot {
pub body: Bytes, Present { body: Bytes, expires_at: SystemTime },
pub expires_at: SystemTime, Missing,
} }
#[async_trait] #[async_trait]
@@ -51,108 +45,124 @@ pub trait ObjectStore: Send + Sync {
#[async_trait] #[async_trait]
impl ObjectStore for StatusStore { impl ObjectStore for StatusStore {
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> { async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> {
// RustFS returns Expires from HeadObject but not GetObject. A let mut connection = self.connection.clone();
// conditional GET binds the body to the metadata returned by HEAD, let (body, pttl): (Option<Vec<u8>>, i64) = redis::Script::new(FETCH_SNAPSHOT)
// even if the publisher overwrites the object between the two calls. .key(key)
for attempt in 1..=MAX_FETCH_ATTEMPTS { .invoke_async(&mut connection)
let head = self .await
.client .map_err(|error| StoreError::Redis(error.to_string()))?;
.head_object() stored_snapshot(body, pttl, SystemTime::now())
.bucket(&self.bucket) }
.key(key) }
.send()
.await
.map_err(|error| StoreError::S3(format!("HeadObject failed: {error:?}")))?;
let expires_at = parse_expiry(head.expires_string.as_deref())?;
let etag = head
.e_tag
.as_deref()
.ok_or(StoreError::MissingEtag)?
.to_string();
let response = match self fn stored_snapshot(
.client body: Option<Vec<u8>>,
.get_object() pttl: i64,
.bucket(&self.bucket) now: SystemTime,
.key(key) ) -> Result<StoredSnapshot, StoreError> {
.if_match(etag) match body {
.send() None => Ok(StoredSnapshot::Missing),
.await Some(_) if pttl == -1 => Err(StoreError::MissingTtl),
{ Some(_) if pttl <= 0 => Ok(StoredSnapshot::Missing),
Ok(response) => response, Some(body) => {
Err(error) if attempt < MAX_FETCH_ATTEMPTS && is_precondition_failed(&error) => { let expires_at = now
continue; .checked_add(Duration::from_millis(pttl as u64))
} .ok_or(StoreError::InvalidTtl(pttl))?;
Err(error) => { Ok(StoredSnapshot::Present {
return Err(StoreError::S3(format!("GetObject failed: {error:?}"))); body: Bytes::from(body),
} expires_at,
}; })
let body = response
.body
.collect()
.await
.map_err(|error| StoreError::S3(format!("GetObject body failed: {error:?}")))?
.into_bytes();
return Ok(StoredSnapshot { body, expires_at });
} }
unreachable!("the final GET attempt either returns or fails")
} }
} }
fn parse_expiry(value: Option<&str>) -> Result<SystemTime, StoreError> {
let value = value.ok_or(StoreError::MissingExpiry)?;
let expires_at = httpdate::parse_http_date(value)
.map_err(|_| StoreError::InvalidExpiry(value.to_string()))?;
if expires_at <= SystemTime::now() {
return Err(StoreError::Expired(value.to_string()));
}
Ok(expires_at)
}
fn is_precondition_failed(error: &SdkError<GetObjectError>) -> bool {
error
.as_service_error()
.and_then(|error| error.meta().code())
== Some("PreconditionFailed")
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StoreError { pub enum StoreError {
#[error("S3 request failed: {0}")] #[error("Redis request failed: {0}")]
S3(String), Redis(String),
#[error("S3 object has no Expires header")] #[error("OCSP snapshot exists without an expiry")]
MissingExpiry, MissingTtl,
#[error("S3 object has an invalid Expires header: {0}")] #[error("OCSP snapshot has an invalid TTL: {0}")]
InvalidExpiry(String), InvalidTtl(i64),
#[error("S3 object expired at {0}")]
Expired(String),
#[error("S3 object has no ETag")]
MissingEtag,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::time::Duration;
use super::*; use super::*;
#[test] #[test]
fn parses_future_http_expiry() { fn missing_and_expired_keys_are_missing_snapshots() {
let expires = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60)); let now = SystemTime::now();
assert!(parse_expiry(Some(&expires)).is_ok()); assert!(matches!(
stored_snapshot(None, -2, now),
Ok(StoredSnapshot::Missing)
));
assert!(matches!(
stored_snapshot(Some(b"stale".to_vec()), 0, now),
Ok(StoredSnapshot::Missing)
));
} }
#[test] #[test]
fn rejects_missing_invalid_and_expired_expiry() { fn present_snapshot_uses_pttl_for_expiry() {
assert!(matches!(parse_expiry(None), Err(StoreError::MissingExpiry))); let now = SystemTime::now();
let stored = stored_snapshot(Some(b"snapshot".to_vec()), 5_000, now).unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
assert_eq!(expires_at, now + Duration::from_millis(5_000));
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
}
#[test]
fn present_snapshot_without_ttl_is_rejected() {
let result = stored_snapshot(Some(b"snapshot".to_vec()), -1, SystemTime::now());
assert!(matches!(result, Err(StoreError::MissingTtl)));
}
#[tokio::test]
#[ignore = "requires KCA_TEST_REDIS_URL"]
async fn real_redis_round_trip() {
let Ok(url) = std::env::var("KCA_TEST_REDIS_URL") else {
return;
};
let config = RedisConfig {
url,
prefix: format!("ocsp:test:{}", std::process::id()),
..Default::default()
};
let key = config.key("round-trip");
let store = StatusStore::new(&config).await.unwrap();
let client = redis::Client::open(config.url.as_str()).unwrap();
let mut connection = client.get_connection_manager().await.unwrap();
let _: () = redis::cmd("SET")
.arg(&key)
.arg("snapshot")
.arg("EX")
.arg(30)
.query_async(&mut connection)
.await
.unwrap();
let stored = store.get(&key).await.unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
assert!(expires_at > SystemTime::now());
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
let _: () = redis::cmd("DEL")
.arg(&key)
.query_async(&mut connection)
.await
.unwrap();
assert!(matches!( assert!(matches!(
parse_expiry(Some("not-a-date")), store.get(&key).await.unwrap(),
Err(StoreError::InvalidExpiry(_)) StoredSnapshot::Missing
));
let expired = httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(1));
assert!(matches!(
parse_expiry(Some(&expired)),
Err(StoreError::Expired(_))
)); ));
} }
} }
+7 -13
View File
@@ -66,21 +66,15 @@ 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 a bucket is configured, KCA publishes one full JSON status snapshot # When REDIS_URL is configured, KCA publishes one full JSON status snapshot
# per CA. The standalone OCSP responder reads these objects. Uses the normal # per CA to the same Redis instance and logical database used for sessions.
# AWS credential chain (AWS_ACCESS_KEY_ID / # Keys use a separate prefix and expire unless refreshed by the heartbeat.
# AWS_SECRET_ACCESS_KEY, role, instance profile, ...). KCA_OCSP_REDIS_PREFIX=ocsp
#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
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1 KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots as a liveness heartbeat. Each object receives # Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
# an Expires header; the responder rejects it after that timestamp. Keep the # comfortably below the key expiry.
# heartbeat comfortably below the object expiry.
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30 KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
KCA_OCSP_OBJECT_EXPIRY_SECONDS=60 KCA_OCSP_REDIS_EXPIRY_SECONDS=60
# --- CORS (optional) ------------------------------------------------------- # --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS= #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-core/pyproject.toml packages/kca-core/
COPY server/packages/kca-app/pyproject.toml packages/kca-app/ COPY server/packages/kca-app/pyproject.toml packages/kca-app/
RUN --mount=type=cache,target=/root/.cache/uv \ 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/ COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \ 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 --reinstall-package kca-core --reinstall-package kca-app
COPY server/migrations ./migrations COPY server/migrations ./migrations
-3
View File
@@ -31,9 +31,6 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
s3 = [
"aioboto3>=13.0",
]
otel = [ otel = [
"kaya-otel>=0.0.7", "kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-asyncpg>=0.66b0",
+8 -16
View File
@@ -190,14 +190,10 @@ class Settings:
otel_excluded_paths: Tuple[str, ...] otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int otel_metrics_poll_interval_seconds: int
ocsp_s3_bucket: Optional[str] ocsp_redis_prefix: str
ocsp_s3_prefix: str
ocsp_s3_region: Optional[str]
ocsp_s3_endpoint: Optional[str]
ocsp_s3_force_path_style: bool
ocsp_publish_interval_seconds: int ocsp_publish_interval_seconds: int
ocsp_heartbeat_interval_seconds: int ocsp_heartbeat_interval_seconds: int
ocsp_object_expiry_seconds: int ocsp_redis_expiry_seconds: int
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -207,12 +203,12 @@ class Settings:
ocsp_heartbeat_interval_seconds = max( ocsp_heartbeat_interval_seconds = max(
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30")) 1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
) )
ocsp_object_expiry_seconds = max( ocsp_redis_expiry_seconds = max(
1, int(_env("KCA_OCSP_OBJECT_EXPIRY_SECONDS", "60")) 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( 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" "KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS"
) )
return Settings( return Settings(
@@ -255,16 +251,12 @@ class Settings:
otel_metrics_poll_interval_seconds=int( otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") _env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
), ),
ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None, ocsp_redis_prefix=_env("KCA_OCSP_REDIS_PREFIX", "ocsp").strip("/:"),
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_publish_interval_seconds=max( ocsp_publish_interval_seconds=max(
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1")) 1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
), ),
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds, 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 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 compact JSON document per CA and stores it in the same Redis instance used for
that the standalone OCSP responder never needs database access or a CA sessions, so the standalone OCSP responder never needs database access or a CA
private key. private key.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,6 +15,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID from uuid import UUID
from kaya.core import KayaApp, KayaMixin from kaya.core import KayaApp, KayaMixin
from redis.asyncio import Redis
from tortoise.expressions import Q from tortoise.expressions import Q
from .config import Settings, settings from .config import Settings, settings
@@ -33,11 +34,11 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
return value.astimezone(timezone.utc).isoformat() return value.astimezone(timezone.utc).isoformat()
def object_key(ca_id: str) -> str: def redis_key(ca_id: str) -> str:
"""The S3 key for one CA database row; the responder uses the same CA ID.""" """The Redis key for one CA database row; the responder uses the same key."""
prefix = settings.ocsp_s3_prefix.strip("/") prefix = settings.ocsp_redis_prefix.strip("/:")
name = f"{ca_id.lower()}.json" name = ca_id.lower()
return f"{prefix}/{name}" if prefix else name return f"{prefix}:{name}" if prefix else name
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]: 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() return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
def _s3_client() -> Any: async def publish_snapshot(
import aioboto3 ca: Certificate,
from botocore.config import Config snapshot: Optional[Dict[str, Any]] = None,
client: Optional[Redis] = None,
kwargs: Dict[str, Any] = { ) -> str:
"region_name": settings.ocsp_s3_region, """Store one CA snapshot with an expiry and return its Redis key."""
"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")
if snapshot is None: if snapshot is None:
snapshot = await snapshot_for_ca(ca) snapshot = await snapshot_for_ca(ca)
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8") 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: owned_client = None
await client.put_object( if client is None:
Bucket=settings.ocsp_s3_bucket, if not settings.redis_url:
Key=key, raise RuntimeError("REDIS_URL is not configured")
Body=body, client = Redis.from_url(settings.redis_url)
ContentType="application/json", owned_client = client
CacheControl="no-store", try:
Expires=datetime.now(timezone.utc) await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
+ timedelta(seconds=settings.ocsp_object_expiry_seconds), finally:
) if owned_client is not None:
await owned_client.aclose()
return key return key
async def mark_ocsp_dirty(ca_id: UUID) -> None: async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status object as needing a republish.""" """Mark one issuing CA's status snapshot as needing a republish."""
if not settings.ocsp_s3_bucket: if not settings.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:
await OcspSnapshot.create(ca_id=ca_id, dirty=True) await OcspSnapshot.create(ca_id=ca_id, dirty=True)
return return
# Always save, even when already dirty: updated_at is the publisher's # 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.dirty = True
row.last_error = None # type: ignore[assignment] row.last_error = None # type: ignore[assignment]
await row.save(update_fields=["dirty", "last_error", "updated_at"]) await row.save(update_fields=["dirty", "last_error", "updated_at"])
async def mark_all_cas_dirty(vault: Vault) -> None: async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.ocsp_s3_bucket: if not settings.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)
@@ -151,10 +140,13 @@ class OcspPublisherMixin(KayaMixin):
interval_seconds: int, interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]], ensure_context: Callable[[], Awaitable[None]],
heartbeat_interval_seconds: int = 30, heartbeat_interval_seconds: int = 30,
redis_client: Optional[Redis] = None,
) -> None: ) -> None:
self._interval = max(1, interval_seconds) self._interval = max(1, interval_seconds)
self._heartbeat_interval = max(1, heartbeat_interval_seconds) self._heartbeat_interval = max(1, heartbeat_interval_seconds)
self._ensure_context = ensure_context self._ensure_context = ensure_context
self._redis_client = redis_client
self._owns_redis_client = False
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._bootstrapped = False self._bootstrapped = False
@@ -163,21 +155,16 @@ class OcspPublisherMixin(KayaMixin):
return None return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None: def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.ocsp_s3_bucket: if not settings.redis_url:
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)") log.info("OCSP Redis publication disabled (REDIS_URL unset)")
return return
try: if self._redis_client is None:
import aioboto3 # noqa: F401 self._redis_client = Redis.from_url(settings.redis_url)
except ImportError as exc: self._owns_redis_client = True
raise RuntimeError(
"KCA_OCSP_S3_BUCKET is set but aioboto3 is not installed; "
"install kca-app with the 's3' extra"
) from exc
self._task = loop.create_task(self._poll()) self._task = loop.create_task(self._poll())
log.info( log.info(
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss", "OCSP Redis publication enabled: prefix=%s interval=%ss",
settings.ocsp_s3_bucket, settings.ocsp_redis_prefix or "(root)",
settings.ocsp_s3_prefix or "(root)",
self._interval, self._interval,
) )
@@ -185,6 +172,10 @@ class OcspPublisherMixin(KayaMixin):
if self._task is not None: if self._task is not None:
self._task.cancel() self._task.cancel()
self._task = None 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: async def _poll(self) -> None:
# Let the Tortoise mixin establish its database context before the # Let the Tortoise mixin establish its database context before the
@@ -194,7 +185,7 @@ class OcspPublisherMixin(KayaMixin):
try: try:
if not self._bootstrapped: if not self._bootstrapped:
# Existing CAs may predate the publisher or have lost # 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. # the first successful tick.
await self._ensure_context() await self._ensure_context()
for ca in await Certificate.filter(is_ca=True): for ca in await Certificate.filter(is_ca=True):
@@ -228,7 +219,7 @@ class OcspPublisherMixin(KayaMixin):
continue continue
try: try:
snapshot = await snapshot_for_ca(ca) 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) published_at = datetime.now(timezone.utc)
cleared = await OcspSnapshot.filter( cleared = await OcspSnapshot.filter(
ca_id=row.ca_id, ca_id=row.ca_id,
@@ -258,7 +249,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.ocsp_s3_bucket: if not settings_obj.redis_url:
return None return None
return OcspPublisherMixin( return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds, settings_obj.ocsp_publish_interval_seconds,
@@ -272,8 +263,8 @@ __all__ = [
"SCHEMA", "SCHEMA",
"mark_all_cas_dirty", "mark_all_cas_dirty",
"mark_ocsp_dirty", "mark_ocsp_dirty",
"object_key",
"publish_snapshot", "publish_snapshot",
"publisher_mixin_from_settings", "publisher_mixin_from_settings",
"redis_key",
"snapshot_for_ca", "snapshot_for_ca",
] ]
@@ -155,10 +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.ocsp_s3_bucket), "enabled": bool(settings.redis_url),
"bucket": settings.ocsp_s3_bucket, "prefix": settings.ocsp_redis_prefix,
"prefix": settings.ocsp_s3_prefix, "dirty": row.dirty if row else bool(settings.redis_url),
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
"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,
+2 -2
View File
@@ -13,14 +13,14 @@ class OcspExpirySettingsTest(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env() settings = Settings.from_env()
self.assertEqual(30, settings.ocsp_heartbeat_interval_seconds) 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: def test_expiry_must_exceed_heartbeat(self) -> None:
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30", "KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30",
"KCA_OCSP_OBJECT_EXPIRY_SECONDS": "30", "KCA_OCSP_REDIS_EXPIRY_SECONDS": "30",
}, },
clear=True, 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) ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
leaf_key = await _key(client, vault_id, "dirty leaf") leaf_key = await _key(client, vault_id, "dirty leaf")
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"]) 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): with patch.object(ocsp, "settings", configured):
response = await client.post( response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1100,7 +1100,7 @@ class OcspSnapshotTests(unittest.TestCase):
leaf = await _cert( leaf = await _cert(
client, vault_id, leaf_key["id"], "Existing Dirty Leaf", signer_id=ca["id"] 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): with patch.object(ocsp, "settings", configured):
response = await client.post( response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
@@ -1129,7 +1129,7 @@ class OcspSnapshotTests(unittest.TestCase):
vault_id = await _vault(client) vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "manual dirty ca") ca_key = await _key(client, vault_id, "manual dirty ca")
ca = await _cert(client, vault_id, ca_key["id"], "Manual Dirty CA", ca=True) 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): with patch.object(ocsp, "settings", configured):
await OcspSnapshot.create(ca_id=ca["id"], dirty=False) await OcspSnapshot.create(ca_id=ca["id"], dirty=False)
response = await client.post( response = await client.post(
@@ -1177,7 +1177,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot): async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot) published.append(snapshot)
if len(published) == 1: if len(published) == 1:
await CertificateRevocation.create( await CertificateRevocation.create(
@@ -1187,9 +1187,9 @@ class OcspSnapshotTests(unittest.TestCase):
reason="keyCompromise", reason="keyCompromise",
) )
await ocsp.mark_ocsp_dirty(publish_ca.id) 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( with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "publish_snapshot", fake_publish ocsp, "publish_snapshot", fake_publish
): ):
@@ -1222,20 +1222,14 @@ class OcspSnapshotTests(unittest.TestCase):
from kca import ocsp from kca import ocsp
from kca.models import OcspSnapshot from kca.models import OcspSnapshot
class FakeS3: class FakeRedis:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return None
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
async def put_object(self, **kwargs): async def set(self, name, value, ex=None):
self.calls.append(kwargs) self.calls.append({"name": name, "value": value, "ex": ex})
fake = FakeS3() fake = FakeRedis()
async with api_client() as client: async with api_client() as client:
with oidc_user("alice"): with oidc_user("alice"):
vault_id = await _vault(client) 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) ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
configured = replace( configured = replace(
ocsp.settings, ocsp.settings,
ocsp_s3_bucket="test-bucket", redis_url="redis://localhost:6379/0",
ocsp_s3_prefix="ocsp", ocsp_redis_prefix="ocsp",
) )
with patch.object(ocsp, "settings", configured), patch.object( with patch.object(ocsp, "settings", configured):
ocsp, "_s3_client", lambda: fake
):
await ocsp.mark_ocsp_dirty(ca["id"]) await ocsp.mark_ocsp_dirty(ca["id"])
row = await OcspSnapshot.get(ca_id=ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is True assert row.dirty is True
async def ensure_context(): async def ensure_context():
return None return None
mixin = ocsp.OcspPublisherMixin(1, ensure_context) mixin = ocsp.OcspPublisherMixin(1, ensure_context, redis_client=fake)
await mixin.run_once() await mixin.run_once()
assert len(fake.calls) == 1 assert len(fake.calls) == 1
call = fake.calls[0] call = fake.calls[0]
assert call["Bucket"] == "test-bucket" assert call["name"] == f"ocsp:{ca['id']}"
assert call["Key"] == f"ocsp/{ca['id']}.json" assert b"kca-ocsp-status/v2" in call["value"]
assert b"kca-ocsp-status/v2" in call["Body"] assert b"generated_at" not in call["value"]
assert b"generated_at" not in call["Body"] assert call["ex"] == 60
expires_in = (call["Expires"] - datetime.now(timezone.utc)).total_seconds()
assert 50 < expires_in <= 60
row = await OcspSnapshot.get(ca_id=ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False assert row.dirty is False
assert row.published_at is not None assert row.published_at is not None
@@ -1289,7 +1279,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot): async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot) published.append(snapshot)
return "key" return "key"
@@ -1327,7 +1317,7 @@ class OcspSnapshotTests(unittest.TestCase):
) )
published = [] published = []
async def fake_publish(publish_ca, snapshot): async def fake_publish(publish_ca, snapshot, client=None):
published.append(snapshot) published.append(snapshot)
return "key" return "key"
@@ -1351,20 +1341,16 @@ class OcspSnapshotTests(unittest.TestCase):
from kca import ocsp from kca import ocsp
from kca.models import Certificate, OcspSnapshot from kca.models import Certificate, OcspSnapshot
class FakeS3: class FakeRedis:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return None
def __init__(self): def __init__(self):
self.objects = {} self.objects = {}
self.expiries = {}
async def put_object(self, **kwargs): async def set(self, name, value, ex=None):
self.objects[kwargs["Key"]] = kwargs["Body"] self.objects[name] = value
self.expiries[name] = ex
fake = FakeS3() fake = FakeRedis()
async with api_client() as client: async with api_client() as client:
with oidc_user("alice"): with oidc_user("alice"):
vault_id = await _vault(client) vault_id = await _vault(client)
@@ -1388,14 +1374,12 @@ class OcspSnapshotTests(unittest.TestCase):
configured = replace( configured = replace(
ocsp.settings, ocsp.settings,
ocsp_s3_bucket="test-bucket", redis_url="redis://localhost:6379/0",
ocsp_s3_prefix="ocsp", ocsp_redis_prefix="ocsp",
) )
with patch.object(ocsp, "settings", configured), patch.object( with patch.object(ocsp, "settings", configured):
ocsp, "_s3_client", lambda: fake
):
target = await Certificate.get(id=ca["id"]) 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"): with oidc_user("bob"):
attacker_vault_id = await _vault(client) attacker_vault_id = await _vault(client)
@@ -1423,12 +1407,13 @@ class OcspSnapshotTests(unittest.TestCase):
assert len(response.json()["skipped"]) == 1 assert len(response.json()["skipped"]) == 1
row = await OcspSnapshot.get(ca_id=imported.id) row = await OcspSnapshot.get(ca_id=imported.id)
assert row.dirty is True 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 target_key == f"ocsp:{ca['id']}"
assert imported_key == f"ocsp/{imported.id}.json" assert imported_key == f"ocsp:{imported.id}"
assert imported_key != target_key assert imported_key != target_key
assert set(fake.objects) == {target_key, imported_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_snapshot = json.loads(fake.objects[target_key])
target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]} target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]}
assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked" assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked"
+92 -999
View File
File diff suppressed because it is too large Load Diff