🤖 LLM/AI WARNING 🤖
This project was largely written by Claude It has been reviewed and tested, but use in production at your own discretion.
🤖 LLM/AI WARNING 🤖
A drop-in replacement for Puppet Server's built-in CA, written in Go. It implements the same HTTP API that Puppet agents and puppet cert / puppetserver ca tooling use, backed by a flat-file certificate store compatible with existing Puppet CA directories.
Migrating from Puppet Server? See the migration guide for step-by-step instructions, directory layout mapping, and CLI command translation.
- Full Puppet CA API compatibility: all 13 endpoints used by agents and puppet-server
- Pluggable storage: filesystem (default, Puppet Server compatible), SQLite (single database file), or PostgreSQL / MySQL (MariaDB) / etcd / Redis (Valkey) for HA clusters; CA cert/key can be pinned to local files independently. See the storage backends guide
- Autosigning:
true, glob-pattern file, or executable plugin modes - mTLS support: optional HTTPS with per-endpoint tier-based client certificate authorization
- CA import: replace a bootstrapped CA with an external cert/key pair offline
- Server-side key generation: issue cert+key pairs without a node-submitted CSR; configurable RSA (2048/3072/4096) or ECDSA (P-256/P-384/P-521)
- Configurable key algorithms: CA and leaf certificates can use RSA or ECDSA; ECDSA support for both bootstrapped CAs and generated leaf certs
- Random serial numbers: every issued leaf certificate gets a cryptographically random 128-bit serial (CA/Browser Forum guidance)
- CRL Distribution Points: optionally embed a CRL URL in every issued certificate (
--crl-url) so verifiers can automatically fetch the CRL - Configurable CRL validity: control how long each published CRL is valid (
crl_validity_days) - Automatic CRL refresh: a background job re-signs the CRL before its validity lapses, so a low-churn CA never serves an expired CRL; safe across replicas (serialised on the shared CRL lock) and tunable or disablable. Operators can also force a refresh on demand via
openvox-ca-ctl reissue-crl - Expired-certificate cleanup (opt-in): a background job removes certificates that expired more than a configurable grace period ago from the inventory and the CRL (and deletes their stored signed certificate), keeping both from growing without bound as nodes are decommissioned; safe across replicas (serialised on the shared CRL lock)
- OCSP responder: built-in RFC 6960 OCSP responder; AIA extension embedded in issued certs when
--ocsp-urlis set; in-memory cache with nonce bypass - Health probes:
/healthz/live,/healthz/ready, and/healthz/startupendpoints for Kubernetes-style liveness/readiness checks - Prometheus exporter: optional
/metricslistener (--metrics-listen) exposing Go runtime/process and HTTP metrics plus CA certificate, CRL, and per–leaf-certificate expiry and issuance-status series; ships with a Jsonnet alerting mixin. See metrics & monitoring - Graceful shutdown:
SIGTERM/SIGINTdrains in-flight requests with a configurable window (25s default) before exiting; deferred storage and signer cleanup always runs - FIPS-compatible: standard library only (
crypto/x509,net/http); no CGO by default; FIPS build available viaGOEXPERIMENT=boringcrypto openvox-ca-ctl: operator CLI matchingpuppetserver casubcommands
Requirements: Go 1.25+, Mage
git clone https://github.com/voxpupuli/openvox-ca.git
cd openvox-ca
# Build both binaries to bin/
mage build:all
# Or with plain Go
go build -o bin/openvox-ca ./cmd/openvox-ca
go build -o bin/openvox-ca-ctl ./cmd/openvox-ca-ctlmage build:fips # → bin/openvox-ca + bin/openvox-ca-ctl (GOEXPERIMENT=boringcrypto, CGO_ENABLED=1)| Flag | Default | Description |
|---|---|---|
--config |
"" |
Path to YAML config file (auto-detected at /etc/puppet-ca/config.yaml) |
--cadir |
"" |
CA storage directory (keys, certs, CSRs, CRL); required via flag, env, or config |
--host |
0.0.0.0 |
Listen address |
--port |
8140 |
Listen port |
--hostname |
"" |
CN suffix for a bootstrapped CA (Puppet CA: <hostname>); defaults to puppet when empty |
--autosign-config |
"" |
Autosign mode: true, false, or path to a file/executable |
--tls-cert |
"" |
Server TLS certificate PEM (enables HTTPS when set with --tls-key) |
--tls-key |
"" |
Server TLS private key PEM |
--puppet-server |
"" |
Comma-separated CNs granted admin API access (mTLS only) |
--puppet-server-file |
"" |
Path to a file of CNs granted admin API access (one per line; # comments and blank lines ignored) |
--no-pp-cli-auth |
false |
Disable pp_cli_auth extension as an admin credential; require CN allow list only |
--no-tls-required |
false |
Allow plain HTTP on non-loopback addresses; use only behind a trusted TLS proxy or in test environments |
--allow-public-status |
false |
Allow unauthenticated GET /certificate_status; by default this endpoint requires a CA-signed client cert |
--ocsp-url |
"" |
OCSP responder URL to embed in issued certificates |
--crl-url |
"" |
CRL distribution point URL to embed in issued certificates |
--metrics-listen |
"" |
Address for the Prometheus exporter (e.g. 127.0.0.1:9140); empty disables it. See metrics & monitoring |
--encrypt-ca-key |
false |
Encrypt the CA private key at rest (AES-256-GCM + Argon2id) |
--ca-key-passphrase-file |
"" |
Path to file containing the CA key passphrase (first line used) |
--csr-rate-limit |
60 |
Max CSR submissions per IP per minute on the public PUT /certificate_request endpoint (0 disables) |
--single-process |
false |
Disable CA key isolation (run signer and frontend in a single process) |
--storage-backend |
filesystem |
Storage backend for CA state: filesystem or etcd. See storage backends |
--etcd-endpoints |
"" |
Comma-separated etcd endpoints (used when --storage-backend etcd) |
--etcd-key-prefix |
/puppet-ca |
etcd key namespace for this CA |
--ca-cert-file |
"" |
Keep the CA certificate at this local path regardless of backend |
--ca-key-file |
"" |
Keep the CA private key at this local path regardless of backend |
--daemon |
false |
Fork to background (not recommended in containers) |
--logfile |
"" |
Write JSON logs to this file instead of stderr |
--verbosity / -v |
0 |
Verbosity: 0=Info, 1=Debug, 2=Trace |
All flags can be set via a YAML config file or environment variables. Precedence (highest → lowest): CLI flag → environment variable → config file → built-in default.
Key generation and CA subject options are intentionally not exposed as CLI flags. They are one-time bootstrap decisions that belong in a config file or environment variable. Use the config file or PUPPET_CA_CA_KEY_ALGO / PUPPET_CA_CA_SUBJECT_* env vars to set them.
The config file is located by checking, in order:
--config /path/to/config.yaml(explicit flag)PUPPET_CA_CONFIGenvironment variable/etc/puppet-ca/config.yaml(auto-detected if the file exists)
Example /etc/puppet-ca/config.yaml:
cadir: /etc/puppetlabs/puppet/ssl/ca
host: 0.0.0.0
port: 8140
hostname: puppet.example.com
tls_cert: /etc/puppetlabs/puppet/ssl/ca/ca_crt.pem
tls_key: /etc/puppetlabs/puppet/ssl/ca/ca_key.pem
puppet_server: puppet.example.com
puppet_server_file: ""
no_pp_cli_auth: false
no_tls_required: false
allow_public_status: false # set true to allow unauthenticated GET /certificate_status
autosign_config: ""
logfile: ""
verbosity: 0
ocsp_url: ""
crl_url: ""
shutdown_timeout_sec: 0 # graceful HTTP-drain budget on SIGTERM; 0 = built-in default (25s)
# Key generation options (applied only when bootstrapping a new CA or generating leaf certs).
ca_key_algo: "" # "rsa" (default) or "ecdsa"
ca_key_size: 0 # RSA: 2048/3072/4096 (default 4096); ECDSA: 256/384/521 (default 256)
leaf_key_algo: "" # "rsa" (default) or "ecdsa"
leaf_key_size: 0 # RSA: 2048/3072/4096 (default 2048); ECDSA: 256/384/521 (default 256)
# CA certificate subject fields (applied only when bootstrapping a new CA).
ca_subject_org: ""
ca_subject_ou: ""
ca_subject_country: ""
ca_subject_locality: ""
ca_subject_province: ""
# Validity and path length.
# ca_* apply only when bootstrapping a new CA.
# leaf_validity_days and crl_validity_days apply on every signing / revocation operation.
ca_path_length: -1 # -1 = unconstrained, 0 = leaf certs only, N = N levels of intermediates
ca_validity_days: 0 # 0 = built-in default (~5 years); positive integer overrides
leaf_validity_days: 0 # 0 = built-in default (~5 years); positive integer overrides
crl_validity_days: 0 # 0 = built-in default (30 days); positive integer overrides
csr_rate_limit: 60 # max CSR submissions per IP per minute; 0 = disable rate limiting
# Background CRL refresh keeps the CRL's NextUpdate from lapsing on a low-churn CA.
# Safe to run on every replica (serialised on the shared CRL lock).
disable_crl_refresh: false # true = never auto-refresh the CRL
crl_refresh_interval_sec: 0 # how often to check; 0 = built-in default (1h)
crl_refresh_before_sec: 0 # re-sign when remaining validity < this; 0 = crl_validity/3
# Background expired-certificate cleanup (opt-in). When enabled, a job removes
# certificates that expired more than the retention grace period ago from the
# inventory and the CRL, and deletes their stored signed certificate. Safe to run
# on every replica (serialised on the shared CRL lock).
enable_expired_cert_cleanup: false # true = run the cleanup job
expired_cert_retention_sec: 0 # grace period after a cert's NotAfter before removal; 0 = built-in default (30d)
expired_cert_cleanup_interval_sec: 0 # how often to run; 0 = built-in default (24h)
# CA key encryption at rest.
encrypt_ca_key: false # encrypt the CA private key (AES-256-GCM + Argon2id)
ca_key_passphrase_file: "" # path to passphrase file; auto-generated if omitted
# Date/time format in JSON responses.
puppet_datetime_format: false # use Puppet CA style "2006-01-02T15:04:05MST" instead of RFC 3339Environment variables (mirrors CLI flags):
| Flag | Environment variable |
|---|---|
--cadir |
PUPPET_CA_CADIR |
--autosign-config |
PUPPET_CA_AUTOSIGN_CONFIG |
--host |
PUPPET_CA_HOST |
--port |
PUPPET_CA_PORT |
--hostname |
PUPPET_CA_HOSTNAME |
--verbosity |
PUPPET_CA_VERBOSITY |
--logfile |
PUPPET_CA_LOGFILE |
--tls-cert |
PUPPET_CA_TLS_CERT |
--tls-key |
PUPPET_CA_TLS_KEY |
--puppet-server |
PUPPET_CA_PUPPET_SERVER |
--puppet-server-file |
PUPPET_CA_PUPPET_SERVER_FILE |
--no-pp-cli-auth |
PUPPET_CA_NO_PP_CLI_AUTH |
--no-tls-required |
PUPPET_CA_NO_TLS_REQUIRED |
--allow-public-status |
PUPPET_CA_ALLOW_PUBLIC_STATUS |
--ocsp-url |
PUPPET_CA_OCSP_URL |
--crl-url |
PUPPET_CA_CRL_URL |
--metrics-listen |
PUPPET_CA_METRICS_LISTEN |
--csr-rate-limit |
PUPPET_CA_CSR_RATE_LIMIT |
--encrypt-ca-key |
PUPPET_CA_ENCRYPT_CA_KEY |
--ca-key-passphrase-file |
PUPPET_CA_KEY_PASSPHRASE_FILE |
--storage-backend |
PUPPET_CA_STORAGE_BACKEND |
--etcd-endpoints |
PUPPET_CA_ETCD_ENDPOINTS |
--etcd-key-prefix |
PUPPET_CA_ETCD_KEY_PREFIX |
--ca-cert-file |
PUPPET_CA_CA_CERT_FILE |
--ca-key-file |
PUPPET_CA_CA_KEY_FILE |
The CA key passphrase can also be provided via PUPPET_CA_KEY_PASSPHRASE (env var only, no CLI flag to avoid /proc/cmdline exposure).
Environment variables (config file / env var only, no CLI flag):
| Config key | Environment variable |
|---|---|
ca_key_algo |
PUPPET_CA_CA_KEY_ALGO |
ca_key_size |
PUPPET_CA_CA_KEY_SIZE |
leaf_key_algo |
PUPPET_CA_LEAF_KEY_ALGO |
leaf_key_size |
PUPPET_CA_LEAF_KEY_SIZE |
ca_subject_org |
PUPPET_CA_CA_SUBJECT_ORG |
ca_subject_ou |
PUPPET_CA_CA_SUBJECT_OU |
ca_subject_country |
PUPPET_CA_CA_SUBJECT_COUNTRY |
ca_subject_locality |
PUPPET_CA_CA_SUBJECT_LOCALITY |
ca_subject_province |
PUPPET_CA_CA_SUBJECT_PROVINCE |
ca_path_length |
PUPPET_CA_CA_PATH_LENGTH |
ca_validity_days |
PUPPET_CA_CA_VALIDITY_DAYS |
leaf_validity_days |
PUPPET_CA_LEAF_VALIDITY_DAYS |
crl_validity_days |
PUPPET_CA_CRL_VALIDITY_DAYS |
disable_crl_refresh |
PUPPET_CA_DISABLE_CRL_REFRESH |
crl_refresh_interval_sec |
PUPPET_CA_CRL_REFRESH_INTERVAL_SEC |
crl_refresh_before_sec |
PUPPET_CA_CRL_REFRESH_BEFORE_SEC |
enable_expired_cert_cleanup |
PUPPET_CA_ENABLE_EXPIRED_CERT_CLEANUP |
expired_cert_retention_sec |
PUPPET_CA_EXPIRED_CERT_RETENTION_SEC |
expired_cert_cleanup_interval_sec |
PUPPET_CA_EXPIRED_CERT_CLEANUP_INTERVAL_SEC |
shutdown_timeout_sec |
PUPPET_CA_SHUTDOWN_TIMEOUT_SEC |
etcd_username |
PUPPET_CA_ETCD_USERNAME |
etcd_password |
PUPPET_CA_ETCD_PASSWORD |
etcd_dial_timeout_sec |
PUPPET_CA_ETCD_DIAL_TIMEOUT_SEC |
etcd_request_timeout_sec |
PUPPET_CA_ETCD_REQUEST_TIMEOUT_SEC |
etcd_tls_ca_file |
PUPPET_CA_ETCD_TLS_CA_FILE |
etcd_tls_cert_file |
PUPPET_CA_ETCD_TLS_CERT_FILE |
etcd_tls_key_file |
PUPPET_CA_ETCD_TLS_KEY_FILE |
puppet_datetime_format |
PUPPET_CA_PUPPET_DATETIME_FORMAT |
Note:
--daemonis intentionally excluded from config file and environment variable support becausePUPPET_CA_DAEMONis used internally as the daemon fork signal.
Boolean env vars accept any value accepted by strconv.ParseBool: 1, t, true,
yes, on (case-insensitive) to enable; 0, f, false, no, off to disable.
./bin/openvox-ca --cadir /etc/puppetlabs/puppet/ssl --hostname puppet.example.comOn first run the server bootstraps a new CA under --cadir and begins serving on port 8140.
./bin/openvox-ca \
--cadir /etc/puppetlabs/puppet/ssl \
--tls-cert /etc/puppetlabs/puppet/ssl/ca/ca_crt.pem \
--tls-key /etc/puppetlabs/puppet/ssl/ca/ca_key.pem \
--puppet-server puppet.example.comWhen --tls-cert and --tls-key are both set, the server:
- Presents those certs to connecting clients
- Requests (but does not require) a client certificate from every connection
- Enforces endpoint-level authorization (see Authorization tiers below)
On SIGTERM or SIGINT, the frontend HTTP server calls http.Server.Shutdown() with a drain context (wired via signal.NotifyContext) so in-flight requests (signing, CRL, OCSP) drain cleanly before the process exits. The request context is cancelled on signal, and the command returns normally rather than calling os.Exit on its error paths, so deferred storage and signer cleanup always runs after all connections are done.
The drain budget defaults to 25 seconds and is configurable via shutdown_timeout_sec (config file) or PUPPET_CA_SHUTDOWN_TIMEOUT_SEC (environment); a non-positive value falls back to the default.
In the default isolated-process deployment, the launcher supervisor forwards the signal to both the signer and frontend children and waits the drain budget plus a 3-second headroom (28 seconds by default) before hard-killing any child that has not exited. Because the launcher's timer starts when it forwards SIGTERM — strictly before the frontend begins its own Shutdown() — this headroom guarantees the supervisor always outlasts the frontend's drain rather than truncating it.
This is particularly important for Kubernetes rolling updates: pods receive SIGTERM with a configurable grace period (terminationGracePeriodSeconds, default 30 seconds). The defaults (25s drain, 28s supervisor) nest under that 30-second grace so the server drains and exits cleanly before the platform SIGKILLs the pod. If you raise shutdown_timeout_sec, raise terminationGracePeriodSeconds to at least the drain budget plus 3 seconds.
The --autosign-config flag controls automatic CSR signing:
| Value | Behavior |
|---|---|
false / "" |
Manual signing only (default) |
true |
Sign every incoming CSR immediately |
/path/to/file (not executable) |
Glob-pattern allowlist (one pattern per line, # comments ignored) |
/path/to/script (executable) |
Custom plugin: called with argv[1]=CN, CSR PEM on stdin; exit 0 = sign, non-zero = hold |
Allowlist example:
# autosign.conf
*.agent.example.com
compile-*.internal
Executable plugin example:
#!/bin/bash
subject="$1"
csr_pem=$(cat)
# approve only nodes whose name starts with "web-"
[[ "$subject" == web-* ]] && exit 0 || exit 1<cadir>/
ca_crt.pem CA certificate
ca_pub.pem CA public key
ca_crl.pem Certificate Revocation List
inventory.txt Signed certificate log (hex serial, dates, subject per line)
signed/ Issued certificates
requests/ Pending CSRs
private/
ca_key.pem CA private key (mode 0600; encrypted PEM when --encrypt-ca-key)
.ca_key_passphrase Auto-generated passphrase file (mode 0600; only when --encrypt-ca-key
is used without an explicit passphrase source)
{subject}_key.pem Server-side generated private keys (mode 0600)
Note: Serial numbers are now cryptographically random (128-bit). The
serialfile used by older Puppet CAs for sequential serial tracking is no longer written or read by this server.
All endpoints are served under both the bare path and /puppet-ca/v1/<path>, so the server can be used directly by agents or placed behind a proxy that strips the prefix.
| Method | Path | Description |
|---|---|---|
GET |
/certificate_status/{subject} |
Get status: signed, requested, or revoked |
PUT |
/certificate_status/{subject} |
Change state (signed or revoked); supports cert_ttl (seconds) |
DELETE |
/certificate_status/{subject} |
Revoke + delete cert and CSR (puppet cert clean) |
PUT body:
{ "desired_state": "signed", "cert_ttl": 86400 }GET response:
{
"name": "agent.example.com",
"state": "signed",
"fingerprint": "AA:BB:...",
"fingerprints": { "SHA256": "AA:BB:...", "default": "AA:BB:..." },
"dns_alt_names": ["agent.example.com"],
"subject_alt_names": ["agent.example.com"],
"authorization_extensions": {},
"serial_number": 7329847239485029341,
"not_before": "2025-01-01T00:00:00Z",
"not_after": "2030-01-01T00:00:00Z"
}Note:
serial_numberis the low 64 bits of the certificate's cryptographically random 128-bit serial, returned as a signed int64 for API compatibility. It is omitted for certificates in therequestedstate.
| Method | Path | Description |
|---|---|---|
GET |
/certificate_statuses/{any} |
List all certificates; filter with ?state=requested|signed|revoked |
| Method | Path | Description |
|---|---|---|
GET |
/certificate_request/{subject} |
Retrieve a pending CSR PEM |
PUT |
/certificate_request/{subject} |
Submit a new CSR (body: raw PEM) |
DELETE |
/certificate_request/{subject} |
Delete a pending CSR |
| Method | Path | Description |
|---|---|---|
GET |
/certificate/{subject} |
Retrieve a signed certificate PEM (ca returns the CA cert) |
| Method | Path | Description |
|---|---|---|
POST |
/certificate_renewal |
Renew an existing certificate; body: raw CSR PEM; returns new certificate PEM |
Requires a valid CA-signed client certificate. The CSR Common Name must match the authenticated client CN — an agent can only renew its own certificate, not another's. The new certificate is issued immediately without entering the pending-CSR queue or autosign evaluation.
| Method | Path | Description |
|---|---|---|
POST |
/sign |
Sign one or more CSRs; body: {"certnames":["a","b"]} |
POST |
/sign/all |
Sign every pending CSR |
| Method | Path | Description |
|---|---|---|
PUT |
/clean |
Revoke + delete multiple certificates in bulk; body: {"certnames":["a","b"]} |
Response:
{ "cleaned": ["a.example.com"], "not-found": ["missing.example.com"], "clean-errors": [] }| Method | Path | Description |
|---|---|---|
GET |
/certificate_revocation_list/ca |
Download the current CRL PEM |
PUT |
/certificate_revocation_list/ca |
Re-sign the CRL with a fresh validity window (preserves all revocations); admin-only. Returns the new CRL PEM |
| Method | Path | Description |
|---|---|---|
GET |
/expirations |
CA cert and CRL expiry dates |
| Method | Path | Description |
|---|---|---|
POST |
/generate/{subject} |
Generate key + cert server-side; optional ?dns=alt.name. Key algorithm follows --leaf-key-algo / --leaf-key-size (default: RSA 2048) |
Response:
{ "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...", "certificate": "-----BEGIN CERTIFICATE-----\n..." }| Method | Path | Description |
|---|---|---|
POST |
/ocsp |
RFC 6960 OCSP request; body is DER-encoded OCSPRequest |
GET |
/ocsp/{request} |
RFC 6960 GET form; {request} is standard or URL-safe base64-encoded DER |
Both paths are also served under /puppet-ca/v1/. Responses are signed by the CA key directly (Content-Type: application/ocsp-response). GET responses include Cache-Control: max-age=…, public; requests carrying a nonce bypass the cache. The AIA extension is embedded in issued certificates when --ocsp-url is set.
These endpoints are served at bare paths only (no /puppet-ca/v1 prefix) and require no client certificate.
| Method | Path | Description |
|---|---|---|
GET |
/healthz/live |
Liveness probe: always 200 while the process is running |
GET |
/healthz/ready |
Readiness probe: 200 once the CA is initialised, 503 before |
GET |
/healthz/startup |
Startup probe: delegates to the readiness check |
Response body: {"status":"ok"} (200) or {"status":"not_ready"} (503).
When mTLS is enabled (both --tls-cert and --tls-key set), each endpoint requires a minimum client certificate tier:
| Tier | Required client cert | Endpoints |
|---|---|---|
| Public | None | GET /healthz/*, GET /certificate/{subject}, GET /certificate_revocation_list/ca, PUT /certificate_request/{subject}, GET /expirations, POST /ocsp, GET /ocsp/{request} |
| Any client | Any CA-signed cert | GET /certificate_status/{subject} (public with --allow-public-status), POST /certificate_renewal |
| Self or admin | Cert CN matches path subject, OR cert is admin | GET /certificate_request/{subject} |
| Admin | Cert is admin (see below) | PUT /certificate_status/{subject}, DELETE /certificate_status/{subject}, DELETE /certificate_request/{subject}, GET /certificate_statuses/*, POST /sign, POST /sign/all, POST /generate/{subject}, PUT /clean, PUT /certificate_revocation_list/ca |
In plain HTTP mode (no TLS), all endpoints are accessible without authentication.
Note:
GET /certificate_status/{subject}requires a CA-signed client certificate by default. Use--allow-public-statusto make it public for environments where bootstrapping agents need to poll status before obtaining a client certificate. The response exposes state, fingerprint, serial number, and authorization extensions.
A client certificate is considered an admin credential if either condition is met:
- CN allow list: the certificate's Common Name appears in the
--puppet-servercomma-separated list or in the file pointed to by--puppet-server-file(one CN per line;#comments and blank lines ignored). Both sources can be used simultaneously; their CNs are merged. pp_cli_authextension: the certificate carries the Puppet authorization extension OID1.3.6.1.4.1.34380.1.3.39with the UTF8String value"true". OpenVox Server embeds this extension in its own certificate by default, so thepuppetserver caCLI can authenticate without being listed by CN.
The pp_cli_auth check is enabled by default. Disable it with --no-pp-cli-auth (or no_pp_cli_auth: true in the config file) if you prefer strict CN-only authorization.
OID source:
lib/puppet/ssl/oids.rb
By default, the CA private key is stored as unencrypted PEM at <cadir>/private/ca_key.pem.
Enable --encrypt-ca-key to encrypt the key at rest using AES-256-GCM with an Argon2id-derived key.
- The private key is marshalled to PKCS#8 DER, then encrypted with AES-256-GCM.
- The encryption key is derived from a passphrase using Argon2id (time=3, memory=64 MiB, threads=4).
- The encrypted key is stored as a PEM block with type
ENCRYPTED PRIVATE KEY. - On startup, the key is decrypted into memory and used for all signing operations.
--ca-key-passphrase-file: reads the first line of the specified file.PUPPET_CA_KEY_PASSPHRASEenvironment variable: avoids CLI/proc/cmdlineexposure.- Auto-generated: if no passphrase source is configured, a cryptographically random
passphrase is generated and saved to
<cadir>/private/.ca_key_passphrase(mode0600). The path is logged at startup so operators know where it is.
# Bootstrap with encryption (auto-generated passphrase):
openvox-ca --cadir /var/lib/puppet-ca --encrypt-ca-key
# Bootstrap with an explicit passphrase file:
echo "my-secret-passphrase" > /etc/puppet-ca/key-passphrase
chmod 600 /etc/puppet-ca/key-passphrase
openvox-ca --cadir /var/lib/puppet-ca --encrypt-ca-key \
--ca-key-passphrase-file /etc/puppet-ca/key-passphrase
# Or via environment variable:
export PUPPET_CA_KEY_PASSPHRASE="my-secret-passphrase"
openvox-ca --cadir /var/lib/puppet-ca --encrypt-ca-key
# openvox-ca-ctl setup also supports encryption:
openvox-ca-ctl setup --cadir /var/lib/puppet-ca --encrypt-ca-keyExisting CAs with unencrypted keys continue to work without changes. The --encrypt-ca-key
flag only affects new CA bootstraps. Loading transparently handles both encrypted and
unencrypted PEM files.
Encrypting the CA key at rest protects against offline exfiltration. If an attacker obtains the key file from a backup, disk image, or volume snapshot, the key is unusable without the passphrase. It does not protect against a live host compromise where the attacker can read the passphrase source or dump the process memory.
For stronger protection, consider hardware security modules (HSM) via PKCS#11; see Planned: PKCS#11 / HSM support below.
A future enhancement will add PKCS#11 support so the CA private key can be held in a hardware security module (HSM), TPM, or software token (e.g. SoftHSM2). The key would never leave the token. Only signing operations would be delegated via the PKCS#11 API.
The implementation path is straightforward because the CA already stores its key as a
crypto.Signer interface (internal/ca/ca.go). A PKCS#11-backed signer would implement
the same interface, requiring no changes to the signing, revocation, or OCSP code paths.
Planned design:
- A
--ca-key-providerflag:file(default, current behaviour) orpkcs11 - For
pkcs11: a PKCS#11 module URI or library path, slot/token label, and PIN (via file or env var, same pattern as--ca-key-passphrase-file) - Integration with p11-kit for module discovery, allowing operators to configure the PKCS#11 backend (SoftHSM2, TPM2 PKCS#11, cloud KMS bridges, Nitrokey, YubiHSM, etc.) via the system p11-kit configuration rather than hardcoding library paths
- CGo dependency for the PKCS#11 C bindings (build-time opt-in)
This is tracked as a separate feature. Contributions welcome.
The server tracks the rate of destructive operations (certificate revocation and deletion) per authenticated client. When a single client exceeds 5 destructive operations per minute, a warning is emitted to the structured log:
level=WARN msg="High rate of destructive operations detected" client=admin.example.com operation=revoke
This is a detective control, not a preventive one. It does not block the operation, but alerts operators to potentially anomalous administrative activity. Operators should:
- Forward
openvox-calogs to a centralized log aggregator (e.g. Loki, Elasticsearch, Splunk) - Create alerts on
"High rate of destructive operations"log messages - Investigate any alerts promptly. A burst of revocations may indicate a compromised admin certificate or an operational error
- Consider whether the
--allow-listshould be tightened if unexpected clients appear in these warnings
The threshold (5 ops/minute) is a sensible default for environments where bulk revocation is uncommon. Future versions may make this configurable.
openvox-ca-ctl mirrors the puppet cert / puppetserver ca subcommands and communicates with a running openvox-ca server over HTTP(S).
--config "" Path to YAML config file (auto-detected at /etc/puppet-ca/ctl.yaml)
--server-url https://localhost:8140 openvox-ca server URL
--ca-cert "" CA cert PEM for TLS verification (omit to skip verify)
--client-cert "" Client certificate PEM for mTLS
--client-key "" Client private key PEM for mTLS
--verbose Enable debug logging
Global flags may be placed before or after the subcommand name.
All global flags can be set via a YAML config file or environment variables. Precedence (highest → lowest): CLI flag → environment variable → config file → built-in default.
The config file is located by checking, in order:
--config /path/to/ctl.yaml(explicit flag)PUPPET_CA_CTL_CONFIGenvironment variable/etc/puppet-ca/ctl.yaml(auto-detected if the file exists)
Example /etc/puppet-ca/ctl.yaml:
server_url: https://openvox-ca.example.com:8140
ca_cert: /etc/puppetlabs/puppet/ssl/ca/ca_crt.pem
client_cert: /etc/puppetlabs/puppet/ssl/certs/puppet-master.pem
client_key: /etc/puppetlabs/puppet/ssl/private_keys/puppet-master.pem
verbose: falseEnvironment variables:
| Flag | Environment variable |
|---|---|
--server-url |
PUPPET_CA_CTL_SERVER_URL |
--ca-cert |
PUPPET_CA_CTL_CA_CERT |
--client-cert |
PUPPET_CA_CTL_CLIENT_CERT |
--client-key |
PUPPET_CA_CTL_CLIENT_KEY |
--verbose |
PUPPET_CA_CTL_VERBOSE |
# List pending CSRs
openvox-ca-ctl list
# List all certificates (signed, revoked, requested)
openvox-ca-ctl list --all
# Sign a pending CSR
openvox-ca-ctl sign --certname agent.example.com
# Sign all pending CSRs
openvox-ca-ctl sign --all
# Revoke a certificate
openvox-ca-ctl revoke --certname agent.example.com
# Revoke + delete cert and CSR
openvox-ca-ctl clean --certname agent.example.com
# Re-sign the CRL with a fresh validity window (preserves all revocations)
openvox-ca-ctl reissue-crl
# Generate a server-side key+cert pair (key saved to ./agent.example.com_key.pem)
openvox-ca-ctl generate --certname agent.example.com
openvox-ca-ctl generate --certname agent.example.com --dns alt.example.com --out-dir /etc/ssl
# Bootstrap a new CA offline (no server required)
openvox-ca-ctl setup --cadir /etc/puppetlabs/puppet/ssl --hostname puppet.example.com
# Import an external CA cert/key offline
openvox-ca-ctl import \
--cadir /etc/puppetlabs/puppet/ssl \
--cert-bundle ca_cert.pem \
--private-key ca_key.pem \
--crl-chain ca_crl.pem # optional; a new CRL is generated if omitted
# Migrate an entire CA between storage backends offline (any pair of backends:
# filesystem, sqlite, postgres, mysql, etcd, redis/valkey). Each backend is
# described by a normal openvox-ca config file. Refuses a non-empty destination
# unless --force.
openvox-ca-ctl migrate \
--source-config /etc/puppet-ca/filesystem.yaml \
--dest-config /etc/puppet-ca/postgres.yamlsetup, import and migrate operate directly on storage. No running server is needed.
See docs/storage-backends.md for migration details.
A Dockerfile and compose.yml are provided for development and integration testing.
# Build images and run the full integration test suite
mage test:integCompose
# integCompose + concurrency/correctness tests (DO_LOAD=true)
mage test:loadCompose
# k6 load test suite: correctness, throughput benchmarks, saturation ramp
mage test:bench
# Full Puppet stack: CA (TLS) + WEBrick master + OpenVoxDB + agent
mage test:puppettest:integCompose and test:loadCompose use compose.yml, the canonical integration test suite. It runs two containers on an isolated network (openvox-ca + test-runner) and exercises the full API in TAP format across 21 test groups:
| Group | Coverage |
|---|---|
| 1 | Endpoint smoke tests (health probes, CA cert, CRL, 404s, expirations) |
| 2 | Full CSR lifecycle: submit → sign → verify → revoke → re-register; issue #8 assertions (no Netscape Comment OID, random serial ≥16 hex digits, CRL Distribution Point present, authorization_extensions field, CSR deleted after signing) |
| 3 | openvox-ca-ctl sign --all (bulk signing) |
| 4 | POST /generate (server-side key+cert generation) |
| 5 | GET /certificate_statuses?state= filter; openvox-ca-ctl list / list --all |
| 6 | cert_ttl custom validity via PUT /certificate_status |
| 7 | subject_alt_names field in status responses |
| 8 | CSR CN mismatch rejection (400) |
| 9 | Error cases: invalid subjects, bad JSON, conflict (409), BasicConstraints CA:TRUE rejection |
| 10 | PUT /clean bulk revoke+delete: success, not-found, and error buckets |
| 11 | Protocol features: bare paths, /puppet-ca/v1/ prefixed paths |
| 12 | openvox-ca-ctl offline subcommands: setup (bootstrap new CA) and import (external CA cert/key/CRL) |
| 13 | POST /sign and POST /sign/all bulk HTTP signing API |
| 14 | Concurrency / load tests (opt-in via DO_LOAD=true / mage test:loadCompose) |
| 15 | OCSP: good/revoked status, nonce handling, cache invalidation on revoke, malformed request (400) |
| 16 | Autosign modes: true, glob-pattern file, executable plugin |
| 17 | Config drivers: env vars, config file |
| 18 | pp_cli_auth mTLS: Phase 1 bootstraps certs (loopback HTTP); Phase 2 asserts pp_cli_auth cert reaches admin endpoints while plain cert is denied |
| 19 | openvox-ca-ctl error paths: revoke/clean/sign/generate against non-existent or duplicate subjects; arg validation; --dns SAN delivery; full mTLS via --ca-cert/--client-cert/--client-key; unreachable server |
| 20 | Migration from Puppet Server CA: import CA cert/key/CRL via openvox-ca-ctl import, copy pre-existing signed certs, verify fetch/sign/revoke/list all work on the migrated CA |
| 21 | POST /certificate_renewal over mTLS: agent renews its own certificate; CN-mismatch renewal rejected |
test:bench uses compose-bench.yml (autosign=true, k6 load runner).
test:puppet uses compose-puppet.yml, a five-service stack that validates end-to-end catalog compilation, PuppetDB reporting, exported resources, and CRL revocation using a real OpenVox 8 agent and WEBrick puppet master. The CA runs with genuine TLS (a cert with CN=openvox-ca signed by the CA itself); all inter-service traffic verifies it.
test:migration uses compose-migration.yml, which starts a real VoxPupuli Puppet Server (voxpupuli/puppetserver:latest) to create a genuine Puppet CA, then imports its CA material into openvox-ca using openvox-ca-ctl import and verifies the full migration path: old certs are fetchable, new certs can be signed, migrated certs can be revoked and cleaned.
The k6 script (test/load.js) runs two concurrent scenarios:
- reads -- hammers GET /certificate/ca, CRL, and expirations; ramps to 200 VUs
- workflow -- POST /generate → GET status → GET cert → DELETE; ramps to 50 VUs (CPU-bound on RSA key generation)
Thresholds that fail the run: error rate ≥ 1%, read p95 ≥ 500 ms, workflow p95 ≥ 5 s.
# Run all unit tests
mage test:unit
# Format, vet, and tidy modules
mage dev:check
# Run integration tests using the compose stack
mage test:integCompose
# Run the full Puppet stack (CA TLS + WEBrick master + OpenVoxDB + agent)
mage test:puppet
# Run k6 load tests (correctness + throughput + saturation) via compose
mage test:bench| Content | Mode |
|---|---|
| Directories | 0750 |
| Private keys | 0600 |
| CRL file | 0600 |
| Public data (certs, CSRs, inventory) | 0644 |
The user running openvox-ca must own (or have write access to) --cadir.