Add S3 OCSP status publishing and standalone Rust Axum responder
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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),
|
||||||
|
}
|
||||||
@@ -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
@@ -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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"
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+75
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user