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
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.
+1
View File
@@ -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:-}
+9 -2
View File
@@ -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",
+1
View File
@@ -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"] }
+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
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.
-2
View File
@@ -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
+79 -112
View File
@@ -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<Snapshot>,
pub snapshot: Arc<Snapshot>,
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<S: ObjectStore + ?Sized>(
&self,
store: &S,
@@ -37,12 +37,10 @@ impl SnapshotCache {
expected_sha256: &str,
expected_ca_id: &str,
ttl: Duration,
stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
) -> Result<CachedSnapshot, CacheError> {
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(),
fetched_at: Instant::now(),
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())
});
Ok(snapshot)
match parsed {
Ok(snapshot) => {
let cached = CachedSnapshot {
snapshot: Arc::new(snapshot),
expires_at: stored.expires_at,
fetched_at: Instant::now(),
};
*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<CachedSnapshot>,
error: String,
stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
) -> Result<CachedSnapshot, CacheError> {
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)
))
}
}
#[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<Bytes, StoreError> {
async fn get(&self, _key: &str) -> Result<StoredSnapshot, StoreError> {
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<Utc>) -> 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);
}
-7
View File
@@ -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
}
+1 -2
View File
@@ -112,7 +112,7 @@ impl Issuer {
&self,
store: &S,
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);
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
}
+17 -29
View File
@@ -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<Utc>,
expires_at: SystemTime,
next_update_seconds: u64,
stale_grace_seconds: u64,
now: 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
.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));
}
}
+3 -32
View File
@@ -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<Utc>,
pub entries: HashMap<Vec<u8>, StatusEntry>,
}
@@ -33,7 +32,6 @@ pub struct Snapshot {
struct RawSnapshot {
schema: String,
issuer: RawIssuer,
generated_at: DateTime<Utc>,
#[serde(default)]
entries: Vec<RawEntry>,
}
@@ -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<Utc>),
#[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::<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]
fn normalizes_serials() {
assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]);
+103 -9
View File
@@ -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<Bytes, StoreError>;
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError>;
}
#[async_trait]
impl ObjectStore for StatusStore {
async fn get(&self, key: &str) -> Result<Bytes, StoreError> {
let response = self
async fn get(&self, key: &str) -> Result<StoredSnapshot, StoreError> {
// 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
.get_object()
.head_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|error| StoreError::S3(format!("{error:?}")))?;
let data = response
.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!("{error:?}")))?
.map_err(|error| StoreError::S3(format!("GetObject body failed: {error:?}")))?
.into_bytes();
Ok(data)
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)]
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(_))
));
}
}
+4 -2
View File
@@ -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=
+14 -3
View File
@@ -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,
)
+3 -2
View File
@@ -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
+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()
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