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, reads CRLs (or serial lists), and batch-produces pre-signed OCSP responses packaged into ahu bundles. The signer can run in a hardened enclave, an HSM-attached host, or an air-gapped machine. It never handles client traffic.

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

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

The ahu bundle format

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

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

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

Workspace overview

hoike is organized as a Rust workspace with six crates:

CrateDescriptionLicense
ahuBundle format library – read, write, verify ahu containersApache-2.0 / MIT
hoike-coreShared types, configuration, and protocol logicGPL-3.0-or-later
hoike-signSigning engine – CRL parsing, response production, bundle sealingGPL-3.0-or-later
hoike-serveraxum-based OCSP responder and HTTP servingGPL-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

Technology stack

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

What’s next

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

Installation

hoike produces two binaries:

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 \
  --issuer-cert ca.crt \
  --signer-cert ocsp.crt \
  --signer-key ocsp.key \
  --crl ca.crl \
  --good-serials good-serials.txt \
  --sig-alg ecdsa-p256 \
  --certid-compat dual \
  --epoch 1 \
  --output demo-ca.ahu

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

Flag summary:

FlagValueMeaning
--cademo-caLabel for this CA scope in the bundle
--sig-algecdsa-p256Signature algorithm for OCSP responses
--certid-compatdualProduce both SHA-256 and SHA-1 CertID entries
--epoch1Monotonic epoch number for anti-rollback

5. Inspect the bundle

Use ahu inspect to examine the bundle metadata:

ahu inspect demo-ca.ahu

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

6. Verify the bundle

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

ahu verify demo-ca.ahu

To also verify each individual entry:

ahu verify demo-ca.ahu --entries

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

What you have now

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

Next steps

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

Starting the Responder

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

1. Create a configuration file

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

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

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

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

Set up the directories and move the bundle into place:

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

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

2. Validate the configuration

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

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

This verifies:

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

Fix any reported issues before proceeding.

3. Start the responder

Launch the OCSP responder:

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

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

To run in the background:

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

4. Test with OpenSSL

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

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

You should see a response with status good.

Now query the revoked certificate:

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

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

5. Test with curl

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

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

Understanding the response path

When the edge server receives an OCSP request, it:

  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

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

Every key can also be set via environment variable using the HOIKE_ prefix, double-underscore section separators, and uppercase names. For example, server.listen becomes HOIKE_SERVER__LISTEN. Environment variables take precedence over the config file.


[server]

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

KeyTypeDefaultDescription
modestringrequiredOperating mode: "signer", "edge", or "combined". See Signer, Edge, and Combined mode pages.
listenstring"0.0.0.0:2560"Socket address for the HTTP listener. Port 2560 is the IANA-assigned port for OCSP over HTTP.
max_requestinteger8192Maximum OCSP request body size in bytes. RFC 6960 POST bodies are typically small; RFC 9919 GET requests encode the request in the URL path and are capped at 255 bytes by the URI length constraint. This limit protects against oversized or malformed requests.
[server]
mode   = "edge"
listen = "0.0.0.0:2560"
max_request = 8192

Mode validation

mode is the single most important setting. It determines which code paths are active:

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

hoike validates mode-specific constraints at startup. For example, nonce_policy = "live" on an edge node is a fatal error (edge nodes have no signing keys).


[storage]

Paths and limits for bundle storage and persistent state.

KeyTypeDefaultDescription
bundle_dirstring"/var/lib/hoike/bundles"Directory where ahu bundles are stored. The signer writes here; the edge reads from here. Must be readable (edge) or read-write (signer/combined).
state_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. Lower values increase bandwidth (more full bundles); higher values save bandwidth but increase recovery time after a missed delta.
[storage]
bundle_dir = "/var/lib/hoike/bundles"
state_db   = "/var/lib/hoike/state"
max_chain  = 24

