Introduction
hoike is a high-performance OCSP responder built in Rust, designed for pre-signed, replayable, multi-CA certificate status serving. The name comes from Hawaiian: hoike means “to show, to exhibit, to testify.”
Why hoike exists
OCSP (Online Certificate Status Protocol) is alive and well in enterprise, federal, DoD, and IoT PKI deployments, even as web browsers have largely moved to CRLs and short-lived certificates. Organizations running their own certificate authorities still need fast, reliable certificate status infrastructure that can:
- Serve thousands of OCSP responses per second with minimal latency
- Support multiple CAs from a single deployment
- Operate in air-gapped and enclave environments
- Meet post-quantum cryptography requirements (ML-DSA)
- Scale horizontally without sharing private keys across nodes
hoike addresses all of these by splitting the problem into two distinct roles: signer and edge.
The signer/edge split
This is hoike’s core architectural bet. Traditional OCSP responders combine signing and serving into a single process, which means every node that handles client requests must hold the OCSP signing key. hoike separates these concerns:
Signer – holds the OCSP signing keys (PKCS#11 HSM or PKCS#8 file),
reads revocation data from CRLs or Dogtag’s 389 DS via RFC 4533 syncrepl,
and batch-produces pre-signed OCSP responses packaged into CMS-sealed
ahu bundles. Signers also handle nonce-bearing requests by signing
fresh responses on demand (nonce_policy = "live"). The signer can run in
a hardened enclave, an HSM-attached host, or an air-gapped machine.
Edge – receives ahu bundles (via gossip, push, or manual import) and serves the pre-signed responses to OCSP clients. Edge nodes are stateless and keyless. They memory-map the bundle file and return the appropriate pre-signed bytes with zero cryptographic work at request time.
This split means you can run dozens of edge nodes without ever exposing signing keys to the network, and each edge node serves responses at memory-read speed.
The ahu bundle format
An ahu bundle is a self-describing container that packages pre-signed OCSP responses for efficient serving. Each bundle contains:
- A CBOR manifest with metadata (CA label, epoch, signature algorithm, timestamps)
- A sorted index of certificate identifiers mapped to their pre-signed responses
- A cryptographic seal binding the manifest and all entries together
Bundles support zero-copy serving via mmap, meaning the edge process maps
the file into memory and serves response bytes directly without
deserialization. The ahu CLI tool lets you inspect, verify, diff, and
apply delta updates to bundles.
Workspace overview
hoike is organized as a Rust workspace with six crates:
| Crate | Description | License |
|---|---|---|
ahu | Bundle format: read, write, verify, CMS seal verification | Apache-2.0 / MIT |
hoike-core | CertID routing, config, anti-rollback state store, seal verification on load | GPL-3.0-or-later |
hoike-sign | CRL + syncrepl adapters, OCSP response + CMS seal creation, PKCS#11, ML-DSA bridge, live nonce signing, key rotation | GPL-3.0-or-later |
hoike-server | axum HTTP handlers, nonce policy dispatch, live signing, forward proxy, admin API with RBAC, React webui | GPL-3.0-or-later |
hoike-gossip | SWIM protocol (via foca) for edge fleet coordination | GPL-3.0-or-later |
hoike-cli | CLI entry points for hoike and ahu binaries | GPL-3.0-or-later |
The ahu crate is dual-licensed under Apache-2.0/MIT so that other
projects can use the bundle format without GPL obligations. All other
crates are GPL-3.0-or-later.
Key standards
hoike implements or targets these RFCs:
- RFC 6960 – Online Certificate Status Protocol (OCSP)
- RFC 9919 – Lightweight OCSP Profile for High-Volume Environments
- RFC 9654 – OCSP Nonce Extension
- RFC 5280 – Authority Information Access (AIA) for OCSP responder discovery
- RFC 5652 – Cryptographic Message Syntax (CMS) for bundle seals
- RFC 4533 – LDAP Content Synchronization (syncrepl) for Dogtag certificate repository integration
Technology stack
- Language: Rust 1.85+
- HTTP server: axum 0.8 on tokio
- Cryptography: RustCrypto (
der,x509-cert,x509-ocsp,cms) - HSM: PKCS#11 via
cryptoki(Luna, Entrust, Utimaco, FutureX, Kryoptic) - Post-quantum: ML-DSA-44/65/87 via
ml-dsa - Serialization: ciborium (CBOR)
- Memory mapping: memmap2
- Compression: zstd
- Gossip: foca (SWIM protocol)
What’s next
Head to the Quick Start to build hoike from source and create your first OCSP responder, or jump to the Architecture Overview for a deeper look at how the pieces fit together.
Installation
hoike produces two binaries:
| Binary | Size | Purpose |
|---|---|---|
hoike | ~8 MB | OCSP responder, signer, config checker |
ahu | ~1 MB | Bundle inspection, verification, diffing, patching |
Prerequisites
- Rust 1.85+ (install via rustup)
- A C linker (provided by Xcode CLT on macOS,
build-essentialon Debian/Ubuntu,gccon Fedora/RHEL)
Verify your Rust version:
rustc --version
# rustc 1.85.0 (... 2025-...)
Build from source
Clone the repository and build in release mode:
git clone https://github.com/czinda/hoike.git
cd hoike
cargo build --release
The binaries are placed in target/release/:
ls -lh target/release/hoike target/release/ahu
Copy them to a directory on your PATH:
sudo install -m 755 target/release/hoike /usr/local/bin/
sudo install -m 755 target/release/ahu /usr/local/bin/
Verify the installation:
hoike --version
ahu --version
Container build
A Containerfile is provided for building a minimal container image:
podman build -t hoike .
Or with Docker:
docker build -t hoike .
Run the container with your configuration and bundle directory mounted:
podman run -d \
--name hoike \
-p 2560:2560 \
-v /etc/hoike/hoike.toml:/etc/hoike/hoike.toml:ro \
-v /var/lib/hoike/bundles:/var/lib/hoike/bundles:ro \
hoike serve --config /etc/hoike/hoike.toml
Build individual crates
If you only need the bundle library (for example, to integrate ahu into another tool):
cargo build --release -p ahu
Or just the CLI without gossip support:
cargo build --release -p hoike-cli --no-default-features
Next steps
With hoike and ahu installed, proceed to Your First Bundle to create a signed ahu bundle from a test CA.
Your First Bundle
This walkthrough creates a test CA, generates a CRL, signs an ahu bundle, and inspects the result. By the end you will have a working bundle ready to serve OCSP responses.
1. Generate a test CA and certificates
Use OpenSSL to create a minimal CA for testing. In production you would use your organization’s existing CA infrastructure.
mkdir -p /tmp/hoike-demo && cd /tmp/hoike-demo
# Create a CA key and self-signed certificate
openssl ecparam -name prime256v1 -genkey -noout -out ca.key
openssl req -new -x509 -key ca.key -out ca.crt -days 365 \
-subj "/CN=Demo Issuing CA/O=Hoike Test"
# Create an OCSP signing key and certificate
openssl ecparam -name prime256v1 -genkey -noout -out ocsp.key
openssl req -new -key ocsp.key -out ocsp.csr \
-subj "/CN=Demo OCSP Signer/O=Hoike Test"
openssl x509 -req -in ocsp.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out ocsp.crt -days 365 \
-extfile <(echo "extendedKeyUsage=OCSPSigning")
# Issue a few end-entity certificates
for i in 1 2 3; do
openssl ecparam -name prime256v1 -genkey -noout -out "ee${i}.key"
openssl req -new -key "ee${i}.key" -out "ee${i}.csr" \
-subj "/CN=server${i}.example.com/O=Hoike Test"
openssl x509 -req -in "ee${i}.csr" -CA ca.crt -CAkey ca.key \
-CAcreateserial -out "ee${i}.crt" -days 180
done
2. Create a CRL
Revoke one certificate and generate a CRL that hoike will consume:
# Set up a minimal CA database
touch index.txt
echo '01' > crlnumber
# Create an openssl.cnf for CRL generation
cat > openssl.cnf <<'EOF'
[ca]
default_ca = demo_ca
[demo_ca]
database = ./index.txt
crlnumber = ./crlnumber
default_md = sha256
default_crl_days = 30
EOF
# Revoke ee3
openssl ca -config openssl.cnf -revoke ee3.crt \
-keyfile ca.key -cert ca.crt
# Generate the CRL
openssl ca -config openssl.cnf -gencrl \
-keyfile ca.key -cert ca.crt -out ca.crl
3. Create a good-serials file
hoike needs to know which serial numbers should be marked as “good.” Extract the serial numbers from the non-revoked certificates:
for i in 1 2; do
openssl x509 -in "ee${i}.crt" -noout -serial | cut -d= -f2
done > good-serials.txt
cat good-serials.txt
4. Sign an ahu bundle
Now use hoike sign to produce the bundle:
hoike sign \
--ca demo-ca \
--crl ca.crl \
--good-serials good-serials.txt \
--signing-key ocsp.key \
--sig-alg ecdsa-p256 \
--certid-compat dual \
--epoch 1 \
-o demo-ca.ahu
This reads the CRL for revocation data, marks the serials in
good-serials.txt as good, signs each OCSP response with the signing
key, and packages everything into demo-ca.ahu.
Note:
--signing-keyor--demo-keyis required. hoike refuses to sign without an explicit key source.
Flag summary:
| Flag | Value | Meaning |
|---|---|---|
--ca | demo-ca | Label for this CA scope in the bundle |
--signing-key | ocsp.key | PKCS#8 signing key file (or use --demo-key for testing) |
--sig-alg | ecdsa-p256 | Signature algorithm (also: ml-dsa-44, ml-dsa-65, ml-dsa-87) |
--certid-compat | dual | Produce both SHA-256 and SHA-1 CertID entries |
--epoch | 1 | Monotonic epoch number for anti-rollback |
--issuer | ca.crt | Issuer certificate (DER) for automatic CertID computation |
--seal-key | seal.key | PKCS#8 key for CMS bundle seal (separate from signing key) |
--dual-alg | ml-dsa-87 | Produce a dual-algorithm bundle alongside --sig-alg |
--pq-signing-key | pq.key | PKCS#8 PQ signing key (required with --dual-alg) |
5. Inspect the bundle
Use ahu inspect to examine the bundle metadata:
ahu inspect demo-ca.ahu
You should see output showing the manifest (CA label, epoch, entry count, signature algorithm, timestamps) and a summary of the scopes and response counts.
6. Verify the bundle
Run a full verification of the seal, digests, and sort order:
ahu verify demo-ca.ahu
To also verify each individual entry:
ahu verify demo-ca.ahu --entries
A successful verification confirms that the bundle has not been tampered with and that all entries are correctly signed and ordered.
What you have now
demo-ca.ahu– a signed ahu bundle containing pre-signed OCSP responses for three certificates (two good, one revoked)- The bundle is self-describing: it carries all the metadata an edge node needs to serve responses without any external configuration
Next steps
Head to Starting the Responder to serve these responses over HTTP.
Starting the Responder
This guide picks up from Your First Bundle. You will create a minimal configuration, validate it, start the responder, and test it with an OpenSSL OCSP client.
1. Create a configuration file
Create a minimal hoike.toml for edge mode (serving pre-signed responses):
[server]
mode = "edge"
listen = "0.0.0.0:2560"
max_request = 8192
[storage]
bundle_dir = "/tmp/hoike-demo/bundles"
state_db = "/tmp/hoike-demo/state"
max_chain = 24
[[ca]]
label = "demo-ca"
bundle_file = "/tmp/hoike-demo/bundles/demo-ca.ahu"
nonce_policy = "ignore"
completeness = "authoritative-complete"
Set up the directories and move the bundle into place:
mkdir -p /tmp/hoike-demo/bundles /tmp/hoike-demo/state
cp /tmp/hoike-demo/demo-ca.ahu /tmp/hoike-demo/bundles/
Save the configuration as /tmp/hoike-demo/hoike.toml.
2. Validate the configuration
Before starting the server, run hoike check to validate the
configuration, bundle integrity, and connectivity:
hoike check --config /tmp/hoike-demo/hoike.toml
This verifies:
- The configuration file parses correctly
- All referenced bundle files exist and pass seal verification
- The storage directories are accessible
- Gossip seeds (if configured) are reachable
Fix any reported issues before proceeding.
3. Start the responder
Launch the OCSP responder:
hoike serve --config /tmp/hoike-demo/hoike.toml
You should see log output indicating the server is listening on port 2560
and has loaded the demo-ca bundle. The server is now ready to accept
OCSP requests.
To run in the background:
hoike serve --config /tmp/hoike-demo/hoike.toml &
4. Test with OpenSSL
Use openssl ocsp to query the responder for the status of one of the
issued certificates:
# Query status of a good certificate
openssl ocsp \
-issuer /tmp/hoike-demo/ca.crt \
-cert /tmp/hoike-demo/ee1.crt \
-url http://localhost:2560 \
-resp_text
You should see a response with status good.
Now query the revoked certificate:
# Query status of the revoked certificate
openssl ocsp \
-issuer /tmp/hoike-demo/ca.crt \
-cert /tmp/hoike-demo/ee3.crt \
-url http://localhost:2560 \
-resp_text
This should return a response with status revoked, including the revocation time from the CRL.
5. Test with hoike query
hoike includes a built-in diagnostic client. Query the responder directly:
# Get the issuer hashes (you'll need these for the query)
ISSUER_NAME_B64=$(openssl x509 -in /tmp/hoike-demo/ca.crt -outform DER | openssl dgst -sha256 -binary | base64)
ISSUER_KEY_B64=$(openssl x509 -in /tmp/hoike-demo/ca.crt -noout -pubkey | openssl pkey -pubin -outform DER | tail -c +25 | base64)
# Query status of ee1
SERIAL=$(openssl x509 -in /tmp/hoike-demo/ee1.crt -noout -serial | cut -d= -f2)
hoike query \
--url http://localhost:2560 \
--serial "$SERIAL" \
--issuer-name-b64 "$ISSUER_NAME_B64" \
--issuer-key-b64 "$ISSUER_KEY_B64"
6. Test with curl
OCSP also supports HTTP GET with a base64-encoded request in the URL path. For a quick connectivity check:
# Health check (if supported)
curl -s http://localhost:2560/health
Understanding the response path
When the edge server receives an OCSP request, it:
- Parses the request to extract the
CertID(issuer name hash, issuer key hash, and serial number) - Looks up the
CertIDin the ahu bundle’s sorted index via binary search - Returns the pre-signed response bytes directly from the memory-mapped bundle
There is no cryptographic work at request time. The response was fully
signed during the hoike sign step. The edge node is keyless.
Stopping the server
# If running in the foreground, press Ctrl+C
# If running in the background:
kill %1
Admin Web UI
If the admin API is configured, you can monitor the responder via a browser. Add to your hoike.toml:
[server.admin]
session_ttl_secs = 3600
[[server.admin.operators]]
name = "admin"
password_hash = "$2b$12$..." # bcrypt hash of your password
role = "administrator"
[server.webui]
static_dir = "/path/to/hoike/webui/dist"
Then visit http://localhost:2560/ui/ to access the dashboard showing bundle status, CA health, rotation status, and more.
Next steps
You now have a working OCSP responder serving pre-signed responses. From here you can:
- Read the Configuration Reference for all available options
- Set up Gossip Configuration for multi-node edge fleets
- Explore the hoike CLI Reference for the full set of commands and flags
- Review the Architecture Overview for a deeper understanding of the signer/edge split
Configuration Reference
hoike is configured with a single TOML file, loaded once at startup. The default path is /etc/hoike/hoike.toml; override it with --config:
hoike serve --config /path/to/hoike.toml
Configuration is read from the file only — there is no environment-variable layering or HOIKE_* override mechanism. The only two values that may come from the environment are named by the config: the HSM PIN (signing_key.pin_env) and the directory bind password (source.bind_password_env). Each names an environment variable to read; no other key has an environment override.
Every table below is parsed with deny_unknown_fields: an unrecognized or misspelled key is a hard startup error, not a silently ignored value. Run hoike check --config <file> to validate a file before deploying it.
[server]
Top-level server settings that control the process mode, listener, and request limits.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "edge" | Operating mode: "signer", "edge", or "combined". See Signer, Edge, and Combined mode pages. |
listen | string | "0.0.0.0:2560" | Socket address for the plaintext OCSP HTTP listener. Port 2560 is the IANA-assigned port for OCSP over HTTP. The OCSP data plane is plaintext by design — every response is signed end to end. |
max_request | integer | 8192 | Maximum OCSP request body size in bytes. Protects against oversized or malformed requests. |
admin_listen | string | — | Dedicated listener for the admin API and web UI, e.g. "127.0.0.1:2561". When unset the admin API rides listen for backward compatibility and hoike check warns. See TLS and Mutual TLS. |
admin_tls | table | — | { cert, key, client_ca } for the admin listener. Requires a build with --features tls and admin_listen set; startup fails otherwise. client_ca enables mutual TLS. |
metrics_listen | string | — | Dedicated listener for Prometheus /metrics, e.g. "127.0.0.1:9184". Requires --features metrics to expose data; otherwise returns 503. |
metrics_tls | table | — | { cert, key, client_ca } for the metrics listener. Requires --features tls and metrics_listen. |
admin | table | — | Operator accounts and session settings; see [server.admin]. |
webui | table | — | { static_dir } to serve the web UI from a directory instead of the embedded build (--features embed-webui). Omit to disable the UI. |
[server]
mode = "edge"
listen = "0.0.0.0:2560"
max_request = 8192
admin_listen = "127.0.0.1:2561"
metrics_listen = "127.0.0.1:9184"
[server.admin_tls]
cert = "/etc/hoike/tls/admin.crt"
key = "/etc/hoike/tls/admin.key"
client_ca = "/etc/hoike/tls/mgmt-ca.pem"
[server.admin]
| Key | Type | Default | Description |
|---|---|---|---|
session_ttl_secs | integer | 3600 | Fixed lifetime of a login session, in seconds. There is no idle timeout. |
operators | array of tables | [] | Named operator accounts. Each has name, password_hash (bcrypt), and role ("viewer", "operator", or "administrator"; default "viewer"). |
[server.admin]
session_ttl_secs = 900
[[server.admin.operators]]
name = "alice"
password_hash = "$2b$12$…"
role = "administrator"
There are no built-in accounts. See Admin API and RBAC for roles and login limits.
Mode validation
mode determines which code paths are active:
signer— reads revocation sources, produces ahu bundles, does not serve OCSP queries.edge— serves pre-signed responses from bundles, holds no private keys.combined— runs both signer and edge in one process.
hoike validates mode-specific constraints at startup. signer and combined require every [[ca]] to have both a source and a signing_key. nonce_policy = "live" is only meaningful where a signing key is present.
[storage]
Paths and limits for bundle storage and persistent state.
| Key | Type | Default | Description |
|---|---|---|---|
bundle_dir | string | required | Directory where ahu bundles are stored. The signer writes here; the edge reads from here. |
state_db | string | "/var/lib/hoike/state" | Path to the persistent state database. Stores epoch high-water marks for anti-rollback protection. This path must survive restarts — losing it resets rollback protection. See Anti-Rollback Protection. |
max_chain | integer | 24 | Maximum number of delta bundles in a chain before the edge demands a full bundle. |
seal_trust_anchors | array of strings | — | Paths to DER/PEM CA certificates. A bundle seal is accepted if its signer certificate was directly issued by one of these anchors. |
seal_signer_pins | array of strings | — | Paths to exact seal-signer certificates (PEM or DER). A seal is accepted if its certificate matches one of these byte for byte. |
seal_authorizations | array of tables | [] | [[storage.seal_authorizations]] entries with producer_id, issuer_key_hash, and signer_sha256 restricting which trusted signer may seal which scope. When any entry exists, every scope needs a matching one. |
When neither seal_trust_anchors nor seal_signer_pins is set, seal enforcement is disabled and bundles load with a warning. Every edge that receives bundles from another machine must set one of them. See Seal Trust Policy.
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
max_chain = 24
Operational note: Back up
state_dbalongside your bundle directory. Ifstate_dbis lost, the node cannot detect rollback or fork attacks until it re-establishes its high-water marks from a trusted source.
[gossip]
SWIM gossip protocol settings for edge fleet coordination. Gossip provides membership tracking, generation announcements (new bundles), and urgent revocation notices. See Gossip Configuration for a deep dive.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable or disable gossip. Set to false for air-gap/enclave deployments. See Air-Gap Deployments. |
bind | string | "0.0.0.0:7946" | UDP/TCP address for the SWIM protocol listener. |
seeds | array of strings | [] | Initial seed nodes for cluster join. Format: "hostname:port". |
identity_key | string | — | Path to this node’s Ed25519 PKCS#8 private key. When set, generation and urgent-revocation broadcasts from this node are signed. |
peer_identities | table | {} | Map of peer node_name → path of that peer’s Ed25519 public key. When non-empty (enforcing mode), unsigned, forged, or misattributed broadcasts are dropped before re-propagation. When empty (permissive mode), unsigned broadcasts are accepted. |
peer_keys | array | — | Rejected. A non-empty value fails startup with an error directing you to peer_identities. |
node_name | string | $HOSTNAME or "hoike-node" | Node identifier used in membership and as the key in peers’ peer_identities maps. Must be unique in the fleet. |
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip/edge-01.key"
node_name = "edge-01"
[gossip.peer_identities]
"edge-02" = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"
Signing authenticates broadcast origin; SWIM liveness traffic (pings/acks) is not authenticated and nothing on the gossip channel is encrypted. Gossip never carries certificate status data.
Disabling gossip
For air-gap or single-node deployments, disable gossip entirely:
[gossip]
enabled = false
When gossip is disabled, bundles must be delivered out-of-band (removable media, admin API upload, or a scheduled file copy). See Air-Gap Deployments.
[[ca]]
Each [[ca]] section configures one CA whose certificates this responder handles. hoike supports multiple [[ca]] sections for multi-CA deployments. Requests are routed to the correct CA by issuerKeyHash (and issuerNameHash) lookup. See Multi-CA Routing.
Identity and routing
| Key | Type | Default | Description |
|---|---|---|---|
label | string | required | Unique, non-empty label for this CA. Used in logs, metrics, and bundle filenames, so it may not be ., .., or contain / or \. |
bundle_file | string | — | Explicit path to this CA’s ahu bundle. When omitted, the edge locates the bundle within bundle_dir by label. |
issuer_name_hash | string (hex) | — | Hex-encoded issuerNameHash for explicit routing. When absent it is extracted from the loaded bundle manifest. |
issuer_key_hash | string (hex) | — | Hex-encoded issuerKeyHash for explicit routing. When absent it is extracted from the loaded bundle manifest. |
source | table | required for signer/combined | Revocation data source. See Source types. |
Signer identity inputs
Signer and combined nodes need the issuer DN and public key to compute each response’s CertID. Supply them base64-encoded; they are decoded on load.
| Key | Type | Default | Description |
|---|---|---|---|
issuer_name_der_b64 | string (base64) | — | DER of the issuer Distinguished Name. |
issuer_key_bytes_b64 | string (base64) | — | Raw issuer public-key bytes. |
Signing key
The [ca.signing_key] table configures how OCSP responses are signed. Required for signer and combined modes. Three types:
File-based (PKCS#8):
[ca.signing_key]
type = "file"
path = "/etc/hoike/ocsp-signing.key" # PKCS#8 PEM or DER
PKCS#11 HSM (requires --features pkcs11):
[ca.signing_key]
type = "pkcs11"
module = "/usr/lib/libCryptoki2_64.so" # Vendor PKCS#11 library
token_label = "hoike-partition" # Token/partition name
key_label = "ocsp-signing" # CKA_LABEL of the signing key
pin_env = "HOIKE_HSM_PIN" # Read PIN from env var
# Omit pin/pin_env and hoike prompts interactively at startup
Demo key (testing only — refuses to run in production intent):
[ca.signing_key]
type = "demo"
| Key | Type | Default | Description |
|---|---|---|---|
sig_alg | string | "ecdsa-p256" | Signature algorithm: "ecdsa-p256", "ml-dsa-44", "ml-dsa-65", or "ml-dsa-87". Any other value is a startup error. nonce_policy = "live" is not yet supported with ML-DSA. |
responder_cert | string | — | Path to the delegated OCSP signing certificate (DER or PEM). Embedded in each BasicOCSPResponse.certs per RFC 9919 §3.2.2. When set, ResponderID uses the cert’s SPKI key hash. |
seal_key | string | — | Path to a PKCS#8 key for CMS bundle seal signing. SHOULD differ from the OCSP signing key. Falls back to the signing key (with a warning) when absent. See Seal Trust Policy. |
seal_cert | string | — | Path to the seal signer’s certificate. |
CertID compatibility
| Key | Type | Default | Description |
|---|---|---|---|
certid_compat | string | "dual" | CertID hash coverage baked into produced bundles. "dual" indexes each response by both SHA-256 and SHA-1 issuerKeyHash (for clients that still send SHA-1) — this doubles the manifest entry count. "sha256" indexes by SHA-256 only; "sha1" by SHA-1 only (not recommended). Any other value is a startup error. |
Nonce handling
| Key | Type | Default | Description |
|---|---|---|---|
nonce_policy | string | "ignore" | "ignore" omits the nonce from responses (appropriate for pre-signed). "forward" proxies nonce-bearing requests to a signer. "live" signs a fresh response with the client’s nonce on every request (needs a signing key). See Nonce Policies. |
forward_to | string | — | URL of the signer to forward nonce-bearing requests to. Required when nonce_policy = "forward". Must be https:// unless forward_insecure is set; redirects are not followed. |
forward_insecure | boolean | false | Permit an http:// forward_to target. Lab use only; logged at startup. |
forward_ca | string | — | Accepted but not yet applied to the outbound client: the forward target is validated against the system trust store. Install a private CA system-wide instead. hoike check prints this caveat. |
Timing and batch production
All timing keys are integer seconds, matching session_ttl_secs.
| Key | Type | Default | Description |
|---|---|---|---|
validity_secs | integer | 86400 | Response validity window (nextUpdate − thisUpdate), in seconds. Determines how long a cached response remains valid. |
batch_interval | integer | 3600 | How often (seconds) the signer produces a new batch. The signer outage budget is roughly validity_secs − batch_interval: if the signer is down longer, edges begin serving expired responses. |
jitter_secs | integer | 7200 | Upper bound of randomized jitter added to nextUpdate so a fleet’s responses do not all expire simultaneously (thundering-herd avoidance). Bounded by the source’s own nextUpdate. |
max_age_fraction | float | 0.5 | Fraction of a response’s validity window advertised as the edge’s HTTP Cache-Control: max-age. Must be in the range (0, 1]; any other value is a startup error. |
urgent_revocation | boolean | true | When true, the signer produces an off-cycle bundle immediately on detecting a newly revoked certificate, instead of waiting for the next batch_interval. The off-cycle run emits a signer_generation audit event with trigger = "urgent". |
archive_cutoff_secs | integer | 0 (disabled) | Drop entries for certificates that expired more than this many seconds ago, bounding bundle size. Requires per-certificate notAfter, which only the 389 DS syncrepl source supplies — it is a no-op for CRL sources, and hoike check warns if you set it on a CRL-backed CA. Entries with unknown expiry are never dropped, so a revoked certificate can never silently degrade to “unknown”. |
Completeness
| Key | Type | Default | Description |
|---|---|---|---|
completeness | string | "partial" | Declares whether the producer asserts a complete directory enumeration for this CA. "partial" (default) means the bundle covers only the certificates it lists. "authoritative-complete" may only be produced from a proven full directory snapshot (389 DS syncrepl after a complete refresh); it is metadata on the bundle, not a serve-time switch. |
Serve-time semantics. Regardless of
completeness, a serial with no entry in the loaded bundle is answeredunauthorized— the edge never fabricates agoodfor an unknown serial.completenessgoverns which bundles the signer is permitted to stamp as authoritative-complete, not whether the edge invents statuses.
Source types
The [ca.source] table specifies where revocation data comes from.
CRL source (implemented):
[ca.source]
type = "crl"
path = "/var/lib/hoike/crls/enterprise.crl"
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt"
| Field | Type | Description |
|---|---|---|
type | string | Must be "crl". |
path | string | Path to the CRL file (DER or PEM). Re-read at each batch interval and on an admin-triggered signing run; there is no file watcher. |
issuer_cert | string | Certificate of the CRL issuer, used to verify the CRL signature and issuer binding. Independently provisioned trusted configuration, not discovered from the CRL. |
Only complete, direct CRLs signed with ECDSA P-256, RSA PKCS#1 v1.5 (SHA-256/384/512), or ML-DSA are accepted. CRL sources carry no per-certificate expiry, so archive_cutoff_secs has no effect on them. See Revocation Sources.
Dogtag syncrepl source (requires --features dogtag-sync):
[ca.source]
type = "dogtag-sync"
ldap_url = "ldaps://ds.pki.example:636"
base_dn = "ou=certificateRepository,ou=ca,o=pki-iot-ca-CA"
bind_dn = "uid=hoike-reader,ou=people,o=pki-iot-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls = "ldaps"
ca_cert = "/etc/hoike/tls/ds-ca.pem"
| Field | Type | Description |
|---|---|---|
type | string | Must be "dogtag-sync". |
ldap_url | string | LDAP URL for the Dogtag 389 DS instance. |
base_dn | string | Search base for the certificate repository. |
bind_dn | string | Bind DN (default cn=Directory Manager). |
bind_password | string | Bind password (prefer bind_password_env). |
bind_password_env | string | Env var containing the bind password. |
cookie_path | string | Path to checkpoint the sync cookie (default: state_db-relative). Population and cookie are checkpointed together. |
filter | string | LDAP filter (default (objectClass=certificateRecord)). |
tls | string | "ldaps", "starttls", or "none" (default, for backward compatibility). Use ldaps or starttls in production; StartTLS upgrades before the bind. |
ca_cert | string | PEM CA bundle used to validate the directory server’s certificate instead of the system roots. |
This source uses RFC 4533 Content Synchronization (syncrepl). It enumerates all issued certificates, supplies each certificate’s notAfter (enabling archive_cutoff_secs), and — after a proven complete refresh — enables authoritative-complete bundles.
Key rotation
| Key | Type | Default | Description |
|---|---|---|---|
key_rotation.renew_before_days | integer | 7 | Days before cert expiry to trigger a rotation warning. |
key_rotation.check_interval_hours | integer | 1 | Hours between rotation checks. |
key_rotation.rotation_command | string | — | Shell command to execute when rotation is needed. Receives the CA label and cert path as arguments. |
[ca.key_rotation]
renew_before_days = 7
check_interval_hours = 1
rotation_command = "/usr/local/bin/renew-ocsp-cert.sh"
Full example (signer with HSM)
[[ca]]
label = "enterprise-issuing-01"
nonce_policy = "live"
completeness = "authoritative-complete"
issuer_name_der_b64 = "MEUx…" # DER of the issuer DN, base64
issuer_key_bytes_b64 = "A0IA…" # issuer public-key bytes, base64
[ca.source]
type = "dogtag-sync"
ldap_url = "ldaps://ds.pki.example:636"
base_dn = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls = "ldaps"
[ca.signing_key]
type = "pkcs11"
module = "/usr/lib/libCryptoki2_64.so"
token_label = "hoike-partition"
key_label = "ocsp-signing"
pin_env = "HOIKE_HSM_PIN"
sig_alg = "ecdsa-p256"
responder_cert = "/etc/hoike/ocsp-signing.pem"
[ca.key_rotation]
renew_before_days = 7
rotation_command = "/usr/local/bin/renew-ocsp-cert.sh"
Validation rules
hoike validates the configuration at startup (and under hoike check) and exits with a descriptive error if any rule is violated. Beyond deny_unknown_fields (any unknown key fails), the enforced rules are:
| Rule | Error (abridged) |
|---|---|
admin_tls or metrics_tls set on a binary built without --features tls | TLS configured but this binary was built without the tls feature |
admin_tls set without admin_listen | admin_tls requires admin_listen; refusing plaintext management fallback |
metrics_tls set without metrics_listen | metrics_tls requires metrics_listen |
gossip.enabled with a non-empty peer_keys | replace gossip.peer_keys with peer_identities … |
Empty, reserved, path-bearing, or duplicate label | CA labels must be unique nonempty file names |
forward_to not https:// (and not forward_insecure + http://) | forward_to requires https:// (or explicit forward_insecure for http://) |
Invalid sig_alg | invalid sig_alg … expected one of: ecdsa-p256, ml-dsa-44, ml-dsa-65, ml-dsa-87 |
ML-DSA sig_alg with nonce_policy = "live" | nonce_policy=live is not yet supported with … signing |
Invalid certid_compat | invalid certid_compat … expected one of: dual, sha256, sha1 |
max_age_fraction outside (0, 1] | invalid max_age_fraction … must be in the range (0, 1] |
signer/combined [[ca]] missing source | has no source configured, required for … mode |
signer/combined [[ca]] missing signing_key | has no signing_key configured, required for … mode |
signer/combined with no [[ca]] | … mode requires at least one [[ca]] with a source |
Complete annotated example
# /etc/hoike/hoike.toml — Edge node serving two CAs with gossip
[server]
mode = "edge" # Keyless serving from pre-signed bundles
listen = "0.0.0.0:2560" # IANA-assigned OCSP port, plaintext by design
max_request = 8192
[storage]
bundle_dir = "/var/lib/hoike/bundles" # Where ahu bundles are read from
state_db = "/var/lib/hoike/state" # Epoch high-water marks — MUST persist across restarts
max_chain = 24
seal_trust_anchors = ["/etc/hoike/trust/producer-ca.pem"] # Admit only sealed bundles from this CA
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip/edge-01.key"
node_name = "edge-01"
[gossip.peer_identities]
"edge-02" = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"
# Enterprise issuing CA — CRL-based, pre-signed responses
[[ca]]
label = "enterprise-issuing-01"
nonce_policy = "ignore" # Pre-signed — nonce omitted from responses
certid_compat = "dual" # Index by both SHA-256 and SHA-1 CertID hashes
validity_secs = 86400 # 24h window; outage budget ≈ 86400 − 3600
batch_interval = 3600 # New batch hourly
jitter_secs = 7200 # Up to 2h of expiry spread
completeness = "partial" # CRL may not list every certificate
[ca.source]
type = "crl"
path = "/var/lib/hoike/crls/enterprise.crl"
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt"
# Partner issuing CA — nonces forwarded to a signer
[[ca]]
label = "partner-issuing-01"
nonce_policy = "forward" # Proxy nonce-bearing requests to the signer
forward_to = "https://signer.pki.example:2560"
certid_compat = "sha256" # Partner clients all support SHA-256
validity_secs = 43200 # 12h
batch_interval = 1800 # 30m
max_age_fraction = 0.5
completeness = "partial"
[ca.source]
type = "crl"
path = "/var/lib/hoike/crls/partner.crl"
issuer_cert = "/etc/hoike/trust/partner-ca.crt"
Environment variables
hoike reads exactly two values from the environment, both secrets that should not be written to the config file:
| Config key | Environment variable |
|---|---|
signing_key.pin_env | the variable it names, holding the HSM PIN |
source.bind_password_env | the variable it names, holding the directory bind password |
No other configuration key can be set or overridden from the environment. For per-instance settings in containers, template the config file or mount an instance-specific hoike.toml.
Signer Mode
The signer is the security-critical half of hoike’s split architecture. It holds private signing keys, reads revocation state from configured sources, and batch-produces ahu bundles — self-describing containers of pre-signed OCSP responses that edge nodes serve verbatim.
flowchart LR
CRL[CRL / Dogtag] -->|revocation state| S[Signer]
S -->|ahu bundle| G[Gossip / Export]
G --> E1[Edge 1]
G --> E2[Edge 2]
G --> EN[Edge N]
Enabling Signer Mode
Set mode = "signer" in the [server] section:
[server]
mode = "signer"
listen = "0.0.0.0:2560"
In signer mode, hoike does not serve OCSP responses to clients directly. Its job is to produce ahu bundles and distribute them to edge nodes (via gossip, manual copy, or scheduled fetch).
Revocation Sources
Each [[ca]] section declares a revocation source that the signer polls for certificate status. See Revocation Sources for the full reference.
CRL (implemented)
The CRL adapter reads a DER- or PEM-encoded CRL from a local path:
[[ca]]
label = "enterprise-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
The signer re-reads the CRL file on every batch cycle. An external process (e.g., cron + curl) is responsible for keeping the CRL file up to date.
Dogtag 389 DS syncrepl (requires --features dogtag-sync)
RFC 4533 Content Synchronization against the Dogtag 389 DS certificate repository. This is the positive issuance source — it enumerates every issued certificate, enabling authoritative-complete bundles where a miss is confirmed “never issued.”
[[ca]]
label = "dogtag-ca-01"
completeness = "authoritative-complete"
[ca.source]
type = "dogtag-sync"
ldap_url = "ldap://ds-iot.cert-lab.local:3389"
base_dn = "ou=certificateRepository,ou=ca,o=pki-iot-ca-CA"
bind_dn = "cn=Directory Manager"
bind_password_env = "HOIKE_LDAP_PASSWORD"
The adapter performs an initial full refresh, then uses the sync cookie for incremental updates. Certificate statuses are mapped: VALID→Good, REVOKED→Revoked (with reason/time), REVOKED_EXPIRED→Revoked, EXPIRED/INVALID→skipped.
Batch Production
The signer produces ahu bundles on a recurring schedule controlled by three parameters:
| Parameter | Default | Description |
|---|---|---|
batch_interval | 1h | How often the signer produces a new bundle |
validity | 24h | Response validity window (nextUpdate − thisUpdate) |
jitter | 2h | Random offset added to thisUpdate to prevent thundering-herd |
[[ca]]
label = "enterprise-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
batch_interval = "1h"
validity = "24h"
jitter = "2h"
Signer Outage Budget
The outage budget is the maximum time the signer can be offline before edge nodes begin serving expired responses:
outage_budget = validity − batch_interval
With defaults: 24h − 1h = 23 hours. This is the single most important number for capacity planning. If the signer goes down, edge nodes continue serving the last bundle until nextUpdate passes.
To increase the outage budget, increase validity — but longer validity means revocation information takes longer to propagate. This is a fundamental trade-off.
Jitter
The jitter parameter adds a random offset (up to the configured duration) to thisUpdate in each response. This prevents all responses in a bundle from expiring at exactly the same instant, which would cause a cache stampede at relying parties.
Signing Configuration
Signing Keys
hoike supports three key sources. The [ca.signing_key] table is required for signer/combined mode.
PKCS#8 file:
[ca.signing_key]
type = "file"
path = "/etc/hoike/ocsp-signing.key"
PKCS#11 HSM (requires --features pkcs11):
[ca.signing_key]
type = "pkcs11"
module = "/usr/lib/libCryptoki2_64.so" # Thales Luna
token_label = "hoike-partition"
key_label = "ocsp-signing"
pin_env = "HOIKE_HSM_PIN"
Omit pin and pin_env to prompt interactively at startup (recommended for production). Documented HSM module paths: Thales Luna, Entrust nShield, Utimaco CryptoServer, FutureX Vectera, Kryoptic (testing).
Demo key (testing only — produces a warning):
[ca.signing_key]
type = "demo"
Delegated Signing
When a delegated OCSP signing certificate is configured, it is embedded in every BasicOCSPResponse.certs per RFC 9919 §3.2.2. The ResponderID is computed from the certificate’s SPKI key hash (not the CA’s key).
[[ca]]
label = "enterprise-issuing-01"
responder_cert = "/etc/hoike/ocsp-responder.pem"
The responder certificate must have id-kp-OCSPSigning EKU and be issued by the CA.
Signature Algorithms
| Algorithm | Type | Notes |
|---|---|---|
ecdsa-p256 | Classical | Default; widely supported |
ml-dsa-44 | Post-quantum | NIST FIPS 204, security level 2 |
ml-dsa-65 | Post-quantum | NIST FIPS 204, security level 3 |
ml-dsa-87 | Post-quantum | NIST FIPS 204, security level 5 |
[[ca]]
label = "pqc-ready-ca"
sig_alg = "ml-dsa-65"
Note: Post-quantum algorithms produce significantly larger signatures. Verify that your relying parties support ML-DSA before switching.
Responder ID
The responder_id field controls how the responder identifies itself in OCSP responses:
responder_id = "by-key" # SubjectPublicKeyInfo hash (default, recommended)
CertID Compatibility
The certid_compat field controls which hash algorithms the signer uses for CertID matching:
| Value | Behavior |
|---|---|
dual | Index by both SHA-256 and SHA-1 hashes (default, widest compat) |
sha256 | SHA-256 only (RFC 9654 compliant, modern clients) |
sha1 | SHA-1 only (legacy clients only — not recommended) |
certid_compat = "dual"
PKCS#11 / HSM Support
PKCS#11 integration allows the signer to use hardware security modules (HSMs) for key storage and signing operations, including ML-DSA via CKM_ML_DSA. Configure the signing key as a PKCS#11 reference:
[ca.signing_key]
type = "pkcs11"
module = "/usr/lib/libCryptoki2_64.so" # Thales Luna
token_label = "hoike-partition"
key_label = "ocsp-signing"
pin_env = "HOIKE_HSM_PIN"
Omit pin and pin_env to prompt interactively at startup (recommended for production). Tested with Kryoptic; documented paths for Thales Luna, Entrust nShield, Utimaco CryptoServer, and FutureX Vectera.
Build with HSM support:
cargo build --release --features pkcs11
Dual-Algorithm Bundles
hoike can produce bundles containing both ECDSA and ML-DSA responses for the same certificates. Clients negotiate the preferred algorithm via RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms.
hoike sign \
--ca enterprise-ca \
--crl ca.crl \
--signing-key ecdsa.key \
--sig-alg ecdsa-p256 \
--dual-alg ml-dsa-87 \
--pq-signing-key ml-dsa.key \
-o dual.ahu
One bundle, both algorithms, no flag day. A client that prefers ml-dsa-87 gets the ML-DSA response; all others get ECDSA.
Key Rotation
hoike monitors OCSP signing certificate expiry and can execute a renewal command automatically.
[ca.key_rotation]
renew_before_days = 7
check_interval_hours = 1
rotation_command = "/usr/local/bin/renew-ocsp-cert.sh"
The signer checks the responder certificate at each batch interval. When the certificate is within renew_before_days of expiry, hoike logs a warning and runs rotation_command. When the certificate has expired, hoike logs an error — responses signed with an expired certificate will be rejected by clients.
CMS Seal
Each ahu bundle is sealed with a CMS SignedData signature (RFC 5652) that binds the manifest, index, and data regions. The seal key must be distinct from the OCSP signing key.
seal_key = "/etc/hoike/seal-key.p8"
seal_cert = "/etc/hoike/seal-cert.pem"
Seal keys can be ECDSA P-256 or any ML-DSA variant. When seal_trust_anchors is configured in [storage], bundles without a valid seal are rejected on load.
Urgent Revocation
When urgent_revocation = true (the default), the signer produces an off-cycle delta bundle immediately upon detecting a newly revoked certificate — without waiting for the next scheduled batch_interval.
[[ca]]
urgent_revocation = true # default
This is critical for high-security deployments where the standard batch interval creates an unacceptable revocation propagation delay. The delta bundle is distributed to edges through the normal gossip or pull mechanism.
Set urgent_revocation = false only if your revocation SLA is satisfied by the regular batch interval.
Completeness
The completeness field declares the signer’s knowledge of the CA’s revocation state:
| Value | Meaning |
|---|---|
authoritative-complete | Signer has full revocation knowledge (direct CA access) |
partial | CRL-only; may miss certificates not yet on the CRL |
completeness = "authoritative-complete"
When set to authoritative-complete, the signer produces good responses for any serial number not found in the revocation source. When set to partial, unknown serials receive an unknown status, since the signer cannot confirm they are unrevoked.
Full Signer Example
[server]
mode = "signer"
listen = "127.0.0.1:2560"
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
[gossip]
enabled = true
bind = "0.0.0.0:7946"
identity_key = "/etc/hoike/gossip.key"
node_name = "signer-01"
[[ca]]
label = "enterprise-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
signing = "delegated"
responder_cert = "/etc/hoike/ocsp-responder.pem"
responder_key = "/etc/hoike/ocsp-responder.key"
sig_alg = "ecdsa-p256"
responder_id = "by-key"
certid_compat = "dual"
nonce_policy = "ignore"
validity = "24h"
batch_interval = "1h"
jitter = "2h"
completeness = "authoritative-complete"
urgent_revocation = true
Operational Considerations
-
Key protection: The signer is the only component that touches private keys. Run it on a hardened host with restricted network access. In high-security deployments, consider an air-gapped signer that exports bundles to removable media (see Air-Gap Deployments).
-
Monitoring: Watch
bundle_production_seconds(histogram) andbundle_next_update_seconds(gauge) metrics. Alert whenbundle_next_update_secondsdrops below your outage budget threshold. -
Backup: The
state_dbdirectory contains epoch high-water marks used for anti-rollback protection. Back it up — but never restore an old snapshot, as this could allow rollback attacks. -
Scaling: The signer does not need horizontal scaling — bundle production is inherently serial per CA. For multiple CAs, configure multiple
[[ca]]sections in a single signer (see Multi-CA Routing).
Edge Mode
Edge nodes are the public-facing layer of a hoike deployment. They hold no private keys — they load pre-signed ahu bundles into memory and serve stored OCSP response bytes verbatim. This makes edge nodes safe to deploy in untrusted network zones, at the perimeter, or in third-party hosting environments.
How It Works
flowchart LR
Client -->|OCSP Request| Edge
Edge -->|lookup by CertID| Bundle["ahu Bundle (mmap)"]
Bundle -->|pre-signed bytes| Edge
Edge -->|OCSP Response| Client
- An ahu bundle is loaded into memory via
mmap. - The sorted index inside the bundle enables O(log n) binary search by CertID.
- On a hit, the edge returns the pre-signed response bytes — no cryptographic operations, no key material involved.
- On a miss, the edge returns an
unauthorizedresponse per RFC 6960 §2.3. It never fabricates or signs responses.
Configuration
Edge mode requires minimal configuration — no signing keys, no revocation sources,
no [[ca]] sections for batch production. Set mode = "edge" and point at storage:
[server]
mode = "edge"
listen = "0.0.0.0:2560"
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
| Key | Purpose |
|---|---|
bundle_dir | Directory where ahu bundle files are stored. Edge watches this directory for new or updated bundles. |
state_db | Persists epoch high-water marks for anti-rollback protection. Must survive restarts — do not place on ephemeral storage. |
Note: The
[[ca]]sections are still needed on an edge to define routing and nonce policy, but signing-related fields (signing,sig_alg,responder_key) are ignored in edge mode.
Response Lookup
When an OCSP request arrives, the edge resolves it in two steps:
-
issuerKeyHash multimap — The CertID’s
issuerKeyHashfield identifies which CA the request is for. The edge maintains a multimap from issuerKeyHash to loaded bundles, supporting multiple CAs and re-keyed CAs. -
Binary search by serial number — Within the matched bundle, the sorted index is searched for the certificate’s serial number. The index is designed for cache-friendly, branchless binary search on mmap’d memory.
If no bundle matches the issuerKeyHash, or the serial number is not in the index,
the edge returns unauthorized. It does not proxy to the signer, attempt to sign,
or return tryLater — the response is deterministic and immediate.
Bundle Acquisition
Edge nodes need to receive ahu bundles from the signer. There are three methods, from most automated to most manual:
Gossip Pull
When gossip is enabled, the edge joins the SWIM membership mesh. The signer broadcasts generation announcements when a new bundle is produced. Edges that see the announcement pull the bundle from the signer or from a peer that already has it.
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip.key"
node_name = "edge-01"
This is the recommended method for connected deployments — it provides automatic bundle distribution, failure detection, and urgent revocation propagation.
Scheduled Fetch
Use a cron job or systemd timer to pull bundles from a central distribution point (an HTTP server, S3 bucket, or the signer’s bundle endpoint):
# Example: fetch bundles every 30 minutes
*/30 * * * * curl -sf https://signer.pki.example/bundles/latest.ahu \
-o /var/lib/hoike/bundles/latest.ahu
The edge detects new or modified files in bundle_dir and loads them automatically.
This method works when gossip is disabled or when bundles are distributed through
existing infrastructure (artifact repos, CI/CD pipelines).
Manual Copy
For air-gap deployments or initial bootstrapping, copy bundles to the
edge with scp, rsync, or removable media:
scp signer:/var/lib/hoike/bundles/enterprise-issuing-01.ahu \
edge-01:/var/lib/hoike/bundles/
Verify bundles before import with ahu verify — see the
air-gap guide for the full procedure.
Cache-Control Headers
The edge sets Cache-Control: max-age=<seconds> on every OCSP response to allow
downstream HTTP caches (CDNs, reverse proxies, browsers) to store responses:
max-age = validity × max_age_fraction
With the defaults (validity = 24h, max_age_fraction = 0.5), this produces:
Cache-Control: max-age=43200
This 12-hour max-age ensures that cached responses are refreshed well before
nextUpdate, even if a bundle refresh is slightly delayed.
Horizontal Scaling
Edge nodes are effectively stateless — the only persistent state is the epoch
high-water mark in state_db, and even that is append-only. This makes horizontal
scaling straightforward:
- Add more edges. Every edge serves the same bundles and returns byte-identical responses for the same CertID.
- No coordination required. Edges do not need to talk to each other (gossip is optional and used only for bundle distribution, not request routing).
- No sticky sessions. Any edge can serve any request. Load balancers need no session affinity.
Anycast Deployment
For geographic distribution, deploy edge nodes at multiple points of presence (PoPs) behind anycast DNS or anycast IP:
flowchart TD
Client1["Client (US-West)"] --> Anycast["Anycast IP 198.51.100.1"]
Client2["Client (EU)"] --> Anycast
Client3["Client (APAC)"] --> Anycast
Anycast --> Edge1["Edge PoP US-West"]
Anycast --> Edge2["Edge PoP EU"]
Anycast --> Edge3["Edge PoP APAC"]
Signer["Signer (HQ)"] -.->|bundles via gossip| Edge1
Signer -.->|bundles via gossip| Edge2
Signer -.->|bundles via gossip| Edge3
Each PoP runs one or more edge instances. The signer distributes bundles to all PoPs via gossip, scheduled fetch, or a combination. Clients are routed to the nearest PoP by the network layer.
Storage Requirements
| Path | Contents | Persistence |
|---|---|---|
bundle_dir | ahu bundle files | Replaceable — bundles can be re-fetched from the signer. Use fast local storage for mmap performance. |
state_db | Epoch high-water marks | Must persist across restarts and redeployments. Loss of state_db disables anti-rollback protection until the next full bundle is loaded. |
Sizing
- bundle_dir: Each bundle is roughly proportional to the number of certificates the CA has issued. A CA with 1 million certificates produces bundles in the tens of megabytes. Plan for 2× headroom to hold both current and in-flight bundles during rotation.
- state_db: Small — a few kilobytes per CA. The critical requirement is durability, not capacity.
Admin API and Web UI
When [server.admin] is configured, edge nodes expose a REST API at /api/admin/ and an optional web dashboard at /ui/. This provides real-time visibility into:
- Bundle inventory (per-CA epoch, entry count, freshness)
- Responder certificate status and expiry
- Key rotation status
- Anti-rollback state (epoch high-water marks)
- Running configuration (sanitized)
See Configuration Reference for setup.
MmapBundle: Zero-Copy Serving
For large-scale deployments (millions of certificates), hoike uses MmapBundle — a zero-copy bundle reader backed by MAP_PRIVATE memory mapping. At 100 million entries (~45 GB with ECDSA), the process uses ~200 MB RSS instead of 45 GB heap, with sub-100ms startup time. Binary search runs directly on the mmap’d index region with zero allocation on the hot path.
Operational Monitoring
Key metrics to watch on edge nodes:
| Metric | Alert Condition | Meaning |
|---|---|---|
bundle_next_update_seconds | < batch_interval | Bundle is about to expire — signer may be down or distribution is broken |
bundle_load_failures | Any increment | Edge failed to load a bundle — check reason label (rollback, fork, digest, seal) |
ocsp_unauthorized_total | Sustained spike | Clients are requesting certificates the edge doesn’t know about — possible misconfiguration or missing CA bundle |
ocsp_request_duration_seconds | p99 > 1ms | Lookup should be sub-millisecond; high latency suggests memory pressure or bundle corruption |
Example: Full Edge Configuration
[server]
mode = "edge"
listen = "0.0.0.0:2560"
max_request = 8192
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
max_chain = 24
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip.key"
node_name = "edge-01"
[[ca]]
label = "enterprise-issuing-01"
nonce_policy = "ignore"
certid_compat = "dual"
Combined Mode
Combined mode runs both signer and edge in a single process. It uses the same code paths as separate signer and edge deployments — the signer loop produces ahu bundles, and the edge serving path loads and serves them — all within one binary.
When to Use Combined Mode
Combined mode is appropriate for:
- Small deployments with a single CA and low request volume
- Development and testing where simplicity matters more than isolation
- Proof of concept before committing to a split architecture
- Single-server environments where running two processes is unnecessary
Combined mode is not recommended when:
- You need key isolation (signing keys live on the serving node)
- You require high availability (single point of failure)
- You need horizontal scaling of the edge tier
- Your security policy requires the signer to be network-isolated or air-gapped
Configuration
Set mode = "combined" in the [server] section. The configuration must include both signing material (the [[ca]] sections with key references) and storage paths for bundles and state.
[server]
mode = "combined"
listen = "0.0.0.0:2560"
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
[[ca]]
label = "internal-ca"
source = { type = "crl", path = "/var/lib/hoike/crls/internal.crl" }
signing = "ca-direct"
sig_alg = "ecdsa-p256"
responder_id = "by-key"
certid_compat = "dual"
nonce_policy = "ignore"
validity = "24h"
batch_interval = "1h"
jitter = "2h"
Same Code Paths
Combined mode is not a separate implementation. It instantiates the same signer task and the same edge serving logic that run independently in split deployments. This makes combined mode useful for validating your configuration before splitting into a signer + edge topology — if it works in combined mode, it will work when split.
The signer task writes bundles to bundle_dir on its normal schedule. The edge path watches bundle_dir for new bundles and loads them, exactly as a standalone edge would.
Gossip in Combined Mode
Gossip can be enabled in combined mode, but it is rarely useful. Since the signer and edge share a process, bundles are available immediately — there is no fleet to coordinate. If you plan to add standalone edge nodes later, you can enable gossip on the combined node so it acts as a seed for the edges.
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = []
node_name = "combined-01"
Live Nonce Signing
Combined mode supports nonce_policy = "live", which signs fresh OCSP responses on demand with the client’s nonce. Since the combined node holds signing keys, this works without forwarding:
[[ca]]
label = "internal-ca"
nonce_policy = "live"
The signer looks up the certificate status from its loaded bundle, then builds a fresh response with that status and the client’s nonce in responseExtensions.
Key Rotation
Key rotation monitoring runs automatically in combined mode. Configure [ca.key_rotation] to receive warnings before the responder certificate expires and optionally run a renewal command:
[ca.key_rotation]
renew_before_days = 7
check_interval_hours = 1
rotation_command = "/usr/local/bin/renew-ocsp-cert.sh"
Limitations
| Concern | Impact |
|---|---|
| Key exposure | Signing keys are on the network-facing node |
| Single point of failure | One process crash stops both signing and serving |
| No horizontal scaling | Cannot add edge replicas without deploying separate edge nodes |
| No air-gap | The signer cannot be isolated from the network |
Migrating to Signer + Edge
When you outgrow combined mode, the migration is straightforward:
-
Deploy edge nodes. Install hoike with
mode = "edge"on one or more edge servers. Point theirbundle_dirat a location where they can receive bundles (via gossip, scheduled copy, or shared storage). -
Enable gossip. Configure the combined node and the new edges with matching gossip settings. The edges will pull bundles from the combined node.
-
Verify edge serving. Confirm that edge nodes are loading bundles and serving responses correctly.
-
Switch the combined node to signer-only. Change
mode = "combined"tomode = "signer"on the original node. It will continue producing bundles but stop serving OCSP requests directly. -
Update DNS / load balancer. Point OCSP traffic to the edge nodes instead of the former combined node.
The signer’s bundle output format is identical regardless of mode — edges don’t know or care whether their bundles came from a combined node or a dedicated signer.
Multi-CA Routing
A single hoike responder can serve OCSP responses for many CAs simultaneously. Routing is based on the issuerKeyHash field inside each OCSP request’s CertID structure, so no URL-path conventions or virtual hosts are needed — one listen address serves all CAs.
How Routing Works
Every OCSP request contains a CertID with three fields:
hashAlgorithm— the hash used (SHA-1, SHA-256, etc.)issuerKeyHash— hash of the issuing CA’s public keyserialNumber— the certificate’s serial number
At startup, hoike builds an issuerKeyHash multimap from all configured [[ca]] sections. Each CA’s public key is hashed (using the algorithms specified by certid_compat) and inserted into the map.
When a request arrives:
- Extract the
issuerKeyHashfrom the CertID. - Look up matching CA(s) in the multimap.
- Within the matched CA’s ahu bundle, binary-search the sorted index for the
serialNumber. - Return the pre-signed response, or
unauthorizedif no match is found.
Request CertID
issuerKeyHash ──► multimap lookup ──► CA's ahu bundle
serialNumber ──────────────────────► binary search ──► response
Configuring Multiple CAs
Add one [[ca]] section per CA. Each section is independent — it has its own revocation source, signing configuration, batch schedule, and nonce policy.
[[ca]]
label = "enterprise-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
signing = "ca-direct"
sig_alg = "ecdsa-p256"
responder_id = "by-key"
certid_compat = "dual"
nonce_policy = "ignore"
validity = "24h"
batch_interval = "1h"
[[ca]]
label = "device-ca"
source = { type = "crl", path = "/var/lib/hoike/crls/device.crl" }
signing = "delegated"
responder_cert = "/etc/hoike/device-responder.pem"
responder_key = "/etc/hoike/device-responder.key"
sig_alg = "ecdsa-p384"
responder_id = "by-key"
certid_compat = "sha256"
nonce_policy = "ignore"
validity = "12h"
batch_interval = "30m"
[[ca]]
label = "legacy-root"
source = { type = "crl", path = "/var/lib/hoike/crls/legacy-root.crl" }
signing = "ca-direct"
sig_alg = "ecdsa-p256"
responder_id = "by-key"
certid_compat = "sha1"
nonce_policy = "ignore"
validity = "48h"
batch_interval = "4h"
The label field is a human-readable identifier used in logs and metrics. It must be unique across all [[ca]] sections.
Re-keyed CAs
When a CA is re-keyed — new key pair, same subject name — the old and new keys produce different issuerKeyHash values. Certificates issued under the old key will have requests with the old hash; certificates under the new key will use the new hash.
Configure both as separate [[ca]] entries:
[[ca]]
label = "enterprise-issuing-01-2023"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise-2023.crl" }
# ... old key's signing config
[[ca]]
label = "enterprise-issuing-01-2025"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise-2025.crl" }
# ... new key's signing config
Both entries are active simultaneously. As old certificates expire and are no longer queried, you can remove the old entry.
Cross-signed CAs
Cross-signing creates multiple issuer paths to the same CA subject. Each cross-signed variant has a different issuer key, producing a different issuerKeyHash. Clients may send requests using any of the cross-signed paths.
Handle this the same way as re-keyed CAs: one [[ca]] entry per cross-signed variant, each with its own signing configuration.
Collision Resolution
The multimap is keyed by issuerKeyHash. In the astronomically unlikely case that two different CAs produce the same hash (effectively impossible with SHA-256, but theoretically possible with SHA-1 truncation), the multimap holds both entries. Resolution proceeds by serialNumber — the correct response is found by matching both issuerKeyHash and serialNumber against the bundle index.
In practice, hash collisions between CA keys do not occur. The multimap’s multi-value design exists to handle the certid_compat = "dual" case cleanly, where the same CA appears under both its SHA-1 and SHA-256 hashes.
The certid_compat Setting
This setting controls which hash algorithms are used to index CertIDs in the ahu bundle:
| Value | Behavior | Use case |
|---|---|---|
"dual" | Compute both SHA-1 and SHA-256 hashes; index and serve under both | Maximum compatibility (default) |
"sha256" | SHA-256 only | Modern clients; smaller index |
"sha1" | SHA-1 only | Legacy environments; not recommended for new deployments |
Most deployments should use "dual" to handle both legacy clients (which send SHA-1 CertIDs per RFC 6960) and modern clients (which use SHA-256 per RFC 9654). Use "sha256" only when you control all clients and can guarantee they use SHA-256 CertIDs.
Nonce Policies
OCSP nonces allow a client to bind a response to a specific request, preventing replay attacks. hoike supports three nonce policies that trade off between throughput and replay protection. The policy is configured per CA in the [[ca]] section.
The Three Policies
ignore (default)
[[ca]]
nonce_policy = "ignore"
Pre-signed responses contain no nonce. Any nonce in the request is silently ignored — the response is served from the ahu bundle as-is.
This is the default and the best choice for most deployments. Pre-signed responses from ahu bundles cannot include nonces (they were signed before the request arrived), so ignore is the only policy compatible with pure bundle serving. It delivers the highest throughput: every request is a simple index lookup with no cryptographic operations at serving time.
RFC 6960 makes nonces optional, and most OCSP clients (including browsers) do not send them.
forward
[[ca]]
nonce_policy = "forward"
forward_to = "https://signer.pki.example:2560"
The edge proxies nonce-bearing requests to the signer for live signing. The signer produces a fresh response that includes the client’s nonce, and the edge relays it back.
Requests without a nonce are still served from the local bundle — only nonce-bearing requests are forwarded. This gives you the throughput of pre-signed responses for the common case while satisfying clients that require nonce echo.
The forward_to URL is required when nonce_policy = "forward". It must point to a signer (or combined-mode node) that has the signing keys for this CA.
live
[[ca]]
nonce_policy = "live"
Every request is signed fresh, including the client’s nonce in the response. This provides the strongest replay protection but the lowest throughput — every request requires a signing operation.
live is only valid on signer or combined mode. Configuring nonce_policy = "live" on an edge node is a startup error, because edge nodes have no signing keys.
Choosing a Policy
| Policy | Throughput | Replay protection | Key required on serving node | Network to signer |
|---|---|---|---|---|
ignore | Highest | None (relies on short validity) | No | No |
forward | High (degrades for nonce requests) | For nonce-bearing requests | No | Yes |
live | Lowest | Full | Yes | N/A (is the signer) |
Use ignore unless you have a specific compliance requirement for nonce echo. Short response validity windows (e.g., 24 hours with 1-hour batch intervals) limit the replay window without nonces.
Use forward when a compliance framework mandates nonce support but you want to keep edge nodes keyless. The signer must be reachable from every edge that uses forward.
Use live only for small-scale deployments or when compliance requires every response to include a nonce. Since live requires signing keys on the serving node, it eliminates the security benefit of the signer/edge split.
RFC 9654 Nonce Length Validation
Regardless of nonce policy, hoike validates nonce length per RFC 9654 before processing:
| Nonce length | Behavior |
|---|---|
| 0 octets | malformedRequest — a nonce extension with empty value is invalid |
| 1 – 15 octets | MAY omit nonce from response |
| 16 – 32 octets | MUST be accepted |
| 33 – 128 octets | MAY omit nonce from response |
| > 128 octets | malformedRequest |
Nonces in the 16–32 octet range are the “MUST accept” window defined by RFC 9654. Nonces outside this range but within 1–128 octets are valid requests, but the responder is permitted to omit the nonce from the response.
Startup Validation
hoike validates nonce policy configuration at startup and refuses to start on invalid combinations:
| Condition | Result |
|---|---|
nonce_policy = "live" on mode = "edge" | Startup error — edges have no signing keys |
nonce_policy = "forward" without forward_to | Startup error — no signer URL to proxy to |
nonce_policy = "forward" on mode = "signer" | Warning — a signer forwarding to itself is valid but unusual |
Performance Implications
The nonce policy directly affects the serving path:
ignore: O(1) hash lookup + O(log n) binary search in the bundle index. No cryptography at serving time.forward: Same asignorefor non-nonce requests. Nonce-bearing requests add a network round-trip to the signer plus a signing operation. Latency depends on signer proximity and signing algorithm.live: The signer looks up the CertID status from its loaded bundle (pre-signed response exists → status is known), then builds a freshSingleResponsewith that status and the client’s nonce inresponseExtensions, and signs it. This avoids a round-trip to the CA — the signer already has the data. Throughput is bounded by the signing rate (ECDSA P-256: fast; ML-DSA-65: slower).
Revocation Sources
A signer produces bundles from one revocation source per CA. Since 0.2.0 every source is authenticated: a CRL must carry a signature that verifies under an independently configured issuer certificate, and a directory population is bound to the identity of the directory it came from. A source that cannot be authenticated never becomes a bundle.
| Source | type | Provides | Bundle completeness it can support |
|---|---|---|---|
| CRL file | crl | revoked serials | partial only |
| 389 DS syncrepl | dogtag-sync | every issued certificate with status | partial or authoritative-complete |
CRL sources
[[ca]]
label = "enterprise-ca"
[ca.source]
type = "crl"
path = "/var/lib/hoike/crl/enterprise-ca.crl" # DER or PEM
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt" # required
issuer_cert is trusted configuration, not a certificate discovered alongside the CRL. Its subject and public key must match the CA identity the bundle is scoped to; a mismatch is a configuration error. The standalone hoike sign command takes the same certificate through --issuer.
Verification profile
| Check | Behaviour |
|---|---|
| Signature | Must verify under issuer_cert. Supported: ECDSA P-256 with SHA-256; RSA PKCS#1 v1.5 with SHA-256/384/512 and 2048–8192-bit keys; ML-DSA-44/65/87. RSA-PSS and P-384 are rejected explicitly. |
| Issuer binding | CRL issuer name must equal the certificate subject; the authority key identifier, when present, must match. |
| Validity window | thisUpdate must be in the past and nextUpdate in the future, with a small clock-skew allowance. An expired or future-dated CRL is rejected; the previous bundle stays in service. |
| Delta and indirect CRLs | Rejected. hoike only consumes complete, direct CRLs. |
| Critical extensions | Unknown critical extensions cause rejection rather than being ignored. |
Freshness and response validity
The signed response window is derived from the source, never the other way round. validity_secs and batch jitter can shorten a response’s nextUpdate but cannot extend it past the CRL’s own nextUpdate. If a CA publishes short-lived CRLs, batch_interval must be shorter still; hoike check warns when the configured interval cannot keep responses fresh.
Why a CRL is always partial
A CRL enumerates revocations; it says nothing about which serials were ever issued. hoike therefore cannot distinguish “issued and good” from “never issued” and marks CRL-derived bundles completeness = "partial". Unknown serials receive an unknown response, never good. Do not set authoritative-complete on a CRL source; the signer refuses it.
389 DS syncrepl sources
The dogtag-sync source (build with --features dogtag-sync) performs RFC 4533 content synchronization against a Dogtag or Red Hat Certificate System certificate repository in 389 Directory Server. The initial refresh loads the full population; later refreshes send a sync cookie and receive only changes.
[ca.source]
type = "dogtag-sync"
ldap_url = "ldaps://ds.pki.example.com:636"
base_dn = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_dn = "uid=hoike-reader,ou=people,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD" # prefer over bind_password
filter = "(objectClass=certificateRecord)"
tls = "ldaps" # see TLS and Mutual TLS
ca_cert = "/etc/hoike/tls/ds-ca.pem"
cookie_path = "/var/lib/hoike/state/enterprise-ca.sync"
Grant the bind identity read-only access to the repository subtree. hoike requests only cn, serialno, certStatus, revokedOn, revReason, and notAfter; it does not need the certificate blobs. notAfter supplies each certificate’s expiry, which is what makes archive_cutoff_secs effective on this source.
Status mapping
Directory certStatus | Bundle entry |
|---|---|
VALID | good |
REVOKED, REVOKED_EXPIRED | revoked, with revokedOn as the revocation time and revReason as the reason; a missing or unparsable time or reason fails the refresh |
INVALID, EXPIRED | excluded from positive issuance (responders answer unknown) |
| missing or unrecognised | error — the refresh fails and no bundle is published |
A missing or unknown status is never treated as good.
Checkpoints
The population and the sync cookie are checkpointed together, atomically, in a file whose name is derived from cookie_path plus the source identity (endpoint, base, filter, bind identity, transport, CA identity — never the password). On restart the checkpoint restores the population and resumes incremental sync. A cookie that belongs to a different source identity, or a legacy cookie-only file from 0.1.x, is ignored and a full refresh runs. Only the directory’s syncRefreshRequired result triggers a full refresh otherwise.
Operational consequences:
- Size the signer’s storage and refresh window for a full repository load; it will happen after migration and after any source-identity change.
- Back up and restore the state directory as one unit; the checkpoint and the anti-rollback marks must stay consistent.
- Run one signer process per state directory and one active signer per CA. hoike serializes within a process but does not fence competing processes.
Pruning expired entries (archive_cutoff_secs)
Because syncrepl delivers each certificate’s notAfter, a directory-backed CA can bound bundle size by dropping long-expired certificates:
[[ca]]
label = "enterprise-ca"
archive_cutoff_secs = 2592000 # drop entries expired more than 30 days ago
An entry is pruned only when its notAfter is known and notAfter + archive_cutoff_secs is already in the past. Entries with unknown expiry are never dropped, so a revoked certificate can never silently degrade to unknown. This is why the feature is a no-op for CRL sources (a CRL carries no per-certificate expiry) — hoike check warns if you set archive_cutoff_secs > 0 on a CRL-backed CA. The default 0 disables pruning.
authoritative-complete
Because syncrepl delivers positive issuance data, a directory-backed CA may set:
[[ca]]
label = "enterprise-ca"
completeness = "authoritative-complete"
With this setting a serial that is absent from the population is answered good-less — the responder returns unknown — but the bundle’s manifest asserts that the population is complete, which downstream tooling can use to treat unknown as “not issued”. The assertion is only made when a refresh succeeds; a failed or partial refresh keeps the previous generation and its completeness claim. Set it only when the bind identity’s scope, base DN, and filter demonstrably cover every certificate the CA issues.
Migration from 0.1.x
| Change | Action |
|---|---|
issuer_cert is mandatory for CRL sources | Add it, or the signer refuses to start |
| Cookie-only sync files are ignored | Expect one full directory refresh after upgrade |
partial is now the default completeness | Explicitly set authoritative-complete where it was previously implied |
| Delta and indirect CRLs no longer accepted | Point path at the complete CRL |
Gossip Configuration
hoike uses the SWIM protocol (implemented via the foca crate) for lightweight, decentralized coordination across edge nodes. Gossip is a notification channel — it never carries OCSP response data and is never authoritative.
What Gossip Provides
Gossip serves three purposes in a hoike deployment:
| Function | Description |
|---|---|
| Membership | Automatic discovery and failure detection of edge nodes in the fleet |
| Generation announcements | Signer broadcasts when a new bundle generation is available; edges pull the bundle on receipt |
| Urgent revocation notices | Immediate notification when an off-cycle delta bundle is produced due to a revocation event |
sequenceDiagram
participant S as Signer
participant E1 as Edge-01
participant E2 as Edge-02
participant E3 as Edge-03
S->>E1: Generation announcement (epoch 42)
E1->>S: Pull bundle (epoch 42)
E1-->>E2: Gossip: new generation 42
E1-->>E3: Gossip: new generation 42
E2->>S: Pull bundle (epoch 42)
E3->>S: Pull bundle (epoch 42)
Configuration
[gossip]
enabled = true
bind = "0.0.0.0:7946"
seeds = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip.key"
node_name = "edge-01"
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable or disable gossip. Set to false for air-gap deployments. |
bind | string | "0.0.0.0:7946" | Address and port to bind the gossip listener. |
seeds | array of strings | [] | Initial contact points for joining the gossip mesh. |
identity_key | path | — | Path to the Ed25519 key used to sign gossip messages. |
peer_identities | table | {} | Map of node names to Ed25519 public key paths for broadcast verification. |
node_name | string | hostname | Human-readable, unique identifier for this node in the mesh. |
Seed Configuration
Seeds are the initial contact points a node uses to join the gossip mesh. They are not special — any existing mesh member can serve as a seed.
Recommendations:
- Configure at least two seeds for redundancy. If the single seed is down when a new node starts, it cannot join the mesh.
- Seeds do not need to be dedicated infrastructure. Point new nodes at two or three stable, long-lived edge nodes.
- A node does not need to list every member — once it contacts one seed, SWIM propagates the full membership.
[gossip]
seeds = [
"edge-a.pki.example:7946",
"edge-b.pki.example:7946",
"edge-c.pki.example:7946",
]
Identity Key
Every gossip participant signs its messages with an Ed25519 key. This prevents spoofed announcements from unauthorized nodes.
Generate a key:
hoike keygen --gossip -o /etc/hoike/gossip.key
chmod 600 /etc/hoike/gossip.key
The key file contains the Ed25519 private key. Protect it with appropriate file permissions.
Peer Identities
The peer_identities table maps each peer’s node_name to the path of its Ed25519 public key file:
[gossip.peer_identities]
"edge-02" = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"
Enforcement modes:
- Enforcing mode (non-empty
peer_identities): Unsigned, forged, or misattributed generation announcements and urgent-revocation broadcasts are dropped before re-propagation. - Permissive mode (empty
peer_identities): Unsigned broadcasts are accepted for backward compatibility with a mixed or unconfigured fleet.
Note: A non-empty legacy
peer_keysarray will now cause startup to fail with an error directing you to migrate topeer_identities.
The corresponding public keys must be distributed to all nodes that will verify broadcasts from that peer.
Node Name
node_name is a human-readable identifier for the node, used in logs and diagnostics. It must be unique across the mesh.
If omitted, hoike defaults to the system hostname. In containerized environments where hostnames may collide, set node_name explicitly.
Failure Detection
SWIM detects failed nodes through a three-phase protocol:
- Ping: A random member is pinged each protocol period.
- Ping-req: If the ping times out,
kother members are asked to ping the suspect on behalf of the requester (indirect probe). - Suspect → Confirm: If indirect probes also fail, the node is marked suspect. After a timeout, it is declared failed and removed from the membership list.
Failed nodes stop receiving generation announcements and urgent revocation notices. When they recover, they rejoin via their configured seeds and pull the current bundle.
Security Model
Gossip is never authoritative. It is a hint channel that triggers bundle pulls. All trust decisions are made by verifying the bundle itself.
The security boundaries are:
- Generation announcements and urgent-revocation broadcasts are signed with the sender’s
identity_key. In enforcing mode (non-emptypeer_identities), unsigned or incorrectly signed broadcasts are dropped before re-propagation. SWIM liveness traffic (pings/acks) is not authenticated. - Bundle validity is verified independently via CMS seal and epoch chain — not via gossip trust. A gossip announcement only says “a new generation exists”; the edge independently verifies every bundle it loads.
- A compromised gossip node cannot inject bad responses. The worst it can do is trigger unnecessary pull attempts. The pulled bundle must still pass CMS seal verification and anti-rollback checks.
- Gossip cannot suppress bundles. Edges also poll on a schedule, so even if gossip is disrupted, bundles are eventually loaded.
Network Requirements
Gossip uses UDP on the configured bind port (default 7946).
Firewall rules must allow:
- Inbound UDP on the gossip port from all mesh members
- Outbound UDP to all mesh members on their gossip port
In environments with strict firewall policies, you may need to allowlist the gossip port between all edge nodes and the signer. If UDP is blocked entirely, disable gossip and use air-gap mode or scheduled bundle fetching.
Disabling Gossip
For air-gap deployments or environments where gossip is not feasible:
[gossip]
enabled = false
See Air-Gap Deployments for the full air-gap configuration guide.
TLS and Mutual TLS
hoike applies TLS to its management surfaces and inter-component channels, not to the OCSP data plane. The OCSP listener (server.listen) serves plaintext HTTP by design: every response is signed end to end, RFC 6960 clients speak plaintext DER, and a transport wrapper there adds cost without adding a security property.
| Channel | Direction | TLS | Configured by |
|---|---|---|---|
| OCSP responses | inbound, server.listen | never | — |
| Admin API and web UI | inbound, server.admin_listen | server or mutual | server.admin_tls |
| Prometheus metrics | inbound, server.metrics_listen | server or mutual | server.metrics_tls |
| Nonce forwarding to an upstream responder | outbound | required (https://) | [[ca]].forward_to |
| 389 DS syncrepl | outbound | LDAPS or StartTLS | [[ca]].source.tls |
TLS support is a build-time feature. Build with --features tls (and dogtag-sync for LDAPS). A binary without the feature refuses to start if any TLS setting is present; it never silently falls back to plaintext.
Admin listener
Bind the admin API and web UI to their own listener and give it a certificate. This is required for a trusted administrative path; without admin_listen, the admin API rides the plaintext OCSP port for backward compatibility and hoike check warns.
[server]
mode = "edge"
listen = "0.0.0.0:2560" # OCSP, plaintext by design
admin_listen = "127.0.0.1:2561" # or a management-network address
[server.admin_tls]
cert = "/etc/hoike/tls/admin.crt" # PEM chain, leaf first
key = "/etc/hoike/tls/admin.key" # PKCS#8 or SEC1 PEM, mode 0600
Mutual TLS
Set client_ca to require a client certificate. Clients must present a certificate that chains to one of the CAs in the bundle; connections without one are rejected at the handshake.
[server.admin_tls]
cert = "/etc/hoike/tls/admin.crt"
key = "/etc/hoike/tls/admin.key"
client_ca = "/etc/hoike/tls/mgmt-ca.pem" # PEM bundle of client-issuing CAs
Mutual TLS authenticates the connection, not the operator. Operators still log in with POST /session; the client certificate is not yet mapped to an operator identity. Use both: mTLS keeps unauthenticated clients off the listener entirely, and the session login attributes actions to a named operator in the audit log.
Metrics listener
The metrics listener follows the same shape. Leave it bound to a private address or terminate TLS on it:
[server]
metrics_listen = "127.0.0.1:9184"
[server.metrics_tls]
cert = "/etc/hoike/tls/metrics.crt"
key = "/etc/hoike/tls/metrics.key"
Protocol versions and ciphersuites
The listeners negotiate TLS 1.3 and TLS 1.2 only. TLS 1.0 and 1.1 are never offered. TLS 1.2 requires the Extended Master Secret extension.
Ciphersuites are supplied by the underlying crypto provider (currently aws-lc-rs via rustls). There is no configuration key to narrow them. See FIPS 140-3 Status for the planned move to the operating system’s OpenSSL provider, which will place suite selection under the host crypto policy.
Forward proxy
When a CA’s nonce_policy = "forward", nonce-bearing requests are proxied to forward_to. The target must be https://; hoike check refuses a cleartext URL. Redirects are not followed and upstream response bodies are read with an incremental bound.
[[ca]]
label = "partner-ca"
nonce_policy = "forward"
forward_to = "https://ocsp-signer.example.com:2560"
forward_insecure = true permits an http:// target for lab use. It is logged at startup and must never be set in production.
forward_cacaveat. Theforward_cakey is accepted but not yet wired into the outbound client. The forward target is validated against the system trust store, so a private CA must be installed system-wide (for example under/etc/pki/ca-trust/source/anchors/followed byupdate-ca-trust).hoike checkprints the same caveat.
LDAPS and StartTLS for syncrepl
The dogtag-sync source supports three transport modes. The default is "none" for backward compatibility; production deployments should set "ldaps" or "starttls".
[ca.source]
type = "dogtag-sync"
ldap_url = "ldaps://ds.pki.example.com:636"
base_dn = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_dn = "cn=hoike-reader,ou=people,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls = "ldaps" # "ldaps" | "starttls" | "none"
ca_cert = "/etc/hoike/tls/ds-ca.pem" # validate the directory's certificate
With starttls, the upgrade completes before the bind, so the bind password never crosses the wire in cleartext.
What hoike check enforces
| Condition | Result |
|---|---|
admin_tls or metrics_tls set on a binary built without tls | startup error |
admin_listen unset while server.admin is configured | warning: admin API on the plaintext OCSP port |
forward_to is http:// without forward_insecure | error |
forward_insecure = true | warning, logged on every start |
forward_ca set | warning: not applied; install the CA system-wide |
dogtag-sync with tls = "none" | warning: bind credentials cross in cleartext |
Certificate requirements
- Server certificates need
serverAuthEKU and a SAN matching the address operators and scrapers connect to. - Private keys must be readable only by the hoike user (
chmod 0600). - Client certificates for mTLS need
clientAuthEKU and must chain to a CA inclient_ca. - hoike does not perform revocation checking on TLS peer certificates. Keep management-CA lifetimes short or rotate
client_cato revoke.
Seal Trust Policy
Every ahu bundle carries a CMS SignedData seal over its manifest and index. The seal is what lets an edge node prove that a bundle came from an authorized signer and was not truncated, reordered, or replayed. Seal verification is cryptographic; deciding which signers to trust is configuration, and that policy lives under [storage].
Without any of the keys below, seal enforcement is disabled: bundles load with a warning and the anti-rollback checks operate on unauthenticated manifest data. That mode is acceptable only when bundle_dir is already trusted end to end (a combined-mode node writing and reading its own bundles). Every edge node that receives bundles from elsewhere must configure a trust policy.
Three mechanisms
| Key | What it trusts | When to use |
|---|---|---|
seal_signer_pins | Exact certificates (byte-for-byte DER equivalence, supplied as PEM or DER) | Small fleets, air-gapped enclaves, or when you want zero PKI dependency on the edge |
seal_trust_anchors | A CA, and any seal certificate directly issued by it | Fleets where seal certificates are renewed under a stable, dedicated CA |
seal_authorizations | Restricts an already-trusted signer to specific producers and CA scopes | Multi-tenant signers, or any deployment where one signer must not be able to seal another CA’s bundle |
The mechanisms compose: a seal is accepted if it verifies under a pin or an anchor, and (when any authorizations are configured) matches an authorization entry for its scope.
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
# Option A: explicit pins
seal_signer_pins = [
"/etc/hoike/trust/seal-signer-2026.crt",
"/etc/hoike/trust/seal-signer-2027.crt", # staged for renewal
]
# Option B: CA anchor
seal_trust_anchors = ["/etc/hoike/trust/seal-ca.pem"]
# Optional restriction, applied on top of A or B
[[storage.seal_authorizations]]
producer_id = "signer-east-1"
issuer_key_hash = "9f3a…" # hex of the manifest scope hash
signer_sha256 = "b71c…" # SHA-256 of the DER seal certificate, hex
[[storage.seal_authorizations]]
producer_id = "signer-east-1"
issuer_key_hash = "e02d…" # dual SHA-1/SHA-256 scopes need one entry each
signer_sha256 = "b71c…"
Supported profile
The seal verifier implements a bounded certificate profile, not general PKIX path building:
- Signature algorithms: ECDSA P-256 with SHA-256, ML-DSA-44/65/87.
- Anchors must carry CA basic constraints and a key usage permitting certificate signing.
- Seal certificates must be within their validity period and carry an applicable key usage.
- Only the directly-issued relationship is checked; intermediate chains, policy processing, and unknown critical extensions cause rejection.
Provision dedicated seal certificates that fit this profile. Do not relabel an arbitrary server or code-signing certificate as a seal anchor.
Renewal choreography
Pins are exact, so renewing a seal certificate is a three-step rollout:
- Add the new certificate to
seal_signer_pins(or ensure it is issued under the existing anchor) on every edge and reload. - Switch the signer to the new key and certificate (
seal_key,seal_cert). - After the fleet has loaded at least one generation sealed by the new certificate, remove the old pin.
Reversing steps 1 and 2 will reject every new bundle at the edge until the pin lands.
Signer side
The signer seals with seal_key and seal_cert. Keep the seal key separate from the OCSP signing key; a compromised seal key can forge bundle containers but not OCSP responses, and the reverse. If seal_key is omitted, hoike falls back to the OCSP signing key with a warning. If seal_cert is omitted, hoike generates a self-signed certificate — acceptable for hoike sign --demo-key experiments, never for a fleet.
[[ca]]
label = "enterprise-ca"
seal_key = "/etc/hoike/keys/seal.p8"
seal_cert = "/etc/hoike/keys/seal.crt"
What ahu verify proves
ahu verify bundle.ahu --anchor seal-ca.pem checks that the seal is cryptographically valid under the anchor you pass on the command line. It does not consult hoike.toml, so a passing ahu verify says nothing about whether a given edge node will accept the bundle. To test a node’s policy, load the bundle through hoike check --config or watch the bundle_load audit event.
Delta bundles
ahu apply produces an unsigned intermediate by default. To install a delta on a trusting edge, seal it with an authorized signer:
ahu apply base.ahu delta.ahu -o merged.ahu \
--seal-key /etc/hoike/keys/seal.p8 \
--seal-cert /etc/hoike/keys/seal.crt \
--input-signer-pin /etc/hoike/trust/seal-signer-2026.crt
Inputs must themselves verify under the supplied pins; the output is sealed by the given key. Keyless edge nodes cannot perform this step — it belongs on the signer tier or an operator workstation with access to the seal key.
Key Rotation
hoike monitors OCSP signing certificate expiry and can automatically execute a renewal command when the certificate approaches its expiration date.
Configuration
[[ca]]
label = "enterprise-ca"
responder_cert = "/etc/hoike/ocsp-responder.pem"
[ca.key_rotation]
renew_before_days = 7
check_interval_hours = 1
rotation_command = "/usr/local/bin/renew-ocsp-cert.sh"
| Key | Type | Default | Description |
|---|---|---|---|
renew_before_days | integer | 7 | Days before cert expiry to trigger the renewal warning and rotation command |
check_interval_hours | integer | 1 | Hours between rotation status checks |
rotation_command | string | — | Shell command to execute when renewal is needed |
How it works
At each signer batch interval, hoike:
- Parses the
responder_certto extractnotBeforeandnotAfter - Computes
expires_in_secs = notAfter - now - Evaluates the rotation status:
| Status | Condition | Action |
|---|---|---|
| Ok | expires_in_secs > renew_before_days * 86400 | Log info: “OCSP signing certificate valid” |
| RenewSoon | 0 < expires_in_secs <= renew_before_days * 86400 | Log warning + execute rotation_command |
| Expired | expires_in_secs <= 0 | Log error: “OCSP signing certificate has EXPIRED” |
Rotation command
The rotation_command is executed via sh -c when the certificate enters the RenewSoon window. It receives the CA label as context in the log. Typical implementations:
- Request a new certificate from the CA via EST or CMP
- Trigger a certmonger renewal
- Call an internal API to issue a new OCSP signing certificate
- Send an alert to the operations team
Admin API
The rotation status for each CA is available via the admin API:
GET /api/admin/rotation
Returns a list of per-CA rotation statuses with expires_in_secs for dashboard display. The admin API also exposes POST /api/admin/rotate/{label} to manually trigger the rotation command.
Certificate requirements
The OCSP signing certificate must:
- Have the
id-kp-OCSPSigningExtended Key Usage - Be issued by the CA it serves
- Include
id-pkix-ocsp-nocheckextension (recommended)
hoike validates these properties and logs warnings when they are missing. Use hoike check to verify certificate configuration before deployment.
Web UI
hoike includes a React + PatternFly 6 web dashboard for monitoring and managing the OCSP responder. The UI is served at /ui/ alongside the OCSP protocol endpoints.
Pages
| Page | Description |
|---|---|
| Dashboard | Server mode, uptime, total entries, bundle count. Per-CA status table with rotation indicators. |
| Bundles | Bundle inventory table. Reload button. Detail view per CA. Inspect page for uploaded bundles. |
| CAs | Per-CA list with algorithm, nonce policy, cert expiry, rotation status. Detail view with config and action buttons. |
| Signing | Trigger signing operations (combined/signer mode only). Per-CA sign buttons. |
| Query | OCSP query form with serial, issuer hashes, and algorithm preference. Displays parsed results. |
| Gossip | Cluster membership status (when gossip is enabled). |
| Config | Read-only view of the running configuration (passwords and keys redacted). |
Setup
Development mode (disk serving)
Build the UI and configure hoike to serve from disk:
cd webui
npm install
npm run build # Produces webui/dist/
Add to hoike.toml:
[server.webui]
static_dir = "/path/to/hoike/webui/dist"
For hot-reload during development:
cd webui
npm run dev # Starts Vite on http://localhost:9000
The Vite dev server proxies /api/admin to http://localhost:2560.
Production mode (embedded binary)
Build with the embed-webui feature to bake the UI into the binary:
cd webui && npm run build
cd .. && cargo build --release --features embed-webui
Add [server.webui] to your config (without static_dir) to enable the embedded UI:
[server.webui]
# No static_dir — served from embedded binary
Authentication
The webui requires [server.admin] to be configured with at least one operator. The login page authenticates against the admin API and stores a session token in the browser’s sessionStorage.
Role-based access controls which pages and actions are available:
| Role | Visible pages | Actions |
|---|---|---|
| Administrator | All | Sign, rotate, reload, view config |
| Operator | All except config details | Sign, reload |
| Viewer | Dashboard, Bundles, CAs, Query, Gossip | Read-only |
Technology stack
- React 19 + TypeScript
- PatternFly 6 (Red Hat’s design system)
- Vite 6 (build tool)
- react-router-dom 7 (client-side routing)
- Pure React state management (useState, useEffect, useContext)
Air-Gap Deployments
hoike supports air-gapped (enclave) deployments where there is no network connectivity between the signer and edge nodes. Bundles are transferred via removable media, and the edge serves responses using byte-identical code — there is no special air-gap binary.
When to Use Air-Gap Mode
Air-gap deployments are appropriate for:
- Classified networks where no data path exists between the signing environment and the serving environment
- High-security enclaves with strict network segmentation requirements
- Compliance regimes that mandate physical separation of key material from internet-facing infrastructure
- Disaster recovery environments where gossip infrastructure is unavailable
Configuration
Air-gap mode is simply an edge with gossip disabled:
[server]
mode = "edge"
listen = "0.0.0.0:2560"
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db = "/var/lib/hoike/state"
[gossip]
enabled = false
[[ca]]
label = "enterprise-issuing-01"
certid_compat = "dual"
nonce_policy = "ignore"
Everything else is standard edge configuration — the same [[ca]] sections, the same [storage] layout, the same serving behavior.
Bundle Import Workflow
flowchart LR
A[Signer produces bundle] --> B[Copy to removable media]
B --> C[Physical transfer]
C --> D["Verify: ahu verify bundle.ahu"]
D --> E["Import: hoike import *.ahu"]
E --> F[Edge serves responses]
Step by Step
-
Signer produces bundles on the signing network as usual (via
batch_intervalscheduling). -
Copy bundles to removable media. USB drives, optical discs, or any physically transferable storage.
-
Physically transfer the media to the air-gapped network following your site’s security procedures.
-
Verify bundles before importing. On the air-gapped edge (or a verification workstation on the air-gapped network):
ahu verify /media/usb/bundles/*.ahu -
Import the verified bundles:
hoike import /media/usb/bundles/*.ahuThe import command copies the bundle files into
bundle_dirand triggers a reload.
Verification with ahu verify
Always verify bundles before importing. ahu verify checks three things:
| Check | What it validates |
|---|---|
| CMS seal | Cryptographic signature over the bundle is valid |
| Epoch chain | Epoch number is consistent — no rollback, no fork |
| Manifest integrity | CBOR manifest is well-formed and content matches the digest |
$ ahu verify bundle-epoch-42.ahu
✓ CMS seal valid (signer: CN=OCSP Signer, O=Example Corp)
✓ Epoch 42 — chain consistent
✓ Manifest integrity OK (sha-256)
If verification fails, do not import the bundle. Investigate the cause — possible media corruption, tampering, or a stale bundle from the wrong signer.
Byte-Identical Serving Code
The edge binary is exactly the same regardless of how bundles arrive:
- Via gossip pull from the signer
- Via manual file copy (e.g.,
scp) - Via
hoike importfrom removable media
There is no compile-time flag, no special air-gap mode in the binary, and no runtime code-path divergence. The only difference is configuration: gossip.enabled = false.
This means security auditors can verify a single binary for all deployment models.
Operational Considerations
Plan Import Frequency Around Validity Windows
Bundles have a validity window (default 24h). You must import fresh bundles before the current bundle expires, or the edge will start returning stale responses that relying parties may reject.
The signer outage budget — the maximum time you can go without producing a new bundle — is:
outage_budget = validity - batch_interval
With defaults (24h validity, 1h batch interval), you have a 23-hour window. For air-gap, plan your physical transfer cadence well within this window. A common approach: transfer bundles daily with a 24h validity, giving you a full day of margin.
state_db Must Persist
The state_db directory stores epoch high-water marks. It must persist across restarts, even in air-gap mode. If state_db is lost:
- The node loses its anti-rollback protection
- It becomes vulnerable to rollback attacks until it loads a current-epoch bundle
- See Anti-Rollback Protection for details
Prefer Full Bundles
In gossip-connected deployments, hoike uses delta bundles for efficient incremental updates. In air-gap mode:
- Delta bundles require the base bundle to already be present
- Tracking the delta chain across physical transfers adds operational complexity
- Recommendation: Transfer full bundles only. The size overhead is acceptable for the operational simplicity.
No Automatic Urgent Revocation
In gossip-connected deployments, urgent revocations trigger immediate delta production and gossip notification. In air-gap mode, there is no notification channel. If an urgent revocation occurs:
- The signer produces the off-cycle delta bundle as usual
- You must physically transfer it to the air-gapped network
- The edge cannot serve the updated revocation status until the import completes
Plan your incident response procedures to account for the physical transfer latency.
Anti-Rollback Protection
Anti-rollback protection prevents an attacker or misconfiguration from replaying an older bundle to restore previously-revoked certificates to “good” status. This is a critical security property — without it, an adversary with access to historical bundles could silently undo revocations.
The Threat
Consider a certificate revoked in epoch 40. If an attacker can replace the current bundle (epoch 42) with a bundle from epoch 38 (before the revocation), the edge would start serving “good” responses for the revoked certificate. Anti-rollback makes this impossible.
flowchart LR
subgraph Epoch Chain
E38[Epoch 38<br/>cert: good] --> E39[Epoch 39] --> E40[Epoch 40<br/>cert: revoked] --> E41[Epoch 41] --> E42[Epoch 42<br/>cert: revoked]
end
E38 -.->|"Rollback attempt<br/>REJECTED"| Edge[Edge Node]
E42 -->|"Current bundle<br/>ACCEPTED"| Edge
Epoch Chain
Each bundle carries a monotonically increasing epoch number, scoped per CA. The signer increments the epoch on every bundle production — whether scheduled or triggered by an urgent revocation.
The epoch chain forms a simple sequence:
epoch N → epoch N+1 → epoch N+2 → ...
Every bundle’s epoch is recorded in its CBOR manifest and covered by the CMS seal, so it cannot be altered without breaking the signature.
High-Water Marks
The state_db directory persists the highest epoch seen for each CA. On every bundle load, the edge enforces:
new_epoch ≥ stored_high_water_mark
If the new bundle’s epoch is less than the stored high-water mark, the bundle is rejected as a rollback.
Example
| Event | Stored HWM | Incoming Epoch | Result |
|---|---|---|---|
| Load epoch 40 | 39 | 40 | Accepted — HWM updated to 40 |
| Load epoch 41 | 40 | 41 | Accepted — HWM updated to 41 |
| Load epoch 38 | 41 | 38 | Rejected — rollback detected |
| Load epoch 41 (different digest) | 41 | 41 | Rejected — fork detected |
Fork Detection
If two bundles arrive with the same epoch but different content digests, this is a fork — it means two signers produced bundles independently for the same CA, or a single signer’s state was cloned.
Fork detection catches:
- Misconfigured duplicate signers: Two signer instances both believe they are authoritative for the same CA
- State cloning: A signer’s state directory was copied, producing a second lineage
- Compromise: An attacker with the signing key producing alternative bundles
Fork is always a critical security event requiring immediate investigation.
Rejection Reasons
Bundle load failures are categorized into four reasons:
| Reason | Condition | Severity |
|---|---|---|
| rollback | New epoch < stored high-water mark | Critical — possible replay attack |
| fork | Same epoch, different content digest | Critical — duplicate signer or compromise |
| digest | Bundle content does not match manifest digest | High — corruption or tampering |
| seal | CMS signature verification failed | High — wrong key, tampering, or corruption |
The first two (rollback and fork) are security events. The latter two (digest and seal) typically indicate data corruption during transfer, though tampering should not be ruled out. See Seal Trust Policy for bundle admission rules.
state_db Persistence
The state_db directory is where epoch high-water marks live. It must persist across process restarts, container recreations, and node replacements.
[storage]
state_db = "/var/lib/hoike/state"
If state_db is lost, the node loses all high-water marks and becomes vulnerable to rollback attacks until it loads a current-epoch bundle. This is why state_db is a separate path from bundle_dir:
bundle_dircan be ephemeral — bundles are replaceable (re-pull from signer or re-import)state_dbis persistent state — mount it on durable storage, back it up, and include it in disaster recovery plans
In containerized environments, state_db should be on a persistent volume, not an ephemeral container filesystem.
Critical Alerts
Two metrics form the foundation of hoike operational monitoring:
bundle_next_update_seconds
What: Gauge showing seconds until the current bundle’s nextUpdate timestamp.
Why it matters: When this reaches zero, the edge is serving responses past their validity window. Relying parties that check freshness will reject them.
Alert thresholds:
| Level | Threshold | Meaning |
|---|---|---|
| Warning | < 4h remaining | Signer may be down; investigate |
| Critical | < 1h remaining | Responses will expire soon; immediate action required |
The warning threshold should be comfortably above batch_interval (default 1h). With a 24h validity and 1h batch interval, alerting at 4h gives you three missed batches before going critical.
bundle_load_failures (by reason)
What: Counter of failed bundle loads, labeled by rejection reason (rollback, fork, digest, seal).
Why it matters: Any non-zero increment for rollback or fork is a critical security alert requiring immediate investigation.
Alert rules:
| Reason | Alert level | Action |
|---|---|---|
rollback | Critical | Possible replay attack. Investigate bundle distribution path immediately. |
fork | Critical | Duplicate signer or compromise. Identify and shut down the rogue signer. |
digest | Warning | Likely transfer corruption. Re-transfer the bundle. |
seal | Warning | Wrong signing key or corruption. Verify signer configuration. |
Recovery Procedures
Rollback Detected
- Investigate the source. Why was an old bundle offered? Common causes:
- Stale bundle cached in a CDN or reverse proxy
- Misconfigured bundle distribution pipeline pointing at an old directory
- An attacker replaying a captured bundle
- Fix the distribution path. Purge stale caches, correct directory pointers.
- Produce a new bundle from the authoritative signer. The new epoch will be above the high-water mark and will load successfully.
Fork Detected
- Identify the duplicate signer. Check which hosts are running in signer mode for the affected CA.
- Shut down the unauthorized signer. Only one signer should be authoritative per CA at any time.
- Produce a new bundle from the authoritative signer with the next epoch.
- Audit the fork window. Determine whether any responses from the forked lineage were served, and whether they differed in revocation status.
Digest or Seal Failure
- Check for media corruption. Re-download or re-transfer the bundle.
- Verify with
ahu verifyon a trusted workstation to confirm the bundle is intact at the source. - If the source bundle also fails verification, investigate the signer — the signing key may have changed, or the signer may be compromised.
Hardening Guide
This page is the checklist a security reviewer should be handed with a hoike deployment. Each control names the configuration key or platform setting that implements it, and says plainly where hoike relies on the host rather than on itself.
Threat model in one paragraph
An edge node holds no signing keys, so compromising one cannot produce a false good; it can only deny service or replay a still-valid generation until nextUpdate. The signer holds keys and is the asset to protect. The bundle seal, the anti-rollback chain, and signed gossip broadcasts exist to make sure that only an authorized signer can change what edges serve. Everything below either protects the signer, protects the management plane, or makes tampering with bundles detectable.
Build
| Control | Setting |
|---|---|
| Enable TLS support | cargo build --release --features tls — a binary built without it cannot terminate TLS on any listener |
| Enable HSM signing | --features pkcs11; production signing keys must not be files |
| Reproducible dependency set | --locked; Cargo.lock is committed and audited with cargo audit in CI |
| Do not ship demo paths | hoike sign --demo-key and signing_key.type = "demo" exist for tutorials; refuse configurations that use them in production review |
Signer tier
| Control | Setting |
|---|---|
| Keys in an HSM | signing_key = { type = "pkcs11", module = "...", token_label = "...", key_label = "...", pin_env = "HOIKE_HSM_PIN" }. A forthcoming release refuses file and demo keys in signer and combined modes unless allow_software_keys = true is set and audited; treat any file-key configuration as non-production now |
| PIN never in the config file | Use pin_env, or omit both pin and pin_env to be prompted interactively at startup |
| Separate seal key | seal_key and seal_cert distinct from the OCSP signing key |
| Authenticated sources | CRL: issuer_cert required. Directory: tls = "ldaps" or "starttls", ca_cert, bind_password_env |
| Delegated responder certificate | responder_cert with the id-kp-OCSPSigning EKU, short lifetime, and key_rotation configured so expiry is caught early |
| No inbound OCSP on the signer | mode = "signer"; edges serve, the signer only produces |
| State directory | Local filesystem with tested fsync/rename semantics; not NFS. One signer process per directory. Backed up as a unit |
Edge tier
| Control | Setting |
|---|---|
| Seal trust policy configured | storage.seal_signer_pins or storage.seal_trust_anchors, plus seal_authorizations where one signer must not seal another CA’s bundle. See Seal Trust Policy |
| Anti-rollback state persisted | storage.state_db on durable local storage; never delete high-water marks to “fix” a load failure |
| Read-only bundle mount | Mount bundle_dir read-only into the container; the edge never writes there |
| Gossip authenticated | gossip.identity_key on every node, gossip.peer_identities listing every peer by name; an empty map is permissive mode and is not equivalent to authenticated operation |
| Gossip bound to the fleet network | gossip.bind on an internal interface; gossip carries no status data but does carry generation announcements |
| Live signing impossible | nonce_policy = "live" on an edge is a startup error; keep it that way |
Management plane
| Control | Setting |
|---|---|
| Dedicated admin listener | server.admin_listen on a management address; never the public OCSP address |
| TLS on the admin listener | server.admin_tls = { cert, key } |
| Mutual TLS | server.admin_tls.client_ca |
| Metrics private | server.metrics_listen = "127.0.0.1:9184" or behind metrics_tls; the endpoint is unauthenticated |
| Operators | Named accounts in server.admin.operators, one per person, least role that does the job; viewer for dashboards |
| Session lifetime | session_ttl_secs at 900 or less for administrator-heavy nodes (default 3600) |
| Web UI | Omit server.webui on nodes where no one will use it; the API still works |
Known gaps in this release, tracked for the next: per-account lockout, password complexity, idle timeout, consent banner, HTTP security headers, and operator identity in audit events. Compensate with mutual TLS and network segmentation until they land.
Process and host
| Control | Setting |
|---|---|
| Non-root | The container image runs as nonroot; on a host, a dedicated hoike system user |
| File permissions | hoike.toml 0640 (it contains password hashes); keys 0600; bundle_dir read-only for the edge user |
| Secrets | Environment variables (pin_env, bind_password_env) injected by the platform’s secret mechanism, never on the command line |
| Time | chrony or the platform equivalent; response validity windows and anti-rollback continuity depend on host time |
| Logging | Forward stdout to journald or the platform collector; add audit=info to any custom RUST_LOG. See Audit Logging |
| FIPS mode | Determined by the host kernel (/proc/sys/crypto/fips_enabled); hoike’s own crypto does not yet run inside the operating system’s validated module — see FIPS 140-3 Status |
| Firewall | Edge: OCSP port (2560) public, gossip port fleet-only, admin and metrics management-only. Signer: no public inbound at all |
OpenShift and Kubernetes
- Run under the
restricted-v2SCC; hoike needs no capabilities, no host paths, and no privilege escalation. - Mount
hoike.tomland TLS material from Secrets; bundles from a ConfigMap, PVC, or an init container that fetches from the signer, mountedreadOnly: true. - Give the state directory a PVC with
ReadWriteOnce; never share it between replicas. - Expose only the OCSP port through the Route or Service; keep the admin port ClusterIP-only and reach it through
oc port-forwardor a management ingress with mTLS.
Air-gapped enclaves
The same binary and configuration apply. Disable gossip (gossip.enabled = false), import bundles on media into bundle_dir, and rely on the seal trust policy to verify what was imported. The anti-rollback chain still enforces monotonic epochs across imports. See Air-Gap Deployments.
Review checklist
- Binary built with
tls(andpkcs11on signers) - Every signer key in an HSM; no
type = "file"or"demo"keys -
issuer_certset for every CRL source; LDAPS or StartTLS for every directory source - Seal trust policy configured on every edge;
ahu verifyis not a substitute -
identity_keyand a completepeer_identitiesmap on every gossiping node -
admin_listenset,admin_tlsset,client_caset -
metrics_listenbound to loopback or behind TLS - One named operator per person; roles reviewed
-
hoike check --configclean, with warnings explained in writing - Logs forwarded;
rollbackandforkaudit events alerting
Audit Logging
hoike emits a structured audit log on a dedicated tracing target named audit. Every event is a single structured record at INFO level with an event field and a small set of typed fields; there is no free-text message to parse.
Filter caveat. Audit events pass through the same
RUST_LOGfilter as everything else. The default filter isinfo,tower_http=debug, which includes them. If you tighten logging (for exampleRUST_LOG=warn), add an explicit directive so audit is never silenced:RUST_LOG=warn,audit=info.
Event catalog
event | Emitted by | Fields | Meaning |
|---|---|---|---|
request_rejected | edge, OCSP handler | reason, serial | An OCSP request was refused. Today the only reason is unauthorized (request for a CertID under a scope the node is not authorized to serve). |
bundle_load_failed | edge, signer, CLI | trigger, reason, ca (when known) | A bundle was rejected at load. trigger is one of initial_load, scheduled_reload, admin_reload, on_demand_reload, on_demand_all_reload. |
signer_generation | signer | ca, trigger, epoch | A new generation was produced and published. trigger is scheduled, on_demand, or on_demand_all. |
signer_generation_failed | signer | ca (when known), trigger, error | Production failed; the previous generation stays in service. |
bundle_load_failed reasons
reason | Cause |
|---|---|
rollback | Epoch lower than the persisted high-water mark, or a jump larger than max_chain |
fork | Two bundles claim the same epoch with different content — duplicate or compromised signer |
bundle | Malformed container, seal verification failure, or a seal from an unauthorized signer |
state | The anti-rollback state store could not be read or persisted |
io | Filesystem error |
other | Anything else; the accompanying error field carries detail |
rollback and fork are the events a SOC should alert on: both indicate that something other than the authorized signer is trying to influence what the edge serves.
Output and forwarding
Audit events go to the same subscriber as operational logs, on stdout in the default tracing text format:
2026-09-17T14:02:11.318Z INFO audit: event="bundle_load_failed" trigger="scheduled_reload" reason="rollback" ca="enterprise-ca"
Fields are key="value" pairs after the audit: target marker. A JSON formatter and a journald sink are planned; until then, collectors should key on the literal audit: marker and parse the key="value" pairs.
Under systemd, stdout lands in journald; filter with journalctl -u hoike -o json | jq 'select(.target=="audit")'. In containers, the platform’s log collector picks up stdout. hoike does not rotate, sign, or persist audit records itself — durability and tamper protection are provided by journald permissions, the collector, and the SIEM. Document those controls as part of the deployment; evaluators and STIG reviewers will ask for them.
Coverage and planned additions
The current catalog covers the integrity-critical paths (what was loaded, what was signed, what was refused). It does not yet record:
- login success, failure, and logout with operator name and source address;
- the operator behind an admin-triggered reload, sign, or rotate;
- configuration reload;
- role-denied requests (
403); - TLS handshake failures on the admin listener.
These are required by the NIAP audit SFRs (FAU_GEN.1/2) and the Application Security and Development STIG and are scheduled for the next release. Fields will be added, not renamed, so existing parsers keep working.
Metrics are not audit
The Prometheus endpoint (metrics_listen, --features metrics) exposes counters and gauges for capacity and freshness monitoring. It is aggregate and unauthenticated by design and should not be used as an audit source.
Admin API and RBAC
The admin API is a JSON REST surface mounted at /api/admin on the admin listener (server.admin_listen) or, when no admin listener is configured, on the OCSP listener. The web UI at /ui/ is a client of this API and has no capabilities beyond it. See TLS and Mutual TLS for putting the listener behind TLS; do not expose it in cleartext.
Operators and roles
Operators are defined statically in configuration. There are no built-in accounts and no self-registration.
[server.admin]
session_ttl_secs = 3600
[[server.admin.operators]]
name = "alice"
password_hash = "$2b$12$…" # bcrypt cost 12; see "Generating a password hash"
role = "administrator"
[[server.admin.operators]]
name = "noc"
password_hash = "$2b$12$…"
role = "viewer" # default when omitted
| Role | Can | Cannot |
|---|---|---|
viewer | Read status, bundles, certificates, rotation state, gossip membership, effective config, anti-rollback state; run OCSP queries; extract a single entry from a bundle | Change anything |
operator | Everything a viewer can, plus reload bundles, inspect/verify/diff bundles, apply deltas, trigger on-demand signing | Rotate keys |
administrator | Everything | — |
Roles are strictly ordered (viewer < operator < administrator); a route’s minimum role is checked on every request.
Generating a password hash
hoike does not yet ship a hashing subcommand. Any bcrypt tool works; cost 12 is the value the server’s timing decoy assumes:
# Apache htpasswd (httpd-tools)
htpasswd -nbBC 12 "" 'correct horse battery staple' | tr -d ':\n'
# Python
python3 -c 'import bcrypt,getpass; print(bcrypt.hashpw(getpass.getpass().encode(), bcrypt.gensalt(12)).decode())'
Never commit a hash from the documentation examples; the server should be treated as compromised if a documented hash is found in a live configuration.
Session lifecycle
sequenceDiagram
participant C as Client
participant A as Admin API
C->>A: POST /session {name, password}
A-->>C: 200 {session_token, role, expires_in_secs}
C->>A: GET /status (Authorization: Bearer <token>)
A-->>C: 200 …
C->>A: DELETE /session
A-->>C: 204
- Tokens are 32 random bytes, hex-encoded, presented as
Authorization: Bearer <token>. - A session lives for
session_ttl_secs(default 3600) from login. There is no idle timeout; the lifetime is fixed. - Logout (
DELETE /session) invalidates the token immediately. Restarting the process invalidates all sessions; the session store is in memory only. - The web UI stores the token in browser
sessionStorage, which is cleared when the tab closes.
Login limits
Login is deliberately expensive and bounded:
| Bound | Value |
|---|---|
| Request body | 4,096 bytes, 5-second read timeout |
| Concurrent password verifications | 4 |
| Process-wide login attempts | 60 per minute (429 Too Many Requests beyond that) |
| Active sessions | 4,096 (login fails with 503 when full; expired sessions are pruned first) |
| Unknown operator | Verified against a decoy hash so timing does not reveal whether the name exists |
There is no per-account lockout and no password-complexity enforcement in this release. Both are planned; see DISA STIG Guidance. Until then, put the admin listener behind mutual TLS and a management network.
Endpoints
All paths are relative to /api/admin. All require a bearer token except POST /session.
Session
| Method | Path | Role | Description |
|---|---|---|---|
POST | /session | — | Log in; body {"name": "...", "password": "..."} |
DELETE | /session | any | Log out |
Read-only
| Method | Path | Role | Description |
|---|---|---|---|
GET | /status | viewer | Process mode, uptime, loaded CAs, generation, freshness |
GET | /bundles | viewer | Loaded bundles per CA with epoch, entry counts, algorithm |
GET | /bundles/{label} | viewer | Detail for one CA’s bundle |
GET | /certs | viewer | Responder and seal certificates with expiry |
GET | /rotation | viewer | Key-rotation monitor state per CA |
GET | /gossip | viewer | SWIM membership and last generation announcements |
GET | /config | viewer | Effective configuration with secrets redacted |
GET | /state | viewer | Anti-rollback high-water marks |
POST | /query | viewer | Run an OCSP query against the local responder; body carries serial and issuer hashes |
POST | /bundles/extract | viewer | Return one entry from a bundle by CertID |
Bundle operations
| Method | Path | Role | Description |
|---|---|---|---|
POST | /bundles/reload | operator | Re-read bundle_dir, applying seal trust and anti-rollback checks |
POST | /bundles/inspect | operator | Parse a bundle and return its manifest |
POST | /bundles/verify | operator | Verify a bundle’s seal against the configured trust policy |
POST | /bundles/diff | operator | Compare two generations |
POST | /bundles/apply | operator | Materialize deltas; output is unsigned unless a seal key is available on this node |
Signing and rotation (signer or combined mode only)
| Method | Path | Role | Description |
|---|---|---|---|
POST | /sign/{label} | operator | Produce a new generation for one CA now |
POST | /sign/all | operator | Produce new generations for every CA |
POST | /rotate/{label} | administrator | Run the configured rotation_command and reload key material for one CA |
Signing routes share the signer mutex with scheduled production, so an on-demand run never interleaves with a scheduled one.
Errors
Errors are returned as short JSON strings with conventional status codes: 401 missing or expired token, 403 insufficient role, 409 signer busy, 422 malformed input, 429 login rate exceeded, 503 feature not built or capacity exhausted. Error bodies never include stack traces, file paths, or key material.
Audit trail
Bundle loads, signing runs, and rotation attempts emit audit events. Login success and failure are not yet audited and events do not yet carry the operator name; both are tracked for the next release.
Architecture Overview
hoike is built around a single architectural bet: separate signing from serving. The OCSP signing key never touches a machine that handles client traffic. An edge node compromise cannot produce a false “good” response because edge nodes have no signing material – they serve only pre-signed bytes delivered through a verified bundle chain.
The signer/edge split
Traditional OCSP responders combine signing and serving in one process. Every node that handles client requests holds the signing key, which makes each node a high-value target. hoike eliminates this by splitting the work into two roles:
graph LR
subgraph Signer["Signer (HSM / enclave)"]
CRL[CRL / 389 DS syncrepl] --> Sign[Batch sign]
Sign --> Bundle[ahu bundle]
end
subgraph Distribution
Bundle -->|gossip / push / sneakernet| Edge1[Edge 1]
Bundle -->|gossip / push / sneakernet| Edge2[Edge 2]
Bundle -->|gossip / push / sneakernet| EdgeN[Edge N]
end
subgraph Clients
C1[OCSP client] -->|HTTP| Edge1
C2[OCSP client] -->|HTTP| Edge2
end
| Concern | Signer | Edge |
|---|---|---|
| Signing key access | Yes (HSM or file) | Never |
| Client traffic | Never | Yes |
| Network exposure | Minimal or air-gapped | Internet-facing |
| Cryptographic work at request time | N/A | Zero |
| Scaling model | Single or active-passive pair | Horizontal, stateless |
Trust boundary
The fundamental invariant:
An edge node compromise must not produce a false “good” response.
Edge nodes are keyless replay engines. They memory-map a verified ahu bundle and return pre-signed bytes verbatim. Without the signing key, a compromised edge can only:
- Serve stale responses (mitigated by anti-rollback epoch checks)
- Refuse to serve (denial of service, not a trust violation)
- Serve the wrong response for a serial (mitigated by the sealed index)
It cannot forge a “good” response for a revoked certificate.
Three operating modes
hoike runs as a single binary (hoike) in one of three modes:
Signer mode
The signer reads CA material (issuer certificate, signing key, CRLs, good serial lists), batch-produces pre-signed OCSP responses, and packages them into ahu bundles. It can optionally push bundles to edge nodes via gossip.
hoike sign \
--ca my-issuing-ca \
--issuer-cert ca.crt \
--signer-cert ocsp.crt \
--signer-key ocsp.key \
--crl ca.crl \
--good-serials serials.txt \
--sig-alg ecdsa-p256 \
--epoch 42 \
--output my-ca.ahu
The signer is the only component that touches private keys.
Edge mode
The edge serves HTTP OCSP responses from one or more loaded ahu bundles. It performs no cryptographic operations at request time – responses are returned as raw bytes from memory-mapped bundle files.
hoike serve \
--config /etc/hoike/hoike.toml \
--bundle-dir /var/lib/hoike/bundles
Combined mode
For smaller deployments, hoike can run signing and serving in a single process. The trust boundary still exists logically: signing happens on a timer (batch interval) and the edge path reads from the resulting bundle.
This mode is convenient for development and single-machine deployments but sacrifices the physical isolation that makes the signer/edge split valuable.
Tier responsibilities
Each tier manages distinct state:
| Tier | Stateful components | Persistence |
|---|---|---|
| Source (CA) | Certificate database, CRLs, revocation records | Authoritative – hoike reads but does not modify |
| Signer | Batch position, current epoch, HSM session, signing key | Durable – epoch must advance monotonically |
| Edge | Loaded working set (mmap’d bundles), epoch marks per CA | Ephemeral – reconstructible from latest bundle |
State flow
graph TD
Source["Source (CA)"] -->|CRL + serial list| Signer
Signer -->|ahu bundle| Edge
Edge -->|pre-signed bytes| Client
State flows strictly downward. The edge never writes back to the signer, and the signer never writes back to the source CA.
Workspace crate map
hoike is a Cargo workspace with six crates. The dependency graph enforces architectural boundaries:
graph TD
CLI[hoike-cli] --> Server[hoike-server]
CLI --> Sign[hoike-sign]
Server --> Core[hoike-core]
Sign --> Core
Server --> Gossip[hoike-gossip]
Core --> Ahu[ahu]
Sign --> Ahu
| Crate | Purpose | License | Key deps |
|---|---|---|---|
ahu | Bundle format read/write/verify | Apache-2.0 OR MIT | der, ciborium, memmap2, zstd |
hoike-core | CertID routing, request parsing, config, state | GPL-3.0+ | ahu, x509-ocsp, der |
hoike-sign | Response production, CRL parsing, batch signing | GPL-3.0+ | ahu, hoike-core, ml-dsa |
hoike-server | axum HTTP handlers, RFC 9919 headers, admin API with RBAC, React webui | GPL-3.0+ | hoike-core, axum, tokio |
hoike-gossip | SWIM membership + generation announcements | GPL-3.0+ | foca |
hoike-cli | Binary entry points for hoike and ahu | GPL-3.0+ | all above |
The ahu crate is dual-licensed so that other projects can consume the
bundle format without GPL obligations. It must never depend on tokio, hyper,
axum, or PKCS#11 – it is a pure data-format library.
ahu Bundle Format
An ahu bundle is a self-describing container that packages pre-signed OCSP responses for efficient, zero-copy serving. The name follows Hawaiian convention – ahu means “a heap, a pile, a collection.”
Container layout
Every ahu bundle follows a fixed layout with five regions:
+========================+
| Magic (8 bytes) | "AHU\x00" + version u32
+------------------------+
| Header (variable) | Lengths and offsets for all regions
+------------------------+
| Manifest (CBOR blob) | Structured metadata about the bundle
+------------------------+
| Seal (CMS / raw) | Cryptographic binding over manifest + index + data
+------------------------+
| Index (sorted keys) | entry_key -> (offset, length) into data region
+------------------------+
| Data (DER responses) | Raw OCSP response bytes, directly servable
+========================+
block-beta
columns 1
magic["Magic: AHU\\x00 + version (8 B)"]
header["Header: region offsets + lengths"]
manifest["Manifest: CBOR metadata"]
seal["Seal: CMS signature"]
index["Index: sorted entry_key records"]
data["Data: raw DER OCSP response bytes"]
Magic bytes
The first 8 bytes identify the file format and version:
| Offset | Length | Contents |
|---|---|---|
| 0 | 4 | AHU\x00 (ASCII + null) |
| 4 | 4 | Version number (little-endian u32, currently 1) |
Header
The header records the byte offset and length of every subsequent region. It is fixed-size for a given format version, making it possible to seek directly to any region without parsing the entire file.
Manifest (CBOR)
The manifest is a CBOR map containing structured metadata:
| Field | CBOR type | Description |
|---|---|---|
producer | text string | Identifier of the signing software (e.g., "hoike-sign/0.2.0") |
epoch | unsigned int | Monotonically increasing generation number |
scope | text string | CA label identifying which issuer this bundle covers |
algorithm | text string | Signature algorithm used for OCSP responses (e.g., "ecdsa-p256", "ml-dsa-65") |
entry_count | unsigned int | Number of entries in the index |
created_at | text string | ISO 8601 creation timestamp |
parent_hash | byte string | SHA-256 of the previous generation’s manifest (null for epoch 1) |
base_epoch | unsigned int | For delta bundles: the epoch this delta applies against |
validity_start | text string | thisUpdate for the batch (ISO 8601) |
validity_end | text string | nextUpdate for the batch (ISO 8601) |
Seal (CMS)
The seal is a CMS (RFC 5652) SignedData structure that covers the
concatenation of the manifest, index, and data regions. It binds the
entire bundle content to the signer’s identity. See Seal Trust Policy for full admission rules.
For verification, the ahu verify command checks:
- The CMS signature is valid against the embedded signer certificate
- The signer certificate chains to a trusted CA
- The signed content matches the SHA-256 digest of (manifest || index || data)
Index
The index is a sorted array of fixed-size records, one per OCSP response entry:
| Field | Size | Description |
|---|---|---|
entry_key | 32 bytes | SHA-256 of the DER-encoded CertID |
data_offset | 8 bytes | Byte offset into the data region (little-endian u64) |
data_length | 4 bytes | Length of the response in the data region (little-endian u32) |
flags | 2 bytes | Bit flags: MULTI=0x01, ALIAS=0x02, TOMBSTONE=0x04 |
discriminator | 2 bytes | Algorithm variant: 0=default (ECDSA), 2=ML-DSA-44, 3=ML-DSA-65, 4=ML-DSA-87 |
Total record size: 48 bytes.
The discriminator field enables dual-algorithm bundles. A single bundle can contain both ECDSA and ML-DSA responses for the same certificate. The index is sorted by (entry_key, discriminator), and binary_search_preferred resolves the best match for the client’s algorithm preference list.
The index is sorted by entry_key in lexicographic order, enabling
O(log n) binary search on the memory-mapped file. For a bundle with
10 million entries, a lookup requires at most 24 comparisons (ceil(log2(10^7))).
Data region
The data region contains raw DER-encoded OCSP responses packed
contiguously. Each response is a complete OCSPResponse (RFC 6960) that
can be written directly to the HTTP response body with no transformation.
This is the key to hoike’s serving performance: the edge process memory-maps the bundle, binary-searches the index for the entry key, and writes the data region slice directly to the socket. No deserialization, no re-encoding, no allocation.
Delta bundles
A delta bundle contains only the entries that changed since a base epoch.
The manifest includes a base_epoch field identifying which full bundle
the delta applies against.
Delta structure
graph LR
Full["Full bundle (epoch N)"] -->|base| Delta["Delta (epoch N+1)"]
Delta -->|apply| Full2["Full bundle (epoch N+1)"]
A delta bundle uses the same container format but with two differences:
- The manifest includes
base_epochpointing to the full bundle - The index contains only changed entries (additions, updates, removals)
Removal entries use a sentinel data_length of 0 to indicate that the
entry should be deleted when applying the delta.
Loading rules
When an edge node receives a new generation:
- If the bundle is a full bundle, replace the current working set
- If the bundle is a delta, verify that
base_epochmatches the currently loaded epoch, then merge:- Add new entries
- Replace updated entries
- Remove entries with zero-length data
- Reject any bundle with an epoch not strictly greater than the current epoch (anti-rollback)
Anti-rollback epoch chain
Each generation’s manifest contains a parent_hash – the SHA-256 of the
previous generation’s manifest bytes. This creates a hash chain:
graph LR
E1["Epoch 1<br/>parent_hash: null"] --> E2["Epoch 2<br/>parent_hash: SHA-256(M1)"]
E2 --> E3["Epoch 3<br/>parent_hash: SHA-256(M2)"]
E3 --> E4["Epoch 4<br/>parent_hash: SHA-256(M3)"]
An edge node that has verified epoch N can verify that epoch N+1 is a legitimate successor by checking:
epoch(N+1) > epoch(N)– monotonic advanceparent_hash(N+1) == SHA-256(manifest(N))– chain continuity- The seal on epoch N+1 is valid
This prevents an attacker from substituting an older bundle (rollback) or a bundle from a different signer lineage (fork).
Memory mapping and zero-copy serving
The ahu format is designed for mmap(2):
Process virtual memory
+======================+
| ahu file |
mmap'd region | +-----------------+ |
| | magic + header | | (parsed once at load)
| +-----------------+ |
| | manifest (CBOR) | | (parsed once at load)
| +-----------------+ |
| | seal | | (verified once at load)
| +-----------------+ |
| | index | | <-- binary search target
| +-----------------+ |
| | data | | <-- response bytes served directly
| +-----------------+ |
+======================+
At load time, the edge verifies the seal and parses the manifest. At request time, only the index is searched and data bytes are written – both operations touch memory pages that the OS manages via its page cache. No heap allocation is required in the hot path.
File sizes
Bundle size scales linearly with entry count and response size:
| Entries | Avg response size | Index size | Data size | Total |
|---|---|---|---|---|
| 1,000 | 500 B | 43 KB | 488 KB | ~550 KB |
| 100,000 | 500 B | 4.2 MB | 47.7 MB | ~52 MB |
| 1,000,000 | 500 B | 42 MB | 477 MB | ~520 MB |
| 10,000,000 | 500 B | 420 MB | 4.7 GB | ~5.1 GB |
For post-quantum signatures (ML-DSA-87), response sizes are roughly 10x larger. See the Post-Quantum Readiness page for detailed sizing.
Request Path
This page traces an OCSP request from HTTP arrival to response delivery. Every step on this path is designed for minimal latency: no heap allocation, no cryptographic work, no database queries. The edge serves pre-signed bytes from memory-mapped files.
Overview
flowchart TD
A[HTTP Request] --> B{Method?}
B -->|GET| C[Base64-decode + URL-decode<br/>path segment]
B -->|POST| D[Read body<br/>Content-Type: application/ocsp-request]
C --> E[Size guard]
D --> E
E --> F[DER parse<br/>strict, reject non-minimal lengths]
F --> G{Profile checks}
G -->|violation| H[malformedRequest]
G -->|pass| I[Nonce validation<br/>RFC 9654]
I --> J{Route by CertID}
J -->|no match| K[unauthorized]
J -->|match| L[Binary search<br/>mmap'd index]
L -->|hit| M[Write stored<br/>octets verbatim]
L -->|miss| N{Authoritative<br/>complete?}
N -->|yes| K
N -->|no| O[Forward or<br/>unauthorized]
M --> P[HTTP headers<br/>per RFC 9919]
K --> P
HTTP method handling
hoike accepts OCSP requests via both GET and POST, as required by RFC 6960 Section 3 and profiled by RFC 9919 Section 6.
GET requests
The OCSP request is DER-encoded, then base64-encoded, then URL-encoded in the path segment:
GET /AhwwGjAYMBYwFDASBBB...base64...= HTTP/1.1
hoike:
- Extracts the path segment after the base path
- URL-decodes the segment
- Base64-decodes (standard alphabet, with padding) to obtain the DER bytes
POST requests
The OCSP request is sent as the raw body:
POST / HTTP/1.1
Content-Type: application/ocsp-request
<DER bytes>
hoike reads the body up to the size limit. The Content-Type header must
be application/ocsp-request.
Size guard
Before parsing, hoike enforces a maximum request size (configurable,
default 4 KB). OCSP requests are small – a single-certificate request
is typically 80-120 bytes. A request exceeding the limit is rejected with
malformedRequest.
DER parsing
hoike uses strict DER parsing via the RustCrypto der crate:
- Non-minimal length encodings are rejected. DER requires that length octets use the smallest possible encoding. A length of 127 encoded in long form (0x81 0x7F instead of 0x7F) is rejected.
- Trailing bytes are rejected. The DER parser must consume the entire input.
- Tag mismatches are rejected. The parser validates every ASN.1 tag against the expected schema.
This strict parsing is a security boundary: it prevents malformed requests from reaching the routing or lookup logic.
Parsed structure
The parsed OCSPRequest yields:
OCSPRequest ::= SEQUENCE {
tbsRequest TBSRequest,
optionalSignature [0] EXPLICIT Signature OPTIONAL
}
TBSRequest ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
requestorName [1] EXPLICIT GeneralName OPTIONAL,
requestList SEQUENCE OF Request,
requestExtensions [2] EXPLICIT Extensions OPTIONAL
}
Request ::= SEQUENCE {
reqCert CertID,
singleRequestExtensions [0] EXPLICIT Extensions OPTIONAL
}
CertID ::= SEQUENCE {
hashAlgorithm AlgorithmIdentifier,
issuerNameHash OCTET STRING,
issuerKeyHash OCTET STRING,
serialNumber CertificateSerialNumber
}
Profile checks
hoike validates the request against the RFC 9919 Lightweight OCSP Profile:
| Check | Rule | Failure |
|---|---|---|
| Version | Must be v1 (default) | malformedRequest |
| Request count | Single CertID per request (RFC 9919 Section 4) | malformedRequest |
| Hash algorithm | SHA-256 preferred; SHA-1 accepted for compatibility | malformedRequest if unsupported |
| Signed request | Signature on request is ignored (RFC 9919 Section 4.1) | N/A |
Nonce validation
If the request contains a nonce extension, hoike applies the rules from RFC 9654:
- Nonce length must be between 1 and 32 octets
- Nonces shorter than 1 octet or longer than 32 octets are rejected
- The nonce is not echoed in pre-signed responses (RFC 9919 Section 5: nonces are incompatible with pre-production)
The nonce policy is configurable per CA scope. Options:
| Policy | Behavior |
|---|---|
reject | Return malformedRequest if nonce present |
ignore | Accept the request but do not echo the nonce |
warn | Log a warning and process without nonce |
CertID routing
The CertID from the parsed request is used to route to the appropriate
CA context. Routing uses the issuerKeyHash as the primary key, with
hashAlgorithm and issuerNameHash as validation:
flowchart LR
CertID --> IKH[issuerKeyHash]
IKH --> Multimap[IKH multimap]
Multimap -->|match| CaCtx[CaContext]
Multimap -->|no match| Unauth[unauthorized]
CaCtx --> Validate{issuerNameHash<br/>matches?}
Validate -->|yes| Lookup
Validate -->|no| Unauth
The issuerKeyHash multimap allows a single hoike instance to serve responses for multiple CAs. Each CA’s loaded bundle is associated with the SHA-256 (and optionally SHA-1) hash of the issuer’s Subject Public Key Info.
Index lookup
Once a CaContext is selected, hoike looks up the specific certificate in the ahu bundle’s sorted index.
Entry key computation
The entry key is the SHA-256 hash of the DER-encoded CertID:
entry_key = SHA-256(DER(CertID))
This hashing step normalizes all CertID variants (SHA-1 vs SHA-256 hash algorithm) into a uniform 32-byte key.
Binary search
The index is a sorted array of 44-byte records in the memory-mapped bundle. hoike performs a standard binary search:
Index region (mmap'd):
+--------+--------+--------+--------+--------+
| rec[0] | rec[1] | rec[2] | ... | rec[N] |
+--------+--------+--------+--------+--------+
44 B 44 B 44 B 44 B
Each record:
[entry_key: 32 B] [offset: 8 B] [length: 4 B]
For a bundle with N entries, lookup requires at most ceil(log2(N)) comparisons:
| Entries | Max comparisons |
|---|---|
| 1,000 | 10 |
| 100,000 | 17 |
| 1,000,000 | 20 |
| 10,000,000 | 24 |
Hit: verbatim byte serving
On a hit, the index record yields an offset and length into the data region. hoike writes those bytes directly to the HTTP response body:
#![allow(unused)]
fn main() {
// Conceptual hot path (no actual allocation)
let data_slice = &mmap[data_start + offset .. data_start + offset + length];
response_body.write_all(data_slice);
}
No deserialization, no re-encoding, no signing. The bytes in the data
region are a complete, valid OCSPResponse DER encoding.
Miss: unauthorized or forward
If the entry key is not found in the index:
- Authoritative-complete mode: The bundle claims to contain responses
for all certificates issued by this CA. A miss means the serial number
was never issued, so hoike returns
unauthorized. - Non-authoritative mode: The bundle may be a partial working set.
A miss can be forwarded to a fallback responder or returned as
unauthorized(configurable).
HTTP response headers
hoike sets response headers per RFC 9919 Section 6 and Section 7.2:
HTTP/1.1 200 OK
Content-Type: application/ocsp-response
Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT
Expires: Fri, 02 Jan 2026 00:00:00 GMT
ETag: "a1b2c3d4..."
Cache-Control: max-age=86400, public, no-transform, must-revalidate
Content-Length: 503
| Header | Source | RFC |
|---|---|---|
Content-Type | Always application/ocsp-response | RFC 6960 Section 3 |
Last-Modified | thisUpdate from the OCSP response | RFC 9919 Section 7.2 |
Expires | nextUpdate from the OCSP response | RFC 9919 Section 7.2 |
ETag | Hex SHA-256 of the response octets | RFC 9919 Section 7.2 |
Cache-Control | max-age derived from nextUpdate minus now | RFC 9919 Section 7.2 |
The ETag is quoted and computed over the raw response bytes. This allows
HTTP caches and CDNs to cache OCSP responses efficiently, reducing load on
hoike edge nodes.
Error responses
| Condition | OCSP response status | HTTP status |
|---|---|---|
| Unparseable request | malformedRequest (1) | 200 |
| Unknown CA | unauthorized (6) | 200 |
| Unknown serial (authoritative) | unauthorized (6) | 200 |
| Request too large | malformedRequest (1) | 200 |
| Server error | internalError (2) | 200 |
Per RFC 6960, OCSP error responses are returned with HTTP 200 and
Content-Type: application/ocsp-response. The OCSP response status byte
within the body conveys the error.
Response Production
The signer produces OCSP responses in batch, packaging them into ahu bundles. This page covers the batch model, timestamp rules, dual CertID support, status outcomes, and post-quantum sizing.
Batch model
hoike signs responses in bulk rather than on-demand. This design has several consequences:
- No signing at request time. The edge serves pre-signed bytes.
- Responses are valid for a window. Each response has a
thisUpdateandnextUpdatedefining its validity period. - Revocation propagation has latency. A revoked certificate is not reflected in OCSP responses until the next batch run.
Batch parameters
| Parameter | Default | Description |
|---|---|---|
batch_interval | 1 hour | How often the signer produces a new generation |
validity | 24 hours | The nextUpdate - thisUpdate window |
jitter | Deterministic by entry_key | Per-entry stagger within the batch window |
The jitter is deterministic: it is derived from the entry key so that the same certificate always gets the same offset within a batch window. This prevents a mass-expiry event where all responses expire simultaneously.
gantt
title Response validity windows (batch_interval = 1h, validity = 24h)
dateFormat HH:mm
axisFormat %H:%M
section Batch 1
Response A (jitter +0m) :active, 00:00, 24h
Response B (jitter +15m) :active, 00:15, 24h
Response C (jitter +42m) :active, 00:42, 24h
section Batch 2
Response A (jitter +0m) :active, 01:00, 24h
Response B (jitter +15m) :active, 01:15, 24h
Response C (jitter +42m) :active, 01:42, 24h
Epoch management
Each batch run increments the epoch. The epoch is a monotonically increasing integer that:
- Uniquely identifies a generation of the working set
- Enables anti-rollback checks at the edge
- Links to the parent generation via
parent_hash
The signer must persist the current epoch across restarts. Reusing an epoch is a fatal error.
Timestamp rules
hoike follows RFC 9919 Section 5 and RFC 6960 for timestamp formatting:
| Rule | Specification |
|---|---|
| Format | GeneralizedTime (ASN.1) |
| Timezone | UTC only (Z suffix, never +00:00) |
| Seconds | Always present (never omit seconds) |
| Fractional seconds | Never used |
| Example | 20260115120000Z |
Validity computation
thisUpdate = batch_start_time + jitter(entry_key)
nextUpdate = thisUpdate + validity_duration
producedAt = batch_start_time
The producedAt field in the ResponseData is set to the batch start
time, while thisUpdate per entry may be slightly later due to jitter.
Dual CertID support
RFC 9919 mandates SHA-256 for the CertID hash algorithm, but many
existing OCSP clients still send SHA-1 CertIDs (as specified in the
original RFC 6960). hoike supports both via the --certid-compat flag:
| Mode | Behavior |
|---|---|
sha256 | Produce only SHA-256 CertID entries |
sha1 | Produce only SHA-1 CertID entries (legacy only) |
dual | Produce one BasicOCSPResponse with two SingleResponse entries: one SHA-1, one SHA-256 |
Dual mode response structure
In dual mode, each certificate gets a single BasicOCSPResponse containing
two SingleResponse entries:
BasicOCSPResponse
ResponseData
producedAt: 20260115120000Z
responses:
SingleResponse # SHA-256 CertID
certID:
hashAlgorithm: SHA-256
issuerNameHash: <SHA-256 of issuer Name>
issuerKeyHash: <SHA-256 of issuer SPKI>
serialNumber: <serial>
certStatus: good
thisUpdate: 20260115120000Z
nextUpdate: 20260116120000Z
SingleResponse # SHA-1 CertID (compatibility)
certID:
hashAlgorithm: SHA-1
issuerNameHash: <SHA-1 of issuer Name>
issuerKeyHash: <SHA-1 of issuer SPKI>
serialNumber: <serial>
certStatus: good
thisUpdate: 20260115120000Z
nextUpdate: 20260116120000Z
Both entries share the same status, timestamps, and signature. The bundle index stores two entry keys for this response: one for each CertID hash. Regardless of whether the client sends a SHA-1 or SHA-256 CertID, the same response bytes are returned.
Status outcomes
The signer produces three types of status:
Good
The certificate is known and not revoked. The signer has the serial number in its good-serials list and it does not appear as revoked in the CRL.
certStatus: good
Revoked
The certificate appears in the CRL. The response includes the revocation time and reason:
certStatus: revoked
revocationTime: 20260110153000Z
revocationReason: keyCompromise (1)
Revocation reasons are taken directly from the CRL entry’s reasonCode
extension. If no reason is present, the reason is omitted (as per
RFC 6960).
Unauthorized
The serial number is not in the working set. This means hoike does not have a signed response for this certificate. This occurs when:
- The serial was never issued by this CA
- The serial is not in the good-serials list and not in the CRL
- The bundle does not cover this CA’s issuer key hash
OCSPResponse.responseStatus: unauthorized (6)
Dual-algorithm bundle production
hoike supports producing bundles that contain both ECDSA and ML-DSA responses for the same certificate set. This enables gradual PQC migration without a flag day.
The produce_dual_bundle function takes two signers (classical + post-quantum) and produces a single bundle where each certificate has two index entries distinguished by the discriminator field. Clients negotiate their preferred algorithm via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension.
hoike sign \
--ca my-ca --crl ca.crl \
--signing-key ecdsa.key --sig-alg ecdsa-p256 \
--dual-alg ml-dsa-87 --pq-signing-key ml-dsa.key \
-o dual.ahu
The CMS seal on a dual-algorithm bundle can use either ECDSA or ML-DSA, configured via --seal-key.
ResponderID
RFC 9919 Section 5 mandates byKey ResponderID, which identifies the
responder by the SHA-1 hash of the responder’s public key. hoike uses
this form exclusively:
ResponderID ::= CHOICE {
byKey [2] KeyHash
}
KeyHash ::= OCTET STRING -- SHA-1 of responder's SubjectPublicKeyInfo
Post-quantum response sizing
ML-DSA signatures are substantially larger than ECDSA signatures. This affects individual response size, bundle size, and network bandwidth:
Per-response size
| Algorithm | Signature size | Response size (no cert) | Response size (with delegated cert) |
|---|---|---|---|
| ECDSA P-256 | 72 B | ~500 B | ~1.2 KB |
| ECDSA P-384 | 104 B | ~530 B | ~1.3 KB |
| ML-DSA-44 | 2,420 B | ~2.8 KB | ~5.5 KB |
| ML-DSA-65 | 3,309 B | ~3.7 KB | ~7.0 KB |
| ML-DSA-87 | 4,627 B | ~5.0 KB | ~9.5 KB |
Bundle size at scale (10M certificates)
| Algorithm | With delegated cert | CA-direct (no cert) |
|---|---|---|
| ECDSA P-256 | ~11.4 GB | ~4.8 GB |
| ML-DSA-44 | ~52.4 GB | ~26.7 GB |
| ML-DSA-65 | ~66.7 GB | ~35.2 GB |
| ML-DSA-87 | ~90.6 GB | ~47.7 GB |
| ML-DSA-87 | ~160 GB (worst case with full cert chain) | ~47.7 GB |
Three levers for PQ size reduction
-
CA-direct signing. The responder certificate in
certswithin theBasicOCSPResponseis the largest single contributor to response size. If the CA signs OCSP responses directly (using its own key, not a delegated responder), the responder certificate can be omitted entirely. This reduces response size by roughly 2/3 for PQ algorithms. -
Batching. The signature is amortized across all entries in a batch. This does not reduce per-response size but reduces the signing workload and allows the signer to operate within HSM throughput constraints.
-
Delta distribution. Instead of distributing a full bundle every batch interval, distribute only the changes. For a stable certificate population, delta bundles are orders of magnitude smaller than full bundles. See ahu Bundle Format.
For a detailed analysis, see Post-Quantum Readiness.
Dual-Algorithm Bundles
hoike supports bundles that contain both ECDSA and ML-DSA responses for the same certificate set, enabling gradual post-quantum migration without a flag day.
How it works
A dual-algorithm bundle contains two index entries for each certificate — one pointing to an ECDSA-signed response and one pointing to an ML-DSA-signed response. The entries share the same entry_key (SHA-256 of the CertID) but differ in the discriminator field:
| Discriminator | Algorithm |
|---|---|
0 | ECDSA P-256 (default) |
2 | ML-DSA-44 |
3 | ML-DSA-65 |
4 | ML-DSA-87 |
The index is sorted by (entry_key, discriminator). The lookup function binary_search_preferred takes the client’s algorithm preference list and returns the best available match.
Producing dual bundles
hoike sign \
--ca enterprise-ca \
--crl ca.crl \
--signing-key ecdsa.key \
--sig-alg ecdsa-p256 \
--dual-alg ml-dsa-87 \
--pq-signing-key ml-dsa.key \
-o dual.ahu
This produces a single bundle where each certificate has both an ECDSA and an ML-DSA response.
Client negotiation
Clients indicate their preference via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension in the OCSP request. hoike parses this extension and resolves the best match.
# Query with PQ preference — returns ML-DSA-87 response
hoike query --url http://localhost:2560 \
--serial 0A1B2C \
--issuer-name-b64 "..." \
--issuer-key-b64 "..." \
--prefer ml-dsa-87
# Query without preference — returns ECDSA response (default)
hoike query --url http://localhost:2560 \
--serial 0A1B2C \
--issuer-name-b64 "..." \
--issuer-key-b64 "..."
Migration strategy
-
Phase 1: Generate dual-algorithm bundles alongside your existing ECDSA-only bundles. Clients that don’t send
PreferredSignatureAlgorithmscontinue to receive ECDSA responses. -
Phase 2: Update PQ-capable clients to send
--prefer ml-dsa-87(or the appropriate level). They start receiving ML-DSA responses while classical clients are unaffected. -
Phase 3: When all clients support ML-DSA, switch to ML-DSA-only bundles (
--sig-alg ml-dsa-87without--dual-alg).
No flag day. One responder, one bundle, both algorithms.
Size implications
Dual-algorithm bundles are roughly 2x the size of single-algorithm bundles, since each certificate has two responses. The ML-DSA responses are significantly larger than ECDSA:
| Configuration | Per-cert size | Bundle (1M certs) |
|---|---|---|
| ECDSA only | ~500 B | ~520 MB |
| ML-DSA-87 only | ~5 KB | ~5.1 GB |
| Dual (ECDSA + ML-DSA-87) | ~5.5 KB | ~5.6 GB |
Delta distribution mitigates the size increase for steady-state operation.
RFC Support Reference
hoike implements or profiles the following IETF standards. This page lists every requirement with its implementation status and the relevant conformance checks.
Standards matrix
| RFC | Title | Role in hoike | Status |
|---|---|---|---|
| RFC 6960 | Online Certificate Status Protocol (OCSP) | Base protocol: request/response format, all status values, extensions | Fully implemented |
| RFC 9919 | Lightweight OCSP Profile for High Volume Environments | Primary operating profile: pre-production, unauthorized semantics, byKey ResponderID, SHA-256 CertID, HTTP caching | Fully implemented |
| RFC 9654 | OCSP Nonce Extension | Nonce length validation, rejection rules | Fully implemented |
| RFC 5280 | Internet X.509 PKI Certificate and CRL Profile | AIA id-ad-ocsp, responder certificate profile, id-pkix-ocsp-nocheck | Referenced for certificate validation |
| RFC 5652 | Cryptographic Message Syntax (CMS) | CMS SignedData seal on ahu bundles (ECDSA P-256 and ML-DSA) | Fully implemented |
| RFC 4533 | LDAP Content Synchronization Operation | 389 DS syncrepl source for Dogtag certificate repositories | Fully implemented |
RFC 6960 – OCSP base protocol
Request handling
| Requirement | Section | Implementation |
|---|---|---|
| Accept GET and POST methods | 3.1 | Both methods handled by axum router |
| DER-encoded request body for POST | 3.1 | Body read and passed to strict DER parser |
| Base64+URL-encoded request for GET | A.1 | Path segment decoded (URL-decode then base64-decode) |
Parse OCSPRequest structure | 4.1.1 | x509-ocsp crate with strict DER parsing |
Support CertID with OID-identified hash | 4.1.1 | SHA-256 (primary) and SHA-1 (compatibility) |
| Ignore signed requests in pre-signed mode | 4.1.1 | Signature field parsed but not validated |
Response production
| Requirement | Section | Implementation |
|---|---|---|
Produce OCSPResponse with responseStatus | 4.2.1 | All six status values supported |
good – certificate is not revoked | 4.2.1 | Generated for serials in good-serials list |
revoked – certificate is revoked | 4.2.1 | Generated from CRL entries with reason and time |
unauthorized – responder has no information | 4.2.1 | Returned for unknown serials or CAs |
malformedRequest – request is invalid | 4.2.1 | Returned for parse failures, profile violations |
internalError – server fault | 4.2.1 | Returned for unexpected processing errors |
BasicOCSPResponse with ResponseData | 4.2.1 | Signed during batch production |
producedAt timestamp | 4.2.1 | Set to batch start time |
thisUpdate and nextUpdate per SingleResponse | 4.2.1 | Computed from batch window + jitter |
ResponderID identification | 4.2.3 | byKey form only (per RFC 9919) |
Extensions
| Extension | OID | Implementation |
|---|---|---|
| Nonce | 1.3.6.1.5.5.7.48.1.2 | Validated per RFC 9654. Pre-signed: omitted. live mode: echoed in response. forward: proxied upstream. |
id-pkix-ocsp-nocheck | 1.3.6.1.5.5.7.48.1.5 | Included in responder certificate profile |
RFC 9919 – Lightweight OCSP Profile
This is hoike’s primary operating profile. All requirements are mandatory unless noted.
| Requirement | Section | Implementation |
|---|---|---|
| Pre-produced responses | 4 | Core design – batch-signed into ahu bundles. Signers also support on-demand live nonce signing. |
| Single CertID per request | 4 | First CertID answered; remaining silently dropped per profile recommendation |
SHA-256 CertID hash algorithm | 4 | Default; SHA-1 accepted for compatibility |
byKey ResponderID (SHA-1 hash of responder public key) | 5 | Only form used |
unauthorized for unknown serials | 5 | Returned when serial not in working set |
| No nonce echoing | 5 | Nonces validated but never echoed |
Content-Type: application/ocsp-response | 6 | Set on all responses |
HTTP Cache-Control header | 7.2 | max-age, public, no-transform, must-revalidate |
HTTP Last-Modified header | 7.2 | Set to thisUpdate |
HTTP Expires header | 7.2 | Set to nextUpdate |
HTTP ETag header | 7.2 | Hex SHA-256 of response octets |
| HTTP 200 for all OCSP responses (including errors) | 6 | All OCSP responses returned with HTTP 200 |
RFC 9654 – OCSP Nonce Extension
| Requirement | Section | Implementation |
|---|---|---|
| Nonce minimum length: 1 octet | 4 | Validated; shorter nonces rejected |
| Nonce maximum length: 32 octets | 4 | Validated; longer nonces rejected |
| Nonce rejection produces error response | 4 | Returns malformedRequest when policy is reject |
RFC 5280 – Certificate and CRL Profile
| Requirement | Section | Implementation |
|---|---|---|
Authority Information Access (AIA) id-ad-ocsp | 4.2.2.1 | Used by clients to discover hoike endpoints |
| CRL parsing for revocation status | 5 | CRL entries consumed by signer for revoked status |
id-pkix-ocsp-nocheck in responder cert | 4.2.2.1 | Delegated responder certificates include this extension |
Conformance test suite
The conformance suite in crates/hoike-server/tests/conformance.rs
exercises 20 checks covering the RFC requirements above. Each check
validates a specific protocol behavior:
| # | Check | Validates |
|---|---|---|
| 1 | GET request with valid base64-encoded CertID | RFC 6960 Section 3 / A.1 |
| 2 | POST request with valid DER body | RFC 6960 Section 3 |
| 3 | POST with wrong Content-Type rejected | RFC 6960 Section 3 |
| 4 | Oversized request rejected as malformedRequest | Size guard |
| 5 | Non-minimal DER length encoding rejected | Strict DER parsing |
| 6 | Trailing bytes after request rejected | Strict DER parsing |
| 7 | Multi-CertID request rejected | RFC 9919 Section 4 |
| 8 | SHA-256 CertID returns valid response | RFC 9919 Section 4 |
| 9 | SHA-1 CertID returns valid response (compat) | Backward compatibility |
| 10 | Good status for known, non-revoked serial | RFC 6960 Section 4.2.1 |
| 11 | Revoked status includes reason and time | RFC 6960 Section 4.2.1 |
| 12 | Unknown CA returns unauthorized | RFC 9919 Section 5 |
| 13 | Unknown serial returns unauthorized (authoritative) | RFC 9919 Section 5 |
| 14 | byKey ResponderID used | RFC 9919 Section 5 |
| 15 | Nonce in request not echoed in response | RFC 9919 Section 5 |
| 16 | Overlong nonce rejected | RFC 9654 Section 4 |
| 17 | Content-Type header correct | RFC 9919 Section 6 |
| 18 | Cache-Control header present and correct | RFC 9919 Section 7.2 |
| 19 | ETag header is hex SHA-256 of response | RFC 9919 Section 7.2 |
| 20 | Last-Modified and Expires headers present | RFC 9919 Section 7.2 |
Run the conformance suite:
cargo test -p hoike-server --test conformance
Non-goals
hoike intentionally does not implement:
- OCSP stapling (RFC 6066 Section 8): This is a TLS-server responsibility, not a responder behavior. hoike produces responses that can be stapled by a TLS server.
- Signed OCSP requests: The request signature field is parsed but never validated. RFC 9919 Section 4.1 explicitly states that signed requests are not required in the lightweight profile.
- OCSP response signing on demand (edge mode): Edge nodes serve only
pre-signed responses. On-demand signing is available via
nonce_policy = "live"on signer/combined nodes — see Nonce Policies.
Post-Quantum Readiness
hoike treats post-quantum cryptography as a first-class configuration, not an experimental add-on. ML-DSA (Module-Lattice-Based Digital Signature Algorithm, FIPS 204) is supported at all three security levels alongside traditional ECDSA.
Supported algorithms
| Algorithm | Standard | Security level | Signature size | Public key size |
|---|---|---|---|---|
| ECDSA P-256 | FIPS 186-5 | ~128-bit classical | 72 B | 65 B |
| ECDSA P-384 | FIPS 186-5 | ~192-bit classical | 104 B | 97 B |
| ML-DSA-44 | FIPS 204 | NIST Level 2 (~128-bit PQ) | 2,420 B | 1,312 B |
| ML-DSA-65 | FIPS 204 | NIST Level 3 (~192-bit PQ) | 3,309 B | 1,952 B |
| ML-DSA-87 | FIPS 204 | NIST Level 5 (~256-bit PQ) | 4,627 B | 2,592 B |
hoike uses the ml-dsa crate from RustCrypto, which implements FIPS 204.
Response size impact
Post-quantum signatures are 30-65x larger than ECDSA signatures. This affects individual response size, bundle size, network bandwidth, and storage requirements.
Per-response size comparison
The response size depends on whether a delegated responder certificate
is included in the certs field of the BasicOCSPResponse.
| Algorithm | Signature | Response (CA-direct, no cert) | Response (delegated, with cert) |
|---|---|---|---|
| ECDSA P-256 | 72 B | ~500 B | ~1.2 KB |
| ECDSA P-384 | 104 B | ~530 B | ~1.3 KB |
| ML-DSA-44 | 2,420 B | ~2.8 KB | ~5.5 KB |
| ML-DSA-65 | 3,309 B | ~3.7 KB | ~7.0 KB |
| ML-DSA-87 | 4,627 B | ~5.0 KB | ~9.5 KB |
The delegated certificate adds roughly one public key plus certificate overhead. For ML-DSA-87, the certificate alone adds ~4 KB.
Storage at scale
Bundle sizes for varying certificate populations with ML-DSA-87 (worst case):
| Certificates | CA-direct | With delegated cert |
|---|---|---|
| 10,000 | ~48 MB | ~91 MB |
| 100,000 | ~477 MB | ~906 MB |
| 1,000,000 | ~4.8 GB | ~9.1 GB |
| 10,000,000 | ~47.7 GB | ~90.6 GB |
| 10,000,000 (full chain) | ~47.7 GB | ~160 GB |
The ~160 GB figure includes a full certificate chain (responder cert + issuer cert) in every response, which is the worst case for ML-DSA-87.
Three mitigations
1. CA-direct signing
The single most effective size reduction. When the CA signs OCSP responses
directly using its own key (rather than delegating to a separate OCSP
responder key), the responder certificate can be omitted from the
BasicOCSPResponse.certs field.
Size reduction: roughly 2/3 for ML-DSA algorithms.
Trade-off: The CA signing key must be available to the signer process. This may conflict with key management policies that restrict CA key usage to certificate issuance. However, for organizations with HSM-attached CA keys, this is often viable.
Configuration:
hoike sign \
--ca my-ca \
--issuer-cert ca.crt \
--signer-cert ca.crt \ # Same as issuer
--signer-key ca.key \ # CA's own key
--sig-alg ml-dsa-65 \
--ca-direct \ # Omit responder cert from responses
...
2. Batching
Batch signing amortizes the computational cost of ML-DSA signatures. While this does not reduce per-response size, it is critical for operating within HSM throughput constraints.
ML-DSA signing performance (approximate, software):
| Algorithm | Signs/sec (software) | Time per 10M batch |
|---|---|---|
| ECDSA P-256 | ~50,000 | ~3.3 minutes |
| ML-DSA-44 | ~10,000 | ~16.7 minutes |
| ML-DSA-65 | ~6,000 | ~27.8 minutes |
| ML-DSA-87 | ~3,000 | ~55.6 minutes |
The batch model is inherent to hoike’s architecture. The batch_interval
should be set to accommodate the signing time for the full certificate
population.
3. Delta distribution
For a stable certificate population, most entries do not change between generations. Delta bundles contain only the additions, modifications, and removals since the base epoch.
Example: A 10M-certificate deployment with 0.1% daily churn (10,000 changes):
| Distribution | ML-DSA-87 (CA-direct) | ML-DSA-87 (delegated) |
|---|---|---|
| Full bundle | ~47.7 GB | ~90.6 GB |
| Delta (0.1% churn) | ~48 MB | ~91 MB |
Delta distribution reduces bandwidth by 1000x for stable populations. See ahu Bundle Format – Delta Bundles for the delta format specification.
FIPS 204 compliance notes
hoike’s ML-DSA implementation targets FIPS 204 compliance:
| Requirement | Status |
|---|---|
| FIPS 204 parameter sets (ML-DSA-44, 65, 87) | Implemented via ml-dsa crate |
| Deterministic signing (hedged, per FIPS 204) | Default mode |
| Key generation per FIPS 204 Section 5 | Delegated to ml-dsa crate |
| Signature verification per FIPS 204 Section 6 | Implemented in ahu verify path |
FIPS 140-3 validation: The ml-dsa crate is not currently FIPS 140-3
validated. For deployments requiring FIPS 140-3 validated cryptography,
use an HSM with ML-DSA support via PKCS#11. hoike’s signer supports
PKCS#11 backends for key operations.
End-to-end PQC testing
The cert-revocation-lab includes an ML-DSA-87 PKI hierarchy (Dogtag PKI + Kryoptic PKCS#11 HSM) with a hoike deployment that signs OCSP responses with ML-DSA-87. This demonstrates the full PQC chain: Dogtag issues ML-DSA certs → hoike signs ML-DSA OCSP responses → NSS-based clients (Firefox, certmonger) validate them.
CIQ achieved CAVP certification for ML-DSA in NSS 3.112 (February 2026), with FIPS 140-3 validation targeted for Q2 2027.
Algorithm selection guidance
| Scenario | Recommended | Rationale |
|---|---|---|
| Current production, no PQ requirement | ECDSA P-256 | Smallest responses, widest compatibility |
| CNSA 2.0 compliance | ML-DSA-65 or ML-DSA-87 | NSA CNSA 2.0 requires NIST Level 3+ |
| Hybrid transition | ECDSA P-256 + ML-DSA-65 (dual-algorithm) | Supported via --dual-alg — one bundle, both algorithms |
| PQ-only, size-constrained | ML-DSA-44 with CA-direct | Smallest PQ option |
| Maximum security | ML-DSA-87 with CA-direct + deltas | Full PQ security with size mitigation |
Dual-algorithm bundles
hoike supports dual-algorithm bundles that contain both ECDSA and ML-DSA responses for the same certificate set. The client selects the preferred algorithm via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension.
hoike sign \
--ca my-ca \
--crl ca.crl \
--signing-key ecdsa.key \
--sig-alg ecdsa-p256 \
--dual-alg ml-dsa-87 \
--pq-signing-key ml-dsa.key \
-o dual.ahu
# Query with PQ preference
hoike query --url http://localhost:2560 --serial 0A1B2C \
--issuer-name-b64 ... --issuer-key-b64 ... --prefer ml-dsa-87
The bundle index uses a discriminator field (bytes 46-47 of each 48-byte index record) to distinguish algorithm variants. Binary search with binary_search_preferred resolves the best match for the client’s preference list.
PKCS#11 ML-DSA
hoike supports ML-DSA signing via PKCS#11 HSMs using the CKM_ML_DSA mechanism (full-message, pure variant). The signer validates mechanism support at startup via get_mechanism_list.
[ca.signing_key]
type = "pkcs11"
module = "/usr/lib/libkryoptic_pkcs11.so"
token_label = "hoike-ocsp"
key_label = "pq-signing"
pin_env = "HOIKE_HSM_PIN"
Build with: cargo build --release --features pkcs11
ML-DSA CMS seals
Bundle seals support both ECDSA and ML-DSA seal keys. The seal key type is auto-detected from the PKCS#8 file:
hoike sign --ca my-ca --crl ca.crl \
--signing-key ecdsa.key \
--seal-key ml-dsa-seal.key \
-o sealed.ahu
Seal verification dispatches on the SignerInfo algorithm OID. ML-DSA signs raw attribute DER (full-message mode); ECDSA uses prehash.
Test coverage
ML-DSA bundle tests are in crates/hoike-sign/tests/:
cargo test -p hoike-sign -- ml_dsa
These tests cover:
- ML-DSA-44/65/87 key generation and signing
- Response production with ML-DSA signatures
- Bundle creation with ML-DSA-signed responses
- Verification of ML-DSA-signed bundles
- Round-trip: sign, bundle, load, verify, serve
CMS Seals
Each ahu bundle is cryptographically sealed with a CMS SignedData structure (RFC 5652) that binds the manifest, index, and data regions together.
Purpose
The seal provides:
- Integrity: Any modification to the bundle contents invalidates the seal
- Authenticity: The seal identifies the signer via the embedded certificate
- Trust anchoring: When
seal_trust_anchorsis configured, only bundles sealed by trusted signers are loaded
Seal structure
The CMS SignedData covers the SHA-256 digest of the manifest bytes. The SignerInfo contains:
- A
MessageDigestsigned attribute with the manifest hash - The signature (ECDSA P-256 or ML-DSA)
- The signer’s certificate embedded in the
certificatesfield
Supported seal algorithms
| Algorithm | Key type | Signing mode |
|---|---|---|
| ECDSA P-256 | PKCS#8 PEM/DER | Prehash (SHA-256 then sign) |
| ML-DSA-44 | PKCS#8 PEM/DER | Full-message (sign raw attrs DER) |
| ML-DSA-65 | PKCS#8 PEM/DER | Full-message |
| ML-DSA-87 | PKCS#8 PEM/DER | Full-message |
The seal key type is auto-detected from the PKCS#8 file’s algorithm OID.
Configuration
The seal key must be separate from the OCSP signing key (different key lifetimes).
[[ca]]
label = "enterprise-ca"
seal_key = "/etc/hoike/seal-key.p8"
seal_cert = "/etc/hoike/seal-cert.pem"
To require seal verification on bundle load:
[storage]
seal_trust_anchors = ["/etc/hoike/seal-ca.pem"]
When seal_trust_anchors is set, bundles without a valid CMS seal are rejected. When omitted, seal verification is skipped with a warning.
Verification
# Verify seal integrity
ahu verify bundle.ahu
# Verify seal + individual entry signatures
ahu verify bundle.ahu --entries
The ahu verify command checks:
- CMS signature is valid against the embedded signer certificate
- The signed
MessageDigestattribute matches the manifest hash - Index and data digests match the manifest’s integrity fields
- Index entries are in sorted order
Current limitations
- Self-referential verification only. The seal is verified against the certificate embedded in the CMS structure. Full PKIX path building against
seal_trust_anchorsis not yet implemented — the trust anchor check verifies the seal signature but does not build a complete chain.
FIPS 140-3 Status
hoike 0.2.0 does not run inside a validated cryptographic module. Products are not themselves FIPS validated — cryptographic modules are — so the accurate claim for any release of hoike is whether it uses validated modules for every approved operation. Today it does not, except where signing is delegated to a validated HSM. This page states exactly where each cryptographic operation executes today, what the target architecture is, and what a deployment can and cannot claim in the meantime.
Current status
| Operation | Implementation | Inside a validated module? |
|---|---|---|
| OCSP response signing, ECDSA P-256 | RustCrypto p256 / ecdsa | No |
| OCSP response signing, ML-DSA | RustCrypto ml-dsa | No |
| Signing through an HSM | PKCS#11 via cryptoki (--features pkcs11) | Yes, when the HSM is validated and the algorithm is in its validated boundary |
| SHA-256 (CertID, bundle index, seal) | RustCrypto sha2 | No |
| ahu CMS seal | RustCrypto cms + p256 / ml-dsa | No |
| CRL signature verification | RustCrypto p256, ml-dsa; aws-lc-rs for RSA (non-FIPS build) | No |
| Gossip broadcast signatures | ed25519-dalek | No |
| Admin password hashing | bcrypt | No — bcrypt is not an approved algorithm |
| Session tokens | rand | No |
| TLS on management listeners and outbound channels | rustls with aws-lc-rs provider (non-FIPS build) | No |
Enabling aws-lc-rs’s FIPS feature would move only the TLS and RSA-verification rows. It is not the fix.
What a deployment can claim today
- Signing keys held in a validated HSM, signatures produced by the HSM. With
signing_key.type = "pkcs11"the OCSP signature itself is generated inside the HSM’s validated boundary. The digest over the response (SHA-256) is still computed in software before the mechanism call, so this is a partial claim; state it that way. - No claim for software keys, the seal, gossip, admin authentication, or TLS.
Target architecture
hoike is intended to ship alongside Red Hat Certificate System on Red Hat Enterprise Linux, and Red Hat products obtain FIPS 140-3 coverage by consuming the operating system’s validated module — the OpenSSL FIPS provider — rather than by validating their own. The planned change introduces a single crypto abstraction and backs it with:
- OpenSSL on the host, dynamically linked, in FIPS mode when the host is: hashing, software ECDSA, PBKDF2 password hashing (replacing bcrypt), the DRBG for tokens and jitter, and TLS through OpenSSL-backed
axum-server,reqwest, andldap3. TLS parameters then follow the host’s system-wide crypto policy. - PKCS#11 into an HSM for all production signing keys, as today.
- Red Hat Universal Base Image for the container, so the image inherits FIPS mode, crypto policies, and errata from the host and Red Hat’s build pipeline.
Gossip signatures move to ECDSA P-256 through the same abstraction. Ed25519 remains available where the module supports it.
ML-DSA and FIPS
ML-DSA (FIPS 204) OCSP signing is supported in two configurations:
- For deployments that require validated cryptography, ML-DSA keys must reside in a FIPS 140-3 validated HSM whose certificate lists
CKM_ML_DSAin the approved mode; hoike accesses the key through PKCS#11 and the signature is produced inside the HSM’s validated boundary. - Software ML-DSA (via the RustCrypto implementation today, via OpenSSL 3.5 on Red Hat Enterprise Linux 10.1 after the crypto-boundary change) is provided for interoperability and testing. It is not within any validated cryptographic module boundary, and the Red Hat Enterprise Linux system OpenSSL FIPS provider does not implement ML-DSA.
These statements are deliberate. hoike does not describe the software path as “FIPS approved” or as running in “FIPS mode”, and it does not claim that a validation is pending. When the validated provider on Red Hat Enterprise Linux gains FIPS 204 algorithms, this page will be updated to say so with the certificate reference.
Timeline and tracking
The crypto-abstraction work is the second phase of the security roadmap and precedes any Common Criteria evaluation, because the evaluated crypto boundary must not change mid-evaluation. Progress is tracked in the repository’s docs/compliance/ directory; this page will be updated when a release ships that runs entirely on the host module, and a hoike check --fips preflight will report the module name, version, and mode at runtime.
Related pages
NIAP Common Criteria Status
hoike has not been evaluated under the Common Criteria and holds no NIAP certificate. The project maintains draft Security Targets against three NIAP documents so that the gap between the code and a certifiable posture is explicit and tracked. This page summarizes that status for deployers; the engineering detail lives in the repository under docs/compliance/.
How hoike fits the profiles
| NIAP document | hoike’s role | Status |
|---|---|---|
| Protection Profile for Certification Authorities (PP 420) | The OCSP component of a composite Target of Evaluation whose issuing CA is Red Hat Certificate System. hoike is not a CA and cannot be evaluated against this profile alone. | Architecture aligned; crypto boundary and audit attribution open |
| Protection Profile for Application Software | The hoike binaries as an application on a Red Hat Enterprise Linux platform | Six functional requirements open |
| Functional Package for TLS | The admin, metrics, forward-proxy, and LDAPS channels | Server side largely aligned; ciphersuite selection and client-side activities open |
The draft Security Targets were written against PP for Certification Authorities v2.1, PP for Application Software v1.4, and TLS Functional Package v1.1. NIAP has since published newer TLS package versions; the STs will be rebased on whatever versions NIAP accepts when an evaluation is scheduled.
What is aligned today
- Proof of origin. Every OCSP response is signed; the delegated responder certificate is embedded per RFC 9919 so relying parties can validate without pre-caching.
- Status freshness and anti-replay. Response windows are bounded by the source; epochs are monotonic with persisted high-water marks; forks are detected.
- Keyless serving tier. Edge nodes cannot produce a false
goodby construction. - Trusted path for administration. Dedicated TLS listener with optional mutual TLS; TLS 1.2 and 1.3 only.
- Trusted channels between components. HTTPS-only nonce forwarding without redirects; LDAPS or StartTLS-before-bind for directory synchronization; Ed25519-signed gossip broadcasts with named-origin authorization.
- Roles and management functions. Administrator, Operator, and Viewer roles enforced per route.
- Third-party library inventory. Locked dependency set,
cargo auditin CI, zero known advisories at the 0.2.0 lockfile. - No default credentials, no PII collection, bounded inputs.
What is open
| Requirement area | Gap | Planned resolution |
|---|---|---|
| Cryptographic support (FCS_COP, FCS_CKM, FCS_RBG) | Operations run in unvalidated Rust crates | Host OpenSSL FIPS provider plus HSM; see FIPS 140-3 Status |
| Key protection (FPT_SKP, FCS_STO) | No zeroization; secrets may be inline in TOML | zeroize; file or environment indirection only |
| Audit (FAU_GEN.2) | Events do not carry the operator identity; logins not audited | Next release |
| Identification and authentication (FIA) | No lockout, no complexity policy, no re-authentication for privileged actions | Next release |
| Trusted update (FPT_TUD) | Releases and images are checksummed, not signed | Signed releases with published verification steps |
| Version identification (FPT_IDV) | hoike --version not implemented | Next release |
| Self-test (FPT_TST) | No cryptographic self-test at startup | Inherited from the host module’s power-on self-test plus a hoike check --fips known-answer run |
| TLS ciphersuite selection | Provider defaults, not an explicit approved list | Explicit allow-list under the host crypto policy; hoike check --tls conformance test |
| Certificate validation for TLS peers (FIA_X509) | No revocation checking for forward-proxy or LDAPS peers | Reuse the signer’s CRL configuration |
Environmental objectives
A Security Target will place these on the operational environment, and deployers should be ready to evidence them:
- Reliable time (chrony) on every node.
- Disk encryption or equivalent for the state directory and bundle storage.
- Audit forwarding and retention (journald or a SIEM) with integrity protection.
- A validated HSM holding every production signing key.
- Host operating system in FIPS mode with the
FIPScrypto policy.
Related pages
DISA STIG Guidance
hoike is designed to be deployed on a host that is itself STIG-compliant — Red Hat Enterprise Linux under the Red Hat Enterprise Linux STIG, or OpenShift under the Container Platform SRG. Most controls are therefore inherited. This page maps the Application Security and Development STIG requirement families to what hoike provides, what is configuration, and what is still open, so that a checklist can be completed without re-deriving the answers. Exact rule IDs should be taken from the current benchmark release.
Inherited from the platform
| Requirement family | Provided by |
|---|---|
| Operating system FIPS mode and crypto policy | Host kernel and crypto-policies; hoike does not override them |
| Audit storage, protection, retention, and forwarding | journald or the platform collector; see Audit Logging |
| Time synchronization | chrony |
| Account management for the service user | Host or platform identity |
| Network segmentation and firewalling | Host firewall, OpenShift NetworkPolicy |
| Disk encryption | LUKS or the platform’s storage encryption |
| Malware and vulnerability scanning of the host | Platform tooling |
Met by hoike as shipped
| Requirement family | How |
|---|---|
| Input validation and bounded requests | OCSP body limit (max_request, 8,192 bytes default); admin login body 4,096 bytes with a 5-second read timeout; checked DER parsing; no unbounded upstream reads |
| Error messages do not reveal internals | Static OCSP error responses; short admin error strings; no stack traces |
| No default or shared accounts | Operators must be configured explicitly |
| Least privilege | Container runs as a non-root user; no capabilities required |
| Separation of management and data interfaces | admin_listen and metrics_listen are distinct from the OCSP listener |
| Session termination on logout | DELETE /session invalidates the token |
| Role-based access | Administrator, Operator, Viewer enforced on every admin route |
| Certificate expiry notification | Key-rotation monitor warns before responder_cert expiry |
| Third-party component tracking | Locked dependency set with cargo audit in CI |
Met by configuration
| Requirement family | Setting |
|---|---|
| Management traffic encrypted | server.admin_tls, server.metrics_tls; build with --features tls |
| Mutual authentication of administrative connections | server.admin_tls.client_ca |
| Use of an approved PKI for management TLS | Point client_ca and the server certificate at the organization’s or DoD’s issuing CA |
| Credentials not stored in clear text | pin_env and bind_password_env instead of pin and bind_password; password hashes only in operators |
| Encrypted channels to backing services | forward_to must be https://; directory tls = "ldaps" or "starttls" |
| Session lifetime | session_ttl_secs (set 900 or less for privileged nodes) |
| Non-production features disabled | Do not use --demo-key, signing_key.type = "demo", or forward_insecure |
Open in this release
| Requirement family | Status | Compensating control until resolved |
|---|---|---|
| FIPS-validated cryptographic module for all security functions | Open — see FIPS 140-3 Status | HSM-held signing keys; host FIPS mode for everything else on the box |
| Account lockout after consecutive failed logins | Open (process-wide rate limit only) | Mutual TLS on the admin listener; management-network-only access |
| Password complexity, minimum length, history, maximum age | Open (hashes are operator-supplied) | Enforce organizational policy when generating hashes; rotate on a schedule |
| DoD Notice and Consent Banner | Open | Present the banner on the management ingress or bastion in front of the admin listener |
| Session inactivity timeout | Open (fixed lifetime only) | Short session_ttl_secs |
| Concurrent session limit per user | Open | Procedural |
| Re-authentication for privileged functions | Open | Restrict administrator role to a minimum set of accounts; mTLS |
| HTTP security headers on the web UI | Open | Serve the UI only over the mTLS admin listener; or omit server.webui |
| Audit of login success/failure and operator identity on privileged actions | Open | Correlate admin-listener access logs at the ingress with hoike audit events |
| Signed release artifacts and images | Open (SHA-256 checksums only) | Build from source in the organization’s pipeline and sign there |
| Application reports its version | Open | Record the deployed image digest in the CMDB |
All items in this table are scheduled; see the roadmap in the repository’s docs/compliance/ directory.
Evidence to collect for a checklist
hoike check --config /etc/hoike/hoike.tomloutput with no unexplained warnings- The effective configuration from
GET /api/admin/config(secrets are redacted) cargo auditreport for the deployed lockfile- Container image digest and base image (Red Hat Universal Base Image once the rebase lands)
- Host STIG scan results (OpenSCAP) for the node
- Audit forwarding configuration and a sample
rollbackorforkevent reaching the SIEM
Related pages
hoike CLI Reference
The hoike binary is the main entry point for the OCSP responder. It
provides five subcommands: serve, check, sign, import, and query.
Global options
hoike [OPTIONS] <COMMAND>
| Option | Description |
|---|---|
--version | Print version information |
--help | Print help |
hoike serve
Start the OCSP responder server.
hoike serve --config <PATH>
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--config <PATH> | Yes | – | Path to the hoike.toml configuration file |
Description
Starts the axum-based HTTP server that serves pre-signed OCSP responses
from loaded ahu bundles. The server operates in the mode specified in the
configuration file (edge, signer, or combined).
In edge mode, the server memory-maps bundle files and serves responses at memory-read speed with no cryptographic work at request time.
If gossip is enabled in the configuration, the server also starts a SWIM protocol listener for fleet coordination and bundle distribution.
Example
hoike serve --config /etc/hoike/hoike.toml
hoike check
Validate configuration, bundles, and connectivity without starting the server.
hoike check --config <PATH>
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--config <PATH> | Yes | – | Path to the hoike.toml configuration file |
Description
Performs a comprehensive pre-flight check:
- Config parsing – validates the TOML configuration syntax and structure
- Bundle verification – for each configured
[[ca]]entry, verifies the referenced bundle file exists and passes seal verification - Storage access – confirms
bundle_dirandstate_dbdirectories exist and are writable - Gossip connectivity – if gossip is enabled, attempts to resolve and connect to seed nodes
Reports issues with clear error messages. Exit code 0 on success, non-zero on failure.
Example
hoike check --config /etc/hoike/hoike.toml
hoike sign
Produce a signed ahu bundle from a CRL and optional good-serials list.
hoike sign --ca <LABEL> --crl <FILE> [OPTIONS]
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--ca <LABEL> | Yes | – | CA scope label for the bundle |
--crl <FILE> | Yes | – | Path to the CRL file (PEM or DER) |
--signing-key <FILE> | * | – | PKCS#8 PEM/DER signing key. Mutually exclusive with --demo-key. |
--demo-key | * | false | Use an ephemeral key (testing only). Mutually exclusive with --signing-key. |
-o <FILE> | No | output.ahu | Output bundle file path |
--sig-alg <ALG> | No | ecdsa-p256 | Signature algorithm (see below) |
--certid-compat <MODE> | No | dual | CertID hash compatibility mode (see below) |
--epoch <N> | No | 1 | Epoch number for anti-rollback |
--good-serials <FILE> | No | – | File of hex serial numbers to mark as good |
--issuer-name-b64 <B64> | No | – | Base64-encoded DER issuer name for correct CertID hashes |
--issuer-key-b64 <B64> | No | – | Base64-encoded issuer public key bytes |
--issuer <FILE> | No | – | Issuer certificate (DER) for automatic CertID computation |
--seal-key <FILE> | No | – | PKCS#8 PEM/DER seal key file (separate from signing key) |
--dual-alg <ALG> | No | – | Produce a dual-algorithm bundle alongside --sig-alg (e.g., ml-dsa-87) |
--pq-signing-key <FILE> | No | – | PKCS#8 PEM/DER PQ signing key file (required with --dual-alg) |
* One of --signing-key or --demo-key is required. hoike refuses to sign without an explicit key source.
Signature algorithms (--sig-alg)
| Value | Algorithm | Key type |
|---|---|---|
ecdsa-p256 | ECDSA with P-256/SHA-256 | EC P-256 |
ml-dsa-44 | ML-DSA-44 (FIPS 204) | ML-DSA-44 |
ml-dsa-65 | ML-DSA-65 (FIPS 204) | ML-DSA-65 |
ml-dsa-87 | ML-DSA-87 (FIPS 204) | ML-DSA-87 |
The ML-DSA algorithms provide post-quantum signing. Use these when your PKI deployment requires quantum-resistant certificate status.
CertID compatibility (--certid-compat)
| Value | Behavior |
|---|---|
dual | Produce both SHA-256 and SHA-1 CertID entries for each certificate. Maximizes client compatibility. |
sha256 | SHA-256 CertID entries only. Standards-compliant but may not work with older clients. |
sha1 | SHA-1 CertID entries only. Legacy compatibility mode. |
Good-serials file format
A plain text file with one hex-encoded serial number per line:
01A3F2
01A3F3
01B7C0
Certificates listed here are marked as good in the OCSP responses. Certificates found in the CRL are marked as revoked. Certificates in neither list are treated according to the CA’s completeness policy.
Epoch numbering
The epoch is a monotonically increasing integer that prevents rollback
attacks. Edge nodes refuse to load a bundle with an epoch lower than the
currently loaded one. If --epoch is not specified, the signer
auto-increments from the previous bundle’s epoch.
Example
# Production (with PKCS#8 key file)
hoike sign \
--ca enterprise-issuing-01 \
--crl /var/lib/pki/ca.crl \
--signing-key /etc/pki/ocsp-signer.key \
--good-serials /var/lib/pki/good-serials.txt \
--sig-alg ecdsa-p256 \
--certid-compat dual \
--epoch 42 \
-o /var/lib/hoike/bundles/enterprise.ahu
# Testing (with demo key)
hoike sign \
--ca test-ca \
--crl test.crl \
--demo-key \
-o test.ahu
hoike import
Import a bundle for air-gap or enclave deployments where gossip is not available.
hoike import --bundle <PATH> [OPTIONS]
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--bundle <PATH> | Yes | – | Path to the ahu bundle file to import |
--config <PATH> | No | – | Path to hoike.toml (for target directory resolution) |
--force | No | false | Skip epoch and seal checks during import |
Description
Copies an ahu bundle into the configured bundle directory and registers it with the state database. This is the manual alternative to gossip-based bundle distribution.
The import process:
- Verifies the bundle seal and integrity
- Checks that the epoch is greater than any currently loaded bundle for the same CA scope
- Copies the bundle to
bundle_dir - Updates the state database
Use --force to bypass epoch and seal checks (for disaster recovery or
initial bootstrap only).
Example
# Standard import
hoike import --bundle /mnt/usb/enterprise.ahu \
--config /etc/hoike/hoike.toml
# Force import (disaster recovery)
hoike import --bundle /mnt/usb/enterprise.ahu \
--config /etc/hoike/hoike.toml --force
hoike query
Query a running OCSP responder with optional algorithm preference negotiation.
hoike query --url <URL> --serial <HEX> --issuer-name-b64 <B64> --issuer-key-b64 <B64> [OPTIONS]
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--url <URL> | Yes | – | Responder URL (e.g., http://localhost:2560) |
--serial <HEX> | Yes | – | Hex-encoded certificate serial number |
--issuer-name-b64 <B64> | Yes | – | Base64-encoded DER issuer name |
--issuer-key-b64 <B64> | Yes | – | Base64-encoded issuer public key bytes |
--prefer <ALGS> | No | – | Comma-separated preferred algorithms (e.g., ml-dsa-87,ecdsa-p256) |
Description
Builds an OCSP request for the given serial number and issuer, sends it to the specified responder, and displays the parsed response including status, signature algorithm, timestamps, and nonce handling.
When --prefer is specified, the request includes an RFC 6960 §4.4.7.1
PreferredSignatureAlgorithms extension. This is used to test
dual-algorithm bundle negotiation — requesting ml-dsa-87 from a
responder serving a dual-algorithm bundle returns the ML-DSA response.
Example
# Basic query
hoike query \
--url http://localhost:2560 \
--serial 0A1B2C \
--issuer-name-b64 "..." \
--issuer-key-b64 "..."
# Query with post-quantum preference
hoike query \
--url http://localhost:2560 \
--serial 0A1B2C \
--issuer-name-b64 "..." \
--issuer-key-b64 "..." \
--prefer ml-dsa-87
ahu CLI Reference
The ahu binary is a standalone tool for working with ahu bundle files.
It does not require a running hoike server or any configuration. All
operations are read-only except apply.
Global options
ahu [OPTIONS] <COMMAND>
| Option | Description |
|---|---|
--version | Print version information |
--help | Print help |
ahu inspect
Display the manifest, scopes, epochs, and entry counts of a bundle.
ahu inspect <FILE>
Arguments
| Argument | Description |
|---|---|
<FILE> | Path to the ahu bundle file |
Description
Reads the bundle’s CBOR manifest and prints a human-readable summary including:
- CA label and scope identifier
- Epoch number
- Signature algorithm used for OCSP responses
- Entry count (total number of pre-signed responses)
- CertID compatibility mode (dual, sha256, sha1)
- Timestamps (production time, thisUpdate, nextUpdate)
- Bundle size on disk
This command does not verify the bundle’s integrity – use ahu verify
for that.
Example
ahu inspect /var/lib/hoike/bundles/enterprise.ahu
ahu verify
Verify the seal, digests, and sort order of a bundle.
ahu verify <FILE> [OPTIONS]
Arguments
| Argument | Description |
|---|---|
<FILE> | Path to the ahu bundle file |
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--entries | No | false | Also verify each individual entry’s OCSP response signature |
Description
Performs integrity verification of the bundle:
- Seal verification – checks the cryptographic seal over the entire bundle, confirming it has not been modified since signing
- Digest verification – recomputes content digests and compares against the manifest
- Sort order – confirms the index entries are in sorted order (required for binary search at serving time)
With --entries, additionally verifies that each individual OCSP response
is properly signed and well-formed. This is more thorough but takes longer
on large bundles.
Exit code 0 on success, non-zero on any verification failure.
Example
# Quick verification (seal + digests + sort order)
ahu verify enterprise.ahu
# Full verification including individual entries
ahu verify enterprise.ahu --entries
ahu extract
Extract a single pre-signed OCSP response by its CertID entry key.
ahu extract <FILE> --certid <HEX>
Arguments
| Argument | Description |
|---|---|
<FILE> | Path to the ahu bundle file |
Options
| Flag | Required | Default | Description |
|---|---|---|---|
--certid <HEX> | Yes | – | Hex-encoded CertID to look up |
Description
Performs a binary search of the bundle’s sorted index for the given CertID and writes the matching pre-signed OCSP response to stdout as DER-encoded bytes.
The CertID is the concatenation of the issuer name hash, issuer key hash,
and serial number that uniquely identifies a certificate in an OCSP
request. Use ahu inspect to see available entries.
Returns exit code 0 if found, non-zero if the CertID is not present in the bundle.
Example
# Extract a response and save to file
ahu extract enterprise.ahu \
--certid 3a7f2b... > response.der
# Decode the extracted response with OpenSSL
openssl ocsp -respin response.der -resp_text
ahu diff
Show differences between two bundle generations.
ahu diff <A> <B>
Arguments
| Argument | Description |
|---|---|
<A> | Path to the older (base) bundle |
<B> | Path to the newer bundle |
Description
Compares two ahu bundles and reports:
- Added entries – CertIDs present in B but not in A
- Removed entries – CertIDs present in A but not in B
- Changed entries – CertIDs present in both but with different response content (e.g., status changed from good to revoked)
- Manifest differences – changes in epoch, timestamps, signature algorithm, or entry counts
This is useful for auditing what changed between bundle generations before deploying an update.
Example
ahu diff enterprise-epoch41.ahu enterprise-epoch42.ahu
ahu apply
Apply one or more delta bundles to a base bundle, producing a new combined bundle.
ahu apply <BASE> <DELTAS>... -o <OUT>
Arguments
| Argument | Description |
|---|---|
<BASE> | Path to the base bundle |
<DELTAS>... | One or more delta bundle files to apply, in order |
Options
| Flag | Required | Default | Description |
|---|---|---|---|
-o <OUT> | Yes | – | Output path for the resulting bundle |
Description
Applies delta bundles to a base bundle to produce a new full bundle. This is the incremental update mechanism: instead of transferring a complete bundle each time, the signer can produce small deltas containing only the changed entries.
Deltas are applied in the order specified on the command line. The resulting bundle is a complete, self-contained ahu file that can be served directly.
The output bundle:
- Contains all entries from the base, with additions and modifications from the deltas applied
- Has a new seal computed over the merged content
- Carries the epoch from the last delta applied
Example
# Apply a single delta
ahu apply base.ahu delta-42.ahu -o merged.ahu
# Apply multiple deltas in sequence
ahu apply base.ahu delta-42.ahu delta-43.ahu -o merged.ahu
# Verify the result
ahu verify merged.ahu --entries
Rust API Reference
Full rustdoc-generated API documentation is available at /api/.
This page provides a high-level map of the six crates and their public API surface to help you find what you need.
Crate overview
ahu
License: Apache-2.0 / MIT
The bundle format library. Use this crate if you need to read, write, or verify ahu containers from your own Rust code.
Key public types and modules:
| Item | Description |
|---|---|
Bundle | Top-level type for reading and inspecting an ahu bundle |
BundleBuilder | Construct a new bundle with manifest, entries, and seal |
Manifest | CBOR-encoded bundle metadata (CA label, epoch, algorithm, timestamps) |
Entry | A single CertID-to-response mapping in the bundle |
Seal | Cryptographic seal binding the manifest and all entries |
verify() | Verify a bundle’s seal, digests, and sort order |
mmap | Memory-mapped bundle access for zero-copy serving |
This crate is dual-licensed (Apache-2.0/MIT) so it can be used as a dependency without GPL obligations.
hoike-core
License: GPL-3.0-or-later
Shared types, configuration parsing, and protocol logic used by all other hoike crates.
Key public types and modules:
| Item | Description |
|---|---|
Config | Parsed hoike.toml configuration |
CaConfig | Per-CA configuration ([[ca]] section) |
ServerConfig | Server mode, listen address, limits |
StorageConfig | Bundle directory, state DB path, chain limits |
GossipConfig | SWIM gossip parameters (seeds, bind address, node name) |
NoncePolicy | Nonce handling strategy (ignore, reject, echo) |
Completeness | Completeness model for unknown certificates |
hoike-sign
License: GPL-3.0-or-later
The signing engine. Parses CRLs, produces OCSP responses, and seals them into ahu bundles.
Key public types and modules:
| Item | Description |
|---|---|
Signer | Main signing orchestrator – CRL + serials in, sealed bundle out |
ResponseBuilder | Construct individual OCSP responses |
CrlParser | Parse PEM or DER CRL files and extract revocation entries |
SigAlgorithm | Enum of supported signature algorithms (ECDSA, ML-DSA variants) |
CertIdCompat | CertID hash compatibility mode selection |
EpochManager | Track and enforce monotonic epoch numbering |
hoike-server
License: GPL-3.0-or-later
The axum-based HTTP server that handles OCSP requests and serves pre-signed responses.
Key public types and modules:
| Item | Description |
|---|---|
Server | Top-level server lifecycle (bind, serve, shutdown) |
OcspHandler | Request parsing, CertID extraction, bundle lookup |
BundleStore | Thread-safe bundle storage with hot-reload support |
Router | axum router configuration with OCSP and health endpoints |
hoike-gossip
License: GPL-3.0-or-later
SWIM gossip protocol integration via foca for edge fleet coordination.
Key public types and modules:
| Item | Description |
|---|---|
GossipRuntime | Manages the foca SWIM protocol instance |
BundleAnnouncement | Notification payload when a new bundle is available |
PeerState | Tracked state for each peer in the gossip cluster |
Transport | UDP transport layer for gossip messages |
hoike-cli
License: GPL-3.0-or-later
CLI entry points and argument parsing for the hoike and ahu binaries.
This crate wires together all other crates behind the command-line
interface.
You generally do not depend on this crate as a library. Its public API is the CLI itself, documented in the hoike CLI and ahu CLI reference pages.
Building the docs locally
Generate the full API documentation with:
cargo doc --workspace --no-deps --open
This builds rustdoc for all six crates and opens the result in your
browser. The --no-deps flag skips documentation for third-party
dependencies.
To build docs for a single crate:
cargo doc -p ahu --no-deps --open
Development Setup
This page covers everything needed to build, run, and develop hoike from source.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Rust | 1.85+ | Edition 2024. Install via rustup. |
| C linker | Any | Xcode CLT (macOS), build-essential (Debian/Ubuntu), gcc (Fedora/RHEL) |
| OpenSSL | 3.x | For test certificate generation only |
| Git | 2.x | For cloning |
Verify your Rust toolchain:
rustc --version # 1.85.0 or later
cargo --version
Clone and build
git clone https://github.com/czinda/hoike.git
cd hoike
cargo build --release
The workspace produces two binaries:
| Binary | Location | Size |
|---|---|---|
hoike | target/release/hoike | ~8 MB |
ahu | target/release/ahu | ~1 MB |
For development builds (faster compilation, slower runtime):
cargo build
Workspace structure
The Cargo workspace contains six crates:
hoike/
Cargo.toml # Workspace root
crates/
ahu/ # Bundle format (Apache-2.0 OR MIT)
Cargo.toml
src/
tests/
hoike-core/ # Shared types, config, routing (GPL-3.0+)
Cargo.toml
src/
tests/
hoike-sign/ # Response production, signing (GPL-3.0+)
Cargo.toml
src/
tests/
hoike-server/ # HTTP handlers (GPL-3.0+)
Cargo.toml
src/
tests/
conformance.rs
hoike-gossip/ # SWIM protocol (GPL-3.0+)
Cargo.toml
src/
hoike-cli/ # CLI entry points (GPL-3.0+)
Cargo.toml
src/
bin/
hoike.rs
ahu.rs
testdata/
generate.rs # Test certificate/CRL generation
Crate dependency graph
Dependencies flow downward. The ahu crate is at the bottom and has no
server-side dependencies:
graph TD
CLI[hoike-cli] --> Server[hoike-server]
CLI --> Sign[hoike-sign]
Server --> Core[hoike-core]
Sign --> Core
Server --> Gossip[hoike-gossip]
Core --> Ahu[ahu]
Sign --> Ahu
style Ahu fill:#e8f5e9,stroke:#2e7d32
The green-highlighted ahu crate is the trust boundary for the
dual-license split. It must never depend on tokio, hyper, axum, or
PKCS#11.
The dual-DER-version note
The workspace uses two versions of the RustCrypto der crate:
| Crate | der version | Reason |
|---|---|---|
x509-ocsp 0.2.x | der 0.7 | OCSP request/response parsing (tracks x509-cert 0.2) |
ahu | der 0.8 | Bundle manifest and seal operations |
This is intentional. The x509-ocsp crate has not yet released a
version that uses der 0.8. Cargo handles the two versions transparently,
but be aware of this when working on code that bridges the two:
- Types from
der0.7 are not interchangeable with types fromder0.8 - Conversion between the two versions requires re-encoding as DER bytes and re-parsing
- The bridge code lives in
hoike-corewhere the two versions meet
If x509-ocsp releases a der 0.8 compatible version, the workspace
should be updated to unify on a single version.
Building individual crates
Build only the bundle library:
cargo build --release -p ahu
The ahu crate supports --no-default-features for minimal builds:
cargo build --release -p ahu --no-default-features
Build without gossip support:
cargo build --release -p hoike-cli --no-default-features
Building the Web UI
The admin web UI is a React + PatternFly 6 application in the webui/ directory.
Development mode (with hot reload):
cd webui
npm install
npm run dev # Starts on http://localhost:9000
The Vite dev server proxies /api/admin requests to http://localhost:2560 (the hoike server).
Production build (for embedding or disk serving):
cd webui
npm run build # Produces webui/dist/
Embedding in the binary:
cd webui && npm run build
cd .. && cargo build --release --features embed-webui
The embed-webui feature uses include_dir to bake webui/dist/ into the binary. The embedded UI is served at /ui/ automatically when [server.webui] is present in the config (without static_dir).
Generating API documentation
cargo doc --workspace --no-deps --open
This builds rustdoc for all six crates and opens the result in a browser.
Running tests
Run the full test suite:
cargo test --workspace
See the Testing page for detailed test categories and options.
Development tools
Recommended but not required:
| Tool | Purpose | Install |
|---|---|---|
cargo-watch | Auto-rebuild on save | cargo install cargo-watch |
cargo-nextest | Faster test runner with better output | cargo install cargo-nextest |
mdbook | Build the documentation book | cargo install mdbook |
mdbook-mermaid | Mermaid diagram support for mdbook | cargo install mdbook-mermaid |
Development workflow with cargo-watch:
# Rebuild on change
cargo watch -x build
# Run tests on change
cargo watch -x 'test --workspace'
Environment variables
| Variable | Default | Description |
|---|---|---|
HOIKE_LOG | info | Log level (trace, debug, info, warn, error) |
HOIKE_CONFIG | None | Path to configuration file |
RUST_BACKTRACE | 0 | Set to 1 for backtraces on panic |
IDE setup
hoike uses standard Rust tooling. Any editor with rust-analyzer support
works well:
- VS Code: Install the
rust-analyzerextension - Neovim: Use
nvim-lspconfigwithrust_analyzer - IntelliJ: Use the Rust plugin
The workspace root Cargo.toml is the correct entry point for
rust-analyzer. No additional configuration is needed.
Testing
hoike has 200 tests on the default build (218 with the tls, metrics, and dogtag-sync features enabled) across 6 crates covering unit, integration, end-to-end, conformance, seal verification, ML-DSA, key rotation, and live nonce signing.
Running all tests
cargo test --workspace
With verbose output:
cargo test --workspace -- --nocapture
With cargo-nextest (recommended for faster parallel execution):
cargo nextest run --workspace
Test categories
Unit tests
Each crate contains inline unit tests (#[cfg(test)] modules) covering
individual functions and types.
# Run unit tests for a specific crate
cargo test -p ahu
cargo test -p hoike-core
cargo test -p hoike-sign
cargo test -p hoike-server
cargo test -p hoike-gossip
Integration tests
Integration tests are in tests/ directories within each crate. They test
cross-module behavior using the public API.
ahu integration tests
Located in crates/ahu/tests/. These cover:
- Bundle creation: write manifest, index, data, and seal
- Bundle reading: parse and verify a bundle from bytes
- Round-trip: create a bundle and read it back
- Index binary search correctness at various sizes
- Delta bundle creation and application
- Corrupt bundle detection (tampered seal, modified data)
- Format version handling
cargo test -p ahu --tests
Anti-rollback tests
Located in crates/hoike-core/tests/anti_rollback.rs. These verify the
epoch chain enforcement:
- Accept a bundle with epoch > current epoch
- Reject a bundle with epoch <= current epoch (rollback)
- Reject a bundle with incorrect parent hash (fork)
- Accept epoch 1 with null parent hash (initial load)
- Reject epoch 2+ with null parent hash (missing chain)
cargo test -p hoike-core --test anti_rollback
Conformance suite
Located in crates/hoike-server/tests/conformance.rs. This suite
exercises the 20 protocol conformance checks listed in the
RFC Support Reference.
The conformance tests spin up an in-process axum server with a test bundle and exercise the full HTTP request path:
cargo test -p hoike-server --test conformance
Each test function is named after the check it validates:
conformance::get_valid_request
conformance::post_valid_request
conformance::post_wrong_content_type
conformance::oversized_request_rejected
conformance::non_minimal_der_rejected
conformance::trailing_bytes_rejected
conformance::multi_certid_rejected
conformance::sha256_certid_response
conformance::sha1_certid_compat
conformance::good_status
conformance::revoked_status_with_reason
conformance::unknown_ca_unauthorized
conformance::unknown_serial_unauthorized
conformance::bykey_responder_id
conformance::nonce_not_echoed
conformance::overlong_nonce_rejected
conformance::content_type_header
conformance::cache_control_header
conformance::etag_header
conformance::last_modified_expires_headers
ML-DSA tests
Located in crates/hoike-sign/tests/. These cover post-quantum signing
and verification:
- ML-DSA-44 key generation and response signing
- ML-DSA-65 key generation and response signing
- ML-DSA-87 key generation and response signing
- Bundle creation with ML-DSA signatures
- Bundle verification of ML-DSA seals
- Round-trip: sign with ML-DSA, bundle, load, verify
cargo test -p hoike-sign -- ml_dsa
Test data generation
The testdata/generate.rs script creates test certificates, keys, CRLs,
and serial lists used by the test suite. Run it to regenerate test
fixtures:
cargo run --example generate -p hoike-cli
This produces:
| File | Contents |
|---|---|
testdata/ca.crt | Test CA certificate (self-signed, P-256) |
testdata/ca.key | Test CA private key |
testdata/ocsp.crt | Delegated OCSP responder certificate |
testdata/ocsp.key | OCSP responder private key |
testdata/ee*.crt | End-entity certificates |
testdata/ca.crl | CRL with one revoked certificate |
testdata/good-serials.txt | Serial numbers of non-revoked certificates |
The test data is committed to the repository so that cargo test works
without running the generator first.
Writing new tests
Conventions
- Use
#[test]for synchronous tests - Use
#[tokio::test]for async tests (server and gossip crates) - Name tests descriptively:
fn rejects_overlong_nonce()notfn test_3() - Put integration tests in
crates/<crate>/tests/ - Put unit tests inline in the module being tested
Test helpers
Common test utilities are available in each crate’s tests/ or as
#[cfg(test)] modules:
hoike-core: Test bundle builder, mock CaContext, sample CertIDshoike-server: In-process server launcher, HTTP client helpersahu: Bundle builder with configurable manifest fields
Example: adding a conformance check
To add a new conformance check:
- Add the test function to
crates/hoike-server/tests/conformance.rs - Name it after the behavior being verified
- Use the test server and HTTP client helpers
- Document the RFC requirement in the test’s doc comment
#![allow(unused)]
fn main() {
/// RFC 9919 Section X: <requirement description>
#[tokio::test]
async fn new_conformance_check() {
let server = TestServer::start().await;
let response = server.post_ocsp_request(&build_test_request()).await;
assert_eq!(response.status(), 200);
// ... verify the specific behavior
}
}
- Update the conformance check table in
doc/src/compliance/rfc-support.md
Continuous integration
The CI pipeline runs:
cargo fmt --check # Formatting
cargo clippy --workspace # Lints
cargo test --workspace # All tests
cargo doc --workspace --no-deps # Documentation builds
All four checks must pass before a pull request can be merged.
Contributing
This guide covers the development workflow, code style, architecture rules, and licensing requirements for contributing to hoike.
Development workflow
- Fork and clone the repository
- Create a feature branch from
main:git checkout -b feature/my-change - Make your changes following the guidelines below
- Run the full check suite before committing:
cargo fmt --check cargo clippy --workspace cargo test --workspace - Commit with a clear message (see Commit messages)
- Open a pull request against
main
Code style
hoike uses rustfmt for formatting and clippy for linting.
Formatting
Format all code before committing:
cargo fmt
The workspace includes a rustfmt.toml with project-specific settings.
Do not override these in individual crates.
Linting
Run clippy with default settings:
cargo clippy --workspace
Fix all warnings. Clippy lints should not be suppressed with
#[allow(...)] unless there is a documented reason in a comment.
Naming
- Types:
PascalCase - Functions and methods:
snake_case - Constants:
SCREAMING_SNAKE_CASE - Modules:
snake_case - Crate names:
kebab-case(e.g.,hoike-core)
Documentation
All public items must have doc comments (///). Include:
- A one-line summary
- Any important invariants or panics
- Examples for non-obvious usage
Architecture boundaries
These boundaries are load-bearing. Violating them breaks the licensing model, the security model, or both.
ahu must not depend on server crates
The ahu crate is a pure data-format library. It must never depend on:
| Forbidden dependency | Reason |
|---|---|
tokio | No async runtime in a format library |
hyper | No HTTP in a format library |
axum | No web framework in a format library |
PKCS#11 bindings | No HSM coupling in a format library |
| Any GPL-licensed crate | ahu is Apache-2.0 OR MIT |
If you need async I/O for bundle operations, put it in hoike-core or
hoike-sign, not in ahu.
Dependency flow is strictly downward
hoike-cli -> hoike-server -> hoike-core -> ahu
-> hoike-gossip
-> hoike-sign -> hoike-core -> ahu
No crate may depend on a crate above it in this graph. Specifically:
ahudepends on nothing in the hoike workspacehoike-coredepends only onahuhoike-signdepends onahuandhoike-corehoike-serverdepends onhoike-coreandhoike-gossiphoike-gossipdepends on nothing in the hoike workspace (uses foca)hoike-clidepends onhoike-serverandhoike-sign
No signing at request time
The edge path (hoike-server) must never perform cryptographic signing operations. It reads pre-signed bytes from memory-mapped bundles and writes them directly to the response. If you find yourself importing signing functions into hoike-server, the design is wrong.
Licensing
hoike uses a split licensing model:
| Crate | License | SPDX |
|---|---|---|
ahu | Apache License 2.0 OR MIT | Apache-2.0 OR MIT |
hoike-core | GNU General Public License v3.0 or later | GPL-3.0-or-later |
hoike-sign | GNU General Public License v3.0 or later | GPL-3.0-or-later |
hoike-server | GNU General Public License v3.0 or later | GPL-3.0-or-later |
hoike-gossip | GNU General Public License v3.0 or later | GPL-3.0-or-later |
hoike-cli | GNU General Public License v3.0 or later | GPL-3.0-or-later |
Why the split?
The ahu bundle format is intended to be an open standard that any project
can implement. The permissive dual license (Apache-2.0 OR MIT) allows other
OCSP responders, certificate authorities, and PKI tools to read and write
ahu bundles without GPL obligations.
The server components are GPL because hoike’s operating logic (routing, signing policy, batch production) is the core intellectual contribution.
Adding dependencies
When adding a dependency to ahu, verify that its license is compatible
with Apache-2.0 and MIT. Common compatible licenses:
- MIT
- Apache-2.0
- BSD-2-Clause, BSD-3-Clause
- ISC
- Zlib
GPL, LGPL, MPL-2.0, and AGPL dependencies are not compatible with
ahu. They may be used in the GPL-licensed crates.
Commit messages
Use conventional-style messages:
<type>(<scope>): <summary>
<body>
<trailers>
Types
| Type | Use for |
|---|---|
feat | New functionality |
fix | Bug fixes |
refactor | Code restructuring without behavior change |
test | Adding or modifying tests |
docs | Documentation changes |
chore | Build, CI, dependency updates |
Scope
Use the crate name as scope: ahu, core, sign, server, gossip,
cli. Use workspace for cross-cutting changes.
Examples
feat(sign): add ML-DSA-87 signing support
Implement FIPS 204 ML-DSA-87 key generation and signing in the batch
production path. Adds tests for round-trip sign-bundle-verify.
Assisted-by: Claude Code (claude.ai/code)
fix(server): reject overlong nonces per RFC 9654
Nonces longer than 32 octets were accepted and silently ignored.
Now returns malformedRequest as required by RFC 9654 Section 4.
Assisted-by: Claude Code (claude.ai/code)
AI attribution policy
hoike follows Red Hat’s AI attribution guidelines:
| Situation | Trailer |
|---|---|
| Human-directed work with AI assistance | Assisted-by: Claude Code (claude.ai/code) |
| Large generated blocks with minimal human edit | Generated-by: Claude Code (claude.ai/code) |
Never use Co-Authored-By: for AI tools – this has CLA and
contributor statistics implications.
Include the appropriate trailer in every commit that involved AI assistance.
Pull request checklist
Before submitting a PR, verify:
-
cargo fmt --checkpasses -
cargo clippy --workspacehas no warnings -
cargo test --workspacepasses (all 81+ tests) -
cargo doc --workspace --no-depsbuilds without warnings - New public APIs have doc comments
- New behavior has test coverage
- Commit messages follow the convention above
-
ahucrate has no new server-side dependencies - License headers are correct for the crate being modified
Reporting issues
File issues on the GitHub issue tracker. Include:
- hoike version (
hoike --version) - Rust version (
rustc --version) - Operating system
- Steps to reproduce
- Expected vs. actual behavior
- Relevant configuration (redact any private key paths)