Fix stale OCSP snapshots indefinitely

- Parse and validate  from snapshot JSON, reject future/missing timestamps
- Enforce snapshot age vs  in cache and stale fallback (no lifetime extension on repeat S3 reads)
- OCSP: , ,
- Remove unused  from signer
- Publisher heartbeats: , republish clean snapshots past interval, preserve dirty-only behavior and concurrency
- Config/env/docker/docs updates for heartbeat

Rolling upgrade: enable publisher heartbeats before deploying the age-enforcing responder so existing snapshots remain within the age window.
This commit is contained in:
2026-10-26 12:06:15 +08:00
parent b59b935d1b
commit aee1025015
12 changed files with 299 additions and 43 deletions
+5 -2
View File
@@ -158,8 +158,11 @@ bucket and ships a standalone Rust/Axum responder on port `2560`.
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 `GetObject` per CA per second.
- If S3 is unavailable, a stale snapshot is served within the configured grace - The publisher refreshes unchanged snapshots every 30 seconds by default. The
period; otherwise the responder returns `tryLater`. 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.
- 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
@@ -113,6 +113,7 @@ services:
KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-} KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-}
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}
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:-}
+17 -4
View File
@@ -27,8 +27,9 @@ 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 JSON
contains all certificates issued by that CA, both valid and revoked. The contains all certificates issued by that CA, both valid and revoked, and a
responder never infers status from a partial revoked-only list. required `generated_at` timestamp. The responder never infers status from a
partial revoked-only list.
## Upgrading from fingerprint-named snapshots ## Upgrading from fingerprint-named snapshots
@@ -97,7 +98,19 @@ build a trust store containing the CA or validate with your normal PKI tooling.
- 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 S3 `GetObject`.
- If S3 fails, a previously cached snapshot is served for - `s3.stale_grace_seconds` is also the hard maximum snapshot age, measured from
`s3.stale_grace_seconds`; otherwise the OCSP response is `tryLater`. `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`.
- 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.
- 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.
+1
View File
@@ -11,6 +11,7 @@ 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 stale_grace_seconds: 60
issuers: issuers:
+92 -16
View File
@@ -3,6 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use chrono::Utc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::snapshot::Snapshot; use crate::snapshot::Snapshot;
@@ -27,7 +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`.
/// ///
/// On refresh failure, data up to `ttl + stale_grace` old is still used. /// Snapshots older than `stale_grace` are never used. On refresh failure,
/// cached data is also limited to `stale_grace` since its last fetch.
pub async fn get<S: ObjectStore + ?Sized>( pub async fn get<S: ObjectStore + ?Sized>(
&self, &self,
store: &S, store: &S,
@@ -38,7 +40,8 @@ impl SnapshotCache {
stale_grace: Duration, stale_grace: Duration,
) -> Result<Arc<Snapshot>, 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 { if value.fetched_at.elapsed() < ttl && snapshot_age_valid(&value.snapshot, stale_grace)
{
return Ok(value.snapshot.clone()); return Ok(value.snapshot.clone());
} }
} }
@@ -49,7 +52,9 @@ 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 value.fetched_at.elapsed() < ttl
&& snapshot_age_valid(&value.snapshot, stale_grace)
{
return Ok(value.snapshot.clone()); return Ok(value.snapshot.clone());
} }
Some(value.clone()) Some(value.clone())
@@ -60,14 +65,17 @@ impl SnapshotCache {
match store.get(key).await { match store.get(key).await {
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) { Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256, expected_ca_id) {
Ok(snapshot) => { Ok(snapshot) => match validate_snapshot_age(&snapshot, stale_grace) {
let snapshot = Arc::new(snapshot); Ok(()) => {
*self.cached.write().await = Some(CachedSnapshot { let snapshot = Arc::new(snapshot);
snapshot: snapshot.clone(), *self.cached.write().await = Some(CachedSnapshot {
fetched_at: Instant::now(), snapshot: snapshot.clone(),
}); fetched_at: Instant::now(),
Ok(snapshot) });
} Ok(snapshot)
}
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.to_string(), stale_grace), Err(error) => self.fallback(stale, error.to_string(), stale_grace),
@@ -81,7 +89,9 @@ impl SnapshotCache {
stale_grace: Duration, stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> { ) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = stale { if let Some(value) = stale {
if value.fetched_at.elapsed() <= stale_grace { if value.fetched_at.elapsed() <= stale_grace
&& snapshot_age_valid(&value.snapshot, stale_grace)
{
tracing::warn!(error = %error, "serving stale OCSP snapshot"); tracing::warn!(error = %error, "serving stale OCSP snapshot");
return Ok(value.snapshot); return Ok(value.snapshot);
} }
@@ -90,6 +100,29 @@ impl SnapshotCache {
} }
} }
fn snapshot_age_valid(snapshot: &Snapshot, stale_grace: Duration) -> bool {
validate_snapshot_age(snapshot, stale_grace).is_ok()
}
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
));
}
Ok(())
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CacheError { pub enum CacheError {
#[error("OCSP snapshot unavailable: {0}")] #[error("OCSP snapshot unavailable: {0}")]
@@ -134,16 +167,17 @@ mod tests {
} }
} }
fn body(sha: &str) -> String { fn body(sha: &str, generated_at: chrono::DateTime<Utc>) -> String {
let generated_at = generated_at.to_rfc3339();
format!( format!(
r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"entries":[{{"serial_hex":"01","status":"valid"}}]}}"# r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"id":"ca","sha256":"{sha}"}},"generated_at":"{generated_at}","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)); let store = FakeStore::new(body(&sha, Utc::now()));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
let (a, b) = tokio::join!( let (a, b) = tokio::join!(
cache.get( cache.get(
@@ -171,7 +205,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn refreshes_after_ttl_and_serves_stale_on_error() { async fn refreshes_after_ttl_and_serves_stale_on_error() {
let sha = sha256_hex(b"ca"); let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha)); let store = FakeStore::new(body(&sha, Utc::now()));
let cache = SnapshotCache::new(); let cache = SnapshotCache::new();
cache cache
.get( .get(
@@ -200,4 +234,46 @@ mod tests {
assert_eq!(stale.entries.len(), 1); assert_eq!(stale.entries.len(), 1);
assert_eq!(store.calls.load(Ordering::SeqCst), 2); 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 cache = SnapshotCache::new();
let result = cache
.get(
&store,
"key",
&sha,
"ca",
Duration::from_millis(1),
Duration::from_secs(1),
)
.await;
assert!(matches!(result, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 1);
}
#[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 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();
tokio::time::sleep(Duration::from_millis(120)).await;
let expired = cache.get(&store, "key", &sha, "ca", ttl, stale_grace).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;
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 3);
}
} }
+59 -10
View File
@@ -8,6 +8,7 @@ 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;
@@ -17,7 +18,7 @@ use x509_ocsp::{
use crate::config::Config; use crate::config::Config;
use crate::issuer::Issuer; use crate::issuer::Issuer;
use crate::signer::{now_time, system_time}; use crate::signer::system_time;
use crate::store::StatusStore; use crate::store::StatusStore;
#[derive(Clone)] #[derive(Clone)]
@@ -74,19 +75,28 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
}; };
let now = match now_time() { let now = SystemTime::now();
let produced_at = match OcspGeneralizedTime::try_from(now) {
Ok(now) => now, Ok(now) => now,
Err(error) => { Err(error) => {
tracing::error!(error = %error, "could not create OCSP time"); tracing::error!(error = %error, "could not create OCSP time");
return ocsp_response(OcspResponse::internal_error()); return ocsp_response(OcspResponse::internal_error());
} }
}; };
let next_update = now_time() let (this_update, next_update) = response_update_times(
.ok() snapshot.generated_at,
.and_then(|_| { state.config.next_update_seconds,
SystemTime::now().checked_add(Duration::from_secs(state.config.next_update_seconds)) state.config.s3.stale_grace_seconds,
}) now,
.and_then(|value| OcspGeneralizedTime::try_from(value).ok()); );
let this_update = match OcspGeneralizedTime::try_from(this_update) {
Ok(value) => value,
Err(error) => {
tracing::error!(error = %error, "could not create OCSP snapshot time");
return ocsp_response(OcspResponse::internal_error());
}
};
let next_update = next_update.and_then(|value| OcspGeneralizedTime::try_from(value).ok());
let mut builder = OcspResponseBuilder::new(issuer.signer_cert.tbs_certificate.subject.clone()); let mut builder = OcspResponseBuilder::new(issuer.signer_cert.tbs_certificate.subject.clone());
for item in &request.tbs_request.request_list { for item in &request.tbs_request.request_list {
@@ -108,7 +118,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
Some(_) => CertStatus::good(), Some(_) => CertStatus::good(),
None => CertStatus::unknown(), None => CertStatus::unknown(),
}; };
let mut single = SingleResponse::new(item.req_cert.clone(), status, now); let mut single = SingleResponse::new(item.req_cert.clone(), status, this_update);
if let Some(next_update) = next_update { if let Some(next_update) = next_update {
single = single.with_next_update(next_update); single = single.with_next_update(next_update);
} }
@@ -123,7 +133,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
let mut signer = issuer.signer.lock().await; let mut signer = issuer.signer.lock().await;
match signer.sign(builder, vec![issuer.signer_cert.clone()], now) { match signer.sign(builder, vec![issuer.signer_cert.clone()], produced_at) {
Ok(response) => ocsp_response(response), Ok(response) => ocsp_response(response),
Err(error) => { Err(error) => {
tracing::error!(issuer = %issuer.name, error = %error, "OCSP signing failed"); tracing::error!(issuer = %issuer.name, error = %error, "OCSP signing failed");
@@ -132,6 +142,20 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
} }
} }
fn response_update_times(
generated_at: DateTime<Utc>,
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)
}
fn ocsp_response(response: OcspResponse) -> Response { fn ocsp_response(response: OcspResponse) -> Response {
match response.to_der() { match response.to_der() {
Ok(bytes) => ( Ok(bytes) => (
@@ -149,3 +173,28 @@ fn ocsp_response(response: OcspResponse) -> Response {
} }
} }
} }
#[cfg(test)]
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);
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);
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))
);
}
}
-6
View File
@@ -83,10 +83,6 @@ pub fn read_certificate(path: &Path) -> Result<Certificate, SignerError> {
Certificate::from_der(&data).map_err(|error| SignerError::Certificate(error.to_string())) Certificate::from_der(&data).map_err(|error| SignerError::Certificate(error.to_string()))
} }
pub fn now_time() -> Result<OcspGeneralizedTime, SignerError> {
Ok(OcspGeneralizedTime::try_from(SystemTime::now())?)
}
pub fn system_time(value: chrono::DateTime<chrono::Utc>) -> SystemTime { pub fn system_time(value: chrono::DateTime<chrono::Utc>) -> SystemTime {
if value.timestamp() >= 0 { if value.timestamp() >= 0 {
SystemTime::UNIX_EPOCH SystemTime::UNIX_EPOCH
@@ -107,6 +103,4 @@ pub enum SignerError {
Certificate(String), Certificate(String),
#[error("OCSP signing failed: {0}")] #[error("OCSP signing failed: {0}")]
Sign(String), Sign(String),
#[error("time conversion failed: {0}")]
Time(#[from] der::Error),
} }
+29 -1
View File
@@ -25,6 +25,7 @@ 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>,
} }
@@ -32,6 +33,7 @@ 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>,
} }
@@ -65,6 +67,9 @@ 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(),
@@ -110,7 +115,10 @@ impl Snapshot {
} }
} }
let _ = raw.issuer.sha256; let _ = raw.issuer.sha256;
Ok(Self { entries }) Ok(Self {
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> {
@@ -170,6 +178,8 @@ 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}")]
@@ -228,6 +238,24 @@ 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]);
+3
View File
@@ -62,6 +62,9 @@ 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
# below the responder's s3.stale_grace_seconds (default 60).
KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS=30
# --- CORS (optional) ------------------------------------------------------- # --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS= #CORS_ALLOW_ORIGINS=
@@ -191,6 +191,7 @@ class Settings:
ocsp_s3_endpoint: Optional[str] ocsp_s3_endpoint: Optional[str]
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
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -245,6 +246,9 @@ 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(
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
),
) )
+15 -4
View File
@@ -10,11 +10,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Optional from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID from uuid import UUID
from kaya.core import KayaApp, KayaMixin from kaya.core import KayaApp, KayaMixin
from tortoise.expressions import Q
from .config import Settings, settings from .config import Settings, settings
from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
@@ -147,14 +148,16 @@ async def mark_all_cas_dirty(vault: Vault) -> None:
class OcspPublisherMixin(KayaMixin): class OcspPublisherMixin(KayaMixin):
"""Background publisher for dirty OCSP snapshots.""" """Background publisher for dirty and due OCSP snapshots."""
def __init__( def __init__(
self, self,
interval_seconds: int, interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]], ensure_context: Callable[[], Awaitable[None]],
heartbeat_interval_seconds: int = 30,
) -> None: ) -> None:
self._interval = max(1, interval_seconds) self._interval = max(1, interval_seconds)
self._heartbeat_interval = max(1, heartbeat_interval_seconds)
self._ensure_context = ensure_context self._ensure_context = ensure_context
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._bootstrapped = False self._bootstrapped = False
@@ -212,7 +215,14 @@ class OcspPublisherMixin(KayaMixin):
# The publisher runs outside an HTTP request task, so it must bind # The publisher runs outside an HTTP request task, so it must bind
# Tortoise's per-loop context before querying models. # Tortoise's per-loop context before querying models.
await self._ensure_context() await self._ensure_context()
rows = await OcspSnapshot.filter(dirty=True).limit(25) heartbeat_before = datetime.now(timezone.utc) - timedelta(
seconds=self._heartbeat_interval
)
rows = await OcspSnapshot.filter(
Q(dirty=True)
| Q(published_at__isnull=True)
| Q(published_at__lte=heartbeat_before)
).limit(25)
for row in rows: for row in rows:
ca = await Certificate.get_or_none(id=row.ca_id) ca = await Certificate.get_or_none(id=row.ca_id)
if ca is None: if ca is None:
@@ -226,7 +236,7 @@ class OcspPublisherMixin(KayaMixin):
published_at = datetime.now(timezone.utc) published_at = datetime.now(timezone.utc)
cleared = await OcspSnapshot.filter( cleared = await OcspSnapshot.filter(
ca_id=row.ca_id, ca_id=row.ca_id,
dirty=True, dirty=row.dirty,
updated_at=row.updated_at, updated_at=row.updated_at,
).update( ).update(
dirty=False, dirty=False,
@@ -257,6 +267,7 @@ def publisher_mixin_from_settings(
return OcspPublisherMixin( return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds, settings_obj.ocsp_publish_interval_seconds,
ensure_context, ensure_context,
settings_obj.ocsp_heartbeat_interval_seconds,
) )
+73
View File
@@ -1038,6 +1038,79 @@ class OcspSnapshotTests(unittest.TestCase):
assert row.dirty is False assert row.dirty is False
assert row.published_at is not None assert row.published_at is not None
@async_test
async def test_publisher_heartbeat_republishes_due_clean_snapshot(self) -> None:
from unittest.mock import patch
from kca import ocsp
from kca.models import OcspSnapshot
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "heartbeat due ca")
ca = await _cert(client, vault_id, ca_key["id"], "Heartbeat Due CA", ca=True)
previous_published_at = datetime.now(timezone.utc) - timedelta(seconds=31)
await OcspSnapshot.create(
ca_id=ca["id"],
dirty=False,
published_at=previous_published_at,
)
published = []
async def fake_publish(publish_ca, snapshot):
published.append(snapshot)
return "key"
async def ensure_context():
return None
mixin = ocsp.OcspPublisherMixin(
1, ensure_context, heartbeat_interval_seconds=30
)
with patch.object(ocsp, "publish_snapshot", fake_publish):
await mixin.run_once()
assert len(published) == 1
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False
assert row.published_at is not None
assert row.published_at != previous_published_at
@async_test
async def test_publisher_heartbeat_skips_recent_clean_snapshot(self) -> None:
from unittest.mock import patch
from kca import ocsp
from kca.models import OcspSnapshot
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "heartbeat recent ca")
ca = await _cert(client, vault_id, ca_key["id"], "Heartbeat Recent CA", ca=True)
await OcspSnapshot.create(
ca_id=ca["id"],
dirty=False,
published_at=datetime.now(timezone.utc),
)
published = []
async def fake_publish(publish_ca, snapshot):
published.append(snapshot)
return "key"
async def ensure_context():
return None
mixin = ocsp.OcspPublisherMixin(
1, ensure_context, heartbeat_interval_seconds=30
)
with patch.object(ocsp, "publish_snapshot", fake_publish):
await mixin.run_once()
assert published == []
@async_test @async_test
async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None: async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None:
import json import json