This page covers how the CA private key is protected while the CA is running and at rest, the options for moving key custody off the CA host entirely, the detective control that watches for anomalous destructive operations, and where inventory integrity's own key lives.
By default openvox-ca runs as three processes, and only one of them ever
loads the CA private key:
| Process | Holds the key | Listens on the network |
|---|---|---|
| launcher | no | no |
openvox-ca [signer] |
yes | no |
openvox-ca [frontend] |
no | yes |
The frontend serves every API request and never has the key in its address
space; when it needs a signature it sends the digest to the signer over a
pre-connected Unix socketpair and gets the signature back. So a memory-disclosure
bug in the HTTP layer — the part exposed to agents — cannot leak the key, because
the key is not there to leak. --single-process collapses all three into one and
gives that up; it exists for containers and debugging.
The two children inherit two file descriptors from the launcher:
| Descriptor | Contents |
|---|---|
| fd 3 | this child's end of the socketpair |
| fd 4 | read end of a pipe holding a per-start pre-shared key |
Before the first signing call the two ends run a mutual challenge-response over that pre-shared key: each proves knowledge of it to the other, with distinct domain-separation labels per direction. A process that somehow obtained a leaked socketpair descriptor can impersonate neither side, and a child whose fd 4 is not the launcher's pipe refuses to start rather than proceeding unauthenticated.
The pre-shared key travels over a pipe, not the environment. A process's exec-time environment stays readable at
/proc/<pid>/environfor its whole lifetime —os.Unsetenvonly mutates the process's own copy — and is captured verbatim by crash-dump tooling such assystemd-coredump. A pipe is consumed once and leaves no such residue.Do not run a role process by hand with a descriptor open at fd 3 or fd 4. The launcher replaces both numbers in the children it spawns, so a descriptor your supervisor or wrapper script leaks is harmless in the default topology. It matters when you set
PUPPET_CA_ROLEyourself:execvepreserves every descriptor not marked close-on-exec, so whatever your shell left at fd 4 is what the process finds there. A descriptor that is not a pipe is refused immediately. A pipe cannot be told from the launcher's by inspection, so it gets ten seconds to produce a valid pre-shared key before the process gives up and says so — and fails at once if it is already at end-of-file or carries anything else.
The same isolation is why the shipped systemd unit sets LimitCORE=0 and
withholds $NOTIFY_SOCKET from the signer — see
running under systemd.
Every signature the CA makes crosses the socketpair to this one process: certificate issuance, CRL re-signing, and OCSP responses. Isolation decides where the key lives; on its own it does nothing to limit how much work reaches it.
OCSP signing does not serialise, and /ocsp is unauthenticated. Issuance
holds the CA's process-wide lock across its signing call, so it proceeds at
roughly one round trip at a time. The OCSP responder does not
(#197) — that was the
point of the change — so concurrent OCSP requests become concurrent signer
round trips. The endpoint is public by design (verifiers query it before they
hold a client certificate), the only rate limiter in the server applies to CSR
submission, and a response that misses the cache signs. An RFC 8954 nonced
request misses on every request.
ca_signing_concurrency is the aggregate cap
(#274). It bounds all
three paths together, because they share one key, and it defaults to
max(4, GOMAXPROCS) — sized for exactly this topology, since signing is
CPU-bound inside the signer child and beyond the CPU count extra concurrency
buys latency and memory rather than throughput. Issuance and CRL re-signing
queue for a slot; the OCSP responder is refused with RFC 6960 tryLater, which
costs no key work because a non-success OCSP response carries no signature. See
bounding CA-key signing.
RemoteSigner.Sign waits at most two minutes. The OpenBao Transit path
bounds every call by its login timeout; the isolated signer's RPC used to bound
nothing at all, so a signer child that stopped answering blocked its caller
indefinitely. It now gives up and returns an error, which is what lets a
signing slot come back.
That deadline is a backstop, not a tuning, and it bounds this caller's wait rather than the signer's work: the RPC layer has no cancellation, so an abandoned call leaves the child still signing and its reply is discarded. If you have raised OpenBao's login timeout substantially, note that the signer child's own call is bounded at roughly twice it — well inside two minutes at the 10s default, but worth knowing the ceiling exists.
An abandoned call also leaves a small entry in the RPC client's pending table,
which is only reclaimed if a reply eventually arrives. Against a signer that is
wedged rather than dead none ever does, so during such an incident the frontend
accumulates roughly a megabyte a day — bounded at ca_signing_concurrency
entries per two minutes, because every signature passes through that bound
first. It is a deliberate trade: without the deadline the frontend instead
accumulated stuck goroutines and never got its signing slots back. Nothing
needs doing about it beyond fixing the signer; the frontend cannot re-dial,
since the socketpair is inherited once at spawn, so restarting the service is
what clears it.
Fronting /ocsp with a cache or a proxy-level rate limit remains worthwhile
where it is reachable by untrusted clients: the cap stops CA-key work growing
without limit, but it does not stop the connections, handshakes and goroutines
that carry the requests.
Watch puppetca_ca_signing_shed_total and puppetca_ca_signing_in_flight
against puppetca_ca_signing_limit — sustained shedding while the signer has
capacity to spare means the limit is too low. A rise in issuance latency with
no rise in issuance rate is still the signal that OCSP is crowding issuance
out. See metrics.md.
The equivalent note for the OpenBao Transit backend is in the OpenBao Transit guide under "Performance and outage behaviour".
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, either delegate key custody to OpenBao entirely (available today; see OpenBao Transit-engine key custody below) or consider a hardware security module (HSM) via PKCS#11 (planned; see Planned: PKCS#11 / HSM support below).
--ca-key-provider openbao delegates the CA private key entirely to an
OpenBao Transit secrets engine key: the key never exists inside
any openvox-ca process at all, on disk or in memory — only a digest crosses the wire
to be signed. This works identically whether openvox-ca runs as a plain systemd
service (AppRole or a static token file) or as a Kubernetes pod authenticating via its
own ServiceAccount (Kubernetes auth) — with no Vault/OpenBao Agent sidecar required:
openvox-ca maintains its own OpenBao token lifecycle, proactively renewing it and
re-authenticating from source credentials whenever renewal fails.
Every existing storage backend keeps working unmodified in this mode — OpenBao only ever supplants key custody, never CSR/certificate/CRL/inventory storage. See OpenBao Transit-engine CA key for full configuration reference and setup instructions.
Whatever holds the key, the CA can run as an intermediate under an external root
rather than as its own root: openvox-ca csr emits a signing request bound to
the key the configured provider holds, and openvox-ca import-ca-cert installs
the chain the parent signs. No key material is passed on the command line at any
point, which is what lets the same procedure work for a Transit key that never
leaves the vault. See offline subcommands on the server
binary.
This integration is built and tested against OpenBao specifically, against current OpenBao releases. It should also work against HashiCorp Vault, since Vault's Transit engine, AppRole/Kubernetes auth methods, and Go client API are what OpenBao forked from and remains wire-compatible with — but Vault is not part of the test matrix, so this is currently unverified. Compatibility bug reports (and fixes) for Vault are welcome.
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.
It would slot in as a third --ca-key-provider value, alongside file (default) and
openbao.
Planned design:
--ca-key-provider 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.cn=admin.example.com client.domain="this CA" operation=revoke
A client is identified by its common name and the trust domain that vouched for
it. With client_ca
configured, an ops-admin issued by a partner CA is therefore counted separately
from an ops-admin this CA issued: they are different principals, so neither can
spend the other's allowance or raise an alert against it.
Upgrading: the
clientlog field has changed shape. It used to be a flat string holding the common name —client=admin.example.com. It is now a group, rendering as the two fields shown above,client.cnandclient.domain(clientbecomes an object under the JSON handler). Every log line in the API that names a client changed with it, not only this one, and three lines that previously used a barecnkey now use the same group.This breaks queries keyed on the old field. A SIEM rule, log-based alert, Loki selector or
grepmatchingclient=<value>will stop matching after the upgrade — and it fails quietly, because the line is still emitted and still carries the name. Update such queries toclient.cnbefore upgrading. The message text is unchanged, so alerts keyed on the message alone are unaffected.The reason for the change is that a common name is not an identity once more than one issuer is trusted: two CAs may each have an
ops-admin, and a record naming only the name cannot say which of them acted.
client.domaintakes one of three shapes.this CAfor a certificate we issued;client_ca "<name>"— quoted — for one attributed to a configured entry; andunattributedwhere the request never passed through the authorisation middleware, so nothing vouched for the name. The last is not an error and is the normal value on a deployment with no mTLS configured, but on one that does have it, anunattributedrecord is a request that reached a handler without being attributed, and is worth looking at.
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 - Create alerts on
"Issued certificate carrying a Puppet authorisation extension"too. That one is emitted byopenvox-ca generate --pp-cli-auth(see operator-cli.md) and marks the minting of a CA administrator credential — the only record that distinguishes one from an ordinary node certificate, since the inventory line keeps the Puppet-compatible format and says nothing about the grant. It is the counterpart to the alert above: this one fires when the privilege is handed out, that one when it is used destructively - Investigate any alerts promptly. A burst of revocations may indicate a compromised admin certificate or an operational error
- Consider whether the allow list that granted the client should be tightened
if unexpected clients appear in these warnings. Read
client.domainfirst: it names which one.this CAmeans--puppet-serveror--puppet-server-file;client_ca "<name>"means that entry's ownadmin_cns, or itsallow_pp_cli_authif the certificate carries the extension — tightening the wrong one changes nothing and leaves the grant in place
The threshold (5 ops/minute) is a sensible default for environments where bulk revocation is uncommon. Future versions may make this configurable.
The certificate inventory has its own integrity control — an HMAC-SHA256 over
the inventory, or a hash chain on backends that store it as rows — keyed by
hmac_key. It is documented, with its threat model, in
inventory-store.md.
It is mentioned here because it is a key-custody question as much as an
integrity one, and the custody choice is the opposite of the one this page makes
for the CA private key. hmac_key lives in the same storage backend as the
inventory it protects, behind the same permissions. An attacker who can write
the inventory can usually also read the key and recompute a valid MAC, so the
control is aimed at accidental corruption and at tampering that cannot reach the
key — not at an adversary with write access to the store. Unlike the CA private
key, there is at present no option to move hmac_key outside that boundary.
Whether that should change is an open question, and moving the key would close only part of the gap: the cheaper bypass needs no key at all. The trade-offs are set out in the threat model linked above.