Publish OCSP snapshots through Redis

This commit is contained in:
2026-10-28 20:27:07 +00:00
parent a7cfda4167
commit df98ada177
24 changed files with 634 additions and 2460 deletions
+42 -12
View File
@@ -26,9 +26,9 @@ impl SnapshotCache {
Self::default()
}
/// Return a snapshot, refreshing from S3 at most once per `ttl`.
/// Return a snapshot, refreshing from Redis at most once per `ttl`.
///
/// A cached snapshot is never used after its S3 `Expires` timestamp,
/// A cached snapshot is never used after its Redis key expires,
/// including when a refresh fails.
pub async fn get<S: ObjectStore + ?Sized>(
&self,
@@ -60,16 +60,22 @@ impl SnapshotCache {
};
match store.get(key).await {
Ok(stored) => {
let parsed = validate_expiry(stored.expires_at).and_then(|()| {
Snapshot::parse(&stored.body, expected_sha256, expected_ca_id)
Ok(crate::store::StoredSnapshot::Missing) => {
// A key that expired or was deleted must not be replaced by
// an older cached status on a later Redis failure.
*self.cached.write().await = None;
Err(CacheError::Missing)
}
Ok(crate::store::StoredSnapshot::Present { body, expires_at }) => {
let parsed = validate_expiry(expires_at).and_then(|()| {
Snapshot::parse(&body, expected_sha256, expected_ca_id)
.map_err(|error| error.to_string())
});
match parsed {
Ok(snapshot) => {
let cached = CachedSnapshot {
snapshot: Arc::new(snapshot),
expires_at: stored.expires_at,
expires_at,
fetched_at: Instant::now(),
};
*self.cached.write().await = Some(cached.clone());
@@ -109,15 +115,14 @@ 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)
))
Err(format!("snapshot Redis key expired at {expires_at:?}"))
}
}
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("OCSP snapshot is missing")]
Missing,
#[error("OCSP snapshot unavailable: {0}")]
Unavailable(String),
}
@@ -138,6 +143,7 @@ mod tests {
expires_at: SystemTime,
calls: AtomicUsize,
fail: AtomicBool,
missing: AtomicBool,
}
impl FakeStore {
@@ -147,6 +153,7 @@ mod tests {
expires_at: SystemTime::now() + lifetime,
calls: AtomicUsize::new(0),
fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
}
}
@@ -156,6 +163,7 @@ mod tests {
expires_at: SystemTime::now() - Duration::from_secs(1),
calls: AtomicUsize::new(0),
fail: AtomicBool::new(false),
missing: AtomicBool::new(false),
}
}
}
@@ -165,9 +173,12 @@ mod tests {
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()));
return Err(StoreError::Redis("offline".to_string()));
}
Ok(StoredSnapshot {
if self.missing.load(Ordering::SeqCst) {
return Ok(StoredSnapshot::Missing);
}
Ok(StoredSnapshot::Present {
body: Bytes::from(self.body.clone()),
expires_at: self.expires_at,
})
@@ -243,4 +254,23 @@ mod tests {
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
assert_eq!(store.calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn missing_key_clears_cached_status_instead_of_reviving_it() {
let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha), Duration::from_secs(1));
let cache = SnapshotCache::new();
let ttl = Duration::from_millis(20);
cache.get(&store, "key", &sha, "ca", ttl).await.unwrap();
store.missing.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(30)).await;
let missing = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(missing, Err(CacheError::Missing)));
store.missing.store(false, Ordering::SeqCst);
store.fail.store(true, Ordering::SeqCst);
let unavailable = cache.get(&store, "key", &sha, "ca", ttl).await;
assert!(matches!(unavailable, Err(CacheError::Unavailable(_))));
}
}
+19 -23
View File
@@ -10,7 +10,7 @@ pub struct Config {
#[serde(default = "default_listen")]
pub listen: SocketAddr,
#[serde(default)]
pub s3: S3Config,
pub redis: RedisConfig,
#[serde(default)]
pub issuers: Vec<IssuerConfig>,
#[serde(default = "default_ocsp_next_update_seconds")]
@@ -27,39 +27,32 @@ pub struct IssuerConfig {
}
#[derive(Debug, Clone, Deserialize)]
pub struct S3Config {
pub bucket: String,
pub struct RedisConfig {
pub url: String,
#[serde(default = "default_prefix")]
pub prefix: String,
pub region: Option<String>,
pub endpoint: Option<String>,
#[serde(default)]
pub force_path_style: bool,
#[serde(default = "default_cache_ttl_ms")]
pub cache_ttl_ms: u64,
}
impl Default for S3Config {
impl Default for RedisConfig {
fn default() -> Self {
Self {
bucket: String::new(),
url: String::new(),
prefix: default_prefix(),
region: None,
endpoint: None,
force_path_style: false,
cache_ttl_ms: default_cache_ttl_ms(),
}
}
}
impl S3Config {
pub fn object_key(&self, ca_id: &str) -> String {
let prefix = self.prefix.trim_matches('/');
let name = format!("{}.json", ca_id.to_lowercase());
impl RedisConfig {
pub fn key(&self, ca_id: &str) -> String {
let prefix = self.prefix.trim_matches(['/', ':']);
let name = ca_id.to_lowercase();
if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
format!("{prefix}:{name}")
}
}
}
@@ -90,8 +83,11 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>>
if config.issuers.is_empty() {
return Err("configuration must contain at least one issuer".into());
}
if config.s3.bucket.trim().is_empty() {
return Err("s3.bucket must be configured".into());
if let Ok(url) = std::env::var("OCSP_REDIS_URL") {
config.redis.url = url;
}
if config.redis.url.trim().is_empty() {
return Err("redis.url must be configured".into());
}
if let Ok(listen) = std::env::var("OCSP_LISTEN") {
config.listen = listen.parse()?;
@@ -104,14 +100,14 @@ mod tests {
use super::*;
#[test]
fn object_key_uses_ca_id() {
let config = S3Config {
fn redis_key_uses_ca_id() {
let config = RedisConfig {
prefix: "ocsp".to_string(),
..Default::default()
};
assert_eq!(
config.object_key("123E4567-E89B-12D3-A456-426614174000"),
"ocsp/123e4567-e89b-12d3-a456-426614174000.json"
config.key("123E4567-E89B-12D3-A456-426614174000"),
"ocsp:123e4567-e89b-12d3-a456-426614174000"
);
}
}
+2 -2
View File
@@ -111,9 +111,9 @@ impl Issuer {
pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>(
&self,
store: &S,
config: &crate::config::S3Config,
config: &crate::config::RedisConfig,
) -> Result<crate::cache::CachedSnapshot, crate::cache::CacheError> {
let key = config.object_key(&self.ca_id);
let key = config.key(&self.ca_id);
self.cache
.get(
store,
+1 -1
View File
@@ -32,7 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.init();
let config = config::load_config()?;
let store = StatusStore::new(&config.s3).await;
let store = StatusStore::new(&config.redis).await?;
let mut issuers = Vec::with_capacity(config.issuers.len());
for issuer_config in &config.issuers {
let issuer = Issuer::load(issuer_config)?;
+16 -8
View File
@@ -66,14 +66,16 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
return ocsp_response(OcspResponse::unauthorized());
}
let cached = match issuer.snapshot(&state.store, &state.config.s3).await {
Ok(cached) => cached,
let (snapshot, expires_at) = match issuer.snapshot(&state.store, &state.config.redis).await {
Ok(cached) => (cached.snapshot, Some(cached.expires_at)),
Err(crate::cache::CacheError::Missing) => {
(Arc::new(crate::snapshot::Snapshot::default()), None)
}
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) {
@@ -84,7 +86,7 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
}
};
let (this_update, next_update) =
response_update_times(cached.expires_at, state.config.next_update_seconds, now);
response_update_times(expires_at, state.config.next_update_seconds, now);
let this_update = match OcspGeneralizedTime::try_from(this_update) {
Ok(value) => value,
Err(error) => {
@@ -139,13 +141,16 @@ async fn handle_request(state: &AppState, body: &[u8]) -> Response {
}
fn response_update_times(
expires_at: SystemTime,
expires_at: Option<SystemTime>,
next_update_seconds: u64,
now: SystemTime,
) -> (SystemTime, Option<SystemTime>) {
let next_update = now
.checked_add(Duration::from_secs(next_update_seconds))
.map(|next| next.min(expires_at));
.map(|next| match expires_at {
Some(expires_at) => next.min(expires_at),
None => next,
});
(now, next_update)
}
@@ -177,12 +182,15 @@ mod tests {
let expires_at = base + Duration::from_secs(60);
let now = base + Duration::from_secs(5);
let (this_update, next_update) = response_update_times(expires_at, 10, now);
let (this_update, next_update) = response_update_times(Some(expires_at), 10, now);
assert_eq!(this_update, now);
assert_eq!(next_update, Some(now + Duration::from_secs(10)));
let now = base + Duration::from_secs(55);
let (_, next_update) = response_update_times(expires_at, 10, now);
let (_, next_update) = response_update_times(Some(expires_at), 10, now);
assert_eq!(next_update, Some(expires_at));
let (_, next_update) = response_update_times(None, 10, now);
assert_eq!(next_update, Some(now + Duration::from_secs(10)));
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub struct StatusEntry {
pub reason: Option<CrlReason>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub entries: HashMap<Vec<u8>, StatusEntry>,
}
+121 -111
View File
@@ -1,46 +1,40 @@
//! S3-compatible object store access.
//! Redis OCSP snapshot access.
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::get_object::GetObjectError;
use aws_sdk_s3::Client;
use bytes::Bytes;
use redis::aio::ConnectionManager;
use crate::config::S3Config;
use crate::config::RedisConfig;
const MAX_FETCH_ATTEMPTS: usize = 3;
const FETCH_SNAPSHOT: &str = r#"
local body = redis.call('GET', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
return {body, ttl}
"#;
#[derive(Clone)]
pub struct StatusStore {
client: Client,
bucket: String,
connection: ConnectionManager,
}
impl StatusStore {
pub async fn new(config: &S3Config) -> Self {
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(region) = config.region.as_ref() {
loader = loader.region(aws_config::Region::new(region.clone()));
}
let shared = loader.load().await;
let mut builder = aws_sdk_s3::config::Builder::from(&shared);
if let Some(endpoint) = config.endpoint.as_ref() {
builder = builder.endpoint_url(endpoint);
}
builder = builder.force_path_style(config.force_path_style);
Self {
client: Client::from_conf(builder.build()),
bucket: config.bucket.clone(),
}
pub async fn new(config: &RedisConfig) -> Result<Self, StoreError> {
let client = redis::Client::open(config.url.as_str())
.map_err(|error| StoreError::Redis(error.to_string()))?;
let connection = client
.get_connection_manager()
.await
.map_err(|error| StoreError::Redis(error.to_string()))?;
Ok(Self { connection })
}
}
#[derive(Debug)]
pub struct StoredSnapshot {
pub body: Bytes,
pub expires_at: SystemTime,
pub enum StoredSnapshot {
Present { body: Bytes, expires_at: SystemTime },
Missing,
}
#[async_trait]
@@ -51,108 +45,124 @@ pub trait ObjectStore: Send + Sync {
#[async_trait]
impl ObjectStore for StatusStore {
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
.head_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|error| StoreError::S3(format!("HeadObject failed: {error:?}")))?;
let expires_at = parse_expiry(head.expires_string.as_deref())?;
let etag = head
.e_tag
.as_deref()
.ok_or(StoreError::MissingEtag)?
.to_string();
let mut connection = self.connection.clone();
let (body, pttl): (Option<Vec<u8>>, i64) = redis::Script::new(FETCH_SNAPSHOT)
.key(key)
.invoke_async(&mut connection)
.await
.map_err(|error| StoreError::Redis(error.to_string()))?;
stored_snapshot(body, pttl, SystemTime::now())
}
}
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 });
fn stored_snapshot(
body: Option<Vec<u8>>,
pttl: i64,
now: SystemTime,
) -> Result<StoredSnapshot, StoreError> {
match body {
None => Ok(StoredSnapshot::Missing),
Some(_) if pttl == -1 => Err(StoreError::MissingTtl),
Some(_) if pttl <= 0 => Ok(StoredSnapshot::Missing),
Some(body) => {
let expires_at = now
.checked_add(Duration::from_millis(pttl as u64))
.ok_or(StoreError::InvalidTtl(pttl))?;
Ok(StoredSnapshot::Present {
body: Bytes::from(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 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,
#[error("Redis request failed: {0}")]
Redis(String),
#[error("OCSP snapshot exists without an expiry")]
MissingTtl,
#[error("OCSP snapshot has an invalid TTL: {0}")]
InvalidTtl(i64),
}
#[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());
fn missing_and_expired_keys_are_missing_snapshots() {
let now = SystemTime::now();
assert!(matches!(
stored_snapshot(None, -2, now),
Ok(StoredSnapshot::Missing)
));
assert!(matches!(
stored_snapshot(Some(b"stale".to_vec()), 0, now),
Ok(StoredSnapshot::Missing)
));
}
#[test]
fn rejects_missing_invalid_and_expired_expiry() {
assert!(matches!(parse_expiry(None), Err(StoreError::MissingExpiry)));
fn present_snapshot_uses_pttl_for_expiry() {
let now = SystemTime::now();
let stored = stored_snapshot(Some(b"snapshot".to_vec()), 5_000, now).unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
assert_eq!(expires_at, now + Duration::from_millis(5_000));
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
}
#[test]
fn present_snapshot_without_ttl_is_rejected() {
let result = stored_snapshot(Some(b"snapshot".to_vec()), -1, SystemTime::now());
assert!(matches!(result, Err(StoreError::MissingTtl)));
}
#[tokio::test]
#[ignore = "requires KCA_TEST_REDIS_URL"]
async fn real_redis_round_trip() {
let Ok(url) = std::env::var("KCA_TEST_REDIS_URL") else {
return;
};
let config = RedisConfig {
url,
prefix: format!("ocsp:test:{}", std::process::id()),
..Default::default()
};
let key = config.key("round-trip");
let store = StatusStore::new(&config).await.unwrap();
let client = redis::Client::open(config.url.as_str()).unwrap();
let mut connection = client.get_connection_manager().await.unwrap();
let _: () = redis::cmd("SET")
.arg(&key)
.arg("snapshot")
.arg("EX")
.arg(30)
.query_async(&mut connection)
.await
.unwrap();
let stored = store.get(&key).await.unwrap();
match stored {
StoredSnapshot::Present { body, expires_at } => {
assert_eq!(body, Bytes::from_static(b"snapshot"));
assert!(expires_at > SystemTime::now());
}
StoredSnapshot::Missing => panic!("snapshot should be present"),
}
let _: () = redis::cmd("DEL")
.arg(&key)
.query_async(&mut connection)
.await
.unwrap();
assert!(matches!(
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(_))
store.get(&key).await.unwrap(),
StoredSnapshot::Missing
));
}
}