feat(server): enroll agents over the mTLS port via a dedicated enrollment sub-CA (VS-68) #87

Merged
David merged 1 commit from feat/VS-68-enroll-over-mtls into main 2026-06-28 03:17:05 +02:00
Owner

Lets agents bootstrap and enroll over the single agent mTLS port, never touching the browser/HTTPS frontend. Prerequisite for VA-67. Greenfield, so VS-63's browser-path token POST is replaced outright.

Design

A dedicated enrollment sub-CA (vervain-pki/src/enrollment_ca.rs) signed by the agent CA, so an enrollment-issued client cert chains to the same root the agent listener's WebPkiClientVerifier already trusts (no verifier change needed). Real agent leaves are issued directly by the agent CA (AGENT_CA_CN = VervainAgentCA); bootstrap leaves by the sub-CA (ENROLLMENT_CA_CN = VervainEnrollmentCA). The leaf issuer is what separates them; single-use + short TTL contains a leaked bootstrap cert.

Flow

  • Mint (POST /api/v1/enroll-tokens, site-admin session, browser listener) returns a bootstrap bundle {enroll_cert_pem, enroll_key_pem, enrollment_ca_pem, server_ca_pem, expires_at} and records a single-use token (carried in the cert CN enroll//<token>) in enroll_tokens. Admins mint from the browser; agents never do.
  • Enroll (POST /api/v1/enroll) moves onto the agent mTLS listener (build_agent_router). Body is {csr_pem} only; the token rides in the presenting bootstrap cert's CN. The handler requires the client cert to be issued by the enrollment sub-CA, atomically consumes the token, signs the CSR with the agent CA, returns {agent_cert_pem, ca_chain}.

Walls

  • agent_ws_handler rejects any leaf issued by the enrollment sub-CA (a bootstrap cert cannot be an agent identity).
  • The enroll endpoint rejects any cert NOT issued by the sub-CA (a real agent cert cannot re-enroll).

AgentClientCert::issuer_cn backs both. The enrollment CA is threaded through ServerConfig/AppState and ensured at boot in main.rs.

Agent contract (for VA-67)

The agent's whole bootstrap input is a domain (host:port) + the bundle above. It connects to the mTLS port with the bootstrap cert (presenting enrollment_ca_pem in its chain), POSTs its CSR (CN = node//<hash>), persists the returned cert + ca_chain, then reconnects with its real cert and sends one AuthInfo. It never reaches the browser/HTTPS path.

Tests

  • pki unit tests: sub-CA is signed by the agent CA / persisted / mints a token-CN ClientAuth leaf.
  • enroll.rs: mint-bundle contract (200 + bundle shape + enroll// CN; 401/403 gates).
  • agent_mtls.rs end to end: real mTLS enroll (bootstrap cert in, agent cert out signed by the agent CA), single-use rejection, a real agent cert refused at enroll, and a bootstrap cert refused on the control channel.
  • Full just pre-commit green (fmt, clippy -D warnings, build, 411 lib tests + integration).

Follow-ups

Server-pushed agent config / group assignment is VS-67; the dead insecureSkipHashCheck knob is VS-66.

🤖 Generated with Claude Code

Lets agents bootstrap and enroll over the single agent mTLS port, never touching the browser/HTTPS frontend. Prerequisite for VA-67. Greenfield, so VS-63's browser-path token POST is replaced outright. ## Design A dedicated **enrollment sub-CA** (`vervain-pki/src/enrollment_ca.rs`) signed by the agent CA, so an enrollment-issued client cert chains to the same root the agent listener's `WebPkiClientVerifier` already trusts (no verifier change needed). Real agent leaves are issued directly by the agent CA (`AGENT_CA_CN` = `VervainAgentCA`); bootstrap leaves by the sub-CA (`ENROLLMENT_CA_CN` = `VervainEnrollmentCA`). The leaf **issuer** is what separates them; single-use + short TTL contains a leaked bootstrap cert. ## Flow - **Mint** (`POST /api/v1/enroll-tokens`, site-admin session, browser listener) returns a bootstrap bundle `{enroll_cert_pem, enroll_key_pem, enrollment_ca_pem, server_ca_pem, expires_at}` and records a single-use token (carried in the cert CN `enroll//<token>`) in `enroll_tokens`. Admins mint from the browser; agents never do. - **Enroll** (`POST /api/v1/enroll`) moves onto the agent mTLS listener (`build_agent_router`). Body is `{csr_pem}` only; the token rides in the presenting bootstrap cert's CN. The handler requires the client cert to be issued by the enrollment sub-CA, atomically consumes the token, signs the CSR with the agent CA, returns `{agent_cert_pem, ca_chain}`. ## Walls - `agent_ws_handler` rejects any leaf issued by the enrollment sub-CA (a bootstrap cert cannot be an agent identity). - The enroll endpoint rejects any cert NOT issued by the sub-CA (a real agent cert cannot re-enroll). `AgentClientCert::issuer_cn` backs both. The enrollment CA is threaded through `ServerConfig`/`AppState` and ensured at boot in `main.rs`. ## Agent contract (for VA-67) The agent's whole bootstrap input is a domain (host:port) + the bundle above. It connects to the mTLS port with the bootstrap cert (presenting `enrollment_ca_pem` in its chain), POSTs its CSR (CN = `node//<hash>`), persists the returned cert + `ca_chain`, then reconnects with its real cert and sends one `AuthInfo`. It never reaches the browser/HTTPS path. ## Tests - pki unit tests: sub-CA is signed by the agent CA / persisted / mints a token-CN ClientAuth leaf. - `enroll.rs`: mint-bundle contract (200 + bundle shape + `enroll//` CN; 401/403 gates). - `agent_mtls.rs` end to end: real mTLS enroll (bootstrap cert in, agent cert out signed by the agent CA), single-use rejection, a real agent cert refused at enroll, and a bootstrap cert refused on the control channel. - Full `just pre-commit` green (fmt, clippy `-D warnings`, build, 411 lib tests + integration). ## Follow-ups Server-pushed agent config / group assignment is VS-67; the dead `insecureSkipHashCheck` knob is VS-66. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(server): enroll agents over the mTLS port via a dedicated enrollment sub-CA (VS-68)
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m16s
Create release / Create release from merged PR (pull_request) Has been skipped
22c74fbd3c
Lets agents bootstrap and enroll over the single agent mTLS port, never touching the browser/HTTPS frontend. Replaces VS-63's browser-path token POST (greenfield, no compatibility kept).

New enrollment sub-CA in vervain-pki (`enrollment_ca.rs`): a dedicated CA signed by the agent CA, so an enrollment-issued client cert chains to the same root the agent listener's `WebPkiClientVerifier` already trusts. `ensure_enrollment_ca` persists it (`enrollment-ca-cert-*`); `mint_enroll_cert` issues a short-lived single-use ClientAuth leaf whose CN is `enroll//<token>`. Real agent leaves are issued directly by the agent CA (`AGENT_CA_CN`); bootstrap leaves by the sub-CA (`ENROLLMENT_CA_CN`). The leaf issuer is what separates the two.

Mint (`POST /api/v1/enroll-tokens`, site-admin, browser listener) now returns a bootstrap bundle `{enroll_cert_pem, enroll_key_pem, enrollment_ca_pem, server_ca_pem, expires_at}` instead of an opaque token, and records the single-use token in `enroll_tokens`. Admins mint from the browser; agents never do.

Enroll (`POST /api/v1/enroll`) moves onto the agent mTLS listener (`build_agent_router`). The body is `{csr_pem}` only; the token rides in the presenting bootstrap cert's CN. The handler requires the client cert to be issued by the enrollment sub-CA, atomically consumes the token, signs the CSR with the agent CA, and returns `{agent_cert_pem, ca_chain}`.

Walls: `agent_ws_handler` rejects any leaf issued by the enrollment sub-CA (a bootstrap cert cannot be an agent identity); the enroll endpoint rejects any cert not issued by the sub-CA (a real agent cert cannot re-enroll). `AgentClientCert::issuer_cn` backs both checks.

The enrollment CA is threaded through `ServerConfig`/`AppState` and ensured at boot in `main.rs` alongside the agent CA.

Tests: pki unit tests (sub-CA signed by agent CA / persisted / mints a token-CN ClientAuth leaf). `enroll.rs` covers the mint-bundle contract (200 + bundle shape + `enroll//` CN, 401/403 gates). A new `agent_mtls.rs` end-to-end drives a real mTLS enroll (bootstrap cert in, agent cert out signed by the agent CA), single-use rejection, a real agent cert refused at enroll, and a bootstrap cert refused on the control channel.

#VS-68

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
David merged commit 3c86419e19 into main 2026-06-28 03:17:05 +02:00
David deleted branch feat/VS-68-enroll-over-mtls 2026-06-28 03:17:06 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-server!87
No description provided.