Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

hoike is a high-performance OCSP responder built in Rust, designed for pre-signed, replayable, multi-CA certificate status serving. The name comes from Hawaiian: hoike means “to show, to exhibit, to testify.”

Why hoike exists

OCSP (Online Certificate Status Protocol) is alive and well in enterprise, federal, DoD, and IoT PKI deployments, even as web browsers have largely moved to CRLs and short-lived certificates. Organizations running their own certificate authorities still need fast, reliable certificate status infrastructure that can:

  • Serve thousands of OCSP responses per second with minimal latency
  • Support multiple CAs from a single deployment
  • Operate in air-gapped and enclave environments
  • Meet post-quantum cryptography requirements (ML-DSA)
  • Scale horizontally without sharing private keys across nodes

hoike addresses all of these by splitting the problem into two distinct roles: signer and edge.

The signer/edge split

This is hoike’s core architectural bet. Traditional OCSP responders combine signing and serving into a single process, which means every node that handles client requests must hold the OCSP signing key. hoike separates these concerns:

Signer – holds the OCSP signing keys (PKCS#11 HSM or PKCS#8 file), reads revocation data from CRLs or Dogtag’s 389 DS via RFC 4533 syncrepl, and batch-produces pre-signed OCSP responses packaged into CMS-sealed ahu bundles. Signers also handle nonce-bearing requests by signing fresh responses on demand (nonce_policy = "live"). The signer can run in a hardened enclave, an HSM-attached host, or an air-gapped machine.

Edge – receives ahu bundles (via gossip, push, or manual import) and serves the pre-signed responses to OCSP clients. Edge nodes are stateless and keyless. They memory-map the bundle file and return the appropriate pre-signed bytes with zero cryptographic work at request time.

This split means you can run dozens of edge nodes without ever exposing signing keys to the network, and each edge node serves responses at memory-read speed.

The ahu bundle format

An ahu bundle is a self-describing container that packages pre-signed OCSP responses for efficient serving. Each bundle contains:

  • A CBOR manifest with metadata (CA label, epoch, signature algorithm, timestamps)
  • A sorted index of certificate identifiers mapped to their pre-signed responses
  • A cryptographic seal binding the manifest and all entries together

Bundles support zero-copy serving via mmap, meaning the edge process maps the file into memory and serves response bytes directly without deserialization. The ahu CLI tool lets you inspect, verify, diff, and apply delta updates to bundles.

Workspace overview

hoike is organized as a Rust workspace with six crates:

CrateDescriptionLicense
ahuBundle format: read, write, verify, CMS seal verificationApache-2.0 / MIT
hoike-coreCertID routing, config, anti-rollback state store, seal verification on loadGPL-3.0-or-later
hoike-signCRL + syncrepl adapters, OCSP response + CMS seal creation, PKCS#11, ML-DSA bridge, live nonce signing, key rotationGPL-3.0-or-later
hoike-serveraxum HTTP handlers, nonce policy dispatch, live signing, forward proxy, admin API with RBAC, React webuiGPL-3.0-or-later
hoike-gossipSWIM protocol (via foca) for edge fleet coordinationGPL-3.0-or-later
hoike-cliCLI entry points for hoike and ahu binariesGPL-3.0-or-later

The ahu crate is dual-licensed under Apache-2.0/MIT so that other projects can use the bundle format without GPL obligations. All other crates are GPL-3.0-or-later.

Key standards

hoike implements or targets these RFCs:

  • RFC 6960 – Online Certificate Status Protocol (OCSP)
  • RFC 9919 – Lightweight OCSP Profile for High-Volume Environments
  • RFC 9654 – OCSP Nonce Extension
  • RFC 5280 – Authority Information Access (AIA) for OCSP responder discovery
  • RFC 5652 – Cryptographic Message Syntax (CMS) for bundle seals
  • RFC 4533 – LDAP Content Synchronization (syncrepl) for Dogtag certificate repository integration

Technology stack

  • Language: Rust 1.85+
  • HTTP server: axum 0.8 on tokio
  • Cryptography: RustCrypto (der, x509-cert, x509-ocsp, cms)
  • HSM: PKCS#11 via cryptoki (Luna, Entrust, Utimaco, FutureX, Kryoptic)
  • Post-quantum: ML-DSA-44/65/87 via ml-dsa
  • Serialization: ciborium (CBOR)
  • Memory mapping: memmap2
  • Compression: zstd
  • Gossip: foca (SWIM protocol)

What’s next

Head to the Quick Start to build hoike from source and create your first OCSP responder, or jump to the Architecture Overview for a deeper look at how the pieces fit together.

Installation

hoike produces two binaries:

BinarySizePurpose
hoike~8 MBOCSP responder, signer, config checker
ahu~1 MBBundle inspection, verification, diffing, patching

Prerequisites

  • Rust 1.85+ (install via rustup)
  • A C linker (provided by Xcode CLT on macOS, build-essential on Debian/Ubuntu, gcc on Fedora/RHEL)

Verify your Rust version:

rustc --version
# rustc 1.85.0 (... 2025-...)

Build from source

Clone the repository and build in release mode:

git clone https://github.com/czinda/hoike.git
cd hoike
cargo build --release

The binaries are placed in target/release/:

ls -lh target/release/hoike target/release/ahu

Copy them to a directory on your PATH:

sudo install -m 755 target/release/hoike /usr/local/bin/
sudo install -m 755 target/release/ahu /usr/local/bin/

Verify the installation:

hoike --version
ahu --version

Container build

A Containerfile is provided for building a minimal container image:

podman build -t hoike .

Or with Docker:

docker build -t hoike .

Run the container with your configuration and bundle directory mounted:

podman run -d \
  --name hoike \
  -p 2560:2560 \
  -v /etc/hoike/hoike.toml:/etc/hoike/hoike.toml:ro \
  -v /var/lib/hoike/bundles:/var/lib/hoike/bundles:ro \
  hoike serve --config /etc/hoike/hoike.toml

Build individual crates

If you only need the bundle library (for example, to integrate ahu into another tool):

cargo build --release -p ahu

Or just the CLI without gossip support:

cargo build --release -p hoike-cli --no-default-features

Next steps

With hoike and ahu installed, proceed to Your First Bundle to create a signed ahu bundle from a test CA.

Your First Bundle

This walkthrough creates a test CA, generates a CRL, signs an ahu bundle, and inspects the result. By the end you will have a working bundle ready to serve OCSP responses.

1. Generate a test CA and certificates

Use OpenSSL to create a minimal CA for testing. In production you would use your organization’s existing CA infrastructure.

mkdir -p /tmp/hoike-demo && cd /tmp/hoike-demo

# Create a CA key and self-signed certificate
openssl ecparam -name prime256v1 -genkey -noout -out ca.key
openssl req -new -x509 -key ca.key -out ca.crt -days 365 \
  -subj "/CN=Demo Issuing CA/O=Hoike Test"

# Create an OCSP signing key and certificate
openssl ecparam -name prime256v1 -genkey -noout -out ocsp.key
openssl req -new -key ocsp.key -out ocsp.csr \
  -subj "/CN=Demo OCSP Signer/O=Hoike Test"
openssl x509 -req -in ocsp.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out ocsp.crt -days 365 \
  -extfile <(echo "extendedKeyUsage=OCSPSigning")

# Issue a few end-entity certificates
for i in 1 2 3; do
  openssl ecparam -name prime256v1 -genkey -noout -out "ee${i}.key"
  openssl req -new -key "ee${i}.key" -out "ee${i}.csr" \
    -subj "/CN=server${i}.example.com/O=Hoike Test"
  openssl x509 -req -in "ee${i}.csr" -CA ca.crt -CAkey ca.key \
    -CAcreateserial -out "ee${i}.crt" -days 180
done

2. Create a CRL

Revoke one certificate and generate a CRL that hoike will consume:

# Set up a minimal CA database
touch index.txt
echo '01' > crlnumber

# Create an openssl.cnf for CRL generation
cat > openssl.cnf <<'EOF'
[ca]
default_ca = demo_ca

[demo_ca]
database       = ./index.txt
crlnumber      = ./crlnumber
default_md     = sha256
default_crl_days = 30
EOF

# Revoke ee3
openssl ca -config openssl.cnf -revoke ee3.crt \
  -keyfile ca.key -cert ca.crt

# Generate the CRL
openssl ca -config openssl.cnf -gencrl \
  -keyfile ca.key -cert ca.crt -out ca.crl

3. Create a good-serials file

hoike needs to know which serial numbers should be marked as “good.” Extract the serial numbers from the non-revoked certificates:

for i in 1 2; do
  openssl x509 -in "ee${i}.crt" -noout -serial | cut -d= -f2
done > good-serials.txt

cat good-serials.txt

4. Sign an ahu bundle

Now use hoike sign to produce the bundle:

hoike sign \
  --ca demo-ca \
  --crl ca.crl \
  --good-serials good-serials.txt \
  --signing-key ocsp.key \
  --sig-alg ecdsa-p256 \
  --certid-compat dual \
  --epoch 1 \
  -o demo-ca.ahu

This reads the CRL for revocation data, marks the serials in good-serials.txt as good, signs each OCSP response with the signing key, and packages everything into demo-ca.ahu.

Note: --signing-key or --demo-key is required. hoike refuses to sign without an explicit key source.

Flag summary:

FlagValueMeaning
--cademo-caLabel for this CA scope in the bundle
--signing-keyocsp.keyPKCS#8 signing key file (or use --demo-key for testing)
--sig-algecdsa-p256Signature algorithm (also: ml-dsa-44, ml-dsa-65, ml-dsa-87)
--certid-compatdualProduce both SHA-256 and SHA-1 CertID entries
--epoch1Monotonic epoch number for anti-rollback
--issuerca.crtIssuer certificate (DER) for automatic CertID computation
--seal-keyseal.keyPKCS#8 key for CMS bundle seal (separate from signing key)
--dual-algml-dsa-87Produce a dual-algorithm bundle alongside --sig-alg
--pq-signing-keypq.keyPKCS#8 PQ signing key (required with --dual-alg)

5. Inspect the bundle

Use ahu inspect to examine the bundle metadata:

ahu inspect demo-ca.ahu

You should see output showing the manifest (CA label, epoch, entry count, signature algorithm, timestamps) and a summary of the scopes and response counts.

6. Verify the bundle

Run a full verification of the seal, digests, and sort order:

ahu verify demo-ca.ahu

To also verify each individual entry:

ahu verify demo-ca.ahu --entries

A successful verification confirms that the bundle has not been tampered with and that all entries are correctly signed and ordered.

What you have now

  • demo-ca.ahu – a signed ahu bundle containing pre-signed OCSP responses for three certificates (two good, one revoked)
  • The bundle is self-describing: it carries all the metadata an edge node needs to serve responses without any external configuration

Next steps

Head to Starting the Responder to serve these responses over HTTP.

Starting the Responder

This guide picks up from Your First Bundle. You will create a minimal configuration, validate it, start the responder, and test it with an OpenSSL OCSP client.

1. Create a configuration file

Create a minimal hoike.toml for edge mode (serving pre-signed responses):

[server]
mode        = "edge"
listen      = "0.0.0.0:2560"
max_request = 8192

[storage]
bundle_dir = "/tmp/hoike-demo/bundles"
state_db   = "/tmp/hoike-demo/state"
max_chain  = 24

[[ca]]
label          = "demo-ca"
bundle_file    = "/tmp/hoike-demo/bundles/demo-ca.ahu"
nonce_policy   = "ignore"
completeness   = "authoritative-complete"

Set up the directories and move the bundle into place:

mkdir -p /tmp/hoike-demo/bundles /tmp/hoike-demo/state
cp /tmp/hoike-demo/demo-ca.ahu /tmp/hoike-demo/bundles/

Save the configuration as /tmp/hoike-demo/hoike.toml.

2. Validate the configuration

Before starting the server, run hoike check to validate the configuration, bundle integrity, and connectivity:

hoike check --config /tmp/hoike-demo/hoike.toml

This verifies:

  • The configuration file parses correctly
  • All referenced bundle files exist and pass seal verification
  • The storage directories are accessible
  • Gossip seeds (if configured) are reachable

Fix any reported issues before proceeding.

3. Start the responder

Launch the OCSP responder:

hoike serve --config /tmp/hoike-demo/hoike.toml

You should see log output indicating the server is listening on port 2560 and has loaded the demo-ca bundle. The server is now ready to accept OCSP requests.

To run in the background:

hoike serve --config /tmp/hoike-demo/hoike.toml &

4. Test with OpenSSL

Use openssl ocsp to query the responder for the status of one of the issued certificates:

# Query status of a good certificate
openssl ocsp \
  -issuer /tmp/hoike-demo/ca.crt \
  -cert /tmp/hoike-demo/ee1.crt \
  -url http://localhost:2560 \
  -resp_text

You should see a response with status good.

Now query the revoked certificate:

# Query status of the revoked certificate
openssl ocsp \
  -issuer /tmp/hoike-demo/ca.crt \
  -cert /tmp/hoike-demo/ee3.crt \
  -url http://localhost:2560 \
  -resp_text

This should return a response with status revoked, including the revocation time from the CRL.

5. Test with hoike query

hoike includes a built-in diagnostic client. Query the responder directly:

# Get the issuer hashes (you'll need these for the query)
ISSUER_NAME_B64=$(openssl x509 -in /tmp/hoike-demo/ca.crt -outform DER | openssl dgst -sha256 -binary | base64)
ISSUER_KEY_B64=$(openssl x509 -in /tmp/hoike-demo/ca.crt -noout -pubkey | openssl pkey -pubin -outform DER | tail -c +25 | base64)

# Query status of ee1
SERIAL=$(openssl x509 -in /tmp/hoike-demo/ee1.crt -noout -serial | cut -d= -f2)
hoike query \
  --url http://localhost:2560 \
  --serial "$SERIAL" \
  --issuer-name-b64 "$ISSUER_NAME_B64" \
  --issuer-key-b64 "$ISSUER_KEY_B64"

6. Test with curl

OCSP also supports HTTP GET with a base64-encoded request in the URL path. For a quick connectivity check:

# Health check (if supported)
curl -s http://localhost:2560/health

Understanding the response path

When the edge server receives an OCSP request, it:

  1. Parses the request to extract the CertID (issuer name hash, issuer key hash, and serial number)
  2. Looks up the CertID in the ahu bundle’s sorted index via binary search
  3. Returns the pre-signed response bytes directly from the memory-mapped bundle

There is no cryptographic work at request time. The response was fully signed during the hoike sign step. The edge node is keyless.

Stopping the server

# If running in the foreground, press Ctrl+C
# If running in the background:
kill %1

Admin Web UI

If the admin API is configured, you can monitor the responder via a browser. Add to your hoike.toml:

[server.admin]
session_ttl_secs = 3600

[[server.admin.operators]]
name          = "admin"
password_hash = "$2b$12$..."   # bcrypt hash of your password
role          = "administrator"

[server.webui]
static_dir = "/path/to/hoike/webui/dist"

Then visit http://localhost:2560/ui/ to access the dashboard showing bundle status, CA health, rotation status, and more.

Next steps

You now have a working OCSP responder serving pre-signed responses. From here you can:

Configuration Reference

hoike is configured with a single TOML file, loaded once at startup. The default path is /etc/hoike/hoike.toml; override it with --config:

hoike serve --config /path/to/hoike.toml

Configuration is read from the file only — there is no environment-variable layering or HOIKE_* override mechanism. The only two values that may come from the environment are named by the config: the HSM PIN (signing_key.pin_env) and the directory bind password (source.bind_password_env). Each names an environment variable to read; no other key has an environment override.

Every table below is parsed with deny_unknown_fields: an unrecognized or misspelled key is a hard startup error, not a silently ignored value. Run hoike check --config <file> to validate a file before deploying it.


[server]

Top-level server settings that control the process mode, listener, and request limits.

KeyTypeDefaultDescription
modestring"edge"Operating mode: "signer", "edge", or "combined". See Signer, Edge, and Combined mode pages.
listenstring"0.0.0.0:2560"Socket address for the plaintext OCSP HTTP listener. Port 2560 is the IANA-assigned port for OCSP over HTTP. The OCSP data plane is plaintext by design — every response is signed end to end.
max_requestinteger8192Maximum OCSP request body size in bytes. Protects against oversized or malformed requests.
admin_listenstring—Dedicated listener for the admin API and web UI, e.g. "127.0.0.1:2561". When unset the admin API rides listen for backward compatibility and hoike check warns. See TLS and Mutual TLS.
admin_tlstable—{ cert, key, client_ca } for the admin listener. Requires a build with --features tls and admin_listen set; startup fails otherwise. client_ca enables mutual TLS.
metrics_listenstring—Dedicated listener for Prometheus /metrics, e.g. "127.0.0.1:9184". Requires --features metrics to expose data; otherwise returns 503.
metrics_tlstable—{ cert, key, client_ca } for the metrics listener. Requires --features tls and metrics_listen.
admintable—Operator accounts and session settings; see [server.admin].
webuitable—{ static_dir } to serve the web UI from a directory instead of the embedded build (--features embed-webui). Omit to disable the UI.
[server]
mode           = "edge"
listen         = "0.0.0.0:2560"
max_request    = 8192
admin_listen   = "127.0.0.1:2561"
metrics_listen = "127.0.0.1:9184"

[server.admin_tls]
cert      = "/etc/hoike/tls/admin.crt"
key       = "/etc/hoike/tls/admin.key"
client_ca = "/etc/hoike/tls/mgmt-ca.pem"

[server.admin]

KeyTypeDefaultDescription
session_ttl_secsinteger3600Fixed lifetime of a login session, in seconds. There is no idle timeout.
operatorsarray of tables[]Named operator accounts. Each has name, password_hash (bcrypt), and role ("viewer", "operator", or "administrator"; default "viewer").
[server.admin]
session_ttl_secs = 900

[[server.admin.operators]]
name          = "alice"
password_hash = "$2b$12$…"
role          = "administrator"

There are no built-in accounts. See Admin API and RBAC for roles and login limits.

Mode validation

mode determines which code paths are active:

  • signer — reads revocation sources, produces ahu bundles, does not serve OCSP queries.
  • edge — serves pre-signed responses from bundles, holds no private keys.
  • combined — runs both signer and edge in one process.

hoike validates mode-specific constraints at startup. signer and combined require every [[ca]] to have both a source and a signing_key. nonce_policy = "live" is only meaningful where a signing key is present.


[storage]

Paths and limits for bundle storage and persistent state.

KeyTypeDefaultDescription
bundle_dirstringrequiredDirectory where ahu bundles are stored. The signer writes here; the edge reads from here.
state_dbstring"/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_chaininteger24Maximum number of delta bundles in a chain before the edge demands a full bundle.
seal_trust_anchorsarray of strings—Paths to DER/PEM CA certificates. A bundle seal is accepted if its signer certificate was directly issued by one of these anchors.
seal_signer_pinsarray of strings—Paths to exact seal-signer certificates (PEM or DER). A seal is accepted if its certificate matches one of these byte for byte.
seal_authorizationsarray of tables[][[storage.seal_authorizations]] entries with producer_id, issuer_key_hash, and signer_sha256 restricting which trusted signer may seal which scope. When any entry exists, every scope needs a matching one.

When neither seal_trust_anchors nor seal_signer_pins is set, seal enforcement is disabled and bundles load with a warning. Every edge that receives bundles from another machine must set one of them. See Seal Trust Policy.

[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"
max_chain  = 24

Operational note: Back up state_db alongside your bundle directory. If state_db is 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.

KeyTypeDefaultDescription
enabledbooleanfalseEnable or disable gossip. Set to false for air-gap/enclave deployments. See Air-Gap Deployments.
bindstring"0.0.0.0:7946"UDP/TCP address for the SWIM protocol listener.
seedsarray of strings[]Initial seed nodes for cluster join. Format: "hostname:port".
identity_keystring—Path to this node’s Ed25519 PKCS#8 private key. When set, generation and urgent-revocation broadcasts from this node are signed.
peer_identitiestable{}Map of peer node_name → path of that peer’s Ed25519 public key. When non-empty (enforcing mode), unsigned, forged, or misattributed broadcasts are dropped before re-propagation. When empty (permissive mode), unsigned broadcasts are accepted.
peer_keysarray—Rejected. A non-empty value fails startup with an error directing you to peer_identities.
node_namestring$HOSTNAME or "hoike-node"Node identifier used in membership and as the key in peers’ peer_identities maps. Must be unique in the fleet.
[gossip]
enabled      = true
bind         = "0.0.0.0:7946"
seeds        = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip/edge-01.key"
node_name    = "edge-01"

[gossip.peer_identities]
"edge-02"  = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"

Signing authenticates broadcast origin; SWIM liveness traffic (pings/acks) is not authenticated and nothing on the gossip channel is encrypted. Gossip never carries certificate status data.

Disabling gossip

For air-gap or single-node deployments, disable gossip entirely:

[gossip]
enabled = false

When gossip is disabled, bundles must be delivered out-of-band (removable media, admin API upload, or a scheduled file copy). See Air-Gap Deployments.


[[ca]]

Each [[ca]] section configures one CA whose certificates this responder handles. hoike supports multiple [[ca]] sections for multi-CA deployments. Requests are routed to the correct CA by issuerKeyHash (and issuerNameHash) lookup. See Multi-CA Routing.

Identity and routing

KeyTypeDefaultDescription
labelstringrequiredUnique, non-empty label for this CA. Used in logs, metrics, and bundle filenames, so it may not be ., .., or contain / or \.
bundle_filestring—Explicit path to this CA’s ahu bundle. When omitted, the edge locates the bundle within bundle_dir by label.
issuer_name_hashstring (hex)—Hex-encoded issuerNameHash for explicit routing. When absent it is extracted from the loaded bundle manifest.
issuer_key_hashstring (hex)—Hex-encoded issuerKeyHash for explicit routing. When absent it is extracted from the loaded bundle manifest.
sourcetablerequired for signer/combinedRevocation data source. See Source types.

Signer identity inputs

Signer and combined nodes need the issuer DN and public key to compute each response’s CertID. Supply them base64-encoded; they are decoded on load.

KeyTypeDefaultDescription
issuer_name_der_b64string (base64)—DER of the issuer Distinguished Name.
issuer_key_bytes_b64string (base64)—Raw issuer public-key bytes.

Signing key

The [ca.signing_key] table configures how OCSP responses are signed. Required for signer and combined modes. Three types:

File-based (PKCS#8):

[ca.signing_key]
type = "file"
path = "/etc/hoike/ocsp-signing.key"  # PKCS#8 PEM or DER

PKCS#11 HSM (requires --features pkcs11):

[ca.signing_key]
type        = "pkcs11"
module      = "/usr/lib/libCryptoki2_64.so"   # Vendor PKCS#11 library
token_label = "hoike-partition"                # Token/partition name
key_label   = "ocsp-signing"                   # CKA_LABEL of the signing key
pin_env     = "HOIKE_HSM_PIN"                  # Read PIN from env var
# Omit pin/pin_env and hoike prompts interactively at startup

Demo key (testing only — refuses to run in production intent):

[ca.signing_key]
type = "demo"
KeyTypeDefaultDescription
sig_algstring"ecdsa-p256"Signature algorithm: "ecdsa-p256", "ml-dsa-44", "ml-dsa-65", or "ml-dsa-87". Any other value is a startup error. nonce_policy = "live" is not yet supported with ML-DSA.
responder_certstring—Path to the delegated OCSP signing certificate (DER or PEM). Embedded in each BasicOCSPResponse.certs per RFC 9919 §3.2.2. When set, ResponderID uses the cert’s SPKI key hash.
seal_keystring—Path to a PKCS#8 key for CMS bundle seal signing. SHOULD differ from the OCSP signing key. Falls back to the signing key (with a warning) when absent. See Seal Trust Policy.
seal_certstring—Path to the seal signer’s certificate.

CertID compatibility

KeyTypeDefaultDescription
certid_compatstring"dual"CertID hash coverage baked into produced bundles. "dual" indexes each response by both SHA-256 and SHA-1 issuerKeyHash (for clients that still send SHA-1) — this doubles the manifest entry count. "sha256" indexes by SHA-256 only; "sha1" by SHA-1 only (not recommended). Any other value is a startup error.

Nonce handling

KeyTypeDefaultDescription
nonce_policystring"ignore""ignore" omits the nonce from responses (appropriate for pre-signed). "forward" proxies nonce-bearing requests to a signer. "live" signs a fresh response with the client’s nonce on every request (needs a signing key). See Nonce Policies.
forward_tostring—URL of the signer to forward nonce-bearing requests to. Required when nonce_policy = "forward". Must be https:// unless forward_insecure is set; redirects are not followed.
forward_insecurebooleanfalsePermit an http:// forward_to target. Lab use only; logged at startup.
forward_castring—Accepted but not yet applied to the outbound client: the forward target is validated against the system trust store. Install a private CA system-wide instead. hoike check prints this caveat.

Timing and batch production

All timing keys are integer seconds, matching session_ttl_secs.

KeyTypeDefaultDescription
validity_secsinteger86400Response validity window (nextUpdate − thisUpdate), in seconds. Determines how long a cached response remains valid.
batch_intervalinteger3600How often (seconds) the signer produces a new batch. The signer outage budget is roughly validity_secs − batch_interval: if the signer is down longer, edges begin serving expired responses.
jitter_secsinteger7200Upper bound of randomized jitter added to nextUpdate so a fleet’s responses do not all expire simultaneously (thundering-herd avoidance). Bounded by the source’s own nextUpdate.
max_age_fractionfloat0.5Fraction of a response’s validity window advertised as the edge’s HTTP Cache-Control: max-age. Must be in the range (0, 1]; any other value is a startup error.
urgent_revocationbooleantrueWhen true, the signer produces an off-cycle bundle immediately on detecting a newly revoked certificate, instead of waiting for the next batch_interval. The off-cycle run emits a signer_generation audit event with trigger = "urgent".
archive_cutoff_secsinteger0 (disabled)Drop entries for certificates that expired more than this many seconds ago, bounding bundle size. Requires per-certificate notAfter, which only the 389 DS syncrepl source supplies — it is a no-op for CRL sources, and hoike check warns if you set it on a CRL-backed CA. Entries with unknown expiry are never dropped, so a revoked certificate can never silently degrade to “unknown”.

Completeness

KeyTypeDefaultDescription
completenessstring"partial"Declares whether the producer asserts a complete directory enumeration for this CA. "partial" (default) means the bundle covers only the certificates it lists. "authoritative-complete" may only be produced from a proven full directory snapshot (389 DS syncrepl after a complete refresh); it is metadata on the bundle, not a serve-time switch.

Serve-time semantics. Regardless of completeness, a serial with no entry in the loaded bundle is answered unauthorized — the edge never fabricates a good for an unknown serial. completeness governs which bundles the signer is permitted to stamp as authoritative-complete, not whether the edge invents statuses.

Source types

The [ca.source] table specifies where revocation data comes from.

CRL source (implemented):

[ca.source]
type        = "crl"
path        = "/var/lib/hoike/crls/enterprise.crl"
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt"
FieldTypeDescription
typestringMust be "crl".
pathstringPath to the CRL file (DER or PEM). Re-read at each batch interval and on an admin-triggered signing run; there is no file watcher.
issuer_certstringCertificate of the CRL issuer, used to verify the CRL signature and issuer binding. Independently provisioned trusted configuration, not discovered from the CRL.

Only complete, direct CRLs signed with ECDSA P-256, RSA PKCS#1 v1.5 (SHA-256/384/512), or ML-DSA are accepted. CRL sources carry no per-certificate expiry, so archive_cutoff_secs has no effect on them. See Revocation Sources.

Dogtag syncrepl source (requires --features dogtag-sync):

[ca.source]
type              = "dogtag-sync"
ldap_url          = "ldaps://ds.pki.example:636"
base_dn           = "ou=certificateRepository,ou=ca,o=pki-iot-ca-CA"
bind_dn           = "uid=hoike-reader,ou=people,o=pki-iot-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls               = "ldaps"
ca_cert           = "/etc/hoike/tls/ds-ca.pem"
FieldTypeDescription
typestringMust be "dogtag-sync".
ldap_urlstringLDAP URL for the Dogtag 389 DS instance.
base_dnstringSearch base for the certificate repository.
bind_dnstringBind DN (default cn=Directory Manager).
bind_passwordstringBind password (prefer bind_password_env).
bind_password_envstringEnv var containing the bind password.
cookie_pathstringPath to checkpoint the sync cookie (default: state_db-relative). Population and cookie are checkpointed together.
filterstringLDAP filter (default (objectClass=certificateRecord)).
tlsstring"ldaps", "starttls", or "none" (default, for backward compatibility). Use ldaps or starttls in production; StartTLS upgrades before the bind.
ca_certstringPEM CA bundle used to validate the directory server’s certificate instead of the system roots.

This source uses RFC 4533 Content Synchronization (syncrepl). It enumerates all issued certificates, supplies each certificate’s notAfter (enabling archive_cutoff_secs), and — after a proven complete refresh — enables authoritative-complete bundles.

Key rotation

KeyTypeDefaultDescription
key_rotation.renew_before_daysinteger7Days before cert expiry to trigger a rotation warning.
key_rotation.check_interval_hoursinteger1Hours between rotation checks.
key_rotation.rotation_commandstring—Shell command to execute when rotation is needed. Receives the CA label and cert path as arguments.
[ca.key_rotation]
renew_before_days    = 7
check_interval_hours = 1
rotation_command     = "/usr/local/bin/renew-ocsp-cert.sh"

Full example (signer with HSM)

[[ca]]
label                = "enterprise-issuing-01"
nonce_policy         = "live"
completeness         = "authoritative-complete"
issuer_name_der_b64  = "MEUx…"   # DER of the issuer DN, base64
issuer_key_bytes_b64 = "A0IA…"   # issuer public-key bytes, base64

[ca.source]
type              = "dogtag-sync"
ldap_url          = "ldaps://ds.pki.example:636"
base_dn           = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls               = "ldaps"

[ca.signing_key]
type           = "pkcs11"
module         = "/usr/lib/libCryptoki2_64.so"
token_label    = "hoike-partition"
key_label      = "ocsp-signing"
pin_env        = "HOIKE_HSM_PIN"
sig_alg        = "ecdsa-p256"
responder_cert = "/etc/hoike/ocsp-signing.pem"

[ca.key_rotation]
renew_before_days = 7
rotation_command  = "/usr/local/bin/renew-ocsp-cert.sh"

Validation rules

hoike validates the configuration at startup (and under hoike check) and exits with a descriptive error if any rule is violated. Beyond deny_unknown_fields (any unknown key fails), the enforced rules are:

RuleError (abridged)
admin_tls or metrics_tls set on a binary built without --features tlsTLS configured but this binary was built without the tls feature
admin_tls set without admin_listenadmin_tls requires admin_listen; refusing plaintext management fallback
metrics_tls set without metrics_listenmetrics_tls requires metrics_listen
gossip.enabled with a non-empty peer_keysreplace gossip.peer_keys with peer_identities …
Empty, reserved, path-bearing, or duplicate labelCA labels must be unique nonempty file names
forward_to not https:// (and not forward_insecure + http://)forward_to requires https:// (or explicit forward_insecure for http://)
Invalid sig_alginvalid sig_alg … expected one of: ecdsa-p256, ml-dsa-44, ml-dsa-65, ml-dsa-87
ML-DSA sig_alg with nonce_policy = "live"nonce_policy=live is not yet supported with … signing
Invalid certid_compatinvalid certid_compat … expected one of: dual, sha256, sha1
max_age_fraction outside (0, 1]invalid max_age_fraction … must be in the range (0, 1]
signer/combined [[ca]] missing sourcehas no source configured, required for … mode
signer/combined [[ca]] missing signing_keyhas no signing_key configured, required for … mode
signer/combined with no [[ca]]… mode requires at least one [[ca]] with a source

Complete annotated example

# /etc/hoike/hoike.toml — Edge node serving two CAs with gossip

[server]
mode        = "edge"           # Keyless serving from pre-signed bundles
listen      = "0.0.0.0:2560"   # IANA-assigned OCSP port, plaintext by design
max_request = 8192

[storage]
bundle_dir = "/var/lib/hoike/bundles"   # Where ahu bundles are read from
state_db   = "/var/lib/hoike/state"     # Epoch high-water marks — MUST persist across restarts
max_chain  = 24
seal_trust_anchors = ["/etc/hoike/trust/producer-ca.pem"]  # Admit only sealed bundles from this CA

[gossip]
enabled      = true
bind         = "0.0.0.0:7946"
seeds        = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip/edge-01.key"
node_name    = "edge-01"

[gossip.peer_identities]
"edge-02"  = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"

# Enterprise issuing CA — CRL-based, pre-signed responses
[[ca]]
label          = "enterprise-issuing-01"
nonce_policy   = "ignore"        # Pre-signed — nonce omitted from responses
certid_compat  = "dual"          # Index by both SHA-256 and SHA-1 CertID hashes
validity_secs  = 86400           # 24h window; outage budget ≈ 86400 − 3600
batch_interval = 3600            # New batch hourly
jitter_secs    = 7200            # Up to 2h of expiry spread
completeness   = "partial"       # CRL may not list every certificate

[ca.source]
type        = "crl"
path        = "/var/lib/hoike/crls/enterprise.crl"
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt"

# Partner issuing CA — nonces forwarded to a signer
[[ca]]
label            = "partner-issuing-01"
nonce_policy     = "forward"     # Proxy nonce-bearing requests to the signer
forward_to       = "https://signer.pki.example:2560"
certid_compat    = "sha256"      # Partner clients all support SHA-256
validity_secs    = 43200         # 12h
batch_interval   = 1800          # 30m
max_age_fraction = 0.5
completeness     = "partial"

[ca.source]
type        = "crl"
path        = "/var/lib/hoike/crls/partner.crl"
issuer_cert = "/etc/hoike/trust/partner-ca.crt"

Environment variables

hoike reads exactly two values from the environment, both secrets that should not be written to the config file:

Config keyEnvironment variable
signing_key.pin_envthe variable it names, holding the HSM PIN
source.bind_password_envthe variable it names, holding the directory bind password

No other configuration key can be set or overridden from the environment. For per-instance settings in containers, template the config file or mount an instance-specific hoike.toml.

Signer Mode

The signer is the security-critical half of hoike’s split architecture. It holds private signing keys, reads revocation state from configured sources, and batch-produces ahu bundles — self-describing containers of pre-signed OCSP responses that edge nodes serve verbatim.

flowchart LR
    CRL[CRL / Dogtag] -->|revocation state| S[Signer]
    S -->|ahu bundle| G[Gossip / Export]
    G --> E1[Edge 1]
    G --> E2[Edge 2]
    G --> EN[Edge N]

Enabling Signer Mode

Set mode = "signer" in the [server] section:

[server]
mode   = "signer"
listen = "0.0.0.0:2560"

In signer mode, hoike does not serve OCSP responses to clients directly. Its job is to produce ahu bundles and distribute them to edge nodes (via gossip, manual copy, or scheduled fetch).

Revocation Sources

Each [[ca]] section declares a revocation source that the signer polls for certificate status. See Revocation Sources for the full reference.

CRL (implemented)

The CRL adapter reads a DER- or PEM-encoded CRL from a local path:

[[ca]]
label  = "enterprise-issuing-01"
source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }

The signer re-reads the CRL file on every batch cycle. An external process (e.g., cron + curl) is responsible for keeping the CRL file up to date.

Dogtag 389 DS syncrepl (requires --features dogtag-sync)

RFC 4533 Content Synchronization against the Dogtag 389 DS certificate repository. This is the positive issuance source — it enumerates every issued certificate, enabling authoritative-complete bundles where a miss is confirmed “never issued.”

[[ca]]
label  = "dogtag-ca-01"
completeness = "authoritative-complete"

[ca.source]
type             = "dogtag-sync"
ldap_url         = "ldap://ds-iot.cert-lab.local:3389"
base_dn          = "ou=certificateRepository,ou=ca,o=pki-iot-ca-CA"
bind_dn          = "cn=Directory Manager"
bind_password_env = "HOIKE_LDAP_PASSWORD"

The adapter performs an initial full refresh, then uses the sync cookie for incremental updates. Certificate statuses are mapped: VALID→Good, REVOKED→Revoked (with reason/time), REVOKED_EXPIRED→Revoked, EXPIRED/INVALID→skipped.

Batch Production

The signer produces ahu bundles on a recurring schedule controlled by three parameters:

ParameterDefaultDescription
batch_interval1hHow often the signer produces a new bundle
validity24hResponse validity window (nextUpdate − thisUpdate)
jitter2hRandom offset added to thisUpdate to prevent thundering-herd
[[ca]]
label          = "enterprise-issuing-01"
source         = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
batch_interval = "1h"
validity       = "24h"
jitter         = "2h"

Signer Outage Budget

The outage budget is the maximum time the signer can be offline before edge nodes begin serving expired responses:

outage_budget = validity − batch_interval

With defaults: 24h − 1h = 23 hours. This is the single most important number for capacity planning. If the signer goes down, edge nodes continue serving the last bundle until nextUpdate passes.

To increase the outage budget, increase validity — but longer validity means revocation information takes longer to propagate. This is a fundamental trade-off.

Jitter

The jitter parameter adds a random offset (up to the configured duration) to thisUpdate in each response. This prevents all responses in a bundle from expiring at exactly the same instant, which would cause a cache stampede at relying parties.

Signing Configuration

Signing Keys

hoike supports three key sources. The [ca.signing_key] table is required for signer/combined mode.

PKCS#8 file:

[ca.signing_key]
type = "file"
path = "/etc/hoike/ocsp-signing.key"

PKCS#11 HSM (requires --features pkcs11):

[ca.signing_key]
type        = "pkcs11"
module      = "/usr/lib/libCryptoki2_64.so"    # Thales Luna
token_label = "hoike-partition"
key_label   = "ocsp-signing"
pin_env     = "HOIKE_HSM_PIN"

Omit pin and pin_env to prompt interactively at startup (recommended for production). Documented HSM module paths: Thales Luna, Entrust nShield, Utimaco CryptoServer, FutureX Vectera, Kryoptic (testing).

Demo key (testing only — produces a warning):

[ca.signing_key]
type = "demo"

Delegated Signing

When a delegated OCSP signing certificate is configured, it is embedded in every BasicOCSPResponse.certs per RFC 9919 §3.2.2. The ResponderID is computed from the certificate’s SPKI key hash (not the CA’s key).

[[ca]]
label          = "enterprise-issuing-01"
responder_cert = "/etc/hoike/ocsp-responder.pem"

The responder certificate must have id-kp-OCSPSigning EKU and be issued by the CA.

Signature Algorithms

AlgorithmTypeNotes
ecdsa-p256ClassicalDefault; widely supported
ml-dsa-44Post-quantumNIST FIPS 204, security level 2
ml-dsa-65Post-quantumNIST FIPS 204, security level 3
ml-dsa-87Post-quantumNIST 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:

ValueBehavior
dualIndex by both SHA-256 and SHA-1 hashes (default, widest compat)
sha256SHA-256 only (RFC 9654 compliant, modern clients)
sha1SHA-1 only (legacy clients only — not recommended)
certid_compat = "dual"

PKCS#11 / HSM Support

PKCS#11 integration allows the signer to use hardware security modules (HSMs) for key storage and signing operations, including ML-DSA via CKM_ML_DSA. Configure the signing key as a PKCS#11 reference:

[ca.signing_key]
type        = "pkcs11"
module      = "/usr/lib/libCryptoki2_64.so"    # Thales Luna
token_label = "hoike-partition"
key_label   = "ocsp-signing"
pin_env     = "HOIKE_HSM_PIN"

Omit pin and pin_env to prompt interactively at startup (recommended for production). Tested with Kryoptic; documented paths for Thales Luna, Entrust nShield, Utimaco CryptoServer, and FutureX Vectera.

Build with HSM support:

cargo build --release --features pkcs11

Dual-Algorithm Bundles

hoike can produce bundles containing both ECDSA and ML-DSA responses for the same certificates. Clients negotiate the preferred algorithm via RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms.

hoike sign \
  --ca enterprise-ca \
  --crl ca.crl \
  --signing-key ecdsa.key \
  --sig-alg ecdsa-p256 \
  --dual-alg ml-dsa-87 \
  --pq-signing-key ml-dsa.key \
  -o dual.ahu

One bundle, both algorithms, no flag day. A client that prefers ml-dsa-87 gets the ML-DSA response; all others get ECDSA.

Key Rotation

hoike monitors OCSP signing certificate expiry and can execute a renewal command automatically.

[ca.key_rotation]
renew_before_days    = 7
check_interval_hours = 1
rotation_command     = "/usr/local/bin/renew-ocsp-cert.sh"

The signer checks the responder certificate at each batch interval. When the certificate is within renew_before_days of expiry, hoike logs a warning and runs rotation_command. When the certificate has expired, hoike logs an error — responses signed with an expired certificate will be rejected by clients.

CMS Seal

Each ahu bundle is sealed with a CMS SignedData signature (RFC 5652) that binds the manifest, index, and data regions. The seal key must be distinct from the OCSP signing key.

seal_key  = "/etc/hoike/seal-key.p8"
seal_cert = "/etc/hoike/seal-cert.pem"

Seal keys can be ECDSA P-256 or any ML-DSA variant. When seal_trust_anchors is configured in [storage], bundles without a valid seal are rejected on load.

Urgent Revocation

When urgent_revocation = true (the default), the signer produces an off-cycle delta bundle immediately upon detecting a newly revoked certificate — without waiting for the next scheduled batch_interval.

[[ca]]
urgent_revocation = true   # default

This is critical for high-security deployments where the standard batch interval creates an unacceptable revocation propagation delay. The delta bundle is distributed to edges through the normal gossip or pull mechanism.

Set urgent_revocation = false only if your revocation SLA is satisfied by the regular batch interval.

Completeness

The completeness field declares the signer’s knowledge of the CA’s revocation state:

ValueMeaning
authoritative-completeSigner has full revocation knowledge (direct CA access)
partialCRL-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) and bundle_next_update_seconds (gauge) metrics. Alert when bundle_next_update_seconds drops below your outage budget threshold.

  • Backup: The state_db directory 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
  1. An ahu bundle is loaded into memory via mmap.
  2. The sorted index inside the bundle enables O(log n) binary search by CertID.
  3. On a hit, the edge returns the pre-signed response bytes — no cryptographic operations, no key material involved.
  4. On a miss, the edge returns an unauthorized response 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"
KeyPurpose
bundle_dirDirectory where ahu bundle files are stored. Edge watches this directory for new or updated bundles.
state_dbPersists 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:

  1. issuerKeyHash multimap — The CertID’s issuerKeyHash field identifies which CA the request is for. The edge maintains a multimap from issuerKeyHash to loaded bundles, supporting multiple CAs and re-keyed CAs.

  2. 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

PathContentsPersistence
bundle_dirahu bundle filesReplaceable — bundles can be re-fetched from the signer. Use fast local storage for mmap performance.
state_dbEpoch high-water marksMust persist across restarts and redeployments. Loss of state_db disables anti-rollback protection until the next full bundle is loaded.

Sizing

  • bundle_dir: Each bundle is roughly proportional to the number of certificates the CA has issued. A CA with 1 million certificates produces bundles in the tens of megabytes. Plan for 2× headroom to hold both current and in-flight bundles during rotation.
  • state_db: Small — a few kilobytes per CA. The critical requirement is durability, not capacity.

Admin API and Web UI

When [server.admin] is configured, edge nodes expose a REST API at /api/admin/ and an optional web dashboard at /ui/. This provides real-time visibility into:

  • Bundle inventory (per-CA epoch, entry count, freshness)
  • Responder certificate status and expiry
  • Key rotation status
  • Anti-rollback state (epoch high-water marks)
  • Running configuration (sanitized)

See Configuration Reference for setup.

MmapBundle: Zero-Copy Serving

For large-scale deployments (millions of certificates), hoike uses MmapBundle — a zero-copy bundle reader backed by MAP_PRIVATE memory mapping. At 100 million entries (~45 GB with ECDSA), the process uses ~200 MB RSS instead of 45 GB heap, with sub-100ms startup time. Binary search runs directly on the mmap’d index region with zero allocation on the hot path.

Operational Monitoring

Key metrics to watch on edge nodes:

MetricAlert ConditionMeaning
bundle_next_update_seconds< batch_intervalBundle is about to expire — signer may be down or distribution is broken
bundle_load_failuresAny incrementEdge failed to load a bundle — check reason label (rollback, fork, digest, seal)
ocsp_unauthorized_totalSustained spikeClients are requesting certificates the edge doesn’t know about — possible misconfiguration or missing CA bundle
ocsp_request_duration_secondsp99 > 1msLookup should be sub-millisecond; high latency suggests memory pressure or bundle corruption

Example: Full Edge Configuration

[server]
mode        = "edge"
listen      = "0.0.0.0:2560"
max_request = 8192

[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"
max_chain  = 24

[gossip]
enabled      = true
bind         = "0.0.0.0:7946"
seeds        = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip.key"
node_name    = "edge-01"

[[ca]]
label         = "enterprise-issuing-01"
nonce_policy  = "ignore"
certid_compat = "dual"

Combined Mode

Combined mode runs both signer and edge in a single process. It uses the same code paths as separate signer and edge deployments — the signer loop produces ahu bundles, and the edge serving path loads and serves them — all within one binary.

When to Use Combined Mode

Combined mode is appropriate for:

  • Small deployments with a single CA and low request volume
  • Development and testing where simplicity matters more than isolation
  • Proof of concept before committing to a split architecture
  • Single-server environments where running two processes is unnecessary

Combined mode is not recommended when:

  • You need key isolation (signing keys live on the serving node)
  • You require high availability (single point of failure)
  • You need horizontal scaling of the edge tier
  • Your security policy requires the signer to be network-isolated or air-gapped

Configuration

Set mode = "combined" in the [server] section. The configuration must include both signing material (the [[ca]] sections with key references) and storage paths for bundles and state.

[server]
mode   = "combined"
listen = "0.0.0.0:2560"

[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"

[[ca]]
label          = "internal-ca"
source         = { type = "crl", path = "/var/lib/hoike/crls/internal.crl" }
signing        = "ca-direct"
sig_alg        = "ecdsa-p256"
responder_id   = "by-key"
certid_compat  = "dual"
nonce_policy   = "ignore"
validity       = "24h"
batch_interval = "1h"
jitter         = "2h"

Same Code Paths

Combined mode is not a separate implementation. It instantiates the same signer task and the same edge serving logic that run independently in split deployments. This makes combined mode useful for validating your configuration before splitting into a signer + edge topology — if it works in combined mode, it will work when split.

The signer task writes bundles to bundle_dir on its normal schedule. The edge path watches bundle_dir for new bundles and loads them, exactly as a standalone edge would.

Gossip in Combined Mode

Gossip can be enabled in combined mode, but it is rarely useful. Since the signer and edge share a process, bundles are available immediately — there is no fleet to coordinate. If you plan to add standalone edge nodes later, you can enable gossip on the combined node so it acts as a seed for the edges.

[gossip]
enabled  = true
bind     = "0.0.0.0:7946"
seeds    = []
node_name = "combined-01"

Live Nonce Signing

Combined mode supports nonce_policy = "live", which signs fresh OCSP responses on demand with the client’s nonce. Since the combined node holds signing keys, this works without forwarding:

[[ca]]
label        = "internal-ca"
nonce_policy = "live"

The signer looks up the certificate status from its loaded bundle, then builds a fresh response with that status and the client’s nonce in responseExtensions.

Key Rotation

Key rotation monitoring runs automatically in combined mode. Configure [ca.key_rotation] to receive warnings before the responder certificate expires and optionally run a renewal command:

[ca.key_rotation]
renew_before_days    = 7
check_interval_hours = 1
rotation_command     = "/usr/local/bin/renew-ocsp-cert.sh"

Limitations

ConcernImpact
Key exposureSigning keys are on the network-facing node
Single point of failureOne process crash stops both signing and serving
No horizontal scalingCannot add edge replicas without deploying separate edge nodes
No air-gapThe signer cannot be isolated from the network

Migrating to Signer + Edge

When you outgrow combined mode, the migration is straightforward:

  1. Deploy edge nodes. Install hoike with mode = "edge" on one or more edge servers. Point their bundle_dir at a location where they can receive bundles (via gossip, scheduled copy, or shared storage).

  2. Enable gossip. Configure the combined node and the new edges with matching gossip settings. The edges will pull bundles from the combined node.

  3. Verify edge serving. Confirm that edge nodes are loading bundles and serving responses correctly.

  4. Switch the combined node to signer-only. Change mode = "combined" to mode = "signer" on the original node. It will continue producing bundles but stop serving OCSP requests directly.

  5. 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 key
  • serialNumber — 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:

  1. Extract the issuerKeyHash from the CertID.
  2. Look up matching CA(s) in the multimap.
  3. Within the matched CA’s ahu bundle, binary-search the sorted index for the serialNumber.
  4. Return the pre-signed response, or unauthorized if 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:

ValueBehaviorUse case
"dual"Compute both SHA-1 and SHA-256 hashes; index and serve under bothMaximum compatibility (default)
"sha256"SHA-256 onlyModern clients; smaller index
"sha1"SHA-1 onlyLegacy 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

PolicyThroughputReplay protectionKey required on serving nodeNetwork to signer
ignoreHighestNone (relies on short validity)NoNo
forwardHigh (degrades for nonce requests)For nonce-bearing requestsNoYes
liveLowestFullYesN/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 lengthBehavior
0 octetsmalformedRequest — a nonce extension with empty value is invalid
1 – 15 octetsMAY omit nonce from response
16 – 32 octetsMUST be accepted
33 – 128 octetsMAY omit nonce from response
> 128 octetsmalformedRequest

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:

ConditionResult
nonce_policy = "live" on mode = "edge"Startup error — edges have no signing keys
nonce_policy = "forward" without forward_toStartup 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 as ignore for non-nonce requests. Nonce-bearing requests add a network round-trip to the signer plus a signing operation. Latency depends on signer proximity and signing algorithm.
  • live: The signer looks up the CertID status from its loaded bundle (pre-signed response exists → status is known), then builds a fresh SingleResponse with that status and the client’s nonce in responseExtensions, and signs it. This avoids a round-trip to the CA — the signer already has the data. Throughput is bounded by the signing rate (ECDSA P-256: fast; ML-DSA-65: slower).

Revocation Sources

A signer produces bundles from one revocation source per CA. Since 0.2.0 every source is authenticated: a CRL must carry a signature that verifies under an independently configured issuer certificate, and a directory population is bound to the identity of the directory it came from. A source that cannot be authenticated never becomes a bundle.

SourcetypeProvidesBundle completeness it can support
CRL filecrlrevoked serialspartial only
389 DS syncrepldogtag-syncevery issued certificate with statuspartial or authoritative-complete

CRL sources

[[ca]]
label = "enterprise-ca"

[ca.source]
type        = "crl"
path        = "/var/lib/hoike/crl/enterprise-ca.crl"   # DER or PEM
issuer_cert = "/etc/hoike/trust/enterprise-ca.crt"      # required

issuer_cert is trusted configuration, not a certificate discovered alongside the CRL. Its subject and public key must match the CA identity the bundle is scoped to; a mismatch is a configuration error. The standalone hoike sign command takes the same certificate through --issuer.

Verification profile

CheckBehaviour
SignatureMust verify under issuer_cert. Supported: ECDSA P-256 with SHA-256; RSA PKCS#1 v1.5 with SHA-256/384/512 and 2048–8192-bit keys; ML-DSA-44/65/87. RSA-PSS and P-384 are rejected explicitly.
Issuer bindingCRL issuer name must equal the certificate subject; the authority key identifier, when present, must match.
Validity windowthisUpdate must be in the past and nextUpdate in the future, with a small clock-skew allowance. An expired or future-dated CRL is rejected; the previous bundle stays in service.
Delta and indirect CRLsRejected. hoike only consumes complete, direct CRLs.
Critical extensionsUnknown critical extensions cause rejection rather than being ignored.

Freshness and response validity

The signed response window is derived from the source, never the other way round. validity_secs and batch jitter can shorten a response’s nextUpdate but cannot extend it past the CRL’s own nextUpdate. If a CA publishes short-lived CRLs, batch_interval must be shorter still; hoike check warns when the configured interval cannot keep responses fresh.

Why a CRL is always partial

A CRL enumerates revocations; it says nothing about which serials were ever issued. hoike therefore cannot distinguish “issued and good” from “never issued” and marks CRL-derived bundles completeness = "partial". Unknown serials receive an unknown response, never good. Do not set authoritative-complete on a CRL source; the signer refuses it.

389 DS syncrepl sources

The dogtag-sync source (build with --features dogtag-sync) performs RFC 4533 content synchronization against a Dogtag or Red Hat Certificate System certificate repository in 389 Directory Server. The initial refresh loads the full population; later refreshes send a sync cookie and receive only changes.

[ca.source]
type              = "dogtag-sync"
ldap_url          = "ldaps://ds.pki.example.com:636"
base_dn           = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_dn           = "uid=hoike-reader,ou=people,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"          # prefer over bind_password
filter            = "(objectClass=certificateRecord)"
tls               = "ldaps"                        # see TLS and Mutual TLS
ca_cert           = "/etc/hoike/tls/ds-ca.pem"
cookie_path       = "/var/lib/hoike/state/enterprise-ca.sync"

Grant the bind identity read-only access to the repository subtree. hoike requests only cn, serialno, certStatus, revokedOn, revReason, and notAfter; it does not need the certificate blobs. notAfter supplies each certificate’s expiry, which is what makes archive_cutoff_secs effective on this source.

Status mapping

Directory certStatusBundle entry
VALIDgood
REVOKED, REVOKED_EXPIREDrevoked, with revokedOn as the revocation time and revReason as the reason; a missing or unparsable time or reason fails the refresh
INVALID, EXPIREDexcluded from positive issuance (responders answer unknown)
missing or unrecognisederror — the refresh fails and no bundle is published

A missing or unknown status is never treated as good.

Checkpoints

The population and the sync cookie are checkpointed together, atomically, in a file whose name is derived from cookie_path plus the source identity (endpoint, base, filter, bind identity, transport, CA identity — never the password). On restart the checkpoint restores the population and resumes incremental sync. A cookie that belongs to a different source identity, or a legacy cookie-only file from 0.1.x, is ignored and a full refresh runs. Only the directory’s syncRefreshRequired result triggers a full refresh otherwise.

Operational consequences:

  • Size the signer’s storage and refresh window for a full repository load; it will happen after migration and after any source-identity change.
  • Back up and restore the state directory as one unit; the checkpoint and the anti-rollback marks must stay consistent.
  • Run one signer process per state directory and one active signer per CA. hoike serializes within a process but does not fence competing processes.

Pruning expired entries (archive_cutoff_secs)

Because syncrepl delivers each certificate’s notAfter, a directory-backed CA can bound bundle size by dropping long-expired certificates:

[[ca]]
label               = "enterprise-ca"
archive_cutoff_secs = 2592000   # drop entries expired more than 30 days ago

An entry is pruned only when its notAfter is known and notAfter + archive_cutoff_secs is already in the past. Entries with unknown expiry are never dropped, so a revoked certificate can never silently degrade to unknown. This is why the feature is a no-op for CRL sources (a CRL carries no per-certificate expiry) — hoike check warns if you set archive_cutoff_secs > 0 on a CRL-backed CA. The default 0 disables pruning.

authoritative-complete

Because syncrepl delivers positive issuance data, a directory-backed CA may set:

[[ca]]
label        = "enterprise-ca"
completeness = "authoritative-complete"

With this setting a serial that is absent from the population is answered good-less — the responder returns unknown — but the bundle’s manifest asserts that the population is complete, which downstream tooling can use to treat unknown as “not issued”. The assertion is only made when a refresh succeeds; a failed or partial refresh keeps the previous generation and its completeness claim. Set it only when the bind identity’s scope, base DN, and filter demonstrably cover every certificate the CA issues.

Migration from 0.1.x

ChangeAction
issuer_cert is mandatory for CRL sourcesAdd it, or the signer refuses to start
Cookie-only sync files are ignoredExpect one full directory refresh after upgrade
partial is now the default completenessExplicitly set authoritative-complete where it was previously implied
Delta and indirect CRLs no longer acceptedPoint path at the complete CRL

Gossip Configuration

hoike uses the SWIM protocol (implemented via the foca crate) for lightweight, decentralized coordination across edge nodes. Gossip is a notification channel — it never carries OCSP response data and is never authoritative.

What Gossip Provides

Gossip serves three purposes in a hoike deployment:

FunctionDescription
MembershipAutomatic discovery and failure detection of edge nodes in the fleet
Generation announcementsSigner broadcasts when a new bundle generation is available; edges pull the bundle on receipt
Urgent revocation noticesImmediate 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"
KeyTypeDefaultDescription
enabledbooltrueEnable or disable gossip. Set to false for air-gap deployments.
bindstring"0.0.0.0:7946"Address and port to bind the gossip listener.
seedsarray of strings[]Initial contact points for joining the gossip mesh.
identity_keypath—Path to the Ed25519 key used to sign gossip messages.
peer_identitiestable{}Map of node names to Ed25519 public key paths for broadcast verification.
node_namestringhostnameHuman-readable, unique identifier for this node in the mesh.

Seed Configuration

Seeds are the initial contact points a node uses to join the gossip mesh. They are not special — any existing mesh member can serve as a seed.

Recommendations:

  • Configure at least two seeds for redundancy. If the single seed is down when a new node starts, it cannot join the mesh.
  • Seeds do not need to be dedicated infrastructure. Point new nodes at two or three stable, long-lived edge nodes.
  • A node does not need to list every member — once it contacts one seed, SWIM propagates the full membership.
[gossip]
seeds = [
    "edge-a.pki.example:7946",
    "edge-b.pki.example:7946",
    "edge-c.pki.example:7946",
]

Identity Key

Every gossip participant signs its messages with an Ed25519 key. This prevents spoofed announcements from unauthorized nodes.

Generate a key:

hoike keygen --gossip -o /etc/hoike/gossip.key
chmod 600 /etc/hoike/gossip.key

The key file contains the Ed25519 private key. Protect it with appropriate file permissions.

Peer Identities

The peer_identities table maps each peer’s node_name to the path of its Ed25519 public key file:

[gossip.peer_identities]
"edge-02"  = "/etc/hoike/gossip/edge-02.pub"
"signer-1" = "/etc/hoike/gossip/signer-1.pub"

Enforcement modes:

  • Enforcing mode (non-empty peer_identities): Unsigned, forged, or misattributed generation announcements and urgent-revocation broadcasts are dropped before re-propagation.
  • Permissive mode (empty peer_identities): Unsigned broadcasts are accepted for backward compatibility with a mixed or unconfigured fleet.

Note: A non-empty legacy peer_keys array will now cause startup to fail with an error directing you to migrate to peer_identities.

The corresponding public keys must be distributed to all nodes that will verify broadcasts from that peer.

Node Name

node_name is a human-readable identifier for the node, used in logs and diagnostics. It must be unique across the mesh.

If omitted, hoike defaults to the system hostname. In containerized environments where hostnames may collide, set node_name explicitly.

Failure Detection

SWIM detects failed nodes through a three-phase protocol:

  1. Ping: A random member is pinged each protocol period.
  2. Ping-req: If the ping times out, k other members are asked to ping the suspect on behalf of the requester (indirect probe).
  3. Suspect → Confirm: If indirect probes also fail, the node is marked suspect. After a timeout, it is declared failed and removed from the membership list.

Failed nodes stop receiving generation announcements and urgent revocation notices. When they recover, they rejoin via their configured seeds and pull the current bundle.

Security Model

Gossip is never authoritative. It is a hint channel that triggers bundle pulls. All trust decisions are made by verifying the bundle itself.

The security boundaries are:

  • Generation announcements and urgent-revocation broadcasts are signed with the sender’s identity_key. In enforcing mode (non-empty peer_identities), unsigned or incorrectly signed broadcasts are dropped before re-propagation. SWIM liveness traffic (pings/acks) is not authenticated.
  • Bundle validity is verified independently via CMS seal and epoch chain — not via gossip trust. A gossip announcement only says “a new generation exists”; the edge independently verifies every bundle it loads.
  • A compromised gossip node cannot inject bad responses. The worst it can do is trigger unnecessary pull attempts. The pulled bundle must still pass CMS seal verification and anti-rollback checks.
  • Gossip cannot suppress bundles. Edges also poll on a schedule, so even if gossip is disrupted, bundles are eventually loaded.

Network Requirements

Gossip uses UDP on the configured bind port (default 7946).

Firewall rules must allow:

  • Inbound UDP on the gossip port from all mesh members
  • Outbound UDP to all mesh members on their gossip port

In environments with strict firewall policies, you may need to allowlist the gossip port between all edge nodes and the signer. If UDP is blocked entirely, disable gossip and use air-gap mode or scheduled bundle fetching.

Disabling Gossip

For air-gap deployments or environments where gossip is not feasible:

[gossip]
enabled = false

See Air-Gap Deployments for the full air-gap configuration guide.

TLS and Mutual TLS

hoike applies TLS to its management surfaces and inter-component channels, not to the OCSP data plane. The OCSP listener (server.listen) serves plaintext HTTP by design: every response is signed end to end, RFC 6960 clients speak plaintext DER, and a transport wrapper there adds cost without adding a security property.

ChannelDirectionTLSConfigured by
OCSP responsesinbound, server.listennever—
Admin API and web UIinbound, server.admin_listenserver or mutualserver.admin_tls
Prometheus metricsinbound, server.metrics_listenserver or mutualserver.metrics_tls
Nonce forwarding to an upstream responderoutboundrequired (https://)[[ca]].forward_to
389 DS syncreploutboundLDAPS or StartTLS[[ca]].source.tls

TLS support is a build-time feature. Build with --features tls (and dogtag-sync for LDAPS). A binary without the feature refuses to start if any TLS setting is present; it never silently falls back to plaintext.

Admin listener

Bind the admin API and web UI to their own listener and give it a certificate. This is required for a trusted administrative path; without admin_listen, the admin API rides the plaintext OCSP port for backward compatibility and hoike check warns.

[server]
mode         = "edge"
listen       = "0.0.0.0:2560"        # OCSP, plaintext by design
admin_listen = "127.0.0.1:2561"      # or a management-network address

[server.admin_tls]
cert = "/etc/hoike/tls/admin.crt"    # PEM chain, leaf first
key  = "/etc/hoike/tls/admin.key"    # PKCS#8 or SEC1 PEM, mode 0600

Mutual TLS

Set client_ca to require a client certificate. Clients must present a certificate that chains to one of the CAs in the bundle; connections without one are rejected at the handshake.

[server.admin_tls]
cert      = "/etc/hoike/tls/admin.crt"
key       = "/etc/hoike/tls/admin.key"
client_ca = "/etc/hoike/tls/mgmt-ca.pem"   # PEM bundle of client-issuing CAs

Mutual TLS authenticates the connection, not the operator. Operators still log in with POST /session; the client certificate is not yet mapped to an operator identity. Use both: mTLS keeps unauthenticated clients off the listener entirely, and the session login attributes actions to a named operator in the audit log.

Metrics listener

The metrics listener follows the same shape. Leave it bound to a private address or terminate TLS on it:

[server]
metrics_listen = "127.0.0.1:9184"

[server.metrics_tls]
cert = "/etc/hoike/tls/metrics.crt"
key  = "/etc/hoike/tls/metrics.key"

Protocol versions and ciphersuites

The listeners negotiate TLS 1.3 and TLS 1.2 only. TLS 1.0 and 1.1 are never offered. TLS 1.2 requires the Extended Master Secret extension.

Ciphersuites are supplied by the underlying crypto provider (currently aws-lc-rs via rustls). There is no configuration key to narrow them. See FIPS 140-3 Status for the planned move to the operating system’s OpenSSL provider, which will place suite selection under the host crypto policy.

Forward proxy

When a CA’s nonce_policy = "forward", nonce-bearing requests are proxied to forward_to. The target must be https://; hoike check refuses a cleartext URL. Redirects are not followed and upstream response bodies are read with an incremental bound.

[[ca]]
label        = "partner-ca"
nonce_policy = "forward"
forward_to   = "https://ocsp-signer.example.com:2560"

forward_insecure = true permits an http:// target for lab use. It is logged at startup and must never be set in production.

forward_ca caveat. The forward_ca key is accepted but not yet wired into the outbound client. The forward target is validated against the system trust store, so a private CA must be installed system-wide (for example under /etc/pki/ca-trust/source/anchors/ followed by update-ca-trust). hoike check prints the same caveat.

LDAPS and StartTLS for syncrepl

The dogtag-sync source supports three transport modes. The default is "none" for backward compatibility; production deployments should set "ldaps" or "starttls".

[ca.source]
type              = "dogtag-sync"
ldap_url          = "ldaps://ds.pki.example.com:636"
base_dn           = "ou=certificateRepository,ou=ca,o=pki-ca-CA"
bind_dn           = "cn=hoike-reader,ou=people,o=pki-ca-CA"
bind_password_env = "HOIKE_LDAP_PASSWORD"
tls               = "ldaps"                      # "ldaps" | "starttls" | "none"
ca_cert           = "/etc/hoike/tls/ds-ca.pem"   # validate the directory's certificate

With starttls, the upgrade completes before the bind, so the bind password never crosses the wire in cleartext.

What hoike check enforces

ConditionResult
admin_tls or metrics_tls set on a binary built without tlsstartup error
admin_listen unset while server.admin is configuredwarning: admin API on the plaintext OCSP port
forward_to is http:// without forward_insecureerror
forward_insecure = truewarning, logged on every start
forward_ca setwarning: not applied; install the CA system-wide
dogtag-sync with tls = "none"warning: bind credentials cross in cleartext

Certificate requirements

  • Server certificates need serverAuth EKU and a SAN matching the address operators and scrapers connect to.
  • Private keys must be readable only by the hoike user (chmod 0600).
  • Client certificates for mTLS need clientAuth EKU and must chain to a CA in client_ca.
  • hoike does not perform revocation checking on TLS peer certificates. Keep management-CA lifetimes short or rotate client_ca to revoke.

Seal Trust Policy

Every ahu bundle carries a CMS SignedData seal over its manifest and index. The seal is what lets an edge node prove that a bundle came from an authorized signer and was not truncated, reordered, or replayed. Seal verification is cryptographic; deciding which signers to trust is configuration, and that policy lives under [storage].

Without any of the keys below, seal enforcement is disabled: bundles load with a warning and the anti-rollback checks operate on unauthenticated manifest data. That mode is acceptable only when bundle_dir is already trusted end to end (a combined-mode node writing and reading its own bundles). Every edge node that receives bundles from elsewhere must configure a trust policy.

Three mechanisms

KeyWhat it trustsWhen to use
seal_signer_pinsExact certificates (byte-for-byte DER equivalence, supplied as PEM or DER)Small fleets, air-gapped enclaves, or when you want zero PKI dependency on the edge
seal_trust_anchorsA CA, and any seal certificate directly issued by itFleets where seal certificates are renewed under a stable, dedicated CA
seal_authorizationsRestricts an already-trusted signer to specific producers and CA scopesMulti-tenant signers, or any deployment where one signer must not be able to seal another CA’s bundle

The mechanisms compose: a seal is accepted if it verifies under a pin or an anchor, and (when any authorizations are configured) matches an authorization entry for its scope.

[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"

# Option A: explicit pins
seal_signer_pins = [
  "/etc/hoike/trust/seal-signer-2026.crt",
  "/etc/hoike/trust/seal-signer-2027.crt",   # staged for renewal
]

# Option B: CA anchor
seal_trust_anchors = ["/etc/hoike/trust/seal-ca.pem"]

# Optional restriction, applied on top of A or B
[[storage.seal_authorizations]]
producer_id     = "signer-east-1"
issuer_key_hash = "9f3a…"     # hex of the manifest scope hash
signer_sha256   = "b71c…"     # SHA-256 of the DER seal certificate, hex

[[storage.seal_authorizations]]
producer_id     = "signer-east-1"
issuer_key_hash = "e02d…"     # dual SHA-1/SHA-256 scopes need one entry each
signer_sha256   = "b71c…"

Supported profile

The seal verifier implements a bounded certificate profile, not general PKIX path building:

  • Signature algorithms: ECDSA P-256 with SHA-256, ML-DSA-44/65/87.
  • Anchors must carry CA basic constraints and a key usage permitting certificate signing.
  • Seal certificates must be within their validity period and carry an applicable key usage.
  • Only the directly-issued relationship is checked; intermediate chains, policy processing, and unknown critical extensions cause rejection.

Provision dedicated seal certificates that fit this profile. Do not relabel an arbitrary server or code-signing certificate as a seal anchor.

Renewal choreography

Pins are exact, so renewing a seal certificate is a three-step rollout:

  1. Add the new certificate to seal_signer_pins (or ensure it is issued under the existing anchor) on every edge and reload.
  2. Switch the signer to the new key and certificate (seal_key, seal_cert).
  3. After the fleet has loaded at least one generation sealed by the new certificate, remove the old pin.

Reversing steps 1 and 2 will reject every new bundle at the edge until the pin lands.

Signer side

The signer seals with seal_key and seal_cert. Keep the seal key separate from the OCSP signing key; a compromised seal key can forge bundle containers but not OCSP responses, and the reverse. If seal_key is omitted, hoike falls back to the OCSP signing key with a warning. If seal_cert is omitted, hoike generates a self-signed certificate — acceptable for hoike sign --demo-key experiments, never for a fleet.

[[ca]]
label     = "enterprise-ca"
seal_key  = "/etc/hoike/keys/seal.p8"
seal_cert = "/etc/hoike/keys/seal.crt"

What ahu verify proves

ahu verify bundle.ahu --anchor seal-ca.pem checks that the seal is cryptographically valid under the anchor you pass on the command line. It does not consult hoike.toml, so a passing ahu verify says nothing about whether a given edge node will accept the bundle. To test a node’s policy, load the bundle through hoike check --config or watch the bundle_load audit event.

Delta bundles

ahu apply produces an unsigned intermediate by default. To install a delta on a trusting edge, seal it with an authorized signer:

ahu apply base.ahu delta.ahu -o merged.ahu \
  --seal-key /etc/hoike/keys/seal.p8 \
  --seal-cert /etc/hoike/keys/seal.crt \
  --input-signer-pin /etc/hoike/trust/seal-signer-2026.crt

Inputs must themselves verify under the supplied pins; the output is sealed by the given key. Keyless edge nodes cannot perform this step — it belongs on the signer tier or an operator workstation with access to the seal key.

Key Rotation

hoike monitors OCSP signing certificate expiry and can automatically execute a renewal command when the certificate approaches its expiration date.

Configuration

[[ca]]
label          = "enterprise-ca"
responder_cert = "/etc/hoike/ocsp-responder.pem"

[ca.key_rotation]
renew_before_days    = 7
check_interval_hours = 1
rotation_command     = "/usr/local/bin/renew-ocsp-cert.sh"
KeyTypeDefaultDescription
renew_before_daysinteger7Days before cert expiry to trigger the renewal warning and rotation command
check_interval_hoursinteger1Hours between rotation status checks
rotation_commandstring—Shell command to execute when renewal is needed

How it works

At each signer batch interval, hoike:

  1. Parses the responder_cert to extract notBefore and notAfter
  2. Computes expires_in_secs = notAfter - now
  3. Evaluates the rotation status:
StatusConditionAction
Okexpires_in_secs > renew_before_days * 86400Log info: “OCSP signing certificate valid”
RenewSoon0 < expires_in_secs <= renew_before_days * 86400Log warning + execute rotation_command
Expiredexpires_in_secs <= 0Log error: “OCSP signing certificate has EXPIRED”

Rotation command

The rotation_command is executed via sh -c when the certificate enters the RenewSoon window. It receives the CA label as context in the log. Typical implementations:

  • Request a new certificate from the CA via EST or CMP
  • Trigger a certmonger renewal
  • Call an internal API to issue a new OCSP signing certificate
  • Send an alert to the operations team

Admin API

The rotation status for each CA is available via the admin API:

GET /api/admin/rotation

Returns a list of per-CA rotation statuses with expires_in_secs for dashboard display. The admin API also exposes POST /api/admin/rotate/{label} to manually trigger the rotation command.

Certificate requirements

The OCSP signing certificate must:

  • Have the id-kp-OCSPSigning Extended Key Usage
  • Be issued by the CA it serves
  • Include id-pkix-ocsp-nocheck extension (recommended)

hoike validates these properties and logs warnings when they are missing. Use hoike check to verify certificate configuration before deployment.

Web UI

hoike includes a React + PatternFly 6 web dashboard for monitoring and managing the OCSP responder. The UI is served at /ui/ alongside the OCSP protocol endpoints.

Pages

PageDescription
DashboardServer mode, uptime, total entries, bundle count. Per-CA status table with rotation indicators.
BundlesBundle inventory table. Reload button. Detail view per CA. Inspect page for uploaded bundles.
CAsPer-CA list with algorithm, nonce policy, cert expiry, rotation status. Detail view with config and action buttons.
SigningTrigger signing operations (combined/signer mode only). Per-CA sign buttons.
QueryOCSP query form with serial, issuer hashes, and algorithm preference. Displays parsed results.
GossipCluster membership status (when gossip is enabled).
ConfigRead-only view of the running configuration (passwords and keys redacted).

Setup

Development mode (disk serving)

Build the UI and configure hoike to serve from disk:

cd webui
npm install
npm run build    # Produces webui/dist/

Add to hoike.toml:

[server.webui]
static_dir = "/path/to/hoike/webui/dist"

For hot-reload during development:

cd webui
npm run dev      # Starts Vite on http://localhost:9000

The Vite dev server proxies /api/admin to http://localhost:2560.

Production mode (embedded binary)

Build with the embed-webui feature to bake the UI into the binary:

cd webui && npm run build
cd .. && cargo build --release --features embed-webui

Add [server.webui] to your config (without static_dir) to enable the embedded UI:

[server.webui]
# No static_dir — served from embedded binary

Authentication

The webui requires [server.admin] to be configured with at least one operator. The login page authenticates against the admin API and stores a session token in the browser’s sessionStorage.

Role-based access controls which pages and actions are available:

RoleVisible pagesActions
AdministratorAllSign, rotate, reload, view config
OperatorAll except config detailsSign, reload
ViewerDashboard, Bundles, CAs, Query, GossipRead-only

Technology stack

  • React 19 + TypeScript
  • PatternFly 6 (Red Hat’s design system)
  • Vite 6 (build tool)
  • react-router-dom 7 (client-side routing)
  • Pure React state management (useState, useEffect, useContext)

Air-Gap Deployments

hoike supports air-gapped (enclave) deployments where there is no network connectivity between the signer and edge nodes. Bundles are transferred via removable media, and the edge serves responses using byte-identical code — there is no special air-gap binary.

When to Use Air-Gap Mode

Air-gap deployments are appropriate for:

  • Classified networks where no data path exists between the signing environment and the serving environment
  • High-security enclaves with strict network segmentation requirements
  • Compliance regimes that mandate physical separation of key material from internet-facing infrastructure
  • Disaster recovery environments where gossip infrastructure is unavailable

Configuration

Air-gap mode is simply an edge with gossip disabled:

[server]
mode   = "edge"
listen = "0.0.0.0:2560"

[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"

[gossip]
enabled = false

[[ca]]
label          = "enterprise-issuing-01"
certid_compat  = "dual"
nonce_policy   = "ignore"

Everything else is standard edge configuration — the same [[ca]] sections, the same [storage] layout, the same serving behavior.

Bundle Import Workflow

flowchart LR
    A[Signer produces bundle] --> B[Copy to removable media]
    B --> C[Physical transfer]
    C --> D["Verify: ahu verify bundle.ahu"]
    D --> E["Import: hoike import *.ahu"]
    E --> F[Edge serves responses]

Step by Step

  1. Signer produces bundles on the signing network as usual (via batch_interval scheduling).

  2. Copy bundles to removable media. USB drives, optical discs, or any physically transferable storage.

  3. Physically transfer the media to the air-gapped network following your site’s security procedures.

  4. Verify bundles before importing. On the air-gapped edge (or a verification workstation on the air-gapped network):

    ahu verify /media/usb/bundles/*.ahu
    
  5. Import the verified bundles:

    hoike import /media/usb/bundles/*.ahu
    

    The import command copies the bundle files into bundle_dir and triggers a reload.

Verification with ahu verify

Always verify bundles before importing. ahu verify checks three things:

CheckWhat it validates
CMS sealCryptographic signature over the bundle is valid
Epoch chainEpoch number is consistent — no rollback, no fork
Manifest integrityCBOR 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 import from 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:

  1. The signer produces the off-cycle delta bundle as usual
  2. You must physically transfer it to the air-gapped network
  3. 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

EventStored HWMIncoming EpochResult
Load epoch 403940Accepted — HWM updated to 40
Load epoch 414041Accepted — HWM updated to 41
Load epoch 384138Rejected — rollback detected
Load epoch 41 (different digest)4141Rejected — 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:

ReasonConditionSeverity
rollbackNew epoch < stored high-water markCritical — possible replay attack
forkSame epoch, different content digestCritical — duplicate signer or compromise
digestBundle content does not match manifest digestHigh — corruption or tampering
sealCMS signature verification failedHigh — wrong key, tampering, or corruption

The first two (rollback and fork) are security events. The latter two (digest and seal) typically indicate data corruption during transfer, though tampering should not be ruled out. See Seal Trust Policy for bundle admission rules.

state_db Persistence

The state_db directory is where epoch high-water marks live. It must persist across process restarts, container recreations, and node replacements.

[storage]
state_db = "/var/lib/hoike/state"

If state_db is lost, the node loses all high-water marks and becomes vulnerable to rollback attacks until it loads a current-epoch bundle. This is why state_db is a separate path from bundle_dir:

  • bundle_dir can be ephemeral — bundles are replaceable (re-pull from signer or re-import)
  • state_db is 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:

LevelThresholdMeaning
Warning< 4h remainingSigner may be down; investigate
Critical< 1h remainingResponses 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:

ReasonAlert levelAction
rollbackCriticalPossible replay attack. Investigate bundle distribution path immediately.
forkCriticalDuplicate signer or compromise. Identify and shut down the rogue signer.
digestWarningLikely transfer corruption. Re-transfer the bundle.
sealWarningWrong signing key or corruption. Verify signer configuration.

Recovery Procedures

Rollback Detected

  1. 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
  2. Fix the distribution path. Purge stale caches, correct directory pointers.
  3. Produce a new bundle from the authoritative signer. The new epoch will be above the high-water mark and will load successfully.

Fork Detected

  1. Identify the duplicate signer. Check which hosts are running in signer mode for the affected CA.
  2. Shut down the unauthorized signer. Only one signer should be authoritative per CA at any time.
  3. Produce a new bundle from the authoritative signer with the next epoch.
  4. 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

  1. Check for media corruption. Re-download or re-transfer the bundle.
  2. Verify with ahu verify on a trusted workstation to confirm the bundle is intact at the source.
  3. If the source bundle also fails verification, investigate the signer — the signing key may have changed, or the signer may be compromised.

Hardening Guide

This page is the checklist a security reviewer should be handed with a hoike deployment. Each control names the configuration key or platform setting that implements it, and says plainly where hoike relies on the host rather than on itself.

Threat model in one paragraph

An edge node holds no signing keys, so compromising one cannot produce a false good; it can only deny service or replay a still-valid generation until nextUpdate. The signer holds keys and is the asset to protect. The bundle seal, the anti-rollback chain, and signed gossip broadcasts exist to make sure that only an authorized signer can change what edges serve. Everything below either protects the signer, protects the management plane, or makes tampering with bundles detectable.

Build

ControlSetting
Enable TLS supportcargo build --release --features tls — a binary built without it cannot terminate TLS on any listener
Enable HSM signing--features pkcs11; production signing keys must not be files
Reproducible dependency set--locked; Cargo.lock is committed and audited with cargo audit in CI
Do not ship demo pathshoike sign --demo-key and signing_key.type = "demo" exist for tutorials; refuse configurations that use them in production review

Signer tier

ControlSetting
Keys in an HSMsigning_key = { type = "pkcs11", module = "...", token_label = "...", key_label = "...", pin_env = "HOIKE_HSM_PIN" }. A forthcoming release refuses file and demo keys in signer and combined modes unless allow_software_keys = true is set and audited; treat any file-key configuration as non-production now
PIN never in the config fileUse pin_env, or omit both pin and pin_env to be prompted interactively at startup
Separate seal keyseal_key and seal_cert distinct from the OCSP signing key
Authenticated sourcesCRL: issuer_cert required. Directory: tls = "ldaps" or "starttls", ca_cert, bind_password_env
Delegated responder certificateresponder_cert with the id-kp-OCSPSigning EKU, short lifetime, and key_rotation configured so expiry is caught early
No inbound OCSP on the signermode = "signer"; edges serve, the signer only produces
State directoryLocal filesystem with tested fsync/rename semantics; not NFS. One signer process per directory. Backed up as a unit

Edge tier

ControlSetting
Seal trust policy configuredstorage.seal_signer_pins or storage.seal_trust_anchors, plus seal_authorizations where one signer must not seal another CA’s bundle. See Seal Trust Policy
Anti-rollback state persistedstorage.state_db on durable local storage; never delete high-water marks to “fix” a load failure
Read-only bundle mountMount bundle_dir read-only into the container; the edge never writes there
Gossip authenticatedgossip.identity_key on every node, gossip.peer_identities listing every peer by name; an empty map is permissive mode and is not equivalent to authenticated operation
Gossip bound to the fleet networkgossip.bind on an internal interface; gossip carries no status data but does carry generation announcements
Live signing impossiblenonce_policy = "live" on an edge is a startup error; keep it that way

Management plane

ControlSetting
Dedicated admin listenerserver.admin_listen on a management address; never the public OCSP address
TLS on the admin listenerserver.admin_tls = { cert, key }
Mutual TLSserver.admin_tls.client_ca
Metrics privateserver.metrics_listen = "127.0.0.1:9184" or behind metrics_tls; the endpoint is unauthenticated
OperatorsNamed accounts in server.admin.operators, one per person, least role that does the job; viewer for dashboards
Session lifetimesession_ttl_secs at 900 or less for administrator-heavy nodes (default 3600)
Web UIOmit server.webui on nodes where no one will use it; the API still works

Known gaps in this release, tracked for the next: per-account lockout, password complexity, idle timeout, consent banner, HTTP security headers, and operator identity in audit events. Compensate with mutual TLS and network segmentation until they land.

Process and host

ControlSetting
Non-rootThe container image runs as nonroot; on a host, a dedicated hoike system user
File permissionshoike.toml 0640 (it contains password hashes); keys 0600; bundle_dir read-only for the edge user
SecretsEnvironment variables (pin_env, bind_password_env) injected by the platform’s secret mechanism, never on the command line
Timechrony or the platform equivalent; response validity windows and anti-rollback continuity depend on host time
LoggingForward stdout to journald or the platform collector; add audit=info to any custom RUST_LOG. See Audit Logging
FIPS modeDetermined by the host kernel (/proc/sys/crypto/fips_enabled); hoike’s own crypto does not yet run inside the operating system’s validated module — see FIPS 140-3 Status
FirewallEdge: OCSP port (2560) public, gossip port fleet-only, admin and metrics management-only. Signer: no public inbound at all

OpenShift and Kubernetes

  • Run under the restricted-v2 SCC; hoike needs no capabilities, no host paths, and no privilege escalation.
  • Mount hoike.toml and TLS material from Secrets; bundles from a ConfigMap, PVC, or an init container that fetches from the signer, mounted readOnly: true.
  • Give the state directory a PVC with ReadWriteOnce; never share it between replicas.
  • Expose only the OCSP port through the Route or Service; keep the admin port ClusterIP-only and reach it through oc port-forward or a management ingress with mTLS.

Air-gapped enclaves

The same binary and configuration apply. Disable gossip (gossip.enabled = false), import bundles on media into bundle_dir, and rely on the seal trust policy to verify what was imported. The anti-rollback chain still enforces monotonic epochs across imports. See Air-Gap Deployments.

Review checklist

  • Binary built with tls (and pkcs11 on signers)
  • Every signer key in an HSM; no type = "file" or "demo" keys
  • issuer_cert set for every CRL source; LDAPS or StartTLS for every directory source
  • Seal trust policy configured on every edge; ahu verify is not a substitute
  • identity_key and a complete peer_identities map on every gossiping node
  • admin_listen set, admin_tls set, client_ca set
  • metrics_listen bound to loopback or behind TLS
  • One named operator per person; roles reviewed
  • hoike check --config clean, with warnings explained in writing
  • Logs forwarded; rollback and fork audit events alerting

Audit Logging

hoike emits a structured audit log on a dedicated tracing target named audit. Every event is a single structured record at INFO level with an event field and a small set of typed fields; there is no free-text message to parse.

Filter caveat. Audit events pass through the same RUST_LOG filter as everything else. The default filter is info,tower_http=debug, which includes them. If you tighten logging (for example RUST_LOG=warn), add an explicit directive so audit is never silenced: RUST_LOG=warn,audit=info.

Event catalog

eventEmitted byFieldsMeaning
request_rejectededge, OCSP handlerreason, serialAn OCSP request was refused. Today the only reason is unauthorized (request for a CertID under a scope the node is not authorized to serve).
bundle_load_failededge, signer, CLItrigger, reason, ca (when known)A bundle was rejected at load. trigger is one of initial_load, scheduled_reload, admin_reload, on_demand_reload, on_demand_all_reload.
signer_generationsignerca, trigger, epochA new generation was produced and published. trigger is scheduled, on_demand, or on_demand_all.
signer_generation_failedsignerca (when known), trigger, errorProduction failed; the previous generation stays in service.

bundle_load_failed reasons

reasonCause
rollbackEpoch lower than the persisted high-water mark, or a jump larger than max_chain
forkTwo bundles claim the same epoch with different content — duplicate or compromised signer
bundleMalformed container, seal verification failure, or a seal from an unauthorized signer
stateThe anti-rollback state store could not be read or persisted
ioFilesystem error
otherAnything else; the accompanying error field carries detail

rollback and fork are the events a SOC should alert on: both indicate that something other than the authorized signer is trying to influence what the edge serves.

Output and forwarding

Audit events go to the same subscriber as operational logs, on stdout in the default tracing text format:

2026-09-17T14:02:11.318Z  INFO audit: event="bundle_load_failed" trigger="scheduled_reload" reason="rollback" ca="enterprise-ca"

Fields are key="value" pairs after the audit: target marker. A JSON formatter and a journald sink are planned; until then, collectors should key on the literal audit: marker and parse the key="value" pairs.

Under systemd, stdout lands in journald; filter with journalctl -u hoike -o json | jq 'select(.target=="audit")'. In containers, the platform’s log collector picks up stdout. hoike does not rotate, sign, or persist audit records itself — durability and tamper protection are provided by journald permissions, the collector, and the SIEM. Document those controls as part of the deployment; evaluators and STIG reviewers will ask for them.

Coverage and planned additions

The current catalog covers the integrity-critical paths (what was loaded, what was signed, what was refused). It does not yet record:

  • login success, failure, and logout with operator name and source address;
  • the operator behind an admin-triggered reload, sign, or rotate;
  • configuration reload;
  • role-denied requests (403);
  • TLS handshake failures on the admin listener.

These are required by the NIAP audit SFRs (FAU_GEN.1/2) and the Application Security and Development STIG and are scheduled for the next release. Fields will be added, not renamed, so existing parsers keep working.

Metrics are not audit

The Prometheus endpoint (metrics_listen, --features metrics) exposes counters and gauges for capacity and freshness monitoring. It is aggregate and unauthenticated by design and should not be used as an audit source.

Admin API and RBAC

The admin API is a JSON REST surface mounted at /api/admin on the admin listener (server.admin_listen) or, when no admin listener is configured, on the OCSP listener. The web UI at /ui/ is a client of this API and has no capabilities beyond it. See TLS and Mutual TLS for putting the listener behind TLS; do not expose it in cleartext.

Operators and roles

Operators are defined statically in configuration. There are no built-in accounts and no self-registration.

[server.admin]
session_ttl_secs = 3600

[[server.admin.operators]]
name          = "alice"
password_hash = "$2b$12$…"      # bcrypt cost 12; see "Generating a password hash"
role          = "administrator"

[[server.admin.operators]]
name          = "noc"
password_hash = "$2b$12$…"
role          = "viewer"          # default when omitted
RoleCanCannot
viewerRead status, bundles, certificates, rotation state, gossip membership, effective config, anti-rollback state; run OCSP queries; extract a single entry from a bundleChange anything
operatorEverything a viewer can, plus reload bundles, inspect/verify/diff bundles, apply deltas, trigger on-demand signingRotate keys
administratorEverything—

Roles are strictly ordered (viewer < operator < administrator); a route’s minimum role is checked on every request.

Generating a password hash

hoike does not yet ship a hashing subcommand. Any bcrypt tool works; cost 12 is the value the server’s timing decoy assumes:

# Apache htpasswd (httpd-tools)
htpasswd -nbBC 12 "" 'correct horse battery staple' | tr -d ':\n'

# Python
python3 -c 'import bcrypt,getpass; print(bcrypt.hashpw(getpass.getpass().encode(), bcrypt.gensalt(12)).decode())'

Never commit a hash from the documentation examples; the server should be treated as compromised if a documented hash is found in a live configuration.

Session lifecycle

sequenceDiagram
    participant C as Client
    participant A as Admin API
    C->>A: POST /session {name, password}
    A-->>C: 200 {session_token, role, expires_in_secs}
    C->>A: GET /status  (Authorization: Bearer <token>)
    A-->>C: 200 …
    C->>A: DELETE /session
    A-->>C: 204
  • Tokens are 32 random bytes, hex-encoded, presented as Authorization: Bearer <token>.
  • A session lives for session_ttl_secs (default 3600) from login. There is no idle timeout; the lifetime is fixed.
  • Logout (DELETE /session) invalidates the token immediately. Restarting the process invalidates all sessions; the session store is in memory only.
  • The web UI stores the token in browser sessionStorage, which is cleared when the tab closes.

Login limits

Login is deliberately expensive and bounded:

BoundValue
Request body4,096 bytes, 5-second read timeout
Concurrent password verifications4
Process-wide login attempts60 per minute (429 Too Many Requests beyond that)
Active sessions4,096 (login fails with 503 when full; expired sessions are pruned first)
Unknown operatorVerified against a decoy hash so timing does not reveal whether the name exists

There is no per-account lockout and no password-complexity enforcement in this release. Both are planned; see DISA STIG Guidance. Until then, put the admin listener behind mutual TLS and a management network.

Endpoints

All paths are relative to /api/admin. All require a bearer token except POST /session.

Session

MethodPathRoleDescription
POST/session—Log in; body {"name": "...", "password": "..."}
DELETE/sessionanyLog out

Read-only

MethodPathRoleDescription
GET/statusviewerProcess mode, uptime, loaded CAs, generation, freshness
GET/bundlesviewerLoaded bundles per CA with epoch, entry counts, algorithm
GET/bundles/{label}viewerDetail for one CA’s bundle
GET/certsviewerResponder and seal certificates with expiry
GET/rotationviewerKey-rotation monitor state per CA
GET/gossipviewerSWIM membership and last generation announcements
GET/configviewerEffective configuration with secrets redacted
GET/stateviewerAnti-rollback high-water marks
POST/queryviewerRun an OCSP query against the local responder; body carries serial and issuer hashes
POST/bundles/extractviewerReturn one entry from a bundle by CertID

Bundle operations

MethodPathRoleDescription
POST/bundles/reloadoperatorRe-read bundle_dir, applying seal trust and anti-rollback checks
POST/bundles/inspectoperatorParse a bundle and return its manifest
POST/bundles/verifyoperatorVerify a bundle’s seal against the configured trust policy
POST/bundles/diffoperatorCompare two generations
POST/bundles/applyoperatorMaterialize deltas; output is unsigned unless a seal key is available on this node

Signing and rotation (signer or combined mode only)

MethodPathRoleDescription
POST/sign/{label}operatorProduce a new generation for one CA now
POST/sign/alloperatorProduce new generations for every CA
POST/rotate/{label}administratorRun the configured rotation_command and reload key material for one CA

Signing routes share the signer mutex with scheduled production, so an on-demand run never interleaves with a scheduled one.

Errors

Errors are returned as short JSON strings with conventional status codes: 401 missing or expired token, 403 insufficient role, 409 signer busy, 422 malformed input, 429 login rate exceeded, 503 feature not built or capacity exhausted. Error bodies never include stack traces, file paths, or key material.

Audit trail

Bundle loads, signing runs, and rotation attempts emit audit events. Login success and failure are not yet audited and events do not yet carry the operator name; both are tracked for the next release.

Architecture Overview

hoike is built around a single architectural bet: separate signing from serving. The OCSP signing key never touches a machine that handles client traffic. An edge node compromise cannot produce a false “good” response because edge nodes have no signing material – they serve only pre-signed bytes delivered through a verified bundle chain.

The signer/edge split

Traditional OCSP responders combine signing and serving in one process. Every node that handles client requests holds the signing key, which makes each node a high-value target. hoike eliminates this by splitting the work into two roles:

graph LR
    subgraph Signer["Signer (HSM / enclave)"]
        CRL[CRL / 389 DS syncrepl] --> Sign[Batch sign]
        Sign --> Bundle[ahu bundle]
    end
    subgraph Distribution
        Bundle -->|gossip / push / sneakernet| Edge1[Edge 1]
        Bundle -->|gossip / push / sneakernet| Edge2[Edge 2]
        Bundle -->|gossip / push / sneakernet| EdgeN[Edge N]
    end
    subgraph Clients
        C1[OCSP client] -->|HTTP| Edge1
        C2[OCSP client] -->|HTTP| Edge2
    end
ConcernSignerEdge
Signing key accessYes (HSM or file)Never
Client trafficNeverYes
Network exposureMinimal or air-gappedInternet-facing
Cryptographic work at request timeN/AZero
Scaling modelSingle or active-passive pairHorizontal, 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:

TierStateful componentsPersistence
Source (CA)Certificate database, CRLs, revocation recordsAuthoritative – hoike reads but does not modify
SignerBatch position, current epoch, HSM session, signing keyDurable – epoch must advance monotonically
EdgeLoaded working set (mmap’d bundles), epoch marks per CAEphemeral – 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
CratePurposeLicenseKey deps
ahuBundle format read/write/verifyApache-2.0 OR MITder, ciborium, memmap2, zstd
hoike-coreCertID routing, request parsing, config, stateGPL-3.0+ahu, x509-ocsp, der
hoike-signResponse production, CRL parsing, batch signingGPL-3.0+ahu, hoike-core, ml-dsa
hoike-serveraxum HTTP handlers, RFC 9919 headers, admin API with RBAC, React webuiGPL-3.0+hoike-core, axum, tokio
hoike-gossipSWIM membership + generation announcementsGPL-3.0+foca
hoike-cliBinary entry points for hoike and ahuGPL-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:

OffsetLengthContents
04AHU\x00 (ASCII + null)
44Version number (little-endian u32, currently 1)

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:

FieldCBOR typeDescription
producertext stringIdentifier of the signing software (e.g., "hoike-sign/0.2.0")
epochunsigned intMonotonically increasing generation number
scopetext stringCA label identifying which issuer this bundle covers
algorithmtext stringSignature algorithm used for OCSP responses (e.g., "ecdsa-p256", "ml-dsa-65")
entry_countunsigned intNumber of entries in the index
created_attext stringISO 8601 creation timestamp
parent_hashbyte stringSHA-256 of the previous generation’s manifest (null for epoch 1)
base_epochunsigned intFor delta bundles: the epoch this delta applies against
validity_starttext stringthisUpdate for the batch (ISO 8601)
validity_endtext stringnextUpdate for the batch (ISO 8601)

Seal (CMS)

The seal is a CMS (RFC 5652) SignedData structure that covers the concatenation of the manifest, index, and data regions. It binds the entire bundle content to the signer’s identity. See Seal Trust Policy for full admission rules.

For verification, the ahu verify command checks:

  1. The CMS signature is valid against the embedded signer certificate
  2. The signer certificate chains to a trusted CA
  3. 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:

FieldSizeDescription
entry_key32 bytesSHA-256 of the DER-encoded CertID
data_offset8 bytesByte offset into the data region (little-endian u64)
data_length4 bytesLength of the response in the data region (little-endian u32)
flags2 bytesBit flags: MULTI=0x01, ALIAS=0x02, TOMBSTONE=0x04
discriminator2 bytesAlgorithm variant: 0=default (ECDSA), 2=ML-DSA-44, 3=ML-DSA-65, 4=ML-DSA-87

Total record size: 48 bytes.

The discriminator field enables dual-algorithm bundles. A single bundle can contain both ECDSA and ML-DSA responses for the same certificate. The index is sorted by (entry_key, discriminator), and binary_search_preferred resolves the best match for the client’s algorithm preference list.

The index is sorted by entry_key in lexicographic order, enabling O(log n) binary search on the memory-mapped file. For a bundle with 10 million entries, a lookup requires at most 24 comparisons (ceil(log2(10^7))).

Data region

The data region contains raw DER-encoded OCSP responses packed contiguously. Each response is a complete OCSPResponse (RFC 6960) that can be written directly to the HTTP response body with no transformation.

This is the key to hoike’s serving performance: the edge process memory-maps the bundle, binary-searches the index for the entry key, and writes the data region slice directly to the socket. No deserialization, no re-encoding, no allocation.

Delta bundles

A delta bundle contains only the entries that changed since a base epoch. The manifest includes a base_epoch field identifying which full bundle the delta applies against.

Delta structure

graph LR
    Full["Full bundle (epoch N)"] -->|base| Delta["Delta (epoch N+1)"]
    Delta -->|apply| Full2["Full bundle (epoch N+1)"]

A delta bundle uses the same container format but with two differences:

  1. The manifest includes base_epoch pointing to the full bundle
  2. 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:

  1. If the bundle is a full bundle, replace the current working set
  2. If the bundle is a delta, verify that base_epoch matches the currently loaded epoch, then merge:
    • Add new entries
    • Replace updated entries
    • Remove entries with zero-length data
  3. 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:

  1. epoch(N+1) > epoch(N) – monotonic advance
  2. parent_hash(N+1) == SHA-256(manifest(N)) – chain continuity
  3. 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:

EntriesAvg response sizeIndex sizeData sizeTotal
1,000500 B43 KB488 KB~550 KB
100,000500 B4.2 MB47.7 MB~52 MB
1,000,000500 B42 MB477 MB~520 MB
10,000,000500 B420 MB4.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:

  1. Extracts the path segment after the base path
  2. URL-decodes the segment
  3. 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:

CheckRuleFailure
VersionMust be v1 (default)malformedRequest
Request countSingle CertID per request (RFC 9919 Section 4)malformedRequest
Hash algorithmSHA-256 preferred; SHA-1 accepted for compatibilitymalformedRequest if unsupported
Signed requestSignature 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:

PolicyBehavior
rejectReturn malformedRequest if nonce present
ignoreAccept the request but do not echo the nonce
warnLog 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.

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:

EntriesMax comparisons
1,00010
100,00017
1,000,00020
10,000,00024

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
HeaderSourceRFC
Content-TypeAlways application/ocsp-responseRFC 6960 Section 3
Last-ModifiedthisUpdate from the OCSP responseRFC 9919 Section 7.2
ExpiresnextUpdate from the OCSP responseRFC 9919 Section 7.2
ETagHex SHA-256 of the response octetsRFC 9919 Section 7.2
Cache-Controlmax-age derived from nextUpdate minus nowRFC 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

ConditionOCSP response statusHTTP status
Unparseable requestmalformedRequest (1)200
Unknown CAunauthorized (6)200
Unknown serial (authoritative)unauthorized (6)200
Request too largemalformedRequest (1)200
Server errorinternalError (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 thisUpdate and nextUpdate defining its validity period.
  • Revocation propagation has latency. A revoked certificate is not reflected in OCSP responses until the next batch run.

Batch parameters

ParameterDefaultDescription
batch_interval1 hourHow often the signer produces a new generation
validity24 hoursThe nextUpdate - thisUpdate window
jitterDeterministic by entry_keyPer-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:

  1. Uniquely identifies a generation of the working set
  2. Enables anti-rollback checks at the edge
  3. 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:

RuleSpecification
FormatGeneralizedTime (ASN.1)
TimezoneUTC only (Z suffix, never +00:00)
SecondsAlways present (never omit seconds)
Fractional secondsNever used
Example20260115120000Z

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:

ModeBehavior
sha256Produce only SHA-256 CertID entries
sha1Produce only SHA-1 CertID entries (legacy only)
dualProduce one BasicOCSPResponse with two SingleResponse entries: one SHA-1, one SHA-256

Dual mode response structure

In dual mode, each certificate gets a single BasicOCSPResponse containing two SingleResponse entries:

BasicOCSPResponse
  ResponseData
    producedAt: 20260115120000Z
    responses:
      SingleResponse                    # SHA-256 CertID
        certID:
          hashAlgorithm: SHA-256
          issuerNameHash: <SHA-256 of issuer Name>
          issuerKeyHash:  <SHA-256 of issuer SPKI>
          serialNumber:   <serial>
        certStatus: good
        thisUpdate: 20260115120000Z
        nextUpdate: 20260116120000Z

      SingleResponse                    # SHA-1 CertID (compatibility)
        certID:
          hashAlgorithm: SHA-1
          issuerNameHash: <SHA-1 of issuer Name>
          issuerKeyHash:  <SHA-1 of issuer SPKI>
          serialNumber:   <serial>
        certStatus: good
        thisUpdate: 20260115120000Z
        nextUpdate: 20260116120000Z

Both entries share the same status, timestamps, and signature. The bundle index stores two entry keys for this response: one for each CertID hash. Regardless of whether the client sends a SHA-1 or SHA-256 CertID, the same response bytes are returned.

Status outcomes

The signer produces three types of status:

Good

The certificate is known and not revoked. The signer has the serial number in its good-serials list and it does not appear as revoked in the CRL.

certStatus: good

Revoked

The certificate appears in the CRL. The response includes the revocation time and reason:

certStatus: revoked
  revocationTime:  20260110153000Z
  revocationReason: keyCompromise (1)

Revocation reasons are taken directly from the CRL entry’s reasonCode extension. If no reason is present, the reason is omitted (as per RFC 6960).

Unauthorized

The serial number is not in the working set. This means hoike does not have a signed response for this certificate. This occurs when:

  • The serial was never issued by this CA
  • The serial is not in the good-serials list and not in the CRL
  • The bundle does not cover this CA’s issuer key hash
OCSPResponse.responseStatus: unauthorized (6)

Dual-algorithm bundle production

hoike supports producing bundles that contain both ECDSA and ML-DSA responses for the same certificate set. This enables gradual PQC migration without a flag day.

The produce_dual_bundle function takes two signers (classical + post-quantum) and produces a single bundle where each certificate has two index entries distinguished by the discriminator field. Clients negotiate their preferred algorithm via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension.

hoike sign \
  --ca my-ca --crl ca.crl \
  --signing-key ecdsa.key --sig-alg ecdsa-p256 \
  --dual-alg ml-dsa-87 --pq-signing-key ml-dsa.key \
  -o dual.ahu

The CMS seal on a dual-algorithm bundle can use either ECDSA or ML-DSA, configured via --seal-key.

ResponderID

RFC 9919 Section 5 mandates byKey ResponderID, which identifies the responder by the SHA-1 hash of the responder’s public key. hoike uses this form exclusively:

ResponderID ::= CHOICE {
    byKey  [2] KeyHash
}

KeyHash ::= OCTET STRING  -- SHA-1 of responder's SubjectPublicKeyInfo

Post-quantum response sizing

ML-DSA signatures are substantially larger than ECDSA signatures. This affects individual response size, bundle size, and network bandwidth:

Per-response size

AlgorithmSignature sizeResponse size (no cert)Response size (with delegated cert)
ECDSA P-25672 B~500 B~1.2 KB
ECDSA P-384104 B~530 B~1.3 KB
ML-DSA-442,420 B~2.8 KB~5.5 KB
ML-DSA-653,309 B~3.7 KB~7.0 KB
ML-DSA-874,627 B~5.0 KB~9.5 KB

Bundle size at scale (10M certificates)

AlgorithmWith delegated certCA-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

  1. CA-direct signing. The responder certificate in certs within the BasicOCSPResponse is 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.

  2. 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.

  3. Delta distribution. Instead of distributing a full bundle every batch interval, distribute only the changes. For a stable certificate population, delta bundles are orders of magnitude smaller than full bundles. See ahu Bundle Format.

For a detailed analysis, see Post-Quantum Readiness.

Dual-Algorithm Bundles

hoike supports bundles that contain both ECDSA and ML-DSA responses for the same certificate set, enabling gradual post-quantum migration without a flag day.

How it works

A dual-algorithm bundle contains two index entries for each certificate — one pointing to an ECDSA-signed response and one pointing to an ML-DSA-signed response. The entries share the same entry_key (SHA-256 of the CertID) but differ in the discriminator field:

DiscriminatorAlgorithm
0ECDSA P-256 (default)
2ML-DSA-44
3ML-DSA-65
4ML-DSA-87

The index is sorted by (entry_key, discriminator). The lookup function binary_search_preferred takes the client’s algorithm preference list and returns the best available match.

Producing dual bundles

hoike sign \
  --ca enterprise-ca \
  --crl ca.crl \
  --signing-key ecdsa.key \
  --sig-alg ecdsa-p256 \
  --dual-alg ml-dsa-87 \
  --pq-signing-key ml-dsa.key \
  -o dual.ahu

This produces a single bundle where each certificate has both an ECDSA and an ML-DSA response.

Client negotiation

Clients indicate their preference via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension in the OCSP request. hoike parses this extension and resolves the best match.

# Query with PQ preference — returns ML-DSA-87 response
hoike query --url http://localhost:2560 \
  --serial 0A1B2C \
  --issuer-name-b64 "..." \
  --issuer-key-b64 "..." \
  --prefer ml-dsa-87

# Query without preference — returns ECDSA response (default)
hoike query --url http://localhost:2560 \
  --serial 0A1B2C \
  --issuer-name-b64 "..." \
  --issuer-key-b64 "..."

Migration strategy

  1. Phase 1: Generate dual-algorithm bundles alongside your existing ECDSA-only bundles. Clients that don’t send PreferredSignatureAlgorithms continue to receive ECDSA responses.

  2. Phase 2: Update PQ-capable clients to send --prefer ml-dsa-87 (or the appropriate level). They start receiving ML-DSA responses while classical clients are unaffected.

  3. Phase 3: When all clients support ML-DSA, switch to ML-DSA-only bundles (--sig-alg ml-dsa-87 without --dual-alg).

No flag day. One responder, one bundle, both algorithms.

Size implications

Dual-algorithm bundles are roughly 2x the size of single-algorithm bundles, since each certificate has two responses. The ML-DSA responses are significantly larger than ECDSA:

ConfigurationPer-cert sizeBundle (1M certs)
ECDSA only~500 B~520 MB
ML-DSA-87 only~5 KB~5.1 GB
Dual (ECDSA + ML-DSA-87)~5.5 KB~5.6 GB

Delta distribution mitigates the size increase for steady-state operation.

RFC Support Reference

hoike implements or profiles the following IETF standards. This page lists every requirement with its implementation status and the relevant conformance checks.

Standards matrix

RFCTitleRole in hoikeStatus
RFC 6960Online Certificate Status Protocol (OCSP)Base protocol: request/response format, all status values, extensionsFully implemented
RFC 9919Lightweight OCSP Profile for High Volume EnvironmentsPrimary operating profile: pre-production, unauthorized semantics, byKey ResponderID, SHA-256 CertID, HTTP cachingFully implemented
RFC 9654OCSP Nonce ExtensionNonce length validation, rejection rulesFully implemented
RFC 5280Internet X.509 PKI Certificate and CRL ProfileAIA id-ad-ocsp, responder certificate profile, id-pkix-ocsp-nocheckReferenced for certificate validation
RFC 5652Cryptographic Message Syntax (CMS)CMS SignedData seal on ahu bundles (ECDSA P-256 and ML-DSA)Fully implemented
RFC 4533LDAP Content Synchronization Operation389 DS syncrepl source for Dogtag certificate repositoriesFully implemented

RFC 6960 – OCSP base protocol

Request handling

RequirementSectionImplementation
Accept GET and POST methods3.1Both methods handled by axum router
DER-encoded request body for POST3.1Body read and passed to strict DER parser
Base64+URL-encoded request for GETA.1Path segment decoded (URL-decode then base64-decode)
Parse OCSPRequest structure4.1.1x509-ocsp crate with strict DER parsing
Support CertID with OID-identified hash4.1.1SHA-256 (primary) and SHA-1 (compatibility)
Ignore signed requests in pre-signed mode4.1.1Signature field parsed but not validated

Response production

RequirementSectionImplementation
Produce OCSPResponse with responseStatus4.2.1All six status values supported
good – certificate is not revoked4.2.1Generated for serials in good-serials list
revoked – certificate is revoked4.2.1Generated from CRL entries with reason and time
unauthorized – responder has no information4.2.1Returned for unknown serials or CAs
malformedRequest – request is invalid4.2.1Returned for parse failures, profile violations
internalError – server fault4.2.1Returned for unexpected processing errors
BasicOCSPResponse with ResponseData4.2.1Signed during batch production
producedAt timestamp4.2.1Set to batch start time
thisUpdate and nextUpdate per SingleResponse4.2.1Computed from batch window + jitter
ResponderID identification4.2.3byKey form only (per RFC 9919)

Extensions

ExtensionOIDImplementation
Nonce1.3.6.1.5.5.7.48.1.2Validated per RFC 9654. Pre-signed: omitted. live mode: echoed in response. forward: proxied upstream.
id-pkix-ocsp-nocheck1.3.6.1.5.5.7.48.1.5Included in responder certificate profile

RFC 9919 – Lightweight OCSP Profile

This is hoike’s primary operating profile. All requirements are mandatory unless noted.

RequirementSectionImplementation
Pre-produced responses4Core design – batch-signed into ahu bundles. Signers also support on-demand live nonce signing.
Single CertID per request4First CertID answered; remaining silently dropped per profile recommendation
SHA-256 CertID hash algorithm4Default; SHA-1 accepted for compatibility
byKey ResponderID (SHA-1 hash of responder public key)5Only form used
unauthorized for unknown serials5Returned when serial not in working set
No nonce echoing5Nonces validated but never echoed
Content-Type: application/ocsp-response6Set on all responses
HTTP Cache-Control header7.2max-age, public, no-transform, must-revalidate
HTTP Last-Modified header7.2Set to thisUpdate
HTTP Expires header7.2Set to nextUpdate
HTTP ETag header7.2Hex SHA-256 of response octets
HTTP 200 for all OCSP responses (including errors)6All OCSP responses returned with HTTP 200

RFC 9654 – OCSP Nonce Extension

RequirementSectionImplementation
Nonce minimum length: 1 octet4Validated; shorter nonces rejected
Nonce maximum length: 32 octets4Validated; longer nonces rejected
Nonce rejection produces error response4Returns malformedRequest when policy is reject

RFC 5280 – Certificate and CRL Profile

RequirementSectionImplementation
Authority Information Access (AIA) id-ad-ocsp4.2.2.1Used by clients to discover hoike endpoints
CRL parsing for revocation status5CRL entries consumed by signer for revoked status
id-pkix-ocsp-nocheck in responder cert4.2.2.1Delegated 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:

#CheckValidates
1GET request with valid base64-encoded CertIDRFC 6960 Section 3 / A.1
2POST request with valid DER bodyRFC 6960 Section 3
3POST with wrong Content-Type rejectedRFC 6960 Section 3
4Oversized request rejected as malformedRequestSize guard
5Non-minimal DER length encoding rejectedStrict DER parsing
6Trailing bytes after request rejectedStrict DER parsing
7Multi-CertID request rejectedRFC 9919 Section 4
8SHA-256 CertID returns valid responseRFC 9919 Section 4
9SHA-1 CertID returns valid response (compat)Backward compatibility
10Good status for known, non-revoked serialRFC 6960 Section 4.2.1
11Revoked status includes reason and timeRFC 6960 Section 4.2.1
12Unknown CA returns unauthorizedRFC 9919 Section 5
13Unknown serial returns unauthorized (authoritative)RFC 9919 Section 5
14byKey ResponderID usedRFC 9919 Section 5
15Nonce in request not echoed in responseRFC 9919 Section 5
16Overlong nonce rejectedRFC 9654 Section 4
17Content-Type header correctRFC 9919 Section 6
18Cache-Control header present and correctRFC 9919 Section 7.2
19ETag header is hex SHA-256 of responseRFC 9919 Section 7.2
20Last-Modified and Expires headers presentRFC 9919 Section 7.2

Run the conformance suite:

cargo test -p hoike-server --test conformance

Non-goals

hoike intentionally does not implement:

  • OCSP stapling (RFC 6066 Section 8): This is a TLS-server responsibility, not a responder behavior. hoike produces responses that can be stapled by a TLS server.
  • Signed OCSP requests: The request signature field is parsed but never validated. RFC 9919 Section 4.1 explicitly states that signed requests are not required in the lightweight profile.
  • OCSP response signing on demand (edge mode): Edge nodes serve only pre-signed responses. On-demand signing is available via nonce_policy = "live" on signer/combined nodes — see Nonce Policies.

Post-Quantum Readiness

hoike treats post-quantum cryptography as a first-class configuration, not an experimental add-on. ML-DSA (Module-Lattice-Based Digital Signature Algorithm, FIPS 204) is supported at all three security levels alongside traditional ECDSA.

Supported algorithms

AlgorithmStandardSecurity levelSignature sizePublic key size
ECDSA P-256FIPS 186-5~128-bit classical72 B65 B
ECDSA P-384FIPS 186-5~192-bit classical104 B97 B
ML-DSA-44FIPS 204NIST Level 2 (~128-bit PQ)2,420 B1,312 B
ML-DSA-65FIPS 204NIST Level 3 (~192-bit PQ)3,309 B1,952 B
ML-DSA-87FIPS 204NIST Level 5 (~256-bit PQ)4,627 B2,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.

AlgorithmSignatureResponse (CA-direct, no cert)Response (delegated, with cert)
ECDSA P-25672 B~500 B~1.2 KB
ECDSA P-384104 B~530 B~1.3 KB
ML-DSA-442,420 B~2.8 KB~5.5 KB
ML-DSA-653,309 B~3.7 KB~7.0 KB
ML-DSA-874,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):

CertificatesCA-directWith 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):

AlgorithmSigns/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):

DistributionML-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:

RequirementStatus
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 5Delegated to ml-dsa crate
Signature verification per FIPS 204 Section 6Implemented in ahu verify path

FIPS 140-3 validation: The ml-dsa crate is not currently FIPS 140-3 validated. For deployments requiring FIPS 140-3 validated cryptography, use an HSM with ML-DSA support via PKCS#11. hoike’s signer supports PKCS#11 backends for key operations.

End-to-end PQC testing

The cert-revocation-lab includes an ML-DSA-87 PKI hierarchy (Dogtag PKI + Kryoptic PKCS#11 HSM) with a hoike deployment that signs OCSP responses with ML-DSA-87. This demonstrates the full PQC chain: Dogtag issues ML-DSA certs → hoike signs ML-DSA OCSP responses → NSS-based clients (Firefox, certmonger) validate them.

CIQ achieved CAVP certification for ML-DSA in NSS 3.112 (February 2026), with FIPS 140-3 validation targeted for Q2 2027.

Algorithm selection guidance

ScenarioRecommendedRationale
Current production, no PQ requirementECDSA P-256Smallest responses, widest compatibility
CNSA 2.0 complianceML-DSA-65 or ML-DSA-87NSA CNSA 2.0 requires NIST Level 3+
Hybrid transitionECDSA P-256 + ML-DSA-65 (dual-algorithm)Supported via --dual-alg — one bundle, both algorithms
PQ-only, size-constrainedML-DSA-44 with CA-directSmallest PQ option
Maximum securityML-DSA-87 with CA-direct + deltasFull PQ security with size mitigation

Dual-algorithm bundles

hoike supports dual-algorithm bundles that contain both ECDSA and ML-DSA responses for the same certificate set. The client selects the preferred algorithm via the RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension.

hoike sign \
  --ca my-ca \
  --crl ca.crl \
  --signing-key ecdsa.key \
  --sig-alg ecdsa-p256 \
  --dual-alg ml-dsa-87 \
  --pq-signing-key ml-dsa.key \
  -o dual.ahu

# Query with PQ preference
hoike query --url http://localhost:2560 --serial 0A1B2C \
  --issuer-name-b64 ... --issuer-key-b64 ... --prefer ml-dsa-87

The bundle index uses a discriminator field (bytes 46-47 of each 48-byte index record) to distinguish algorithm variants. Binary search with binary_search_preferred resolves the best match for the client’s preference list.

PKCS#11 ML-DSA

hoike supports ML-DSA signing via PKCS#11 HSMs using the CKM_ML_DSA mechanism (full-message, pure variant). The signer validates mechanism support at startup via get_mechanism_list.

[ca.signing_key]
type        = "pkcs11"
module      = "/usr/lib/libkryoptic_pkcs11.so"
token_label = "hoike-ocsp"
key_label   = "pq-signing"
pin_env     = "HOIKE_HSM_PIN"

Build with: cargo build --release --features pkcs11

ML-DSA CMS seals

Bundle seals support both ECDSA and ML-DSA seal keys. The seal key type is auto-detected from the PKCS#8 file:

hoike sign --ca my-ca --crl ca.crl \
  --signing-key ecdsa.key \
  --seal-key ml-dsa-seal.key \
  -o sealed.ahu

Seal verification dispatches on the SignerInfo algorithm OID. ML-DSA signs raw attribute DER (full-message mode); ECDSA uses prehash.

Test coverage

ML-DSA bundle tests are in crates/hoike-sign/tests/:

cargo test -p hoike-sign -- ml_dsa

These tests cover:

  • ML-DSA-44/65/87 key generation and signing
  • Response production with ML-DSA signatures
  • Bundle creation with ML-DSA-signed responses
  • Verification of ML-DSA-signed bundles
  • Round-trip: sign, bundle, load, verify, serve

CMS Seals

Each ahu bundle is cryptographically sealed with a CMS SignedData structure (RFC 5652) that binds the manifest, index, and data regions together.

Purpose

The seal provides:

  • Integrity: Any modification to the bundle contents invalidates the seal
  • Authenticity: The seal identifies the signer via the embedded certificate
  • Trust anchoring: When seal_trust_anchors is configured, only bundles sealed by trusted signers are loaded

Seal structure

The CMS SignedData covers the SHA-256 digest of the manifest bytes. The SignerInfo contains:

  • A MessageDigest signed attribute with the manifest hash
  • The signature (ECDSA P-256 or ML-DSA)
  • The signer’s certificate embedded in the certificates field

Supported seal algorithms

AlgorithmKey typeSigning mode
ECDSA P-256PKCS#8 PEM/DERPrehash (SHA-256 then sign)
ML-DSA-44PKCS#8 PEM/DERFull-message (sign raw attrs DER)
ML-DSA-65PKCS#8 PEM/DERFull-message
ML-DSA-87PKCS#8 PEM/DERFull-message

The seal key type is auto-detected from the PKCS#8 file’s algorithm OID.

Configuration

The seal key must be separate from the OCSP signing key (different key lifetimes).

[[ca]]
label     = "enterprise-ca"
seal_key  = "/etc/hoike/seal-key.p8"
seal_cert = "/etc/hoike/seal-cert.pem"

To require seal verification on bundle load:

[storage]
seal_trust_anchors = ["/etc/hoike/seal-ca.pem"]

When seal_trust_anchors is set, bundles without a valid CMS seal are rejected. When omitted, seal verification is skipped with a warning.

Verification

# Verify seal integrity
ahu verify bundle.ahu

# Verify seal + individual entry signatures
ahu verify bundle.ahu --entries

The ahu verify command checks:

  1. CMS signature is valid against the embedded signer certificate
  2. The signed MessageDigest attribute matches the manifest hash
  3. Index and data digests match the manifest’s integrity fields
  4. Index entries are in sorted order

Current limitations

  • Self-referential verification only. The seal is verified against the certificate embedded in the CMS structure. Full PKIX path building against seal_trust_anchors is not yet implemented — the trust anchor check verifies the seal signature but does not build a complete chain.

FIPS 140-3 Status

hoike 0.2.0 does not run inside a validated cryptographic module. Products are not themselves FIPS validated — cryptographic modules are — so the accurate claim for any release of hoike is whether it uses validated modules for every approved operation. Today it does not, except where signing is delegated to a validated HSM. This page states exactly where each cryptographic operation executes today, what the target architecture is, and what a deployment can and cannot claim in the meantime.

Current status

OperationImplementationInside a validated module?
OCSP response signing, ECDSA P-256RustCrypto p256 / ecdsaNo
OCSP response signing, ML-DSARustCrypto ml-dsaNo
Signing through an HSMPKCS#11 via cryptoki (--features pkcs11)Yes, when the HSM is validated and the algorithm is in its validated boundary
SHA-256 (CertID, bundle index, seal)RustCrypto sha2No
ahu CMS sealRustCrypto cms + p256 / ml-dsaNo
CRL signature verificationRustCrypto p256, ml-dsa; aws-lc-rs for RSA (non-FIPS build)No
Gossip broadcast signaturesed25519-dalekNo
Admin password hashingbcryptNo — bcrypt is not an approved algorithm
Session tokensrandNo
TLS on management listeners and outbound channelsrustls with aws-lc-rs provider (non-FIPS build)No

Enabling aws-lc-rs’s FIPS feature would move only the TLS and RSA-verification rows. It is not the fix.

What a deployment can claim today

  • Signing keys held in a validated HSM, signatures produced by the HSM. With signing_key.type = "pkcs11" the OCSP signature itself is generated inside the HSM’s validated boundary. The digest over the response (SHA-256) is still computed in software before the mechanism call, so this is a partial claim; state it that way.
  • No claim for software keys, the seal, gossip, admin authentication, or TLS.

Target architecture

hoike is intended to ship alongside Red Hat Certificate System on Red Hat Enterprise Linux, and Red Hat products obtain FIPS 140-3 coverage by consuming the operating system’s validated module — the OpenSSL FIPS provider — rather than by validating their own. The planned change introduces a single crypto abstraction and backs it with:

  1. OpenSSL on the host, dynamically linked, in FIPS mode when the host is: hashing, software ECDSA, PBKDF2 password hashing (replacing bcrypt), the DRBG for tokens and jitter, and TLS through OpenSSL-backed axum-server, reqwest, and ldap3. TLS parameters then follow the host’s system-wide crypto policy.
  2. PKCS#11 into an HSM for all production signing keys, as today.
  3. Red Hat Universal Base Image for the container, so the image inherits FIPS mode, crypto policies, and errata from the host and Red Hat’s build pipeline.

Gossip signatures move to ECDSA P-256 through the same abstraction. Ed25519 remains available where the module supports it.

ML-DSA and FIPS

ML-DSA (FIPS 204) OCSP signing is supported in two configurations:

  • For deployments that require validated cryptography, ML-DSA keys must reside in a FIPS 140-3 validated HSM whose certificate lists CKM_ML_DSA in the approved mode; hoike accesses the key through PKCS#11 and the signature is produced inside the HSM’s validated boundary.
  • Software ML-DSA (via the RustCrypto implementation today, via OpenSSL 3.5 on Red Hat Enterprise Linux 10.1 after the crypto-boundary change) is provided for interoperability and testing. It is not within any validated cryptographic module boundary, and the Red Hat Enterprise Linux system OpenSSL FIPS provider does not implement ML-DSA.

These statements are deliberate. hoike does not describe the software path as “FIPS approved” or as running in “FIPS mode”, and it does not claim that a validation is pending. When the validated provider on Red Hat Enterprise Linux gains FIPS 204 algorithms, this page will be updated to say so with the certificate reference.

Timeline and tracking

The crypto-abstraction work is the second phase of the security roadmap and precedes any Common Criteria evaluation, because the evaluated crypto boundary must not change mid-evaluation. Progress is tracked in the repository’s docs/compliance/ directory; this page will be updated when a release ships that runs entirely on the host module, and a hoike check --fips preflight will report the module name, version, and mode at runtime.

NIAP Common Criteria Status

hoike has not been evaluated under the Common Criteria and holds no NIAP certificate. The project maintains draft Security Targets against three NIAP documents so that the gap between the code and a certifiable posture is explicit and tracked. This page summarizes that status for deployers; the engineering detail lives in the repository under docs/compliance/.

How hoike fits the profiles

NIAP documenthoike’s roleStatus
Protection Profile for Certification Authorities (PP 420)The OCSP component of a composite Target of Evaluation whose issuing CA is Red Hat Certificate System. hoike is not a CA and cannot be evaluated against this profile alone.Architecture aligned; crypto boundary and audit attribution open
Protection Profile for Application SoftwareThe hoike binaries as an application on a Red Hat Enterprise Linux platformSix functional requirements open
Functional Package for TLSThe admin, metrics, forward-proxy, and LDAPS channelsServer side largely aligned; ciphersuite selection and client-side activities open

The draft Security Targets were written against PP for Certification Authorities v2.1, PP for Application Software v1.4, and TLS Functional Package v1.1. NIAP has since published newer TLS package versions; the STs will be rebased on whatever versions NIAP accepts when an evaluation is scheduled.

What is aligned today

  • Proof of origin. Every OCSP response is signed; the delegated responder certificate is embedded per RFC 9919 so relying parties can validate without pre-caching.
  • Status freshness and anti-replay. Response windows are bounded by the source; epochs are monotonic with persisted high-water marks; forks are detected.
  • Keyless serving tier. Edge nodes cannot produce a false good by construction.
  • Trusted path for administration. Dedicated TLS listener with optional mutual TLS; TLS 1.2 and 1.3 only.
  • Trusted channels between components. HTTPS-only nonce forwarding without redirects; LDAPS or StartTLS-before-bind for directory synchronization; Ed25519-signed gossip broadcasts with named-origin authorization.
  • Roles and management functions. Administrator, Operator, and Viewer roles enforced per route.
  • Third-party library inventory. Locked dependency set, cargo audit in CI, zero known advisories at the 0.2.0 lockfile.
  • No default credentials, no PII collection, bounded inputs.

What is open

Requirement areaGapPlanned resolution
Cryptographic support (FCS_COP, FCS_CKM, FCS_RBG)Operations run in unvalidated Rust cratesHost OpenSSL FIPS provider plus HSM; see FIPS 140-3 Status
Key protection (FPT_SKP, FCS_STO)No zeroization; secrets may be inline in TOMLzeroize; file or environment indirection only
Audit (FAU_GEN.2)Events do not carry the operator identity; logins not auditedNext release
Identification and authentication (FIA)No lockout, no complexity policy, no re-authentication for privileged actionsNext release
Trusted update (FPT_TUD)Releases and images are checksummed, not signedSigned releases with published verification steps
Version identification (FPT_IDV)hoike --version not implementedNext release
Self-test (FPT_TST)No cryptographic self-test at startupInherited from the host module’s power-on self-test plus a hoike check --fips known-answer run
TLS ciphersuite selectionProvider defaults, not an explicit approved listExplicit allow-list under the host crypto policy; hoike check --tls conformance test
Certificate validation for TLS peers (FIA_X509)No revocation checking for forward-proxy or LDAPS peersReuse the signer’s CRL configuration

Environmental objectives

A Security Target will place these on the operational environment, and deployers should be ready to evidence them:

  • Reliable time (chrony) on every node.
  • Disk encryption or equivalent for the state directory and bundle storage.
  • Audit forwarding and retention (journald or a SIEM) with integrity protection.
  • A validated HSM holding every production signing key.
  • Host operating system in FIPS mode with the FIPS crypto policy.

DISA STIG Guidance

hoike is designed to be deployed on a host that is itself STIG-compliant — Red Hat Enterprise Linux under the Red Hat Enterprise Linux STIG, or OpenShift under the Container Platform SRG. Most controls are therefore inherited. This page maps the Application Security and Development STIG requirement families to what hoike provides, what is configuration, and what is still open, so that a checklist can be completed without re-deriving the answers. Exact rule IDs should be taken from the current benchmark release.

Inherited from the platform

Requirement familyProvided by
Operating system FIPS mode and crypto policyHost kernel and crypto-policies; hoike does not override them
Audit storage, protection, retention, and forwardingjournald or the platform collector; see Audit Logging
Time synchronizationchrony
Account management for the service userHost or platform identity
Network segmentation and firewallingHost firewall, OpenShift NetworkPolicy
Disk encryptionLUKS or the platform’s storage encryption
Malware and vulnerability scanning of the hostPlatform tooling

Met by hoike as shipped

Requirement familyHow
Input validation and bounded requestsOCSP body limit (max_request, 8,192 bytes default); admin login body 4,096 bytes with a 5-second read timeout; checked DER parsing; no unbounded upstream reads
Error messages do not reveal internalsStatic OCSP error responses; short admin error strings; no stack traces
No default or shared accountsOperators must be configured explicitly
Least privilegeContainer runs as a non-root user; no capabilities required
Separation of management and data interfacesadmin_listen and metrics_listen are distinct from the OCSP listener
Session termination on logoutDELETE /session invalidates the token
Role-based accessAdministrator, Operator, Viewer enforced on every admin route
Certificate expiry notificationKey-rotation monitor warns before responder_cert expiry
Third-party component trackingLocked dependency set with cargo audit in CI

Met by configuration

Requirement familySetting
Management traffic encryptedserver.admin_tls, server.metrics_tls; build with --features tls
Mutual authentication of administrative connectionsserver.admin_tls.client_ca
Use of an approved PKI for management TLSPoint client_ca and the server certificate at the organization’s or DoD’s issuing CA
Credentials not stored in clear textpin_env and bind_password_env instead of pin and bind_password; password hashes only in operators
Encrypted channels to backing servicesforward_to must be https://; directory tls = "ldaps" or "starttls"
Session lifetimesession_ttl_secs (set 900 or less for privileged nodes)
Non-production features disabledDo not use --demo-key, signing_key.type = "demo", or forward_insecure

Open in this release

Requirement familyStatusCompensating control until resolved
FIPS-validated cryptographic module for all security functionsOpen — see FIPS 140-3 StatusHSM-held signing keys; host FIPS mode for everything else on the box
Account lockout after consecutive failed loginsOpen (process-wide rate limit only)Mutual TLS on the admin listener; management-network-only access
Password complexity, minimum length, history, maximum ageOpen (hashes are operator-supplied)Enforce organizational policy when generating hashes; rotate on a schedule
DoD Notice and Consent BannerOpenPresent the banner on the management ingress or bastion in front of the admin listener
Session inactivity timeoutOpen (fixed lifetime only)Short session_ttl_secs
Concurrent session limit per userOpenProcedural
Re-authentication for privileged functionsOpenRestrict administrator role to a minimum set of accounts; mTLS
HTTP security headers on the web UIOpenServe the UI only over the mTLS admin listener; or omit server.webui
Audit of login success/failure and operator identity on privileged actionsOpenCorrelate admin-listener access logs at the ingress with hoike audit events
Signed release artifacts and imagesOpen (SHA-256 checksums only)Build from source in the organization’s pipeline and sign there
Application reports its versionOpenRecord the deployed image digest in the CMDB

All items in this table are scheduled; see the roadmap in the repository’s docs/compliance/ directory.

Evidence to collect for a checklist

  • hoike check --config /etc/hoike/hoike.toml output with no unexplained warnings
  • The effective configuration from GET /api/admin/config (secrets are redacted)
  • cargo audit report for the deployed lockfile
  • Container image digest and base image (Red Hat Universal Base Image once the rebase lands)
  • Host STIG scan results (OpenSCAP) for the node
  • Audit forwarding configuration and a sample rollback or fork event reaching the SIEM

hoike CLI Reference

The hoike binary is the main entry point for the OCSP responder. It provides five subcommands: serve, check, sign, import, and query.

Global options

hoike [OPTIONS] <COMMAND>
OptionDescription
--versionPrint version information
--helpPrint help

hoike serve

Start the OCSP responder server.

hoike serve --config <PATH>

Options

FlagRequiredDefaultDescription
--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

FlagRequiredDefaultDescription
--config <PATH>Yes–Path to the hoike.toml configuration file

Description

Performs a comprehensive pre-flight check:

  1. Config parsing – validates the TOML configuration syntax and structure
  2. Bundle verification – for each configured [[ca]] entry, verifies the referenced bundle file exists and passes seal verification
  3. Storage access – confirms bundle_dir and state_db directories exist and are writable
  4. 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

FlagRequiredDefaultDescription
--ca <LABEL>Yes–CA scope label for the bundle
--crl <FILE>Yes–Path to the CRL file (PEM or DER)
--signing-key <FILE>*–PKCS#8 PEM/DER signing key. Mutually exclusive with --demo-key.
--demo-key*falseUse an ephemeral key (testing only). Mutually exclusive with --signing-key.
-o <FILE>Nooutput.ahuOutput bundle file path
--sig-alg <ALG>Noecdsa-p256Signature algorithm (see below)
--certid-compat <MODE>NodualCertID hash compatibility mode (see below)
--epoch <N>No1Epoch number for anti-rollback
--good-serials <FILE>No–File of hex serial numbers to mark as good
--issuer-name-b64 <B64>No–Base64-encoded DER issuer name for correct CertID hashes
--issuer-key-b64 <B64>No–Base64-encoded issuer public key bytes
--issuer <FILE>No–Issuer certificate (DER) for automatic CertID computation
--seal-key <FILE>No–PKCS#8 PEM/DER seal key file (separate from signing key)
--dual-alg <ALG>No–Produce a dual-algorithm bundle alongside --sig-alg (e.g., ml-dsa-87)
--pq-signing-key <FILE>No–PKCS#8 PEM/DER PQ signing key file (required with --dual-alg)

* One of --signing-key or --demo-key is required. hoike refuses to sign without an explicit key source.

Signature algorithms (--sig-alg)

ValueAlgorithmKey type
ecdsa-p256ECDSA with P-256/SHA-256EC P-256
ml-dsa-44ML-DSA-44 (FIPS 204)ML-DSA-44
ml-dsa-65ML-DSA-65 (FIPS 204)ML-DSA-65
ml-dsa-87ML-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)

ValueBehavior
dualProduce both SHA-256 and SHA-1 CertID entries for each certificate. Maximizes client compatibility.
sha256SHA-256 CertID entries only. Standards-compliant but may not work with older clients.
sha1SHA-1 CertID entries only. Legacy compatibility mode.

Good-serials file format

A plain text file with one hex-encoded serial number per line:

01A3F2
01A3F3
01B7C0

Certificates listed here are marked as good in the OCSP responses. Certificates found in the CRL are marked as revoked. Certificates in neither list are treated according to the CA’s completeness policy.

Epoch numbering

The epoch is a monotonically increasing integer that prevents rollback attacks. Edge nodes refuse to load a bundle with an epoch lower than the currently loaded one. If --epoch is not specified, the signer auto-increments from the previous bundle’s epoch.

Example

# Production (with PKCS#8 key file)
hoike sign \
  --ca enterprise-issuing-01 \
  --crl /var/lib/pki/ca.crl \
  --signing-key /etc/pki/ocsp-signer.key \
  --good-serials /var/lib/pki/good-serials.txt \
  --sig-alg ecdsa-p256 \
  --certid-compat dual \
  --epoch 42 \
  -o /var/lib/hoike/bundles/enterprise.ahu

# Testing (with demo key)
hoike sign \
  --ca test-ca \
  --crl test.crl \
  --demo-key \
  -o test.ahu

hoike import

Import a bundle for air-gap or enclave deployments where gossip is not available.

hoike import --bundle <PATH> [OPTIONS]

Options

FlagRequiredDefaultDescription
--bundle <PATH>Yes–Path to the ahu bundle file to import
--config <PATH>No–Path to hoike.toml (for target directory resolution)
--forceNofalseSkip 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:

  1. Verifies the bundle seal and integrity
  2. Checks that the epoch is greater than any currently loaded bundle for the same CA scope
  3. Copies the bundle to bundle_dir
  4. Updates the state database

Use --force to bypass epoch and seal checks (for disaster recovery or initial bootstrap only).

Example

# Standard import
hoike import --bundle /mnt/usb/enterprise.ahu \
  --config /etc/hoike/hoike.toml

# Force import (disaster recovery)
hoike import --bundle /mnt/usb/enterprise.ahu \
  --config /etc/hoike/hoike.toml --force

hoike query

Query a running OCSP responder with optional algorithm preference negotiation.

hoike query --url <URL> --serial <HEX> --issuer-name-b64 <B64> --issuer-key-b64 <B64> [OPTIONS]

Options

FlagRequiredDefaultDescription
--url <URL>Yes–Responder URL (e.g., http://localhost:2560)
--serial <HEX>Yes–Hex-encoded certificate serial number
--issuer-name-b64 <B64>Yes–Base64-encoded DER issuer name
--issuer-key-b64 <B64>Yes–Base64-encoded issuer public key bytes
--prefer <ALGS>No–Comma-separated preferred algorithms (e.g., ml-dsa-87,ecdsa-p256)

Description

Builds an OCSP request for the given serial number and issuer, sends it to the specified responder, and displays the parsed response including status, signature algorithm, timestamps, and nonce handling.

When --prefer is specified, the request includes an RFC 6960 §4.4.7.1 PreferredSignatureAlgorithms extension. This is used to test dual-algorithm bundle negotiation — requesting ml-dsa-87 from a responder serving a dual-algorithm bundle returns the ML-DSA response.

Example

# Basic query
hoike query \
  --url http://localhost:2560 \
  --serial 0A1B2C \
  --issuer-name-b64 "..." \
  --issuer-key-b64 "..."

# Query with post-quantum preference
hoike query \
  --url http://localhost:2560 \
  --serial 0A1B2C \
  --issuer-name-b64 "..." \
  --issuer-key-b64 "..." \
  --prefer ml-dsa-87

ahu CLI Reference

The ahu binary is a standalone tool for working with ahu bundle files. It does not require a running hoike server or any configuration. All operations are read-only except apply.

Global options

ahu [OPTIONS] <COMMAND>
OptionDescription
--versionPrint version information
--helpPrint help

ahu inspect

Display the manifest, scopes, epochs, and entry counts of a bundle.

ahu inspect <FILE>

Arguments

ArgumentDescription
<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

ArgumentDescription
<FILE>Path to the ahu bundle file

Options

FlagRequiredDefaultDescription
--entriesNofalseAlso verify each individual entry’s OCSP response signature

Description

Performs integrity verification of the bundle:

  1. Seal verification – checks the cryptographic seal over the entire bundle, confirming it has not been modified since signing
  2. Digest verification – recomputes content digests and compares against the manifest
  3. 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

ArgumentDescription
<FILE>Path to the ahu bundle file

Options

FlagRequiredDefaultDescription
--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

ArgumentDescription
<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

ArgumentDescription
<BASE>Path to the base bundle
<DELTAS>...One or more delta bundle files to apply, in order

Options

FlagRequiredDefaultDescription
-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:

ItemDescription
BundleTop-level type for reading and inspecting an ahu bundle
BundleBuilderConstruct a new bundle with manifest, entries, and seal
ManifestCBOR-encoded bundle metadata (CA label, epoch, algorithm, timestamps)
EntryA single CertID-to-response mapping in the bundle
SealCryptographic seal binding the manifest and all entries
verify()Verify a bundle’s seal, digests, and sort order
mmapMemory-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:

ItemDescription
ConfigParsed hoike.toml configuration
CaConfigPer-CA configuration ([[ca]] section)
ServerConfigServer mode, listen address, limits
StorageConfigBundle directory, state DB path, chain limits
GossipConfigSWIM gossip parameters (seeds, bind address, node name)
NoncePolicyNonce handling strategy (ignore, reject, echo)
CompletenessCompleteness 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:

ItemDescription
SignerMain signing orchestrator – CRL + serials in, sealed bundle out
ResponseBuilderConstruct individual OCSP responses
CrlParserParse PEM or DER CRL files and extract revocation entries
SigAlgorithmEnum of supported signature algorithms (ECDSA, ML-DSA variants)
CertIdCompatCertID hash compatibility mode selection
EpochManagerTrack 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:

ItemDescription
ServerTop-level server lifecycle (bind, serve, shutdown)
OcspHandlerRequest parsing, CertID extraction, bundle lookup
BundleStoreThread-safe bundle storage with hot-reload support
Routeraxum 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:

ItemDescription
GossipRuntimeManages the foca SWIM protocol instance
BundleAnnouncementNotification payload when a new bundle is available
PeerStateTracked state for each peer in the gossip cluster
TransportUDP 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

RequirementVersionNotes
Rust1.85+Edition 2024. Install via rustup.
C linkerAnyXcode CLT (macOS), build-essential (Debian/Ubuntu), gcc (Fedora/RHEL)
OpenSSL3.xFor test certificate generation only
Git2.xFor 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:

BinaryLocationSize
hoiketarget/release/hoike~8 MB
ahutarget/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:

Crateder versionReason
x509-ocsp 0.2.xder 0.7OCSP request/response parsing (tracks x509-cert 0.2)
ahuder 0.8Bundle 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 der 0.7 are not interchangeable with types from der 0.8
  • Conversion between the two versions requires re-encoding as DER bytes and re-parsing
  • The bridge code lives in hoike-core where the two versions meet

If x509-ocsp releases a der 0.8 compatible version, the workspace should be updated to unify on a single version.

Building individual crates

Build only the bundle library:

cargo build --release -p ahu

The ahu crate supports --no-default-features for minimal builds:

cargo build --release -p ahu --no-default-features

Build without gossip support:

cargo build --release -p hoike-cli --no-default-features

Building the Web UI

The admin web UI is a React + PatternFly 6 application in the webui/ directory.

Development mode (with hot reload):

cd webui
npm install
npm run dev       # Starts on http://localhost:9000

The Vite dev server proxies /api/admin requests to http://localhost:2560 (the hoike server).

Production build (for embedding or disk serving):

cd webui
npm run build     # Produces webui/dist/

Embedding in the binary:

cd webui && npm run build
cd .. && cargo build --release --features embed-webui

The embed-webui feature uses include_dir to bake webui/dist/ into the binary. The embedded UI is served at /ui/ automatically when [server.webui] is present in the config (without static_dir).

Generating API documentation

cargo doc --workspace --no-deps --open

This builds rustdoc for all six crates and opens the result in a browser.

Running tests

Run the full test suite:

cargo test --workspace

See the Testing page for detailed test categories and options.

Development tools

Recommended but not required:

ToolPurposeInstall
cargo-watchAuto-rebuild on savecargo install cargo-watch
cargo-nextestFaster test runner with better outputcargo install cargo-nextest
mdbookBuild the documentation bookcargo install mdbook
mdbook-mermaidMermaid diagram support for mdbookcargo 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

VariableDefaultDescription
HOIKE_LOGinfoLog level (trace, debug, info, warn, error)
HOIKE_CONFIGNonePath to configuration file
RUST_BACKTRACE0Set 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-analyzer extension
  • Neovim: Use nvim-lspconfig with rust_analyzer
  • IntelliJ: Use the Rust plugin

The workspace root Cargo.toml is the correct entry point for rust-analyzer. No additional configuration is needed.

Testing

hoike has 200 tests on the default build (218 with the tls, metrics, and dogtag-sync features enabled) across 6 crates covering unit, integration, end-to-end, conformance, seal verification, ML-DSA, key rotation, and live nonce signing.

Running all tests

cargo test --workspace

With verbose output:

cargo test --workspace -- --nocapture

With cargo-nextest (recommended for faster parallel execution):

cargo nextest run --workspace

Test categories

Unit tests

Each crate contains inline unit tests (#[cfg(test)] modules) covering individual functions and types.

# Run unit tests for a specific crate
cargo test -p ahu
cargo test -p hoike-core
cargo test -p hoike-sign
cargo test -p hoike-server
cargo test -p hoike-gossip

Integration tests

Integration tests are in tests/ directories within each crate. They test cross-module behavior using the public API.

ahu integration tests

Located in crates/ahu/tests/. These cover:

  • Bundle creation: write manifest, index, data, and seal
  • Bundle reading: parse and verify a bundle from bytes
  • Round-trip: create a bundle and read it back
  • Index binary search correctness at various sizes
  • Delta bundle creation and application
  • Corrupt bundle detection (tampered seal, modified data)
  • Format version handling
cargo test -p ahu --tests

Anti-rollback tests

Located in crates/hoike-core/tests/anti_rollback.rs. These verify the epoch chain enforcement:

  • Accept a bundle with epoch > current epoch
  • Reject a bundle with epoch <= current epoch (rollback)
  • Reject a bundle with incorrect parent hash (fork)
  • Accept epoch 1 with null parent hash (initial load)
  • Reject epoch 2+ with null parent hash (missing chain)
cargo test -p hoike-core --test anti_rollback

Conformance suite

Located in crates/hoike-server/tests/conformance.rs. This suite exercises the 20 protocol conformance checks listed in the RFC Support Reference.

The conformance tests spin up an in-process axum server with a test bundle and exercise the full HTTP request path:

cargo test -p hoike-server --test conformance

Each test function is named after the check it validates:

conformance::get_valid_request
conformance::post_valid_request
conformance::post_wrong_content_type
conformance::oversized_request_rejected
conformance::non_minimal_der_rejected
conformance::trailing_bytes_rejected
conformance::multi_certid_rejected
conformance::sha256_certid_response
conformance::sha1_certid_compat
conformance::good_status
conformance::revoked_status_with_reason
conformance::unknown_ca_unauthorized
conformance::unknown_serial_unauthorized
conformance::bykey_responder_id
conformance::nonce_not_echoed
conformance::overlong_nonce_rejected
conformance::content_type_header
conformance::cache_control_header
conformance::etag_header
conformance::last_modified_expires_headers

ML-DSA tests

Located in crates/hoike-sign/tests/. These cover post-quantum signing and verification:

  • ML-DSA-44 key generation and response signing
  • ML-DSA-65 key generation and response signing
  • ML-DSA-87 key generation and response signing
  • Bundle creation with ML-DSA signatures
  • Bundle verification of ML-DSA seals
  • Round-trip: sign with ML-DSA, bundle, load, verify
cargo test -p hoike-sign -- ml_dsa

Test data generation

The testdata/generate.rs script creates test certificates, keys, CRLs, and serial lists used by the test suite. Run it to regenerate test fixtures:

cargo run --example generate -p hoike-cli

This produces:

FileContents
testdata/ca.crtTest CA certificate (self-signed, P-256)
testdata/ca.keyTest CA private key
testdata/ocsp.crtDelegated OCSP responder certificate
testdata/ocsp.keyOCSP responder private key
testdata/ee*.crtEnd-entity certificates
testdata/ca.crlCRL with one revoked certificate
testdata/good-serials.txtSerial 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() not fn 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 CertIDs
  • hoike-server: In-process server launcher, HTTP client helpers
  • ahu: Bundle builder with configurable manifest fields

Example: adding a conformance check

To add a new conformance check:

  1. Add the test function to crates/hoike-server/tests/conformance.rs
  2. Name it after the behavior being verified
  3. Use the test server and HTTP client helpers
  4. 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
}
}
  1. 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

  1. Fork and clone the repository
  2. Create a feature branch from main:
    git checkout -b feature/my-change
    
  3. Make your changes following the guidelines below
  4. Run the full check suite before committing:
    cargo fmt --check
    cargo clippy --workspace
    cargo test --workspace
    
  5. Commit with a clear message (see Commit messages)
  6. 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 dependencyReason
tokioNo async runtime in a format library
hyperNo HTTP in a format library
axumNo web framework in a format library
PKCS#11 bindingsNo HSM coupling in a format library
Any GPL-licensed crateahu 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:

  • ahu depends on nothing in the hoike workspace
  • hoike-core depends only on ahu
  • hoike-sign depends on ahu and hoike-core
  • hoike-server depends on hoike-core and hoike-gossip
  • hoike-gossip depends on nothing in the hoike workspace (uses foca)
  • hoike-cli depends on hoike-server and hoike-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:

CrateLicenseSPDX
ahuApache License 2.0 OR MITApache-2.0 OR MIT
hoike-coreGNU General Public License v3.0 or laterGPL-3.0-or-later
hoike-signGNU General Public License v3.0 or laterGPL-3.0-or-later
hoike-serverGNU General Public License v3.0 or laterGPL-3.0-or-later
hoike-gossipGNU General Public License v3.0 or laterGPL-3.0-or-later
hoike-cliGNU General Public License v3.0 or laterGPL-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

TypeUse for
featNew functionality
fixBug fixes
refactorCode restructuring without behavior change
testAdding or modifying tests
docsDocumentation changes
choreBuild, 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:

SituationTrailer
Human-directed work with AI assistanceAssisted-by: Claude Code (claude.ai/code)
Large generated blocks with minimal human editGenerated-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 --check passes
  • cargo clippy --workspace has no warnings
  • cargo test --workspace passes (all 81+ tests)
  • cargo doc --workspace --no-deps builds without warnings
  • New public APIs have doc comments
  • New behavior has test coverage
  • Commit messages follow the convention above
  • ahu crate 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)