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
+5
View File
@@ -12,3 +12,8 @@ web/.trunk
e2e/node_modules e2e/node_modules
e2e/test-results e2e/test-results
e2e/playwright-report e2e/playwright-report
ocsp-responder/target
ocsp-responder/ca
ocsp-responder/signers
ocsp-responder/responder.yaml
+4
View File
@@ -10,6 +10,10 @@ __pycache__/
web/target/ web/target/
web/dist/ web/dist/
web/.trunk/ web/.trunk/
ocsp-responder/target/
ocsp-responder/responder.yaml
ocsp-responder/ca/
ocsp-responder/signers/
# Environment and local data # Environment and local data
.env .env
+37 -1
View File
@@ -145,6 +145,34 @@ fingerprints and extension text. Personal API tokens (`Authorization: Bearer
kca_…`) let scripts use the REST API; tokens act as their owner, are stored kca_…`) let scripts use the REST API; tokens act as their owner, are stored
only as SHA-256 digests, and cannot mint/revoke tokens. only as SHA-256 digests, and cannot mint/revoke tokens.
## OCSP responder
KCA can publish full per-CA certificate-status snapshots to any S3-compatible
bucket and ships a standalone Rust/Axum responder on port `2560`.
- KCA publishes `ocsp/<CA certificate SHA-256>.json` whenever certificates are
issued, renewed, revoked, deleted or imported. Revocation reasons are
included; this is a full status snapshot, not a revoked-only list.
- The responder has no Postgres/Redis/KCA master-key access. It only reads the
configured S3 prefix and uses a delegated OCSP signing certificate/key per
CA.
- Each CA snapshot is cached for one second with a single-flight refresh, so
concurrent requests cause at most one S3 `GetObject` per CA per second.
- If S3 is unavailable, a stale snapshot is served within the configured grace
period; otherwise the responder returns `tryLater`.
Enable publishing in KCA with `KCA_OCSP_S3_BUCKET` and the other
`KCA_OCSP_*` variables in `server/.env.example`. Run the responder with a
configuration derived from `ocsp-responder/responder.example.yaml`, or with
Compose:
```sh
docker compose --profile ocsp up --build
```
See `ocsp-responder/README.md` for the JSON layout, signer requirements and
`openssl ocsp` verification.
## REST API ## REST API
OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`. OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`.
@@ -155,7 +183,7 @@ OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`.
| Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` | | Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search`, `POST /api/vaults/{id}/export` |
| Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` | | Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` |
| Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` | | Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` |
| Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | | Certificates | `GET/POST /api/vaults/{id}/certificates`, `POST /api/vaults/{id}/certificates/preview`, `GET /api/vaults/{id}/certificates/{ca_id}/ocsp-status`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `POST /api/vaults/{id}/certificates/bulk-revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms |
| CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` | | CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` |
| Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` | | Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` |
| Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) | | Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) |
@@ -190,6 +218,13 @@ npm test
They use Playwright Chromium. Set `KCA_BASE_URL` to target another port, e.g. They use Playwright Chromium. Set `KCA_BASE_URL` to target another port, e.g.
`KCA_BASE_URL=http://localhost:8083 npm test`. See `e2e/README.md`. `KCA_BASE_URL=http://localhost:8083 npm test`. See `e2e/README.md`.
OCSP responder (from `ocsp-responder/`):
```sh
cargo test
cargo run # uses OCSP_CONFIG or /etc/ocsp/responder.yaml
```
Frontend (from `web/`, with the backend running on `:8080`): Frontend (from `web/`, with the backend running on `:8080`):
```sh ```sh
@@ -255,6 +290,7 @@ web/
src/{api,auth,model,dom}.rs src/{api,auth,model,dom}.rs
pwa/ # manifest + app-shell assets pwa/ # manifest + app-shell assets
e2e/ # Playwright browser suite (Node) e2e/ # Playwright browser suite (Node)
ocsp-responder/ # standalone Rust/Axum OCSP responder
``` ```
## Limits ## Limits
+36
View File
@@ -105,8 +105,44 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health} OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-/api/health}
# OCSP status publisher (optional). The bucket must be writable by
# KCA; the responder later reads the same prefix with read-only access.
KCA_OCSP_S3_BUCKET: ${KCA_OCSP_S3_BUCKET:-}
KCA_OCSP_S3_PREFIX: ${KCA_OCSP_S3_PREFIX:-ocsp}
KCA_OCSP_S3_REGION: ${KCA_OCSP_S3_REGION:-}
KCA_OCSP_S3_ENDPOINT: ${KCA_OCSP_S3_ENDPOINT:-}
KCA_OCSP_S3_FORCE_PATH_STYLE: ${KCA_OCSP_S3_FORCE_PATH_STYLE:-false}
KCA_OCSP_PUBLISH_INTERVAL_SECONDS: ${KCA_OCSP_PUBLISH_INTERVAL_SECONDS:-1}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
# Optional standalone OCSP responder. It deliberately has no database,
# Redis or KCA master-key environment. Enable with:
# docker compose --profile ocsp up --build
ocsp:
profiles: ["ocsp"]
build:
context: .
dockerfile: ocsp-responder/Dockerfile
environment:
OCSP_CONFIG: /etc/ocsp/responder.yaml
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
AWS_REGION: ${AWS_REGION:-}
volumes:
- ${OCSP_CONFIG_FILE:-./ocsp-responder/responder.example.yaml}:/etc/ocsp/responder.yaml:ro
- ${OCSP_CA_DIR:-./ocsp-responder/ca}:/etc/ocsp/ca:ro
- ${OCSP_SIGNER_DIR:-./ocsp-responder/signers}:/etc/ocsp/signers:ro
ports:
- "127.0.0.1:${OCSP_PORT:-2560}:2560"
read_only: true
security_opt:
- no-new-privileges:true
volumes: volumes:
pgdata: pgdata:
+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),
}
+12
View File
@@ -51,6 +51,18 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000 #OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15 #OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) --------------------------------------
# When a bucket is configured, KCA publishes one full JSON status snapshot
# per CA. The standalone OCSP responder reads these objects. Uses the normal
# AWS credential chain (AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY, role, instance profile, ...).
#KCA_OCSP_S3_BUCKET=kca-ocsp
KCA_OCSP_S3_PREFIX=ocsp
#KCA_OCSP_S3_REGION=eu-central-1
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000
KCA_OCSP_S3_FORCE_PATH_STYLE=false
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# --- CORS (optional) ------------------------------------------------------- # --- CORS (optional) -------------------------------------------------------
#CORS_ALLOW_ORIGINS= #CORS_ALLOW_ORIGINS=
#CORS_ALLOW_ORIGIN_REGEX= #CORS_ALLOW_ORIGIN_REGEX=
+2 -2
View File
@@ -46,11 +46,11 @@ COPY server/uv.lock server/pyproject.toml ./
COPY server/packages/kca-core/pyproject.toml packages/kca-core/ COPY server/packages/kca-core/pyproject.toml packages/kca-core/
COPY server/packages/kca-app/pyproject.toml packages/kca-app/ COPY server/packages/kca-app/pyproject.toml packages/kca-app/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-workspace --package kca-app --extra otel --no-dev uv sync --frozen --no-install-workspace --package kca-app --extra otel --extra s3 --no-dev
COPY server/packages/ ./packages/ COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --package kca-app --extra otel --no-dev \ uv sync --locked --no-editable --package kca-app --extra otel --extra s3 --no-dev \
--reinstall-package kca-core --reinstall-package kca-app --reinstall-package kca-core --reinstall-package kca-app
COPY server/migrations ./migrations COPY server/migrations ./migrations
@@ -0,0 +1,97 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
"ca_id" UUID NOT NULL PRIMARY KEY,
"dirty" BOOL NOT NULL,
"published_at" TIMESTAMPTZ,
"last_error" TEXT,
"updated_at" TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_ocsp_snapsh_dirty_a8d440" ON "ocsp_snapshot" ("dirty");
COMMENT ON TABLE "ocsp_snapshot" IS 'Publication state for one CA''s OCSP status JSON object.';"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP TABLE IF EXISTS "ocsp_snapshot";"""
MODELS_STATE = (
"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"
)
+3
View File
@@ -31,6 +31,9 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
s3 = [
"boto3>=1.34",
]
otel = [ otel = [
"kaya-otel>=0.0.7", "kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-asyncpg>=0.66b0",
+4
View File
@@ -20,6 +20,7 @@ from redis.asyncio import Redis
from .config import Settings, settings from .config import Settings, settings
from .logging_config import configure_logging from .logging_config import configure_logging
from .mixin import Kca, KcaMixin from .mixin import Kca, KcaMixin
from .ocsp import publisher_mixin_from_settings
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
from .telemetry import ( from .telemetry import (
CountableSessionStore, CountableSessionStore,
@@ -143,6 +144,9 @@ mixins: list[KayaMixin] = [
openapi_mixin, openapi_mixin,
KcaMixin(kca), KcaMixin(kca),
] ]
ocsp_publisher = publisher_mixin_from_settings(settings, tortoise_mixin.ensure_context)
if ocsp_publisher is not None:
mixins.append(ocsp_publisher)
telemetry = telemetry_from_settings(settings, session_store) telemetry = telemetry_from_settings(settings, session_store)
if telemetry is not None: if telemetry is not None:
+14
View File
@@ -185,6 +185,12 @@ class Settings:
otel_excluded_paths: Tuple[str, ...] otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int otel_metrics_poll_interval_seconds: int
ocsp_s3_bucket: Optional[str]
ocsp_s3_prefix: str
ocsp_s3_region: Optional[str]
ocsp_s3_endpoint: Optional[str]
ocsp_s3_force_path_style: bool
ocsp_publish_interval_seconds: int
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -231,6 +237,14 @@ class Settings:
otel_metrics_poll_interval_seconds=int( otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") _env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
), ),
ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None,
ocsp_s3_prefix=_env("KCA_OCSP_S3_PREFIX", "ocsp").strip("/"),
ocsp_s3_region=os.environ.get("KCA_OCSP_S3_REGION") or None,
ocsp_s3_endpoint=os.environ.get("KCA_OCSP_S3_ENDPOINT") or None,
ocsp_s3_force_path_style=_env_bool("KCA_OCSP_S3_FORCE_PATH_STYLE"),
ocsp_publish_interval_seconds=max(
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
),
) )
+13
View File
@@ -298,6 +298,19 @@ class Crl(Model):
return f"Crl(name={self.name!r}, number={self.crl_number})" return f"Crl(name={self.name!r}, number={self.crl_number})"
class OcspSnapshot(Model):
"""Publication state for one CA's OCSP status JSON object."""
ca_id = fields.UUIDField(pk=True)
dirty = fields.BooleanField(default=True, db_index=True)
published_at = fields.DatetimeField(null=True)
last_error = fields.TextField(null=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "ocsp_snapshot"
class Template(Model): class Template(Model):
"""An XCA-style template: subject + extension/validity defaults.""" """An XCA-style template: subject + extension/validity defaults."""
+258
View File
@@ -0,0 +1,258 @@
"""Per-CA OCSP status snapshots and S3 publication.
KCA is the source of truth for certificate status. This module builds a
compact JSON document per CA and uploads it to an S3-compatible bucket so
that the standalone OCSP responder never needs database access or a CA
private key.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Dict, Optional
from uuid import UUID
from kaya.core import KayaApp, KayaMixin
from .config import Settings, settings
from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
log = logging.getLogger(__name__)
SCHEMA = "kca-ocsp-status/v1"
def _iso(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def object_key(ca_fingerprint_sha256: str) -> str:
"""The S3 key for one CA; the responder derives the same key."""
prefix = settings.ocsp_s3_prefix.strip("/")
name = f"{ca_fingerprint_sha256.lower()}.json"
return f"{prefix}/{name}" if prefix else name
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
"""Build the full OCSP status snapshot for certificates issued by ``ca``."""
certs = await Certificate.filter(vault_id=ca.vault_id, issuer_id=ca.id).order_by("serial_hex") # type: ignore[attr-defined]
revocations = {
row.serial_hex.lower(): row
for row in await CertificateRevocation.filter(ca_id=ca.id)
}
entries = []
for cert in certs:
revocation = revocations.get(cert.serial_hex.lower())
entry: Dict[str, Any] = {
"serial_hex": cert.serial_hex.lower(),
"status": "revoked" if revocation is not None else "valid",
"not_after": _iso(cert.not_after),
}
if revocation is not None:
entry.update(
{
"revoked_at": _iso(revocation.revoked_at),
"reason": revocation.reason,
"invalidity_date": _iso(revocation.invalidity_date),
}
)
entries.append(entry)
return {
"schema": SCHEMA,
"issuer": {
"id": str(ca.id),
"sha256": ca.fingerprint_sha256.lower(),
"subject": cert_subject(ca),
},
"generated_at": _iso(datetime.now(timezone.utc)),
"entries": entries,
}
def cert_subject(ca: Certificate) -> str:
from kca.core import certificates as cert_core
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
def _s3_client() -> Any:
import boto3
from botocore.config import Config
kwargs: Dict[str, Any] = {
"region_name": settings.ocsp_s3_region,
"endpoint_url": settings.ocsp_s3_endpoint,
}
if settings.ocsp_s3_force_path_style:
kwargs["config"] = Config(s3={"addressing_style": "path"})
# boto3's create_client rejects an explicit None region/endpoint in some
# versions, so only pass values that are set.
kwargs = {key: value for key, value in kwargs.items() if value is not None}
return boto3.client("s3", **kwargs)
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
"""Upload one CA snapshot and return its S3 key."""
if not settings.ocsp_s3_bucket:
raise RuntimeError("KCA_OCSP_S3_BUCKET is not configured")
if snapshot is None:
snapshot = await snapshot_for_ca(ca)
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
key = object_key(ca.fingerprint_sha256)
# boto3 is synchronous and credential resolution can block; do both the
# client creation and the PUT outside the event loop.
def upload() -> None:
client = _s3_client()
client.put_object(
Bucket=settings.ocsp_s3_bucket,
Key=key,
Body=body,
ContentType="application/json",
CacheControl="no-store",
)
await asyncio.to_thread(upload)
return key
async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status object as needing a republish."""
if not settings.ocsp_s3_bucket:
return
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
if row is None:
await OcspSnapshot.create(ca_id=ca_id, dirty=True)
return
if not row.dirty:
row.dirty = True
row.last_error = None # type: ignore[assignment]
await row.save(update_fields=["dirty", "last_error", "updated_at"])
async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.ocsp_s3_bucket:
return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id)
class OcspPublisherMixin(KayaMixin):
"""Background publisher for dirty OCSP snapshots."""
def __init__(
self,
interval_seconds: int,
ensure_context: Callable[[], Awaitable[None]],
) -> None:
self._interval = max(1, interval_seconds)
self._ensure_context = ensure_context
self._task: Optional[asyncio.Task] = None
self._bootstrapped = False
def apply(self, app: KayaApp) -> None:
# This mixin only owns a background task; no request hooks.
return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.ocsp_s3_bucket:
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
return
try:
import boto3 # noqa: F401
except ImportError as exc:
raise RuntimeError(
"KCA_OCSP_S3_BUCKET is set but boto3 is not installed; "
"install kca-app with the 's3' extra"
) from exc
self._task = loop.create_task(self._poll())
log.info(
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
settings.ocsp_s3_bucket,
settings.ocsp_s3_prefix or "(root)",
self._interval,
)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
async def _poll(self) -> None:
# Let the Tortoise mixin establish its database context before the
# first query. Subsequent failures are retried on the next tick.
await asyncio.sleep(self._interval)
while True:
try:
if not self._bootstrapped:
# Existing CAs may predate the publisher or have lost
# their S3 objects; publish one snapshot for every CA on
# the first successful tick.
await self._ensure_context()
for ca in await Certificate.filter(is_ca=True):
await mark_ocsp_dirty(ca.id)
self._bootstrapped = True
await self.run_once()
except asyncio.CancelledError:
raise
except Exception:
log.exception("OCSP snapshot publisher iteration failed")
await asyncio.sleep(self._interval)
async def run_once(self) -> None:
# The publisher runs outside an HTTP request task, so it must bind
# Tortoise's per-loop context before querying models.
await self._ensure_context()
rows = await OcspSnapshot.filter(dirty=True).limit(25)
for row in rows:
ca = await Certificate.get_or_none(id=row.ca_id)
if ca is None:
row.dirty = False
row.last_error = "CA certificate not found"
await row.save(update_fields=["dirty", "last_error", "updated_at"])
continue
try:
snapshot = await snapshot_for_ca(ca)
key = await publish_snapshot(ca, snapshot)
row.dirty = False
row.published_at = datetime.now(timezone.utc)
row.last_error = None # type: ignore[assignment]
await row.save(
update_fields=["dirty", "published_at", "last_error", "updated_at"]
)
log.info("published OCSP snapshot ca=%s key=%s", ca.id, key)
except Exception as exc: # noqa: BLE001 - keep retrying the same row
row.dirty = True
row.last_error = str(exc)[:2000]
await row.save(update_fields=["dirty", "last_error", "updated_at"])
log.warning("OCSP snapshot publish failed ca=%s: %s", ca.id, exc)
def publisher_mixin_from_settings(
settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]:
if not settings_obj.ocsp_s3_bucket:
return None
return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds,
ensure_context,
)
__all__ = [
"OcspPublisherMixin",
"SCHEMA",
"mark_all_cas_dirty",
"mark_ocsp_dirty",
"object_key",
"publish_snapshot",
"publisher_mixin_from_settings",
"snapshot_for_ca",
]
@@ -19,7 +19,8 @@ from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
from ..auth import authenticated_user, require_auth from ..auth import authenticated_user, require_auth
from ..config import settings from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, OcspSnapshot, Request, Template
from ..ocsp import snapshot_for_ca
from ..openapi import error_response, json_response, schema_ref from ..openapi import error_response, json_response, schema_ref
from ..service import ( from ..service import (
ValidationError, ValidationError,
@@ -136,6 +137,32 @@ def register(app: KayaApp, kca: "Kca") -> None:
return return
await send_json(ctx, 200, result) await send_json(ctx, 200, result)
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ocsp-status")
@operation(summary="OCSP status snapshot for a CA", tags=["certificates"])
@require_auth(kca.oidc)
async def ocsp_status(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
if not cert.is_ca:
await send_error(ctx, 400, "the certificate is not a CA")
return
row = await OcspSnapshot.get_or_none(ca_id=cert.id)
snapshot = await snapshot_for_ca(cert)
await send_json(ctx, 200, {
"enabled": bool(settings.ocsp_s3_bucket),
"bucket": settings.ocsp_s3_bucket,
"prefix": settings.ocsp_s3_prefix,
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
"published_at": row.published_at.isoformat() if row and row.published_at else None,
"last_error": row.last_error if row else None,
"snapshot": snapshot,
})
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}") @app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Certificate detail", tags=["certificates"]) @operation(summary="Certificate detail", tags=["certificates"])
@require_auth(kca.oidc) @require_auth(kca.oidc)
@@ -22,6 +22,7 @@ from kca.core.imports import ParsedItem
from kca.core.names import DnEntry from kca.core.names import DnEntry
from .config import settings from .config import settings
from .ocsp import mark_all_cas_dirty, mark_ocsp_dirty
from .models import ( from .models import (
ApiToken, ApiToken,
Authority, Authority,
@@ -807,14 +808,19 @@ async def issue_certificate(
request_obj.signed_at = datetime.now(timezone.utc) request_obj.signed_at = datetime.now(timezone.utc)
await request_obj.save(update_fields=["signed", "signed_at"]) await request_obj.save(update_fields=["signed", "signed_at"])
await relink_certificates(vault) await relink_certificates(vault)
if signer is not None:
await mark_ocsp_dirty(signer.id)
return row return row
async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None: async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None:
issuer_id = cert.issuer_id
await Certificate.filter(issuer_id=cert.id).update(issuer_id=None) await Certificate.filter(issuer_id=cert.id).update(issuer_id=None)
await Crl.filter(issuer_id=cert.id).update(issuer_id=None) await Crl.filter(issuer_id=cert.id).update(issuer_id=None)
await CertificateRevocation.filter(ca_id=cert.id).delete() await CertificateRevocation.filter(ca_id=cert.id).delete()
await cert.delete() await cert.delete()
if issuer_id is not None:
await mark_ocsp_dirty(issuer_id)
async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]: async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
@@ -946,6 +952,8 @@ async def renew_certificate(
if payload.get("replace_old"): if payload.get("replace_old"):
await delete_certificate_cascade(vault, cert) await delete_certificate_cascade(vault, cert)
await relink_certificates(vault) await relink_certificates(vault)
if signer.id != cert.id:
await mark_ocsp_dirty(signer.id)
return row return row
@@ -973,6 +981,7 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
invalidity_date=parse_datetime(invalidity, "invalidity_date") if invalidity else None, invalidity_date=parse_datetime(invalidity, "invalidity_date") if invalidity else None,
reason=reason, reason=reason,
) )
await mark_ocsp_dirty(ca.id)
return row return row
@@ -1369,6 +1378,7 @@ async def import_parsed_items(
"crl_number": int(crl.crl_number or 0), "crl_number": int(crl.crl_number or 0),
}, },
) )
await mark_all_cas_dirty(vault)
return {"imported": imported, "skipped": skipped} return {"imported": imported, "skipped": skipped}
+100
View File
@@ -401,3 +401,103 @@ class BulkRevocationTests(unittest.TestCase):
json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"}, json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"},
) )
assert response.status_code == 404 assert response.status_code == 404
class OcspSnapshotTests(unittest.TestCase):
@async_test
async def test_snapshot_contains_status_and_reason(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "ocsp ca")
ca = await _cert(client, vault_id, ca_key["id"], "OCSP CA", ca=True)
leaf_key = await _key(client, vault_id, "ocsp leaf")
leaf = await _cert(client, vault_id, leaf_key["id"], "OCSP Leaf", signer_id=ca["id"])
await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
json={"reason": "keyCompromise"},
)
response = await client.get(
f"/api/vaults/{vault_id}/certificates/{ca['id']}/ocsp-status"
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["enabled"] is False
snapshot = payload["snapshot"]
assert snapshot["schema"] == "kca-ocsp-status/v1"
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
assert entries[leaf["serial_hex"]]["status"] == "revoked"
assert entries[leaf["serial_hex"]]["reason"] == "keyCompromise"
assert entries[leaf["serial_hex"]]["revoked_at"]
@async_test
async def test_revocation_marks_snapshot_dirty(self) -> None:
from dataclasses import replace
from unittest.mock import patch
from kca import ocsp
from kca.models import OcspSnapshot
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "dirty ca")
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
leaf_key = await _key(client, vault_id, "dirty leaf")
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
with patch.object(ocsp, "settings", configured):
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
json={"reason": "superseded"},
)
assert response.status_code == 200, response.text
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is True
@async_test
async def test_publisher_marks_dirty_and_uploads(self) -> None:
from dataclasses import replace
from unittest.mock import patch
from kca import ocsp
from kca.models import OcspSnapshot
class FakeS3:
def __init__(self):
self.calls = []
def put_object(self, **kwargs):
self.calls.append(kwargs)
fake = FakeS3()
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
ca_key = await _key(client, vault_id, "ocsp publish ca")
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
configured = replace(
ocsp.settings,
ocsp_s3_bucket="test-bucket",
ocsp_s3_prefix="ocsp",
)
with patch.object(ocsp, "settings", configured), patch.object(
ocsp, "_s3_client", lambda: fake
):
await ocsp.mark_ocsp_dirty(ca["id"])
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is True
async def ensure_context():
return None
mixin = ocsp.OcspPublisherMixin(1, ensure_context)
await mixin.run_once()
assert len(fake.calls) == 1
call = fake.calls[0]
assert call["Bucket"] == "test-bucket"
assert call["Key"] == f"ocsp/{ca['fingerprint_sha256']}.json"
assert b"kca-ocsp-status/v1" in call["Body"]
row = await OcspSnapshot.get(ca_id=ca["id"])
assert row.dirty is False
assert row.published_at is not None
+75 -1
View File
@@ -309,6 +309,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
] ]
[[package]]
name = "boto3"
version = "1.43.118"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" },
]
[[package]]
name = "botocore"
version = "1.43.118"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.10.19" version = "2026.10.19"
@@ -876,6 +904,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "kaya-core" name = "kaya-core"
version = "0.0.8" version = "0.0.8"
@@ -1015,6 +1052,9 @@ otel = [
{ name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-instrumentation-redis" },
{ name = "opentelemetry-instrumentation-system-metrics" }, { name = "opentelemetry-instrumentation-system-metrics" },
] ]
s3 = [
{ name = "boto3" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@@ -1026,6 +1066,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aerich" }, { name = "aerich" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" },
{ name = "cryptography", specifier = ">=44" }, { name = "cryptography", specifier = ">=44" },
{ name = "granian", specifier = ">=2.0" }, { name = "granian", specifier = ">=2.0" },
{ name = "httpx" }, { name = "httpx" },
@@ -1048,7 +1089,7 @@ requires-dist = [
{ name = "redis" }, { name = "redis" },
{ name = "tortoise-orm", specifier = ">=0.25" }, { name = "tortoise-orm", specifier = ">=0.25" },
] ]
provides-extras = ["otel"] provides-extras = ["s3", "otel"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -1635,6 +1676,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" }, { url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -1726,6 +1779,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.19.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.5.0" version = "2.5.0"
+8
View File
@@ -342,6 +342,14 @@ pub async fn ca_info(vault_id: &str, cert_id: &str) -> Result<serde_json::Value,
check(response).await check(response).await
} }
pub async fn ocsp_status(vault_id: &str, cert_id: &str) -> Result<serde_json::Value, String> {
let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/ocsp-status"))
.send()
.await
.map_err(|e| e.to_string())?;
check(response).await
}
pub async fn update_ca(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> { pub async fn update_ca(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca"), Some(payload)).await?; let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca"), Some(payload)).await?;
check(response).await check(response).await