Skip to content

Add OpenBao Transit-engine CA key provider#89

Open
bootc wants to merge 6 commits into
mainfrom
feature/openbao-transit-signer
Open

Add OpenBao Transit-engine CA key provider#89
bootc wants to merge 6 commits into
mainfrom
feature/openbao-transit-signer

Conversation

@bootc

@bootc bootc commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

Adds an alternative CA key-custody mode backed by OpenBao's Transit secrets engine, so the CA private key never has to exist on disk (or in process memory) at all — openvox-ca only ever sends a digest to Bao for signing.

  • New internal/signer/openbao package: OpenBao API v2 client, AppRole/static-token-file/Kubernetes auth methods, background token lease renewal (TokenManager, built on NewLifetimeWatcher) with reactive re-authentication on 403, and a crypto.Signer/ca.KeyProvider implementation backed by Transit sign/keys.
  • Works both as a traditional service (AppRole or static token file) and natively in Kubernetes (ServiceAccount + Bao's Kubernetes auth method) — the client handles the whole token lifecycle itself (acquire, proactively renew, and reactively re-authenticate from source credentials), no sidecar/agent required, and credential files (secret_id, projected SA JWT) are re-read fresh on every login rather than cached at startup.
  • Fits the existing isolated-signer architecture unchanged: the Bao client/token live inside the openvox-ca [signer] child process, same trust boundary as a local private key today; the frontend keeps talking to it over the existing RPC with no changes.
  • New ca.KeyProvider interface generalises key custody (local PEM blob vs. Bao-backed), and a new optional ca.KeyVerifier interface lets a key implementation assert it still matches its source of truth:
    • At CA load, the existing cert/key match check (RSA and ECDSA) already catches a Bao key that doesn't match the stored CA certificate.
    • Additionally, before every certificate issuance, if the CA key supports live verification (OpenBao Transit does), it's re-checked against OpenBao so a key rotated at Bao out from under a running CA process is caught immediately rather than silently issuing certificates under a key nobody can prove is still the CA's.
    • This works through the isolated-signer RPC boundary too (frontend → RemoteSigner → signer process), not just in single-process mode.
  • New --openbao-* flags / openbao.* config keys / PUPPET_CA_OPENBAO_* env vars, docs/openbao-transit.md, README updates, and a compose-backends-openbao.yml + mage test:backendsOpenBao CI job running the full flow against a real OpenBao container.

Explicitly out of scope for this PR (see docs/openbao-transit.md and prior discussion): BYOK/wrapped import of an existing CA key into Transit, openvox-ca-ctl setup flags for offline Bao-targeted bootstrap, and automated Kubernetes-auth CI testing (no kind/k3d infra in this repo yet) — Kubernetes auth has full unit coverage but is only verified live manually.

Test plan

  • go build ./..., go vet ./... (incl. -tags mage), gofmt -l . clean
  • go test ./... — full suite passing across all packages
  • mage test:backendsOpenBao — live integration run against a real OpenBao 2.5.5 container (AppRole auth, sign/verify, key generate-then-load, rotation detection)
  • CI workflow updated with a backends-openbao job

🤖 Generated with Claude Code

@bootc
bootc requested a review from trevor-vaughan July 4, 2026 18:28
@bootc
bootc force-pushed the feature/openbao-transit-signer branch from 49c3809 to 86f3145 Compare July 4, 2026 19:25
bootc and others added 6 commits July 18, 2026 01:24
Adds --ca-key-provider openbao, delegating the CA private key to an
OpenBao Transit secrets engine key instead of a local PEM file: the
key never exists inside any openvox-ca process. Supports AppRole,
static token file, and Kubernetes auth (native, no sidecar), with
proactive token renewal and reactive re-authentication on a 403 so a
revoked or expired token self-heals rather than requiring a restart.

internal/ca gains a KeyProvider interface alongside the existing
ExternalSigner, wired into bootstrapCA/loadCA; the frontend process and
signer RPC protocol are unchanged. Every existing storage backend keeps
working unmodified -- OpenBao only ever supplants key custody.

The OpenBao integration lives under internal/signer/openbao, alongside
the existing local-key isolation signer; all OpenBao-specific config
is grouped under its own top-level "openbao" YAML key / --openbao-*
flags rather than a flat prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds compose-backends-openbao.yml + mage test:backendsOpenBao,
mirroring the existing Postgres/MySQL/Redis-Go backend targets: brings
up a throwaway OpenBao dev server (2.5.5 in CI), configures its
transit engine and a least-privilege-scoped AppRole via the same SDK
internal/signer/openbao uses, runs the new openbao_integration Go
suite, then tears it down. Wired into CI as backends-openbao alongside
the other backend jobs.

The suite (internal/signer/openbao/openbao_integration_test.go) signs
a digest through a real Transit key and verifies it, and exercises the
create-key-on-first-boot convenience path -- complementing the
existing httptest-based unit tests, which fake the wire protocol.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
openvox-ca already refused to start if the CA certificate's public key
didn't match the loaded key (internal/ca's loadCA, covering local keys
and OpenBao alike); this adds the live counterpart for OpenBao: a
CA.KeyVerifier interface, implemented by internal/signer/openbao's
Signer, re-fetches the Transit key's current public component and
compares it against what was loaded at startup before every
certificate issuance. A key rotated directly at OpenBao (bao write -f
transit/keys/<name>/rotate) while openvox-ca is already running is
caught at the next issuance -- refusing the request -- instead of
silently producing a certificate that fails verification later.