Operational note: Back up state_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
enabledbooleantrueEnable 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. At least one seed must be reachable for a new node to join the fleet. Format: "hostname:port".
identity_keystringPath to the node’s gossip identity key. All gossip messages are signed with this key.
node_namestringhostnameHuman-readable node identifier. Must be unique within the gossip cluster. Defaults to the system hostname if omitted.
[gossip]
enabled      = true
bind         = "0.0.0.0:7946"
seeds        = ["edge-a.pki.example:7946", "edge-b.pki.example:7946"]
identity_key = "/etc/hoike/gossip.key"
node_name    = "edge-01"

Disabling gossip

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

[gossip]
enabled = false

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


[[ca]]

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

Identity and source

KeyTypeDefaultDescription
labelstringrequiredHuman-readable label for this CA. Used in logs, metrics, and bundle filenames. Must be unique across all [[ca]] sections.
sourceinline tablerequiredRevocation data source. See Source types below.

Signing

KeyTypeDefaultDescription
signingstring"ca-direct"Signing mode. "ca-direct" signs with the CA’s own key. "delegated" uses a separate OCSP responder certificate and key (requires responder_cert and responder_key).
sig_algstring"ecdsa-p256"Signature algorithm. Supported values: "ecdsa-p256", "ecdsa-p384", "ed25519", "rsa-sha256", "ml-dsa-44", "ml-dsa-65", "ml-dsa-87".
responder_certstringPath to the OCSP responder certificate. Required when signing = "delegated".
responder_keystringPath to the OCSP responder private key. Required when signing = "delegated".
responder_idstring"by-key"ResponderID format in OCSP responses: "by-key" (SubjectPublicKeyInfo hash) or "by-name" (Distinguished Name). "by-key" is recommended — it survives certificate renewal.

CertID and compatibility

KeyTypeDefaultDescription
certid_compatstring"dual"CertID hash algorithm compatibility. "dual" indexes responses by both SHA-256 and SHA-1 issuerKeyHash (for clients that still send SHA-1). "sha256" accepts only SHA-256. "sha1" accepts only SHA-1 (not recommended).

Nonce handling

KeyTypeDefaultDescription
nonce_policystring"ignore"How to handle nonces in OCSP requests. "ignore" omits the nonce from responses (appropriate for pre-signed). "forward" proxies the request to the signer for a live-signed response with nonce. "live" signs a fresh response with nonce on every request (signer mode only). See Nonce Policies.
forward_tostringURL of the signer to forward nonce-bearing requests to. Required when nonce_policy = "forward".

Timing and batch production

KeyTypeDefaultDescription
validityduration string"24h"Response validity window (nextUpdate − thisUpdate). Determines how long a cached response remains valid.
batch_intervalduration string"1h"How often the signer produces a new batch of responses. The signer outage budget is validity − batch_interval — if the signer is down longer than this, edge nodes will begin serving expired responses.
jitterduration string"2h"Random jitter added to thisUpdate to prevent response expiration thundering herds. Each response’s thisUpdate is shifted by a random offset within [0, jitter].
max_age_fractionfloat0.5Fraction of remaining validity used for Cache-Control: max-age. A value of 0.5 means the max-age header is set to half the time remaining until nextUpdate.
urgent_revocationbooleantrueWhen true, the signer produces an off-cycle delta bundle immediately upon detecting a new revocation, rather than waiting for the next batch_interval.
archive_cutoffduration string"1y"How far back to keep responses for expired certificates. Certificates that expired more than this duration ago are dropped from bundles.

Completeness

KeyTypeDefaultDescription
completenessstring"authoritative-complete"Declares whether this responder has complete revocation data for the CA. "authoritative-complete" means the responder is the authoritative source for this CA’s revocation status — any certificate not found in the bundle is reported as good. "partial" means the responder only knows about certificates listed in a CRL — unlisted certificates return unauthorized (unknown).

Source types

The source field is an inline table that specifies where revocation data comes from.

CRL source (implemented):

source = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
FieldTypeDescription
typestringMust be "crl".
pathstringPath to the CRL file. hoike watches this path for changes and reloads automatically.

