OCSP pre-signing and static artifact publishing (CDN offload)¶
The public revocation endpoints — OCSP, CRLs, chains — are the PKI's highest volume surface, and in the naive design every OCSP request costs an on-HSM signature. Task 58 removes the HSM from that hot path twice over:
- OCSP pre-signing — a background batch signs a response for every known serial on a schedule and places it in the response cache. The public responder then answers from memory; the HSM is only touched by the batch, by nonce-bearing requests (RFC 8954 requires a fresh, request-bound signature), and by cache misses.
- Static artifact publishing — a publisher writes the CRLs, delta CRLs, partition shards, issuer chains, and the pre-signed OCSP responses as static files to a local directory or an S3-compatible object store, in a layout a CDN (or any static file server) can front. The PKI can then be entirely absent from the serving path.
Because a pre-signed response is an ordinary signed OCSP response valid until
its NextUpdate, both layers double as an availability hedge: an HSM outage
does not interrupt revocation serving until the already-signed material
actually expires (see the runbook section below).
OCSP pre-signing¶
What gets signed¶
Each batch covers, per CA:
- every issued certificate whose
NotAfteris within the expired-grace window (default: expired less than 24h ago) → statusgood, - every revocation record (always, so
revokedanswers never age out while relying parties still ask) → statusrevoked, - when enabled, serials recently queried through the online responder —
including serials the store does not know (answered
unknown). The tracker is a bounded LRU (default 4096), which caps the extra signing an adversarial scanner can induce.
The batch resolves all statuses up front, opens one HSM signer for the
whole CA, and signs sequentially — no per-request session churn. When the
delegated responder is enabled (server.ocsp.delegated), pre-signed responses
are signed by the same short-lived delegated OCSP-signing certificate as
online responses, keeping the CA key cold.
Responses are placed in the shared response cache with an expiry equal to
their own NextUpdate (not the demand-fill TTL), and the cache prefers
evicting demand-filled entries under pressure so a flood of distinct serials
cannot evict the pre-signed set. Revoking a certificate still invalidates its
cache entry immediately — the next request is answered freshly with revoked.
Configuration¶
server:
ocsp:
presign:
enabled: true
validity_minutes: 1440 # NextUpdate window (default 24h)
refresh_minutes: 360 # batch cadence (default validity/4)
recently_queried: true # track+cover serials seen online (default)
recent_capacity: 4096
expired_grace_minutes: 1440 # keep signing 24h past leaf expiry; -1 disables
Validation refuses refresh_minutes > validity_minutes / 2 (a missed batch or
two must never mean serving expired responses) and refuses pre-signing when
the response cache is disabled (server.ocsp_cache_ttl_seconds < 0).
Choosing the windows. validity is simultaneously (a) the maximum
revocation-propagation delay for consumers of pre-signed/published responses
and (b) the maximum HSM outage the responder can ride out. 24h/6h is a
sensible default; high-security deployments shorten validity, availability-
focused ones lengthen it. Nonce-bearing and demand-signed responses are always
fresh regardless.
Metrics¶
| Metric | Meaning |
|---|---|
secsy_ocsp_presign_batch_duration_seconds |
Histogram of full batch runs |
secsy_ocsp_presign_responses_total{result} |
Responses signed / failed |
secsy_ocsp_presigned_responses |
Unexpired pre-signed entries servable now |
secsy_ocsp_presign_last_success_timestamp_seconds |
Last successful batch |
secsy_ocsp_presign_staleness_seconds |
Seconds since the last successful batch, computed at scrape; absent until the first batch |
Alert when secsy_ocsp_presign_staleness_seconds approaches the validity
window (e.g. > validity/2): responses are aging with no fresh batch behind
them.
Static artifact publishing¶
Layout¶
All paths are relative to the publish root (directory backend: <path>/current/;
S3 backend: s3://<bucket>/<prefix>/):
<caID>/ca.der, ca.pem CA certificate (AIA caIssuers payload)
<caID>/chain.pem rollover-overlap chain bundle
<caID>/chain-<crossSignID>.pem alternate (cross-signed) chains
<caID>/crl.der complete base CRL
<caID>/crl-delta.der delta CRL
<caID>/crl-partition-<n>.der shard base CRL (when crl.shards >= 2)
<caID>/crl-partition-<n>-delta.der shard delta CRL
<caID>/ocsp/by-serial/<serial>.der pre-signed OCSP response (decimal serial)
<caID>/ocsp/by-request/<b64url>.der same bytes, keyed by the canonical request
manifest.json snapshot manifest, written last
The by-request key is the base64url (unpadded) encoding of the canonical
RFC 6960 OCSP GET request for that serial: SHA-1 certID, no extensions —
byte-identical to what RFC 5019-profile clients send. A CDN function fronting
GET {base}/api/ca/{id}/ocsp/{b64} maps the URL path onto the static object by
url-decoding the path segment and translating standard base64 to base64url
(+→-, /→_, strip =). Requests that don't match a static key (nonce
requests, exotic certID hashes) fall through to the origin responder, which is
exactly the RFC-required behavior.
Example CDN mappings:
/api/ca/{id}/crl → /{id}/crl.der
/api/ca/{id}/crl/delta → /{id}/crl-delta.der
/api/ca/{id}/crl/partition/{n} → /{id}/crl-partition-{n}.der
/api/ca/{id}/crl/partition/{n}/delta → /{id}/crl-partition-{n}-delta.der
/api/ca/{id}/chain → /{id}/chain.pem
/api/ca/{id}/ocsp/{b64} → /{id}/ocsp/by-request/{b64url(b64)}.der
Atomicity and integrity¶
- Every artifact is validated before inclusion: CRLs must parse, verify against the CA, and be unexpired; chains must parse as certificates; expired pre-signed responses are dropped.
- Directory backend: each snapshot is written to its own versioned
directory under
snapshots/, every file is read back and its SHA-256 compared to the manifest, and only then is thecurrentsymlink flipped over it with a singlerename(2)— consumers see the old snapshot or the new one, never a mixture. Old snapshots beyondkeep_snapshots(default 3) are pruned afterwards. Point your CDN origin / rsync / file server at<path>/current. - S3 backend: objects offer no multi-key atomicity, so the contract is
manifest-last:
manifest.jsonis only uploaded after every artifact succeeded, and each PUT carries aContent-MD5the server verifies, with the returned ETag checked against the local digest. Manifest-driven consumers never see a partial snapshot; per-object consumers see atomic object replacement. manifest.jsonrecords the SHA-256, size, kind, and validity horizon of every artifact plus the snapshot-wideearliest_expiry.
Configuration¶
publish:
enabled: true
interval_minutes: 360 # default: the presign refresh cadence
cas: [] # ids/labels; empty = all unexpired X.509 CAs
include_ocsp: true
dir:
path: /var/lib/secsy/publish
keep_snapshots: 3
# or an S3-compatible store (setting s3.bucket selects it):
s3:
endpoint: http://minio.internal:9000 # empty = AWS S3
region: us-east-1
bucket: pki-artifacts
prefix: rev/prod
access_key_id: "..." # empty = AWS default chain
secret_access_key: "..."
concurrency: 8
publish.enabled with OCSP artifacts requires server.ocsp.presign.enabled
(or an explicit include_ocsp: false) — the two are designed to run off the
same batch so the CDN serves exactly what the origin cache serves.
Metrics¶
secsy_publish_runs_total{backend,result}, secsy_publish_duration_seconds,
secsy_publish_artifacts{kind}, secsy_publish_last_success_timestamp_seconds{backend},
and secsy_publish_staleness_seconds (scrape-time, absent until the first
publish). Alert on staleness approaching the shortest artifact validity —
manifest.json's earliest_expiry is the authoritative horizon.
CLI¶
secsy-ca publish [-ca id,label,...] [-out DIR] [-skip-ocsp] [-ocsp-validity 24h] [-quiet]
secsy-ca publish -verify
publish runs one snapshot: it (re)signs any stale CRLs, pre-signs the OCSP
response set fresh, and writes the snapshot to the configured backend (-out
forces a directory). publish -verify audits the currently published
snapshot — it re-reads every artifact and checks it against the manifest
digests. Verification deliberately needs neither the HSM nor the key provider,
so it works mid-outage.
Runbook¶
HSM outage while CDN-offloaded¶
Symptoms: secsy_hsm_operations_total{result="error"} climbing,
secsy_component_up{component="hsm"} == 0, presign batches logging
WARNING: OCSP pre-signing batch failed.
What still works, and for how long:
| Surface | Behavior during outage |
|---|---|
| OCSP for pre-signed serials (cache or CDN) | Served normally until each response's NextUpdate (up to presign.validity) |
| OCSP nonce-bearing requests | tryLater (correct: they must be freshly signed) |
| OCSP for uncached serials | tryLater |
| CRLs (server and published) | Served from the store/snapshot until NextUpdate; base CRLs are typically 7-day objects |
| Issuance / renewal / revocation signing | Unavailable (HSM required) |
Operator actions:
- Confirm scope with
secsy-ca publish -verify— the published snapshot's integrity is provable without the HSM. - Watch
secsy_ocsp_presign_staleness_secondsagainstpresign.validityandmanifest.json'searliest_expiry: that is the hard deadline for HSM recovery before revocation serving degrades. - On recovery, the next presign batch and publish run restore the full
window automatically; run
secsy-ca publishfor an immediate refresh.
Note that a revocation performed during an outage cannot be signed into new
material: the server invalidates the online cache entry (those requests get
tryLater rather than a stale good), but a CDN keeps serving the previously
published good response until a fresh snapshot replaces it. This is the
standard OCSP trade-off — bound it with presign.validity.
Revocation propagation when CDN-offloaded¶
After secsy-ca revoke (or the API/gRPC equivalents):
- the online responder answers
revokedimmediately (cache invalidated); - pre-signed/published copies update on the next presign + publish cycle —
worst case
presign.refresh + publish.interval, bounded bypresign.validityplus CDN TTL.
For an urgent revocation, force the cycle: secsy-ca publish (fresh presign +
snapshot), then invalidate the CDN cache for
/{caID}/ocsp/by-serial/{serial}.der, the matching by-request key, and the
CRL paths.
Storage sizing¶
Per CA: 2 CRL objects (+2 per shard), 1–2 chain bundles, and — with OCSP
enabled — two objects per serial (by-serial + by-request), each roughly
0.5–2 KB. 100k certificates ≈ 200k objects ≈ ~300 MB per snapshot; the
directory backend retains keep_snapshots of those. Disable include_ocsp
(and front the live responder with a caching CDN instead) if object count is a
concern.
Tests¶
internal/ca/presign_test.go— batch statuses (good/revoked/unknown), cache fill and eviction preference, delegated signing, expired-grace window, andTestOCSPPresignSurvivesHSMOutage: pre-signed responses stay valid and servable across a simulated outage, on both the software provider and SoftHSM.internal/handlers/ocsp_presign_test.go— HTTP-level proof: GET/POST served from cache with the provider down, nonce requests degrade totryLater, recently-queried serials join the next batch, revocation invalidates.internal/publish/— directory layout + manifest + atomic swap + prune, corruption detection, S3 fake-endpoint publish/verify, ETag integrity failure, manifest-last on partial failure, and the full snapshot test (sharded CRLs, by-request keys,publish.Verify).