feat(server): agent CA, enrollment token, CSR-signing endpoint (VS-63) #83

Merged
Claude-Run merged 1 commit from feat/VS-63-agent-ca-enrollment into main 2026-06-27 19:41:23 +02:00
Member

Add the server side of token-bootstrapped agent enrollment, replacing the *.vagent mint + cert-pin handshake with a dedicated agent CA that issues mTLS client certs from agent CSRs.

vervain-pki: new agent_ca module. ensure_agent_ca generates (or reuses) a self-signed ECDSA P-256 agent CA (IsCa, KeyCertSign), persisted under <datapath>/agent-ca-cert-{public.crt,private.key}, distinct from the web-serving root from ensure_chain. AgentCa::sign_csr certifies the CSR's OWN public key (it does NOT regenerate a throwaway keypair, which is roci's sign_csr bug) and pins the leaf authoritatively: CN/SAN = node id from the CSR subject, ExtendedKeyUsage = ClientAuth, not a CA, validity bounded to 1 year.

vervain-db: new enroll_tokens table (schema v2) plus insert_enroll_token and consume_enroll_token. Consumption is a single atomic UPDATE ... WHERE consumed_at IS NULL AND expires_at > now whose affected-row count is the accept/reject decision, so a consumed, expired, or replayed token cannot succeed twice.

vervain-web: new enroll module wiring two routes. POST /api/v1/enroll-tokens (site-admin session) mints a random opaque single-use token with a 1-hour TTL and persists it. POST /api/v1/enroll (public, no client cert, since the enrolling agent has none yet) takes { token, csr_pem }, atomically consumes the token, signs the CSR with the agent CA, and returns { agent_cert_pem, ca_chain } where ca_chain is the server root the agent must trust to verify the server's TLS cert. The agent CA is threaded through ServerConfig/AppState and ensured at boot in main.rs.

Tests: agent-CA unit tests (self-signed/IsCa, persistence/reuse, distinct-from-web-root, certifies-the-CSR's-own-key, bounded validity, garbage rejected); db single-use/expired/unknown-token tests; and an end-to-end tests/enroll.rs covering admin mint, public redeem returning both PEM blocks, single-use rejection, and the 401/403 auth gates.

Scope note: acceptance criterion 5 (remove the AuthRequest/AuthVerify/ServerId handshake from the server) is intentionally NOT done here. It is mutually coupled with VS-62 (the agent mTLS listener), which is still open: VS-62 builds its WebPkiClientVerifier from this CA, and the handshake removal needs VS-62's listener to supply agent identity (the mTLS client cert) once the handshake is gone. Removing the handshake now would leave agent authentication with no replacement and break currently-working agent connections, since VA-67 (the agent client) also still speaks the handshake. The removal should land together with VS-62; that dependency is already linked from this issue.

#VS-63

Add the server side of token-bootstrapped agent enrollment, replacing the `*.vagent` mint + cert-pin handshake with a dedicated agent CA that issues mTLS client certs from agent CSRs. vervain-pki: new `agent_ca` module. `ensure_agent_ca` generates (or reuses) a self-signed ECDSA P-256 agent CA (`IsCa`, `KeyCertSign`), persisted under `<datapath>/agent-ca-cert-{public.crt,private.key}`, distinct from the web-serving root from `ensure_chain`. `AgentCa::sign_csr` certifies the CSR's OWN public key (it does NOT regenerate a throwaway keypair, which is roci's `sign_csr` bug) and pins the leaf authoritatively: CN/SAN = node id from the CSR subject, `ExtendedKeyUsage = ClientAuth`, not a CA, validity bounded to 1 year. vervain-db: new `enroll_tokens` table (schema v2) plus `insert_enroll_token` and `consume_enroll_token`. Consumption is a single atomic `UPDATE ... WHERE consumed_at IS NULL AND expires_at > now` whose affected-row count is the accept/reject decision, so a consumed, expired, or replayed token cannot succeed twice. vervain-web: new `enroll` module wiring two routes. `POST /api/v1/enroll-tokens` (site-admin session) mints a random opaque single-use token with a 1-hour TTL and persists it. `POST /api/v1/enroll` (public, no client cert, since the enrolling agent has none yet) takes `{ token, csr_pem }`, atomically consumes the token, signs the CSR with the agent CA, and returns `{ agent_cert_pem, ca_chain }` where `ca_chain` is the server root the agent must trust to verify the server's TLS cert. The agent CA is threaded through `ServerConfig`/`AppState` and ensured at boot in `main.rs`. Tests: agent-CA unit tests (self-signed/IsCa, persistence/reuse, distinct-from-web-root, certifies-the-CSR's-own-key, bounded validity, garbage rejected); db single-use/expired/unknown-token tests; and an end-to-end `tests/enroll.rs` covering admin mint, public redeem returning both PEM blocks, single-use rejection, and the 401/403 auth gates. Scope note: acceptance criterion 5 (remove the `AuthRequest`/`AuthVerify`/`ServerId` handshake from the server) is intentionally NOT done here. It is mutually coupled with VS-62 (the agent mTLS listener), which is still open: VS-62 builds its `WebPkiClientVerifier` from this CA, and the handshake removal needs VS-62's listener to supply agent identity (the mTLS client cert) once the handshake is gone. Removing the handshake now would leave agent authentication with no replacement and break currently-working agent connections, since VA-67 (the agent client) also still speaks the handshake. The removal should land together with VS-62; that dependency is already linked from this issue. #VS-63
feat(server): agent CA, enrollment token, CSR-signing endpoint (VS-63)
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 2m10s
Create release / Create release from merged PR (pull_request) Has been skipped
f5522816b7
Add the server side of token-bootstrapped agent enrollment, replacing the `*.vagent` mint + cert-pin handshake with a dedicated agent CA that issues mTLS client certs from agent CSRs.