Dogtag source (planned):

source = { type = "dogtag", url = "https://ca01.pki.example:8443", auth = "mtls", cert = "/etc/hoike/ra.pem" }
FieldTypeDescription
typestringMust be "dogtag".
urlstringDogtag CA REST API endpoint.
authstringAuthentication method: "mtls".
certstringPath to the client certificate for mTLS authentication.
[[ca]]
label          = "enterprise-issuing-01"
source         = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
signing        = "ca-direct"
sig_alg        = "ecdsa-p256"
responder_id   = "by-key"
certid_compat  = "dual"
nonce_policy   = "ignore"
validity       = "24h"
batch_interval = "1h"
jitter         = "2h"
archive_cutoff = "1y"
completeness   = "authoritative-complete"

Duration strings

Duration values use a human-readable format: a number followed by a unit suffix.

SuffixMeaningExample
sseconds"30s"
mminutes"15m"
hhours"24h"
ddays"7d"
yyears (365 days)"1y"

Validation rules

hoike validates the configuration at startup and exits with a descriptive error if any rule is violated:

RuleError
mode is missing or not one of signer, edge, combinedinvalid server mode
nonce_policy = "live" with mode = "edge"live nonce signing requires signer mode
nonce_policy = "forward" without forward_toforward_to is required when nonce_policy = "forward"
signing = "delegated" without responder_cert or responder_keydelegated signing requires responder_cert and responder_key
batch_interval >= validitybatch_interval must be less than validity
max_chain < 1max_chain must be at least 1
Duplicate label across [[ca]] sectionsduplicate CA label
bundle_dir does not exist or is not writable (signer/combined)bundle_dir is not writable
state_db parent directory does not existstate_db path is invalid
gossip.enabled = true without identity_keygossip identity_key is required when gossip is enabled

Complete annotated example

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

[server]
mode        = "edge"           # Keyless serving from pre-signed bundles
listen      = "0.0.0.0:2560"   # IANA-assigned OCSP port
max_request = 8192             # Max POST body; GET is path-limited to ~255 bytes

[storage]
bundle_dir = "/var/lib/hoike/bundles"   # Where ahu bundles are read from
state_db   = "/var/lib/hoike/state"     # Epoch high-water marks — MUST persist across restarts
max_chain  = 24                         # Accept up to 24 delta bundles before requiring a full

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

# Enterprise issuing CA — CRL-based, pre-signed responses
[[ca]]
label          = "enterprise-issuing-01"
source         = { type = "crl", path = "/var/lib/hoike/crls/enterprise.crl" }
signing        = "ca-direct"
sig_alg        = "ecdsa-p256"
responder_id   = "by-key"
certid_compat  = "dual"          # Accept both SHA-256 and SHA-1 CertID hashes
nonce_policy   = "ignore"        # Pre-signed — nonce omitted from responses
validity       = "24h"           # Signer outage budget: 24h − 1h = 23h
batch_interval = "1h"
jitter         = "2h"
archive_cutoff = "1y"
completeness   = "authoritative-complete"

# Partner issuing CA — delegated responder, nonces forwarded to signer
[[ca]]
label          = "partner-issuing-01"
source         = { type = "crl", path = "/var/lib/hoike/crls/partner.crl" }
signing        = "delegated"
responder_cert = "/etc/hoike/partner-ocsp.pem"
responder_key  = "/etc/hoike/partner-ocsp.key"
sig_alg        = "ecdsa-p384"
responder_id   = "by-key"
certid_compat  = "sha256"        # Partner clients all support SHA-256
nonce_policy   = "forward"       # Proxy nonce-bearing requests to signer
forward_to     = "https://signer.pki.example:2560"
validity       = "12h"
batch_interval = "30m"
jitter         = "1h"
archive_cutoff = "6m"            # Partner certs are short-lived
completeness   = "partial"       # CRL may not list every certificate

