Add S3 OCSP status publishing and standalone Rust Axum responder

This commit is contained in:
2026-10-23 09:18:00 +00:00
parent 4ed14b42a7
commit 0384995570
30 changed files with 5274 additions and 5 deletions
+3309
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "kca-ocsp-responder"
version = "0.1.0"
edition = "2021"
description = "Standalone OCSP responder for KCA status snapshots"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["limit", "trace", "timeout"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
bytes = "1"
async-trait = "0.1"
base64 = "0.22"
hex = "0.4"
sha1 = "0.10"
sha2 = "0.10"
der = { version = "0.7", features = ["std"] }
const-oid = "0.9"
rsa = { version = "0.9", features = ["sha2"] }
p256 = { version = "0.13", features = ["ecdsa"] }
sec1 = { version = "0.7", features = ["pem"] }
ecdsa = { version = "0.16", features = ["der"] }
pkcs8 = { version = "0.10", features = ["pem"] }
x509-ocsp = { version = "0.2.1", features = ["builder", "std"] }
x509-cert = { version = "0.2", features = ["std"] }
aws-config = "1"
aws-sdk-s3 = "1"
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# Standalone Rust/Axum OCSP responder. It has no database or KCA master-key
# access; configuration and signer material are mounted at runtime.
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
COPY ocsp-responder/Cargo.toml ocsp-responder/Cargo.lock ./
COPY ocsp-responder/src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --locked \
&& cp /app/target/release/kca-ocsp-responder /usr/local/bin/kca-ocsp-responder
FROM debian:bookworm-slim
RUN addgroup --system ocsp \
&& adduser --system --ingroup ocsp --no-create-home ocsp \
&& mkdir -p /etc/ssl/certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /usr/local/bin/kca-ocsp-responder /usr/local/bin/kca-ocsp-responder
ENV OCSP_CONFIG=/etc/ocsp/responder.yaml
USER ocsp
EXPOSE 2560
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD ["/usr/local/bin/kca-ocsp-responder", "--healthcheck"]
CMD ["/usr/local/bin/kca-ocsp-responder"]
+74
View File
@@ -0,0 +1,74 @@
# KCA OCSP responder
Standalone Rust/Axum responder. It reads one full JSON status snapshot per CA
from S3-compatible object storage, caches it for one second with a single-flight
refresh, and signs OCSP responses with a delegated responder certificate.
It has **no KCA database access**, no Redis access, and no KCA master key. It
only needs:
- `s3:GetObject` on the configured bucket/prefix;
- the CA certificate for each issuer;
- a delegated OCSP signer certificate and private key for each CA. The
responder currently accepts RSA-SHA256 and ECDSA P-256 signers in PKCS#8,
PKCS#1 RSA, or SEC1 EC PEM form.
## Snapshot layout
KCA publishes objects as:
```
<prefix>/<CA certificate SHA-256>.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
responder never infers status from a partial revoked-only list.
## Run locally
1. Create a config based on `responder.example.yaml`.
2. Put your CA certificates and delegated signer files where the config points.
3. Export standard AWS credentials / endpoint variables as needed.
4. Make the signer key readable by the container user (`ocsp`, uid/gid
`100:101` in the provided image), or mount it via a Docker secret.
5. Run:
```sh
OCSP_CONFIG=./responder.yaml cargo run
```
The default listen address is `0.0.0.0:2560`.
## Verify with OpenSSL
For a CA certificate `ca.crt` and leaf certificate `leaf.crt`:
```sh
openssl ocsp \
-issuer ca.crt \
-cert leaf.crt \
-url http://127.0.0.1:2560/ocsp \
-resp_text \
-noverify
```
`-noverify` skips local trust setup; the response still shows the signature
algorithm and included responder certificate. For production verification,
build a trust store containing the CA or validate with your normal PKI tooling.
## Endpoints
- `POST /ocsp` — `application/ocsp-request`
- `GET /ocsp/<base64url-request>` — optional GET form
- `GET /healthz`
- `GET /readyz`
## Cache and availability
- Per-CA cache TTL: `s3.cache_ttl_ms` (default 1000 ms).
- Concurrent requests for the same CA share one S3 `GetObject`.
- If S3 fails, a previously cached snapshot is served for
`s3.stale_grace_seconds`; otherwise the OCSP response is `tryLater`.
- 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.
+24
View File
@@ -0,0 +1,24 @@
# Copy this file, provide your own CA certificates and delegated OCSP
# signer certificate/key pairs, then mount it at /etc/ocsp/responder.yaml.
listen: 0.0.0.0:2560
next_update_seconds: 10
s3:
bucket: kca-ocsp
prefix: ocsp
region: eu-central-1
# Optional for MinIO / other S3-compatible stores:
# endpoint: https://s3.example.com
# force_path_style: true
cache_ttl_ms: 1000
stale_grace_seconds: 60
issuers:
- name: Example Root CA
ca_cert: /etc/ocsp/ca/root.pem
signer_cert: /etc/ocsp/signers/root.pem
signer_key: /etc/ocsp/signers/root.key
# - name: Example Intermediate CA
# ca_cert: /etc/ocsp/ca/intermediate.pem
# signer_cert: /etc/ocsp/signers/intermediate.pem
# signer_key: /etc/ocsp/signers/intermediate.key
+198
View File
@@ -0,0 +1,198 @@
//! One-second, single-flight cache of per-CA status snapshots.
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock};
use crate::snapshot::Snapshot;
use crate::store::ObjectStore;
#[derive(Clone)]
pub struct CachedSnapshot {
snapshot: Arc<Snapshot>,
fetched_at: Instant,
}
#[derive(Default)]
pub struct SnapshotCache {
cached: RwLock<Option<CachedSnapshot>>,
refresh: Mutex<()>,
}
impl SnapshotCache {
pub fn new() -> Self {
Self::default()
}
/// Return a snapshot, refreshing from S3 at most once per `ttl`.
///
/// On refresh failure, data up to `ttl + stale_grace` old is still used.
pub async fn get<S: ObjectStore + ?Sized>(
&self,
store: &S,
key: &str,
expected_sha256: &str,
ttl: Duration,
stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = self.cached.read().await.as_ref() {
if value.fetched_at.elapsed() < ttl {
return Ok(value.snapshot.clone());
}
}
// Only one request refreshes a given CA. Everybody else waits here
// and then observes the refreshed value below.
let _guard = self.refresh.lock().await;
let stale = {
let cache = self.cached.read().await;
if let Some(value) = cache.as_ref() {
if value.fetched_at.elapsed() < ttl {
return Ok(value.snapshot.clone());
}
Some(value.clone())
} else {
None
}
};
match store.get(key).await {
Ok(bytes) => match Snapshot::parse(&bytes, expected_sha256) {
Ok(snapshot) => {
let snapshot = Arc::new(snapshot);
*self.cached.write().await = Some(CachedSnapshot {
snapshot: snapshot.clone(),
fetched_at: Instant::now(),
});
Ok(snapshot)
}
Err(error) => self.fallback(stale, error.to_string(), stale_grace),
},
Err(error) => self.fallback(stale, error.to_string(), stale_grace),
}
}
fn fallback(
&self,
stale: Option<CachedSnapshot>,
error: String,
stale_grace: Duration,
) -> Result<Arc<Snapshot>, CacheError> {
if let Some(value) = stale {
if value.fetched_at.elapsed() <= stale_grace {
tracing::warn!(error = %error, "serving stale OCSP snapshot");
return Ok(value.snapshot);
}
}
Err(CacheError::Unavailable(error))
}
}
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("OCSP snapshot unavailable: {0}")]
Unavailable(String),
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use async_trait::async_trait;
use bytes::Bytes;
use super::*;
use crate::snapshot::sha256_hex;
use crate::store::{ObjectStore, StoreError};
struct FakeStore {
body: String,
calls: AtomicUsize,
fail: AtomicBool,
}
impl FakeStore {
fn new(body: String) -> Self {
Self {
body,
calls: AtomicUsize::new(0),
fail: AtomicBool::new(false),
}
}
}
#[async_trait]
impl ObjectStore for FakeStore {
async fn get(&self, _key: &str) -> Result<Bytes, 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()))
}
}
fn body(sha: &str) -> String {
format!(
r#"{{"schema":"kca-ocsp-status/v1","issuer":{{"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));
let cache = SnapshotCache::new();
let (a, b) = tokio::join!(
cache.get(
&store,
"key",
&sha,
Duration::from_millis(100),
Duration::from_secs(1)
),
cache.get(
&store,
"key",
&sha,
Duration::from_millis(100),
Duration::from_secs(1)
)
);
assert!(a.is_ok());
assert!(b.is_ok());
assert_eq!(store.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn refreshes_after_ttl_and_serves_stale_on_error() {
let sha = sha256_hex(b"ca");
let store = FakeStore::new(body(&sha));
let cache = SnapshotCache::new();
cache
.get(
&store,
"key",
&sha,
Duration::from_millis(20),
Duration::from_secs(1),
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
store.fail.store(true, Ordering::SeqCst);
let stale = cache
.get(
&store,
"key",
&sha,
Duration::from_millis(20),
Duration::from_secs(1),
)
.await
.unwrap();
assert_eq!(stale.entries.len(), 1);
assert_eq!(store.calls.load(Ordering::SeqCst), 2);
}
}
+106
View File
@@ -0,0 +1,106 @@
//! OCSP responder configuration.
use std::net::SocketAddr;
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default = "default_listen")]
pub listen: SocketAddr,
#[serde(default)]
pub s3: S3Config,
#[serde(default)]
pub issuers: Vec<IssuerConfig>,
#[serde(default = "default_ocsp_next_update_seconds")]
pub next_update_seconds: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IssuerConfig {
pub name: String,
pub ca_cert: PathBuf,
pub signer_cert: PathBuf,
pub signer_key: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
pub struct S3Config {
pub bucket: 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,
#[serde(default = "default_stale_grace_seconds")]
pub stale_grace_seconds: u64,
}
impl Default for S3Config {
fn default() -> Self {
Self {
bucket: String::new(),
prefix: default_prefix(),
region: None,
endpoint: None,
force_path_style: false,
cache_ttl_ms: default_cache_ttl_ms(),
stale_grace_seconds: default_stale_grace_seconds(),
}
}
}
impl S3Config {
pub fn object_key(&self, ca_fingerprint_sha256: &str) -> String {
let prefix = self.prefix.trim_matches('/');
let name = format!("{}.json", ca_fingerprint_sha256.to_lowercase());
if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
}
}
}
fn default_listen() -> SocketAddr {
"0.0.0.0:2560"
.parse()
.expect("valid default listen address")
}
fn default_prefix() -> String {
"ocsp".to_string()
}
fn default_cache_ttl_ms() -> u64 {
1000
}
fn default_stale_grace_seconds() -> u64 {
60
}
fn default_ocsp_next_update_seconds() -> u64 {
10
}
pub fn load_config() -> Result<Config, Box<dyn std::error::Error + Send + Sync>> {
let path =
std::env::var("OCSP_CONFIG").unwrap_or_else(|_| "/etc/ocsp/responder.yaml".to_string());
let text = std::fs::read_to_string(&path)?;
let mut config: Config = serde_yaml::from_str(&text)?;
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(listen) = std::env::var("OCSP_LISTEN") {
config.listen = listen.parse()?;
}
Ok(config)
}
+158
View File
@@ -0,0 +1,158 @@
//! Configured CA + delegated signer + snapshot cache.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use const_oid::db::rfc5280::{ID_CE_EXT_KEY_USAGE, ID_KP_OCSP_SIGNING};
use der::{Decode, Encode};
use tokio::sync::Mutex;
use x509_cert::ext::pkix::ExtendedKeyUsage;
use x509_cert::Certificate;
use x509_ocsp::CertId;
use crate::cache::SnapshotCache;
use crate::config::IssuerConfig;
use crate::signer::{read_certificate, SignerError, SignerKey};
use crate::snapshot::{hash_sha1, hash_sha256, sha256_hex};
pub struct Issuer {
pub name: String,
pub signer_cert: Certificate,
pub signer: Mutex<SignerKey>,
pub fingerprint: String,
name_hashes: HashMap<String, Vec<u8>>,
key_hashes: HashMap<String, Vec<u8>>,
pub cache: SnapshotCache,
}
impl Issuer {
pub fn load(config: &IssuerConfig) -> Result<Arc<Self>, IssuerError> {
let ca_cert = read_certificate(&config.ca_cert)?;
let signer_cert = read_certificate(&config.signer_cert)?;
let signer = SignerKey::load(&config.signer_key)?;
if !signer.matches_certificate(&signer_cert)? {
return Err(IssuerError::KeyMismatch(config.name.clone()));
}
if signer_cert.tbs_certificate.issuer != ca_cert.tbs_certificate.subject {
return Err(IssuerError::SignerIssuer(config.name.clone()));
}
check_ocsp_signing_eku(&signer_cert)?;
let now = SystemTime::now();
if signer_cert
.tbs_certificate
.validity
.not_before
.to_system_time()
> now
|| signer_cert
.tbs_certificate
.validity
.not_after
.to_system_time()
< now
{
return Err(IssuerError::SignerExpired(config.name.clone()));
}
let ca_der = ca_cert
.to_der()
.map_err(|error| IssuerError::Certificate(error.to_string()))?;
let fingerprint = sha256_hex(&ca_der);
let name_der = ca_cert
.tbs_certificate
.subject
.to_der()
.map_err(|error| IssuerError::Certificate(error.to_string()))?;
let key_bytes = ca_cert
.tbs_certificate
.subject_public_key_info
.subject_public_key
.raw_bytes();
let mut name_hashes = HashMap::new();
let mut key_hashes = HashMap::new();
for (oid, hash_name, hash_key) in [
("1.3.14.3.2.26", hash_sha1(&name_der), hash_sha1(key_bytes)),
(
"2.16.840.1.101.3.4.2.1",
hash_sha256(&name_der),
hash_sha256(key_bytes),
),
] {
name_hashes.insert(oid.to_string(), hash_name);
key_hashes.insert(oid.to_string(), hash_key);
}
Ok(Arc::new(Self {
name: config.name.clone(),
signer_cert,
signer: Mutex::new(signer),
fingerprint,
name_hashes,
key_hashes,
cache: SnapshotCache::new(),
}))
}
pub fn matches(&self, cert_id: &CertId) -> bool {
let algorithm = cert_id.hash_algorithm.oid.to_string();
let Some(expected_name) = self.name_hashes.get(&algorithm) else {
return false;
};
let Some(expected_key) = self.key_hashes.get(&algorithm) else {
return false;
};
cert_id.issuer_name_hash.as_bytes() == expected_name.as_slice()
&& cert_id.issuer_key_hash.as_bytes() == expected_key.as_slice()
}
pub async fn snapshot<S: crate::store::ObjectStore + ?Sized>(
&self,
store: &S,
config: &crate::config::S3Config,
) -> Result<Arc<crate::snapshot::Snapshot>, crate::cache::CacheError> {
let key = config.object_key(&self.fingerprint);
self.cache
.get(
store,
&key,
&self.fingerprint,
Duration::from_millis(config.cache_ttl_ms),
Duration::from_secs(config.stale_grace_seconds),
)
.await
}
}
fn check_ocsp_signing_eku(cert: &Certificate) -> Result<(), IssuerError> {
let extensions = match &cert.tbs_certificate.extensions {
Some(extensions) => extensions,
None => return Err(IssuerError::MissingEku),
};
for extension in extensions {
if extension.extn_id == ID_CE_EXT_KEY_USAGE {
let eku = ExtendedKeyUsage::from_der(extension.extn_value.as_bytes())
.map_err(|error| IssuerError::Certificate(error.to_string()))?;
if eku.0.contains(&ID_KP_OCSP_SIGNING) {
return Ok(());
}
}
}
Err(IssuerError::MissingEku)
}
#[derive(Debug, thiserror::Error)]
pub enum IssuerError {
#[error("signer error: {0}")]
Signer(#[from] SignerError),
#[error("invalid CA certificate: {0}")]
Certificate(String),
#[error("signer certificate for {0} is not issued by its CA")]
SignerIssuer(String),
#[error("signer certificate for {0} is not currently valid")]
SignerExpired(String),
#[error("signer key does not match its certificate for {0}")]
KeyMismatch(String),
#[error("signer certificate has no OCSPSigning extended key usage")]
MissingEku,
}
+91
View File
@@ -0,0 +1,91 @@
//! Standalone KCA OCSP responder.
mod cache;
mod config;
mod issuer;
mod ocsp;
mod signer;
mod snapshot;
mod store;
use std::sync::Arc;
use axum::extract::DefaultBodyLimit;
use axum::routing::{get, post};
use axum::Router;
use tower_http::timeout::TimeoutLayer;
use tracing_subscriber::EnvFilter;
use crate::issuer::Issuer;
use crate::ocsp::AppState;
use crate::store::StatusStore;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if std::env::args().any(|argument| argument == "--healthcheck") {
return healthcheck();
}
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let config = config::load_config()?;
let store = StatusStore::new(&config.s3).await;
let mut issuers = Vec::with_capacity(config.issuers.len());
for issuer_config in &config.issuers {
let issuer = Issuer::load(issuer_config)?;
tracing::info!(
issuer = %issuer.name,
fingerprint = %issuer.fingerprint,
"loaded OCSP issuer"
);
issuers.push(issuer);
}
let state = AppState {
issuers,
store,
config: Arc::new(config.clone()),
};
let app = Router::new()
.route("/ocsp", post(ocsp::ocsp_post))
.route("/ocsp/{request}", get(ocsp::ocsp_get))
.route("/healthz", get(health))
.route("/readyz", get(health))
.layer(DefaultBodyLimit::max(16 * 1024))
.layer(TimeoutLayer::with_status_code(
axum::http::StatusCode::REQUEST_TIMEOUT,
std::time::Duration::from_secs(15),
))
.with_state(state);
let listener = tokio::net::TcpListener::bind(config.listen).await?;
tracing::info!(listen = %config.listen, "OCSP responder listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}
fn healthcheck() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::io::{Read, Write};
let mut stream = std::net::TcpStream::connect("127.0.0.1:2560")?;
stream.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
if response.starts_with("HTTP/1.") && response.contains(" 200 ") {
Ok(())
} else {
Err("health endpoint did not return HTTP 200".into())
}
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}
+151
View File
@@ -0,0 +1,151 @@
//! OCSP request handling.
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::Engine;
use der::{Decode, Encode};
use x509_cert::ext::pkix::CrlReason;
use x509_ocsp::builder::OcspResponseBuilder;
use x509_ocsp::{
CertStatus, OcspGeneralizedTime, OcspRequest, OcspResponse, RevokedInfo, SingleResponse,
};
use crate::config::Config;
use crate::issuer::Issuer;
use crate::signer::{now_time, system_time};
use crate::store::StatusStore;
#[derive(Clone)]
pub struct AppState {
pub issuers: Vec<Arc<Issuer>>,
pub store: StatusStore,
pub config: Arc<Config>,
}
pub async fn ocsp_post(State(state): State<AppState>, body: Bytes) -> Response {
handle_request(&state, &body).await
}
pub async fn ocsp_get(State(state): State<AppState>, Path(encoded): Path<String>) -> Response {
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded.as_bytes())
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()));
match decoded {
Ok(data) => handle_request(&state, &data).await,
Err(_) => ocsp_response(OcspResponse::malformed_request()),
}
}
async fn handle_request(state: &AppState, body: &[u8]) -> Response {
let request = match OcspRequest::from_der(body) {
Ok(request) => request,
Err(_) => return ocsp_response(OcspResponse::malformed_request()),
};
if request.tbs_request.request_list.is_empty() {
return ocsp_response(OcspResponse::malformed_request());
}
let first_id = &request.tbs_request.request_list[0].req_cert;
let issuer = match state.issuers.iter().find(|issuer| issuer.matches(first_id)) {
Some(issuer) => issuer,
None => return ocsp_response(OcspResponse::unauthorized()),
};
// One BasicOCSPResponse has one signer, so all CertIDs must belong to
// the same CA.
if request
.tbs_request
.request_list
.iter()
.any(|item| !issuer.matches(&item.req_cert))
{
return ocsp_response(OcspResponse::unauthorized());
}
let snapshot = match issuer.snapshot(&state.store, &state.config.s3).await {
Ok(snapshot) => snapshot,
Err(error) => {
tracing::warn!(issuer = %issuer.name, error = %error, "OCSP snapshot unavailable");
return ocsp_response(OcspResponse::try_later());
}
};
let now = match now_time() {
Ok(now) => now,
Err(error) => {
tracing::error!(error = %error, "could not create OCSP time");
return ocsp_response(OcspResponse::internal_error());
}
};
let next_update = now_time()
.ok()
.and_then(|_| {
SystemTime::now().checked_add(Duration::from_secs(state.config.next_update_seconds))
})
.and_then(|value| OcspGeneralizedTime::try_from(value).ok());
let mut builder = OcspResponseBuilder::new(issuer.signer_cert.tbs_certificate.subject.clone());
for item in &request.tbs_request.request_list {
let serial = item.req_cert.serial_number.as_bytes();
let status = match snapshot.status_for_serial(serial) {
Some(entry) if entry.status == crate::snapshot::Status::Revoked => {
let revocation_time = match entry.revoked_at {
Some(value) => match OcspGeneralizedTime::try_from(system_time(value)) {
Ok(value) => value,
Err(_) => return ocsp_response(OcspResponse::internal_error()),
},
None => return ocsp_response(OcspResponse::internal_error()),
};
CertStatus::revoked(RevokedInfo {
revocation_time,
revocation_reason: Some(entry.reason.unwrap_or(CrlReason::Unspecified)),
})
}
Some(_) => CertStatus::good(),
None => CertStatus::unknown(),
};
let mut single = SingleResponse::new(item.req_cert.clone(), status, now);
if let Some(next_update) = next_update {
single = single.with_next_update(next_update);
}
builder = builder.with_single_response(single);
}
if let Some(nonce) = request.nonce() {
if let Ok(updated) = builder.clone().with_extension(nonce) {
builder = updated;
} else {
tracing::warn!("could not echo OCSP nonce");
}
}
let mut signer = issuer.signer.lock().await;
match signer.sign(builder, vec![issuer.signer_cert.clone()], now) {
Ok(response) => ocsp_response(response),
Err(error) => {
tracing::error!(issuer = %issuer.name, error = %error, "OCSP signing failed");
ocsp_response(OcspResponse::internal_error())
}
}
}
fn ocsp_response(response: OcspResponse) -> Response {
match response.to_der() {
Ok(bytes) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/ocsp-response"),
)],
bytes,
)
.into_response(),
Err(error) => {
tracing::error!(error = %error, "could not encode OCSP response");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Delegated OCSP response signers.
use std::fs;
use std::path::Path;
use std::time::SystemTime;
use der::{Decode, DecodePem, Encode};
use pkcs8::{DecodePrivateKey, DecodePublicKey};
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::pkcs1v15::SigningKey as RsaSigningKey;
use rsa::RsaPrivateKey;
use sha2::Sha256;
use x509_cert::Certificate;
use x509_ocsp::builder::OcspResponseBuilder;
use x509_ocsp::{OcspGeneralizedTime, OcspResponse};
pub enum SignerKey {
Rsa(Box<RsaSigningKey<Sha256>>),
P256(p256::ecdsa::SigningKey),
}
impl SignerKey {
pub fn load(path: &Path) -> Result<Self, SignerError> {
let text = fs::read_to_string(path)?;
if let Ok(key) = RsaPrivateKey::from_pkcs8_pem(&text) {
return Ok(Self::Rsa(Box::new(RsaSigningKey::<Sha256>::new(key))));
}
if let Ok(key) = RsaPrivateKey::from_pkcs1_pem(&text) {
return Ok(Self::Rsa(Box::new(RsaSigningKey::<Sha256>::new(key))));
}
if let Ok(secret) = p256::SecretKey::from_pkcs8_pem(&text) {
return Ok(Self::P256(p256::ecdsa::SigningKey::from(secret)));
}
if let Ok(secret) = p256::SecretKey::from_sec1_pem(&text) {
return Ok(Self::P256(p256::ecdsa::SigningKey::from(secret)));
}
Err(SignerError::KeyFormat)
}
pub fn matches_certificate(&self, cert: &Certificate) -> Result<bool, SignerError> {
let spki = cert
.tbs_certificate
.subject_public_key_info
.to_der()
.map_err(|error| SignerError::Certificate(error.to_string()))?;
match self {
Self::Rsa(key) => {
let public = rsa::RsaPublicKey::from_public_key_der(&spki)
.map_err(|error| SignerError::Certificate(error.to_string()))?;
Ok(key.as_ref().as_ref().to_public_key() == public)
}
Self::P256(key) => {
let public = p256::PublicKey::from_public_key_der(&spki)
.map_err(|error| SignerError::Certificate(error.to_string()))?;
Ok(key.verifying_key().to_sec1_bytes().as_ref() == public.to_sec1_bytes().as_ref())
}
}
}
pub fn sign(
&mut self,
builder: OcspResponseBuilder,
chain: Vec<Certificate>,
produced_at: OcspGeneralizedTime,
) -> Result<OcspResponse, SignerError> {
let response = match self {
Self::Rsa(key) => builder
.sign::<_, rsa::pkcs1v15::Signature>(key.as_mut(), Some(chain), produced_at)
.map_err(|error| SignerError::Sign(error.to_string()))?,
Self::P256(key) => builder
.sign::<_, ecdsa::der::Signature<p256::NistP256>>(key, Some(chain), produced_at)
.map_err(|error| SignerError::Sign(error.to_string()))?,
};
Ok(response)
}
}
pub fn read_certificate(path: &Path) -> Result<Certificate, SignerError> {
let data = fs::read(path)?;
if let Ok(cert) = Certificate::from_pem(&data) {
return Ok(cert);
}
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 {
if value.timestamp() >= 0 {
SystemTime::UNIX_EPOCH
+ std::time::Duration::new(value.timestamp() as u64, value.timestamp_subsec_nanos())
} else {
SystemTime::UNIX_EPOCH
- std::time::Duration::new((-value.timestamp()) as u64, value.timestamp_subsec_nanos())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SignerError {
#[error("could not read signer file: {0}")]
Io(#[from] std::io::Error),
#[error("unsupported signer private-key format")]
KeyFormat,
#[error("invalid signer certificate: {0}")]
Certificate(String),
#[error("OCSP signing failed: {0}")]
Sign(String),
#[error("time conversion failed: {0}")]
Time(#[from] der::Error),
}
+218
View File
@@ -0,0 +1,218 @@
//! Parsed KCA OCSP status snapshots.
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use sha1::{Digest as _, Sha1};
use sha2::Sha256;
use x509_cert::ext::pkix::CrlReason;
pub const SCHEMA: &str = "kca-ocsp-status/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Good,
Revoked,
}
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub status: Status,
pub revoked_at: Option<DateTime<Utc>>,
pub reason: Option<CrlReason>,
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub entries: HashMap<Vec<u8>, StatusEntry>,
}
#[derive(Debug, Deserialize)]
struct RawSnapshot {
schema: String,
issuer: RawIssuer,
#[serde(default)]
entries: Vec<RawEntry>,
}
#[derive(Debug, Deserialize)]
struct RawIssuer {
#[serde(default)]
sha256: String,
}
#[derive(Debug, Deserialize)]
struct RawEntry {
serial_hex: String,
status: String,
#[serde(default)]
revoked_at: Option<DateTime<Utc>>,
#[serde(default)]
reason: Option<String>,
}
impl Snapshot {
/// Parse and validate a snapshot against the configured CA fingerprint.
pub fn parse(bytes: &[u8], expected_sha256: &str) -> Result<Self, SnapshotError> {
let raw: RawSnapshot = serde_json::from_slice(bytes)?;
if raw.schema != SCHEMA {
return Err(SnapshotError::Schema(raw.schema));
}
if !raw.issuer.sha256.eq_ignore_ascii_case(expected_sha256) {
return Err(SnapshotError::IssuerMismatch {
expected: expected_sha256.to_string(),
actual: raw.issuer.sha256,
});
}
let mut entries = HashMap::with_capacity(raw.entries.len());
for entry in raw.entries {
let serial = parse_serial(&entry.serial_hex)?;
let status = match entry.status.as_str() {
"valid" => Status::Good,
"revoked" => Status::Revoked,
other => return Err(SnapshotError::Status(other.to_string())),
};
if status == Status::Revoked && entry.revoked_at.is_none() {
return Err(SnapshotError::MissingRevocationTime(entry.serial_hex));
}
let reason = if status == Status::Revoked {
Some(parse_reason(
entry.reason.as_deref().unwrap_or("unspecified"),
))
} else {
None
};
if entries
.insert(
serial,
StatusEntry {
status,
revoked_at: entry.revoked_at,
reason,
},
)
.is_some()
{
return Err(SnapshotError::DuplicateSerial(entry.serial_hex));
}
}
let _ = raw.issuer.sha256;
Ok(Self { entries })
}
pub fn status_for_serial(&self, serial: &[u8]) -> Option<&StatusEntry> {
self.entries.get(&normalize_serial(serial))
}
}
pub fn normalize_serial(serial: &[u8]) -> Vec<u8> {
let first = serial
.iter()
.position(|byte| *byte != 0)
.unwrap_or(serial.len());
serial[first..].to_vec()
}
fn parse_serial(value: &str) -> Result<Vec<u8>, SnapshotError> {
let text = value.trim();
let text = text.strip_prefix("0x").unwrap_or(text);
let decoded = hex::decode(text).map_err(|_| SnapshotError::Serial(value.to_string()))?;
Ok(normalize_serial(&decoded))
}
fn parse_reason(value: &str) -> CrlReason {
match value.to_ascii_lowercase().as_str() {
"keycompromise" => CrlReason::KeyCompromise,
"cacompromise" => CrlReason::CaCompromise,
"affiliationchanged" => CrlReason::AffiliationChanged,
"superseded" => CrlReason::Superseded,
"cessationofoperation" => CrlReason::CessationOfOperation,
"certificatehold" => CrlReason::CertificateHold,
"removefromcrl" => CrlReason::RemoveFromCRL,
"privilegewithdrawn" => CrlReason::PrivilegeWithdrawn,
"aacompromise" => CrlReason::AaCompromise,
_ => CrlReason::Unspecified,
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(Sha256::digest(bytes))
}
/// OCSP CertID hashes are SHA-1 in the common case, with SHA-256 as an
/// alternative. The responder supports both.
pub fn hash_sha1(bytes: &[u8]) -> Vec<u8> {
Sha1::digest(bytes).to_vec()
}
pub fn hash_sha256(bytes: &[u8]) -> Vec<u8> {
use sha2::Digest as _;
Sha256::digest(bytes).to_vec()
}
#[derive(Debug, thiserror::Error)]
pub enum SnapshotError {
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("unsupported snapshot schema {0:?}")]
Schema(String),
#[error("snapshot issuer {actual} does not match configured CA {expected}")]
IssuerMismatch { expected: String, actual: String },
#[error("invalid serial {0:?}")]
Serial(String),
#[error("unknown status {0:?}")]
Status(String),
#[error("revoked entry {0:?} has no revocation time")]
MissingRevocationTime(String),
#[error("duplicate serial {0:?}")]
DuplicateSerial(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn body(sha: &str) -> String {
format!(
r#"{{
"schema": "kca-ocsp-status/v1",
"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"}}
]
}}"#
)
}
#[test]
fn parses_statuses_and_reasons() {
let sha = sha256_hex(b"ca");
let snapshot = Snapshot::parse(body(&sha).as_bytes(), &sha).unwrap();
let good = snapshot.status_for_serial(&[0x01, 0xab]).unwrap();
assert_eq!(good.status, Status::Good);
let revoked = snapshot.status_for_serial(&[0xff]).unwrap();
assert_eq!(revoked.status, Status::Revoked);
assert_eq!(revoked.reason, Some(CrlReason::KeyCompromise));
assert!(revoked.revoked_at.is_some());
}
#[test]
fn rejects_wrong_issuer_schema_and_duplicates() {
let sha = sha256_hex(b"ca");
assert!(Snapshot::parse(body(&sha).as_bytes(), "wrong").is_err());
let wrong_schema = body(&sha).replace(SCHEMA, "other");
assert!(Snapshot::parse(wrong_schema.as_bytes(), &sha).is_err());
let duplicate = body(&sha).replace("\"00ff\"", "\"01ab\"");
assert!(Snapshot::parse(duplicate.as_bytes(), &sha).is_err());
}
#[test]
fn normalizes_serials() {
assert_eq!(normalize_serial(&[0, 0, 1, 2]), vec![1, 2]);
assert_eq!(normalize_serial(&[0, 0]), Vec::<u8>::new());
}
}
+64
View File
@@ -0,0 +1,64 @@
//! S3-compatible object store access.
use async_trait::async_trait;
use aws_sdk_s3::Client;
use bytes::Bytes;
use crate::config::S3Config;
#[derive(Clone)]
pub struct StatusStore {
client: Client,
bucket: String,
}
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(),
}
}
}
#[async_trait]
pub trait ObjectStore: Send + Sync {
async fn get(&self, key: &str) -> Result<Bytes, StoreError>;
}
#[async_trait]
impl ObjectStore for StatusStore {
async fn get(&self, key: &str) -> Result<Bytes, StoreError> {
let response = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|error| StoreError::S3(error.to_string()))?;
let data = response
.body
.collect()
.await
.map_err(|error| StoreError::S3(error.to_string()))?
.into_bytes();
Ok(data)
}
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("S3 GetObject failed: {0}")]
S3(String),
}