Add S3 OCSP status publishing and standalone Rust Axum responder
This commit is contained in:
Generated
+3309
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user