Environment variable overrides

Any configuration key can be overridden with an environment variable. The naming convention is:

HOIKE_<SECTION>__<KEY>

Double underscores separate section from key; single underscores within a key name are preserved.

Config keyEnvironment variable
server.modeHOIKE_SERVER__MODE
server.listenHOIKE_SERVER__LISTEN
storage.bundle_dirHOIKE_STORAGE__BUNDLE_DIR
gossip.enabledHOIKE_GOSSIP__ENABLED

Environment variables are useful for container deployments where the base config file is baked into the image and per-instance settings (like node_name or listen) vary.

Note: [[ca]] array sections cannot be fully configured via environment variables due to TOML array-of-tables semantics. Use the config file for CA definitions.

Signer Mode

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

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

Enabling Signer Mode

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

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

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

Revocation Sources

Each [[ca]] section declares a revocation source that the signer polls for certificate status.

CRL (implemented)

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

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

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

Dogtag / 389 DS (planned)

Direct integration with Red Hat Certificate System (Dogtag) is planned. The intended configuration:

[[ca]]
label  = "dogtag-ca-01"
source = { type = "dogtag", url = "https://ca01.pki.example:8443", auth = "mtls", cert = "/etc/hoike/ra.pem" }

This will query the Dogtag REST API for revocation state using mutual TLS authentication, eliminating the CRL export step.

Note: Until Dogtag support ships, use the CRL adapter and export CRLs from Dogtag on a schedule.

Batch Production

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

ParameterDefaultDescription
batch_interval1hHow often the signer produces a new bundle
validity24hResponse validity window (nextUpdatethisUpdate)
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 Mode

ModeKey usedWhen to use
ca-directCA’s own private keyCA key is available to the signer (simpler, common)
delegatedSeparate responder keyDedicated OCSP signing key with id-kp-OCSPSigning EKU

CA-direct signing:

[[ca]]
label   = "root-ca"
signing = "ca-direct"

Delegated signing:

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

When using delegated, the responder certificate must contain the id-kp-OCSPSigning extended key usage and be issued by the CA it signs on behalf of.

Signature Algorithms

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

Status: Planned, not yet implemented.

PKCS#11 integration will allow the signer to use hardware security modules (HSMs) for key storage and signing operations. When available, the signing key path will be replaced with a PKCS#11 URI:

# Future syntax (not yet supported)
responder_key = "pkcs11:token=hoike;object=ocsp-signer;type=private"

Until then, the signer reads key material from PEM files on disk. Protect these files with filesystem permissions and, where possible, full-disk encryption.

Urgent Revocation

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

[[ca]]
urgent_revocation = true   # default

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

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

Completeness

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

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.

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"

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: Every request requires a signing operation. Throughput is bounded by the signing rate, which varies by algorithm (ECDSA P-256 is fast; ML-DSA-65 is slower). No bundle index is consulted — the signer produces responses directly from revocation state.

Gossip Configuration

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

What Gossip Provides

Gossip serves three purposes in a hoike deployment:

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_keypathPath to the Ed25519 key used to sign gossip messages.
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. The corresponding public key is derived automatically and exchanged during the SWIM join handshake.

Node Name

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

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

Failure Detection

SWIM detects failed nodes through a three-phase protocol:

  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:

  • All gossip messages are signed with the sender’s identity_key. Unsigned or incorrectly signed messages are dropped.
  • Bundle validity is verified independently via CMS seal and epoch chain — not via gossip trust. A gossip announcement only says “a new generation exists”; the edge independently verifies every bundle it loads.
  • A compromised gossip node cannot inject bad responses. The worst it can do is trigger unnecessary pull attempts. The pulled bundle must still pass CMS seal verification and anti-rollback checks.
  • Gossip cannot suppress bundles. Edges also poll on a schedule, so even if gossip is disrupted, bundles are eventually loaded.

Network Requirements

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

Firewall rules must allow:

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

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

Disabling Gossip

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

