Use S3 object expiry for OCSP snapshots

This commit is contained in:
2026-10-28 19:39:51 +00:00
parent 9da68a9625
commit a7cfda4167
17 changed files with 321 additions and 238 deletions
+9 -6
View File
@@ -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 configured S3 prefix and uses a delegated OCSP signing certificate/key per
CA. CA.
- 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 `GetObject` per CA per second. concurrent requests cause at most one S3 `HeadObject` plus conditional
- The publisher refreshes unchanged snapshots every 30 seconds by default. The `GetObject` sequence per CA per second. The ETag condition keeps the body and
responder rejects snapshots older than its configured grace period even when expiry metadata from different object versions from being combined.
S3 is readable, and returns `tryLater` when no fresh snapshot is available. - The publisher refreshes unchanged snapshots every 30 seconds by default and
- OCSP `thisUpdate` reflects the snapshot generation time and `nextUpdate` never sets each object's `Expires` header 60 seconds in the future. The responder
extends beyond the snapshot's hard expiry. 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 - 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.
+1
View File
@@ -114,6 +114,7 @@ services:
KCA_OCSP_S3_FORCE_PATH_STYLE: ${KCA_OCSP_S3_FORCE_PATH_STYLE:-false} 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}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
+9 -2
View File
@@ -1075,6 +1075,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47"
[[package]] [[package]]
name = "httpdate" name = "httpdate"
version = "1.0.3" version = "1.0.3"
@@ -1104,7 +1110,7 @@ dependencies = [
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
"httpdate", "httpdate 1.0.3",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.5.10",
@@ -1126,7 +1132,7 @@ dependencies = [
"http 1.5.0", "http 1.5.0",
"http-body 1.1.0", "http-body 1.1.0",
"httparse", "httparse",
"httpdate", "httpdate 1.0.3",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -1333,6 +1339,7 @@ dependencies = [
"der", "der",
"ecdsa", "ecdsa",
"hex", "hex",
"httpdate 0.3.2",
"p256", "p256",
"pkcs8", "pkcs8",
"rsa", "rsa",
+1
View File
@@ -19,6 +19,7 @@ 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"] }
+29 -18
View File
@@ -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 vaults. The responder validates both this ID and the CA certificate fingerprint
against every downloaded snapshot. against every downloaded snapshot.
KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The JSON KCA publishes these objects when `KCA_OCSP_S3_BUCKET` is configured. The
contains all certificates issued by that CA, both valid and revoked, and a `kca-ocsp-status/v2` JSON contains all certificates issued by that CA, both
required `generated_at` timestamp. The responder never infers status from a valid and revoked, without a generation timestamp. Freshness is carried by the
partial revoked-only list. S3 `Expires` header instead. The responder never infers status from a partial
revoked-only list.
## Upgrading from fingerprint-named snapshots ## 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, objects, OCSP requests return `tryLater`. After verifying the new snapshots,
the obsolete fingerprint-named objects can be removed. 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
@@ -97,20 +105,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: `s3.cache_ttl_ms` (default 1000 ms).
- Concurrent requests for the same CA share one S3 `GetObject`. - Concurrent requests for the same CA share one refresh. Each refresh performs
- `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from `HeadObject` to obtain `Expires` and the ETag, then `GetObject` with
`generated_at`. Expired or future-dated snapshots are rejected even when S3 `If-Match` for that ETag. This supports RustFS, which does not return the
remains readable; if no valid cached snapshot remains, OCSP returns stored `Expires` value from its local `GetObject` path, and prevents mixing
`tryLater`. metadata and bodies from different object versions. Concurrent overwrites
- If a refresh fails, a cached snapshot is served only while both its time since cause a bounded retry of the complete HEAD/GET sequence.
the last successful fetch and its `generated_at` age remain within - `Expires` is the hard snapshot expiry. Missing, malformed and expired values
`s3.stale_grace_seconds`. 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 republishes unchanged snapshots every
`KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds). Keep that interval `KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS` (default 30 seconds) and sets `Expires`
comfortably below the responder's `s3.stale_grace_seconds` (default 60). For using `KCA_OCSP_OBJECT_EXPIRY_SECONDS` (default 60 seconds). Keep the
a rolling upgrade, enable publisher heartbeats before the freshness-enforcing heartbeat comfortably below the object expiry.
responder so existing snapshots remain within the age limit. - OCSP `thisUpdate` and `producedAt` use the response time. `nextUpdate` is
- OCSP `thisUpdate` is the snapshot's `generated_at`. `nextUpdate` is capped at capped at the snapshot's hard expiry.
the snapshot's hard expiry, while `producedAt` is the response signing time.
- With multiple CAs, the TTL is per CA. If you need a global one-request-per- - 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. second limit across all CAs, that requires a shared rate limiter.
-2
View File
@@ -11,8 +11,6 @@ s3:
# endpoint: https://s3.example.com # endpoint: https://s3.example.com
# force_path_style: true # force_path_style: true
cache_ttl_ms: 1000 cache_ttl_ms: 1000
# Hard maximum snapshot age. Keep comfortably above KCA's heartbeat interval.
stale_grace_seconds: 60
issuers: issuers:
- name: Example Root CA - name: Example Root CA
+80 -113
View File
@@ -1,9 +1,8 @@
//! One-second, single-flight cache of per-CA status snapshots. //! One-second, single-flight cache of per-CA status snapshots.
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant, SystemTime};
use chrono::Utc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::snapshot::Snapshot; use crate::snapshot::Snapshot;
@@ -11,7 +10,8 @@ use crate::store::ObjectStore;
#[derive(Clone)] #[derive(Clone)]
pub struct CachedSnapshot { pub struct CachedSnapshot {
snapshot: Arc<Snapshot>, pub snapshot: Arc<Snapshot>,
pub expires_at: SystemTime,
fetched_at: Instant, fetched_at: Instant,
} }
@@ -28,8 +28,8 @@ impl SnapshotCache {
/// Return a snapshot, refreshing from S3 at most once per `ttl`. /// Return a snapshot, refreshing from S3 at most once per `ttl`.
/// ///
/// Snapshots older than `stale_grace` are never used. On refresh failure, /// A cached snapshot is never used after its S3 `Expires` timestamp,
/// cached data is also limited to `stale_grace` since its last fetch. /// including when a refresh fails.
pub async fn get<S: ObjectStore + ?Sized>( pub async fn get<S: ObjectStore + ?Sized>(
&self, &self,
store: &S, store: &S,
@@ -37,12 +37,10 @@ impl SnapshotCache {
expected_sha256: &str, expected_sha256: &str,
expected_ca_id: &str, expected_ca_id: &str,
ttl: Duration, ttl: Duration,
stale_grace: Duration, ) -> Result<CachedSnapshot, CacheError> {
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = self.cached.read().await.as_ref() { if let Some(value) = self.cached.read().await.as_ref() {
if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace) if fresh_cache_entry_valid(value, ttl) {
{ return Ok(value.clone());
return Ok(value.snapshot.clone());
} }
} }
@@ -52,10 +50,8 @@ impl SnapshotCache {
let stale = { let stale = {
let cache = self.cached.read().await; let cache = self.cached.read().await;
if let Some(value) = cache.as_ref() { if let Some(value) = cache.as_ref() {
if value.fetched_at.elapsed() < ttl if fresh_cache_entry_valid(value, ttl) {
&& snapshot_age_valid(&value.snapshot, stale_grace) return Ok(value.clone());
{
return Ok(value.snapshot.clone());
} }
Some(value.clone()) Some(value.clone())
} else { } else {
@@ -64,21 +60,25 @@ impl SnapshotCache {
}; };
match store.get(key).await { match store.get(key).await {
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) { Ok(stored) => {
Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) { let parsed = validate_expiry(stored.expires_at).and_then(|()| {
Ok(()) => { Snapshot::parse(&stored.body, expected_sha256, expected_ca_id)
let snapshot = Arc::new(snapshot); .map_err(|error| error.to_string())
*self.cached.write().await = Some(CachedSnapshot { });
snapshot: snapshot.clone(), match parsed {
Ok(snapshot) => {
let cached = CachedSnapshot {
snapshot: Arc::new(snapshot),
expires_at: stored.expires_at,
fetched_at: Instant::now(), 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),
}, }
Err(error) => self.fallback(stale, error.to_string(), stale_grace), }
}, Err(error) => self.fallback(stale, error.to_string()),
Err(error) => self.fallback(stale, error.to_string(), stale_grace),
} }
} }
@@ -86,41 +86,34 @@ impl SnapshotCache {
&self, &self,
stale: Option<CachedSnapshot>, stale: Option<CachedSnapshot>,
error: String, error: String,
stale_grace: Duration, ) -> Result<CachedSnapshot, CacheError> {
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = stale { if let Some(value) = stale {
if value.fetched_at.elapsed() <= stale_grace if expiry_valid(value.expires_at) {
&& snapshot_age_valid(&value.snapshot, stale_grace) tracing::warn!(error = %error, "serving unexpired cached OCSP snapshot after refresh failure");
{ return Ok(value);
tracing::warn!(error = %error, "serving stale OCSP snapshot");
return Ok(value.snapshot);
} }
} }
Err(CacheError::Unavailable(error)) Err(CacheError::Unavailable(error))
} }
} }
fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool { fn fresh_cache_entry_valid(value: &CachedSnapshot, ttl: Duration) -> bool {
validate_snapshot_age(snapshot, stale_grace).is_ok() value.fetched_at.elapsed() < ttl && expiry_valid(value.expires_at)
} }
fn validate_snapshot_age(snapshot: &Snapshot, stale_grace: Duration) -> Result<(), String> { fn expiry_valid(expires_at: SystemTime) -> bool {
let age = Utc::now() expires_at > SystemTime::now()
.signed_duration_since(snapshot.generated_at) }
.to_std()
.map_err(|_| { fn validate_expiry(expires_at: SystemTime) -> Result<(), String> {
format!( if expiry_valid(expires_at) {
"snapshot generation time {} is in the future", Ok(())
snapshot.generated_at } else {
) Err(format!(
})?; "snapshot expired at {}",
if age > stale_grace { httpdate::fmt_http_date(expires_at)
return Err(format!( ))
"snapshot generated at {} exceeds maximum age {stale_grace:?}",
snapshot.generated_at
));
} }
Ok(())
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -138,18 +131,29 @@ mod tests {
use super::*; use super::*;
use crate::snapshot::sha256_hex; use crate::snapshot::sha256_hex;
use crate::store::{ObjectStore, StoreError}; use crate::store::{ObjectStore, StoreError, StoredSnapshot};
struct FakeStore { struct FakeStore {
body: String, body: String,
expires_at: SystemTime,
calls: AtomicUsize, calls: AtomicUsize,
fail: AtomicBool, fail: AtomicBool,
} }
impl FakeStore { impl FakeStore {
fn new(body: String) -> Self { fn new(body: String, lifetime: Duration) -> Self {
Self { Self {
body, 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), calls: AtomicUsize::new(0),
fail: AtomicBool::new(false), fail: AtomicBool::new(false),
} }
@@ -158,98 +162,65 @@ mod tests {
#[async_trait] #[async_trait]
impl ObjectStore for FakeStore { impl ObjectStore for FakeStore {
async fn get(&self, _key: &str) -> Result<Bytes, 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::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<Utc>) -> String { fn body(sha: &str) -> String {
let generated_at = generated_at.to_rfc3339();
format!( 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] #[tokio::test]
async fn single_flight_refreshes_once() { async fn single_flight_refreshes_once() {
let sha = sha256_hex(b"ca"); 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 cache = SnapshotCache::new();
let (a, b) = tokio::join!( let (a, b) = tokio::join!(
cache.get( cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),),
&store, cache.get(&store, "key", &sha, "ca", Duration::from_millis(100),)
"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)
)
); );
assert!(a.is_ok()); assert!(a.is_ok());
assert!(b.is_ok()); assert!(b.is_ok());
assert_eq!(store.calls.load(Ordering::SeqCst), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 1);
assert_eq!(a.unwrap().expires_at, store.expires_at);
} }
#[tokio::test] #[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 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 cache = SnapshotCache::new();
cache cache
.get( .get(&store, "key", &sha, "ca", Duration::from_millis(20))
&store,
"key",
&sha,
"ca",
Duration::from_millis(20),
Duration::from_secs(1),
)
.await .await
.unwrap(); .unwrap();
tokio::time::sleep(Duration::from_millis(30)).await; tokio::time::sleep(Duration::from_millis(30)).await;
store.fail.store(true, Ordering::SeqCst); store.fail.store(true, Ordering::SeqCst);
let stale = cache let stale = cache
.get( .get(&store, "key", &sha, "ca", Duration::from_millis(20))
&store,
"key",
&sha,
"ca",
Duration::from_millis(20),
Duration::from_secs(1),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(stale.entries.len(), 1); assert_eq!(stale.snapshot.entries.len(), 1);
assert_eq!(store.calls.load(Ordering::SeqCst), 2); assert_eq!(store.calls.load(Ordering::SeqCst), 2);
} }
#[tokio::test] #[tokio::test]
async fn rejects_expired_snapshot_from_healthy_store() { async fn rejects_expired_snapshot_from_healthy_store() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let generated_at = Utc::now() - chrono::Duration::seconds(30); let store = FakeStore::expired(body(&sha));
let store = FakeStore::new(body(&sha, generated_at));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
let result = cache let result = cache
.get( .get(&store, "key", &sha, "ca", Duration::from_millis(1))
&store,
"key",
&sha,
"ca",
Duration::from_millis(1),
Duration::from_secs(1),
)
.await; .await;
assert!(matches!(result, Err(CacheError::Unavailable(_)))); assert!(matches!(result, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 1); assert_eq!(store.calls.load(Ordering::SeqCst), 1);
@@ -258,21 +229,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn successful_fetches_do_not_extend_snapshot_lifetime() { async fn successful_fetches_do_not_extend_snapshot_lifetime() {
let sha = sha256_hex(b"ca"); 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 cache = SnapshotCache::new();
let ttl = Duration::from_secs(1); let ttl = Duration::from_secs(1);
let stale_grace = Duration::from_millis(100); cache.get(&store, "key", &sha, "ca", ttl).await.unwrap();
cache
.get(&store, "key", &sha, "ca", ttl, stale_grace)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(120)).await; 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(_)))); assert!(matches!(expired, Err(CacheError::Unavailable(_))));
store.fail.store(true, Ordering::SeqCst); 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!(matches!(unavailable, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 3); assert_eq!(store.calls.load(Ordering::SeqCst), 3);
} }
-7
View File
@@ -37,8 +37,6 @@ pub struct S3Config {
pub force_path_style: bool, 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,
#[serde(default = "default_stale_grace_seconds")]
pub stale_grace_seconds: u64,
} }
impl Default for S3Config { impl Default for S3Config {
@@ -50,7 +48,6 @@ impl Default for S3Config {
endpoint: None, endpoint: None,
force_path_style: false, force_path_style: false,
cache_ttl_ms: default_cache_ttl_ms(), 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 1000
} }
fn default_stale_grace_seconds() -> u64 {
60
}
fn default_ocsp_next_update_seconds() -> u64 { fn default_ocsp_next_update_seconds() -> u64 {
10 10
} }
+1 -2
View File
@@ -112,7 +112,7 @@ impl Issuer {
&self, &self,
store: &S, store: &S,
config: &crate::config::S3Config, config: &crate::config::S3Config,
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> { ) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> {
let key = config.object_key(&self.ca_id); let key = config.object_key(&self.ca_id);
self.cache self.cache
.get( .get(
@@ -121,7 +121,6 @@ impl Issuer {
&self.fingerprint, &self.fingerprint,
&self.ca_id, &self.ca_id,
Duration::from_millis(config.cache_ttl_ms), Duration::from_millis(config.cache_ttl_ms),
Duration::from_secs(config.stale_grace_seconds),
) )
.await .await
} }
+17 -29
View File
@@ -8,7 +8,6 @@ use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use base64::Engine; use base64::Engine;
use chrono::{DateTime, Utc};
use der::{Decode, Encode}; use der::{Decode, Encode};
use x509_cert::ext::pkix::CrlReason; use x509_cert::ext::pkix::CrlReason;
use x509_ocsp::builder::OcspResponseBuilder; use x509_ocsp::builder::OcspResponseBuilder;
@@ -67,13 +66,14 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::unauthorized()); return ocsp_response(OcspResponse::unauthorized());
} }
let snapshot = match issuer.snapshot(&state.store, &state.config.s3).await { let cached = match issuer.snapshot(&state.store, &state.config.s3).await {
Ok(snapshot) => snapshot, Ok(cached) => cached,
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) {
@@ -83,12 +83,8 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::internal_error()); return ocsp_response(OcspResponse::internal_error());
} }
}; };
let (this_update, next_update) = response_update_times( let (this_update, next_update) =
snapshot.generated_at, response_update_times(cached.expires_at, state.config.next_update_seconds, now);
state.config.next_update_seconds,
state.config.s3.stale_grace_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) => {
@@ -143,17 +139,14 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
fn response_update_times( fn response_update_times(
generated_at: DateTime<Utc>, expires_at: SystemTime,
next_update_seconds: u64, next_update_seconds: u64,
stale_grace_seconds: u64,
now: SystemTime, now: SystemTime,
) -> (SystemTime, Option<SystemTime>) { ) -> (SystemTime, Option<SystemTime>) {
let this_update = system_time(generated_at);
let expires_at = this_update.checked_add(Duration::from_secs(stale_grace_seconds));
let next_update = now let next_update = now
.checked_add(Duration::from_secs(next_update_seconds)) .checked_add(Duration::from_secs(next_update_seconds))
.and_then(|next| expires_at.map(|expires| next.min(expires))); .map(|next| next.min(expires_at));
(this_update, next_update) (now, next_update)
} }
fn ocsp_response(response: OcspResponse) -> Response { fn ocsp_response(response: OcspResponse) -> Response {
@@ -179,22 +172,17 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn update_times_use_snapshot_age_and_bound_next_update() { fn update_times_use_response_time_and_bound_next_update_to_expiry() {
let generated_at = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600);
.unwrap() let expires_at = base + Duration::from_secs(60);
.with_timezone(&Utc);
let generated_at_system = system_time(generated_at);
let now = generated_at_system + Duration::from_secs(5); let now = base + Duration::from_secs(5);
let (this_update, next_update) = response_update_times(generated_at, 10, 60, now); let (this_update, next_update) = response_update_times(expires_at, 10, now);
assert_eq!(this_update, generated_at_system); 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 = generated_at_system + Duration::from_secs(55); let now = base + Duration::from_secs(55);
let (_, next_update) = response_update_times(generated_at, 10, 60, now); let (_, next_update) = response_update_times(expires_at, 10, now);
assert_eq!( assert_eq!(next_update, Some(expires_at));
next_update,
Some(generated_at_system + Duration::from_secs(60))
);
} }
} }
+3 -32
View File
@@ -8,7 +8,7 @@ use sha1::{Digest as _, Sha1};
use sha2::Sha256; use sha2::Sha256;
use x509_cert::ext::pkix::CrlReason; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status { pub enum Status {
@@ -25,7 +25,6 @@ pub struct StatusEntry {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Snapshot { pub struct Snapshot {
pub generated_at: DateTime<Utc>,
pub entries: HashMap<Vec<u8>, StatusEntry>, pub entries: HashMap<Vec<u8>, StatusEntry>,
} }
@@ -33,7 +32,6 @@ pub struct Snapshot {
struct RawSnapshot { struct RawSnapshot {
schema: String, schema: String,
issuer: RawIssuer, issuer: RawIssuer,
generated_at: DateTime<Utc>,
#[serde(default)] #[serde(default)]
entries: Vec<RawEntry>, entries: Vec<RawEntry>,
} }
@@ -67,9 +65,6 @@ impl Snapshot {
if raw.schema != SCHEMA { if raw.schema != SCHEMA {
return Err(SnapshotError::Schema(raw.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) { if !raw.issuer.id.eq_ignore_ascii_case(expected_ca_id) {
return Err(SnapshotError::IssuerIdMismatch { return Err(SnapshotError::IssuerIdMismatch {
expected: expected_ca_id.to_string(), expected: expected_ca_id.to_string(),
@@ -115,10 +110,7 @@ impl Snapshot {
} }
} }
let _ = raw.issuer.sha256; let _ = raw.issuer.sha256;
Ok(Self { Ok(Self { entries })
generated_at: raw.generated_at,
entries,
})
} }
pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> { pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> {
@@ -178,8 +170,6 @@ pub enum SnapshotError {
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("unsupported snapshot schema {0:?}")] #[error("unsupported snapshot schema {0:?}")]
Schema(String), Schema(String),
#[error("snapshot generation time {0} is in the future")]
FutureGeneration(DateTime<Utc>),
#[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")] #[error("snapshot issuer ID {actual} does not match configured CA ID {expected}")]
IssuerIdMismatch { expected: String, actual: String }, IssuerIdMismatch { expected: String, actual: String },
#[error("snapshot issuer {actual} does not match configured CA {expected}")] #[error("snapshot issuer {actual} does not match configured CA {expected}")]
@@ -201,9 +191,8 @@ mod tests {
fn body(sha: &str) -> String { fn body(sha: &str) -> String {
format!( format!(
r#"{{ r#"{{
"schema": "kca-ocsp-status/v1", "schema": "kca-ocsp-status/v2",
"issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}}, "issuer": {{"id": "ca", "sha256": "{sha}", "subject": "CN=CA"}},
"generated_at": "2026-01-01T00:00:00Z",
"entries": [ "entries": [
{{"serial_hex": "01ab", "status": "valid", "not_after": "2027-01-01T00:00:00Z"}}, {{"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"}} {{"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()); 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::<Vec<_>>()
.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] #[test]
fn normalizes_serials() { fn normalizes_serials() {
assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]); assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]);
+112 -18
View File
@@ -1,11 +1,17 @@
//! S3-compatible object store access. //! S3-compatible object store access.
use std::time::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 aws_sdk_s3::Client;
use bytes::Bytes; use bytes::Bytes;
use crate::config::S3Config; use crate::config::S3Config;
const MAX_FETCH_ATTEMPTS: usize = 3;
#[derive(Clone)] #[derive(Clone)]
pub struct StatusStore { pub struct StatusStore {
client: Client, client: Client,
@@ -31,34 +37,122 @@ impl StatusStore {
} }
} }
#[derive(Debug)]
pub struct StoredSnapshot {
pub body: Bytes,
pub expires_at: SystemTime,
}
#[async_trait] #[async_trait]
pub trait ObjectStore: Send + Sync { pub trait ObjectStore: Send + Sync {
async fn get(&self, key: &str) -> Result<Bytes, StoreError>; async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError>;
} }
#[async_trait] #[async_trait]
impl ObjectStore for StatusStore { impl ObjectStore for StatusStore {
async fn get(&self, key: &str) -> Result<Bytes, StoreError> { async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> {
let response = self // RustFS returns Expires from HeadObject but not GetObject. A
.client // conditional GET binds the body to the metadata returned by HEAD,
.get_object() // even if the publisher overwrites the object between the two calls.
.bucket(&self.bucket) for attempt in 1..=MAX_FETCH_ATTEMPTS {
.key(key) let head = self
.send() .client
.await .head_object()
.map_err(|error| StoreError::S3(format!("{error:?}")))?; .bucket(&self.bucket)
let data = response .key(key)
.body .send()
.collect() .await
.await .map_err(|error| StoreError::S3(format!("HeadObject failed: {error:?}")))?;
.map_err(|error| StoreError::S3(format!("{error:?}")))? let expires_at = parse_expiry(head.expires_string.as_deref())?;
.into_bytes(); let etag = head
Ok(data) .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<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 GetObject failed: {0}")] #[error("S3 request failed: {0}")]
S3(String), 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(_))
));
}
} }
+4 -2
View File
@@ -76,9 +76,11 @@ KCA_OCSP_S3_PREFIX=ocsp
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000 #KCA_OCSP_S3_ENDPOINT=http://localhost:9000
KCA_OCSP_S3_FORCE_PATH_STYLE=false 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. Keep this comfortably # Republish unchanged snapshots as a liveness heartbeat. Each object receives
# below the responder's s3.stale_grace_seconds (default 60). # 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_HEARTBEAT_INTERVAL_SECONDS=30
KCA_OCSP_OBJECT_EXPIRY_SECONDS=60
# --- CORS (optional) ------------------------------------------------------- # --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS= #CORS_ALLOW_ORIGINS=
+14 -3
View File
@@ -197,12 +197,24 @@ class Settings:
ocsp_s3_force_path_style: bool 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
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY") raw_master = os.environ.get("KCA_MASTER_KEY")
master_key_is_dev = not raw_master master_key_is_dev = not raw_master
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY 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( return Settings(
database_url=os.environ.get("DATABASE_URL") database_url=os.environ.get("DATABASE_URL")
or _database_url_from_parts( or _database_url_from_parts(
@@ -251,9 +263,8 @@ class Settings:
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=max( ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds,
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30")) ocsp_object_expiry_seconds=ocsp_object_expiry_seconds,
),
) )
+3 -2
View File
@@ -22,7 +22,7 @@ from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SCHEMA = "kca-ocsp-status/v1" SCHEMA = "kca-ocsp-status/v2"
def _iso(value: Optional[datetime]) -> Optional[str]: 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(), "sha256": ca.fingerprint_sha256.lower(),
"subject": cert_subject(ca), "subject": cert_subject(ca),
}, },
"generated_at": _iso(datetime.now(timezone.utc)),
"entries": entries, "entries": entries,
} }
@@ -115,6 +114,8 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] =
Body=body, Body=body,
ContentType="application/json", ContentType="application/json",
CacheControl="no-store", CacheControl="no-store",
Expires=datetime.now(timezone.utc)
+ timedelta(seconds=settings.ocsp_object_expiry_seconds),
) )
return key return key
+32
View File
@@ -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()
+6 -2
View File
@@ -941,7 +941,8 @@ class OcspSnapshotTests(unittest.TestCase):
payload = response.json() payload = response.json()
assert payload["enabled"] is False assert payload["enabled"] is False
snapshot = payload["snapshot"] 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"] assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]} entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
assert len(snapshot["entries"]) == 1 assert len(snapshot["entries"]) == 1
@@ -1260,7 +1261,10 @@ class OcspSnapshotTests(unittest.TestCase):
call = fake.calls[0] call = fake.calls[0]
assert call["Bucket"] == "test-bucket" assert call["Bucket"] == "test-bucket"
assert call["Key"] == f"ocsp/{ca['id']}.json" 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"]) 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