diff --git a/README.md b/README.md index 7c04dcc..6b19a5b 100644 --- a/README.md +++ b/README.md @@ -203,12 +203,15 @@ bucket and ships a standalone Rust/Axum responder on port `2560`. configured S3 prefix and uses a delegated OCSP signing certificate/key per CA. - Each CA snapshot is cached for one second with a single-flight refresh, so - concurrent requests cause at most one S3 `GetObject` per CA per second. -- The publisher refreshes unchanged snapshots every 30 seconds by default. The - responder rejects snapshots older than its configured grace period even when - S3 is readable, and returns `tryLater` when no fresh snapshot is available. -- OCSP `thisUpdate` reflects the snapshot generation time and `nextUpdate` never - extends beyond the snapshot's hard expiry. + concurrent requests cause at most one S3 `HeadObject` plus conditional + `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 + sets each object's `Expires` header 60 seconds in the future. The responder + rejects expired objects even when S3 is readable and returns `tryLater` when + no unexpired snapshot is available. S3 does not delete objects at `Expires`. +- OCSP `thisUpdate` is the response time and `nextUpdate` never extends beyond + the snapshot's hard expiry. - Gitea Actions publishes `gitea.woggioni.net/woggioni/kca-ocsp` on the same `release/*` tags as the server image. The responder image is a statically linked musl executable running `FROM scratch` with only the CA bundle. diff --git a/docker-compose.yml b/docker-compose.yml index bac423b..c76867b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,6 +114,7 @@ services: 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_HEARTBEAT_INTERVAL_SECONDS: ${KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS:-30} + KCA_OCSP_OBJECT_EXPIRY_SECONDS: ${KCA_OCSP_OBJECT_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:-} diff --git a/ocsp-responder/Cargo.lock b/ocsp-responder/Cargo.lock index 3120c1f..5e31377 100644 --- a/ocsp-responder/Cargo.lock +++ b/ocsp-responder/Cargo.lock @@ -1075,6 +1075,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + [[package]] name = "httpdate" version = "1.0.3" @@ -1104,7 +1110,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "httparse", - "httpdate", + "httpdate 1.0.3", "itoa", "pin-project-lite", "socket2 0.5.10", @@ -1126,7 +1132,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "httparse", - "httpdate", + "httpdate 1.0.3", "itoa", "pin-project-lite", "smallvec", @@ -1333,6 +1339,7 @@ dependencies = [ "der", "ecdsa", "hex", + "httpdate 0.3.2", "p256", "pkcs8", "rsa", diff --git a/ocsp-responder/Cargo.toml b/ocsp-responder/Cargo.toml index d734372..aeb840b 100644 --- a/ocsp-responder/Cargo.toml +++ b/ocsp-responder/Cargo.toml @@ -19,6 +19,7 @@ bytes = "1" async-trait = "0.1" base64 = "0.22" hex = "0.4" +httpdate = "0.3" sha1 = "0.10" sha2 = "0.10" der = { version = "0.7", features = ["std"] } diff --git a/ocsp-responder/README.md b/ocsp-responder/README.md index a2ccdde..fe44726 100644 --- a/ocsp-responder/README.md +++ b/ocsp-responder/README.md @@ -26,10 +26,11 @@ 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 objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON -contains all certificates issued by that CA, both valid and revoked, and a -required `generated_at` timestamp. The responder never infers status from a -partial revoked-only list. +KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The +`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 +S3 `Expires` header instead. The responder never infers status from a partial +revoked-only list. ## Upgrading from fingerprint-named snapshots @@ -39,6 +40,13 @@ KCA publisher. The responder deliberately does not fall back to legacy 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 1. Create a config based on `responder.example.yaml`, setting each issuer's @@ -97,20 +105,23 @@ build a trust store containing the CA or validate with your normal PKI tooling. ## Cache and availability - Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms). -- Concurrent requests for the same CA share one S3 `GetObject`. -- `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from - `generated_at`. Expired or future-dated snapshots are rejected even when S3 - remains readable; if no valid cached snapshot remains, OCSP returns - `tryLater`. -- If a refresh fails, a cached snapshot is served only while both its time since - the last successful fetch and its `generated_at` age remain within - `s3.stale_grace_seconds`. +- Concurrent requests for the same CA share one refresh. Each refresh performs + `HeadObject` to obtain `Expires` and the ETag, then `GetObject` with + `If-Match` for that ETag. This supports RustFS, which does not return the + stored `Expires` value from its local `GetObject` path, and prevents mixing + metadata and bodies from different object versions. Concurrent overwrites + cause a bounded retry of the complete HEAD/GET sequence. +- `Expires` is the hard snapshot expiry. Missing, malformed and expired values + are rejected; if no unexpired cached snapshot remains, OCSP returns + `tryLater`. The header controls responder freshness only—S3 does not delete + the object at that time. +- 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_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds). Keep that interval - comfortably below the responder's `s3.stale_grace_seconds` (default 60). For - a rolling upgrade, enable publisher heartbeats before the freshness-enforcing - responder so existing snapshots remain within the age limit. -- OCSP `thisUpdate` is the snapshot's `generated_at`. `nextUpdate` is capped at - the snapshot's hard expiry, while `producedAt` is the response signing time. + `KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets `Expires` + using `KCA_OCSP_OBJECT_EXPIRY_SECONDS` (default 60 seconds). Keep the + heartbeat comfortably below the object expiry. +- OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is + capped at the snapshot's hard expiry. - With multiple CAs, the TTL is per CA. If you need a global one-request-per- second limit across all CAs, that requires a shared rate limiter. diff --git a/ocsp-responder/responder.example.yaml b/ocsp-responder/responder.example.yaml index ecded02..2db6b8a 100644 --- a/ocsp-responder/responder.example.yaml +++ b/ocsp-responder/responder.example.yaml @@ -11,8 +11,6 @@ s3: # endpoint: https://s3.example.com # force_path_style: true cache_ttl_ms: 1000 - # Hard maximum snapshot age. Keep comfortably above KCA's heartbeat interval. - stale_grace_seconds: 60 issuers: - name: Example Root CA diff --git a/ocsp-responder/src/cache.rs b/ocsp-responder/src/cache.rs index 66a2bc7..d27f72b 100644 --- a/ocsp-responder/src/cache.rs +++ b/ocsp-responder/src/cache.rs @@ -1,9 +1,8 @@ //! One-second, single-flight cache of per-CA status snapshots. use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; -use chrono::Utc; use tokio::sync::{Mutex, RwLock}; use crate::snapshot::Snapshot; @@ -11,7 +10,8 @@ use crate::store::ObjectStore; #[derive(Clone)] pub struct CachedSnapshot { - snapshot: Arc, + pub snapshot: Arc, + pub expires_at: SystemTime, fetched_at: Instant, } @@ -28,8 +28,8 @@ impl SnapshotCache { /// Return a snapshot, refreshing from S3 at most once per `ttl`. /// - /// Snapshots older than `stale_grace` are never used. On refresh failure, - /// cached data is also limited to `stale_grace` since its last fetch. + /// A cached snapshot is never used after its S3 `Expires` timestamp, + /// including when a refresh fails. pub async fn get( &self, store: &S, @@ -37,12 +37,10 @@ impl SnapshotCache { expected_sha256: &str, expected_ca_id: &str, ttl: Duration, - stale_grace: Duration, - ) -> Result, CacheError> { + ) -> Result { if let Some(value) = self.cached.read().await.as_ref() { - if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace) - { - return Ok(value.snapshot.clone()); + if fresh_cache_entry_valid(value, ttl) { + return Ok(value.clone()); } } @@ -52,10 +50,8 @@ impl SnapshotCache { let stale = { let cache = self.cached.read().await; if let Some(value) = cache.as_ref() { - if value.fetched_at.elapsed() < ttl - && snapshot_age_valid(&value.snapshot, stale_grace) - { - return Ok(value.snapshot.clone()); + if fresh_cache_entry_valid(value, ttl) { + return Ok(value.clone()); } Some(value.clone()) } else { @@ -64,21 +60,25 @@ impl SnapshotCache { }; match store.get(key).await { - Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) { - Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) { - Ok(()) => { - let snapshot = Arc::new(snapshot); - *self.cached.write().await = Some(CachedSnapshot { - snapshot: snapshot.clone(), + Ok(stored) => { + let parsed = validate_expiry(stored.expires_at).and_then(|()| { + Snapshot::parse(&stored.body, expected_sha256, expected_ca_id) + .map_err(|error| error.to_string()) + }); + match parsed { + Ok(snapshot) => { + let cached = CachedSnapshot { + snapshot: Arc::new(snapshot), + expires_at: stored.expires_at, fetched_at: Instant::now(), - }); - Ok(snapshot) + }; + *self.cached.write().await = Some(cached.clone()); + Ok(cached) } - Err(error) => self.fallback(stale, error, stale_grace), - }, - Err(error) => self.fallback(stale, error.to_string(), stale_grace), - }, - Err(error) => self.fallback(stale, error.to_string(), stale_grace), + Err(error) => self.fallback(stale, error), + } + } + Err(error) => self.fallback(stale, error.to_string()), } } @@ -86,41 +86,34 @@ impl SnapshotCache { &self, stale: Option, error: String, - stale_grace: Duration, - ) -> Result, CacheError> { + ) -> Result { if let Some(value) = stale { - if value.fetched_at.elapsed() <= stale_grace - && snapshot_age_valid(&value.snapshot, stale_grace) - { - tracing::warn!(error = %error, "serving stale OCSP snapshot"); - return Ok(value.snapshot); + if expiry_valid(value.expires_at) { + tracing::warn!(error = %error, "serving unexpired cached OCSP snapshot after refresh failure"); + return Ok(value); } } Err(CacheError::Unavailable(error)) } } -fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool { - validate_snapshot_age(snapshot, stale_grace).is_ok() +fn fresh_cache_entry_valid(value: &CachedSnapshot, ttl: Duration) -> bool { + value.fetched_at.elapsed() < ttl && expiry_valid(value.expires_at) } -fn validate_snapshot_age(snapshot: &Snapshot, stale_grace: Duration) -> Result<(), String> { - let age = Utc::now() - .signed_duration_since(snapshot.generated_at) - .to_std() - .map_err(|_| { - format!( - "snapshot generation time {} is in the future", - snapshot.generated_at - ) - })?; - if age > stale_grace { - return Err(format!( - "snapshot generated at {} exceeds maximum age {stale_grace:?}", - snapshot.generated_at - )); +fn expiry_valid(expires_at: SystemTime) -> bool { + expires_at > SystemTime::now() +} + +fn validate_expiry(expires_at: SystemTime) -> Result<(), String> { + if expiry_valid(expires_at) { + Ok(()) + } else { + Err(format!( + "snapshot expired at {}", + httpdate::fmt_http_date(expires_at) + )) } - Ok(()) } #[derive(Debug, thiserror::Error)] @@ -138,18 +131,29 @@ mod tests { use super::*; use crate::snapshot::sha256_hex; - use crate::store::{ObjectStore, StoreError}; + use crate::store::{ObjectStore, StoreError, StoredSnapshot}; struct FakeStore { body: String, + expires_at: SystemTime, calls: AtomicUsize, fail: AtomicBool, } impl FakeStore { - fn new(body: String) -> Self { + fn new(body: String, lifetime: Duration) -> Self { Self { body, + expires_at: SystemTime::now() + lifetime, + calls: AtomicUsize::new(0), + fail: AtomicBool::new(false), + } + } + + fn expired(body: String) -> Self { + Self { + body, + expires_at: SystemTime::now() - Duration::from_secs(1), calls: AtomicUsize::new(0), fail: AtomicBool::new(false), } @@ -158,98 +162,65 @@ mod tests { #[async_trait] impl ObjectStore for FakeStore { - async fn get(&self, _key: &str) -> Result { + async fn get(&self, _key: &str) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); if self.fail.load(Ordering::SeqCst) { return Err(StoreError::S3("offline".to_string())); } - Ok(Bytes::from(self.body.clone())) + Ok(StoredSnapshot { + body: Bytes::from(self.body.clone()), + expires_at: self.expires_at, + }) } } - fn body(sha: &str, generated_at: chrono::DateTime) -> String { - let generated_at = generated_at.to_rfc3339(); + fn body(sha: &str) -> String { format!( - r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"generated_at":"{generated_at}","entries":[{{"serial_hex":"01","status":"valid"}}]}}"# + r#"{{"schema":"kca-ocsp-status/v2","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"# ) } #[tokio::test] async fn single_flight_refreshes_once() { let sha = sha256_hex(b"ca"); - let store = FakeStore::new(body(&sha, Utc::now())); + let store = FakeStore::new(body(&sha), Duration::from_secs(1)); let cache = SnapshotCache::new(); let (a, b) = tokio::join!( - cache.get( - &store, - "key", - &sha, - "ca", - Duration::from_millis(100), - Duration::from_secs(1) - ), - cache.get( - &store, - "key", - &sha, - "ca", - Duration::from_millis(100), - Duration::from_secs(1) - ) + cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),), + cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),) ); assert!(a.is_ok()); assert!(b.is_ok()); assert_eq!(store.calls.load(Ordering::SeqCst), 1); + assert_eq!(a.unwrap().expires_at, store.expires_at); } #[tokio::test] - async fn refreshes_after_ttl_and_serves_stale_on_error() { + async fn refreshes_after_ttl_and_serves_unexpired_cache_on_error() { let sha = sha256_hex(b"ca"); - let store = FakeStore::new(body(&sha, Utc::now())); + let store = FakeStore::new(body(&sha), Duration::from_secs(1)); let cache = SnapshotCache::new(); cache - .get( - &store, - "key", - &sha, - "ca", - Duration::from_millis(20), - Duration::from_secs(1), - ) + .get(&store, "key", &sha, "ca", Duration::from_millis(20)) .await .unwrap(); tokio::time::sleep(Duration::from_millis(30)).await; store.fail.store(true, Ordering::SeqCst); let stale = cache - .get( - &store, - "key", - &sha, - "ca", - Duration::from_millis(20), - Duration::from_secs(1), - ) + .get(&store, "key", &sha, "ca", Duration::from_millis(20)) .await .unwrap(); - assert_eq!(stale.entries.len(), 1); + assert_eq!(stale.snapshot.entries.len(), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 2); } #[tokio::test] async fn rejects_expired_snapshot_from_healthy_store() { let sha = sha256_hex(b"ca"); - let generated_at = Utc::now() - chrono::Duration::seconds(30); - let store = FakeStore::new(body(&sha, generated_at)); + let store = FakeStore::expired(body(&sha)); let cache = SnapshotCache::new(); let result = cache - .get( - &store, - "key", - &sha, - "ca", - Duration::from_millis(1), - Duration::from_secs(1), - ) + .get(&store, "key", &sha, "ca", Duration::from_millis(1)) .await; assert!(matches!(result, Err(CacheError::Unavailable(_)))); assert_eq!(store.calls.load(Ordering::SeqCst), 1); @@ -258,21 +229,17 @@ mod tests { #[tokio::test] async fn successful_fetches_do_not_extend_snapshot_lifetime() { let sha = sha256_hex(b"ca"); - let store = FakeStore::new(body(&sha, Utc::now())); + let store = FakeStore::new(body(&sha), Duration::from_millis(100)); let cache = SnapshotCache::new(); let ttl = Duration::from_secs(1); - let stale_grace = Duration::from_millis(100); - cache - .get(&store, "key", &sha, "ca", ttl, stale_grace) - .await - .unwrap(); + cache.get(&store, "key", &sha, "ca", ttl).await.unwrap(); tokio::time::sleep(Duration::from_millis(120)).await; - let expired = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await; + let expired = cache.get(&store, "key", &sha, "ca", ttl).await; assert!(matches!(expired, Err(CacheError::Unavailable(_)))); store.fail.store(true, Ordering::SeqCst); - let unavailable = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).await; + let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await; assert!(matches!(unavailable, Err(CacheError::Unavailable(_)))); assert_eq!(store.calls.load(Ordering::SeqCst), 3); } diff --git a/ocsp-responder/src/config.rs b/ocsp-responder/src/config.rs index 88852b8..0ef42cd 100644 --- a/ocsp-responder/src/config.rs +++ b/ocsp-responder/src/config.rs @@ -37,8 +37,6 @@ pub struct S3Config { pub force_path_style: bool, #[serde(default = "default_cache_ttl_ms")] pub cache_ttl_ms: u64, - #[serde(default = "default_stale_grace_seconds")] - pub stale_grace_seconds: u64, } impl Default for S3Config { @@ -50,7 +48,6 @@ impl Default for S3Config { endpoint: None, force_path_style: false, cache_ttl_ms: default_cache_ttl_ms(), - stale_grace_seconds: default_stale_grace_seconds(), } } } @@ -81,10 +78,6 @@ fn default_cache_ttl_ms() -> u64 { 1000 } -fn default_stale_grace_seconds() -> u64 { - 60 -} - fn default_ocsp_next_update_seconds() -> u64 { 10 } diff --git a/ocsp-responder/src/issuer.rs b/ocsp-responder/src/issuer.rs index 6a21488..71021c6 100644 --- a/ocsp-responder/src/issuer.rs +++ b/ocsp-responder/src/issuer.rs @@ -112,7 +112,7 @@ impl Issuer { &self, store: &S, config: &crate::config::S3Config, - ) -> Result, crate::cache::CacheError> { + ) -> Result { let key = config.object_key(&self.ca_id); self.cache .get( @@ -121,7 +121,6 @@ impl Issuer { &self.fingerprint, &self.ca_id, Duration::from_millis(config.cache_ttl_ms), - Duration::from_secs(config.stale_grace_seconds), ) .await } diff --git a/ocsp-responder/src/ocsp.rs b/ocsp-responder/src/ocsp.rs index 0a08620..08a5906 100644 --- a/ocsp-responder/src/ocsp.rs +++ b/ocsp-responder/src/ocsp.rs @@ -8,7 +8,6 @@ use axum::extract::{Path, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use base64::Engine; -use chrono::{DateTime, Utc}; use der::{Decode, Encode}; use x509_cert::ext::pkix::CrlReason; use x509_ocsp::builder::OcspResponseBuilder; @@ -67,13 +66,14 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { return ocsp_response(OcspResponse::unauthorized()); } - let snapshot = match issuer.snapshot(&state.store, &state.config.s3).await { - Ok(snapshot) => snapshot, + let cached = match issuer.snapshot(&state.store, &state.config.s3).await { + Ok(cached) => cached, Err(error) => { tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable"); return ocsp_response(OcspResponse::try_later()); } }; + let snapshot = &cached.snapshot; let now = SystemTime::now(); let produced_at = match OcspGeneralizedTime::try_from(now) { @@ -83,12 +83,8 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { return ocsp_response(OcspResponse::internal_error()); } }; - let (this_update, next_update) = response_update_times( - snapshot.generated_at, - state.config.next_update_seconds, - state.config.s3.stale_grace_seconds, - now, - ); + let (this_update, next_update) = + response_update_times(cached.expires_at, state.config.next_update_seconds, now); let this_update = match OcspGeneralizedTime::try_from(this_update) { Ok(value) => value, Err(error) => { @@ -143,17 +139,14 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response { } fn response_update_times( - generated_at: DateTime, + expires_at: SystemTime, next_update_seconds: u64, - stale_grace_seconds: u64, now: SystemTime, ) -> (SystemTime, Option) { - let this_update = system_time(generated_at); - let expires_at = this_update.checked_add(Duration::from_secs(stale_grace_seconds)); let next_update = now .checked_add(Duration::from_secs(next_update_seconds)) - .and_then(|next| expires_at.map(|expires| next.min(expires))); - (this_update, next_update) + .map(|next| next.min(expires_at)); + (now, next_update) } fn ocsp_response(response: OcspResponse) -> Response { @@ -179,22 +172,17 @@ mod tests { use super::*; #[test] - fn update_times_use_snapshot_age_and_bound_next_update() { - let generated_at = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let generated_at_system = system_time(generated_at); + fn update_times_use_response_time_and_bound_next_update_to_expiry() { + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600); + let expires_at = base + Duration::from_secs(60); - let now = generated_at_system + Duration::from_secs(5); - let (this_update, next_update) = response_update_times(generated_at, 10, 60, now); - assert_eq!(this_update, generated_at_system); + let now = base + Duration::from_secs(5); + let (this_update, next_update) = response_update_times(expires_at, 10, now); + assert_eq!(this_update, now); assert_eq!(next_update, Some(now + Duration::from_secs(10))); - let now = generated_at_system + Duration::from_secs(55); - let (_, next_update) = response_update_times(generated_at, 10, 60, now); - assert_eq!( - next_update, - Some(generated_at_system + Duration::from_secs(60)) - ); + let now = base + Duration::from_secs(55); + let (_, next_update) = response_update_times(expires_at, 10, now); + assert_eq!(next_update, Some(expires_at)); } } diff --git a/ocsp-responder/src/snapshot.rs b/ocsp-responder/src/snapshot.rs index 367a196..65b1be8 100644 --- a/ocsp-responder/src/snapshot.rs +++ b/ocsp-responder/src/snapshot.rs @@ -8,7 +8,7 @@ use sha1::{Digest as _, Sha1}; use sha2::Sha256; use x509_cert::ext::pkix::CrlReason; -pub const SCHEMA: &str = "kca-ocsp-status/v1"; +pub const SCHEMA: &str = "kca-ocsp-status/v2"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { @@ -25,7 +25,6 @@ pub struct StatusEntry { #[derive(Debug, Clone)] pub struct Snapshot { - pub generated_at: DateTime, pub entries: HashMap, StatusEntry>, } @@ -33,7 +32,6 @@ pub struct Snapshot { struct RawSnapshot { schema: String, issuer: RawIssuer, - generated_at: DateTime, #[serde(default)] entries: Vec, } @@ -67,9 +65,6 @@ impl Snapshot { if raw.schema != SCHEMA { return Err(SnapshotError::Schema(raw.schema)); } - if raw.generated_at > Utc::now() { - return Err(SnapshotError::FutureGeneration(raw.generated_at)); - } if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) { return Err(SnapshotError::IssuerIdMismatch { expected: expected_ca_id.to_string(), @@ -115,10 +110,7 @@ impl Snapshot { } } let _ = raw.issuer.sha256; - Ok(Self { - generated_at: raw.generated_at, - entries, - }) + Ok(Self { entries }) } pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> { @@ -178,8 +170,6 @@ pub enum SnapshotError { Json(#[from] serde_json::Error), #[error("unsupported snapshot schema {0:?}")] Schema(String), - #[error("snapshot generation time {0} is in the future")] - FutureGeneration(DateTime), #[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")] IssuerIdMismatch { expected: String, actual: String }, #[error("snapshot issuer {actual} does not match configured CA {expected}")] @@ -201,9 +191,8 @@ mod tests { fn body(sha: &str) -> String { format!( r#"{{ - "schema": "kca-ocsp-status/v1", + "schema": "kca-ocsp-status/v2", "issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}}, - "generated_at": "2026-01-01T00:00:00Z", "entries": [ {{"serial_hex": "01ab", "status": "valid", "not_after": "2027-01-01T00:00:00Z"}}, {{"serial_hex": "00ff", "status": "revoked", "revoked_at": "2026-02-03T04:05:06Z", "reason": "keyCompromise"}} @@ -238,24 +227,6 @@ mod tests { assert!(Snapshot::parse(duplicate.as_bytes(), &sha, "ca").is_err()); } - #[test] - fn rejects_missing_or_future_generation_time() { - let sha = sha256_hex(b"ca"); - let missing = body(&sha) - .lines() - .filter(|line| !line.contains("\"generated_at\"")) - .collect::>() - .join("\n"); - assert!(Snapshot::parse(missing.as_bytes(), &sha, "ca").is_err()); - - let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); - let future = body(&sha).replace("2026-01-01T00:00:00Z", &future); - assert!(matches!( - Snapshot::parse(future.as_bytes(), &sha, "ca"), - Err(SnapshotError::FutureGeneration(_)) - )); - } - #[test] fn normalizes_serials() { assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]); diff --git a/ocsp-responder/src/store.rs b/ocsp-responder/src/store.rs index d3edb8c..0331e52 100644 --- a/ocsp-responder/src/store.rs +++ b/ocsp-responder/src/store.rs @@ -1,11 +1,17 @@ //! S3-compatible object store access. +use std::time::SystemTime; + 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 crate::config::S3Config; +const MAX_FETCH_ATTEMPTS: usize = 3; + #[derive(Clone)] pub struct StatusStore { client: Client, @@ -31,34 +37,122 @@ impl StatusStore { } } +#[derive(Debug)] +pub struct StoredSnapshot { + pub body: Bytes, + pub expires_at: SystemTime, +} + #[async_trait] pub trait ObjectStore: Send + Sync { - async fn get(&self, key: &str) -> Result; + async fn get(&self, key: &str) -> Result; } #[async_trait] impl ObjectStore for StatusStore { - async fn get(&self, key: &str) -> Result { - let response = self - .client - .get_object() - .bucket(&self.bucket) - .key(key) - .send() - .await - .map_err(|error| StoreError::S3(format!("{error:?}")))?; - let data = response - .body - .collect() - .await - .map_err(|error| StoreError::S3(format!("{error:?}")))? - .into_bytes(); - Ok(data) + async fn get(&self, key: &str) -> Result { + // RustFS returns Expires from HeadObject but not GetObject. A + // conditional GET binds the body to the metadata returned by HEAD, + // even if the publisher overwrites the object between the two calls. + for attempt in 1..=MAX_FETCH_ATTEMPTS { + let head = self + .client + .head_object() + .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 + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .if_match(etag) + .send() + .await + { + Ok(response) => response, + Err(error) if attempt < MAX_FETCH_ATTEMPTS && is_precondition_failed(&error) => { + continue; + } + Err(error) => { + return Err(StoreError::S3(format!("GetObject failed: {error:?}"))); + } + }; + 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 { + 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) -> bool { + error + .as_service_error() + .and_then(|error| error.meta().code()) + == Some("PreconditionFailed") +} + #[derive(Debug, thiserror::Error)] pub enum StoreError { - #[error("S3 GetObject failed: {0}")] + #[error("S3 request failed: {0}")] S3(String), + #[error("S3 object has no Expires header")] + MissingExpiry, + #[error("S3 object has an invalid Expires header: {0}")] + InvalidExpiry(String), + #[error("S3 object expired at {0}")] + Expired(String), + #[error("S3 object has no ETag")] + MissingEtag, +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn parses_future_http_expiry() { + let expires = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60)); + assert!(parse_expiry(Some(&expires)).is_ok()); + } + + #[test] + fn rejects_missing_invalid_and_expired_expiry() { + assert!(matches!(parse_expiry(None), Err(StoreError::MissingExpiry))); + assert!(matches!( + parse_expiry(Some("not-a-date")), + Err(StoreError::InvalidExpiry(_)) + )); + let expired = httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(1)); + assert!(matches!( + parse_expiry(Some(&expired)), + Err(StoreError::Expired(_)) + )); + } } diff --git a/server/.env.example b/server/.env.example index ff095fa..e6c8a2c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -76,9 +76,11 @@ KCA_OCSP_S3_PREFIX=ocsp #KCA_OCSP_S3_ENDPOINT=http://localhost:9000 KCA_OCSP_S3_FORCE_PATH_STYLE=false KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1 -# Republish unchanged snapshots as a liveness heartbeat. Keep this comfortably -# below the responder's s3.stale_grace_seconds (default 60). +# Republish unchanged snapshots as a liveness heartbeat. Each object receives +# an Expires header; the responder rejects it after that timestamp. Keep the +# heartbeat comfortably below the object expiry. KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30 +KCA_OCSP_OBJECT_EXPIRY_SECONDS=60 # --- CORS (optional) ------------------------------------------------------- #CORS_ALLOW_ORIGINS= diff --git a/server/packages/kca-app/src/kca/config.py b/server/packages/kca-app/src/kca/config.py index 7ce6f91..aadbd6a 100644 --- a/server/packages/kca-app/src/kca/config.py +++ b/server/packages/kca-app/src/kca/config.py @@ -197,12 +197,24 @@ class Settings: ocsp_s3_force_path_style: bool ocsp_publish_interval_seconds: int ocsp_heartbeat_interval_seconds: int + ocsp_object_expiry_seconds: int @staticmethod def from_env() -> "Settings": raw_master = os.environ.get("KCA_MASTER_KEY") master_key_is_dev = not raw_master master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY + ocsp_heartbeat_interval_seconds = max( + 1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30")) + ) + ocsp_object_expiry_seconds = max( + 1, int(_env("KCA_OCSP_OBJECT_EXPIRY_SECONDS", "60")) + ) + if ocsp_object_expiry_seconds <= ocsp_heartbeat_interval_seconds: + raise RuntimeError( + "KCA_OCSP_OBJECT_EXPIRY_SECONDS must be greater than " + "KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS" + ) return Settings( database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts( @@ -251,9 +263,8 @@ class Settings: ocsp_publish_interval_seconds=max( 1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1")) ), - ocsp_heartbeat_interval_seconds=max( - 1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30")) - ), + ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds, + ocsp_object_expiry_seconds=ocsp_object_expiry_seconds, ) diff --git a/server/packages/kca-app/src/kca/ocsp.py b/server/packages/kca-app/src/kca/ocsp.py index a37937b..d8eccc7 100644 --- a/server/packages/kca-app/src/kca/ocsp.py +++ b/server/packages/kca-app/src/kca/ocsp.py @@ -22,7 +22,7 @@ from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault log = logging.getLogger(__name__) -SCHEMA = "kca-ocsp-status/v1" +SCHEMA = "kca-ocsp-status/v2" def _iso(value: Optional[datetime]) -> Optional[str]: @@ -73,7 +73,6 @@ async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]: "sha256": ca.fingerprint_sha256.lower(), "subject": cert_subject(ca), }, - "generated_at": _iso(datetime.now(timezone.utc)), "entries": entries, } @@ -115,6 +114,8 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = Body=body, ContentType="application/json", CacheControl="no-store", + Expires=datetime.now(timezone.utc) + + timedelta(seconds=settings.ocsp_object_expiry_seconds), ) return key diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..ab6b1e0 --- /dev/null +++ b/server/tests/test_config.py @@ -0,0 +1,32 @@ +"""Environment configuration tests.""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from kca.config import Settings + + +class OcspExpirySettingsTest(unittest.TestCase): + def test_defaults(self) -> None: + with patch.dict(os.environ, {}, clear=True): + settings = Settings.from_env() + self.assertEqual(30, settings.ocsp_heartbeat_interval_seconds) + self.assertEqual(60, settings.ocsp_object_expiry_seconds) + + def test_expiry_must_exceed_heartbeat(self) -> None: + with patch.dict( + os.environ, + { + "KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS": "30", + "KCA_OCSP_OBJECT_EXPIRY_SECONDS": "30", + }, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "must be greater"): + Settings.from_env() + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index 2c77732..eada6c9 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -941,7 +941,8 @@ class OcspSnapshotTests(unittest.TestCase): payload = response.json() assert payload["enabled"] is False snapshot = payload["snapshot"] - assert snapshot["schema"] == "kca-ocsp-status/v1" + assert snapshot["schema"] == "kca-ocsp-status/v2" + assert "generated_at" not in snapshot assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"] entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]} assert len(snapshot["entries"]) == 1 @@ -1260,7 +1261,10 @@ class OcspSnapshotTests(unittest.TestCase): call = fake.calls[0] assert call["Bucket"] == "test-bucket" assert call["Key"] == f"ocsp/{ca['id']}.json" - assert b"kca-ocsp-status/v1" in call["Body"] + assert b"kca-ocsp-status/v2" in call["Body"] + assert b"generated_at" not in call["Body"] + expires_in = (call["Expires"] - datetime.now(timezone.utc)).total_seconds() + assert 50 < expires_in <= 60 row = await OcspSnapshot.get(ca_id=ca["id"]) assert row.dirty is False assert row.published_at is not None