[gossip]
enabled = false

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

Air-Gap Deployments

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

When to Use Air-Gap Mode

Air-gap deployments are appropriate for:

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

Configuration

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

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

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

[gossip]
enabled = false

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

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

Bundle Import Workflow

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

Step by Step

  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.

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.

Architecture Overview

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

The signer/edge split

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

graph LR
    subgraph Signer["Signer (HSM / enclave)"]
        CRL[CRL + serial list] --> Sign[Batch sign]
        Sign --> Bundle[ahu bundle]
    end
    subgraph Distribution
        Bundle -->|gossip / push / sneakernet| Edge1[Edge 1]
        Bundle -->|gossip / push / sneakernet| Edge2[Edge 2]
        Bundle -->|gossip / push / sneakernet| EdgeN[Edge N]
    end
    subgraph Clients
        C1[OCSP client] -->|HTTP| Edge1
        C2[OCSP client] -->|HTTP| Edge2
    end
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 headersGPL-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.1.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.

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)

Total record size: 44 bytes.

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

Data region

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

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

Delta bundles

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

Delta structure

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

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

  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)

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.

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 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, not echoed in pre-signed mode
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 responses (no on-demand signing)4Core design – all responses are batch-signed
Single CertID per request4Multi-CertID requests rejected as malformedRequest
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: All responses are pre-signed during batch production. There is no code path for on-demand signing.

Post-Quantum Readiness

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

Supported algorithms

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.

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 (future)Not yet supported; planned
PQ-only, size-constrainedML-DSA-44 with CA-directSmallest PQ option
Maximum securityML-DSA-87 with CA-direct + deltasFull PQ security with size mitigation

Test coverage

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

cargo test -p hoike-sign -- ml_dsa

These tests cover:

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

hoike CLI Reference

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

Global options

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

hoike serve

Start the OCSP responder server.

hoike serve --config <PATH>

Options

FlagRequiredDefaultDescription
--config <PATH>YesPath 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>YesPath 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>YesCA scope label for the bundle
--crl <FILE>YesPath to the CRL file (PEM or DER)
--issuer-cert <FILE>YesCA certificate (issuer)
--signer-cert <FILE>YesOCSP signing certificate
--signer-key <FILE>YesOCSP signing private key
--output <FILE>No<label>.ahuOutput bundle file path
--sig-alg <ALG>Noecdsa-p256Signature algorithm (see below)
--certid-compat <MODE>NodualCertID hash compatibility mode (see below)
--epoch <N>NoautoEpoch number for anti-rollback
--good-serials <FILE>NoFile of hex serial numbers to mark as good

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

hoike sign \
  --ca enterprise-issuing-01 \
  --issuer-cert /etc/pki/ca.crt \
  --signer-cert /etc/pki/ocsp-signer.crt \
  --signer-key /etc/pki/ocsp-signer.key \
  --crl /var/lib/pki/ca.crl \
  --good-serials /var/lib/pki/good-serials.txt \
  --sig-alg ecdsa-p256 \
  --certid-compat dual \
  --epoch 42 \
  --output /var/lib/hoike/bundles/enterprise.ahu

hoike import

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

hoike import --bundle <PATH> [OPTIONS]

Options

FlagRequiredDefaultDescription
--bundle <PATH>YesPath to the ahu bundle file to import
--config <PATH>NoPath 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

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>YesHex-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>YesOutput 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

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 81 tests across 6 crates covering unit, integration, conformance, and algorithm-specific behavior.

Running all tests

cargo test --workspace

With verbose output:

cargo test --workspace -- --nocapture

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

cargo nextest run --workspace

Test categories

Unit tests

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

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

Integration tests

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

ahu integration tests

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

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

Anti-rollback tests

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

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

Conformance suite

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

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

cargo test -p hoike-server --test conformance

Each test function is named after the check it validates:

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

ML-DSA tests

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

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

Test data generation

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

cargo run --example generate -p hoike-cli

This produces:

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)