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, reads CRLs (or serial lists), and batch-produces pre-signed OCSP responses packaged into ahu bundles. The signer can run in a hardened enclave, an HSM-attached host, or an air-gapped machine. It never handles client traffic.
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 library – read, write, verify ahu containers | Apache-2.0 / MIT |
hoike-core | Shared types, configuration, and protocol logic | GPL-3.0-or-later |
hoike-sign | Signing engine – CRL parsing, response production, bundle sealing | GPL-3.0-or-later |
hoike-server | axum-based OCSP responder and HTTP serving | 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
Technology stack
- Language: Rust 1.85+
- HTTP server: axum 0.8 on tokio
- Cryptography: RustCrypto (
der,x509-cert,x509-ocsp) - 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 \
--issuer-cert ca.crt \
--signer-cert ocsp.crt \
--signer-key ocsp.key \
--crl ca.crl \
--good-serials good-serials.txt \
--sig-alg ecdsa-p256 \
--certid-compat dual \
--epoch 1 \
--output 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 OCSP signing
key, and packages everything into demo-ca.ahu.
Flag summary:
| Flag | Value | Meaning |
|---|---|---|
--ca | demo-ca | Label for this CA scope in the bundle |
--sig-alg | ecdsa-p256 | Signature algorithm for OCSP responses |
--certid-compat | dual | Produce both SHA-256 and SHA-1 CertID entries |
--epoch | 1 | Monotonic epoch number for anti-rollback |
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 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
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
Every key can also be set via environment variable using the HOIKE_ prefix, double-underscore section separators, and uppercase names. For example, server.listen becomes HOIKE_SERVER__LISTEN. Environment variables take precedence over the config file.
[server]
Top-level server settings that control the process mode, listener, and request limits.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | required | Operating mode: "signer", "edge", or "combined". See Signer, Edge, and Combined mode pages. |
listen | string | "0.0.0.0:2560" | Socket address for the HTTP listener. Port 2560 is the IANA-assigned port for OCSP over HTTP. |
max_request | integer | 8192 | Maximum OCSP request body size in bytes. RFC 6960 POST bodies are typically small; RFC 9919 GET requests encode the request in the URL path and are capped at 255 bytes by the URI length constraint. This limit protects against oversized or malformed requests. |
[server]
mode = "edge"
listen = "0.0.0.0:2560"
max_request = 8192
Mode validation
mode is the single most important setting. It 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. For example, nonce_policy = "live" on an edge node is a fatal error (edge nodes have no signing keys).
[storage]
Paths and limits for bundle storage and persistent state.
| Key | Type | Default | Description |
|---|---|---|---|
bundle_dir | string | "/var/lib/hoike/bundles" | Directory where ahu bundles are stored. The signer writes here; the edge reads from here. Must be readable (edge) or read-write (signer/combined). |
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. Lower values increase bandwidth (more full bundles); higher values save bandwidth but increase recovery time after a missed delta. |
[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 | true | 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. At least one seed must be reachable for a new node to join the fleet. Format: "hostname:port". |
identity_key | string | — | Path to the node’s gossip identity key. All gossip messages are signed with this key. |
node_name | string | hostname | Human-readable node identifier. Must be unique within the gossip cluster. Defaults to the system hostname if omitted. |
[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"
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, hoike import, 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 lookup. See Multi-CA Routing.
Identity and source
| Key | Type | Default | Description |
|---|---|---|---|
label | string | required | Human-readable label for this CA. Used in logs, metrics, and bundle filenames. Must be unique across all [[ca]] sections. |
source | inline table | required | Revocation data source. See Source types below. |
Signing
| Key | Type | Default | Description |
|---|---|---|---|
signing | string | "ca-direct" | Signing mode. "ca-direct" signs with the CA’s own key. "delegated" uses a separate OCSP responder certificate and key (requires responder_cert and responder_key). |
sig_alg | string | "ecdsa-p256" | Signature algorithm. Supported values: "ecdsa-p256", "ecdsa-p384", "ed25519", "rsa-sha256", "ml-dsa-44", "ml-dsa-65", "ml-dsa-87". |
responder_cert | string | — | Path to the OCSP responder certificate. Required when signing = "delegated". |
responder_key | string | — | Path to the OCSP responder private key. Required when signing = "delegated". |
responder_id | string | "by-key" | ResponderID format in OCSP responses: "by-key" (SubjectPublicKeyInfo hash) or "by-name" (Distinguished Name). "by-key" is recommended — it survives certificate renewal. |
CertID and compatibility
| Key | Type | Default | Description |
|---|---|---|---|
certid_compat | string | "dual" | CertID hash algorithm compatibility. "dual" indexes responses by both SHA-256 and SHA-1 issuerKeyHash (for clients that still send SHA-1). "sha256" accepts only SHA-256. "sha1" accepts only SHA-1 (not recommended). |
Nonce handling
| Key | Type | Default | Description |
|---|---|---|---|
nonce_policy | string | "ignore" | How to handle nonces in OCSP requests. "ignore" omits the nonce from responses (appropriate for pre-signed). "forward" proxies the request to the signer for a live-signed response with nonce. "live" signs a fresh response with nonce on every request (signer mode only). See Nonce Policies. |
forward_to | string | — | URL of the signer to forward nonce-bearing requests to. Required when nonce_policy = "forward". |
Timing and batch production
| Key | Type | Default | Description |
|---|---|---|---|
validity | duration string | "24h" | Response validity window (nextUpdate − thisUpdate). Determines how long a cached response remains valid. |
batch_interval | duration string | "1h" | How often the signer produces a new batch of responses. The signer outage budget is validity − batch_interval — if the signer is down longer than this, edge nodes will begin serving expired responses. |
jitter | duration string | "2h" | Random jitter added to thisUpdate to prevent response expiration thundering herds. Each response’s thisUpdate is shifted by a random offset within [0, jitter]. |
max_age_fraction | float | 0.5 | Fraction of remaining validity used for Cache-Control: max-age. A value of 0.5 means the max-age header is set to half the time remaining until nextUpdate. |
urgent_revocation | boolean | true | When true, the signer produces an off-cycle delta bundle immediately upon detecting a new revocation, rather than waiting for the next batch_interval. |
archive_cutoff | duration string | "1y" | How far back to keep responses for expired certificates. Certificates that expired more than this duration ago are dropped from bundles. |
Completeness
| Key | Type | Default | Description |
|---|---|---|---|
completeness | string | "authoritative-complete" | Declares whether this responder has complete revocation data for the CA. "authoritative-complete" means the responder is the authoritative source for this CA’s revocation status — any certificate not found in the bundle is reported as good. "partial" means the responder only knows about certificates listed in a CRL — unlisted certificates return unauthorized (unknown). |
Source types
The source field is an inline table that specifies where revocation data comes from.
CRL source (implemented):
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
| Field | Type | Description |
|---|---|---|
type | string | Must be "crl". |
path | string | Path to the CRL file. hoike watches this path for changes and reloads automatically. |
Dogtag source (planned):
source = { type = "dogtag", url = "https://ca01.pki.example:8443", auth = "mtls", cert = "/etc/hoike/ra.pem" }
| Field | Type | Description |
|---|---|---|
type | string | Must be "dogtag". |
url | string | Dogtag CA REST API endpoint. |
auth | string | Authentication method: "mtls". |
cert | string | Path to the client certificate for mTLS authentication. |
[[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"
jitter = "2h"
archive_cutoff = "1y"
completeness = "authoritative-complete"
Duration strings
Duration values use a human-readable format: a number followed by a unit suffix.
| Suffix | Meaning | Example |
|---|---|---|
s | seconds | "30s" |
m | minutes | "15m" |
h | hours | "24h" |
d | days | "7d" |
y | years (365 days) | "1y" |
Validation rules
hoike validates the configuration at startup and exits with a descriptive error if any rule is violated:
| Rule | Error |
|---|---|
mode is missing or not one of signer, edge, combined | invalid server mode |
nonce_policy = "live" with mode = "edge" | live nonce signing requires signer mode |
nonce_policy = "forward" without forward_to | forward_to is required when nonce_policy = "forward" |
signing = "delegated" without responder_cert or responder_key | delegated signing requires responder_cert and responder_key |
batch_interval >= validity | batch_interval must be less than validity |
max_chain < 1 | max_chain must be at least 1 |
Duplicate label across [[ca]] sections | duplicate CA label |
bundle_dir does not exist or is not writable (signer/combined) | bundle_dir is not writable |
state_db parent directory does not exist | state_db path is invalid |
gossip.enabled = true without identity_key | gossip identity_key is required when gossip is enabled |
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
max_request = 8192 # Max POST body; GET is path-limited to ~255 bytes
[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 # Accept up to 24 delta bundles before requiring a full
[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"
# Enterprise issuing CA — CRL-based, pre-signed responses
[[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" # Accept both SHA-256 and SHA-1 CertID hashes
nonce_policy = "ignore" # Pre-signed — nonce omitted from responses
validity = "24h" # Signer outage budget: 24h − 1h = 23h
batch_interval = "1h"
jitter = "2h"
archive_cutoff = "1y"
completeness = "authoritative-complete"
# Partner issuing CA — delegated responder, nonces forwarded to signer
[[ca]]
label = "partner-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/partner.crl" }
signing = "delegated"
responder_cert = "/etc/hoike/partner-ocsp.pem"
responder_key = "/etc/hoike/partner-ocsp.key"
sig_alg = "ecdsa-p384"
responder_id = "by-key"
certid_compat = "sha256" # Partner clients all support SHA-256
nonce_policy = "forward" # Proxy nonce-bearing requests to signer
forward_to = "https://signer.pki.example:2560"
validity = "12h"
batch_interval = "30m"
jitter = "1h"
archive_cutoff = "6m" # Partner certs are short-lived
completeness = "partial" # CRL may not list every certificate
Environment variable overrides
Any configuration key can be overridden with an environment variable. The naming convention is:
HOIKE_<SECTION>__<KEY>
Double underscores separate section from key; single underscores within a key name are preserved.
| Config key | Environment variable |
|---|---|
server.mode | HOIKE_SERVER__MODE |
server.listen | HOIKE_SERVER__LISTEN |
storage.bundle_dir | HOIKE_STORAGE__BUNDLE_DIR |
gossip.enabled | HOIKE_GOSSIP__ENABLED |
Environment variables are useful for container deployments where the base config file is baked into the image and per-instance settings (like node_name or listen) vary.
Note:
[[ca]]array sections cannot be fully configured via environment variables due to TOML array-of-tables semantics. Use the config file for CA definitions.
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.
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 (planned)
Direct integration with Red Hat Certificate System (Dogtag) is planned. The intended configuration:
[[ca]]
label = "dogtag-ca-01"
source = { type = "dogtag", url = "https://ca01.pki.example:8443", auth = "mtls", cert = "/etc/hoike/ra.pem" }
This will query the Dogtag REST API for revocation state using mutual TLS authentication, eliminating the CRL export step.
Note: Until Dogtag support ships, use the CRL adapter and export CRLs from Dogtag on a schedule.
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 Mode
| Mode | Key used | When to use |
|---|---|---|
ca-direct | CA’s own private key | CA key is available to the signer (simpler, common) |
delegated | Separate responder key | Dedicated OCSP signing key with id-kp-OCSPSigning EKU |
CA-direct signing:
[[ca]]
label = "root-ca"
signing = "ca-direct"
Delegated signing:
[[ca]]
label = "enterprise-issuing-01"
signing = "delegated"
responder_cert = "/etc/hoike/ocsp-responder.pem"
responder_key = "/etc/hoike/ocsp-responder.key"
When using delegated, the responder certificate must contain the id-kp-OCSPSigning extended key usage and be issued by the CA it signs on behalf of.
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
Status: Planned, not yet implemented.
PKCS#11 integration will allow the signer to use hardware security modules (HSMs) for key storage and signing operations. When available, the signing key path will be replaced with a PKCS#11 URI:
# Future syntax (not yet supported)
responder_key = "pkcs11:token=hoike;object=ocsp-signer;type=private"
Until then, the signer reads key material from PEM files on disk. Protect these files with filesystem permissions and, where possible, full-disk encryption.
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.
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"
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: Every request requires a signing operation. Throughput is bounded by the signing rate, which varies by algorithm (ECDSA P-256 is fast; ML-DSA-65 is slower). No bundle index is consulted — the signer produces responses directly from revocation state.
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. |
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. The corresponding public key is derived automatically and exchanged during the SWIM join handshake.
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:
- All gossip messages are signed with the sender’s
identity_key. Unsigned or incorrectly signed messages are dropped. - 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.
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.
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.
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 + serial list] --> 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 | 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.1.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.
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) |
Total record size: 44 bytes.
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)
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.
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 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, not echoed in pre-signed mode |
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 (no on-demand signing) | 4 | Core design – all responses are batch-signed |
| Single CertID per request | 4 | Multi-CertID requests rejected as malformedRequest |
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: All responses are pre-signed during batch production. There is no code path for on-demand signing.
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.
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 (future) | Not yet supported; planned |
| 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 |
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
hoike CLI Reference
The hoike binary is the main entry point for the OCSP responder. It
provides four subcommands: serve, check, sign, and import.
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) |
--issuer-cert <FILE> | Yes | – | CA certificate (issuer) |
--signer-cert <FILE> | Yes | – | OCSP signing certificate |
--signer-key <FILE> | Yes | – | OCSP signing private key |
--output <FILE> | No | <label>.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 | auto | Epoch number for anti-rollback |
--good-serials <FILE> | No | – | File of hex serial numbers to mark as good |
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
hoike sign \
--ca enterprise-issuing-01 \
--issuer-cert /etc/pki/ca.crt \
--signer-cert /etc/pki/ocsp-signer.crt \
--signer-key /etc/pki/ocsp-signer.key \
--crl /var/lib/pki/ca.crl \
--good-serials /var/lib/pki/good-serials.txt \
--sig-alg ecdsa-p256 \
--certid-compat dual \
--epoch 42 \
--output /var/lib/hoike/bundles/enterprise.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
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
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 81 tests across 6 crates covering unit, integration, conformance, and algorithm-specific behavior.
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)