vervain-pki: new `agent_ca` module. `ensure_agent_ca` generates (or reuses) a self-signed ECDSA P-256 agent CA (`IsCa`, `KeyCertSign`), persisted under `<datapath>/agent-ca-cert-{public.crt,private.key}`, distinct from the web-serving root from `ensure_chain`. `AgentCa::sign_csr` certifies the CSR's OWN public key (it does NOT regenerate a throwaway keypair, which is roci's `sign_csr` bug) and pins the leaf authoritatively: CN/SAN = node id from the CSR subject, `ExtendedKeyUsage = ClientAuth`, not a CA, validity bounded to 1 year.

vervain-db: new `enroll_tokens` table (schema v2) plus `insert_enroll_token` and `consume_enroll_token`. Consumption is a single atomic `UPDATE ... WHERE consumed_at IS NULL AND expires_at > now` whose affected-row count is the accept/reject decision, so a consumed, expired, or replayed token cannot succeed twice.

vervain-web: new `enroll` module wiring two routes. `POST /api/v1/enroll-tokens` (site-admin session) mints a random opaque single-use token with a 1-hour TTL and persists it. `POST /api/v1/enroll` (public, no client cert, since the enrolling agent has none yet) takes `{ token, csr_pem }`, atomically consumes the token, signs the CSR with the agent CA, and returns `{ agent_cert_pem, ca_chain }` where `ca_chain` is the server root the agent must trust to verify the server's TLS cert. The agent CA is threaded through `ServerConfig`/`AppState` and ensured at boot in `main.rs`.

Tests: agent-CA unit tests (self-signed/IsCa, persistence/reuse, distinct-from-web-root, certifies-the-CSR's-own-key, bounded validity, garbage rejected); db single-use/expired/unknown-token tests; and an end-to-end `tests/enroll.rs` covering admin mint, public redeem returning both PEM blocks, single-use rejection, and the 401/403 auth gates.

Scope note: acceptance criterion 5 (remove the `AuthRequest`/`AuthVerify`/`ServerId` handshake from the server) is intentionally NOT done here. It is mutually coupled with VS-62 (the agent mTLS listener), which is still open: VS-62 builds its `WebPkiClientVerifier` from this CA, and the handshake removal needs VS-62's listener to supply agent identity (the mTLS client cert) once the handshake is gone. Removing the handshake now would leave agent authentication with no replacement and break currently-working agent connections, since VA-67 (the agent client) also still speaks the handshake. The removal should land together with VS-62; that dependency is already linked from this issue.

#VS-63
David deleted branch feat/VS-63-agent-ca-enrollment 2026-06-28 00:52:03 +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!83
No description provided.