Introduction
kipuka (Hawaiian) — an area of older land surrounded by younger lava flows; an island of stability in a landscape of constant change.
kipuka is a Rust-based EST (Enrollment over Secure Transport) server that issues and renews X.509 certificates at scale. It targets environments where compliance, high availability, and hardware-backed key protection are non-negotiable: government enclaves, regulated enterprise networks, IoT device fleets, and zero-trust architectures.
What kipuka does
- Certificate enrollment and renewal — full RFC 7030 EST implementation
including
/cacerts,/simpleenroll,/simplereenroll,/serverkeygen,/fullcmc, and/csrattrs. - Multi-CA high availability — route enrollment requests to different Certificate Authorities based on EST labels, with automatic failover.
- HSM integration — PKCS #11 support via
cryptokifor hardware-backed CA signing keys (Thales Luna, YubiHSM 2, SoftHSM, and others). - One-time password enrollment — generate and validate OTPs through the admin API for initial device bootstrapping.
- Profile-based routing — EST labels map incoming requests to specific CA configurations, certificate profiles, and policy sets.
- Audit logging — structured, tamper-evident logs suitable for NIAP and CA/Browser Forum audit requirements.
- Dogtag PKI back-end — delegate signing to a Red Hat Certificate System / Dogtag PKI instance when full CA lifecycle management is needed.
- CoAP transport — constrained-device enrollment over RFC 7252 (CoAP) for IoT environments with limited bandwidth.
What kipuka does not do
- Full CA lifecycle management — kipuka is an enrollment front-end, not a complete CA. It delegates signing to local key material, an HSM, or a back-end CA such as Dogtag PKI. It does not manage CRL publication, OCSP responders, or CA key ceremonies.
- ACME — kipuka implements EST, not ACME (RFC 8555). Use a dedicated ACME server if your clients speak that protocol.
- Certificate transparency — kipuka does not submit pre-certificates to CT logs. Pair it with a CT-aware CA if your trust model requires it.
- End-entity key management — private keys for enrolled devices are
generated client-side (or via
/serverkeygen). kipuka never stores end-entity private keys beyond the lifetime of a single request.
Technology stack
| Component | Crate / Library | Role |
|---|---|---|
| Language | Rust (edition 2021) | Memory safety, performance, fearless concurrency |
| HTTP framework | axum | Async request routing and middleware |
| TLS | rustls | TLS 1.2/1.3 termination with certificate-based client auth |
| Database | sqlx | Async database access (SQLite, PostgreSQL) |
| ASN.1 / X.509 | synta | DER/BER encoding, CSR parsing, certificate construction |
| PKCS #11 | cryptoki | HSM integration for hardware-backed signing |
Standards implemented
kipuka targets conformance with the following specifications:
| Standard | Scope |
|---|---|
| RFC 7030 | Enrollment over Secure Transport (EST) |
| RFC 8951 | Clarifications and updates to EST |
| RFC 5272 | Certificate Management over CMS (Full CMC) |
| RFC 8739 | Short-term, automatically renewed certificates |
| RFC 7252 | Constrained Application Protocol (CoAP) transport |
| CA/Browser Forum Baseline Requirements | TLS certificate issuance policy |
| NIAP CA Protection Profile v2.0 | Common Criteria for Certificate Authorities |
| FIPS 140-3 | Cryptographic module validation (via HSM) |
Who this documentation is for
This book is organized for three audiences:
-
Operators — you deploy, configure, and maintain kipuka in production. Start with the Quick Start and then read the Operator Guide for the full configuration reference, HA setup, HSM integration, and audit logging.
-
API integrators — you write client software that enrolls certificates through kipuka. The API Reference documents every endpoint, request format, and response code. The Your First Certificate walkthrough gives you a working example in five minutes.
-
Contributors — you want to build kipuka from source, run the test suite, or submit patches. The Developer Guide covers the workspace layout, architecture decisions, database migrations, and contribution process.
Quick navigation
| I want to … | Start here |
|---|---|
| Run kipuka in a container in under two minutes | Installation |
| Issue my first certificate | Your First Certificate |
| Understand every configuration knob | Configuration Reference |
| Connect an HSM | HSM Integration |
| Set up multi-CA high availability | High Availability |
| Integrate with Dogtag PKI | Dogtag PKI Integration |
| Review RFC conformance details | RFC Support Reference |
| Prepare for a NIAP evaluation | NIAP CA Protection Profile |
| Read the EST API specification | EST Endpoints |
| Build from source and run tests | Development Setup |
Installation
This page covers every way to get kipuka running: pulling a pre-built container image, building from source, and installing as a systemd service.
Prerequisites
| Requirement | Minimum version | Notes |
|---|---|---|
| Rust toolchain | 1.88+ | Only needed when building from source |
| OpenSSL dev headers | 1.1.1+ or 3.x | Needed for the build; not linked at runtime (kipuka uses rustls) |
| SQLite or PostgreSQL | SQLite 3.35+ / PG 14+ | Database for OTP state and audit records |
Container (fastest)
Pre-built images are published to the kipuka container registry for both
x86_64 and aarch64:
# x86_64 (default)
podman pull registry.kipuka.dev/kipuka:latest
# Apple Silicon / ARM servers
podman pull registry.kipuka.dev/kipuka:latest-arm64
Run the container with a bind-mounted configuration directory:
podman run -d \
--name kipuka \
-p 9443:9443 \
-v /etc/kipuka:/etc/kipuka:ro \
-v /var/lib/kipuka:/var/lib/kipuka:rw \
registry.kipuka.dev/kipuka:latest \
kipuka --config /etc/kipuka/kipuka.toml
The container image ships a minimal filesystem. All state lives in
/var/lib/kipuka (database, OTP records) and all configuration is read from
/etc/kipuka. TLS certificates and CA key material are expected under
/etc/kipuka/tls/ and /etc/kipuka/ca/ respectively.
Tip: For Kubernetes or OpenShift deployments, mount the configuration as a
ConfigMapand secrets (TLS keys, CA keys) asSecretvolumes.
Building from source
Clone the repository and build in release mode:
git clone https://codeberg.org/czinda/kipuka.git
cd kipuka
cargo build --release
The workspace contains six crates:
| Crate | Purpose |
|---|---|
kipuka-est | Core EST server, HTTP handlers, TLS, database |
kipuka-hsm | PKCS #11 / HSM integration via cryptoki |
kipuka-otp | One-time password generation and validation |
kipuka-util | Shared utilities (ASN.1 helpers, configuration parsing) |
kipuka-dogtag | Dogtag PKI back-end connector |
kipuka-coap | CoAP (RFC 7252) transport layer |
The final binary is at target/release/kipuka.
OS-specific build dependencies
Fedora / RHEL / CentOS Stream
sudo dnf install openssl-devel clang cmake pkg-config
Debian / Ubuntu
sudo apt install libssl-dev clang cmake pkg-config
macOS
brew install openssl cmake
export OPENSSL_DIR=$(brew --prefix openssl)
Installing the binary
Copy the release binary to a location on $PATH:
sudo cp target/release/kipuka /usr/local/bin/
sudo chmod 755 /usr/local/bin/kipuka
Verify the installation:
kipuka --version
systemd service
Create a dedicated service account:
sudo useradd -r -s /sbin/nologin -d /var/lib/kipuka kipuka
sudo mkdir -p /var/lib/kipuka /var/log/kipuka /etc/kipuka
sudo chown kipuka:kipuka /var/lib/kipuka /var/log/kipuka
Install the unit file at /etc/systemd/system/kipuka.service:
[Unit]
Description=kipuka EST enrollment server
Documentation=https://codeberg.org/czinda/kipuka
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=kipuka
Group=kipuka
ExecStart=/usr/local/bin/kipuka --config /etc/kipuka/kipuka.toml
Restart=on-failure
RestartSec=5s
# Security hardening
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/kipuka /var/log/kipuka
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=kipuka
[Install]
WantedBy=multi-user.target
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now kipuka
sudo systemctl status kipuka
Note: The
CAP_NET_BIND_SERVICEcapability allows kipuka to bind to port 443 without running as root. If you run on a high port (e.g., 9443) you can remove bothCapabilityBoundingSetandAmbientCapabilitieslines.
Running tests
The full test suite runs against an in-memory SQLite database and does not require any external services:
cargo test
To run tests for a specific crate:
cargo test -p kipuka-est
cargo test -p kipuka-hsm
Integration tests that require a running EST server are gated behind a feature flag:
cargo test --features integration
Next: First Run walks you through creating a minimal configuration and starting the server.
First Run
This guide takes you from a freshly installed kipuka binary to a running EST
server responding to /cacerts requests. By the end you will have a working
server with a self-signed CA suitable for development and testing.
Step 1: Create directories and service account
sudo mkdir -p /etc/kipuka/{tls,ca}
sudo mkdir -p /var/lib/kipuka
sudo mkdir -p /var/log/kipuka
sudo useradd -r -s /sbin/nologin -d /var/lib/kipuka kipuka
sudo chown kipuka:kipuka /var/lib/kipuka /var/log/kipuka
| Path | Purpose |
|---|---|
/etc/kipuka/tls/ | Server TLS certificate and private key |
/etc/kipuka/ca/ | CA certificate and signing key |
/var/lib/kipuka/ | Database files, OTP state |
/var/log/kipuka/ | Audit logs (when file-based logging is enabled) |
Step 2: Generate test certificates
The repository includes a helper script that creates a complete test PKI:
./contrib/local-dev/setup-ca.sh
This generates a root CA, a server TLS certificate, and a client certificate
under contrib/local-dev/pki/. Copy the relevant files:
sudo cp contrib/local-dev/pki/ca.pem /etc/kipuka/ca/ca.pem
sudo cp contrib/local-dev/pki/ca-key.pem /etc/kipuka/ca/ca-key.pem
sudo cp contrib/local-dev/pki/server.pem /etc/kipuka/tls/server.pem
sudo cp contrib/local-dev/pki/server-key.pem /etc/kipuka/tls/server-key.pem
Manual certificate generation
If you prefer to create certificates by hand:
# Root CA
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout /etc/kipuka/ca/ca-key.pem \
-out /etc/kipuka/ca/ca.pem \
-days 3650 -nodes \
-subj "/CN=kipuka Test CA"
# Server TLS certificate
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout /etc/kipuka/tls/server-key.pem \
-out /tmp/server.csr -nodes \
-subj "/CN=localhost"
openssl x509 -req -in /tmp/server.csr \
-CA /etc/kipuka/ca/ca.pem \
-CAkey /etc/kipuka/ca/ca-key.pem \
-CAcreateserial \
-out /etc/kipuka/tls/server.pem \
-days 365 \
-extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1")
rm /tmp/server.csr
Set restrictive permissions on key material:
sudo chmod 600 /etc/kipuka/ca/ca-key.pem /etc/kipuka/tls/server-key.pem
sudo chown kipuka:kipuka /etc/kipuka/ca/* /etc/kipuka/tls/*
Step 3: Write a minimal configuration
Create /etc/kipuka/kipuka.toml:
[server]
listen = "0.0.0.0:9443"
[tls]
cert = "/etc/kipuka/tls/server.pem"
key = "/etc/kipuka/tls/server-key.pem"
[tls.client_auth]
# Trust anchor for EST client certificate authentication.
# Clients presenting a certificate signed by this CA are
# permitted to re-enroll without an OTP.
trust_anchors = ["/etc/kipuka/ca/ca.pem"]
[db]
# SQLite for development. Use a PostgreSQL URL for production.
url = "sqlite:///var/lib/kipuka/kipuka.db?mode=rwc"
[[ca]]
id = "default"
cert = "/etc/kipuka/ca/ca.pem"
key = "/etc/kipuka/ca/ca-key.pem"
# Optional: restrict which subject names this CA will sign.
# allowed_subjects = ["CN=*"]
# Optional: set a default certificate validity period.
# validity_days = 365
Production note: For production deployments, replace the
[db]URL with a PostgreSQL connection string and store CA keys in an HSM. See the Configuration Reference for the full set of options.
Step 4: Run database migrations
kipuka manages its own schema. Run the migration command before the first start:
sudo -u kipuka kipuka migrate --config /etc/kipuka/kipuka.toml
Expected output:
Applied 3 migrations to sqlite:///var/lib/kipuka/kipuka.db
Step 5: Start the server
Start kipuka in the foreground to verify everything works:
sudo -u kipuka kipuka --config /etc/kipuka/kipuka.toml
You should see log output similar to:
2026-06-24T12:00:00.000Z INFO kipuka::server: kipuka v0.1.0 starting
2026-06-24T12:00:00.001Z INFO kipuka::tls: TLS configured, client auth enabled
2026-06-24T12:00:00.002Z INFO kipuka::ca: Loaded CA "default" (CN=kipuka Test CA)
2026-06-24T12:00:00.003Z INFO kipuka::db: Database connected (sqlite)
2026-06-24T12:00:00.004Z INFO kipuka::server: Listening on 0.0.0.0:9443
Press Ctrl+C to stop. For long-running deployments, use the
systemd service instead.
Step 6: Verify the EST endpoint
The /cacerts endpoint returns the CA certificate(s) without requiring client
authentication. Use it to confirm the server is responding:
curl -sk https://localhost:9443/.well-known/est/cacerts \
| base64 -d \
| openssl pkcs7 -inform DER -print_certs
You should see the PEM-encoded CA certificate:
subject=CN = kipuka Test CA
issuer=CN = kipuka Test CA
-----BEGIN CERTIFICATE-----
MIIBkTCB+...
-----END CERTIFICATE-----
If this works, your server is ready to issue certificates.
Logging
kipuka uses Rust’s tracing framework. Control verbosity with the RUST_LOG
environment variable:
| Level | What you see |
|---|---|
RUST_LOG=error | Only errors |
RUST_LOG=warn | Errors and warnings |
RUST_LOG=info | Startup, shutdown, enrollment events (default) |
RUST_LOG=debug | Request/response details, TLS handshake info |
RUST_LOG=trace | Full ASN.1 dumps, raw bytes, internal state |
To set the log level when running directly:
RUST_LOG=debug kipuka --config /etc/kipuka/kipuka.toml
For the systemd service, add an environment override:
sudo systemctl edit kipuka
[Service]
Environment="RUST_LOG=debug"
Next: Your First Certificate walks through the complete enrollment flow — generating an OTP, submitting a CSR, and extracting the signed certificate.
Your First Certificate
This guide walks through a complete EST enrollment cycle: generating a one-time password, creating a certificate signing request, enrolling through the EST endpoint, and verifying the result. By the end you will have a signed X.509 certificate issued by your kipuka server.
Prerequisites: A running kipuka instance from the First Run
guide and the CA certificate saved as ca.pem.
Step 1: Generate a one-time password
kipuka authenticates initial enrollment requests with a one-time password (OTP). Generate one through the admin API:
curl -sk -X POST https://localhost:9443/admin/otp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"entity_id": "test-device"}'
Response:
{
"entity_id": "test-device",
"otp": "a1b2c3d4e5f6",
"expires_at": "2026-06-25T12:00:00Z"
}
Save the OTP value:
OTP="a1b2c3d4e5f6"
Note: The admin API bearer token is configured in
kipuka.tomlunder[admin]. See the Admin API Reference for details on token management.
Step 2: Generate a CSR
Create an EC P-256 key pair and a certificate signing request:
openssl req -new \
-newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout client.key \
-out client.csr \
-nodes \
-subj "/CN=test-device"
This produces two files:
| File | Contents |
|---|---|
client.key | Private key (stays on the device, never sent to the server) |
client.csr | Certificate signing request (sent to kipuka) |
Tip: For production IoT devices, generate the key pair in a secure element or TPM and export only the CSR.
Step 3: Enroll with OTP
Submit the CSR to the EST /simpleenroll endpoint, authenticating with the
entity ID and OTP as HTTP Basic credentials:
curl -sk \
--cacert ca.pem \
-u "test-device:${OTP}" \
--data-binary @client.csr \
-H "Content-Type: application/pkcs10" \
-o cert.p7 \
https://localhost:9443/.well-known/est/simpleenroll
The server returns a PKCS #7 (CMS) envelope containing the signed certificate
in DER format, saved here as cert.p7.
If enrollment succeeds, the HTTP response code is 200. Common error codes:
| Code | Meaning |
|---|---|
401 | Invalid or expired OTP |
400 | Malformed CSR |
403 | Subject name not permitted by CA policy |
503 | Back-end CA unavailable |
Step 4: Extract the certificate
Convert the PKCS #7 envelope to PEM:
openssl pkcs7 -inform DER -in cert.p7 -print_certs -out client.pem
You now have the signed certificate in client.pem.
Step 5: Verify the certificate
Inspect the certificate details:
openssl x509 -in client.pem -text -noout
Expected output (abbreviated):
Certificate:
Data:
Version: 3 (0x2)
Serial Number: ...
Signature Algorithm: ecdsa-with-SHA256
Issuer: CN = kipuka Test CA
Validity
Not Before: Jun 24 12:00:00 2026 GMT
Not After : Jun 24 12:00:00 2027 GMT
Subject: CN = test-device
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
...
Verify the certificate chains back to your CA:
openssl verify -CAfile ca.pem client.pem
client.pem: OK
Re-enrollment
Once a device holds a valid certificate, it can renew without an OTP by
authenticating with TLS client certificate authentication. The EST
/simplereenroll endpoint accepts the same CSR format:
# Generate a new CSR (optionally with a new key pair)
openssl req -new \
-newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout client-new.key \
-out client-new.csr \
-nodes \
-subj "/CN=test-device"
# Re-enroll using the existing certificate for authentication
curl -sk \
--cacert ca.pem \
--cert client.pem \
--key client.key \
--data-binary @client-new.csr \
-H "Content-Type: application/pkcs10" \
-o cert-new.p7 \
https://localhost:9443/.well-known/est/simplereenroll
Extract the renewed certificate:
openssl pkcs7 -inform DER -in cert-new.p7 -print_certs -out client-new.pem
Key rotation: The example above generates a fresh key pair during re-enrollment. This is recommended practice. If your use case requires keeping the same key, omit
-newkeyand pass the existing key with-key client.key.
EST labels: profile-based routing
kipuka supports EST labels (also called path segments) to route enrollment
requests to different CA configurations or certificate profiles. The label
appears in the URL path between /.well-known/est/ and the operation name.
For example, if you configure a label called iot-devices that maps to a
dedicated CA and profile:
[[ca]]
id = "iot"
cert = "/etc/kipuka/ca/iot-ca.pem"
key = "/etc/kipuka/ca/iot-ca-key.pem"
label = "iot-devices"
validity_days = 90
Clients enroll against the labeled path:
curl -sk \
--cacert iot-ca.pem \
-u "sensor-001:${OTP}" \
--data-binary @sensor.csr \
-H "Content-Type: application/pkcs10" \
-o sensor-cert.p7 \
https://localhost:9443/.well-known/est/iot-devices/simpleenroll
The /cacerts endpoint also respects labels, returning only the CA
certificate(s) for that label:
curl -sk https://localhost:9443/.well-known/est/iot-devices/cacerts \
| base64 -d \
| openssl pkcs7 -inform DER -print_certs
Labels are a powerful mechanism for multi-tenant and multi-profile deployments. See EST Labels in the Operator Guide for the complete configuration reference.
Summary
You have completed a full EST enrollment lifecycle:
- Generated a one-time password via the admin API
- Created a CSR with an EC P-256 key pair
- Enrolled through
/simpleenrollwith OTP authentication - Extracted and verified the signed certificate
- Learned how to re-enroll with certificate-based authentication
- Explored label-based routing for multi-CA deployments
Next steps:
- Configuration Reference — explore every
kipuka.tomloption - Authentication — configure client certificate auth policies
- HSM Integration — move CA keys into hardware
- Admin API Reference — full OTP and management API documentation
Configuration Reference
This document provides a complete reference for kipuka.toml, the main configuration file for the kipuka EST server.
Configuration Sections
[server]
Core server settings for HTTP/HTTPS listeners and runtime behavior.
| Key | Type | Default | Description |
|---|---|---|---|
listen | string | "0.0.0.0:8443" | Address and port for the main EST endpoint (HTTPS). |
admin_listen | string | "127.0.0.1:9443" | Address and port for the administrative API. Bind to localhost by default for security. |
workers | integer | num_cpus | Number of worker threads. Defaults to the number of CPU cores. |
max_body_size | string | "1MB" | Maximum allowed request body size. Accepts suffixes: B, KB, MB, GB. |
shutdown_timeout | string | "30s" | Grace period for connection draining during shutdown. Accepts suffixes: s, m, h. |
[tls]
TLS configuration for the main EST endpoint.
| Key | Type | Default | Description |
|---|---|---|---|
cert | string | required | Path to PEM-encoded server certificate. |
key | string | required | Path to PEM-encoded private key for the server certificate. |
min_version | string | "1.2" | Minimum TLS protocol version. Valid values: "1.2", "1.3". |
cipher_suites | array of strings | (TLS library defaults) | Explicit list of allowed cipher suites. Omit to use secure defaults. |
[tls.client_auth]
Mutual TLS (mTLS) client authentication settings.
| Key | Type | Default | Description |
|---|---|---|---|
trust_anchors | string | required | Path to PEM file containing trusted CA certificates for client authentication. |
mode | string | "required" | Client certificate verification mode: required, optional, or none. |
[db]
Database connection settings.
| Key | Type | Default | Description |
|---|---|---|---|
url | string | "sqlite://kipuka.db" | Database connection URL. Supports sqlite://, postgres://, and mysql:// (MariaDB) schemes. |
max_connections | integer | 5 | Maximum number of database connections in the pool. |
connect_timeout | string | "5s" | Timeout for establishing database connections. |
auto_migrate | boolean | true | Automatically apply schema migrations on startup. |
[[ca]]
Certificate Authority (CA) definitions. Multiple [[ca]] sections define multiple CAs.
| Key | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique identifier for this CA. Referenced by EST labels. |
name | string | required | Human-readable CA name. |
cert | string | required | Path to PEM-encoded CA certificate. |
key | string | required | Path to PEM-encoded CA private key (or HSM key reference). |
chain | string | optional | Path to PEM-encoded intermediate chain (if applicable). |
validity_days | integer | 365 | Default validity period for issued certificates (days). |
max_validity_days | integer | 398 | Maximum allowed validity period. RFC 5280 and NIAP recommend 398 days max. |
default_key_usage | array of strings | ["digitalSignature", "keyEncipherment"] | Default X.509 Key Usage extensions. |
default_ext_key_usage | array of strings | ["serverAuth"] | Default Extended Key Usage OIDs. |
hsm_slot | integer | optional | HSM slot number if this CA key is stored in an HSM. |
[est]
EST protocol feature flags and global settings.
| Key | Type | Default | Description |
|---|---|---|---|
base_path | string | "/.well-known/est" | Base URL path for EST endpoints. |
cacerts | boolean | true | Enable /cacerts endpoint (RFC 7030 section 4.1). |
simpleenroll | boolean | true | Enable /simpleenroll endpoint (initial certificate enrollment). |
simplereenroll | boolean | true | Enable /simplereenroll endpoint (certificate renewal). |
fullcmc | boolean | false | Enable /fullcmc endpoint (full CMC protocol). |
serverkeygen | boolean | false | Enable /serverkeygen endpoint (server-generated key pairs). |
csrattrs | boolean | true | Enable /csrattrs endpoint (CSR attribute hints). |
csr_attributes | array of strings | [] | OIDs to return in /csrattrs response. Empty means no specific attributes. |
retry_after | integer | 60 | Seconds to return in Retry-After header for pending requests. |
[[est.label]]
EST label definitions for CA-specific endpoints. Multiple [[est.label]] sections define multiple labels.
| Key | Type | Default | Description |
|---|---|---|---|
name | string | required | Label name. Appears in URL as /.well-known/est/{name}/simpleenroll. |
ca_id | string | required | ID of the CA to use for this label (references [[ca]] section). |
allowed_key_types | array of strings | [] | Restrict key types (e.g., ["rsa-2048", "ecdsa-p256"]). Empty allows all. |
required_ext_key_usage | array of strings | [] | Require specific EKU OIDs in CSR. |
max_validity_days | integer | (inherited from CA) | Override CA’s max validity for this label. |
require_san | boolean | true | Require Subject Alternative Name extension in CSR. |
subject_pattern | string | optional | Regex pattern for validating CSR subject DN. |
[hsm]
Hardware Security Module (HSM) configuration.
| Key | Type | Default | Description |
|---|---|---|---|
library | string | required | Path to PKCS#11 library (e.g., /usr/lib/softhsm/libsofthsm2.so). |
slot | integer | optional | Default HSM slot number. Can be overridden per CA. |
token_label | string | optional | Token label for slot selection. |
pin | string | optional | HSM PIN. NOT RECOMMENDED (use pin_env or pin_file instead). |
pin_env | string | optional | Environment variable containing the HSM PIN. |
pin_file | string | optional | Path to file containing the HSM PIN (first line, newline stripped). |
[otp]
One-Time Password (OTP) authentication for initial enrollment.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable OTP authentication. |
token_length | integer | 20 | Length of generated OTP tokens. NIAP requires >= 16 characters. |
default_ttl | string | "24h" | Default OTP validity period. |
max_uses | integer | 1 | Maximum times an OTP can be used before expiration. |
hash_algorithm | string | "argon2id" | Password hashing algorithm: argon2id, bcrypt, or sha256-hmac. |
max_failures | integer | 5 | Maximum failed OTP attempts before lockout. |
failure_window | string | "15m" | Time window for counting failed attempts. |
lockout_duration | string | "30m" | Duration of account lockout after exceeding max_failures. |
[audit]
Audit logging configuration.
| Key | Type | Default | Description |
|---|---|---|---|
file | string | optional | Path to audit log file. JSON Lines format. |
syslog | string | optional | Syslog server URL (e.g., tcp+tls://syslog.example.com:6514). |
syslog_facility | string | "local0" | Syslog facility for audit events. |
events | array of strings | (all events) | Filter events to log. Omit to log all. Examples: enroll, renew, reject. |
include_cert_data | boolean | false | Include full certificate PEM in audit logs. Increases log volume. |
[ha]
High Availability configuration for CA failover.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable HA mode. |
strategy | string | "active-passive" | Global failover strategy: active-passive, round-robin, weighted, latency-based. |
check_interval | string | "10s" | Health check interval for CAs. |
failure_threshold | integer | 3 | Consecutive failures before marking CA unhealthy. |
recovery_timeout | string | "60s" | Time to wait before retrying a failed CA. |
check_timeout | string | "5s" | Timeout for individual health checks. |
[[ha.group]]
HA groups for label-specific CA failover. Multiple [[ha.group]] sections define multiple groups.
| Key | Type | Default | Description |
|---|---|---|---|
name | string | required | Group name. |
ca_ids | array of strings | required | List of CA IDs in priority order. |
strategy | string | (inherited from [ha]) | Override global HA strategy for this group. |
[admin]
Administrative API configuration.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable the admin API. |
auth | string | "mtls" | Authentication method: mtls, bearer, or both. |
trust_anchors | string | optional | Path to PEM file with trusted CAs for admin mTLS. Required if auth includes mtls. |
bearer_token_env | string | optional | Environment variable containing bearer token. Required if auth includes bearer. |
Configuration Examples
Minimal Configuration
A basic single-CA deployment with one EST label.
[server]
listen = "0.0.0.0:8443"
[tls]
cert = "/etc/kipuka/server.pem"
key = "/etc/kipuka/server-key.pem"
[tls.client_auth]
trust_anchors = "/etc/kipuka/client-ca.pem"
mode = "required"
[db]
url = "sqlite:///var/lib/kipuka/kipuka.db"
[[ca]]
id = "main-ca"
name = "Main CA"
cert = "/etc/kipuka/ca.pem"
key = "/etc/kipuka/ca-key.pem"
validity_days = 365
max_validity_days = 398
[est]
base_path = "/.well-known/est"
[[est.label]]
name = "default"
ca_id = "main-ca"
require_san = true
Production Configuration
A realistic production deployment with HSM, HA, audit logging, admin API, OTP, and multiple CAs.
[server]
listen = "0.0.0.0:8443"
admin_listen = "127.0.0.1:9443"
workers = 8
max_body_size = "2MB"
shutdown_timeout = "60s"
[tls]
cert = "/etc/kipuka/certs/server.pem"
key = "/etc/kipuka/certs/server-key.pem"
min_version = "1.3"
[tls.client_auth]
trust_anchors = "/etc/kipuka/certs/client-ca-bundle.pem"
mode = "required"
[db]
url = "postgres://kipuka:password@db.example.com/kipuka?sslmode=require"
max_connections = 20
connect_timeout = "10s"
auto_migrate = true
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
slot = 0
token_label = "kipuka-prod"
pin_env = "KIPUKA_HSM_PIN"
[[ca]]
id = "root-ca"
name = "Production Root CA"
cert = "/etc/kipuka/ca/root-ca.pem"
key = "pkcs11:object=root-ca-key"
chain = "/etc/kipuka/ca/root-chain.pem"
validity_days = 365
max_validity_days = 398
default_key_usage = ["digitalSignature", "keyEncipherment"]
default_ext_key_usage = ["serverAuth", "clientAuth"]
hsm_slot = 0
[[ca]]
id = "backup-ca"
name = "Production Backup CA"
cert = "/etc/kipuka/ca/backup-ca.pem"
key = "pkcs11:object=backup-ca-key"
chain = "/etc/kipuka/ca/backup-chain.pem"
validity_days = 365
max_validity_days = 398
default_key_usage = ["digitalSignature", "keyEncipherment"]
default_ext_key_usage = ["serverAuth", "clientAuth"]
hsm_slot = 1
[[ca]]
id = "iot-ca"
name = "IoT Device CA"
cert = "/etc/kipuka/ca/iot-ca.pem"
key = "pkcs11:object=iot-ca-key"
validity_days = 180
max_validity_days = 180
default_key_usage = ["digitalSignature"]
default_ext_key_usage = ["clientAuth"]
hsm_slot = 2
[est]
base_path = "/.well-known/est"
cacerts = true
simpleenroll = true
simplereenroll = true
fullcmc = false
serverkeygen = false
csrattrs = true
csr_attributes = ["2.5.4.3", "2.5.4.11"]
retry_after = 120
[[est.label]]
name = "servers"
ca_id = "root-ca"
allowed_key_types = ["rsa-2048", "rsa-4096", "ecdsa-p256"]
required_ext_key_usage = ["serverAuth"]
max_validity_days = 398
require_san = true
subject_pattern = "^CN=.*\\.example\\.com$"
[[est.label]]
name = "clients"
ca_id = "root-ca"
allowed_key_types = ["ecdsa-p256", "ecdsa-p384"]
required_ext_key_usage = ["clientAuth"]
max_validity_days = 365
require_san = true
[[est.label]]
name = "iot"
ca_id = "iot-ca"
allowed_key_types = ["ecdsa-p256"]
required_ext_key_usage = ["clientAuth"]
max_validity_days = 180
require_san = false
[otp]
enabled = true
token_length = 20
default_ttl = "24h"
max_uses = 1
hash_algorithm = "argon2id"
max_failures = 5
failure_window = "15m"
lockout_duration = "30m"
[audit]
file = "/var/log/kipuka/audit.jsonl"
syslog = "tcp+tls://syslog.example.com:6514"
syslog_facility = "local0"
events = ["enroll", "renew", "reject", "revoke"]
include_cert_data = false
[ha]
enabled = true
strategy = "active-passive"
check_interval = "10s"
failure_threshold = 3
recovery_timeout = "60s"
check_timeout = "5s"
[[ha.group]]
name = "production"
ca_ids = ["root-ca", "backup-ca"]
strategy = "active-passive"
[[ha.group]]
name = "iot"
ca_ids = ["iot-ca"]
strategy = "active-passive"
[admin]
enabled = true
auth = "both"
trust_anchors = "/etc/kipuka/certs/admin-ca.pem"
bearer_token_env = "KIPUKA_ADMIN_TOKEN"
Notes
- Security: Never commit
kipuka.tomlwith plaintext secrets to version control. Use environment variables or HSM-backed keys. - Validation: kipuka validates the configuration at startup and reports errors for missing required fields or invalid values.
- Reloading: Configuration changes require a server restart. Plan maintenance windows for production deployments.
- HSM Keys: When using HSM-backed CA keys, specify
keyas a PKCS#11 URI (e.g.,pkcs11:object=my-key) and ensurehsm_slotmatches the token slot. - NIAP Compliance: For NIAP Common Criteria compliance, ensure
otp.token_length >= 16,tls.min_version = "1.3", andca.max_validity_days <= 398.
Certificate Authorities
kipuka supports multiple Certificate Authorities (CAs) through its [[ca]] array configuration. This architecture enables operators to serve different trust domains, key algorithms, and PKI hierarchies from a single EST server instance.
Multi-CA Architecture
Each CA in kipuka is defined by a unique id field used to bind EST labels to specific certificate authorities. Multiple CAs are configured in kipuka.toml:
[[ca]]
id = "internal-rsa"
cert = "/etc/kipuka/ca/internal-rsa-ca.crt"
key = "/etc/kipuka/ca/internal-rsa-ca.key"
chain = "/etc/kipuka/ca/internal-rsa-chain.pem"
max_validity_days = 90
[[ca]]
id = "external-ecdsa"
cert = "/etc/kipuka/ca/external-ecdsa-ca.crt"
key = "/etc/kipuka/ca/external-ecdsa-ca.key"
chain = "/etc/kipuka/ca/external-ecdsa-chain.pem"
max_validity_days = 90
Use Cases for Multiple CAs
Separate Trust Domains: Internal infrastructure certificates from one CA, external-facing services from another. This isolation prevents compromise of one domain from affecting the other.
Algorithm Migration: Run both RSA and ECDSA CAs simultaneously to support legacy clients while transitioning to modern algorithms.
Tiered Assurance Levels: Different CAs for different assurance requirements (standard validation vs. extended validation, different key sizes).
Geographic or Organizational Boundaries: Separate CAs for different regions or business units within an organization.
Development vs. Production: Dedicated CAs for testing environments prevent accidental trust of development certificates in production.
CA Lifecycle
Creating a Root CA
Generate a self-signed root CA private key and certificate:
# RSA 4096-bit root CA
openssl genrsa -out root-ca.key 4096
openssl req -new -x509 -days 7300 -key root-ca.key -out root-ca.crt \
-subj "/C=US/O=Example Corp/CN=Example Root CA"
# ECDSA P-384 root CA
openssl ecparam -name secp384r1 -genkey -noout -out root-ca.key
openssl req -new -x509 -days 7300 -key root-ca.key -out root-ca.crt \
-subj "/C=US/O=Example Corp/CN=Example Root CA"
Root CAs typically have 10-20 year validity periods and are kept offline. The root private key should be stored in secure offline storage (HSM, encrypted media in a safe).
Creating an Intermediate CA
Generate an intermediate CA signed by the root:
# Generate intermediate CA private key
openssl ecparam -name prime256v1 -genkey -noout -out intermediate-ca.key
# Create CSR for intermediate CA
openssl req -new -key intermediate-ca.key -out intermediate-ca.csr \
-subj "/C=US/O=Example Corp/CN=Example Issuing CA"
# Sign intermediate CSR with root CA (5 year validity)
openssl x509 -req -in intermediate-ca.csr -CA root-ca.crt -CAkey root-ca.key \
-CAcreateserial -out intermediate-ca.crt -days 1825 \
-extensions v3_ca -extfile openssl-ca.cnf
The openssl-ca.cnf must include intermediate CA extensions:
[v3_ca]
basicConstraints = critical,CA:TRUE,pathlen:0
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
Building the Certificate Chain
The chain file contains certificates from the issuing CA up to (but typically not including) the root:
# Create chain file (intermediate only, root excluded)
cat intermediate-ca.crt > chain.pem
# If there are multiple intermediates, order from issuing CA to root:
# cat issuing-intermediate.crt sub-intermediate.crt > chain.pem
Chain ordering is critical: the first certificate in the chain must be the issuer of the end-entity certificate, and each subsequent certificate must be the issuer of the previous one.
Configuring kipuka to Use the Intermediate
[[ca]]
id = "prod-issuing-ca"
cert = "/etc/kipuka/ca/intermediate-ca.crt"
key = "/etc/kipuka/ca/intermediate-ca.key"
chain = "/etc/kipuka/ca/chain.pem"
max_validity_days = 90
The /cacerts endpoint returns the intermediate certificate plus the chain (but not the root). Clients must have the root CA in their trust store independently.
Key Types and Recommendations
RSA Keys
# RSA 2048-bit (legacy compatibility)
openssl genrsa -out ca-rsa2048.key 2048
# RSA 3072-bit (transitional strength)
openssl genrsa -out ca-rsa3072.key 3072
# RSA 4096-bit (high assurance, slower)
openssl genrsa -out ca-rsa4096.key 4096
RSA 2048: Minimum for legacy compatibility. Acceptable for short-lived certificates (under 100 days). Avoid for new deployments.
RSA 3072: Equivalent security to AES-128. Reasonable choice for transitional deployments.
RSA 4096: Equivalent security to AES-256. Use for long-lived root CAs. Performance penalty for signing and verification operations.
ECDSA Keys
# NIST P-256 (secp256r1, prime256v1)
openssl ecparam -name prime256v1 -genkey -noout -out ca-p256.key
# NIST P-384 (secp384r1)
openssl ecparam -name secp384r1 -genkey -noout -out ca-p384.key
# NIST P-521 (secp521r1)
openssl ecparam -name secp521r1 -genkey -noout -out ca-p521.key
P-256 (prime256v1): Recommended for general use. Equivalent security to AES-128. Wide client support, fast operations, small certificate size.
P-384: Equivalent security to AES-192. Use for high-assurance environments. Moderate performance impact.
P-521: Equivalent security to AES-256. Use for maximum security requirements. Larger certificates and slower operations than P-256.
Algorithm Selection Guidance
For new deployments, prefer ECDSA P-256 unless specific requirements dictate otherwise:
- Smaller key and certificate sizes reduce bandwidth
- Faster signing and verification operations
- Equivalent or better security than RSA 2048
- Supported by all modern clients (TLS 1.2+, most TLS 1.3 implementations)
Use RSA 2048 only when legacy client compatibility is required (older embedded devices, Windows XP, ancient Java versions).
Use P-384 or RSA 4096 for root CAs in high-assurance environments where the performance penalty is acceptable.
HSM-Backed vs File-Based Keys
File-Based Keys
File-based keys are stored as PEM-encoded files on the filesystem:
[[ca]]
id = "file-based-ca"
cert = "/etc/kipuka/ca/ca.crt"
key = "/etc/kipuka/ca/ca.key"
chain = "/etc/kipuka/ca/chain.pem"
max_validity_days = 90
Advantages:
- Simple configuration and operation
- No additional hardware or drivers required
- Easy backup and disaster recovery
- Standard OpenSSL tooling for key generation
Disadvantages:
- Private key exists in cleartext on disk (even if file permissions are restrictive)
- Key compromise if filesystem is breached
- No audit trail for key usage
- Key can be copied without detection
Security Measures for File-Based Keys:
- Store keys on encrypted filesystems
- Use file permissions to restrict access (mode 0400, root-owned)
- Consider encrypted key files with passphrase protection (requires manual unlock on service start)
- Implement filesystem integrity monitoring (AIDE, Tripwire)
- Regular key rotation
HSM-Backed Keys
HSM-backed keys reference a PKCS#11 token and slot instead of filesystem paths:
[hsm]
module = "/usr/lib64/libsofthsm2.so"
pin = "1234"
[[ca]]
id = "hsm-backed-ca"
cert = "/etc/kipuka/ca/ca.crt"
hsm_slot = 0
chain = "/etc/kipuka/ca/chain.pem"
max_validity_days = 90
Advantages:
- Private key never leaves the HSM hardware
- Tamper-resistant hardware with physical security controls
- Audit logging of all key usage operations
- FIPS 140-2/140-3 compliance available
- Key backup and recovery through HSM vendor mechanisms
Disadvantages:
- Additional hardware cost and complexity
- PKCS#11 driver setup and maintenance
- Potential performance bottleneck for high-volume signing
- Vendor lock-in for key backup/recovery
- Operational complexity (HSM initialization, token management, PIN management)
HSM Selection Considerations:
Network HSMs (Thales Luna, Entrust nShield) provide centralized key management and high availability but introduce network dependencies.
USB HSMs (YubiHSM, Nitrokey HSM) offer lower cost and simpler deployment but limited performance and single points of failure.
Software HSMs (SoftHSM) provide PKCS#11 API compatibility for testing but offer no security advantage over file-based keys.
For production issuing CAs, network HSMs are recommended. For offline root CAs, USB HSMs stored in physically secure locations are acceptable.
Certificate Chain Configuration
The chain parameter provides the certificate chain from the issuing CA to the root (root typically excluded). This chain is returned via the /cacerts endpoint.
Chain File Format
Chain files are PEM-encoded, containing one or more certificates. Order matters:
-----BEGIN CERTIFICATE-----
[Issuing Intermediate CA Certificate]
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
[Sub-Intermediate CA Certificate, if any]
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
[Root CA Certificate - optional, often excluded]
-----END CERTIFICATE-----
Chain Ordering Rules
- First certificate is the issuer of the end-entity certificates that the CA signs
- Each subsequent certificate is the issuer of the previous certificate
- Last certificate is either the root CA or the certificate directly issued by the root
- Root CA is often excluded from the chain (clients must have it in their trust store)
Why Exclude the Root CA
Most TLS/PKI implementations expect clients to have the root CA in their local trust store. Including the root in the chain:
- Increases bandwidth unnecessarily
- May cause validation issues with strict clients
- Violates some certificate profile specifications
However, some closed environments include the root in the chain for convenience. kipuka supports both approaches.
Verifying Chain Correctness
# Verify the intermediate can be validated against the root
openssl verify -CAfile root-ca.crt intermediate-ca.crt
# Verify the complete chain
openssl verify -CAfile root-ca.crt -untrusted chain.pem end-entity.crt
Multiple Intermediate Levels
For deep hierarchies (root -> policy CA -> issuing CA):
# Build chain with two intermediates
cat issuing-ca.crt policy-ca.crt > chain.pem
# Root CA is still excluded
kipuka returns the issuing CA certificate concatenated with the chain file contents via /cacerts.
CA/B Forum Validity Limits
The CA/Browser Forum mandates maximum validity periods for publicly-trusted certificates. These limits progressively shorten:
| Effective Date | Maximum Validity |
|---|---|
| Current | 398 days |
| March 15, 2026 | 200 days |
| March 15, 2027 | 100 days |
| March 15, 2029 | 47 days |
Configuring max_validity_days
The max_validity_days parameter in each [[ca]] section enforces these limits:
[[ca]]
id = "public-ca-2026"
cert = "/etc/kipuka/ca/ca.crt"
key = "/etc/kipuka/ca/ca.key"
chain = "/etc/kipuka/ca/chain.pem"
max_validity_days = 200 # Ready for March 2026 limit
kipuka rejects CSRs requesting validity beyond max_validity_days. Clients must request shorter lifetimes or accept the CA’s default.
Planning for Shorter Lifetimes
March 2026 (200 days): Transition to quarterly certificate renewal cycles. Most organizations can adapt existing manual processes.
March 2027 (100 days): Manual renewal becomes impractical. EST-based automation is essential. Plan for 90-day certificate lifetimes to allow renewal buffer.
March 2029 (47 days): Monthly or bi-weekly renewal cycles. Full automation required. No manual certificate issuance workflows can scale.
Why Shorter Lifetimes Drive EST Adoption
Reduced Blast Radius: Compromised keys have limited validity windows. Stolen certificates expire quickly.
Faster Cryptographic Agility: Transitioning to new algorithms (post-quantum cryptography) becomes operationally feasible when certificates renew frequently.
Improved Revocation Handling: Short lifetimes reduce reliance on CRL and OCSP infrastructure. Expired certificates are automatically untrusted.
Forcing Automation: Organizations must build robust certificate lifecycle management. This operational maturity pays dividends in incident response and compliance.
EST as the Solution: Manual CSR generation, approval workflows, and certificate installation cannot scale to 47-day lifetimes. EST provides:
- Automated enrollment via
/simpleenroll - Automated renewal via
/simplereenroll - Automated trust anchor updates via
/cacerts - Standard protocol implemented by network equipment, IoT devices, operating systems
Operational Recommendations
Set max_validity_days to 90 days for new CAs. This provides:
- Compliance with current and near-term CA/B Forum limits
- 30-day renewal buffer (renew at 60 days remaining)
- Operational experience with short-lived certificates before 47-day limit
For existing CAs, plan a staged reduction:
- 2026: 180 days
- 2027: 90 days
- 2029: 45 days
Use monitoring to track certificate expiration across your fleet. Alert when certificates are not renewed on schedule. EST’s /simplereenroll should be triggered automatically when 1/3 of the validity period remains.
Internal vs. Publicly-Trusted CAs
CA/B Forum limits apply only to publicly-trusted CAs (trust anchors in browser and OS trust stores). Internal CAs can use longer lifetimes:
[[ca]]
id = "internal-ca"
cert = "/etc/kipuka/ca/internal-ca.crt"
key = "/etc/kipuka/ca/internal-ca.key"
chain = "/etc/kipuka/ca/chain.pem"
max_validity_days = 365 # Internal CA, not subject to CA/B Forum
However, adopting short lifetimes even for internal CAs provides operational benefits (automation, reduced compromise windows) and prepares infrastructure for future regulatory requirements.
EST Labels
EST labels provide a powerful mechanism for operating multiple certificate profiles from a single EST server instance. This chapter covers label configuration, policy enforcement, and practical deployment patterns.
What Are EST Labels
RFC 7030 Section 3.2.2 defines EST path segments that enable certificate profile selection. The URL pattern for enrollment operations follows this structure:
/.well-known/est/<label>/simpleenroll
/.well-known/est/<label>/simplereenroll
When a client makes a request without a label (e.g., /.well-known/est/simpleenroll), the EST server applies its default certificate profile. Labels allow a single EST server to issue different types of certificates with different policies, CA issuers, and validation rules.
Common use cases include:
- Certificate purpose separation: Different labels for TLS server certificates, client authentication certificates, and code signing certificates
- Policy enforcement: Distinct labels enforcing different validity periods, key requirements, or subject naming constraints
- Multi-tenant operation: Separate labels for different organizational units or trust boundaries
- CA hierarchy management: Different labels issuing from different intermediate CAs within the same PKI
Label Configuration
Each [[est.label]] section in kipuka.toml defines a named profile. Labels are bound to specific CAs and enforce policy constraints on certificate issuance.
Full Label Syntax
[[est.label]]
# Unique label name used in EST URLs
name = "server-tls"
# CA ID reference (must match a [[ca]] entry)
ca_id = "intermediate-tls"
# Allowed CSR key types (optional)
# If omitted, all key types are accepted
allowed_key_types = ["ecdsaP256", "ecdsaP384", "rsa2048", "rsa3072"]
# Required Extended Key Usage OIDs (optional)
# Forces these EKUs into all issued certificates
required_ext_key_usage = ["serverAuth"]
# Maximum certificate validity in days (optional)
# Overrides CA default if set
max_validity_days = 398
# Require Subject Alternative Names (default: true)
require_san = true
# Subject DN validation regex (optional)
# CSRs must match this pattern to be accepted
subject_pattern = "^CN=[a-z0-9-]+\\.example\\.com(,.*)?$"
All fields except name and ca_id are optional. Without constraints, the label inherits behavior from the referenced CA.
CA Binding
Every label must reference a CA through ca_id. This CA identifier must match the id field of a [[ca]] entry defined elsewhere in kipuka.toml.
Multiple Labels, Same CA
Different labels can reference the same CA to provide different policy enforcement while using the same issuer:
[[ca]]
id = "corp-intermediate"
cert_path = "/etc/kipuka/pki/intermediate.crt"
key_path = "/etc/kipuka/pki/intermediate.key"
default_validity_days = 365
[[est.label]]
name = "server"
ca_id = "corp-intermediate"
required_ext_key_usage = ["serverAuth"]
max_validity_days = 398
[[est.label]]
name = "client"
ca_id = "corp-intermediate"
required_ext_key_usage = ["clientAuth"]
max_validity_days = 90
require_san = false
Both labels issue certificates from the same intermediate CA but enforce different EKUs, validity periods, and SAN requirements.
Multiple Labels, Different CAs
Labels can reference different CAs to support complex PKI hierarchies:
[[ca]]
id = "public-tls-ca"
cert_path = "/etc/kipuka/pki/public-tls-intermediate.crt"
key_path = "/etc/kipuka/pki/public-tls-intermediate.key"
[[ca]]
id = "internal-device-ca"
cert_path = "/etc/kipuka/pki/internal-device-intermediate.crt"
key_path = "/etc/kipuka/pki/internal-device-intermediate.key"
[[est.label]]
name = "public-server"
ca_id = "public-tls-ca"
required_ext_key_usage = ["serverAuth"]
[[est.label]]
name = "iot-device"
ca_id = "internal-device-ca"
required_ext_key_usage = ["clientAuth"]
max_validity_days = 30
This configuration issues public TLS certificates from one CA and private IoT device certificates from another.
Key Type Restrictions
The allowed_key_types field restricts which cryptographic key algorithms are accepted in certificate signing requests. This prevents weak key types or enforces organizational cryptographic policies.
Supported Key Type Values
allowed_key_types = [
"rsa2048", # RSA with 2048-bit modulus
"rsa3072", # RSA with 3072-bit modulus
"rsa4096", # RSA with 4096-bit modulus
"ecdsaP256", # ECDSA with NIST P-256 curve
"ecdsaP384", # ECDSA with NIST P-384 curve
"ecdsaP521", # ECDSA with NIST P-521 curve
]
If allowed_key_types is omitted, the label accepts any supported key type. An empty array [] rejects all requests.
Example: ECDSA-Only Label
[[est.label]]
name = "modern-tls"
ca_id = "tls-ca"
allowed_key_types = ["ecdsaP256", "ecdsaP384"]
This configuration rejects all RSA CSRs and only accepts P-256 or P-384 ECDSA keys.
EKU Constraints
The required_ext_key_usage field forces specific Extended Key Usage OIDs into all certificates issued under this label. This ensures certificates are only used for their intended purpose.
Common EKU Values
required_ext_key_usage = [
"serverAuth", # TLS server authentication (1.3.6.1.5.5.7.3.1)
"clientAuth", # TLS client authentication (1.3.6.1.5.5.7.3.2)
"emailProtection", # S/MIME email (1.3.6.1.5.5.7.3.4)
"codeSigning", # Code signing (1.3.6.1.5.5.7.3.3)
]
You can specify multiple EKUs for dual-purpose certificates. If the field is omitted, the label does not enforce EKU requirements (though the CA may still add EKUs based on its own policy).
Example: Mutual TLS Label
[[est.label]]
name = "mtls"
ca_id = "corp-ca"
required_ext_key_usage = ["serverAuth", "clientAuth"]
Certificates issued under this label can be used for both server and client authentication.
SAN Requirements
The require_san field enforces the presence of Subject Alternative Names in certificate signing requests. This follows CA/Browser Forum Baseline Requirements, which mandate SAN extensions for publicly trusted TLS certificates.
require_san = true # Default: reject CSRs without SAN
require_san = false # Accept CSRs without SAN
When require_san = true, the EST server rejects any CSR that does not contain at least one SAN entry (DNS name, IP address, email, or URI).
When to Disable SAN Requirements
Set require_san = false for:
- Client authentication certificates: User certificates often have meaningful Subject DNs but no SAN
- Legacy device certificates: Older systems may not support SAN extension generation
- Internal PKI: Private infrastructures may rely solely on Subject DN for identification
[[est.label]]
name = "user-cert"
ca_id = "user-ca"
required_ext_key_usage = ["clientAuth", "emailProtection"]
require_san = false # Allow subject-only certificates
Subject DN Patterns
The subject_pattern field validates the Subject Distinguished Name of incoming CSRs using regular expressions. This prevents clients from requesting certificates with arbitrary subject fields.
Pattern Syntax
# Exact CN match
subject_pattern = "^CN=server\\.example\\.com$"
# Domain restriction with variable hostname
subject_pattern = "^CN=[a-z0-9-]+\\.example\\.com$"
# Multiple RDN components
subject_pattern = "^CN=[^,]+,O=Example Corp,C=US$"
# Flexible ordering (less restrictive)
subject_pattern = "CN=[a-z0-9-]+\\.example\\.com"
The regex engine is Rust’s regex crate, which supports standard PCRE-like syntax. Patterns are case-sensitive by default.
Example: Locked-Down Production Label
[[est.label]]
name = "prod-server"
ca_id = "prod-ca"
subject_pattern = "^CN=(www|api|mail)\\.example\\.com(,O=Example Corp)?(,C=US)?$"
required_ext_key_usage = ["serverAuth"]
allowed_key_types = ["ecdsaP256", "ecdsaP384"]
This pattern only allows CSRs with CN values of www.example.com, api.example.com, or mail.example.com, with optional Organization and Country fields.
Practical Examples
Server TLS Label
Configuration for public-facing TLS server certificates following CA/B Forum requirements:
[[ca]]
id = "public-tls-ca"
cert_path = "/etc/kipuka/pki/tls-intermediate.crt"
key_path = "/etc/kipuka/pki/tls-intermediate.key"
default_validity_days = 365
[[est.label]]
name = "server-tls"
ca_id = "public-tls-ca"
allowed_key_types = ["ecdsaP256", "ecdsaP384", "rsa2048", "rsa3072"]
required_ext_key_usage = ["serverAuth"]
max_validity_days = 398 # CA/B Forum 398-day limit
require_san = true
subject_pattern = "^CN=[a-z0-9-]+\\.example\\.com$"
Client enrollment:
# Generate CSR with SAN
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-nodes -keyout server-key.pem -out server-csr.pem \
-subj "/CN=web.example.com" \
-addext "subjectAltName=DNS:web.example.com,DNS:www.example.com"
# Enroll via EST
curl --cacert ca.pem --cert client.pem --key client-key.pem \
--data-binary @server-csr.pem \
-H "Content-Type: application/pkcs10" \
-o server-cert.p7 \
https://est.example.com/.well-known/est/server-tls/simpleenroll
# Extract certificate from PKCS#7
openssl pkcs7 -in server-cert.p7 -inform DER -print_certs -out server.pem
Client Authentication Label
Configuration for mutual TLS client certificates with shorter validity and relaxed SAN requirements:
[[ca]]
id = "client-ca"
cert_path = "/etc/kipuka/pki/client-intermediate.crt"
key_path = "/etc/kipuka/pki/client-intermediate.key"
[[est.label]]
name = "client-auth"
ca_id = "client-ca"
allowed_key_types = ["ecdsaP256", "rsa2048"]
required_ext_key_usage = ["clientAuth"]
max_validity_days = 90
require_san = false
subject_pattern = "^CN=[a-z]+\\.[a-z]+@example\\.com$"
Client enrollment:
# Generate CSR (no SAN required)
openssl req -new -newkey rsa:2048 -nodes \
-keyout client-key.pem -out client-csr.pem \
-subj "/CN=john.doe@example.com"
# Enroll via EST
curl --cacert ca.pem --cert bootstrap.pem --key bootstrap-key.pem \
--data-binary @client-csr.pem \
-H "Content-Type: application/pkcs10" \
-o client-cert.p7 \
https://est.example.com/.well-known/est/client-auth/simpleenroll
# Extract certificate
openssl pkcs7 -in client-cert.p7 -inform DER -print_certs -out client.pem
Device Identity Label
Configuration for IoT device certificates with strict key type enforcement and short validity:
[[ca]]
id = "device-ca"
cert_path = "/etc/kipuka/pki/device-intermediate.crt"
key_path = "/etc/kipuka/pki/device-intermediate.key"
[[est.label]]
name = "device"
ca_id = "device-ca"
allowed_key_types = ["ecdsaP256"] # P-256 only for embedded devices
required_ext_key_usage = ["clientAuth"]
max_validity_days = 30
require_san = true
subject_pattern = "^CN=device-[0-9a-f]{8}$"
Device enrollment:
# Generate CSR with device-specific CN and SAN
DEVICE_ID="device-a1b2c3d4"
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-nodes -keyout device-key.pem -out device-csr.pem \
-subj "/CN=${DEVICE_ID}" \
-addext "subjectAltName=URI:urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890"
# Enroll via EST
curl --cacert ca.pem --cert device-bootstrap.pem --key device-bootstrap-key.pem \
--data-binary @device-csr.pem \
-H "Content-Type: application/pkcs10" \
-o device-cert.p7 \
https://est.example.com/.well-known/est/device/simpleenroll
# Extract certificate
openssl pkcs7 -in device-cert.p7 -inform DER -print_certs -out device.pem
Label Selection Strategy
When designing your EST label architecture, consider:
-
Certificate Purpose Separation: Create distinct labels for server authentication, client authentication, email protection, and code signing. Never mix purposes in a single label.
-
Security Posture: Use restrictive labels for production workloads (
subject_pattern, strictallowed_key_types) and permissive labels for development environments. -
Validity Period: Match
max_validity_daysto certificate rotation cadence. Short-lived certificates (30-90 days) for automated systems, longer validity for manually managed certificates. -
CA Hierarchy Mapping: Map labels to CAs based on trust requirements. Public-facing services may require certificates from a different CA than internal infrastructure.
-
Client Capabilities: Consider what key types and extensions your clients can generate. Legacy systems may require
require_san = falseand broaderallowed_key_types.
Label Discovery
Clients can discover available labels through the EST /csrattrs endpoint:
curl --cacert ca.pem --cert client.pem --key client-key.pem \
https://est.example.com/.well-known/est/server-tls/csrattrs
However, RFC 7030 does not define a standard mechanism for enumerating all labels. Operators should document available labels and their intended purposes in client onboarding documentation.
Validation Behavior
When a client submits a CSR to a labeled endpoint, kipuka performs validation in this order:
- Key type validation: If
allowed_key_typesis set, reject CSRs with non-matching key algorithms - SAN validation: If
require_san = true, reject CSRs without SAN extension - Subject DN validation: If
subject_patternis set, reject CSRs with non-matching Subject DN - EKU injection: Add
required_ext_key_usageOIDs to the issued certificate (does not validate CSR EKUs) - Validity enforcement: Apply
max_validity_dayslimit, overriding CA default if stricter
If any validation step fails, the EST server returns HTTP 400 Bad Request with an error message indicating which constraint was violated.
Performance Considerations
Label lookups occur on every EST request. For deployments with hundreds of labels, ensure kipuka.toml is stored on fast local storage. Label matching is O(n) in the number of configured labels.
If you anticipate very large numbers of labels (>100), consider deploying multiple kipuka instances, each serving a subset of labels, behind a reverse proxy that routes based on URL path.
Authentication
kipuka supports multiple authentication methods to accommodate different enrollment scenarios and enterprise environments. This chapter covers the three primary authentication mechanisms available for EST endpoints.
Authentication Methods
mTLS (Mutual TLS) Authentication
Mutual TLS is the primary authentication method for EST as defined in RFC 7030. The client presents a certificate during the TLS handshake, which the server validates against a configured trust store.
Configuration
mTLS is configured in the [tls.client_auth] section:
[tls]
# ... other TLS settings ...
[tls.client_auth]
# Path to CA bundle containing trusted client certificate issuers
trust_anchors = "/etc/kipuka/client-ca-bundle.pem"
# Client certificate verification mode
mode = "required" # required | optional | none
Mode Options:
required: All EST endpoints require a valid client certificate. The TLS handshake fails if no certificate is presented or if the certificate is not signed by a trusted CA.optional: Client certificate is verified if presented, but clients without certificates can still connect. This mode allows fallback to OTP or GSSAPI for initial enrollment.required_for_reenroll: Enrollment optionally requires a client certificate, but reenrollment is only possible using an existing client certificate.none: No client certificate verification. Not recommended for production environments.
Trust Anchors:
The trust_anchors file should contain one or more PEM-encoded CA certificates. These CAs define which client certificates are accepted for authentication.
# Example: concatenate multiple CA certificates
cat corporate-ca.pem partner-ca.pem > /etc/kipuka/client-ca-bundle.pem
Re-enrollment Workflow
For re-enrollment (/simplereenroll), the client typically uses its previously-issued certificate as the client certificate:
- Client initiates TLS connection with its current certificate
- Server validates certificate against trust anchors
- Client submits CSR for new certificate
- Server issues new certificate
Example: Initial Enrollment with mTLS
For clients that already have a certificate (e.g., issued by a different CA or manually installed):
curl --cert client.crt --key client.key \
--cacert est-server-ca.pem \
-H "Content-Type: application/pkcs10" \
--data-binary @request.csr \
https://est.example.com:8443/.well-known/est/simpleenroll
Example: Re-enrollment
# Use the previously-issued certificate for authentication
curl --cert current.crt --key current.key \
--cacert est-server-ca.pem \
-H "Content-Type: application/pkcs10" \
--data-binary @renewal.csr \
https://est.example.com:8443/.well-known/est/simplereenroll
OTP (One-Time Password) Authentication
OTP authentication is designed for initial enrollment scenarios where the client does not yet have a certificate. The administrator generates a token via the Admin API, delivers it to the client through a secure out-of-band channel, and the client uses it for authentication.
Configuration
[otp]
# Enable OTP authentication
enabled = true
# Hash algorithm for token storage
# Options: argon2id (recommended), bcrypt, sha256-hmac
hash_algorithm = "argon2id"
# Minimum token length (NIAP PP requires >= 16)
token_length = 20
# Single-use or multi-use tokens
max_uses = 1
# Rate limiting configuration
max_failures = 5
failure_window = 300 # 5 minutes
lockout_duration = 900 # 15 minutes
Hash Algorithms:
argon2id(recommended): NIAP-compliant, memory-hard algorithm resistant to GPU attacksbcrypt: Industry-standard password hashingsha256-hmac: Fast but less secure; use only for low-security environments
All implementations use timing-safe comparison to prevent timing attacks.
Rate Limiting:
max_failures: Maximum authentication failures before lockoutfailure_window: Time window (seconds) for counting failureslockout_duration: How long (seconds) the client is locked out after exceeding max_failures
OTP Workflow
- Token Generation: Administrator calls the Admin API to generate an OTP token
- Out-of-Band Delivery: Token is securely delivered to the client (email, SMS, secure portal, etc.)
- Client Authentication: Client uses the token in HTTP Basic authentication
- Certificate Issuance: Server validates token and issues certificate
- Future Renewals: Client uses mTLS with the issued certificate
Generating OTP Tokens
Tokens are generated via the Admin API:
# Generate a single-use OTP token for a client
curl -X POST https://est.example.com:8443/admin/otp \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{
"client_id": "workstation-42",
"max_uses": 1,
"expires_at": "2026-06-30T23:59:59Z"
}'
Response:
{
"token": "xK9mP2vL8qR5wN3jT7fY",
"client_id": "workstation-42",
"created_at": "2026-06-24T10:15:00Z",
"expires_at": "2026-06-30T23:59:59Z",
"max_uses": 1
}
Using OTP for Enrollment
The client sends the OTP token as the password in HTTP Basic authentication. The username is ignored but should be provided for RFC compliance:
# Enroll using OTP authentication
curl -u ":xK9mP2vL8qR5wN3jT7fY" \
--cacert est-server-ca.pem \
-H "Content-Type: application/pkcs10" \
--data-binary @request.csr \
https://est.example.com:8443/.well-known/est/simpleenroll
After successful enrollment, the client stores the issued certificate and uses mTLS for all future operations, including re-enrollment.
NIAP PP Compliance
For NIAP Protection Profile compliance:
- Set
token_length >= 16 - Use
hash_algorithm = "argon2id" - Configure appropriate rate limiting
- Enable audit logging for OTP generation and usage
GSSAPI/Kerberos Authentication
GSSAPI authentication provides enterprise Single Sign-On (SSO) integration using Kerberos tickets. This is particularly useful in Active Directory and FreeIPA environments.
Configuration
[gssapi]
# Enable GSSAPI/Kerberos authentication
enabled = true
# Path to server's keytab file
keytab = "/etc/kipuka/kipuka.keytab"
# Service principal name
# Format: HTTP/hostname@REALM
service_principal = "HTTP/est.example.com@EXAMPLE.COM"
# Principal to certificate subject mapping
# Maps Kerberos principal to X.509 subject DN
[gssapi.principal_mapping]
"user@EXAMPLE.COM" = "CN=User,OU=People,DC=example,DC=com"
"admin@EXAMPLE.COM" = "CN=Admin,OU=Admins,DC=example,DC=com"
# Default mapping template (optional)
# {principal} is replaced with the authenticated Kerberos principal
default_template = "CN={principal},OU=Users,DC=example,DC=com"
Server Setup
The EST server must have a keytab containing credentials for the HTTP service principal:
# On Active Directory or FreeIPA KDC, create the service principal
# and export the keytab
# FreeIPA example:
ipa service-add HTTP/est.example.com
ipa-getkeytab -s ipaserver.example.com \
-p HTTP/est.example.com \
-k /etc/kipuka/kipuka.keytab
# Set appropriate permissions
chown kipuka:kipuka /etc/kipuka/kipuka.keytab
chmod 600 /etc/kipuka/kipuka.keytab
Client Authentication
Clients use SPNEGO (RFC 4559) to send Kerberos tickets via the Negotiate HTTP authentication scheme:
# Obtain Kerberos ticket
kinit user@EXAMPLE.COM
# Enroll using GSSAPI authentication
curl --negotiate -u : \
--cacert est-server-ca.pem \
-H "Content-Type: application/pkcs10" \
--data-binary @request.csr \
https://est.example.com:8443/.well-known/est/simpleenroll
The server:
- Receives the
Negotiateheader with Kerberos ticket - Validates the ticket against the KDC
- Extracts the authenticated principal name
- Maps the principal to a certificate subject DN using the configured mapping rules
- Issues a certificate with the mapped subject
Principal Mapping
The principal_mapping table defines explicit mappings from Kerberos principals to certificate subject DNs. If no explicit mapping exists, the default_template is used (if configured).
Example: Principal alice@EXAMPLE.COM with default template "CN={principal},OU=Users,DC=example,DC=com" results in subject CN=alice@EXAMPLE.COM,OU=Users,DC=example,DC=com.
Authentication per Endpoint
Different EST endpoints have different authentication requirements:
| Endpoint | Authentication | Notes |
|---|---|---|
/cacerts | None | Public endpoint; returns CA certificate chain |
/csrattrs | None | Public endpoint; returns CSR attributes |
/simpleenroll | mTLS OR OTP OR GSSAPI | Initial enrollment; at least one method must succeed |
/simplereenroll | mTLS required | Re-enrollment requires an existing valid certificate |
/serverkeygen | mTLS OR OTP OR GSSAPI | Server-side key generation; same as simpleenroll |
/fullcmc | mTLS required | Full CMC protocol requires client certificate |
/admin/* | Admin auth | Separate authentication via [admin] section |
Admin Endpoint Authentication
Admin endpoints (OTP generation, certificate revocation, etc.) use separate authentication configured in the [admin] section:
[admin]
# mTLS for admin endpoints
[admin.tls]
trust_anchors = "/etc/kipuka/admin-ca-bundle.pem"
mode = "required"
# Bearer token authentication (alternative or in addition to mTLS)
[admin.bearer_token]
enabled = true
tokens = [
{ token_hash = "sha256:...", description = "CI/CD automation" },
{ token_hash = "sha256:...", description = "Admin portal" }
]
Admin authentication is independent of EST endpoint authentication. You can require mTLS for admin operations even if EST endpoints accept OTP or GSSAPI.
Authentication Precedence
When multiple authentication methods are enabled, kipuka evaluates them in the following order:
- mTLS: If a client certificate is presented (and
mode != "none"), it is validated first - GSSAPI: If no valid client certificate and
Negotiateheader is present, GSSAPI is attempted - OTP: If no valid client certificate and
Authorization: Basicheader is present, OTP is checked
A request succeeds if any enabled method authenticates successfully. For endpoints requiring mTLS (e.g., /simplereenroll), only mTLS is evaluated.
Security Best Practices
- Production environments: Use
mode = "required"for mTLS and disable OTP after initial enrollment - Initial enrollment: Use
mode = "optional"with OTP enabled, then migrate tomode = "required"after all devices are enrolled - Token delivery: Never send OTP tokens over the same channel as enrollment (e.g., do not email a token to an address that auto-forwards to the EST client)
- Token entropy: Use
token_length >= 20for high-security environments - Keytab protection: Store GSSAPI keytabs with restrictive permissions (600) and limit access to the kipuka service account
- Audit logging: Enable audit logs for all authentication events to detect suspicious activity
Troubleshooting
mTLS Issues
Error: “client certificate required”
- Verify
modeis set tooptionalornoneif client does not have a certificate - Check that the client is sending a certificate (
--certand--keyin curl)
Error: “certificate signed by unknown authority”
- Ensure the client certificate is signed by a CA in
trust_anchors - Verify the CA bundle is readable and contains valid PEM data
OTP Issues
Error: “invalid OTP token”
- Check token expiration (
expires_at) - Verify token has not exceeded
max_uses - Ensure token is sent as password in Basic auth (username can be empty or any value)
Error: “too many failures, locked out”
- Wait for
lockout_durationto expire - Review rate limiting configuration (
max_failures,failure_window)
GSSAPI Issues
Error: “GSSAPI authentication failed”
- Verify client has a valid Kerberos ticket (
klist) - Check server’s keytab is readable and contains the correct principal
- Ensure clocks are synchronized between client, server, and KDC (Kerberos requires time sync within 5 minutes)
Error: “principal not mapped”
- Add an explicit mapping in
[gssapi.principal_mapping]or configuredefault_template - Check that the authenticated principal name matches the expected format
TLS Configuration
This guide covers TLS configuration for the kipuka EST server, including certificate requirements, protocol versions, cipher suites, client authentication modes, and production deployment scenarios.
Server Certificate Requirements
RFC 7030 Section 3.3.2 mandates that EST server certificates include the id-kp-cmcRA Extended Key Usage (EKU) with OID 1.3.6.1.5.5.7.3.28. This EKU identifies the server as an authorized Registration Authority (RA) for certificate enrollment operations.
Without this EKU, compliant EST clients may reject the server certificate, even if it is otherwise valid. In production deployments, include both:
- id-kp-cmcRA (
1.3.6.1.5.5.7.3.28) - Required for EST protocol compliance - id-kp-serverAuth (
1.3.6.1.5.5.7.3.1) - Standard server authentication for web browser compatibility
Generating a Server Certificate with cmcRA EKU
Create an OpenSSL configuration file (est-server.cnf) with the required extensions:
[ req ]
default_bits = 2048
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[ req_distinguished_name ]
CN = est.example.com
O = Example Organization
C = US
[ v3_req ]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, 1.3.6.1.5.5.7.3.28
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = est.example.com
DNS.2 = kipuka.example.com
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, keyCertSign, cRLSign
Generate the server private key and CSR:
# Generate private key
openssl genrsa -out est-server-key.pem 2048
# Generate certificate signing request
openssl req -new -key est-server-key.pem \
-out est-server.csr \
-config est-server.cnf
# Sign with your CA (replace ca-cert.pem and ca-key.pem)
openssl x509 -req -in est-server.csr \
-CA ca-cert.pem -CAkey ca-key.pem \
-CAcreateserial -out est-server-cert.pem \
-days 365 -sha256 \
-extensions v3_req -extfile est-server.cnf
# Verify the cmcRA EKU is present
openssl x509 -in est-server-cert.pem -text -noout | grep -A1 "Extended Key Usage"
Expected output should include:
X509v3 Extended Key Usage:
TLS Web Server Authentication, 1.3.6.1.5.5.7.3.28
Self-Signed Certificate for Development
For development and testing only:
# Generate self-signed certificate with cmcRA EKU
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout est-server-key.pem \
-out est-server-cert.pem \
-days 365 -sha256 \
-config est-server.cnf \
-extensions v3_req
Warning: Self-signed certificates should never be used in production. Always obtain certificates from a trusted CA.
TLS Version Configuration
The min_version parameter in the [tls] section controls the minimum TLS protocol version accepted by the server.
[tls]
min_version = "1.2" # or "1.3"
Supported Values
"1.2"(default): Accept TLS 1.2 and TLS 1.3 connections. This is the minimum version required for RFC 7030 compliance and maintains compatibility with older EST clients."1.3": Accept only TLS 1.3 connections. Recommended for new deployments where all clients support TLS 1.3, as it provides improved security and performance.
TLS 1.0 and TLS 1.1 are not supported, as they have been deprecated by RFC 8996 and are considered insecure.
Recommendation
For production environments deployed after 2024, set min_version = "1.3" unless you have specific legacy client requirements. TLS 1.3 removes obsolete cryptographic algorithms, reduces handshake latency, and encrypts more of the handshake metadata.
Cipher Suite Configuration
The cipher_suites array in the [tls] section specifies the allowed cipher suites. If omitted, kipuka uses a secure default set that excludes weak ciphers.
[tls]
cipher_suites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
]
TLS 1.2 Cipher Suites
The default cipher suite list for TLS 1.2 connections includes:
cipher_suites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
]
All suites provide:
- Forward Secrecy via ECDHE key exchange
- AEAD encryption (GCM or ChaCha20-Poly1305)
- Strong hash functions (SHA-256 or SHA-384)
TLS 1.3 Cipher Suites
TLS 1.3 defines only three mandatory cipher suites, which are always enabled when min_version = "1.3":
TLS_AES_256_GCM_SHA384TLS_AES_128_GCM_SHA256TLS_CHACHA20_POLY1305_SHA256
The cipher_suites configuration parameter does not affect TLS 1.3, as the protocol specification mandates these suites.
NIAP and FIPS Compliance
For systems requiring NIAP Common Criteria or FIPS 140-2/3 compliance, disable ChaCha20-Poly1305 cipher suites, as they are not FIPS-approved algorithms:
[tls]
cipher_suites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
]
Consult your organization’s security policy for approved cipher suites.
Client Authentication Modes
The mode parameter in the [tls.client_auth] section controls whether and how client certificates are verified.
[tls.client_auth]
mode = "optional"
trust_anchors = "/etc/kipuka/client-ca.pem"
Available Modes
mode = "required"
All TLS connections must present a valid client certificate. The certificate must:
- Chain to a CA in the
trust_anchorsfile - Not be expired or revoked
- Have valid signatures
Use this mode when:
- All clients have existing certificates (e.g., re-enrollment only)
- Initial enrollment is performed via a separate out-of-band mechanism
- Maximum security is required
Note: With mode = "required", clients cannot perform initial enrollment unless they already possess a certificate. You must provision initial certificates through another channel (e.g., manual issuance, SCEP, or ACME).
mode = "optional"
Client certificates are verified if presented, but connections are allowed without a certificate. This mode supports:
- mTLS re-enrollment: Clients with existing certificates use them for authentication
- OTP-based enrollment: Clients without certificates authenticate with a one-time password (OTP) during initial enrollment
This is the recommended mode for production EST deployments, as it supports the full enrollment lifecycle.
[tls.client_auth]
mode = "optional"
trust_anchors = "/etc/kipuka/client-ca.pem"
[enrollment]
require_proof_of_possession = true # Enforce PoP for OTP enrollments
mode = "none"
No client certificate verification is performed. Connections are accepted without client authentication.
This mode is for development and testing only. Do not use in production, as it disables a critical security layer.
Trust Anchors
The trust_anchors parameter specifies a PEM file containing the CA certificates trusted for client authentication. This file may contain multiple concatenated certificates.
# Example trust_anchors file with two CAs
cat ca1-cert.pem ca2-cert.pem > /etc/kipuka/client-ca.pem
kipuka validates client certificates against this trust anchor bundle. Ensure the file is readable by the kipuka process user.
Separate Admin API TLS Configuration
The administrative API runs on a separate TCP port (default 9443) and can use independent TLS settings. This allows you to:
- Bind the admin API to a management network interface
- Use separate client authentication for administrative operations
- Restrict admin access to authorized systems only
Basic Admin API Configuration
[server]
listen = "0.0.0.0:8443" # EST API (client-facing)
admin_listen = "127.0.0.1:9443" # Admin API (localhost only)
[tls]
cert = "/etc/kipuka/est-server-cert.pem"
key = "/etc/kipuka/est-server-key.pem"
[tls.client_auth]
mode = "optional"
trust_anchors = "/etc/kipuka/client-ca.pem"
[admin.client_auth]
mode = "required"
trust_anchors = "/etc/kipuka/admin-ca.pem" # Separate CA for admin certs
In this example:
- EST API on port 8443 accepts optional client certificates from end-user devices
- Admin API on localhost port 9443 requires client certificates from administrator systems
- Admin certificates are issued by a separate CA (
admin-ca.pem)
Binding to Management Networks
For multi-homed systems, bind the admin API to a dedicated management network interface:
[server]
listen = "0.0.0.0:8443" # EST API on all interfaces
admin_listen = "10.0.1.100:9443" # Admin API on management VLAN
Configure firewall rules to restrict access to the management network. For example, using iptables:
# Allow admin API only from management subnet
iptables -A INPUT -p tcp --dport 9443 -s 10.0.1.0/24 -j ACCEPT
iptables -A INPUT -p tcp --dport 9443 -j DROP
Localhost-Only Admin Access
For single-host deployments or when using a reverse proxy, bind the admin API to localhost:
[server]
admin_listen = "127.0.0.1:9443"
Administrators can access the API via SSH port forwarding:
ssh -L 9443:localhost:9443 user@est-server.example.com
curl --cert admin-cert.pem --key admin-key.pem \
https://localhost:9443/admin/health
Practical Configuration Examples
Development Setup with Self-Signed Certificate
For local testing and development:
[server]
listen = "127.0.0.1:8443"
admin_listen = "127.0.0.1:9443"
[tls]
cert = "/home/dev/kipuka/dev-cert.pem"
key = "/home/dev/kipuka/dev-key.pem"
min_version = "1.2"
[tls.client_auth]
mode = "none" # Accept all connections for testing
[enrollment]
require_proof_of_possession = false # Simplified for dev
Generate the development certificate:
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout dev-key.pem -out dev-cert.pem \
-days 365 -sha256 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "extendedKeyUsage=serverAuth,1.3.6.1.5.5.7.3.28"
Production Setup with Internal CA
For enterprise deployments with an internal PKI:
[server]
listen = "0.0.0.0:8443"
admin_listen = "10.0.1.100:9443"
[tls]
cert = "/etc/kipuka/certs/est-server-cert.pem"
key = "/etc/kipuka/private/est-server-key.pem"
min_version = "1.2"
cipher_suites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
]
[tls.client_auth]
mode = "optional"
trust_anchors = "/etc/kipuka/ca/device-ca-bundle.pem"
[admin.client_auth]
mode = "required"
trust_anchors = "/etc/kipuka/ca/admin-ca.pem"
[enrollment]
require_proof_of_possession = true
allowed_key_types = ["rsa2048", "rsa4096", "ecdsa256", "ecdsa384"]
Certificate generation workflow:
# 1. Generate server private key
openssl genrsa -out est-server-key.pem 2048
chmod 600 est-server-key.pem
# 2. Create CSR with cmcRA EKU
openssl req -new -key est-server-key.pem \
-out est-server.csr \
-config est-server.cnf
# 3. Submit CSR to internal CA for signing
# (Process varies by CA implementation)
# 4. Install signed certificate
install -m 644 est-server-cert.pem /etc/kipuka/certs/
install -m 600 est-server-key.pem /etc/kipuka/private/
chown kipuka:kipuka /etc/kipuka/certs/est-server-cert.pem
chown kipuka:kipuka /etc/kipuka/private/est-server-key.pem
# 5. Verify cmcRA EKU
openssl x509 -in /etc/kipuka/certs/est-server-cert.pem -text -noout \
| grep -A1 "Extended Key Usage"
High-Security Setup with TLS 1.3 Only
For environments requiring maximum security (e.g., classified networks, financial services):
[server]
listen = "10.0.1.50:8443"
admin_listen = "127.0.0.1:9443"
[tls]
cert = "/etc/kipuka/certs/est-server-cert.pem"
key = "/etc/kipuka/private/est-server-key.pem"
min_version = "1.3" # TLS 1.3 only
# Note: cipher_suites parameter has no effect in TLS 1.3
# The following suites are always enabled:
# - TLS_AES_256_GCM_SHA384
# - TLS_AES_128_GCM_SHA256
# - TLS_CHACHA20_POLY1305_SHA256
[tls.client_auth]
mode = "required" # All connections must present client cert
trust_anchors = "/etc/kipuka/ca/device-ca-bundle.pem"
[admin.client_auth]
mode = "required"
trust_anchors = "/etc/kipuka/ca/admin-ca.pem"
[enrollment]
require_proof_of_possession = true
allowed_key_types = ["ecdsa384"] # P-384 only
max_validity_days = 365
require_san = true
Additional hardening:
# Restrict file permissions
chmod 700 /etc/kipuka/private
chmod 600 /etc/kipuka/private/*.pem
chmod 644 /etc/kipuka/certs/*.pem
# Run as unprivileged user
useradd -r -s /bin/false -d /var/lib/kipuka kipuka
chown -R kipuka:kipuka /etc/kipuka
# Enable mandatory access control (SELinux/AppArmor)
# Example SELinux policy (adjust for your distribution)
semanage fcontext -a -t cert_t "/etc/kipuka/certs(/.*)?"
semanage fcontext -a -t cert_t "/etc/kipuka/ca(/.*)?"
semanage fcontext -a -t cert_t "/etc/kipuka/private(/.*)?"
restorecon -R /etc/kipuka
FIPS-Compliant Configuration
For systems requiring FIPS 140-2/3 compliance:
[server]
listen = "0.0.0.0:8443"
admin_listen = "10.0.1.100:9443"
[tls]
cert = "/etc/kipuka/certs/est-server-cert.pem"
key = "/etc/kipuka/private/est-server-key.pem"
min_version = "1.2"
# Disable ChaCha20 (not FIPS-approved)
cipher_suites = [
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
]
[tls.client_auth]
mode = "required"
trust_anchors = "/etc/kipuka/ca/device-ca-bundle.pem"
[enrollment]
allowed_key_types = ["rsa2048", "rsa4096", "ecdsa256", "ecdsa384"]
allowed_signature_algorithms = ["sha256", "sha384", "sha512"]
Ensure the underlying operating system is running in FIPS mode:
# RHEL/CentOS
fips-mode-setup --enable
reboot
# Verify FIPS mode
cat /proc/sys/crypto/fips_enabled # Should output: 1
Troubleshooting
Client Rejects Server Certificate
Symptom: EST client fails with “server certificate verification failed” or similar.
Cause: Server certificate missing the id-kp-cmcRA EKU.
Solution: Verify the EKU is present:
openssl x509 -in est-server-cert.pem -text -noout | grep -A1 "Extended Key Usage"
Expected output:
X509v3 Extended Key Usage:
TLS Web Server Authentication, 1.3.6.1.5.5.7.3.28
If 1.3.6.1.5.5.7.3.28 is missing, regenerate the certificate using the instructions in “Server Certificate Requirements.”
TLS Handshake Failure
Symptom: Connection fails during TLS handshake with “no shared cipher” or “protocol version mismatch.”
Cause: Client and server have no common cipher suites or TLS versions.
Solution: Enable TLS 1.2 support if clients do not support TLS 1.3:
[tls]
min_version = "1.2"
Check client cipher suite support and add compatible suites to cipher_suites.
Admin API Unreachable
Symptom: Cannot connect to admin API on port 9443.
Cause: Admin API bound to wrong interface or blocked by firewall.
Solution: Verify admin_listen binding:
[server]
admin_listen = "0.0.0.0:9443" # Listen on all interfaces
Check firewall rules:
# List firewall rules
iptables -L -n -v | grep 9443
# Allow admin API port
iptables -A INPUT -p tcp --dport 9443 -j ACCEPT
For localhost-only access, use SSH port forwarding:
ssh -L 9443:localhost:9443 user@est-server.example.com
Security Best Practices
- Use TLS 1.3 for new deployments: Set
min_version = "1.3"to eliminate legacy protocol weaknesses. - Restrict cipher suites: Use the default list or a more restrictive set. Avoid CBC-mode ciphers and weak hashes.
- Require client authentication for re-enrollment: Set
mode = "required"if all clients have certificates. - Separate admin and client CAs: Use distinct trust anchors for administrative and end-user certificates.
- Bind admin API to management networks: Limit exposure of administrative endpoints.
- Rotate certificates before expiration: Monitor certificate expiry and renew at least 30 days in advance.
- Protect private keys: Store keys on encrypted filesystems or HSMs. Use
chmod 600and restrict access to the kipuka process user. - Enable OCSP or CRL checking: Configure revocation checking for client certificates (see enrollment configuration documentation).
- Audit TLS configuration regularly: Use tools like
testssl.shorsslyzeto verify cipher suite and protocol configuration. - Follow your organization’s security policy: Consult internal PKI and cryptography standards before deployment.
References
- RFC 7030: Enrollment over Secure Transport (EST)
- RFC 8996: Deprecating TLS 1.0 and TLS 1.1
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- NIST SP 800-52 Rev. 2: Guidelines for the Selection, Configuration, and Use of TLS
- NIAP Protection Profile for Network Devices: Common Criteria requirements for TLS configuration
HSM Integration
kipuka supports Hardware Security Modules (HSMs) and software HSMs through the PKCS#11 (Cryptoki) standard. HSM integration ensures that CA private keys never leave the secure hardware boundary, with all signing operations delegated to the HSM.
PKCS#11 Overview
kipuka uses the PKCS#11 API to interface with HSMs. The integration model follows these principles:
- The
[hsm]section inkipuka.tomlconfigures the PKCS#11 provider library and authentication - CA definitions reference HSM-stored keys using the
hsm_slotparameter in the[[ca]]section - Private keys remain on the HSM at all times—signing operations are performed by the HSM
- When
hsm_slotis set for a CA, thekeyparameter is ignored
This architecture ensures that sensitive key material never exists in kipuka’s process memory or on the filesystem.
Supported Vendors and Library Paths
kipuka works with any PKCS#11-compliant HSM. The following table lists common vendors and their typical library paths:
| Vendor | Product | Library Path (Linux) | Library Path (macOS) |
|---|---|---|---|
| Entrust | nShield | /opt/nfast/toolkits/pkcs11/libcknfast.so | N/A |
| Utimaco | CryptoServer | /opt/utimaco/lib/libcs_pkcs11_R3.so | N/A |
| Thales | Luna | /usr/safenet/lunaclient/lib/libCryptoki2_64.so | /usr/safenet/lunaclient/lib/libCryptoki2.dylib |
| Kryoptic | SoftHSM-compatible | /usr/lib/pkcs11/libkryoptic_pkcs11.so | target/release/libkryoptic_pkcs11.dylib |
| SoftHSM2 | SoftHSM2 | /usr/lib/softhsm/libsofthsm2.so | /usr/local/lib/softhsm/libsofthsm2.so |
| AWS | CloudHSM | /opt/cloudhsm/lib/libcloudhsm_pkcs11.so | N/A |
| YubiHSM | YubiHSM2 | /usr/lib/x86_64-linux-gnu/pkcs11/yubihsm_pkcs11.so | /usr/local/lib/pkcs11/yubihsm_pkcs11.dylib |
Consult your HSM vendor documentation for the exact library path on your system.
PIN Management
kipuka supports three methods for providing the HSM PIN, evaluated in the following priority order:
Environment Variable (RECOMMENDED)
Set pin_env to the name of an environment variable containing the PIN:
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
token_label = "kipuka"
pin_env = "KIPUKA_HSM_PIN"
Then start kipuka with the PIN in the environment:
export KIPUKA_HSM_PIN="your-pin-here"
kipuka
For systemd services, use EnvironmentFile:
[Service]
EnvironmentFile=/etc/kipuka/hsm.env
ExecStart=/usr/local/bin/kipuka
Where /etc/kipuka/hsm.env contains:
KIPUKA_HSM_PIN=your-pin-here
Set restrictive permissions on the environment file:
chmod 0400 /etc/kipuka/hsm.env
chown kipuka:kipuka /etc/kipuka/hsm.env
PIN File
Set pin_file to a path containing only the PIN:
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
token_label = "kipuka"
pin_file = "/etc/kipuka/hsm.pin"
Secure the PIN file:
echo "your-pin-here" > /etc/kipuka/hsm.pin
chmod 0400 /etc/kipuka/hsm.pin
chown kipuka:kipuka /etc/kipuka/hsm.pin
Plaintext PIN (NOT RECOMMENDED)
For development only, the PIN can be stored directly in the configuration:
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
token_label = "kipuka"
pin = "1234"
Never use this method in production. The PIN will be visible in the configuration file and process listings.
Slot Configuration
PKCS#11 tokens are identified by either slot number or token label.
By Slot Number
Reference the HSM token by its numeric slot identifier:
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
slot = 0
pin_env = "KIPUKA_HSM_PIN"
By Token Label
Reference the HSM token by its label:
[hsm]
library = "/usr/lib/softhsm/libsofthsm2.so"
token_label = "kipuka"
pin_env = "KIPUKA_HSM_PIN"
Using token_label is generally more portable across HSM reconfigurations.
Discovering Slots
Use pkcs11-tool to list available slots:
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --list-slots
Example output:
Available slots:
Slot 0 (0x1d6d28f6): SoftHSM slot ID 0x1d6d28f6
token label : kipuka
token manufacturer : SoftHSM project
token model : SoftHSM v2
token flags : login required, rng, token initialized, PIN initialized
hardware version : 2.6
firmware version : 2.6
serial num : 4c8e0a766d6d28f6
pin min/max : 4/255
The token label from this output can be used as the token_label value.
Key Generation Examples
Before configuring kipuka to use HSM-stored keys, you must generate key pairs on the HSM. The following examples use pkcs11-tool from the OpenSC package.
Generate RSA 2048-bit Key Pair
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login \
--pin 1234 \
--keypairgen \
--key-type rsa:2048 \
--id 01 \
--label "kipuka-ca-rsa-2048"
Generate RSA 4096-bit Key Pair
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login \
--pin 1234 \
--keypairgen \
--key-type rsa:4096 \
--id 02 \
--label "kipuka-ca-rsa-4096"
Generate ECDSA P-256 Key Pair
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login \
--pin 1234 \
--keypairgen \
--key-type EC:prime256v1 \
--id 03 \
--label "kipuka-ca-ec-p256"
Generate ECDSA P-384 Key Pair
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login \
--pin 1234 \
--keypairgen \
--key-type EC:secp384r1 \
--id 04 \
--label "kipuka-ca-ec-p384"
List HSM Objects
Verify key generation by listing objects on the token:
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login \
--pin 1234 \
--list-objects
Example output:
Public Key Object; RSA 2048 bits
label: kipuka-ca-rsa-2048
ID: 01
Usage: encrypt, verify, wrap
Private Key Object; RSA
label: kipuka-ca-rsa-2048
ID: 01
Usage: decrypt, sign, unwrap
The ID field value is used as the hsm_slot parameter in kipuka’s CA configuration.
Development Setup with Kryoptic
Kryoptic is a Rust-based software PKCS#11 implementation suitable for development and testing. It provides a lightweight alternative to hardware HSMs.
Installation
Install Kryoptic from crates.io:
cargo install kryoptic
Or build from source:
git clone https://github.com/latchset/kryoptic.git
cd kryoptic
cargo build --release
The PKCS#11 library will be at target/release/libkryoptic_pkcs11.so (Linux) or target/release/libkryoptic_pkcs11.dylib (macOS).
Initialize a Token
Create a Kryoptic configuration directory:
mkdir -p ~/.config/kryoptic
Initialize a token using pkcs11-tool:
pkcs11-tool --module target/release/libkryoptic_pkcs11.so \
--init-token \
--label "kipuka-dev" \
--so-pin 12345678
Set the user PIN:
pkcs11-tool --module target/release/libkryoptic_pkcs11.so \
--init-pin \
--so-pin 12345678 \
--pin 1234
Generate Development Keys
Generate an RSA 2048-bit key pair:
pkcs11-tool --module target/release/libkryoptic_pkcs11.so \
--login \
--pin 1234 \
--keypairgen \
--key-type rsa:2048 \
--id 01 \
--label "kipuka-dev-ca"
Configure kipuka
Update kipuka.toml to use the Kryoptic library:
[hsm]
library = "/home/user/kryoptic/target/release/libkryoptic_pkcs11.so"
token_label = "kipuka-dev"
pin_env = "KIPUKA_HSM_PIN"
[[ca]]
id = "dev-ca"
name = "Development CA"
cert = "/etc/kipuka/ca/dev-ca.pem"
hsm_slot = 1
validity_days = 365
Generate the CA certificate (the private key remains on Kryoptic):
export KIPUKA_HSM_PIN=1234
# Generate a self-signed CA certificate using the HSM key
openssl req -new -x509 \
-engine pkcs11 \
-keyform engine \
-key "pkcs11:token=kipuka-dev;id=%01;type=private" \
-out /etc/kipuka/ca/dev-ca.pem \
-days 3650 \
-subj "/CN=kipuka Development CA"
Start kipuka:
kipuka
kipuka will now use the Kryoptic HSM for all signing operations for the dev-ca CA.
Full Configuration Example
The following example shows a complete HSM configuration with multiple CAs:
# HSM configuration
[hsm]
library = "/usr/lib/pkcs11/libkryoptic_pkcs11.so"
token_label = "kipuka"
pin_env = "KIPUKA_HSM_PIN"
# Server configuration
[server]
listen = "0.0.0.0:8443"
tls_cert = "/etc/kipuka/server.pem"
tls_key = "/etc/kipuka/server-key.pem"
# RSA CA using HSM key
[[ca]]
id = "hsm-rsa-ca"
name = "HSM-Protected RSA CA"
cert = "/etc/kipuka/ca/hsm-rsa-ca.pem"
hsm_slot = 1
validity_days = 365
key_usage = ["digitalSignature", "keyCertSign", "cRLSign"]
# ECDSA CA using HSM key
[[ca]]
id = "hsm-ec-ca"
name = "HSM-Protected ECDSA CA"
cert = "/etc/kipuka/ca/hsm-ec-ca.pem"
hsm_slot = 3
validity_days = 365
key_usage = ["digitalSignature", "keyCertSign", "cRLSign"]
Key points:
- The
hsm_slotparameter references the object ID assigned during key generation - When
hsm_slotis set, thekeyparameter must be omitted—the private key exists only on the HSM - Multiple CAs can share the same HSM by using different slot identifiers
- The CA certificate in the
certparameter is the public certificate; the private key remains on the HSM
Troubleshooting
Library Not Found
If kipuka reports that the PKCS#11 library cannot be loaded, verify:
- The library path is correct for your system
- The library file has appropriate read and execute permissions
- Required dependencies are installed (consult HSM vendor documentation)
Authentication Failures
If kipuka reports authentication failures:
- Verify the PIN is correct using
pkcs11-tool --login - Check that the token is not locked due to failed login attempts
- Ensure the environment variable or PIN file is readable by the kipuka process user
Key Not Found
If kipuka reports that the key cannot be found in the HSM:
- List objects with
pkcs11-tool --list-objectsand verify the key exists - Ensure the
hsm_slotvalue matches the key’sIDfield - Check that the token label or slot number is correct
Performance Considerations
HSM signing operations have higher latency than software signing. For high-throughput deployments:
- Use hardware HSMs with dedicated crypto acceleration
- Consider load balancing across multiple kipuka instances
- Monitor HSM session limits and adjust
max_sessionsif supported by your HSM
High Availability
kipuka provides multi-CA high availability at the application layer. When a Certificate Authority backend becomes unavailable—whether due to HSM failure, Dogtag server downtime, or certificate expiration—kipuka can automatically failover to an alternate CA. This ensures continuous certificate enrollment even when individual CA components fail.
High availability is configured through [ha] and [[ha.group]] sections in the configuration file.
Overview
Traditional PKI deployments often rely on infrastructure-level HA (load balancers, database replication) for a single CA. kipuka takes a different approach: it treats each CA as an independent backend and implements application-layer failover logic. This allows:
- Heterogeneous CA backends: Mix HSM-backed CAs with file-based CAs, or Dogtag with other implementations
- Gradual migration: Route a percentage of traffic to a new CA while maintaining the old one
- Geographic distribution: Route requests to the nearest or fastest CA
- Independent failure domains: HSM failure doesn’t take down file-based backup CAs
When a CA fails, kipuka’s circuit breaker immediately stops routing requests to it and redistributes traffic to healthy CAs in the same group. When the failed CA recovers, kipuka automatically reintegrates it based on the configured strategy.
Failover Strategies
kipuka supports four failover strategies, selectable globally via [ha] or per-group via [[ha.group]]:
active-passive
The primary CA handles all requests. If it fails, the secondary takes over. When the primary recovers, traffic automatically returns to it.
Use when:
- You have a clear primary CA (e.g., HSM-backed) and want a hot standby
- You need predictable CA assignment for audit or compliance
- Your CAs have different trust levels or security characteristics
Behavior:
- All requests routed to the first healthy CA in the group
- On failure, immediately switch to the next CA
- On recovery, immediately switch back to the primary
round-robin
Requests are distributed evenly across all healthy CAs in the group. Any failure removes that CA from rotation until it recovers.
Use when:
- All CAs in the group have equivalent capacity and trust
- You want to distribute load across multiple CAs
- You need to maximize utilization of all CA backends
Behavior:
- Each request goes to the next CA in a circular list
- Failed CAs are skipped in the rotation
- Load is balanced evenly across all healthy CAs
weighted
Requests are distributed according to configured weights (e.g., 80% to CA1, 20% to CA2). Allows proportional load distribution.
Use when:
- CAs have different capacities (e.g., HSM vs. software)
- You’re migrating from one CA to another gradually
- You want to test a new CA with a small percentage of traffic
Behavior:
- Each CA receives traffic proportional to its weight
- Weights are specified per-CA in the group configuration
- If a CA fails, its weight is redistributed to remaining CAs
Configuration:
[[ha.group]]
name = "primary"
ca_ids = ["hsm-ca", "file-ca"]
strategy = "weighted"
weights = { "hsm-ca" = 80, "file-ca" = 20 }
latency-based
Requests are routed to the CA with the lowest recent response time. Self-optimizing for geographically distributed CAs.
Use when:
- CAs are in different geographic locations
- Network latency varies significantly between CAs
- You want automatic optimization without manual tuning
Behavior:
- kipuka tracks rolling average latency for each CA
- Each request goes to the CA with the lowest average latency
- Latency is measured from health checks and actual signing operations
- Failed CAs are assigned infinite latency
Circuit Breaker Pattern
kipuka implements a circuit breaker to prevent cascading failures and automatically recover from transient issues. The circuit breaker prevents the system from repeatedly attempting to use a failing CA, which could delay client requests or exhaust resources.
States
The circuit breaker transitions through five states:
Healthy: CA is responding normally. All requests are routed to it according to the failover strategy.
Degraded: CA has experienced some failures but remains below the failure threshold. Requests continue to be routed, but the CA is monitored more closely. This state provides early warning of potential issues.
Unhealthy: Failure count exceeds failure_threshold within the check window. The CA is immediately removed from rotation to prevent client impact.
CircuitOpen: After transitioning to Unhealthy, the circuit opens. No requests are sent to this CA. A timer starts for recovery_timeout seconds to allow the CA time to recover.
Recovering: After recovery_timeout expires, a single probe request is sent. If it succeeds, the CA transitions back to Healthy and rejoins rotation. If it fails, the circuit returns to CircuitOpen with an extended timeout (exponential backoff).
State Transitions
Healthy --> Degraded --> Unhealthy --> CircuitOpen --> Recovering --> Healthy
^ | |
| +-------<-------+
+------------------<-------------------+
- Healthy → Degraded: First failure detected
- Degraded → Unhealthy: Failure count exceeds threshold
- Unhealthy → CircuitOpen: Immediate transition; timer starts
- CircuitOpen → Recovering: After
recovery_timeoutexpires - Recovering → Healthy: Probe succeeds; CA rejoins rotation
- Recovering → CircuitOpen: Probe fails; extended timeout begins
- Degraded → Healthy: CA recovers before hitting threshold
- CircuitOpen → Healthy: Manual operator override (health check passes)
Configuration
Circuit breaker behavior is tuned via [ha]:
[ha]
check_interval = "10s" # How often to probe each CA
failure_threshold = 3 # Consecutive failures before marking unhealthy
recovery_timeout = "60s" # Wait time before probing a failed CA
check_timeout = "5s" # Max time to wait for health check response
check_interval: Frequency of active health checks. Shorter intervals detect failures faster but increase CA load.failure_threshold: Number of consecutive failures before removing a CA from rotation. Lower values improve responsiveness but may cause flapping; higher values increase tolerance for transient failures.recovery_timeout: How long to wait before attempting recovery. This gives the CA time to fully restart or for transient issues to resolve. kipuka uses exponential backoff: if the first probe fails, the next timeout is doubled.check_timeout: Maximum time to wait for a health check response. Should be shorter thancheck_intervalto avoid overlapping checks.
HA Groups
HA groups define sets of CAs that provide redundancy for each other. All CAs in a group should issue from the same root (or cross-certified roots) so clients trust all issuers.
Configuration
[[ha.group]]
name = "primary" # Unique group name
ca_ids = ["hsm-ca", "file-ca"] # Array of [[ca]] id values
strategy = "active-passive" # Optional: override global strategy
name: Unique identifier for this group. Used in logs and metrics.ca_ids: Array of CA identifiers. Must reference valid[[ca]]sections. Order matters foractive-passivestrategy.strategy: Optional override of the global[ha]strategy. Allows different strategies for different groups.
EST Label Integration
EST labels reference individual CAs via their id. When the primary CA in a group fails, the HA system automatically routes requests to the next healthy CA in the group. From the client’s perspective, the EST label remains the same—failover is transparent.
Example:
[[ca]]
id = "hsm-ca"
backend = "dogtag"
# ... HSM configuration ...
[[ca]]
id = "file-ca"
backend = "file"
# ... file configuration ...
[[ha.group]]
name = "production"
ca_ids = ["hsm-ca", "file-ca"]
strategy = "active-passive"
[[est.label]]
name = "device-cert"
ca_id = "hsm-ca" # References the group leader
profile = "deviceCert"
If hsm-ca fails, requests to the device-cert label automatically use file-ca until hsm-ca recovers.
Health Check Configuration
kipupa performs active health checks to detect CA failures and recoveries. Health checks are lightweight signing operations that verify the CA is fully functional—not just network-reachable.
Health Check Behavior
- Every
check_intervalseconds, kipuka sends a test signing request to each CA - If the CA responds successfully within
check_timeout, the check passes - If the CA fails to respond or returns an error, the check fails
- After
failure_thresholdconsecutive failures, the CA is marked Unhealthy - After
recovery_timeoutseconds, kipuka sends a single probe to the failed CA - If the probe succeeds, the CA returns to Healthy; if it fails, the timeout doubles
Tuning Recommendations
Low-latency environment (local CAs):
[ha]
check_interval = "5s"
failure_threshold = 2
recovery_timeout = "30s"
check_timeout = "2s"
High-latency environment (geographically distributed CAs):
[ha]
check_interval = "30s"
failure_threshold = 5
recovery_timeout = "120s"
check_timeout = "10s"
Production (balanced):
[ha]
check_interval = "10s"
failure_threshold = 3
recovery_timeout = "60s"
check_timeout = "5s"
Example Configurations
Two-CA Active-Passive with HSM Primary
A production deployment with an HSM-backed primary CA and a file-based backup. Normal traffic uses the HSM; if it fails, the file-based CA provides continuity.
[ha]
strategy = "active-passive"
check_interval = "10s"
failure_threshold = 3
recovery_timeout = "60s"
check_timeout = "5s"
[[ca]]
id = "hsm-ca"
backend = "dogtag"
[ca.dogtag]
url = "https://pki.example.com:8443"
ca_cert = "/etc/kipuka/pki-ca.pem"
auth_cert = "/etc/kipuka/ra-agent.pem"
auth_key = "pkcs11:token=HSM;object=ra-key"
[[ca]]
id = "backup-ca"
backend = "file"
[ca.file]
ca_cert = "/etc/kipuka/backup-ca.pem"
ca_key = "/etc/kipuka/backup-ca-key.pem"
[[ha.group]]
name = "production"
ca_ids = ["hsm-ca", "backup-ca"]
[[est.label]]
name = "device"
ca_id = "hsm-ca" # HA group leader
profile = "deviceCert"
Expected behavior:
- All requests use
hsm-caunder normal conditions - If the HSM or Dogtag server fails, traffic immediately switches to
backup-ca - When
hsm-carecovers, traffic returns to it within onecheck_interval - Clients see no difference—the
devicelabel works throughout
Three-CA Round-Robin for Load Distribution
Three identical CAs in different datacenters. Traffic is distributed evenly to maximize utilization and provide geographic redundancy.
[ha]
strategy = "round-robin"
check_interval = "15s"
failure_threshold = 3
recovery_timeout = "90s"
check_timeout = "7s"
[[ca]]
id = "ca-east"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-east.example.com:8443"
ca_cert = "/etc/kipuka/ca.pem"
auth_cert = "/etc/kipuka/ra-east.pem"
auth_key = "/etc/kipuka/ra-east-key.pem"
[[ca]]
id = "ca-west"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-west.example.com:8443"
ca_cert = "/etc/kipuka/ca.pem"
auth_cert = "/etc/kipuka/ra-west.pem"
auth_key = "/etc/kipuka/ra-west-key.pem"
[[ca]]
id = "ca-central"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-central.example.com:8443"
ca_cert = "/etc/kipuka/ca.pem"
auth_cert = "/etc/kipuka/ra-central.pem"
auth_key = "/etc/kipuka/ra-central-key.pem"
[[ha.group]]
name = "global"
ca_ids = ["ca-east", "ca-west", "ca-central"]
[[est.label]]
name = "iot"
ca_id = "ca-east" # Any CA in the group; round-robin applies
profile = "iotDevice"
Expected behavior:
- Requests are distributed 33/33/33 across the three CAs
- If
ca-eastfails, traffic is redistributed 50/50 toca-westandca-central - When
ca-eastrecovers, it rejoins the rotation - Each CA operates independently; no shared state required
Geographic HA with Latency-Based Routing
Two CAs in different regions. kipuka automatically routes requests to the CA with the lowest latency, optimizing performance for geographically distributed clients.
[ha]
strategy = "latency-based"
check_interval = "20s"
failure_threshold = 4
recovery_timeout = "120s"
check_timeout = "10s"
[[ca]]
id = "ca-us"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-us.example.com:8443"
ca_cert = "/etc/kipuka/ca.pem"
auth_cert = "/etc/kipuka/ra-us.pem"
auth_key = "/etc/kipuka/ra-us-key.pem"
[[ca]]
id = "ca-eu"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-eu.example.com:8443"
ca_cert = "/etc/kipuka/ca.pem"
auth_cert = "/etc/kipuka/ra-eu.pem"
auth_key = "/etc/kipuka/ra-eu-key.pem"
[[ha.group]]
name = "global"
ca_ids = ["ca-us", "ca-eu"]
[[est.label]]
name = "vpn"
ca_id = "ca-us" # HA group leader; latency-based routing applies
profile = "vpnCert"
Expected behavior:
- kipuka measures latency to both CAs during health checks
- Requests are automatically routed to the faster CA (e.g.,
ca-usfor US clients,ca-eufor EU clients) - If latency increases for one CA (network congestion, overload), traffic shifts to the other
- If one CA fails completely, all traffic uses the remaining CA
- No manual configuration required—self-optimizing based on network conditions
Weighted Migration from Old to New CA
Gradual migration from an existing CA to a new one. Start with 90% traffic on the old CA, gradually shift to 100% on the new CA, then decommission the old CA.
[ha]
strategy = "weighted"
check_interval = "10s"
failure_threshold = 3
recovery_timeout = "60s"
check_timeout = "5s"
[[ca]]
id = "old-ca"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-old.example.com:8443"
ca_cert = "/etc/kipuka/old-ca.pem"
auth_cert = "/etc/kipuka/ra-old.pem"
auth_key = "/etc/kipuka/ra-old-key.pem"
[[ca]]
id = "new-ca"
backend = "dogtag"
[ca.dogtag]
url = "https://pki-new.example.com:8443"
ca_cert = "/etc/kipuka/new-ca.pem"
auth_cert = "/etc/kipuka/ra-new.pem"
auth_key = "/etc/kipuka/ra-new-key.pem"
[[ha.group]]
name = "migration"
ca_ids = ["old-ca", "new-ca"]
strategy = "weighted"
weights = { "old-ca" = 90, "new-ca" = 10 }
[[est.label]]
name = "server"
ca_id = "old-ca"
profile = "serverCert"
Migration procedure:
- Start with
weights = { "old-ca" = 100, "new-ca" = 0 }(new CA online but unused) - Update to
weights = { "old-ca" = 90, "new-ca" = 10 }(10% canary traffic) - Monitor logs and metrics; if no issues, increase to 80/20, 50/50, 20/80
- Finish with
weights = { "old-ca" = 0, "new-ca" = 100 }(full cutover) - Remove
old-cafrom the configuration once fully decommissioned
No client reconfiguration required—weights can be adjusted without restarting kipuka.
Database Backends
kipuka uses a database to store issued certificates, audit logs, OTP tokens, and certificate authority state. Three backend options are supported: SQLite, PostgreSQL, and MariaDB. The database backend is configured via the [db] section in kipuka.toml.
SQLite
SQLite is the default backend and requires zero configuration. It is ideal for single-node deployments, development environments, and small to medium workloads.
URL Format
[db]
url = "sqlite:///var/lib/kipuka/kipuka.db"
auto_migrate = true
For in-memory testing:
[db]
url = "sqlite://:memory:"
Features and Characteristics
- Write-Ahead Logging (WAL): kipuka enables WAL mode by default, allowing concurrent reads while a write transaction is in progress.
- File Permissions: The database file should be owned by the kipuka service user with mode 0600 to prevent unauthorized access.
- Single-Writer: SQLite supports only one concurrent writer. This is not a limitation for single-node deployments but makes it unsuitable for high-availability configurations with shared state.
- No Network Access: The database must be on the local filesystem. Network-mounted filesystems (NFS, SMB) are not recommended due to locking and performance issues.
Backup
SQLite databases can be backed up using:
-
File copy: While the WAL is checkpointed (kipuka handles this automatically), the database file can be copied. Use a consistent snapshot method to avoid corruption.
-
sqlite3 .backup: The SQLite command-line tool provides a
.backupcommand for online backups:sqlite3 /var/lib/kipuka/kipuka.db ".backup /backup/kipuka-$(date +%Y%m%d).db"
Best For
- Single-node deployments
- Development and testing
- Small to medium workloads (< 1000 requests/minute)
- Scenarios where operational simplicity is prioritized
Limitations
- Single writer (not suitable for multi-node HA with shared state)
- No network access (cannot be shared across nodes)
- Limited to local filesystem performance
PostgreSQL
PostgreSQL is the recommended backend for production multi-node deployments. It provides robust support for concurrent writes, network access, replication, and point-in-time recovery.
URL Format
[db]
url = "postgres://kipuka:secret@db.example.com:5432/kipuka?sslmode=require"
max_connections = 20
connect_timeout = "5s"
auto_migrate = true
Features and Characteristics
- Concurrent Writes: Multiple kipuka nodes can write to the database simultaneously.
- Network Access: The database can be accessed over the network, enabling multi-node deployments.
- Replication: PostgreSQL supports streaming replication for read replicas and high availability.
- Point-in-Time Recovery (PITR): WAL archiving enables recovery to any point in time.
- Connection Pooling: kipuka maintains a connection pool sized by
max_connections.
Connection Pool Tuning
The max_connections setting controls the size of kipuka’s connection pool to PostgreSQL:
- Default: 5 (suitable for low-load scenarios)
- Formula: Start with
workers * 2and adjust based on monitoring. - Too Low: Connection pool exhaustion under load, causing requests to queue.
- Too High: Unnecessary resource consumption; may exceed PostgreSQL’s
max_connectionslimit (default 100).
Monitor pool utilization via the Admin API endpoint /admin/health, which reports connection pool statistics.
PostgreSQL Configuration
Ensure max_connections in postgresql.conf is set higher than the sum of all kipuka nodes’ max_connections. Leave headroom for other applications and administrative connections.
# postgresql.conf
max_connections = 100
SSL Connections
Append ?sslmode=require to the URL to enforce SSL/TLS connections:
url = "postgres://kipuka:${DB_PASSWORD}@db.example.com:5432/kipuka?sslmode=require"
Best For
- Production environments
- High-availability deployments (with replication)
- High-throughput scenarios (> 1000 requests/minute)
- Multi-node clusters with shared state
MariaDB
MariaDB is an alternative to PostgreSQL and supports Galera Cluster for synchronous multi-master replication. It is well-suited for organizations already standardized on MariaDB or requiring Galera-based high availability.
URL Format
[db]
url = "mysql://kipuka:secret@galera1.example.com:3306/kipuka"
max_connections = 20
connect_timeout = "5s"
auto_migrate = true
Features and Characteristics
- Galera Cluster: Provides synchronous multi-master replication, allowing writes to any node in the cluster.
- Concurrent Writes: Multiple kipuka nodes can write to the database simultaneously.
- Network Access: The database can be accessed over the network.
Galera Considerations
- Replication Format: Use ROW-based replication (
binlog_format=ROW) for deterministic replication. - Synchronous Reads: Set
wsrep_sync_wait=1to ensure reads reflect writes from all nodes (reads-after-writes consistency).
# MariaDB Galera configuration
wsrep_sync_wait=1
binlog_format=ROW
Best For
- Organizations standardized on MariaDB
- Galera Cluster-based high availability
- Multi-master replication requirements
Connection URL Formats Summary
| Backend | URL Format | Default Port |
|---|---|---|
| SQLite | sqlite://path/to/db | N/A |
| PostgreSQL | postgres://user:pass@host:port/db | 5432 |
| MariaDB | mysql://user:pass@host:port/db | 3306 |
Migrations
kipuka uses versioned, idempotent migrations to manage database schema changes. Migrations can be applied automatically on startup or run manually as a separate step.
Automatic Migrations
Set auto_migrate = true (the default) to apply pending migrations on startup:
[db]
auto_migrate = true
This is convenient for development and single-node deployments, but may not be suitable for production environments where migrations should be reviewed and tested before deployment.
Manual Migrations
Set auto_migrate = false to disable automatic migrations:
[db]
auto_migrate = false
Then run migrations manually using the kipuka migrate command:
# Show pending migrations
kipuka migrate status
# Apply pending migrations
kipuka migrate run
Best Practices
- Production: Run migrations as a separate step before starting the server. This allows you to review migrations, test them in a staging environment, and coordinate downtime if necessary.
- Development: Use
auto_migrate = truefor convenience. - Idempotency: Migrations are idempotent and can be re-run safely. If a migration is interrupted, re-running it will complete the operation.
max_connections Tuning
The max_connections setting controls the size of kipuka’s database connection pool. Tuning this parameter is critical for balancing resource utilization and performance.
Default
The default is 5, which is suitable for SQLite and small deployments with low concurrency.
Tuning Formula
Start with the formula:
max_connections = workers * 2
Adjust based on monitoring and observed load. For example, if kipuka is configured with 4 workers, start with max_connections = 8.
Symptoms of Incorrect Settings
- Too Low: Connection pool exhaustion under load. Requests will queue waiting for an available connection, increasing latency.
- Too High: Unnecessary resource consumption (memory, file descriptors). May exceed the database’s
max_connectionslimit, causing connection failures.
Monitoring
Use the Admin API endpoint /admin/health to monitor connection pool utilization:
curl http://localhost:8080/admin/health
The response includes metrics such as active connections, idle connections, and pool size.
PostgreSQL Configuration
Ensure PostgreSQL’s max_connections setting is higher than the sum of all kipuka nodes’ max_connections. For example:
- 3 kipuka nodes, each with
max_connections = 20→ 60 total - PostgreSQL
max_connections = 100→ 40 connections available for other applications and administrative tasks
Credential Security
Database connection URLs often contain sensitive credentials (usernames and passwords). Follow these best practices to protect credentials:
Environment Variable Substitution
kipuka supports environment variable substitution in the url field using the ${ENV_VAR} syntax:
[db]
url = "postgres://kipuka:${DB_PASSWORD}@db.example.com:5432/kipuka"
Set the environment variable before starting kipuka:
export DB_PASSWORD="your-secure-password"
kipuka run
Or use a systemd service file with EnvironmentFile:
[Service]
EnvironmentFile=/etc/kipuka/db.env
ExecStart=/usr/bin/kipuka run
Never Commit Passwords
Never commit plaintext passwords to version control. Use:
- Environment variables (as shown above)
- Secret management systems (e.g., HashiCorp Vault, AWS Secrets Manager)
- Encrypted configuration files with restricted file permissions
Example: Secrets Manager Integration
For production deployments, consider integrating with a secrets manager:
# Fetch password from secrets manager
export DB_PASSWORD=$(vault kv get -field=password secret/kipuka/db)
# Start kipuka
kipuka run
This ensures credentials are never written to disk in plaintext and can be rotated without modifying configuration files.
Audit Logging
kipuka implements comprehensive audit logging designed to meet NIAP Protection Profile for Certification Authorities requirements, specifically FAU_GEN.1 (Audit Data Generation). All security-relevant events are recorded with sufficient detail for forensic analysis and compliance verification.
NIAP FAU_GEN.1 Compliance
kipuka’s audit logging implementation satisfies the following FAU_GEN.1 requirements:
- Completeness: All security-relevant events are recorded, including certificate issuance, revocation, authentication attempts, and administrative actions.
- Sufficient Detail: Each audit record contains timestamp (UTC), event type, outcome (success/failure), subject identity, source IP address, and event-specific data necessary for forensic analysis.
- Tamper Evidence: Audit records are append-only. The database audit tables permit only INSERT operations; UPDATE and DELETE operations are prohibited at the schema level.
- Reliability: Audit records are written synchronously to the database, with optional asynchronous replication to file and syslog destinations.
Audit Event Types
kipuka records 22 distinct event types across four categories:
| Event Name | Category | Description |
|---|---|---|
cert.issued | Certificate | Certificate successfully issued |
cert.denied | Certificate | Certificate request denied (policy violation) |
cert.revoked | Certificate | Certificate revoked |
cert.renewed | Certificate | Certificate renewed via simplereenroll |
cert.expired | Certificate | Certificate reached expiration |
enroll.request | Enrollment | simpleenroll request received |
enroll.success | Enrollment | Enrollment completed successfully |
enroll.failure | Enrollment | Enrollment failed |
reenroll.request | Enrollment | simplereenroll request received |
reenroll.success | Enrollment | Re-enrollment completed successfully |
reenroll.failure | Enrollment | Re-enrollment failed |
auth.mtls.success | Authentication | mTLS authentication succeeded |
auth.mtls.failure | Authentication | mTLS authentication failed |
auth.otp.success | Authentication | OTP authentication succeeded |
auth.otp.failure | Authentication | OTP authentication failed |
auth.otp.lockout | Authentication | OTP account locked due to excessive failures |
auth.gssapi.success | Authentication | GSSAPI/Kerberos authentication succeeded |
auth.gssapi.failure | Authentication | GSSAPI/Kerberos authentication failed |
admin.access | Admin | Admin API endpoint accessed |
otp.created | Admin | OTP token provisioned |
ca.health.changed | System | CA health state changed (HA) |
server.startup | System | kipuka server started |
server.shutdown | System | kipuka server stopped |
Audit Destinations
kipuka supports three audit destinations that operate independently. The database destination is always active; file and syslog destinations are optional.
Database (Always Active)
All audit events are written to the audit_log table in the kipuka database. This destination is always active and cannot be disabled.
Characteristics:
- INSERT-only: The database schema enforces that audit records can only be inserted, never updated or deleted.
- Queryable: Audit records can be retrieved via the Admin API for compliance reporting and forensic analysis.
- Persistent: Records remain in the database until explicitly purged by an administrator through documented procedures.
File (Append-Only JSON)
When configured, kipuka writes audit events to a file in JSON Lines format (one JSON object per line). The file is opened with the O_APPEND flag to ensure records cannot be overwritten.
Configuration:
[audit]
file = "/var/log/kipuka/audit.json"
Rotation:
Use logrotate with the copytruncate option or equivalent log rotation tools. Example logrotate configuration:
/var/log/kipuka/audit.json {
daily
rotate 90
compress
delaycompress
copytruncate
missingok
notifempty
}
Example JSON Record:
{
"timestamp": "2026-06-24T14:32:10.123456Z",
"event_type": "cert.issued",
"outcome": "success",
"subject": "CN=server.example.com",
"source_ip": "192.0.2.45",
"serial": "4A:3F:21:8C:09:77:34:2B",
"issuer": "CN=Example CA",
"not_before": "2026-06-24T14:32:00Z",
"not_after": "2027-06-24T14:32:00Z"
}
Syslog (RFC 5424, Optional TLS)
kipuka can forward audit events to a remote syslog server using RFC 5424 format. UDP, TCP, and TLS-encrypted TCP are supported.
Configuration:
[audit]
syslog = "tcp+tls://syslog.example.com:6514"
syslog_facility = "local0"
Supported URL Schemes:
udp://host:port- Unencrypted UDP (not recommended for production)tcp://host:port- Unencrypted TCPtcp+tls://host:port- TLS-encrypted TCP (recommended for NIAP compliance)
Syslog Facilities:
The syslog_facility option accepts local0 through local7. Default is local0.
Structured Data: Audit events are encoded as RFC 5424 structured data elements. Example syslog message:
<134>1 2026-06-24T14:32:10.123456Z server1 kipuka 12345 cert.issued [audit event_type="cert.issued" outcome="success" subject="CN=server.example.com" source_ip="192.0.2.45" serial="4A:3F:21:8C:09:77:34:2B"]
Event Filtering
The events array in the [audit] section controls which events are forwarded to file and syslog destinations. The database always records all events regardless of this filter.
Default Behavior:
If the events array is omitted or empty, all event types are forwarded to file and syslog.
Filtering Example: To log only security-critical events to file/syslog:
[audit]
file = "/var/log/kipuka/audit.json"
events = [
"cert.issued",
"cert.denied",
"cert.revoked",
"auth.otp.failure",
"auth.otp.lockout",
"auth.mtls.failure",
"admin.access",
]
In this configuration, successful routine operations (e.g., enroll.success, auth.otp.success) are recorded only in the database, reducing file and syslog volume while maintaining complete audit trail in the database for compliance queries.
Certificate Data in Audit Records
By default, audit records for certificate lifecycle events include metadata (serial number, subject, issuer, validity dates) but not the full certificate. The include_cert_data option controls whether the complete PEM-encoded certificate is included.
Default (include_cert_data = false):
{
"timestamp": "2026-06-24T14:32:10.123456Z",
"event_type": "cert.issued",
"serial": "4A:3F:21:8C:09:77:34:2B",
"subject": "CN=server.example.com",
"issuer": "CN=Example CA",
"not_before": "2026-06-24T14:32:00Z",
"not_after": "2027-06-24T14:32:00Z"
}
With Full Certificate Data (include_cert_data = true):
{
"timestamp": "2026-06-24T14:32:10.123456Z",
"event_type": "cert.issued",
"serial": "4A:3F:21:8C:09:77:34:2B",
"subject": "CN=server.example.com",
"issuer": "CN=Example CA",
"not_before": "2026-06-24T14:32:00Z",
"not_after": "2027-06-24T14:32:00Z",
"certificate": "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAEo/IYwJdzQr...\n-----END CERTIFICATE-----"
}
Trade-offs:
- Enabling
include_cert_datasignificantly increases audit log size (typical certificate is 1-2 KB). - Required by some compliance frameworks (e.g., certain government PKI policies) for complete certificate lifecycle tracking.
- Useful for forensic analysis when the original certificate is no longer available in the database.
Configuration Examples
Minimal Configuration (File Only)
[audit]
file = "/var/log/kipuka/audit.json"
This configuration writes all audit events to a local file with default settings.
Production Configuration (File + Syslog over TLS)
[audit]
file = "/var/log/kipuka/audit.json"
syslog = "tcp+tls://syslog.example.com:6514"
syslog_facility = "local1"
events = [
"cert.issued",
"cert.denied",
"cert.revoked",
"auth.otp.failure",
"auth.otp.lockout",
"auth.mtls.failure",
"admin.access",
"otp.created",
"server.startup",
"server.shutdown",
]
This configuration:
- Logs all events to the database (always active).
- Logs security-critical events to both file and remote syslog over TLS.
- Reduces file and syslog volume by filtering routine successful operations.
NIAP-Compliant Configuration
[audit]
file = "/var/log/kipuka/audit.json"
syslog = "tcp+tls://syslog.example.com:6514"
syslog_facility = "local0"
include_cert_data = true
# events array omitted: all events forwarded to file and syslog
This configuration:
- Logs all 22 event types to database, file, and syslog.
- Uses TLS-encrypted syslog for confidentiality and integrity.
- Includes full certificate data in audit records for complete lifecycle tracking.
- Meets NIAP FAU_GEN.1 requirements for completeness, detail, and tamper evidence.
Note: Enabling include_cert_data will substantially increase storage requirements. Plan for approximately 2-5 KB per certificate lifecycle event (issuance, renewal, revocation).
Admin API
The Admin API provides privileged endpoints for server management, monitoring, OTP provisioning, and certificate lifecycle operations. It runs on a separate port from the EST service to enable network-level access control and is disabled by default for security.
Enabling the Admin API
The Admin API is disabled by default. To enable it, configure both the [admin] section and the admin_listen address in [server].
The Admin API runs on a separate port (default 9443) to allow network-level access control. Bind to 127.0.0.1 for localhost-only access, or to a management VLAN IP for remote administration:
[server]
admin_listen = "127.0.0.1:9443"
[admin]
enabled = true
auth = "mtls"
trust_anchors = "/etc/kipuka/admin-ca.pem"
Authentication Methods
The Admin API supports three authentication modes configured via the auth parameter:
mTLS Authentication
Admin clients must present a certificate signed by the admin trust anchors CA. This is the most secure option and recommended for production environments.
[admin]
enabled = true
auth = "mtls"
trust_anchors = "/etc/kipuka/admin-ca.pem"
Bearer Token Authentication
Admin clients provide a bearer token in the Authorization header. The token value is loaded from the environment variable specified by bearer_token_env.
[admin]
enabled = true
auth = "bearer"
bearer_token_env = "KIPUKA_ADMIN_TOKEN"
Clients authenticate using:
curl -H "Authorization: Bearer $KIPUKA_ADMIN_TOKEN" \
https://localhost:9443/admin/health
Both Authentication
Either mTLS or bearer token accepted. Useful during migration periods or for mixed tooling environments.
[admin]
enabled = true
auth = "both"
trust_anchors = "/etc/kipuka/admin-ca.pem"
bearer_token_env = "KIPUKA_ADMIN_TOKEN"
Endpoints Overview
| Method | Path | Description |
|---|---|---|
| GET | /admin/health | Server health and component status |
| GET | /admin/health/ready | Readiness probe (for Kubernetes) |
| GET | /admin/health/live | Liveness probe (for Kubernetes) |
| GET | /admin/ca | List all configured CAs with status |
| GET | /admin/ca/{id} | Get specific CA details and health |
| GET | /admin/ca/{id}/chain | Get CA certificate chain (PEM) |
| POST | /admin/otp | Generate a new OTP token |
| GET | /admin/otp | List active (unused) OTP tokens |
| DELETE | /admin/otp/{token_id} | Revoke an unused OTP token |
| GET | /admin/certs | List issued certificates (paginated) |
| GET | /admin/certs/{serial} | Get certificate details by serial |
| POST | /admin/certs/{serial}/revoke | Revoke a certificate |
| GET | /admin/audit | Query audit log (paginated, filterable) |
| GET | /admin/metrics | Prometheus-format metrics |
OTP Provisioning Workflow
The OTP provisioning workflow enables secure initial enrollment for devices that do not yet have certificates.
Step 1: Admin Generates OTP
The administrator creates a new OTP token with specified constraints:
curl -s --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
-X POST https://localhost:9443/admin/otp \
-H "Content-Type: application/json" \
-d '{"label": "device-42", "ttl": "24h", "max_uses": 1}'
Response:
{
"token_id": "01234567-89ab-cdef-0123-456789abcdef",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires": "2026-06-25T14:30:00Z",
"label": "device-42",
"max_uses": 1
}
Parameters:
label: Human-readable identifier for the token (for audit logs)ttl: Time-to-live (e.g., “24h”, “7d”, “1h30m”)max_uses: Maximum number of times the token can be used (typically 1)
Step 2: Admin Delivers Token to Client Operator
The token value must be delivered out-of-band through a secure channel:
- Encrypted email
- Ticketing system
- Secure messaging platform
- In-person handoff
Never transmit OTP tokens over unencrypted channels.
Step 3: Client Uses OTP for Initial Enrollment
The client operator uses the OTP token for initial EST enrollment:
curl --cacert ca.pem \
-u ":eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
--data-binary @csr.pem \
-H "Content-Type: application/pkcs10" \
-o cert.p7 \
https://est.example.com/.well-known/est/simpleenroll
Note the colon prefix in the -u flag: HTTP Basic Auth uses username:password format, and OTP tokens are submitted as the password with an empty username.
Step 4: OTP Consumed After Successful Enrollment
After successful enrollment, the OTP is marked as consumed if max_uses=1. Subsequent attempts to use the same token will be rejected.
Step 5: Future Renewals Use Certificate-Based mTLS
Once the device has a certificate, all future operations (reenrollment, renewal) use the issued certificate for mTLS authentication. The OTP is no longer required.
CA Status Monitoring
List All CAs
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/ca
Response:
[
{
"id": "primary-ca",
"name": "Primary Issuing CA",
"status": "healthy",
"last_check": "2026-06-24T12:34:56Z",
"cert_not_after": "2028-12-31T23:59:59Z",
"certs_issued_today": 42,
"certs_issued_total": 15234
},
{
"id": "backup-ca",
"name": "Backup Issuing CA",
"status": "healthy",
"last_check": "2026-06-24T12:34:56Z",
"cert_not_after": "2029-06-30T23:59:59Z",
"certs_issued_today": 0,
"certs_issued_total": 0
}
]
Status values:
healthy: CA is operational and passing all health checksdegraded: CA is operational but experiencing issues (e.g., approaching expiration)unhealthy: CA is not operational (e.g., expired, HSM unreachable)
Get Specific CA Details
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/ca/primary-ca
This endpoint provides detailed status for a single CA, including backend-specific health information.
Get CA Certificate Chain
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/ca/primary-ca/chain
Returns the CA certificate chain in PEM format. This is useful for distributing trust anchors to clients.
Aggregate Health Status
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/health
Response:
{
"status": "healthy",
"components": {
"database": "healthy",
"cas": {
"primary-ca": "healthy",
"backup-ca": "healthy"
},
"hsm": "healthy"
},
"uptime": "3d 14h 22m"
}
Use this endpoint for monitoring dashboards and alerting. Aggregate status reflects the worst status of any component:
- All components healthy:
healthy - Any component degraded:
degraded - Any component unhealthy:
unhealthy
Kubernetes Probes
For Kubernetes deployments, use the dedicated probe endpoints:
Readiness probe (server is ready to accept traffic):
curl https://localhost:9443/admin/health/ready
Liveness probe (server is running):
curl https://localhost:9443/admin/health/live
Configure in your Deployment manifest:
livenessProbe:
httpGet:
path: /admin/health/live
port: 9443
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /admin/health/ready
port: 9443
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
Certificate Management
List Issued Certificates
List certificates with optional filtering and pagination:
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
"https://localhost:9443/admin/certs?status=active&ca=primary-ca&page=1&per_page=50"
Query parameters:
status: Filter by certificate status (active,revoked,expired)ca: Filter by issuing CA IDsubject: Filter by subject DN (substring match)page: Page number (1-indexed)per_page: Results per page (default 50, max 1000)
Response:
{
"certs": [
{
"serial": "1a2b3c4d5e6f7890",
"subject": "CN=device-42.example.com",
"issuer": "CN=Primary Issuing CA",
"not_before": "2026-06-24T00:00:00Z",
"not_after": "2027-06-24T23:59:59Z",
"status": "active",
"ca_id": "primary-ca"
}
],
"total": 15234,
"page": 1,
"per_page": 50
}
Get Certificate Details
Retrieve detailed information for a specific certificate by serial number:
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/certs/1a2b3c4d5e6f7890
Response includes full certificate details, issuance metadata, and audit trail.
Revoke a Certificate
Revoke a certificate by serial number with a reason code:
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
-X POST https://localhost:9443/admin/certs/1a2b3c4d5e6f7890/revoke \
-H "Content-Type: application/json" \
-d '{"reason": "keyCompromise"}'
Valid reason codes (per RFC 5280):
unspecified: Default reasonkeyCompromise: Private key compromisecaCompromise: CA key compromiseaffiliationChanged: Subject changed affiliationsuperseded: Certificate replacedcessationOfOperation: Certificate no longer neededprivilegeWithdrawn: Certificate privileges revoked
Response:
{
"serial": "1a2b3c4d5e6f7890",
"status": "revoked",
"revocation_time": "2026-06-24T14:30:00Z",
"revocation_reason": "keyCompromise"
}
The certificate is immediately marked as revoked in the database and will appear in the next CRL update.
Audit Log Queries
Query the audit log for security analysis and compliance:
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
"https://localhost:9443/admin/audit?action=enroll&start=2026-06-01&end=2026-06-24&page=1&per_page=100"
Query parameters:
action: Filter by action type (enroll,reenroll,serverkeygen,revoke,otp_generate,otp_use)subject: Filter by certificate subject DNca: Filter by CA IDstart: Start timestamp (ISO 8601)end: End timestamp (ISO 8601)page: Page number (1-indexed)per_page: Results per page (default 100, max 1000)
Response:
{
"events": [
{
"timestamp": "2026-06-24T10:15:30Z",
"action": "enroll",
"subject": "CN=device-42.example.com",
"ca_id": "primary-ca",
"result": "success",
"client_ip": "10.0.1.42",
"auth_method": "otp",
"serial": "1a2b3c4d5e6f7890"
}
],
"total": 1523,
"page": 1,
"per_page": 100
}
Metrics
The Admin API exposes Prometheus-format metrics for monitoring:
curl --cert admin.pem --key admin-key.pem \
--cacert admin-ca.pem \
https://localhost:9443/admin/metrics
Key metrics include:
kipuka_enrollments_total: Counter of enrollment operations by CA and resultkipuka_reenrollments_total: Counter of reenrollment operations by CA and resultkipuka_revocations_total: Counter of revocation operations by CA and reasonkipuka_otp_generated_total: Counter of OTP tokens generatedkipuka_otp_consumed_total: Counter of OTP tokens consumedkipuka_request_duration_seconds: Histogram of request durations by endpointkipuka_active_certificates: Gauge of currently active certificates by CAkipuka_ca_health: Gauge of CA health status (1=healthy, 0=unhealthy)
Configure Prometheus to scrape the Admin API endpoint with appropriate mTLS credentials.
Dogtag PKI Integration
Overview
kipuka can use Red Hat Certificate System (Dogtag PKI) as a backend CA instead of, or alongside, file-based or HSM-backed local CAs. This integration enables organizations already running Dogtag/RHCS to add EST enrollment capabilities without modifying their existing PKI infrastructure.
Architecture:
- Dogtag provides a full-featured PKI with REST API, comprehensive audit logging, HSM support, and complete certificate lifecycle management
- kipuka acts as an EST frontend (Registration Authority) to Dogtag’s CA subsystem
- The integration preserves all Dogtag policies, approval workflows, and certificate profiles while exposing EST protocol endpoints
Benefits:
- Leverage existing Dogtag infrastructure and operational expertise
- Maintain centralized certificate policy enforcement in Dogtag profiles
- Benefit from Dogtag’s enterprise features: HSM integration, audit trails, approval workflows, Key Recovery Authority (KRA)
- Add EST enrollment to IoT devices, network equipment, and mobile endpoints without deploying new CA infrastructure
REST API Client
kipuka communicates with Dogtag exclusively via its REST API, typically exposed at https://dogtag.example.com:8443/ca/rest/certrequests.
Authentication
kipuka authenticates to Dogtag using a client certificate (agent cert) that has enrollment privileges. This certificate must be issued by the Dogtag CA itself and associated with an agent user in the Dogtag database.
Obtaining an agent certificate:
-
On the Dogtag server, create an agent user (if not already present):
pki -d /var/lib/pki/pki-tomcat/alias -c <password> \ ca-user-add kipuka-agent --fullName "kipuka EST RA" -
Generate a certificate request and submit it for the agent certificate:
pki -d /var/lib/pki/pki-tomcat/alias -c <password> \ client-cert-request "CN=kipuka EST RA,O=Example Corp" \ --profile caAgentServerCert -
Approve and issue the certificate via the Dogtag web UI or CLI
-
Export the agent certificate and key for use by kipuka
Configuration
Configure Dogtag as a CA entry with type = "dogtag":
[[ca]]
id = "dogtag-ca"
name = "Dogtag Enterprise CA"
type = "dogtag"
# Dogtag instance URL (without /ca/rest suffix)
url = "https://dogtag.example.com:8443"
# Agent certificate for authenticating to Dogtag
agent_cert = "/etc/kipuka/dogtag/agent.pem"
agent_key = "/etc/kipuka/dogtag/agent-key.pem"
# Dogtag CA certificate chain for TLS verification
ca_cert = "/etc/kipuka/dogtag/ca-chain.pem"
# Default certificate profile for this CA
profile = "caServerCert"
# Optional: Request timeout (default: 30s)
timeout = "30s"
# Optional: Enable certificate caching to reduce Dogtag load
cache_certificates = true
cache_ttl = "1h"
File formats:
agent_certandca_cert: PEM-encoded X.509 certificatesagent_key: PEM-encoded RSA or EC private key (can be encrypted; kipuka will prompt for passphrase)
Enrollment
When kipuka receives an EST /simpleenroll request, it translates the PKCS#10 CSR into a Dogtag certificate enrollment request and submits it to the Dogtag REST API.
Profile Mapping
Dogtag uses certificate profiles (e.g., caServerCert, caUserCert, caTPSCert) to define certificate content, extensions, and policy constraints. kipuka maps EST labels to Dogtag profiles:
[[est.label]]
name = "server-tls"
ca_id = "dogtag-ca"
dogtag_profile = "caServerCert"
[[est.label]]
name = "client-auth"
ca_id = "dogtag-ca"
dogtag_profile = "caUserCert"
[[est.label]]
name = "token-signing"
ca_id = "dogtag-ca"
dogtag_profile = "caTPSCert"
Default profile: If no label is provided in the EST request, the profile specified in the [[ca]] entry is used.
Enrollment Flow
- Client sends EST
/simpleenrollrequest with PKCS#10 CSR - kipuka validates the CSR and authenticates the client (via HTTP Basic or TLS client cert)
- kipuka determines the Dogtag profile based on the EST label
- kipuka submits the CSR to Dogtag:
POST /ca/rest/certrequests - Dogtag processes the request according to the profile policy:
- If auto-approval is enabled in the profile, the certificate is issued immediately
- If approval is required, the request enters pending state
- kipuka polls Dogtag until the certificate is issued (or timeout expires)
- kipuka returns the issued certificate to the EST client in PKCS#7 format
Manual Approval Workflow
If the Dogtag profile requires manual approval (common for high-assurance certificates):
- The enrollment request remains in Dogtag’s pending queue
- An agent approves the request via Dogtag web UI or CLI:
pki ca-cert-request-review <request_id> --action approve - kipuka’s next poll retrieves the issued certificate
- The EST client receives the certificate (this may take seconds to minutes depending on poll interval)
Polling configuration:
[[ca]]
id = "dogtag-ca"
type = "dogtag"
# ... other config ...
# Poll interval for pending certificate requests (default: 5s)
poll_interval = "5s"
# Maximum time to wait for approval (default: 5m)
approval_timeout = "5m"
If approval_timeout expires before the certificate is issued, kipuka returns an HTTP 202 response to the client with a retry-after header.
Revocation
kipuka’s Admin API revocation calls are forwarded to Dogtag’s revocation endpoint.
Revoke via Admin API
curl -X POST https://kipuka.example.com/admin/revoke \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{
"serial": "0x1A2B3C4D",
"reason": "keyCompromise"
}'
Revocation Flow
- kipuka receives the revocation request via Admin API
- kipuka maps the serial number to the issuing CA (in this case,
dogtag-ca) - kipuka submits a revocation request to Dogtag:
POST /ca/rest/agent/certs/<serial>/revoke - Dogtag revokes the certificate and updates its internal database
- Dogtag automatically updates CRLs and OCSP responder (if enabled)
- kipuka returns success to the caller
Revocation Reason Mapping
kipuka maps its revocation reason codes to Dogtag’s reason codes per RFC 5280:
| kipuka Reason | Dogtag Reason Code | RFC 5280 Code |
|---|---|---|
unspecified | 0 | 0 |
keyCompromise | 1 | 1 |
caCompromise | 2 | 2 |
affiliationChanged | 3 | 3 |
superseded | 4 | 4 |
cessationOfOperation | 5 | 5 |
certificateHold | 6 | 6 |
removeFromCRL | 8 | 8 |
privilegeWithdrawn | 9 | 9 |
aaCompromise | 10 | 10 |
Note: Dogtag does not support removeFromCRL (un-hold) via the REST API for certificates revoked with certificateHold. Use the Dogtag CLI for un-hold operations.
KRA Server-Side Key Generation
EST’s /serverkeygen endpoint can use Dogtag’s Key Recovery Authority (KRA) subsystem for server-side key generation and archival.
Overview
When server-side key generation is requested:
- kipuka forwards the request to Dogtag with the KRA archival flag enabled
- Dogtag’s KRA subsystem generates the key pair server-side
- The private key is encrypted and archived in the KRA database
- Dogtag issues the certificate using the generated public key
- kipuka receives both the certificate and the encrypted private key
- The private key is returned to the EST client as a PKCS#8
EncryptedPrivateKeyInfowrapped in the EST response
Prerequisites
- Dogtag KRA subsystem must be installed and configured
- The KRA must be configured to communicate with the CA subsystem
- The agent certificate used by kipuka must have key archival privileges
Configuration
[[ca]]
id = "dogtag-ca"
type = "dogtag"
url = "https://dogtag.example.com:8443"
# KRA endpoint (often same host as CA)
kra_url = "https://dogtag.example.com:8443"
# KRA agent credentials (may be same as CA agent or separate)
kra_agent_cert = "/etc/kipuka/dogtag/kra-agent.pem"
kra_agent_key = "/etc/kipuka/dogtag/kra-agent-key.pem"
# ... other CA config ...
If kra_url is not specified, server-side key generation is disabled for this CA.
EST Request
curl -X POST https://kipuka.example.com/.well-known/est/serverkeygen \
--user client:password \
--cacert ca.pem \
-H "Content-Type: application/pkcs10" \
--data-binary @csr.p10 \
-o response.p7
The response is a PKCS#7 structure containing:
- The issued certificate
- The encrypted private key (PKCS#8 EncryptedPrivateKeyInfo)
- Optional additional CA certificates
Client-side decryption: The client must decrypt the private key using the password provided during enrollment (passed in the CSR’s challenge password attribute, or via out-of-band key).
Key Recovery
If a client loses their private key, authorized users can recover it from the KRA:
pki kra-key-recover --keyID <key_id> --output recovered-key.p12
This operation requires dual approval from KRA agents (depending on KRA policy).
Multi-CA Pool with Circuit Breaker
Multiple Dogtag instances can be configured as separate CA entries to provide high availability and load distribution.
Configuration
[[ca]]
id = "dogtag-primary"
type = "dogtag"
url = "https://dogtag1.example.com:8443"
agent_cert = "/etc/kipuka/dogtag/agent.pem"
agent_key = "/etc/kipuka/dogtag/agent-key.pem"
ca_cert = "/etc/kipuka/dogtag/ca-chain.pem"
profile = "caServerCert"
[[ca]]
id = "dogtag-secondary"
type = "dogtag"
url = "https://dogtag2.example.com:8443"
agent_cert = "/etc/kipuka/dogtag/agent.pem"
agent_key = "/etc/kipuka/dogtag/agent-key.pem"
ca_cert = "/etc/kipuka/dogtag/ca-chain.pem"
profile = "caServerCert"
[ha]
enabled = true
strategy = "active-passive"
[[ha.group]]
name = "dogtag-pool"
ca_ids = ["dogtag-primary", "dogtag-secondary"]
# Health check interval
health_check_interval = "30s"
# Circuit breaker thresholds
failure_threshold = 3
success_threshold = 2
timeout = "10s"
Failover Behavior
Active-Passive Strategy:
- All requests are routed to
dogtag-primary - If
dogtag-primaryfails health checks (3 consecutive failures), the circuit breaker trips - Traffic is routed to
dogtag-secondarywhiledogtag-primaryis unavailable - Once
dogtag-primarypasses health checks (2 consecutive successes), traffic is restored to primary
Active-Active Strategy (future):
[ha]
enabled = true
strategy = "active-active"
[[ha.group]]
name = "dogtag-pool"
ca_ids = ["dogtag-primary", "dogtag-secondary"]
load_balancing = "round-robin" # or "least-connections"
Health Checks
kipuka performs health checks against each Dogtag instance:
GET /ca/rest/certs/ca HTTP/1.1
Host: dogtag1.example.com:8443
A successful response (HTTP 200 with the CA certificate) indicates the instance is healthy.
Monitoring endpoint:
curl https://kipuka.example.com/health/ca
Response:
{
"dogtag-primary": {
"status": "healthy",
"last_check": "2026-06-24T10:30:00Z",
"consecutive_failures": 0
},
"dogtag-secondary": {
"status": "circuit_open",
"last_check": "2026-06-24T10:30:00Z",
"consecutive_failures": 5
}
}
Full CMC Passthrough (RFC 5272)
When fullcmc = true in the [est] configuration, kipuka can pass Full CMC (Certificate Management over CMS) requests directly to Dogtag.
Overview
Full CMC (RFC 5272/5273/5274) supports complex certificate management operations beyond simple enrollment:
- Batch enrollment (multiple certificates in a single request)
- Certificate update/renewal
- Key recovery requests
- Revocation requests
- Get certificate/CRL operations
Dogtag is one of the few CAs that fully implements RFC 5272/5273/5274, making it ideal for enterprise CMC deployments.
Configuration
[est]
enabled = true
fullcmc = true
# Optional: CMC-specific settings
[est.cmc]
# Maximum CMC message size (default: 10MB)
max_message_size = "10MB"
# Allow unsigned CMC requests (default: false)
allow_unsigned = false
CMC Request Flow
- Client constructs a Full CMC request (typically using
CMCRequesttool or smart card management software) - Client submits the CMC request to kipuka’s EST endpoint:
POST /.well-known/est/fullcmc - kipuka validates the CMC request structure and authenticates the client
- kipuka forwards the CMC payload to Dogtag:
POST /ca/rest/certrequests/cmc - Dogtag processes the CMC request according to its internal policies
- Dogtag returns a CMC Full PKI Response
- kipuka returns the CMC response to the EST client
Example: Batch Enrollment via CMC
# Create a CMC request with multiple CSRs
CMCRequest \
-d /path/to/nssdb \
-i csr1.pem \
-i csr2.pem \
-i csr3.pem \
-o batch-request.cmc
# Submit to kipuka
curl -X POST https://kipuka.example.com/.well-known/est/fullcmc \
--user agent:password \
--cacert ca.pem \
-H "Content-Type: application/pkcs7-mime" \
--data-binary @batch-request.cmc \
-o batch-response.cmc
# Parse the response
CMCResponse -d /path/to/nssdb -i batch-response.cmc
Token Processing System (TPS) Integration
Dogtag’s Token Processing System (TPS) uses Full CMC for smart card lifecycle management:
- Format and enroll smart cards
- Update certificates on existing tokens
- Recover keys from KRA to re-provision tokens
kipuka acts as a CMC gateway, enabling TPS to operate over EST endpoints. This is particularly useful for remote TPS operations where direct Dogtag access is restricted by firewall policy.
TPS configuration example:
[[ca]]
id = "dogtag-ca"
type = "dogtag"
url = "https://dogtag.example.com:8443"
kra_url = "https://dogtag.example.com:8443"
tps_url = "https://dogtag.example.com:8443"
# ... agent certs ...
[est.cmc]
# TPS may send large CMC messages with multiple key recovery requests
max_message_size = "50MB"
# TPS operations require signed CMC requests
allow_unsigned = false
Prerequisites
Before configuring kipuka with Dogtag integration, ensure:
-
Dogtag PKI Installation:
- Dogtag PKI 10.x or 11.x (RHEL 7/8/9: Red Hat Certificate System 9.x or 10.x)
- Fedora: Dogtag PKI 11.x available via dnf
- CA subsystem installed and operational
-
Agent Certificate:
- Agent certificate issued by the Dogtag CA with enrollment privileges
- For KRA integration: separate agent cert with key archival/recovery privileges
- Agent user configured in Dogtag database and added to appropriate groups
-
Network Connectivity:
- kipuka must reach Dogtag REST API port (default: 8443)
- If using Dogtag behind a load balancer, ensure session affinity for approval workflows
- Firewall rules permit bidirectional HTTPS between kipuka and Dogtag
-
TLS Certificates:
- Dogtag CA certificate chain for TLS verification
- If Dogtag uses a private CA, install the root and intermediate certificates on kipuka host
-
Optional: KRA Subsystem:
- KRA installed and configured if server-side key generation is required
- KRA transport certificate and storage certificate properly configured
- KRA connector enabled in CA subsystem
-
Profiles:
- Ensure the certificate profiles referenced in kipuka configuration exist in Dogtag
- Verify profile policies match your security requirements
- Test profile submission via Dogtag CLI before configuring kipuka
Verification
Test Dogtag connectivity from the kipuka host:
# Verify CA REST API is accessible
curl -k https://dogtag.example.com:8443/ca/rest/certs/ca
# Submit a test certificate request using agent cert
curl -k https://dogtag.example.com:8443/ca/rest/certrequests \
-X POST \
--cert /etc/kipuka/dogtag/agent.pem \
--key /etc/kipuka/dogtag/agent-key.pem \
-H "Content-Type: application/json" \
-d '{
"ProfileID": "caServerCert",
"Renewal": false,
"Input": [{
"Name": "cert_request_type",
"Value": "pkcs10"
}, {
"Name": "cert_request",
"Value": "<base64-encoded-csr>"
}]
}'
A successful response indicates the agent certificate is configured correctly and has enrollment privileges.
See Also
- High Availability Configuration for general HA strategies
- Admin API Reference for revocation endpoints
- Certificate Profiles for mapping EST labels to CA profiles
- Red Hat Certificate System documentation: https://access.redhat.com/documentation/en-us/red_hat_certificate_system/
- Dogtag PKI wiki: https://www.dogtagpki.org/wiki/PKI_Main_Page
RFC Support Reference
This page documents every standards specification that kipuka implements, which sections are covered, and any caveats relevant to conformance testing.
Core Enrollment Standards
RFC 7030 – Enrollment over Secure Transport (EST)
| Section | Operation | Status | Notes |
|---|---|---|---|
| 4.1 | /cacerts | Implemented | Returns the CA certificate chain as a PKCS#7 certs-only message. Unauthenticated; available without client credentials. |
| 4.2 | /simpleenroll | Implemented | PKCS#10 CSR in, signed certificate out. Supports OTP, mTLS, GSSAPI, and CMS-based authentication. |
| 4.2.2 | /simplereenroll | Implemented | Renewal with the existing client certificate presented via mTLS. Subject and SAN matching enforced by default. |
| 4.3 | /fullcmc | Implemented | Full CMC (RFC 5272) request/response over the EST transport. Disabled by default; enable with est.fullcmc = true. |
| 4.4 | /serverkeygen | Implemented | Server generates the key pair; returns a PKCS#7 EnvelopedData wrapping the private key and the signed certificate. Disabled by default. |
| 4.5 | /csrattrs | Implemented | Returns a DER-encoded sequence of OIDs and attribute definitions the server expects in a CSR. |
| 3.2 | EST-over-TLS binding | Implemented | All endpoints served over TLS 1.2 or 1.3 via rustls. The well-known URI prefix /.well-known/est is configurable via est.base_path. |
| 3.2.2 | EST labels | Implemented | Label-specific paths (/.well-known/est/{label}/...) route to per-CA configurations with independent certificate profiles, allowed key types, and validity limits. |
| 3.3.2 | HTTP-based client auth | Implemented | HTTP-layer authentication (OTP via Authorization header) supplements TLS-layer mTLS authentication. |
| 4.2.3 | Retry-After handling | Implemented | When a request is deferred (e.g., pending manual approval), kipuka returns HTTP 202 with a Retry-After header. The interval is configurable via est.retry_after. |
| 3.5 | Linking identity and POP | Implemented | Proof-of-Possession via CSR self-signature is verified. When mTLS is used, the binding between the TLS identity and the CSR subject is enforced. |
RFC 8951 – Clarifications for EST
RFC 8951 addresses ambiguities in RFC 7030. kipuka incorporates all clarifications that affect server behavior:
| Clarification | Status | Notes |
|---|---|---|
| Content-Type enforcement | Implemented | Strict application/pkcs10 for enrollment; application/pkcs7-mime; smime-type=certs-only for /cacerts responses. |
| Base64 encoding rules | Implemented | Accepts both raw DER and Base64-encoded bodies. Responses use Base64 with Content-Transfer-Encoding: base64. |
| Error response format | Implemented | HTTP 4xx/5xx responses include a Content-Type: application/json body with a machine-readable error code and human-readable message. |
| TLS-unique channel binding | Implemented | Channel binding values used for proof-of-possession when available (TLS 1.2 with tls-unique; TLS 1.3 uses Exporter instead). |
RFC 5272 – Certificate Management over CMS (Full CMC)
The /fullcmc endpoint accepts a Full PKI Request (a ContentInfo
containing a PKIData structure) and returns a Full PKI Response.
| Feature | Status | Notes |
|---|---|---|
| PKIData parsing | Implemented | Parses TaggedRequest sequences containing PKCS#10 or CRMF requests. |
| PKIResponse construction | Implemented | Returns ResponseBody with certificates and optional control attributes. |
| Control attributes | Partial | id-cmc-statusInfoV2, id-cmc-identification, id-cmc-transactionId, and id-cmc-senderNonce / id-cmc-recipientNonce are supported. RA-delegated controls are not yet implemented. |
| Nested CMS signing | Implemented | Requests may be signed by a Registration Authority; kipuka validates the RA certificate against a configured RA trust store. |
RFC 8739 – Short-Term, Automatically Renewed Certificates (STAR)
kipuka implements the server-side portion of STAR for short-lived certificate
management. The kipuka::star module tracks renewal orders and issues
replacement certificates before expiry.
| Feature | Status | Notes |
|---|---|---|
| STAR order lifecycle | Implemented | StarOrder tracks each auto-renewal series through pending, active, expired, and cancelled states. |
| Automatic re-issuance | Implemented | The StarManager monitors active orders and issues replacement certificates before the not-after of the current certificate. Renewal interval is configurable per order. |
| Certificate fetch endpoint | Implemented | The /.well-known/est/{label}/star/{order-id} path returns the latest certificate in the renewal series. |
| Cancellation | Implemented | Operators can cancel a STAR order via the admin API. The server stops issuing renewals and the order transitions to cancelled. |
Transport
RFC 7252 – Constrained Application Protocol (CoAP)
The kipuka-coap crate implements EST-over-CoAP for resource-constrained
devices (IoT sensors, embedded controllers) that cannot maintain persistent
TCP connections.
| Feature | Status | Notes |
|---|---|---|
| CoAP message parsing | Implemented | Confirmable (CON) and Non-confirmable (NON) message types. Token matching for request/response correlation. |
| DTLS transport | Implemented | CoAP endpoints served over DTLS 1.2 with PSK or certificate-based authentication via the kipuka_coap::dtls module. |
| Block-wise transfer | Implemented | RFC 7959 Block1/Block2 options for transferring certificates and CSRs that exceed a single CoAP datagram. Block size is negotiated automatically. |
| Content-Format mapping | Implemented | EST content types mapped to CoAP Content-Format option values per the EST-coaps draft. |
/cacerts over CoAP | Implemented | Returns the CA certificate chain using block-wise transfer. |
/simpleenroll over CoAP | Implemented | Enrollment via PKCS#10 CSR over CoAP/DTLS. |
Certificate Policy
CA/Browser Forum Baseline Requirements
kipuka enforces the CA/B Forum Baseline Requirements for TLS server certificates when configured to issue publicly-trusted certificates. See CA/B Forum Baseline Requirements for the full mapping.
| Requirement | Status | Notes |
|---|---|---|
| Certificate profile | Enforced | Subject fields, key types, extensions validated against BR profiles. Non-compliant CSRs are rejected before signing. |
| Serial number generation | Enforced | 160-bit serials from OS CSPRNG (exceeds the BR minimum of 64 bits). |
| Maximum validity period | Enforced | Configurable max_validity_days with declining defaults that track the BR timeline (398/200/100/47 days). |
| Key size minimums | Enforced | RSA >= 2048 bits, ECDSA P-256 or P-384. Smaller keys are rejected at CSR validation. |
NIAP Common Criteria – CA Protection Profile v2.0
kipuka is designed to satisfy the Security Functional Requirements (SFRs) of the NIAP CA Protection Profile. See NIAP CA Protection Profile for the full SFR-by-SFR mapping.
Cryptographic Standards
FIPS 140-3 – Cryptographic Module Validation
kipuka does not itself hold a FIPS 140-3 certificate. FIPS compliance is achieved by delegating all cryptographic operations to a validated PKCS#11 HSM module.
| Aspect | Mechanism | Notes |
|---|---|---|
| Key generation | C_GenerateKeyPair | RSA and ECDSA key pairs generated inside the HSM boundary. |
| Signing | C_Sign | All certificate signing operations execute within the validated module. |
| Random number generation | C_GenerateRandom | Serial number entropy sourced from the HSM’s validated DRBG when an HSM is configured; falls back to OS CSPRNG (getrandom) otherwise. |
| Key storage | HSM token | Private keys are CKA_SENSITIVE and CKA_EXTRACTABLE=false by default. |
When kipuka operates with a software-only key (no HSM), it uses the Synta crate’s software implementations. These are suitable for testing and non-FIPS environments but do not carry a FIPS validation.
FIPS 204 – ML-DSA (Post-Quantum Digital Signatures)
kipuka supports ML-DSA (formerly CRYSTALS-Dilithium) signing via two paths:
| Path | Mechanism | Levels |
|---|---|---|
| Synta software | kipuka_hsm::sign::sign_ml_dsa | ML-DSA-44 (L2), ML-DSA-65 (L3), ML-DSA-87 (L5) |
| PKCS#11 HSM | CKM_IBM_DILITHIUM or vendor-specific | Depends on HSM firmware; see HSM Compatibility Matrix |
ML-DSA support is experimental. CA certificates and end-entity certificates can use ML-DSA key pairs, but client and browser ecosystem support is limited as of 2026.
FIPS 203 – ML-KEM (Post-Quantum Key Encapsulation)
ML-KEM (formerly CRYSTALS-Kyber) is supported for key encapsulation in
hybrid key exchange scenarios. The kipuka_hsm::key module defines
MlKemLevel variants L1, L3, and L5 corresponding to ML-KEM-512,
ML-KEM-768, and ML-KEM-1024 respectively.
| Path | Mechanism | Levels |
|---|---|---|
| Synta software | Software KEM | ML-KEM-512 (L1), ML-KEM-768 (L3), ML-KEM-1024 (L5) |
| PKCS#11 HSM | Vendor-specific mechanisms | Depends on HSM firmware support |
ML-KEM is primarily relevant for /serverkeygen responses where the server
encrypts the generated private key for transport to the client.
Summary Matrix
| Standard | Scope | Status |
|---|---|---|
| RFC 7030 | EST protocol | Core implementation – all six endpoints |
| RFC 8951 | EST clarifications | Fully implemented |
| RFC 5272 | CMC (Full) | /fullcmc endpoint, partial control attributes |
| RFC 8739 | STAR auto-renewal | Short-lived certificate management |
| RFC 7252 | CoAP transport | Constrained device enrollment over DTLS |
| CA/B Forum BR | Certificate profiles, validity | Enforced at CSR validation and signing |
| NIAP CA PP v2.0 | Protection Profile | SFR mapping documented |
| FIPS 140-3 | Cryptographic modules | Via HSM integration |
| FIPS 204 | ML-DSA post-quantum signing | Via Synta / PKCS#11 |
| FIPS 203 | ML-KEM post-quantum KEM | Via Synta / PKCS#11 |
NIAP CA Protection Profile
This page maps kipuka capabilities to the Security Functional Requirements (SFRs) defined in the NIAP Certificate Authority Protection Profile v2.0. The mapping is intended for evaluation teams preparing a Common Criteria assessment and for operators who need to verify that their deployment satisfies the Protection Profile.
Note: A Common Criteria evaluation requires an accredited evaluation facility. This document describes how kipuka addresses each SFR at the implementation level; it does not constitute a certified Security Target.
Audit (FAU)
FAU_GEN.1 – Audit Data Generation
Requirement: The TOE shall generate an audit record for each auditable event, including the date/time, event type, subject identity, and outcome.
How kipuka satisfies it:
The kipuka::audit module records structured audit events through the
AuditEvent struct. Every event includes:
- Timestamp (UTC, microsecond precision)
- Event type (
AuditEventTypeenum) - Subject identity (client certificate DN, OTP identifier, or GSSAPI principal)
- Outcome (success or failure with reason)
- Source IP address and EST label
The following event types are audited:
| Event Type | Trigger |
|---|---|
EnrollRequest | Client submits a CSR to /simpleenroll, /simplereenroll, or /fullcmc |
CertIssue | Certificate successfully signed and returned to client |
CertReenroll | Certificate re-enrollment completed |
CertRevoke | Certificate revocation processed |
EnrollReject | CSR rejected (policy violation, invalid key, unauthorized) |
AuthSuccess | Client authentication succeeded (any method) |
AuthFailure | Client authentication failed (bad OTP, invalid cert, GSSAPI error) |
KeyGenerate | CA key pair generated (HSM or software) |
KeyLoad | CA key loaded from HSM slot or file |
KeyDestroy | CA key destroyed (HSM C_DestroyObject or file wipe) |
OtpCreate | OTP token created via admin API |
OtpUse | OTP token consumed during enrollment |
OtpRevoke | OTP token revoked via admin API |
OtpExpire | OTP token expired (TTL exceeded) |
AdminLogin | Operator authenticated to admin API |
AdminLogout | Operator session ended |
AdminAction | Operator performed an administrative operation |
CaStart | CA instance started and ready for signing |
CaStop | CA instance shut down |
CaHealthChange | CA health status changed (HA failover event) |
CrlGenerate | CRL generated (when using Dogtag back-end) |
SecurityViolation | Policy violation detected (tampered request, replay, etc.) |
Implementation location: kipuka::audit::record() is called from every
EST handler and authentication layer.
Evaluation notes: The event set covers all “minimum audit events” listed in Table 5 of the Protection Profile. Additional events (OTP lifecycle, HA health changes) exceed the minimum.
FAU_GEN.2 – User Identity Association
Requirement: The TOE shall associate each auditable event with the identity of the user that caused the event.
How kipuka satisfies it:
The AuditEvent struct contains a subject field populated from the
authenticated identity:
- mTLS: Subject DN and serial number of the client certificate.
- OTP: The OTP identifier (opaque token ID, not the secret).
- GSSAPI: The Kerberos principal name.
- CMS: The signer DN from the CMS
SignerInfo. - Unauthenticated (
/cacerts): Recorded as"anonymous"with the source IP.
FAU_STG.1 – Protected Audit Trail Storage
Requirement: The TOE shall protect the stored audit records from unauthorized deletion.
How kipuka satisfies it:
Audit records are written through two channels, both designed for append-only semantics:
-
Database – Audit records are
INSERT-only. The database schema does not exposeUPDATEorDELETEoperations on the audit table. When using PostgreSQL, row-level security policies can further restrict modification. -
File – When
audit.fileis configured, events are appended to a JSON Lines file. kipuka opens the file in append-only mode (O_APPEND). File-system permissions should restrict the audit file to the kipuka service account. -
Syslog – When
audit.syslogis configured, events are forwarded to a remote syslog server over TLS, placing the audit trail outside the TOE boundary.
Evaluation notes: The Protection Profile requires that “the TSF shall be able to prevent unauthorized deletion of the stored audit records.” Operators must ensure that file-system permissions and database RBAC are configured to prevent the kipuka process from deleting its own audit records. Shipping audit events to a remote syslog server (channel 3) provides the strongest protection.
Cryptographic Support (FCS)
FCS_CKM.1 – Cryptographic Key Generation
Requirement: The TOE shall generate asymmetric cryptographic keys in accordance with a specified key generation algorithm and key sizes.
How kipuka satisfies it:
| Key Type | Generation Method | Module |
|---|---|---|
| RSA 2048, 3072, 4096 | PKCS#11 C_GenerateKeyPair (HSM) or Synta software | kipuka_hsm::key |
| ECDSA P-256, P-384, P-521 | PKCS#11 C_GenerateKeyPair (HSM) or Synta software | kipuka_hsm::key |
| ML-DSA-44, ML-DSA-65, ML-DSA-87 | Synta software or vendor PKCS#11 | kipuka_hsm::key |
| Certificate serial numbers | OS CSPRNG (getrandom) or PKCS#11 C_GenerateRandom | kipuka::ca::issue |
Serial numbers are 160 bits (20 bytes) from a CSPRNG, exceeding the CA/B Forum minimum of 64 bits and the NIAP requirement for unpredictable serial numbers.
Implementation location: kipuka_hsm::key::HsmKeyPair for CA keys;
kipuka_est::serverkeygen for end-entity keys generated via
/serverkeygen.
FCS_CKM.2 – Cryptographic Key Distribution
Requirement: The TOE shall distribute cryptographic keys in accordance with a specified key distribution method.
How kipuka satisfies it:
-
CA certificate distribution – The
/cacertsendpoint returns the CA certificate chain as a PKCS#7certs-onlymessage. Distribution is over TLS (FCS_TLSS_EXT.1). -
Server-generated key distribution – When
/serverkeygenis enabled, the generated private key is encrypted to the client using PKCS#7EnvelopedData:- AES-256 key wrapping with the content-encryption key wrapped to the client’s public key via RSA-OAEP or ECDH-ES.
- The wrapped key and signed certificate are returned as a single multipart MIME response.
Implementation location: kipuka_est::serverkeygen for key wrapping;
kipuka_est::cacerts for CA chain distribution.
FCS_COP.1 – Cryptographic Operation
Requirement: The TOE shall perform cryptographic operations in accordance with specified algorithms and key sizes.
How kipuka satisfies it:
| Operation | Algorithm(s) | Key Size | Module |
|---|---|---|---|
| Certificate signing | RSA PKCS#1 v1.5, RSA-PSS, ECDSA | >= 2048 (RSA), P-256/P-384/P-521 (EC) | kipuka_hsm::sign |
| Certificate signing (PQC) | ML-DSA | L2/L3/L5 | kipuka_hsm::sign::sign_ml_dsa |
| Hashing | SHA-256, SHA-384, SHA-512 | – | Synta / HSM |
| TLS | TLS 1.2 / TLS 1.3 | Per cipher suite | rustls |
| OTP hashing | Argon2id, bcrypt, SHA-256-HMAC | – | kipuka_otp |
Key wrapping (/serverkeygen) | AES-256-WRAP, RSA-OAEP | 256 (AES), >= 2048 (RSA) | kipuka_est::serverkeygen |
Implementation location: kipuka_hsm::sign dispatches to
sign_rsa_pkcs1, sign_rsa_pss, sign_ecdsa, or sign_ml_dsa depending
on the CA key type.
FCS_RBG_EXT.1 – Random Bit Generation
Requirement: The TOE shall use a DRBG that is seeded by an entropy source.
How kipuka satisfies it:
- With HSM: Random bytes are obtained via PKCS#11
C_GenerateRandom, which invokes the HSM’s FIPS-validated DRBG. - Without HSM: Random bytes are obtained from the operating system’s
CSPRNG via
getrandom(2)on Linux or the equivalent on other platforms.
Serial number generation always uses whichever RBG is active.
FCS_TLSS_EXT.1 – TLS Server
Requirement: The TOE shall implement TLS 1.2 and/or TLS 1.3 as a server.
How kipuka satisfies it:
kipuka uses rustls for TLS termination.
| Parameter | Configuration | Notes |
|---|---|---|
| Minimum version | tls.min_version (default "1.2") | Set to "1.3" to disable TLS 1.2 entirely. |
| Cipher suites | tls.cipher_suites | Defaults to rustls safe defaults (TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256, TLS_CHACHA20_POLY1305_SHA256 for TLS 1.3; ECDHE suites for TLS 1.2). |
| Client auth | tls.client_auth | Supports required, optional, and none modes. |
| Server certificate EKU | id-kp-cmcRA | The server certificate should include the CMC Registration Authority extended key usage OID (1.3.6.1.5.5.7.3.28) for NIAP compliance. |
Implementation location: kipuka::tls module configures
rustls::ServerConfig.
Identification and Authentication (FIA)
FIA_AFL.1 – Authentication Failure Handling
Requirement: The TOE shall detect when a configurable number of unsuccessful authentication attempts occur and take action.
How kipuka satisfies it:
The OTP authentication subsystem implements rate limiting and lockout:
| Parameter | Config Key | Default |
|---|---|---|
| Max consecutive failures | otp.max_failures | 5 |
| Failure counting window | otp.failure_window | 15 minutes |
| Lockout duration | otp.lockout_duration | 30 minutes |
When the threshold is reached:
- An
AuthFailureaudit event is recorded with reason"lockout". - Subsequent authentication attempts for that OTP identifier return HTTP 403 until the lockout expires.
- If the lockout is triggered by mTLS (invalid client cert), the TLS handshake fails before the HTTP layer is reached; the audit event records the client IP and certificate serial number.
Implementation location: kipuka::auth::otp for OTP lockout;
kipuka::auth::mtls for mTLS failure logging.
FIA_UAU.1 – Timing of Authentication
Requirement: The TOE shall allow only the retrieval of CA certificates before the user is authenticated.
How kipuka satisfies it:
/cacerts– Unauthenticated. No client credential is required.- All other endpoints (
/simpleenroll,/simplereenroll,/fullcmc,/serverkeygen,/csrattrs) – Require authentication via at least one configured method (mTLS, OTP, GSSAPI, or CMS signature).
The EstAuth extractor in kipuka::auth enforces this policy. The
OptionalAuth extractor is used only for /cacerts and returns
AuthMethod::None when no credential is presented.
Implementation location: kipuka::auth::EstAuth and
kipuka::auth::OptionalAuth.
FIA_UAU.5 – Multiple Authentication Mechanisms
Requirement: The TOE shall provide multiple authentication mechanisms.
How kipuka satisfies it:
kipuka supports five authentication methods, selectable per EST label:
| Method | AuthMethod Variant | Use Case |
|---|---|---|
| Mutual TLS | Mtls | Primary method for re-enrollment and machine identity |
| One-Time Password | Otp | Initial device bootstrapping |
| GSSAPI / Kerberos | Gssapi | Enterprise domain-joined devices |
| CMS signature | Cms | Full CMC requests signed by an RA |
| None (unauthenticated) | None | /cacerts only |
Security Management (FMT)
FMT_SMR.1 – Security Roles
Requirement: The TOE shall maintain the roles of operator and user.
How kipuka satisfies it:
kipuka defines two distinct roles:
| Role | Access | Authentication |
|---|---|---|
| Operator | Admin API (OTP management, CA health, HA control, audit retrieval) | Admin-specific mTLS certificate or bearer token, on a separate listener (admin_listen) |
| User | EST endpoints (enrollment, renewal, CA certificate retrieval) | EST client credentials (mTLS, OTP, GSSAPI) |
The admin API listens on a separate address and port
(server.admin_listen, default 127.0.0.1:9443) with independent TLS
configuration. No EST client credential grants access to the admin API,
and no admin credential grants access to EST endpoints on behalf of a
client.
Implementation location: kipuka::routes::admin for operator
endpoints; kipuka::routes::est for user endpoints.
FMT_SMF.1 – Management Functions
Requirement: The TOE shall provide management functions for authorized operators.
How kipuka satisfies it:
The admin API provides:
| Function | Endpoint | Description |
|---|---|---|
| OTP management | POST /admin/otp, DELETE /admin/otp/{id} | Create and revoke OTP tokens |
| CA health | GET /admin/ca/health | View CA status, signing latency, HSM connectivity |
| HA control | POST /admin/ha/failover | Force failover to a backup CA |
| Audit retrieval | GET /admin/audit | Query audit records with time range and event type filters |
| Configuration reload | POST /admin/reload | Hot-reload certificate and label configuration without restart |
Trusted Path (FTP)
FTP_TRP.1 – Trusted Path
Requirement: The TOE shall provide a communication path between itself and remote users that is logically distinct from other paths and provides assured identification of its endpoints.
How kipuka satisfies it:
-
EST over TLS – All enrollment communication uses TLS with server certificate authentication and (for authenticated endpoints) mutual TLS. The server certificate identifies the kipuka instance; the client certificate identifies the enrolling device.
-
Admin on separate TLS – The admin API uses an independent TLS listener with its own certificate and trust anchors. This provides logical separation between the user-facing EST path and the operator-facing management path.
-
DTLS for CoAP – CoAP enrollment uses DTLS 1.2 for the trusted path, providing the same endpoint authentication guarantees over UDP.
Implementation location: kipuka::tls for EST and admin TLS;
kipuka_coap::dtls for DTLS.
SFR Coverage Summary
| SFR | Requirement | kipuka Module | Status |
|---|---|---|---|
| FAU_GEN.1 | Audit data generation | kipuka::audit | Satisfied |
| FAU_GEN.2 | User identity association | kipuka::audit | Satisfied |
| FAU_STG.1 | Protected audit trail | kipuka::audit, DB, syslog | Satisfied |
| FCS_CKM.1 | Key generation | kipuka_hsm::key | Satisfied |
| FCS_CKM.2 | Key distribution | kipuka_est::serverkeygen, kipuka_est::cacerts | Satisfied |
| FCS_COP.1 | Crypto operations | kipuka_hsm::sign, rustls | Satisfied |
| FCS_RBG_EXT.1 | Random bit generation | OS CSPRNG, PKCS#11 | Satisfied |
| FCS_TLSS_EXT.1 | TLS server | kipuka::tls (rustls) | Satisfied |
| FIA_AFL.1 | Auth failure handling | kipuka::auth::otp | Satisfied |
| FIA_UAU.1 | Timing of authentication | kipuka::auth | Satisfied |
| FIA_UAU.5 | Multiple auth mechanisms | kipuka::auth | Satisfied |
| FMT_SMR.1 | Security roles | kipuka::routes::admin, kipuka::routes::est | Satisfied |
| FMT_SMF.1 | Management functions | kipuka::routes::admin | Satisfied |
| FTP_TRP.1 | Trusted path | kipuka::tls, kipuka_coap::dtls | Satisfied |
CA/B Forum Baseline Requirements
This page documents how kipuka enforces the CA/Browser Forum Baseline Requirements (BR) for the issuance and management of publicly-trusted TLS server certificates. Operators issuing certificates under a publicly-trusted root must configure kipuka to comply with these requirements; operators running private PKI may relax some constraints through configuration.
Certificate Profile Enforcement
kipuka validates every CSR against the BR certificate profile before
signing. A CSR that violates any of the following rules is rejected with an
EnrollReject audit event and an HTTP 400 response describing the
violation.
Subject Fields
| Field | BR Requirement | kipuka Enforcement |
|---|---|---|
commonName | Must match a SAN value if present | Validated at CSR parsing; rejected if CN is present but does not appear in the SAN extension |
organizationName | Must be verified if included | kipuka does not verify organizational identity (this is a CA responsibility); operators can restrict allowed subjects via est.label.subject_pattern |
serialNumber | Must be unique within the CA | Not included by default; kipuka does not inject Subject serial numbers |
countryName | Two-letter ISO 3166 code if present | Format-validated at CSR parsing |
Key Type Requirements
| Key Type | Minimum Size | kipuka Config |
|---|---|---|
| RSA | 2048 bits | Enforced unconditionally; CSRs with RSA keys < 2048 are rejected |
| RSA | 3072 bits (recommended) | Configurable via est.label.allowed_key_types |
| ECDSA P-256 | 256 bits | Accepted |
| ECDSA P-384 | 384 bits | Accepted |
| ECDSA P-521 | 521 bits | Accepted but not recommended by BR |
To restrict a label to specific key types:
[[est.label]]
name = "web-servers"
ca_id = "issuing-ca-1"
allowed_key_types = ["rsa-3072", "rsa-4096", "ecdsa-p256", "ecdsa-p384"]
Serial Number Generation
The BR requires that certificate serial numbers contain at least 64 bits of output from a CSPRNG.
kipuka generates 160-bit (20-byte) serial numbers, significantly exceeding the minimum. The generation path depends on configuration:
| Configuration | Source | Entropy |
|---|---|---|
| HSM configured | PKCS#11 C_GenerateRandom | 160 bits from HSM’s FIPS-validated DRBG |
| Software-only | getrandom(2) | 160 bits from OS CSPRNG |
Serial numbers are encoded as unsigned integers in the serialNumber field
of the TBSCertificate, with the high bit set to zero to ensure a positive
ASN.1 INTEGER encoding (per RFC 5280 section 4.1.2.2).
Extension Enforcement
kipuka enforces the following extensions on every issued certificate. Extensions not listed here may be added via CSR attributes or label configuration but are not required by the BR.
Mandatory Extensions
| Extension | OID | BR Requirement | kipuka Behavior |
|---|---|---|---|
| Authority Key Identifier (AKI) | 2.5.29.35 | Must be present (non-critical) | Always injected using the SHA-1 hash of the CA’s public key |
| Subject Key Identifier (SKI) | 2.5.29.14 | Must be present (non-critical) | Always injected using the SHA-1 hash of the end-entity public key |
| Basic Constraints | 2.5.29.19 | CA:FALSE for end-entity (critical) | Always set to CA:FALSE with pathLenConstraint absent |
| Key Usage | 2.5.29.15 | Must be present (critical) | Set from ca.default_key_usage; defaults to digitalSignature, keyEncipherment for RSA, digitalSignature for ECDSA |
| Extended Key Usage | 2.5.29.37 | Must include id-kp-serverAuth for TLS certificates | Set from ca.default_ext_key_usage; default includes serverAuth |
| Subject Alternative Name (SAN) | 2.5.29.17 | Must be present; CN deprecated as identity source | kipuka requires SAN by default (est.label.require_san = true). SAN values from the CSR are passed through to the certificate. |
| Certificate Policies | 2.5.29.32 | Must reference applicable CP/CPS | Operators configure this via label-level certificate profile settings |
Extension Validation Rules
-
If the CSR contains a
basicConstraintsextension withCA:TRUE, kipuka rejects the request. CA certificates are issued through the admin API, not through EST enrollment. -
If the CSR’s Key Usage includes
keyCertSignorcRLSign, the request is rejected. -
SAN entries are validated for syntactic correctness: DNS names must be valid hostnames (no wildcards unless explicitly enabled), IP addresses must parse as IPv4 or IPv6, and email addresses must contain exactly one
@.
Validity Period Enforcement
The CA/B Forum is reducing maximum certificate validity on a declining
timeline. kipuka tracks this timeline through the max_validity_days
configuration parameter.
BR Validity Timeline
| Effective Date | Maximum Validity | Maximum notBefore to notAfter | Recommended max_validity_days |
|---|---|---|---|
| Current | 398 days | 398 days | 398 |
| 15 March 2026 | 200 days | 200 days | 200 |
| 15 March 2027 | 100 days | 100 days | 100 |
| 15 March 2029 | 47 days | 47 days | 47 |
Configuration
Each CA and each EST label can specify a maximum validity:
[[ca]]
id = "public-ca"
name = "Public TLS CA"
cert = "/etc/kipuka/ca/public-ca.pem"
key = "/etc/kipuka/ca/public-ca.key"
validity_days = 90
max_validity_days = 200
[[est.label]]
name = "short-lived"
ca_id = "public-ca"
max_validity_days = 47
The effective maximum validity for a certificate is the minimum of:
- The CA’s
max_validity_days - The label’s
max_validity_days(if set) - The client’s requested validity (if the CSR includes a
ValidityPeriodattribute)
If the client’s requested validity exceeds the effective maximum, kipuka
clamps the notAfter date to the allowed maximum rather than rejecting the
request. This behavior is logged as an audit event with the original and
clamped values.
STAR Integration
For STAR (RFC 8739) auto-renewal certificates, the validity period is
typically much shorter than the BR maximum – often hours or days. STAR
renewal orders track their own validity interval independently of the
label’s max_validity_days.
Server-Side Key Generation Compliance
The /serverkeygen endpoint generates a key pair on the server and returns
the private key to the client. The BR imposes requirements on how this
private key is protected in transit.
Key Generation
- Key pairs are generated using the same CSPRNG path as serial numbers
(PKCS#11
C_GenerateKeyPairwhen an HSM is configured, Synta software otherwise). - The generated key type and size must satisfy the same minimums as client-generated keys (RSA >= 2048, ECDSA P-256 or P-384).
- The private key exists in kipuka’s memory only for the duration of the request. It is not written to disk or database.
Key Transport
The private key is returned to the client in a PKCS#7 EnvelopedData
structure:
| Component | Algorithm | Notes |
|---|---|---|
| Content encryption | AES-256-CBC or AES-256-GCM | Symmetric encryption of the private key |
| Key wrapping (RSA) | RSA-OAEP (SHA-256) | Wraps the CEK to the client’s public key from the CSR |
| Key wrapping (ECDH) | ECDH-ES + AES-256-WRAP | Key agreement with the client’s EC public key |
The response is a multipart MIME message containing:
- The signed certificate (
application/pkcs7-mime; smime-type=certs-only) - The encrypted private key (
application/pkcs8)
Implementation Location
kipuka_est::serverkeygen handles key generation, wrapping, and response
construction.
Name Constraints and Encoding
Distinguished Name Encoding
All Distinguished Name components are encoded as UTF8String per RFC 5280
section 4.1.2.4 and BR section 7.1.4. kipuka does not use PrintableString
or TeletexString encoding for any DN attribute.
Name Constraints
kipuka supports Name Constraints through CA configuration. When a CA certificate contains a Name Constraints extension, kipuka enforces those constraints at CSR validation time:
- Permitted subtrees – SAN DNS names must fall within the permitted DNS name subtrees. IP addresses must fall within permitted IP ranges.
- Excluded subtrees – SAN entries matching excluded subtrees cause CSR rejection.
This enforcement happens before signing, so a constraint violation results
in an EnrollReject audit event rather than an improperly-issued
certificate.
Internationalized Domain Names
kipuka accepts internationalized domain names (IDN) in SAN dNSName
entries only in their A-label (Punycode) form, per BR section 7.1.4.2.
U-label (Unicode) forms are rejected at CSR validation.
Compliance Checklist
The following checklist summarizes BR compliance status for operators running kipuka under a publicly-trusted root.
| Requirement | BR Section | Status | Configuration |
|---|---|---|---|
| RSA >= 2048 bits | 6.1.5 | Enforced | Unconditional |
| ECDSA P-256 or P-384 | 6.1.5 | Enforced | Unconditional |
| Serial >= 64 bits CSPRNG | 7.1 | Exceeded (160 bits) | Unconditional |
| AKI present | 7.1.2.7 | Enforced | Unconditional |
| SKI present | 7.1.2.8 | Enforced | Unconditional |
CA:FALSE for EE certs | 7.1.2.3 | Enforced | Unconditional |
| Key Usage critical | 7.1.2.1 | Enforced | Unconditional |
| SAN required | 7.1.4.2 | Default true | est.label.require_san |
| Max validity period | 6.3.2 | Enforced | ca.max_validity_days, est.label.max_validity_days |
/serverkeygen key protection | 6.1.2 | Enforced | PKCS#7 EnvelopedData transport |
| Certificate Policies extension | 7.1.2.2 | Operator-configured | Label certificate profile |
| DN encoding (UTF8String) | 7.1.4 | Enforced | Unconditional |
HSM Compatibility Matrix
This page documents the Hardware Security Modules that kipuka has been
tested with, their supported mechanisms, configuration specifics, and known
limitations. kipuka communicates with HSMs exclusively through the PKCS#11
(Cryptoki) interface via the cryptoki crate.
Compatibility Overview
| Feature | Entrust nShield | Utimaco CryptoServer | Kryoptic | Thales Luna 7 |
|---|---|---|---|---|
| PKCS#11 version | 2.40 | 2.40 | 2.40 | 2.40 |
| RSA 2048 | Yes | Yes | Yes | Yes |
| RSA 3072 | Yes | Yes | Yes | Yes |
| RSA 4096 | Yes | Yes | Yes | Yes |
| ECDSA P-256 | Yes | Yes | Yes | Yes |
| ECDSA P-384 | Yes | Yes | Yes | Yes |
| ECDSA P-521 | Yes | Yes | Yes | Yes |
| RSA PKCS#1 v1.5 signing | Yes | Yes | Yes | Yes |
| RSA-PSS signing | Yes | Yes | Yes | Yes |
| ECDSA signing | Yes | Yes | Yes | Yes |
| ML-DSA (FIPS 204) | No | Firmware-dependent | Yes (software) | No |
| AES-WRAP key wrapping | Yes | Yes | Yes | Yes |
| RSA-OAEP key wrapping | Yes | Yes | Yes | Yes |
| Concurrent sessions | Up to 256 | Up to 128 | Unlimited | Up to 64 |
| FIPS 140-3 level | Level 3 | Level 3 (CP5) | N/A (software) | Level 3 |
| Key non-exportability | CKA_EXTRACTABLE=false | CKA_EXTRACTABLE=false | Configurable | CKA_EXTRACTABLE=false |
| Remote/network HSM | Yes (nShield Connect) | Yes (LAN) | N/A | Yes (Luna Network HSM) |
Signing Mechanisms
kipuka uses the following PKCS#11 mechanisms for certificate signing, selected automatically based on the CA key type:
| Key Type | PKCS#11 Mechanism | kipuka Function |
|---|---|---|
| RSA (PKCS#1 v1.5) | CKM_RSA_PKCS | sign_rsa_pkcs1 |
| RSA (PSS) | CKM_RSA_PKCS_PSS | sign_rsa_pss |
| ECDSA (any curve) | CKM_ECDSA | sign_ecdsa |
| ML-DSA | CKM_IBM_DILITHIUM (vendor-specific) | sign_ml_dsa |
The hash algorithm for RSA mechanisms is configurable per CA via the
RsaHashAlgorithm enum: SHA-256 (default), SHA-384, or SHA-512.
Key Wrapping for /serverkeygen
When /serverkeygen generates a key pair on the server, the private key
must be encrypted for transport to the client. kipuka supports the
following wrapping methods:
| Wrapping Method | PKCS#11 Mechanism | Client Key Type | Notes |
|---|---|---|---|
| AES-256-WRAP | CKM_AES_KEY_WRAP | Any (symmetric wrapping) | Content encryption key wrapped; CEK encrypts the private key via AES-CBC or AES-GCM |
| RSA-OAEP (SHA-256) | CKM_RSA_PKCS_OAEP | RSA | CEK wrapped directly to client’s RSA public key |
| ECDH-ES + AES-WRAP | CKM_ECDH1_DERIVE + CKM_AES_KEY_WRAP | ECDSA/ECDH | Ephemeral ECDH key agreement derives a wrapping key |
Not all HSMs support all wrapping combinations. The following table shows which wrapping methods are available per vendor:
| Wrapping Method | Entrust nShield | Utimaco CryptoServer | Kryoptic | Thales Luna 7 |
|---|---|---|---|---|
| AES-256-WRAP | Yes | Yes | Yes | Yes |
| RSA-OAEP (SHA-256) | Yes | Yes | Yes | Yes |
| RSA-OAEP (SHA-384) | Yes | No | Yes | Yes |
| ECDH-ES + AES-WRAP | Yes | Yes | Yes | Yes |
Per-Vendor Configuration
Entrust nShield
The nShield HSM family includes the Solo XC (PCIe), Connect XC (network), and Edge (compact form factor) models.
Library path:
[hsm]
library = "/opt/nfast/toolkits/pkcs11/libcknfast.so"
Environment variables:
# Security World configuration
export NFAST_HOME="/opt/nfast"
export NFAST_KMDATA="/opt/nfast/kmdata/local"
Key generation (via nShield tools):
# Generate an RSA 4096 CA key in the Security World
generatekey pkcs11 \
--key-type=RSA \
--size=4096 \
--token-label="kipuka-ca" \
--key-label="ca-signing-key" \
--protect=module
# Generate an ECDSA P-384 CA key
generatekey pkcs11 \
--key-type=EC \
--curve=P-384 \
--token-label="kipuka-ca" \
--key-label="ca-ec-signing-key" \
--protect=module
Configuration example:
[hsm]
library = "/opt/nfast/toolkits/pkcs11/libcknfast.so"
token_label = "kipuka-ca"
pin_env = "KIPUKA_HSM_PIN"
[[ca]]
id = "nshield-ca"
name = "nShield-backed CA"
cert = "/etc/kipuka/ca/nshield-ca.pem"
key = "pkcs11:token=kipuka-ca;object=ca-signing-key"
hsm_slot = 0
Known limitations:
- nShield requires the Security World to be initialized and the module to be in operational state before kipuka starts.
- Concurrent session limits depend on the module model and license. The Solo XC supports up to 256 concurrent PKCS#11 sessions.
- ML-DSA is not supported as of nShield firmware 13.x. Use Synta software fallback for post-quantum signing with nShield.
Utimaco CryptoServer
Utimaco CryptoServer models include the LAN appliance, PCIe card, and Se-Series (cloud HSM).
Library path:
[hsm]
library = "/usr/lib/utimaco/libcs_pkcs11_R3.so"
Configuration file:
Utimaco requires a configuration file referenced by the CS_PKCS11_R3_CFG
environment variable:
export CS_PKCS11_R3_CFG="/etc/utimaco/cs_pkcs11_R3.cfg"
Example cs_pkcs11_R3.cfg:
[Global]
Logging = 0
Logpath = /var/log/utimaco
SlotCount = 10
[CryptoServer]
Device = TCP:192.168.1.100:3001
Timeout = 30000
Key generation (via Utimaco tools):
# Generate RSA 4096 CA key
p11tool2 --module=/usr/lib/utimaco/libcs_pkcs11_R3.so \
--login --so-pin=$SO_PIN \
--generate-rsa --bits=4096 \
--label="ca-signing-key" \
--id=01
# Generate ECDSA P-384 CA key
p11tool2 --module=/usr/lib/utimaco/libcs_pkcs11_R3.so \
--login --so-pin=$SO_PIN \
--generate-ec --curve=P-384 \
--label="ca-ec-signing-key" \
--id=02
Configuration example:
[hsm]
library = "/usr/lib/utimaco/libcs_pkcs11_R3.so"
slot = 0
pin_env = "KIPUKA_HSM_PIN"
[[ca]]
id = "utimaco-ca"
name = "Utimaco-backed CA"
cert = "/etc/kipuka/ca/utimaco-ca.pem"
key = "pkcs11:slot=0;object=ca-signing-key"
hsm_slot = 0
Known limitations:
- RSA-OAEP with SHA-384 is not supported; use SHA-256 for
/serverkeygenkey wrapping. - Maximum 128 concurrent PKCS#11 sessions. Size the kipuka connection pool
accordingly (
db.max_connectionsshould not exceed this when HSM-bound operations dominate). - ML-DSA support depends on firmware version. Check with Utimaco for availability on your CryptoServer model.
Kryoptic
Kryoptic is a software PKCS#11 implementation used for development, testing, and non-FIPS deployments. It implements the standard PKCS#11 interface without requiring physical hardware.
Library path:
[hsm]
library = "/usr/lib/kryoptic/libkryoptic_pkcs11.so"
On macOS:
[hsm]
library = "/usr/local/lib/libkryoptic_pkcs11.dylib"
Token initialization:
# Initialize a Kryoptic token
pkcs11-tool --module=/usr/lib/kryoptic/libkryoptic_pkcs11.so \
--init-token --label="kipuka-dev" \
--so-pin=12345678
# Set the user PIN
pkcs11-tool --module=/usr/lib/kryoptic/libkryoptic_pkcs11.so \
--init-pin --token-label="kipuka-dev" \
--so-pin=12345678 --new-pin=userpin
Key generation:
# Generate RSA 2048 key for testing
pkcs11-tool --module=/usr/lib/kryoptic/libkryoptic_pkcs11.so \
--login --pin=userpin \
--token-label="kipuka-dev" \
--keypairgen --key-type=RSA:2048 \
--label="test-ca-key" --id=01
# Generate ECDSA P-256 key for testing
pkcs11-tool --module=/usr/lib/kryoptic/libkryoptic_pkcs11.so \
--login --pin=userpin \
--token-label="kipuka-dev" \
--keypairgen --key-type=EC:prime256v1 \
--label="test-ec-key" --id=02
Configuration example:
[hsm]
library = "/usr/lib/kryoptic/libkryoptic_pkcs11.so"
token_label = "kipuka-dev"
pin = "userpin" # Acceptable for development only
[[ca]]
id = "dev-ca"
name = "Development CA"
cert = "/etc/kipuka/ca/dev-ca.pem"
key = "pkcs11:token=kipuka-dev;object=test-ca-key"
Known limitations:
- Kryoptic is a software implementation and does not hold a FIPS 140-3 validation. Do not use it for production deployments that require hardware-backed key protection.
- ML-DSA signing uses the Synta software fallback through
SoftwarePqcFallback, not native PKCS#11 mechanisms. - Token state is stored on the filesystem. Protect the Kryoptic data directory with appropriate file permissions.
- Useful for CI/CD pipelines and integration testing where a real HSM is not available.
Thales Luna 7 (Network HSM and PCIe)
The Thales Luna 7 family includes the Luna Network HSM (SA 7000) and Luna PCIe HSM.
Library path:
[hsm]
library = "/usr/safenet/lunaclient/lib/libCryptoki2_64.so"
On macOS (Luna client):
[hsm]
library = "/usr/local/lib/libCryptoki2.dylib"
Client configuration:
Luna requires a client certificate registered with the HSM partition.
Configuration is managed through the Luna client tools and the
Chrystoki.conf file:
Chrystoki2 = {
LibUNIX64 = /usr/safenet/lunaclient/lib/libCryptoki2_64.so;
}
LunaSA Client = {
ServerCAFile = /etc/Chrystoki2/certs/server.pem;
ClientCertFile = /etc/Chrystoki2/certs/client.pem;
ClientPrivKeyFile = /etc/Chrystoki2/certs/client-key.pem;
ServerName00 = luna-hsm.example.com;
ServerPort00 = 1792;
}
Key generation (via Luna tools):
# Generate RSA 4096 CA key in partition
cmu generatekeypair \
-modulusLen=4096 \
-publicExponent=65537 \
-label="ca-signing-key" \
-sign=True \
-verify=True \
-extractable=False \
-token=True
# Generate ECDSA P-384 CA key
cmu generatekeypair \
-keyType=EC \
-curvetype=3 \
-label="ca-ec-signing-key" \
-sign=True \
-verify=True \
-extractable=False \
-token=True
Configuration example:
[hsm]
library = "/usr/safenet/lunaclient/lib/libCryptoki2_64.so"
slot = 1
pin_env = "KIPUKA_HSM_PIN"
[[ca]]
id = "luna-ca"
name = "Luna 7-backed CA"
cert = "/etc/kipuka/ca/luna-ca.pem"
key = "pkcs11:slot=1;object=ca-signing-key"
hsm_slot = 1
Known limitations:
- Maximum 64 concurrent PKCS#11 sessions per partition. For high-throughput deployments, consider using HA groups across multiple partitions or HSMs.
- ML-DSA is not supported as of Luna firmware 7.x. Use Synta software fallback for post-quantum signing.
- Luna Network HSM requires a network partition and registered client certificate. The client certificate must be registered before kipuka can connect to the HSM.
- Session timeout: Luna partitions have a configurable idle session timeout.
Set
Idle Sessions Timeoutto 0 (disabled) or to a value longer than kipuka’s HA health check interval to prevent session churn.
HSM Session Management
kipuka maintains a pool of PKCS#11 sessions to avoid the overhead of
opening and closing sessions for every signing operation. The pool is
managed by the Pkcs11Context struct in kipuka_hsm::pkcs11.
| Parameter | Description | Recommendation |
|---|---|---|
| Session pool size | Controlled by db.max_connections (shared pool) | Set to the lesser of the HSM’s max concurrent sessions and the expected peak signing rate |
| Login state | Sessions are logged in once at pool creation | The HSM PIN is used at startup; it is not stored in memory after login |
| Error recovery | Failed sessions are dropped and replaced | kipuka logs a CaHealthChange audit event when session errors exceed the HA threshold |
Adding a New HSM
kipuka’s HSM support is modular. The kipuka_hsm::providers module
contains per-vendor configuration through the HsmProvider enum:
Entrust– Entrust nShield familyUtimaco– Utimaco CryptoServer familyKryoptic– Kryoptic software PKCS#11ThalesCsp– Thales CipherTrust / SafeNet (legacy)ThalesTct– Thales Luna 7 and later
Each provider module exports:
default_library_path()– Platform-specific default library locationprovider_config()– Vendor-specific PKCS#11 initialization settingssupported_mechanisms()– List of PKCS#11 mechanisms the provider is known to support
To add support for a new HSM vendor, implement these three functions in a
new module under kipuka_hsm::providers and add a variant to the
HsmProvider enum. The PKCS#11 standard interface means most HSMs will
work without vendor-specific code as long as the library path is configured
correctly; the provider module primarily documents known quirks and
default paths.
EST Endpoints
kipuka implements the six operations defined by
RFC 7030 (Enrollment over Secure
Transport) and the clarifications in
RFC 8951. All EST operations are
served under the /.well-known/est/ path on the EST listener (default port
9443).
Base URL and EST labels
The base URL for all operations is:
https://<host>:9443/.well-known/est/
When EST labels are configured, a label segment is inserted between /est/
and the operation name to route the request to a specific CA:
https://<host>:9443/.well-known/est/{label}/simpleenroll
For example, if two labels are configured – iot-fleet backed by a
hardware-HSM CA and corp-devices backed by a Dogtag CA – clients target the
appropriate CA by including the label in the URL:
/.well-known/est/iot-fleet/simpleenroll
/.well-known/est/corp-devices/simpleenroll
Requests to the unlabeled path (/.well-known/est/simpleenroll) use the
default CA. See EST Labels for configuration
details.
Content types
EST uses CMS (PKCS #7) encoding throughout. All request and response bodies are base64-encoded DER unless otherwise noted.
| Direction | Content-Type | Encoding |
|---|---|---|
| CSR request | application/pkcs10 | base64-encoded DER PKCS #10 |
| Certificate response | application/pkcs7-mime; smime-type=certs-only | base64-encoded DER PKCS #7 |
| CMC request | application/pkcs7-mime; smime-type=CMC-request | base64-encoded DER CMS |
| CMC response | application/pkcs7-mime; smime-type=CMC-response | base64-encoded DER CMS |
| Server keygen response | multipart/mixed | See Server-Side Key Generation |
| CSR attributes | application/csrattrs | base64-encoded DER |
GET /cacerts
Retrieve the CA certificate chain. This operation requires no authentication and is typically the first call a client makes to bootstrap trust.
Authentication: None
Response: 200 OK with Content-Type application/pkcs7-mime; smime-type=certs-only.
The body is a base64-encoded PKCS #7 certs-only message containing the CA
certificate chain in order from the issuing CA to the root.
Example
# Fetch the CA chain (no auth required)
curl -o cacerts.p7b \
https://est.example.com:9443/.well-known/est/cacerts
# Decode the base64 PKCS#7 and view the certificates
base64 -d cacerts.p7b | openssl pkcs7 -inform DER -print_certs -noout
# Save decoded certificates to a PEM file
base64 -d cacerts.p7b | openssl pkcs7 -inform DER -print_certs -out ca-chain.pem
With an EST label:
curl -o cacerts.p7b \
https://est.example.com:9443/.well-known/est/iot-fleet/cacerts
POST /simpleenroll
Submit a PKCS #10 certificate signing request (CSR) for initial enrollment. The server validates the request, signs the certificate using the configured CA, and returns the signed certificate in a PKCS #7 response.
Authentication: OTP (HTTP Basic) or mTLS (client certificate).
For initial enrollment of a device that does not yet have a certificate, use OTP authentication. For enrollment of a device that already holds a valid certificate from a trusted CA, use mTLS.
Request body: base64-encoded DER PKCS #10 CSR, Content-Type application/pkcs10.
Response: 200 OK with Content-Type application/pkcs7-mime; smime-type=certs-only.
Example with OTP authentication
# Generate a private key and CSR
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout device.key -out device.csr -nodes \
-subj "/CN=device-001.example.com"
# Base64-encode the CSR (DER format)
openssl req -in device.csr -outform DER | base64 > device.csr.b64
# Enroll using OTP (entity-id:otp-value as HTTP Basic auth)
curl -X POST \
--cacert ca-chain.pem \
-u "device-001:a7f3b9c2d1e4" \
-H "Content-Type: application/pkcs10" \
--data-binary @device.csr.b64 \
-o enrolled.p7b \
https://est.example.com:9443/.well-known/est/simpleenroll
# Decode the response to get the signed certificate
base64 -d enrolled.p7b | openssl pkcs7 -inform DER -print_certs -out device.crt
Example with mTLS authentication
# Enroll using an existing client certificate for authentication
curl -X POST \
--cacert ca-chain.pem \
--cert existing-device.crt \
--key existing-device.key \
-H "Content-Type: application/pkcs10" \
--data-binary @device.csr.b64 \
-o enrolled.p7b \
https://est.example.com:9443/.well-known/est/simpleenroll
Example with an EST label
curl -X POST \
--cacert ca-chain.pem \
-u "device-001:a7f3b9c2d1e4" \
-H "Content-Type: application/pkcs10" \
--data-binary @device.csr.b64 \
-o enrolled.p7b \
https://est.example.com:9443/.well-known/est/corp-devices/simpleenroll
POST /simplereenroll
Renew or rekey an existing certificate. The client authenticates with its current certificate via mTLS and submits a new CSR. The server validates that the CSR subject matches the authenticated client certificate (or that the change is permitted by policy) and returns a fresh certificate.
Authentication: mTLS required. The client must present the certificate being renewed.
Request body: base64-encoded DER PKCS #10 CSR, Content-Type application/pkcs10.
Response: 200 OK with Content-Type application/pkcs7-mime; smime-type=certs-only.
Example
# Generate a new CSR using the existing key (or a new key for rekeying)
openssl req -new -key device.key -out renew.csr -nodes \
-subj "/CN=device-001.example.com"
# Base64-encode the CSR
openssl req -in renew.csr -outform DER | base64 > renew.csr.b64
# Re-enroll using the current device certificate for mTLS auth
curl -X POST \
--cacert ca-chain.pem \
--cert device.crt \
--key device.key \
-H "Content-Type: application/pkcs10" \
--data-binary @renew.csr.b64 \
-o renewed.p7b \
https://est.example.com:9443/.well-known/est/simplereenroll
# Decode the renewed certificate
base64 -d renewed.p7b | openssl pkcs7 -inform DER -print_certs -out device-renewed.crt
POST /fullcmc
Full Certificate Management over CMS (RFC 5272). This operation accepts a
full CMC request wrapped in a CMS SignedData structure and returns a full CMC
response. Full CMC supports advanced features not available through simple
enrollment: certificate revocation, key update with proof-of-possession, and
batch operations.
Authentication: The CMC request itself carries authentication (the CMS
SignedData is signed by the requester). mTLS may also be required depending
on server policy.
Request body: base64-encoded DER CMS, Content-Type
application/pkcs7-mime; smime-type=CMC-request.
Response: 200 OK with Content-Type
application/pkcs7-mime; smime-type=CMC-response.
Example
# Full CMC requests are typically constructed by a CMC-aware client library.
# This example uses a pre-built CMC request.
curl -X POST \
--cacert ca-chain.pem \
--cert ra-agent.crt \
--key ra-agent.key \
-H "Content-Type: application/pkcs7-mime; smime-type=CMC-request" \
--data-binary @cmc-request.b64 \
-o cmc-response.p7b \
https://est.example.com:9443/.well-known/est/fullcmc
# Decode the CMC response
base64 -d cmc-response.p7b | openssl cms -inform DER -cmsout -print
Note: Full CMC support requires the
fullcmcfeature in the server configuration. When Dogtag PKI is the back-end CA, CMC requests are forwarded to the Dogtag CMC profile.
POST /serverkeygen
Request server-side key generation. The server generates a key pair, signs the certificate, and returns both the certificate and the encrypted private key. This operation is used in environments where the client device cannot generate strong keys locally, or where key escrow through a Key Recovery Authority (KRA) is required.
Authentication: OTP (HTTP Basic) or mTLS.
Request body: base64-encoded DER PKCS #10 CSR, Content-Type application/pkcs10.
The CSR provides the subject and requested attributes; the public key in the
CSR is replaced by the server-generated key.
Response: 200 OK with Content-Type multipart/mixed. The response
contains two parts:
| Part | Content-Type | Contents |
|---|---|---|
| 1 | application/pkcs7-mime; smime-type=certs-only | Signed certificate (base64 PKCS #7) |
| 2 | application/pkcs8 | Encrypted private key (base64 PKCS #8) |
The private key is encrypted to the client using the asymmetric key from the CSR or a pre-shared symmetric key, depending on configuration.
Example
# Generate a throwaway CSR (the server will generate the real key pair)
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout throwaway.key -out serverkeygen.csr -nodes \
-subj "/CN=device-002.example.com"
# Base64-encode
openssl req -in serverkeygen.csr -outform DER | base64 > serverkeygen.csr.b64
# Request server-side key generation
curl -X POST \
--cacert ca-chain.pem \
-u "device-002:b8e4c1d5f2a7" \
-H "Content-Type: application/pkcs10" \
--data-binary @serverkeygen.csr.b64 \
-o serverkeygen-response.mime \
https://est.example.com:9443/.well-known/est/serverkeygen
# The response is multipart/mixed -- extract the certificate and key
# (exact parsing depends on the MIME boundary in the response headers)
Note: Server-side key generation requires a KRA (Key Recovery Authority) when key escrow is mandated by policy. With Dogtag PKI, the KRA subsystem handles key archival automatically.
GET /csrattrs
Retrieve the set of CSR attributes that the server expects or recommends clients include in their certificate signing requests. The response is an ASN.1 sequence of OIDs and attribute definitions.
Authentication: None (or mTLS, depending on server policy).
Response: 200 OK with Content-Type application/csrattrs. The body is
a base64-encoded DER CsrAttrs structure as defined in RFC 7030 Section 4.5.2.
Example
# Fetch supported CSR attributes
curl --cacert ca-chain.pem \
-o csrattrs.b64 \
https://est.example.com:9443/.well-known/est/csrattrs
# Decode and inspect (requires ASN.1 tools)
base64 -d csrattrs.b64 | openssl asn1parse -inform DER
Typical attributes returned include:
- ecPublicKey with secp256r1 or secp384r1 – indicating preferred key algorithms
- challengePassword – when the server requires a challenge in the CSR
- Extension requests – specific X.509v3 extensions the server will honor
Error responses
All EST endpoints return standard HTTP error codes. The response body for
errors is application/json on the admin API and plain text on the EST
endpoints.
| Status | Meaning | When returned |
|---|---|---|
400 Bad Request | Malformed CSR, invalid base64 encoding, missing Content-Type, or CSR fails policy validation. | Any POST endpoint |
401 Unauthorized | Missing or invalid authentication. For OTP: the OTP value is wrong, expired, or already consumed. For mTLS: no client certificate presented or the certificate is not trusted. | /simpleenroll, /simplereenroll, /serverkeygen |
403 Forbidden | Authentication succeeded but the client is not authorized for the requested operation. Common cause: re-enrollment with a certificate whose subject does not match the CSR. | /simplereenroll |
404 Not Found | Unknown EST label. | Any endpoint with an invalid label |
500 Internal Server Error | Server-side failure: HSM unreachable, database error, or CA signing failure. | Any endpoint |
503 Service Unavailable | The requested CA is temporarily unavailable (all replicas down, HSM session exhausted). | Any endpoint |
Retry-After
When an operation requires asynchronous processing (for example, a Dogtag PKI
back-end that queues signing requests for RA approval), the server returns
202 Accepted with a Retry-After header indicating the number of seconds
the client should wait before polling.
HTTP/1.1 202 Accepted
Retry-After: 30
Content-Length: 0
The client should repeat the same request (with the same CSR and
authentication) after the indicated interval. The server tracks pending
requests and returns the signed certificate when it becomes available, or
another 202 if the request is still pending.
Example: handling a 401
# A failed OTP enrollment returns 401
curl -v -X POST \
--cacert ca-chain.pem \
-u "device-001:wrong-otp-value" \
-H "Content-Type: application/pkcs10" \
--data-binary @device.csr.b64 \
https://est.example.com:9443/.well-known/est/simpleenroll
# Response:
# < HTTP/1.1 401 Unauthorized
# < WWW-Authenticate: Basic realm="est"
TLS requirements
The EST listener enforces TLS 1.2 or 1.3 for all connections. The server
certificate must be configured in [tls] in the kipuka configuration file.
For mTLS-authenticated endpoints, the server’s TLS configuration must include a
client_ca trust anchor that covers the certificates clients present. See
TLS Configuration for details.
# Verify server TLS configuration
openssl s_client -connect est.example.com:9443 -showcerts </dev/null
Admin API Reference
The admin API provides management operations for kipuka: health checks, CA status, OTP lifecycle, and issued certificate queries. It runs on a separate TLS listener (default port 9444) from the EST enrollment endpoints, allowing operators to restrict admin access at the network level independently of client enrollment traffic.
Admin listener configuration
The admin API binds to its own address and port, configured in the [admin]
section of the kipuka configuration file:
[admin]
listen = "0.0.0.0:9444"
[admin.tls]
cert = "/etc/kipuka/admin-server.crt"
key = "/etc/kipuka/admin-server.key"
client_ca = "/etc/kipuka/admin-ca.pem"
The admin TLS certificate and trust anchors are independent of the EST listener. This separation allows operators to:
- Issue admin certificates from a different CA than enrollment certificates.
- Restrict admin access to a management VLAN by binding to a specific interface.
- Apply different TLS policies (cipher suites, minimum version) for admin traffic.
Authentication
The admin API supports two authentication methods:
| Method | Header | Description |
|---|---|---|
| mTLS | (client certificate) | The client presents a certificate trusted by the admin client_ca. Preferred for automated integrations. |
| Bearer token | Authorization: Bearer <token> | A static token configured in the [admin] section. Suitable for quick manual access and scripts. |
[admin]
bearer_token = "${KIPUKA_ADMIN_TOKEN}"
All examples below use Bearer token authentication. Substitute --cert /
--key flags for mTLS.
Endpoints
GET /admin/health
System health check. Returns server uptime, version, and the health status of every configured CA.
Response: 200 OK
curl -s \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
https://admin.example.com:9444/admin/health | jq .
{
"status": "healthy",
"version": "0.4.0",
"uptime_seconds": 86412,
"cas": [
{
"id": "default",
"label": null,
"status": "healthy",
"last_sign_time": "2026-06-24T14:22:01Z"
},
{
"id": "iot-fleet",
"label": "iot-fleet",
"status": "healthy",
"last_sign_time": "2026-06-24T14:18:33Z"
},
{
"id": "corp-devices",
"label": "corp-devices",
"status": "degraded",
"last_sign_time": "2026-06-24T13:55:10Z",
"error": "HSM session pool exhausted, 1 of 2 replicas available"
}
],
"database": {
"status": "healthy",
"backend": "postgresql",
"pool_size": 10,
"active_connections": 3
}
}
The top-level status field aggregates CA and database health:
| Value | Meaning |
|---|---|
healthy | All CAs and the database are operational. |
degraded | At least one CA is degraded (partial replica loss) but enrollment is still possible. |
unhealthy | A critical CA or the database is down. Enrollment requests will fail. |
GET /admin/ca
List all configured CAs with their current health status.
Response: 200 OK
curl -s \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
https://admin.example.com:9444/admin/ca | jq .
[
{
"id": "default",
"label": null,
"type": "local",
"status": "healthy",
"last_sign_time": "2026-06-24T14:22:01Z"
},
{
"id": "iot-fleet",
"label": "iot-fleet",
"type": "hsm",
"status": "healthy",
"last_sign_time": "2026-06-24T14:18:33Z"
},
{
"id": "corp-devices",
"label": "corp-devices",
"type": "dogtag",
"status": "degraded",
"last_sign_time": "2026-06-24T13:55:10Z"
}
]
GET /admin/ca/:id
Retrieve detailed information about a specific CA, including its type, EST label binding, certificate chain, and operational status.
Response: 200 OK
curl -s \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
https://admin.example.com:9444/admin/ca/iot-fleet | jq .
{
"id": "iot-fleet",
"label": "iot-fleet",
"type": "hsm",
"status": "healthy",
"hsm": {
"module": "/usr/lib/libCryptoki2_64.so",
"slot": 1,
"key_label": "iot-signing-key"
},
"certificate_chain": [
{
"subject": "CN=IoT Fleet Issuing CA, O=Example Corp",
"issuer": "CN=Example Root CA, O=Example Corp",
"serial": "0A:1B:2C:3D:4E:5F",
"not_before": "2025-01-15T00:00:00Z",
"not_after": "2030-01-15T00:00:00Z",
"key_algorithm": "ECDSA P-384"
},
{
"subject": "CN=Example Root CA, O=Example Corp",
"issuer": "CN=Example Root CA, O=Example Corp",
"serial": "01",
"not_before": "2020-01-01T00:00:00Z",
"not_after": "2040-01-01T00:00:00Z",
"key_algorithm": "RSA 4096"
}
],
"last_sign_time": "2026-06-24T14:18:33Z",
"total_certs_issued": 14832
}
Error: 404 Not Found if the CA id does not exist.
POST /admin/otp
Generate a one-time password for initial device enrollment. The OTP is bound to an entity identifier (typically a device hostname or serial number) and is valid for a limited time and number of uses.
Request body: application/json
| Field | Type | Required | Description |
|---|---|---|---|
entity_id | string | yes | Identifier for the device or entity. Must match the HTTP Basic username during enrollment. |
ttl | integer | no | Time-to-live in seconds. Default: 3600 (1 hour). |
max_uses | integer | no | Maximum number of times the OTP can be used. Default: 1. |
Response: 201 Created
curl -s -X POST \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
--cacert admin-ca.pem \
-d '{
"entity_id": "device-001.example.com",
"ttl": 7200,
"max_uses": 1
}' \
https://admin.example.com:9444/admin/otp | jq .
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"entity_id": "device-001.example.com",
"otp": "a7f3b9c2d1e4",
"created": "2026-06-24T15:00:00Z",
"expires": "2026-06-24T17:00:00Z",
"max_uses": 1,
"remaining_uses": 1
}
The otp value is returned only in this response and is never stored in
plaintext on the server (the server stores a salted hash). Record it
immediately.
GET /admin/otp
List all active (non-expired, non-exhausted) OTPs. The OTP secret value is not included – only metadata.
Response: 200 OK
curl -s \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
https://admin.example.com:9444/admin/otp | jq .
[
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"entity_id": "device-001.example.com",
"created": "2026-06-24T15:00:00Z",
"expires": "2026-06-24T17:00:00Z",
"max_uses": 1,
"remaining_uses": 1
},
{
"id": "e29b1d4a-7c83-4f91-b234-1a23c4d5e678",
"entity_id": "device-002.example.com",
"created": "2026-06-24T14:30:00Z",
"expires": "2026-06-24T15:30:00Z",
"max_uses": 3,
"remaining_uses": 2
}
]
DELETE /admin/otp/:id
Revoke an active OTP before it expires or is fully consumed. Revoked OTPs are immediately invalid for enrollment.
Response: 204 No Content
curl -s -X DELETE \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
https://admin.example.com:9444/admin/otp/f47ac10b-58cc-4372-a567-0e02b2c3d479
Error: 404 Not Found if the OTP id does not exist or has already expired.
GET /admin/certs
List certificates issued by kipuka. Results are paginated and can be filtered by subject, issuer, status, or date range.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number (1-indexed). |
per_page | integer | 50 | Results per page (max 500). |
subject | string | – | Filter by subject CN (substring match). |
status | string | – | Filter by status: active, expired, revoked. |
issued_after | string | – | ISO 8601 datetime. Only certificates issued after this time. |
issued_before | string | – | ISO 8601 datetime. Only certificates issued before this time. |
Response: 200 OK
curl -s \
-H "Authorization: Bearer ${KIPUKA_ADMIN_TOKEN}" \
--cacert admin-ca.pem \
"https://admin.example.com:9444/admin/certs?status=active&per_page=5" | jq .
{
"page": 1,
"per_page": 5,
"total": 14832,
"certificates": [
{
"serial": "0A:1B:2C:3D:4E:5F:60:71",
"subject": "CN=device-001.example.com",
"issuer": "CN=IoT Fleet Issuing CA, O=Example Corp",
"not_before": "2026-06-24T14:22:01Z",
"not_after": "2027-06-24T14:22:01Z",
"status": "active",
"ca_id": "iot-fleet",
"enrollment_type": "simpleenroll"
},
{
"serial": "0A:1B:2C:3D:4E:5F:60:72",
"subject": "CN=device-002.example.com",
"issuer": "CN=IoT Fleet Issuing CA, O=Example Corp",
"not_before": "2026-06-24T14:18:33Z",
"not_after": "2027-06-24T14:18:33Z",
"status": "active",
"ca_id": "iot-fleet",
"enrollment_type": "serverkeygen"
}
]
}
Error responses
Admin API errors return JSON with a consistent structure:
{
"error": "not_found",
"message": "CA with id 'nonexistent' does not exist"
}
| Status | Meaning |
|---|---|
400 Bad Request | Invalid request body or query parameters. |
401 Unauthorized | Missing or invalid Bearer token; untrusted client certificate. |
404 Not Found | Resource (CA, OTP, certificate) not found. |
500 Internal Server Error | Server-side failure. |
Rust API Reference
kipuka’s Rust API documentation is auto-generated from source code doc comments
using cargo doc and published alongside this book. The generated docs are
the authoritative reference for types, traits, function signatures, and module
structure.
Online API docs: kipuka.dev/api/kipuka/
Workspace crates
The kipuka workspace is organized into six crates, each with a focused responsibility:
| Crate | Path | API docs | Description |
|---|---|---|---|
| kipuka-est | crates/kipuka-est | kipuka_est | EST protocol implementation. Axum route handlers for all six RFC 7030 operations, TLS listener setup with rustls, mTLS client authentication, CSR validation, and certificate response encoding. |
| kipuka-hsm | crates/kipuka-hsm | kipuka_hsm | PKCS #11 HSM integration via the cryptoki crate. Manages HSM sessions, slot enumeration, key lookup by label, signing operations (RSA-PSS, ECDSA), and session pool lifecycle. |
| kipuka-otp | crates/kipuka-otp | kipuka_otp | OTP lifecycle management. Generation of cryptographically random OTP values, salted hash storage, validation against entity ID binding, use-count tracking, and expiry enforcement. |
| kipuka-util | crates/kipuka-util | kipuka_util | Shared types and utilities. Configuration file parsing (TOML), ASN.1 helpers built on synta, error type hierarchy, database connection pooling via sqlx, and audit log formatting. |
| kipuka-dogtag | crates/kipuka-dogtag | kipuka_dogtag | Dogtag PKI REST client. Submits certificate signing requests to a Dogtag CA subsystem, retrieves signed certificates, and interacts with the KRA subsystem for server-side key generation and escrow. |
| kipuka-coap | crates/kipuka-coap | kipuka_coap | CoAP transport layer (RFC 7252). Provides EST-over-CoAP endpoints for constrained IoT devices that cannot use HTTP/TLS, with DTLS for transport security. |
Building the docs locally
To generate and open the API documentation from a local checkout:
# Clone the repository
git clone https://codeberg.org/czinda/kipuka.git
cd kipuka
# Build docs for all workspace crates (skip dependency docs for speed)
cargo doc --no-deps --open
This builds HTML documentation into target/doc/ and opens it in your
default browser. The landing page lists all six crates with links to their
module trees.
To build docs for a single crate:
cargo doc --no-deps -p kipuka-est --open
Including private items
By default, cargo doc only documents public API surface. To include
private functions, types, and modules (useful during development):
cargo doc --no-deps --document-private-items --open
Prerequisites
Building the docs requires:
- Rust 1.88+ (edition 2021)
- A working C toolchain (required by
cryptokibuild script for PKCS #11 header compilation) - SQLx offline mode or a running database for query checking – see Development Setup for details
Documentation conventions
The codebase follows these doc comment conventions:
- Every public type, trait, function, and module has a
///doc comment. - Examples in doc comments are runnable via
cargo test --docwhere practical. - Cross-references use intra-doc links (
[OtpStore],[CaConfig]) for navigable HTML output. - Safety invariants on
unsafeblocks are documented with# Safetysections. - Error conditions are documented with
# Errorssections listing the specific error variants returned.
Architecture
kipuka is structured as a Cargo workspace with six crates, each owning a distinct responsibility. This separation enforces module boundaries at the compilation level and allows operators to build only the features they need.
Workspace layout
Clients
|
TLS + mTLS/OTP
|
+-------+-------+
| kipuka-est | axum routes, EST protocol
+---+---+---+---+
| | |
+---------+ | +---------+
| | |
kipuka-otp kipuka-hsm kipuka-util
OTP lifecycle PKCS#11 shared types
HSM ops & config
| |
| kipuka-dogtag
| Dogtag PKI
| REST client
|
+----+----+ kipuka-coap
| sqlx | CoAP transport
| sqlite | (RFC 7252)
| postgres|
| mariadb |
+---------+
Crate responsibilities
| Crate | Role |
|---|---|
| kipuka-est | Core server binary. Owns the axum HTTP router, TLS termination (rustls), EST protocol handlers (/cacerts, /simpleenroll, /simplereenroll, /serverkeygen, /fullcmc, /csrattrs), request authentication, CSR validation, certificate construction (via synta), the admin API, database access (sqlx), and the HA state machine. |
| kipuka-hsm | PKCS #11 integration via the cryptoki crate. Provides a Signer trait implementation that delegates cryptographic operations to an HSM. Handles slot enumeration, session management, key lookup, and sign operations. Isolates all unsafe FFI behind a safe Rust API. |
| kipuka-otp | One-time password lifecycle: generation (CSPRNG), hashing (argon2id / bcrypt), storage, validation, rate limiting, and expiration. Exposes an internal API consumed by kipuka-est for enrollment authentication and by the admin API for token provisioning. |
| kipuka-util | Shared types and configuration parsing. Owns kipuka.toml deserialization (via serde + toml), X.509 helper functions, ASN.1 OID constants, error types, and the zeroize-aware wrappers for sensitive data. |
| kipuka-dogtag | REST client for Red Hat Certificate System / Dogtag PKI. Translates EST enrollment requests into Dogtag profile-based certificate issuance calls, delegating signing to a full CA back-end instead of local key material. |
| kipuka-coap | CoAP (RFC 7252) transport layer for constrained-device enrollment. Maps CoAP request/response semantics onto the same EST handlers used by the HTTPS path, enabling bandwidth-constrained IoT devices to enroll without HTTP overhead. |
Dependencies flow strictly downward: kipuka-est depends on all other crates;
leaf crates (kipuka-util, kipuka-coap) depend on nothing project-internal
except kipuka-util.
EST operation data flow
A certificate enrollment request traverses the following path from TLS handshake to certificate issuance.
1. Client opens TLS connection to kipuka-est (rustls).
2. rustls performs TLS 1.2/1.3 handshake.
- If mTLS: client certificate is validated against configured trust anchors.
- If OTP: TLS completes without client cert; HTTP Basic auth carries the token.
3. axum routes the request based on URL path:
/.well-known/est/{label?}/simpleenroll -> enroll handler
/.well-known/est/{label?}/simplereenroll -> reenroll handler
/.well-known/est/{label?}/cacerts -> cacerts handler
/.well-known/est/{label?}/serverkeygen -> serverkeygen handler
...
4. Label resolution: if a label segment is present, kipuka looks up the
[[est.label]] entry and resolves the bound [[ca]] configuration.
If absent, the default CA is used.
5. Authentication:
a. mTLS: extract client certificate from TLS session, verify chain.
b. OTP: extract Basic auth credentials, validate against kipuka-otp
(argon2 hash comparison, rate limiting, expiration check).
c. GSSAPI: validate Negotiate header against KDC, map principal.
6. CSR parsing: the request body (application/pkcs10) is parsed by synta.
Key type, subject DN, SANs, and extensions are extracted and validated
against the label's policy (allowed_key_types, subject_pattern,
require_san, required_ext_key_usage).
7. Certificate construction: synta builds the X.509 TBS (to-be-signed)
certificate from the validated CSR fields, CA configuration, and
label policy. Serial number is generated from OsRng (CSPRNG).
8. Signing:
- File-based CA key: synta signs the TBS certificate directly.
- HSM-backed CA key: kipuka-hsm opens a PKCS#11 session and delegates
the sign operation to the hardware token.
- Dogtag back-end: kipuka-dogtag sends the CSR to the Dogtag REST API
and retrieves the signed certificate.
9. Response: the signed certificate is wrapped in a PKCS#7 / CMS
ContentInfo envelope (DER-encoded) and returned with Content-Type
application/pkcs7-mime.
10. Audit: an audit event is written to the configured destinations
(file, syslog, database) with request metadata, outcome, and
certificate fingerprint.
Multi-CA HA failover state machine
When [ha] is enabled, each CA in an [[ha.group]] transitions through four
states. The HA controller runs periodic health checks and manages transitions
automatically.
check passes
+---------------------------+
| |
v |
+-----------+ check fails +-----------+
| Healthy | ----------------> | Degraded |
+-----------+ +-----------+
^ |
| | failure_threshold
| | consecutive failures
| v
+-----------+ check passes +-----------+
| Recovery | <---------------- | Failed |
+-----------+ after +-----------+
| recovery_timeout
| sustained success
| (failure_threshold checks pass)
v
+-----------+
| Healthy |
+-----------+
State definitions:
- Healthy – CA is operational. Health checks pass. Enrollment requests are routed to this CA normally.
- Degraded – One or more health checks have failed but the threshold has not been reached. The CA continues to receive traffic. An alert is raised.
- Failed – Consecutive failures have reached
failure_threshold. The CA is removed from the routing pool. If the CA was the active node in anactive-passivegroup, the next CA inca_idsorder is promoted. - Recovery – After
recovery_timeoutelapses, the HA controller begins probing the failed CA. It must passfailure_thresholdconsecutive checks before returning to Healthy. During recovery the CA does not receive enrollment traffic.
Failover strategies (set per [[ha.group]] or globally in [ha]):
| Strategy | Behavior |
|---|---|
active-passive | First healthy CA in ca_ids order handles all requests. Failover promotes the next CA in order. |
round-robin | Requests are distributed across all healthy CAs in rotation. |
weighted | CAs are weighted by a configurable priority; higher-priority CAs receive more traffic. |
latency-based | Health check latency is tracked; requests are routed to the CA with the lowest observed latency. |
The health check itself performs a lightweight signing operation (or, for Dogtag-backed CAs, a REST API ping) to verify that the CA can actually issue certificates. Network reachability alone is insufficient – a reachable HSM that has entered an error state must still be detected as unhealthy.
Authentication flow
OTP validation path
Client ---[HTTP Basic: entity_id:otp_value]---> kipuka-est
|
+-> kipuka-otp: look up entity_id in database
|
+-> Check expiration (expires_at > now)
+-> Check use count (uses < max_uses)
+-> Check lockout (failed_attempts < max_failures within failure_window)
+-> Hash provided OTP with argon2id
+-> Timing-safe comparison (subtle::ConstantTimeEq) against stored hash
|
+-> On success: increment use count, clear failure counter, return Ok
+-> On failure: increment failed_attempts, check lockout threshold
OTP values are never stored in plaintext. The argon2id hash is computed at
token generation time and only the hash is persisted. The raw token is
returned to the administrator exactly once in the generation response.
mTLS certificate chain validation
Client ---[TLS ClientHello + Certificate]---> rustls
|
+-> rustls verifies:
1. Certificate signature is valid
2. Certificate is not expired
3. Issuer chain terminates at a configured trust anchor
4. Key usage includes digitalSignature
5. Extended key usage includes clientAuth (if enforced)
|
+-> kipuka-est extracts:
- Subject DN (for audit and authorization)
- Serial number (for certificate identity tracking)
- SAN entries (for device identification)
GSSAPI / Kerberos
Client ---[Negotiate: base64(SPNEGO token)]---> kipuka-est
|
+-> Accept security context using server keytab
+-> Extract authenticated principal (e.g., user@REALM)
+-> Map principal to certificate subject via [gssapi.principal_mapping]
or default_template
+-> Return mapped subject for certificate construction
Database schema overview
kipuka uses sqlx with compile-time checked queries. The schema is managed
through sequential migrations in migrations/{sqlite,postgres,mariadb}/.
Core tables
otps – One-time password state.
| Column | Type | Description |
|---|---|---|
id | INTEGER / SERIAL | Primary key |
entity_id | TEXT | Client identifier (e.g., device hostname) |
otp_hash | TEXT | Argon2id hash of the OTP value |
created_at | TIMESTAMP | Token generation time |
expires_at | TIMESTAMP | Token expiration time |
max_uses | INTEGER | Maximum allowed uses |
use_count | INTEGER | Current use count |
failed_attempts | INTEGER | Consecutive failed validation attempts |
last_failure_at | TIMESTAMP | Time of most recent failed attempt |
locked_until | TIMESTAMP | Lockout expiration (NULL if not locked) |
audit_log – Tamper-evident audit trail.
| Column | Type | Description |
|---|---|---|
id | INTEGER / SERIAL | Primary key |
timestamp | TIMESTAMP | Event time (UTC) |
event_type | TEXT | Event category (enroll, renew, reject, otp_create, etc.) |
entity_id | TEXT | Client or device identifier |
ca_id | TEXT | CA that processed the request |
label | TEXT | EST label (NULL for unlabeled requests) |
auth_method | TEXT | Authentication method (mtls, otp, gssapi) |
outcome | TEXT | success or failure |
detail | TEXT | Human-readable detail or error message |
cert_fingerprint | TEXT | SHA-256 fingerprint of issued certificate (NULL on failure) |
client_ip | TEXT | Source IP address |
certs – Certificate inventory.
| Column | Type | Description |
|---|---|---|
id | INTEGER / SERIAL | Primary key |
serial_number | TEXT | Certificate serial (hex-encoded) |
subject_dn | TEXT | Subject distinguished name |
issuer_dn | TEXT | Issuer distinguished name |
not_before | TIMESTAMP | Validity start |
not_after | TIMESTAMP | Validity end |
fingerprint | TEXT | SHA-256 fingerprint |
ca_id | TEXT | Issuing CA identifier |
label | TEXT | EST label used for issuance |
entity_id | TEXT | Associated entity (from OTP or mTLS subject) |
pem | TEXT | Full PEM-encoded certificate (optional, controlled by config) |
EST label routing
EST labels are the primary mechanism for multi-profile and multi-CA operation.
When a request arrives at /.well-known/est/{label}/simpleenroll, kipuka
resolves the label to a [[est.label]] configuration entry:
Request URL: /.well-known/est/iot-devices/simpleenroll
|
v
+--------------------------------------+
| Label lookup: name == "iot-devices" |
+--------------------------------------+
|
ca_id = "iot-ca"
|
v
+--------------------------------------+
| CA lookup: id == "iot-ca" |
| cert, key, chain, validity_days, |
| hsm_slot, max_validity_days |
+--------------------------------------+
|
v
+--------------------------------------+
| Policy enforcement: |
| - allowed_key_types |
| - required_ext_key_usage |
| - require_san |
| - subject_pattern |
| - max_validity_days |
+--------------------------------------+
|
v
+--------------------------------------+
| Certificate issuance using resolved |
| CA key material and label policy |
+--------------------------------------+
Requests without a label segment (e.g., /.well-known/est/simpleenroll) use
the first [[ca]] entry as the default CA with no additional label-level
policy enforcement.
When HA is enabled, label routing is extended: the label’s ca_id is checked
against [[ha.group]] memberships. If the CA belongs to an HA group and is in
a Failed state, the request is transparently routed to the next healthy CA in
the group’s ca_ids list. The label’s policy constraints (key types, subject
pattern, etc.) are still enforced regardless of which CA in the group handles
the request.
Development Setup
This guide covers everything needed to build kipuka from source and run it locally with Docker Compose.
Prerequisites
| Tool | Minimum version | Purpose |
|---|---|---|
| Rust toolchain | 1.88+ | Compiler and cargo |
| Docker or Podman | 24+ / 4+ | Container runtime for Compose profiles |
| Docker Compose | 2.20+ | Orchestrates database and HSM dev containers |
| OpenSSL CLI | 1.1.1+ or 3.x | Generating test certificates |
| pkg-config | any | Locating system libraries during build |
Clone and build
git clone https://codeberg.org/czinda/kipuka.git
cd kipuka
cargo build
A release-optimized build (slower compilation, faster binary):
cargo build --release
The workspace produces a single binary at target/debug/kipuka (or
target/release/kipuka).
OS-specific dependencies
Fedora / RHEL / CentOS Stream
sudo dnf install openssl-devel clang cmake pkg-config sqlite-devel
Debian / Ubuntu
sudo apt install libssl-dev clang cmake pkg-config libsqlite3-dev
macOS
brew install openssl cmake pkg-config
export OPENSSL_DIR=$(brew --prefix openssl)
Docker Compose profiles
The repository includes a compose.yaml with profiles for different database
backends and an HSM development environment. Each profile starts only the
services relevant to that backend.
Available profiles
| Profile | Services started | Use case |
|---|---|---|
sqlite (default) | kipuka only | Minimal local development; SQLite file on disk |
postgres | kipuka + PostgreSQL 16 | Testing against PostgreSQL |
mariadb | kipuka + MariaDB 11 | Testing against MariaDB |
hsm | kipuka + Kryoptic SoftHSM | PKCS#11 development without hardware |
Running with SQLite (default)
docker compose up
This starts kipuka with an in-process SQLite database. No external database container is required.
Running with PostgreSQL
docker compose --profile postgres up
The PostgreSQL container is preconfigured with:
- Database:
kipuka - User:
kipuka - Password:
kipuka-dev - Port:
5432
The kipuka service automatically uses the connection string
postgres://kipuka:kipuka-dev@postgres:5432/kipuka.
Running with MariaDB
docker compose --profile mariadb up
The MariaDB container is preconfigured with:
- Database:
kipuka - User:
kipuka - Password:
kipuka-dev - Port:
3306
Running with Kryoptic HSM
docker compose --profile hsm up
This starts a Kryoptic SoftHSM container alongside kipuka. Kryoptic is an open-source PKCS#11 implementation written in Rust, suitable for development and testing. It provides the same PKCS#11 API as hardware HSMs without requiring physical tokens.
See the HSM development section below for slot configuration and key management.
Generate test certificates
The repository includes a helper script that creates a complete test PKI hierarchy suitable for local development:
./contrib/local-dev/setup-ca.sh
This generates the following files under contrib/local-dev/pki/:
| File | Contents |
|---|---|
ca.pem | Self-signed root CA certificate |
ca-key.pem | Root CA private key |
server.pem | Server TLS certificate (SAN: localhost, 127.0.0.1) |
server-key.pem | Server TLS private key |
client.pem | Client certificate for mTLS testing |
client-key.pem | Client private key |
The script is idempotent – running it again regenerates all certificates.
Minimal kipuka.toml for local development
Create a kipuka.toml in the repository root:
[server]
listen = "0.0.0.0:9443"
[tls]
cert = "contrib/local-dev/pki/server.pem"
key = "contrib/local-dev/pki/server-key.pem"
[tls.client_auth]
trust_anchors = "contrib/local-dev/pki/ca.pem"
mode = "optional"
[db]
url = "sqlite://kipuka-dev.db?mode=rwc"
auto_migrate = true
[[ca]]
id = "dev-ca"
name = "Development CA"
cert = "contrib/local-dev/pki/ca.pem"
key = "contrib/local-dev/pki/ca-key.pem"
validity_days = 365
[est]
base_path = "/.well-known/est"
[[est.label]]
name = "default"
ca_id = "dev-ca"
[otp]
enabled = true
token_length = 16
default_ttl = "24h"
max_uses = 1
hash_algorithm = "argon2id"
[admin]
enabled = true
auth = "bearer"
bearer_token_env = "KIPUKA_ADMIN_TOKEN"
Run the server:
export KIPUKA_ADMIN_TOKEN="dev-token-do-not-use-in-production"
cargo run -- --config kipuka.toml
Running against each database backend
SQLite
No additional setup. The database file is created automatically when
auto_migrate = true:
[db]
url = "sqlite://kipuka-dev.db?mode=rwc"
auto_migrate = true
PostgreSQL
Start a local PostgreSQL instance or use the Compose profile:
docker compose --profile postgres up -d postgres
Update kipuka.toml:
[db]
url = "postgres://kipuka:kipuka-dev@localhost:5432/kipuka"
auto_migrate = true
Run migrations explicitly if auto_migrate is disabled:
cargo run -- migrate --config kipuka.toml
MariaDB
Start MariaDB via the Compose profile:
docker compose --profile mariadb up -d mariadb
Update kipuka.toml:
[db]
url = "mysql://kipuka:kipuka-dev@localhost:3306/kipuka"
auto_migrate = true
HSM development with Kryoptic
Kryoptic provides a PKCS#11 interface compatible with the cryptoki crate
used by kipuka-hsm. It stores keys in software but exposes the same API as a
hardware token.
Starting Kryoptic
docker compose --profile hsm up -d kryoptic
The container exposes the PKCS#11 shared library at a bind-mounted path.
Check the compose.yaml for the exact mount point (typically
/usr/lib/libkryoptic.so inside the container).
Initializing a token
Use pkcs11-tool (from the OpenSC package) to initialize a slot and generate
a CA signing key:
# Initialize the token in slot 0
pkcs11-tool --module /usr/lib/libkryoptic.so \
--init-token --slot 0 \
--label "kipuka-dev" \
--so-pin 12345678
# Set the user PIN
pkcs11-tool --module /usr/lib/libkryoptic.so \
--init-pin --slot 0 \
--login --so-pin 12345678 \
--new-pin 1234
# Generate an ECDSA P-256 key pair for CA signing
pkcs11-tool --module /usr/lib/libkryoptic.so \
--login --pin 1234 \
--keypairgen --key-type EC:prime256v1 \
--id 01 --label "dev-ca-key"
Configuring kipuka for HSM
Update kipuka.toml to reference the PKCS#11 module:
[hsm]
library = "/usr/lib/libkryoptic.so"
slot = 0
token_label = "kipuka-dev"
pin = "1234" # For dev only; use pin_env or pin_file in production
[[ca]]
id = "hsm-dev-ca"
name = "HSM Development CA"
cert = "contrib/local-dev/pki/ca.pem"
key = "pkcs11:object=dev-ca-key"
hsm_slot = 0
Listing objects in the token
pkcs11-tool --module /usr/lib/libkryoptic.so \
--login --pin 1234 \
--list-objects
Verifying HSM signing
# Sign a test payload to confirm the PKCS#11 path works
pkcs11-tool --module /usr/lib/libkryoptic.so \
--login --pin 1234 \
--sign --mechanism ECDSA \
--id 01 \
--input-file /dev/urandom --read-write
IDE setup
rust-analyzer
kipuka works out of the box with rust-analyzer. The workspace Cargo.toml
at the repository root defines all crate members, so rust-analyzer
automatically discovers the full project.
VS Code
Recommended extensions:
| Extension | Purpose |
|---|---|
rust-lang.rust-analyzer | Rust language support, inline type hints, go-to-definition |
vadimcn.vscode-lldb | Debugger for Rust binaries |
tamasfe.even-better-toml | TOML syntax highlighting and validation |
serayuzgur.crates | Crate version hints in Cargo.toml |
usernamehw.errorlens | Inline compiler error display |
Recommended .vscode/settings.json for the workspace:
{
"rust-analyzer.check.command": "clippy",
"rust-analyzer.check.extraArgs": ["--", "-D", "warnings"],
"rust-analyzer.cargo.features": "all",
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
}
Environment variables for development
| Variable | Purpose | Example |
|---|---|---|
RUST_LOG | Log verbosity | debug, kipuka_est=trace |
KIPUKA_ADMIN_TOKEN | Admin API bearer token | dev-token-do-not-use-in-production |
KIPUKA_HSM_PIN | HSM PIN (production) | (set in env, not in config file) |
Set these in a .env file (excluded from version control via .gitignore)
or export them in your shell.
Next steps
- Testing – run the test suite and perform protocol-level verification
- Database Migrations – create and manage schema changes
- Contributing – code style, commit conventions, and security invariants
Testing
kipuka’s test suite covers unit tests, integration tests, EST protocol conformance, HSM operations, and full-stack enrollment flows.
Unit tests
Unit tests run against in-memory state and do not require any external services. They execute quickly and are suitable for rapid iteration:
cargo test
Per-crate testing
Run tests for a specific crate to reduce compilation time during focused development:
cargo test -p kipuka-est
cargo test -p kipuka-hsm
cargo test -p kipuka-otp
cargo test -p kipuka-util
cargo test -p kipuka-dogtag
cargo test -p kipuka-coap
Filtering tests
Run a single test or a subset by name:
# Run all tests with "otp" in the name
cargo test otp
# Run a specific test function
cargo test -p kipuka-otp -- test_argon2_hash_roundtrip
# Show test output (including println! in passing tests)
cargo test -- --nocapture
Integration tests
Integration tests exercise the full HTTP stack: TLS handshake, request routing, authentication, CSR validation, certificate issuance, and database persistence. They are gated behind a feature flag because they start a real axum server on a random port:
cargo test --features integration
Integration tests create a temporary SQLite database for each test run and clean it up on completion. No external database is required.
What integration tests cover
- Full
/simpleenrollflow with OTP authentication - Full
/simplereenrollflow with mTLS client certificate /cacertsresponse format and PKCS#7 encoding/csrattrsresponse with configured OID sets/serverkeygenkey pair generation and certificate issuance- EST label routing to different CAs
- CSR policy rejection (wrong key type, missing SAN, bad subject DN)
- OTP rate limiting and lockout behavior
- Concurrent enrollment under load
- HA failover (with mock CA health checks)
- Audit log correctness
EST protocol testing with curl and openssl
These commands verify kipuka’s EST implementation at the protocol level.
They assume a running server at localhost:9443 with the test PKI from
contrib/local-dev/setup-ca.sh.
Fetch CA certificates
The /cacerts endpoint requires no authentication:
curl -sk https://localhost:9443/.well-known/est/cacerts \
| base64 -d \
| openssl pkcs7 -inform DER -print_certs
Expected output: the PEM-encoded CA certificate chain.
Enroll with OTP
Generate an OTP via the admin API, then enroll:
# Generate OTP
OTP=$(curl -sk -X POST https://localhost:9443/admin/otp \
-H "Authorization: Bearer $KIPUKA_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"entity_id": "test-device"}' \
| jq -r '.otp')
# Generate CSR
openssl req -new \
-newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout test.key -out test.csr -nodes \
-subj "/CN=test-device"
# Enroll
curl -sk \
--cacert contrib/local-dev/pki/ca.pem \
-u "test-device:${OTP}" \
--data-binary @test.csr \
-H "Content-Type: application/pkcs10" \
-o test.p7 \
-w "%{http_code}\n" \
https://localhost:9443/.well-known/est/simpleenroll
# Extract certificate
openssl pkcs7 -inform DER -in test.p7 -print_certs -out test.pem
# Verify
openssl verify -CAfile contrib/local-dev/pki/ca.pem test.pem
Re-enroll with client certificate
Use the certificate from the previous step for mTLS authentication:
# Generate new CSR (with fresh key pair)
openssl req -new \
-newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-keyout test-new.key -out test-new.csr -nodes \
-subj "/CN=test-device"
# Re-enroll with mTLS
curl -sk \
--cacert contrib/local-dev/pki/ca.pem \
--cert test.pem --key test.key \
--data-binary @test-new.csr \
-H "Content-Type: application/pkcs10" \
-o test-new.p7 \
-w "%{http_code}\n" \
https://localhost:9443/.well-known/est/simplereenroll
# Extract renewed certificate
openssl pkcs7 -inform DER -in test-new.p7 -print_certs -out test-new.pem
Label routing
Test enrollment against a specific EST label:
# Enroll against the "iot" label (if configured)
curl -sk \
--cacert contrib/local-dev/pki/ca.pem \
-u "sensor-01:${OTP}" \
--data-binary @sensor.csr \
-H "Content-Type: application/pkcs10" \
-o sensor.p7 \
https://localhost:9443/.well-known/est/iot/simpleenroll
# Verify the issuer matches the CA bound to the "iot" label
openssl pkcs7 -inform DER -in sensor.p7 -print_certs \
| openssl x509 -noout -issuer
Server key generation
Test the /serverkeygen endpoint (if enabled in config):
# Request server-generated key pair
curl -sk \
--cacert contrib/local-dev/pki/ca.pem \
--cert test.pem --key test.key \
--data-binary @test.csr \
-H "Content-Type: application/pkcs10" \
-o serverkeygen.p7 \
https://localhost:9443/.well-known/est/serverkeygen
The response is a multipart MIME message containing both the certificate (PKCS#7) and the server-generated private key (PKCS#8).
TLS handshake inspection
Use openssl s_client to inspect the TLS configuration:
# Connect and show server certificate
openssl s_client -connect localhost:9443 \
-CAfile contrib/local-dev/pki/ca.pem \
-servername localhost \
2>/dev/null | openssl x509 -noout -text
# Test mTLS with client certificate
openssl s_client -connect localhost:9443 \
-CAfile contrib/local-dev/pki/ca.pem \
-cert contrib/local-dev/pki/client.pem \
-key contrib/local-dev/pki/client-key.pem \
-servername localhost
# Show negotiated cipher and protocol
openssl s_client -connect localhost:9443 \
-CAfile contrib/local-dev/pki/ca.pem \
2>/dev/null | grep -E "Protocol|Cipher"
# Test TLS 1.3 only
openssl s_client -connect localhost:9443 \
-CAfile contrib/local-dev/pki/ca.pem \
-tls1_3 2>/dev/null | grep "Protocol"
HSM testing with Kryoptic
Test the PKCS#11 code path using the Kryoptic SoftHSM container from
compose.yaml:
# Start Kryoptic
docker compose --profile hsm up -d kryoptic
# Initialize token and generate test key (see Development Setup for details)
pkcs11-tool --module /usr/lib/libkryoptic.so \
--init-token --slot 0 --label "test-hsm" --so-pin 12345678
pkcs11-tool --module /usr/lib/libkryoptic.so \
--init-pin --slot 0 --login --so-pin 12345678 --new-pin 1234
pkcs11-tool --module /usr/lib/libkryoptic.so \
--login --pin 1234 \
--keypairgen --key-type EC:prime256v1 \
--id 01 --label "test-ca-key"
# Run HSM-specific tests
cargo test -p kipuka-hsm --features integration
HSM tests verify:
- PKCS#11 session lifecycle (open, login, operate, close)
- Key lookup by label and ID
- ECDSA and RSA signing operations
- Error handling for unavailable slots, wrong PINs, missing keys
- Concurrent signing from multiple threads (session pooling)
CI pipeline
What runs in CI
Every push and merge request triggers the following:
| Step | Command | Purpose |
|---|---|---|
| Format check | cargo fmt --check | Enforce consistent formatting |
| Lint | cargo clippy -- -D warnings | Catch common mistakes and style issues |
| Unit tests | cargo test | All per-crate unit tests |
| Integration tests | cargo test --features integration | Full-stack EST protocol tests |
| Build (release) | cargo build --release | Verify release compilation succeeds |
| Documentation | cargo doc --no-deps | Verify rustdoc builds without warnings |
What requires manual or environment-specific testing
These tests cannot run in a standard CI runner and must be performed during development or in dedicated test environments:
| Test | Reason | How to run |
|---|---|---|
| Hardware HSM signing | Requires physical HSM hardware | Connect a YubiHSM 2 or Luna, run cargo test -p kipuka-hsm --features hsm-hardware |
| PostgreSQL integration | Requires a running PostgreSQL instance | docker compose --profile postgres up -d && cargo test --features integration-postgres |
| MariaDB integration | Requires a running MariaDB instance | docker compose --profile mariadb up -d && cargo test --features integration-mariadb |
| Dogtag PKI back-end | Requires a running Dogtag instance | Deploy Dogtag in a container, configure connection in test config |
| GSSAPI authentication | Requires a KDC (FreeIPA or AD) | Set up FreeIPA in a container, create service principal, run GSSAPI tests |
| NIAP compliance audit | Manual review against Protection Profile | Follow the checklist in docs/compliance/niap.md |
Integration testing with idm-ci / Beaker
For full end-to-end testing of kipuka integrated with FreeIPA (IPA-to-kipuka certificate enrollment flows), use the idm-ci framework or Beaker test infrastructure.
idm-ci
idm-ci provisions multi-host test environments with FreeIPA servers, kipuka instances, and client machines. Tests exercise the complete enrollment lifecycle:
- FreeIPA issues a Kerberos ticket to the client
- Client authenticates to kipuka using GSSAPI
- kipuka maps the Kerberos principal to a certificate subject
- kipuka issues a certificate
- Client installs the certificate and uses it for mTLS re-enrollment
These tests are defined outside the kipuka repository and are triggered by the IdM CI infrastructure.
Local smoke test with FreeIPA container
For a lightweight local approximation:
# Start FreeIPA in a container
podman run -d --name freeipa \
-h ipa.example.test \
-p 389:389 -p 443:443 -p 88:88 -p 464:464 \
quay.io/freeipa/freeipa-server:latest \
ipa-server-install --unattended \
--realm EXAMPLE.TEST \
--domain example.test \
--ds-password Secret123 \
--admin-password Secret123
# Wait for installation to complete (5-10 minutes)
podman logs -f freeipa
# Create a service principal for kipuka
podman exec freeipa kinit admin <<< "Secret123"
podman exec freeipa ipa service-add HTTP/kipuka.example.test
podman exec freeipa ipa-getkeytab \
-s ipa.example.test \
-p HTTP/kipuka.example.test \
-k /tmp/kipuka.keytab
# Copy the keytab out
podman cp freeipa:/tmp/kipuka.keytab ./kipuka.keytab
Then configure kipuka’s [gssapi] section to point to the extracted keytab
and run enrollment tests using curl --negotiate.
Test data cleanup
Test runs that create database state (OTP tokens, certificates, audit entries) use temporary SQLite databases by default. To clean up after manual testing against a persistent database:
# Delete the development SQLite database
rm -f kipuka-dev.db
# For PostgreSQL, drop and recreate the database
psql -U kipuka -h localhost -c "DROP DATABASE kipuka; CREATE DATABASE kipuka;"
Re-run migrations after cleanup:
cargo run -- migrate --config kipuka.toml
Database Migrations
kipuka manages its database schema through sequential migration files. Every schema change is expressed as a migration that runs exactly once on each database, tracked by a migration history table.
Migration file layout
Migrations live under migrations/ with one subdirectory per supported
database backend:
migrations/
sqlite/
0001_initial_schema.sql
0002_add_audit_log.sql
0003_add_cert_inventory.sql
postgres/
0001_initial_schema.sql
0002_add_audit_log.sql
0003_add_cert_inventory.sql
mariadb/
0001_initial_schema.sql
0002_add_audit_log.sql
0003_add_cert_inventory.sql
Naming convention
Migration files follow the pattern NNNN_description.sql where:
NNNNis a zero-padded sequential number starting at0001descriptionis a lowercase, underscore-separated summary of the change- The
.sqlextension is required
Examples:
0004_add_otp_lockout_columns.sql
0005_create_ha_state_table.sql
0006_add_label_to_certs.sql
The numeric prefix determines execution order. kipuka runs migrations in
ascending order and skips any that have already been applied (tracked in the
_sqlx_migrations table).
Running migrations
Automatic migration on startup
When auto_migrate = true in the [db] configuration section, kipuka applies
pending migrations automatically when the server starts:
[db]
url = "sqlite:///var/lib/kipuka/kipuka.db?mode=rwc"
auto_migrate = true
This is convenient for development but may not be appropriate for production environments where schema changes require review and approval.
Explicit migration command
Run migrations manually using the migrate subcommand:
kipuka migrate --config kipuka.toml
Output:
Applied 0001_initial_schema (23ms)
Applied 0002_add_audit_log (11ms)
Applied 0003_add_cert_inventory (15ms)
3 migrations applied successfully
If all migrations have already been applied:
No pending migrations
Checking migration status
View which migrations have been applied:
kipuka migrate --config kipuka.toml --status
Output:
Migration Applied At
0001_initial_schema 2026-06-20T10:00:00Z
0002_add_audit_log 2026-06-20T10:00:00Z
0003_add_cert_inventory 2026-06-20T10:00:01Z
0004_add_otp_lockout_columns (pending)
The three-backend rule
Every migration must have counterparts for all three database backends. A migration that adds a column to a table in SQLite must also add that column in PostgreSQL and MariaDB. This ensures that kipuka can be deployed against any supported backend without schema drift.
The migration runner selects the correct subdirectory based on the database URL scheme:
| URL scheme | Migration directory |
|---|---|
sqlite:// | migrations/sqlite/ |
postgres:// or postgresql:// | migrations/postgres/ |
mysql:// or mariadb:// | migrations/mariadb/ |
Creating a new migration
Step 1: Choose a descriptive name
Pick a name that describes the change, not the ticket number:
Good: 0007_add_gssapi_principal_mapping.sql
Bad: 0007_issue_42.sql
Step 2: Write the SQL for each backend
Create three files with the same sequence number and description:
touch migrations/sqlite/0007_add_gssapi_principal_mapping.sql
touch migrations/postgres/0007_add_gssapi_principal_mapping.sql
touch migrations/mariadb/0007_add_gssapi_principal_mapping.sql
Each file contains the DDL for that specific backend.
SQLite example
-- migrations/sqlite/0007_add_gssapi_principal_mapping.sql
CREATE TABLE IF NOT EXISTS gssapi_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
principal TEXT NOT NULL UNIQUE,
subject_dn TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_gssapi_mappings_principal ON gssapi_mappings(principal);
PostgreSQL example
-- migrations/postgres/0007_add_gssapi_principal_mapping.sql
CREATE TABLE IF NOT EXISTS gssapi_mappings (
id SERIAL PRIMARY KEY,
principal TEXT NOT NULL UNIQUE,
subject_dn TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_gssapi_mappings_principal ON gssapi_mappings(principal);
MariaDB example
-- migrations/mariadb/0007_add_gssapi_principal_mapping.sql
CREATE TABLE IF NOT EXISTS gssapi_mappings (
id INT AUTO_INCREMENT PRIMARY KEY,
principal VARCHAR(512) NOT NULL UNIQUE,
subject_dn VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_gssapi_mappings_principal ON gssapi_mappings(principal);
Step 3: Update sqlx query metadata
After adding migrations, regenerate the sqlx offline query data so that compile-time query checking works without a live database:
cargo sqlx prepare --workspace
This updates the .sqlx/ directory with query metadata for all three
backends.
Schema differences between backends
While the logical schema is identical across backends, SQL syntax differences require backend-specific migration files.
Type mapping
| Logical type | SQLite | PostgreSQL | MariaDB |
|---|---|---|---|
| Auto-increment PK | INTEGER PRIMARY KEY AUTOINCREMENT | SERIAL PRIMARY KEY | INT AUTO_INCREMENT PRIMARY KEY |
| Timestamp | TEXT (ISO 8601 strings) | TIMESTAMPTZ | TIMESTAMP |
| Variable text | TEXT | TEXT | VARCHAR(n) or TEXT |
| Boolean | INTEGER (0/1) | BOOLEAN | TINYINT(1) |
| Binary data | BLOB | BYTEA | BLOB |
Default value expressions
| Backend | Current timestamp | UUID generation |
|---|---|---|
| SQLite | strftime('%Y-%m-%dT%H:%M:%SZ', 'now') | Application-generated |
| PostgreSQL | NOW() | gen_random_uuid() |
| MariaDB | CURRENT_TIMESTAMP | UUID() |
Feature availability
- PostgreSQL supports
ON CONFLICT DO UPDATE(upsert) natively. - SQLite supports
INSERT OR REPLACEandON CONFLICT(3.24+). - MariaDB uses
INSERT ... ON DUPLICATE KEY UPDATE.
When writing migrations that use upsert-like behavior, use the backend-appropriate syntax.
Rules for migration safety
Never modify a released migration
Once a migration has been applied to any environment (including other developers’ local databases), it is immutable. To change an existing table:
- Create a new migration with the next sequence number
- Use
ALTER TABLEto modify the schema - Provide the new migration for all three backends
Additive changes only
Prefer adding columns, tables, and indexes. Avoid dropping columns or tables unless absolutely necessary. If a column is no longer used:
- Stop writing to it in the application code
- After a release cycle, add a migration to drop the column
Handle NULL for new columns
When adding a column to an existing table, either provide a DEFAULT value or
allow NULL. Existing rows cannot retroactively satisfy a NOT NULL
constraint without a default:
-- Correct: new column with a default
ALTER TABLE otps ADD COLUMN locked_until TEXT DEFAULT NULL;
-- Incorrect: will fail if the table has existing rows
ALTER TABLE otps ADD COLUMN locked_until TEXT NOT NULL;
Test data migration
If a migration transforms existing data (not just schema), include the data transformation in the same migration file:
-- Add new column
ALTER TABLE audit_log ADD COLUMN auth_method TEXT DEFAULT 'unknown';
-- Backfill existing rows
UPDATE audit_log SET auth_method = 'mtls' WHERE auth_method = 'unknown';
Testing migrations against all backends
Before committing a new migration, verify it applies cleanly against all three database backends:
# SQLite (in-memory)
cargo run -- migrate --config test-sqlite.toml
# PostgreSQL
docker compose --profile postgres up -d
cargo run -- migrate --config test-postgres.toml
# MariaDB
docker compose --profile mariadb up -d
cargo run -- migrate --config test-mariadb.toml
The CI pipeline runs migrations against SQLite automatically. PostgreSQL and MariaDB migration tests require the corresponding Compose profiles and are part of the extended test suite.
Rollback strategy
kipuka migrations are forward-only. There is no built-in migrate down
command. This is a deliberate design choice: automated rollback of DDL changes
is unreliable in production (data loss, constraint violations, transaction
semantics vary by backend).
Manual rollback procedure
If a migration must be undone:
-
Write a new forward migration that reverses the change:
-- 0008_revert_gssapi_mappings.sql DROP TABLE IF EXISTS gssapi_mappings; -
Apply it normally:
kipuka migrate --config kipuka.toml
Emergency rollback
In an emergency where the server cannot start due to a bad migration:
- Restore the database from backup
- Remove the problematic migration files from
migrations/ - Restart kipuka
For PostgreSQL and MariaDB, point-in-time recovery (PITR) can restore the database to the moment before the migration ran. For SQLite, restore from a filesystem-level backup.
Backup before migrating
Always back up the database before applying migrations in production:
# SQLite
cp /var/lib/kipuka/kipuka.db /var/lib/kipuka/kipuka.db.bak
# PostgreSQL
pg_dump -U kipuka kipuka > kipuka-backup.sql
# MariaDB
mysqldump -u kipuka -p kipuka > kipuka-backup.sql
Contributing
kipuka welcomes contributions. This document covers the license, code style, commit conventions, security invariants, and how to report vulnerabilities.
License
kipuka is licensed under the GNU General Public License v3.0 or later
(GPL-3.0-or-later). The full license text is in the LICENSE file at the
repository root.
Source: https://codeberg.org/czinda/kipuka
Contribution licensing
kipuka uses an inbound = outbound contribution model. By submitting a patch, you agree that your contribution is licensed under the same GPL-3.0-or-later terms as the rest of the project.
Developer Certificate of Origin (DCO)
All commits must include a Signed-off-by trailer certifying that you have
the right to submit the contribution under the project’s license. This
follows the Developer Certificate of Origin
(DCO v1.1).
Add it automatically with git commit -s:
git commit -s -m "Add OTP lockout duration configuration"
This appends a line like:
Signed-off-by: Your Name <your.email@example.com>
Commits without a valid Signed-off-by line will be rejected.
Code style
Formatting
All Rust code must pass cargo fmt with no modifications:
cargo fmt --check
Format before committing:
cargo fmt
Linting
All code must compile cleanly under cargo clippy with warnings treated as
errors:
cargo clippy -- -D warnings
Fix clippy warnings before submitting. If a specific lint is intentionally
suppressed, add an #[allow(...)] attribute with a comment explaining why:
#![allow(unused)]
fn main() {
// Clippy suggests using `map_or`, but the explicit match is clearer
// for this error-handling path.
#[allow(clippy::manual_map)]
fn resolve_ca(&self, label: &str) -> Option<&CaConfig> {
// ...
}
}
Rust edition
kipuka uses Rust edition 2021. Do not use unstable features or nightly-only APIs.
Commit message conventions
Follow a conventional commit style:
<type>: <short summary>
<optional body explaining what and why>
Signed-off-by: Your Name <your.email@example.com>
Types
| Type | Use for |
|---|---|
feat | New functionality visible to users or API consumers |
fix | Bug fix |
refactor | Code restructuring with no behavior change |
test | Adding or updating tests |
docs | Documentation changes |
chore | Build system, CI, dependency updates |
security | Security-related fixes (see disclosure process below) |
Examples
feat: add GSSAPI principal-to-subject mapping
Administrators can now define explicit mappings from Kerberos principals
to X.509 subject DNs in kipuka.toml under [gssapi.principal_mapping].
A default template is also supported for unmapped principals.
Signed-off-by: Alice Engineer <alice@example.com>
fix: prevent OTP reuse after max_uses reached
The use_count check was off-by-one, allowing one extra use beyond
max_uses. This commit corrects the comparison to use strict less-than.
Signed-off-by: Bob Developer <bob@example.com>
Scope
Keep commits focused on a single logical change. A commit that adds a feature, fixes a bug, and reformats unrelated code should be split into three commits.
Security invariants
The following invariants are critical to kipuka’s security posture. Any patch that weakens or removes these protections will be rejected.
OTP values are never stored in plaintext
OTP tokens are hashed with argon2id (or the configured hash algorithm) immediately upon generation. The raw token is returned to the administrator exactly once in the HTTP response. Only the hash is persisted to the database.
#![allow(unused)]
fn main() {
// Correct: hash before storage
let hash = argon2::hash_encoded(otp.as_bytes(), &salt, &config)?;
db::store_otp_hash(entity_id, &hash).await?;
// WRONG: never store the raw OTP
// db::store_otp(entity_id, &otp).await?;
}
CA private keys are zeroized on drop
All types that hold CA private key material implement Zeroize and
ZeroizeOnDrop from the zeroize crate. This ensures that key bytes are
overwritten with zeros when the value goes out of scope, preventing residual
key material in freed memory.
#![allow(unused)]
fn main() {
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Zeroize, ZeroizeOnDrop)]
struct CaPrivateKey {
bytes: Vec<u8>,
}
}
Never use std::mem::forget or ManuallyDrop on types containing key
material.
Timing-safe comparisons for all authentication checks
All authentication comparisons (OTP validation, bearer token checks, HMAC
verification) use constant-time operations from the subtle crate. This
prevents timing side-channel attacks that could leak information about valid
credentials.
#![allow(unused)]
fn main() {
use subtle::ConstantTimeEq;
fn verify_token(provided: &[u8], expected: &[u8]) -> bool {
provided.ct_eq(expected).into()
}
}
Never use == for comparing secrets, tokens, or hashes.
CSPRNG for serial numbers
Certificate serial numbers are generated using OsRng from the rand crate,
which delegates to the operating system’s cryptographically secure random
number generator (/dev/urandom on Linux, CryptGenRandom on Windows).
#![allow(unused)]
fn main() {
use rand::rngs::OsRng;
use rand::RngCore;
fn generate_serial() -> [u8; 20] {
let mut serial = [0u8; 20];
OsRng.fill_bytes(&mut serial);
serial[0] &= 0x7f; // Ensure positive (RFC 5280)
serial
}
}
Never use rand::thread_rng(), rand::random(), or any non-CSPRNG source
for serial numbers, nonces, or key material. Non-cryptographic PRNGs are
predictable and can lead to serial number collisions or key compromise.
No logging of sensitive material
The following data must never appear in log output at any verbosity level,
including RUST_LOG=trace:
- CA private keys or key bytes
- OTP token values (raw or hashed)
- CSR private keys
- HSM PINs or passwords
- Bearer tokens
- TLS session keys
When logging certificate-related operations, log only the certificate fingerprint, subject DN, serial number, and outcome. When logging OTP operations, log only the entity ID and outcome (success/failure).
#![allow(unused)]
fn main() {
// Correct: log the fingerprint, not the key
tracing::info!(
entity_id = %entity_id,
fingerprint = %cert.fingerprint_sha256(),
"Certificate issued"
);
// WRONG: never log key material
// tracing::debug!(key = ?ca_key, "Signing with CA key");
}
If you are unsure whether a value is sensitive, treat it as sensitive.
Reporting security vulnerabilities
Do not open a public issue for security vulnerabilities.
Report vulnerabilities privately by emailing security@kipuka.dev with:
- A description of the vulnerability
- Steps to reproduce
- Affected versions (if known)
- Any suggested fix (optional)
You will receive an acknowledgment within 48 hours and a detailed response within 7 business days. Security fixes are developed privately and disclosed after a patch is available.
If you prefer encrypted communication, request a PGP key in your initial email.
Submitting patches
Workflow
-
Fork the repository on Codeberg
-
Create a feature branch from
main -
Make your changes (following the conventions above)
-
Run the full check suite:
cargo fmt --check cargo clippy -- -D warnings cargo test cargo test --features integration -
Commit with
Signed-off-by(usegit commit -s) -
Open a pull request against
main
Pull request checklist
Before requesting review, verify:
- All commits have
Signed-off-bytrailers -
cargo fmt --checkpasses -
cargo clippy -- -D warningspasses -
cargo testpasses -
cargo test --features integrationpasses - New database migrations exist for all three backends (if applicable)
- Security invariants listed above are preserved
- No sensitive data in log statements, comments, or test fixtures
Review process
All pull requests require at least one maintainer review. Security-sensitive changes require two reviews. CI must pass before merge.
Feature requests and bug reports
File issues on the Codeberg issue tracker:
https://codeberg.org/czinda/kipuka/issues
For feature requests, describe the use case and expected behavior. For bugs, include:
- kipuka version (
kipuka --version) - Operating system and architecture
- Database backend and version
- Steps to reproduce
- Expected vs. actual behavior
- Relevant log output (with sensitive data redacted)