Files
kca/ocsp-responder

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 and its KCA CA UUID 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>/<KCA CA UUID>.json

Set each issuer's required ca_id to the UUID of its CA certificate row in KCA. This isolates snapshots when the same public CA certificate exists in multiple vaults. The responder validates both this ID and the CA certificate fingerprint against every downloaded snapshot.

KCA publishes these objects when KCA_OCSP_S3_BUCKET is configured. The kca-ocsp-status/v2 JSON contains all certificates issued by that CA, both valid and revoked, without a generation timestamp. Freshness is carried by the S3 Expires header instead. The responder never infers status from a partial revoked-only list.

Upgrading from fingerprint-named snapshots

Deploy the updated responder with ca_id before or together with the updated KCA publisher. The responder deliberately does not fall back to legacy <prefix>/<CA certificate SHA-256>.json objects. Until KCA publishes the new objects, OCSP requests return tryLater. After verifying the new snapshots, the obsolete fingerprint-named objects can be removed.

Upgrading from timestamped snapshots

Version 2 snapshots remove generated_at and require an S3 Expires header. Upgrade the publisher and responder together: old objects have no Expires and are rejected, while old responders cannot parse the version 2 JSON. Until the new publisher writes a snapshot, the new responder returns tryLater.

Run locally

  1. Create a config based on responder.example.yaml, setting each issuer's ca_id to its CA certificate UUID in KCA.
  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 (65532:65532 in the published scratch image), or mount it via a Docker secret.
  5. Run:
OCSP_CONFIG=./responder.yaml cargo run

The default listen address is 0.0.0.0:2560.

The published image is built by Gitea Actions on release/* tags:

docker run --rm -p 2560:2560 \
  -v "$PWD/responder.yaml:/etc/ocsp/responder.yaml:ro" \
  -v "$PWD/ca:/etc/ocsp/ca:ro" \
  -v "$PWD/signers:/etc/ocsp/signers:ro" \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
  gitea.woggioni.net/woggioni/kca-ocsp:latest

It runs FROM scratch and contains only a statically linked musl executable and a CA bundle for outbound S3 HTTPS. There is no shell or package manager in the image.

Verify with OpenSSL

For a CA certificate ca.crt and leaf certificate leaf.crt:

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 refresh. Each refresh performs HeadObject to obtain Expires and the ETag, then GetObject with If-Match for that ETag. This supports RustFS, which does not return the stored Expires value from its local GetObject path, and prevents mixing metadata and bodies from different object versions. Concurrent overwrites cause a bounded retry of the complete HEAD/GET sequence.
  • Expires is the hard snapshot expiry. Missing, malformed and expired values are rejected; if no unexpired cached snapshot remains, OCSP returns tryLater. The header controls responder freshness only—S3 does not delete the object at that time.
  • If a refresh fails, a cached snapshot is served only until its own Expires timestamp, regardless of how recently it was fetched.
  • KCA republishes unchanged snapshots every KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS (default 30 seconds) and sets Expires using KCA_OCSP_OBJECT_EXPIRY_SECONDS (default 60 seconds). Keep the heartbeat comfortably below the object expiry.
  • OCSP thisUpdate and producedAt use the response time. nextUpdate is capped at the snapshot's hard expiry.
  • 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.