The isolated-signer RPC protocol (internal/signer) gains a matching
VerifyCurrentKey method so this works whether or not key isolation is
in use: RemoteSigner forwards the check to the signer process, which
is the one actually holding the OpenBao-backed key. Keys that can't
rotate independently of the process (a local PEM file) don't implement
the interface, so the check is a no-op for them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The `token` auth method authenticated by returning the raw lookup-self
secret to the LifetimeWatcher. A lookup-self response carries the token's
lifetime in its Data map, not in the auth/lease envelope the watcher
renews on, so the watcher saw an empty LeaseID and no Auth block, treated
the token as un-renewable, and fired DoneCh the instant it started. run()
then re-authenticated (another lookup-self) and started a fresh watcher
that again ended immediately -- a tight loop of lookup-self calls and
"renewal ended" warnings hammering OpenBao, with no actual renewal. Only
the token method was affected; AppRole/Kubernetes return a proper auth
secret from their /login exchange.

Re-shape the lookup-self result into an auth secret carrying the real TTL
and renewable flag, so the watcher renews a renewable token and, for a
non-renewable one, waits until near expiry before re-reading the file. Add
a minReauthInterval floor in run() as a backstop so any watcher that still
ends immediately (e.g. a non-renewable token with no expiry, TTL 0)
settles into a steady re-auth cadence rather than a busy loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…back

ca_key_provider was only ever compared against "openbao" (UsesOpenBao);
any other value, including a typo like "openba" or the anticipated-but-
unimplemented "pkcs11", silently fell through to local-file key custody.
For a setting whose whole purpose is to keep the CA private key off local
disk, that failure mode writes the key to disk exactly when the operator
asked for it to live elsewhere.

Add CAKeyProviderConfig.Validate, accepting only "", "file", and
"openbao", and call it during server startup validation so an
unrecognised provider is a hard error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the pre-issuance KeyVerifier check — and all of its plumbing —
with the verification x509.CreateCertificate already performs. Before every
issuance the frontend used to call VerifyCurrentKey, which under key
isolation was an extra RPC to the signer that re-fetched the Transit key's
public component from OpenBao and compared it to the value cached at load:
two sequential OpenBao round trips per issuance (verify then sign), held
under the CA mutex, plus a redundant RPC on every local-file issuance since
RemoteSigner unconditionally advertised the capability.

CreateCertificate already guards against exactly this, unconditionally and
in-process: it refuses if the signer's public key doesn't equal the parent
CA certificate's, and it re-verifies the signature the signer returned
against that public key before returning it. loadCA has pinned
c.CAKey.Public() to the CA certificate, so a Transit key rotated at OpenBao
out from under a running CA — cached Public() still matches the CA cert,
but OpenBao now signs with the new key — produces a signature that fails
that re-verification, and issuance errors instead of emitting a certificate
no verifier could validate. No extra round trip: the one Sign that issuance
already makes is the only OpenBao call.

Removes ca.KeyVerifier and its call site; signer.{VerifyKeyRequest,
VerifyKeyResponse,KeyVerifier}, Service.VerifyCurrentKey and
RemoteSigner.VerifyCurrentKey; and openbao.Signer.VerifyCurrentKey, along
with their tests. On the way past, openbao.Signer's inline 403 self-heal for
Sign (token revoked out-of-band → re-authenticate and retry once) is
factored into a small withReauth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bootc
bootc force-pushed the feature/openbao-transit-signer branch from 5ebb935 to 9d02e8d Compare July 18, 2026 00:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant