feat(agent): token-bootstrapped mTLS enrollment; remove .vagent + handshake (VA-67) #77

Merged
David merged 1 commit from feat/VA-67-token-enroll-mtls into main 2026-06-28 14:53:09 +02:00
Owner

Summary

Implements VA-67: the agent now bootstraps over the server's mTLS port with a one-time enrollment certificate, obtains a server-signed client certificate, and connects with it thereafter. The agentcore.c challenge-response handshake and the *.vagent seed are removed. The server half landed in VS-63 (vervain-server PR #86, merged to main).

This is the security-first design ("Path C"): the agent's mTLS key is a fresh ECDSA P-256 key that is non-exportable and TPM-backed when a TPM is present (the TLS handshake and the CSR are signed inside the chip via a custom rustls::sign::SigningKey and an rcgen RemoteKeyPair), with a software keystore fallback. No legacy RSA handshake-signing key remains.

What changed

  • Enrollment (first run): generate an ECDSA P-256 keypair, build a PKCS#10 CSR with CN = node//<hex SHA-384 of the SPKI>, present the bootstrap chain [enroll_cert, enrollment_ca] over mTLS, POST /api/v1/enroll {csr_pem}, and persist the issued leaf + server CA + domain + group id (crates/vervain-agent/src/net.rs).
  • Steady state: connect WSS over mTLS to /api/v1/agent presenting the enrolled client cert, send a single AuthInfo frame, then idle. No handshake, no AuthConfirm.
  • Server TLS verification: PinnedServerVerifier checks the chain to the pinned server CA from the enrollment bundle and captures the leaf for the OOB-update trust anchor. The server name is intentionally not enforced (identity is the private-CA pin; the agent-port leaf carries no SAN). CapturingNoopVerifier is gone.
  • Identity (identity.rs + identity/tpm.rs): ECDSA P-256 software key or TPM-held ECC key; CSR + client-cert resolver for both backends; sign_handshake and the RSA self-signed path removed. The TPM backend now mints an ECC NIST-P256 key and signs in-chip.
  • Bootstrap inputs (bootstrap.rs): server domain + the four enrollment PEMs come from VERVAIN_* env vars (inline or _FILE). group_id defaults to all-zeros, overridable via VERVAIN_GROUP_ID.
  • Removed: parse_vagent_text / from_vagent_file / ServerSettings, the migrate feature, the seed import (settings.rs, config import, install --from <seed>), and the handshake wire structs (AuthRequest/AuthVerify/ServerIdHint + AgentCommand 1/2/4/5) from vervain-agent-protocol.
  • Installer: install --from bootstrap.json (the server mint bundle + domain) writes the PEMs into the config dir and exports VERVAIN_SERVER_DOMAIN + VERVAIN_*_FILE (plus VERVAIN_DB_KEY) in the unit. Mirrored on Windows via setx /M.
  • Config DB is now optional runtime tunables only and is never created at runtime, so ProtectSystem=strict (read-only config dir) is respected.

Acceptance criteria

  • A fresh agent connects with the bootstrap enrollment cert, generates a keypair + CSR (CN = node id), and redeems it at POST /api/v1/enroll over mTLS.
  • The agent stores the issued cert + server CA and reconnects via mTLS with the enrolled client cert, sending AuthInfo and idling (no handshake, no AuthConfirm).
  • No *.vagent file is read, required, or importable; parse_vagent_text and the seed-import paths are removed.
  • The AuthRequest / AuthVerify / ServerId / ServerID-pin handshake and CapturingNoopVerifier are removed from the agent.
  • The agent contacts only the mTLS port; it never reaches the browser/HTTPS/WSS frontend.

Testing

just pre-commit green (fmt, clippy --all-targets -D warnings, build, lib tests, Windows cross-compile, agent-gui, and the --features tpm swtpm test that exercises ECC-P256 key create / in-TPM sign / verify / reload).

Follow-up

  • VA-74: restore a one-command local dev loop (just dev) under mTLS-only enrollment; the now-inert dev-insecure plain-WS path should be removed or repurposed.

🤖 Generated with Claude Code

## Summary Implements VA-67: the agent now bootstraps over the server's mTLS port with a one-time enrollment certificate, obtains a server-signed client certificate, and connects with it thereafter. The `agentcore.c` challenge-response handshake and the `*.vagent` seed are removed. The server half landed in VS-63 (`vervain-server` PR #86, merged to `main`). This is the security-first design ("Path C"): the agent's mTLS key is a fresh **ECDSA P-256** key that is **non-exportable and TPM-backed when a TPM is present** (the TLS handshake and the CSR are signed inside the chip via a custom `rustls::sign::SigningKey` and an `rcgen` `RemoteKeyPair`), with a software keystore fallback. No legacy RSA handshake-signing key remains. ## What changed - **Enrollment (first run):** generate an ECDSA P-256 keypair, build a PKCS#10 CSR with CN = `node//<hex SHA-384 of the SPKI>`, present the bootstrap chain `[enroll_cert, enrollment_ca]` over mTLS, `POST /api/v1/enroll {csr_pem}`, and persist the issued leaf + server CA + domain + group id (`crates/vervain-agent/src/net.rs`). - **Steady state:** connect WSS over mTLS to `/api/v1/agent` presenting the enrolled client cert, send a single `AuthInfo` frame, then idle. No handshake, no `AuthConfirm`. - **Server TLS verification:** `PinnedServerVerifier` checks the chain to the pinned server CA from the enrollment bundle and captures the leaf for the OOB-update trust anchor. The server name is intentionally not enforced (identity is the private-CA pin; the agent-port leaf carries no SAN). `CapturingNoopVerifier` is gone. - **Identity (`identity.rs` + `identity/tpm.rs`):** ECDSA P-256 software key or TPM-held ECC key; CSR + client-cert resolver for both backends; `sign_handshake` and the RSA self-signed path removed. The TPM backend now mints an ECC NIST-P256 key and signs in-chip. - **Bootstrap inputs (`bootstrap.rs`):** server domain + the four enrollment PEMs come from `VERVAIN_*` env vars (inline or `_FILE`). `group_id` defaults to all-zeros, overridable via `VERVAIN_GROUP_ID`. - **Removed:** `parse_vagent_text` / `from_vagent_file` / `ServerSettings`, the `migrate` feature, the seed import (`settings.rs`, `config import`, `install --from <seed>`), and the handshake wire structs (`AuthRequest`/`AuthVerify`/`ServerIdHint` + `AgentCommand` 1/2/4/5) from `vervain-agent-protocol`. - **Installer:** `install --from bootstrap.json` (the server mint bundle + domain) writes the PEMs into the config dir and exports `VERVAIN_SERVER_DOMAIN` + `VERVAIN_*_FILE` (plus `VERVAIN_DB_KEY`) in the unit. Mirrored on Windows via `setx /M`. - **Config DB** is now optional runtime tunables only and is never created at runtime, so `ProtectSystem=strict` (read-only config dir) is respected. ## Acceptance criteria - [x] A fresh agent connects with the bootstrap enrollment cert, generates a keypair + CSR (CN = node id), and redeems it at `POST /api/v1/enroll` over mTLS. - [x] The agent stores the issued cert + server CA and reconnects via mTLS with the enrolled client cert, sending `AuthInfo` and idling (no handshake, no `AuthConfirm`). - [x] No `*.vagent` file is read, required, or importable; `parse_vagent_text` and the seed-import paths are removed. - [x] The `AuthRequest` / `AuthVerify` / `ServerId` / `ServerID`-pin handshake and `CapturingNoopVerifier` are removed from the agent. - [x] The agent contacts only the mTLS port; it never reaches the browser/HTTPS/WSS frontend. ## Testing `just pre-commit` green (fmt, `clippy --all-targets -D warnings`, build, lib tests, Windows cross-compile, agent-gui, and the `--features tpm` swtpm test that exercises ECC-P256 key create / in-TPM sign / verify / reload). ## Follow-up - VA-74: restore a one-command local dev loop (`just dev`) under mTLS-only enrollment; the now-inert `dev-insecure` plain-WS path should be removed or repurposed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(agent): token-enroll mTLS identity; drop .vagent + handshake
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 5m17s
Create release / Create release from merged PR (pull_request) Has been skipped
2f19ca946d
Bootstrap a fresh agent over the server's mTLS port: present the one-time enrollment certificate, POST a CSR (CN = node id) to /api/v1/enroll, store the server-issued client cert + server CA, then connect to /api/v1/agent with that cert and send a single AuthInfo frame. The agentcore.c challenge-response handshake (AuthRequest/AuthVerify/ServerId/AuthConfirm) and the ServerID SPKI pin are removed; identity is now the verified mTLS client certificate (server half: VS-63 / vervain-server PR #86).

The agent identity is a fresh ECDSA P-256 key, non-exportable and TPM-backed when a TPM is present (the TLS handshake and the CSR are signed inside the chip via a custom rustls SigningKey and rcgen RemoteKeyPair), with a software keystore fallback. The node id is SHA-384 of the SubjectPublicKeyInfo; the certificate CommonName is node//<hex node id>.

The server's TLS certificate is verified against the pinned server CA from the enrollment bundle (PinnedServerVerifier): the chain is checked to the pinned private root and the leaf captured for the OOB-update trust anchor; the server name is not enforced because identity is the CA pin and the agent-port leaf carries no SAN. CapturingNoopVerifier is gone.

Bootstrap inputs (server domain + the four enrollment PEMs) come from environment variables / files (crate::bootstrap), and the installer takes a JSON bootstrap bundle (install --from bootstrap.json) that it writes as unit Environment= pointers plus the PEM files. The *.vagent parser, ServerSettings, the seed import (settings.rs / config import / install --from <seed>), and the handshake wire structs are removed; the config DB is now optional runtime tunables only and is never created at runtime (ProtectSystem=strict keeps the config dir read-only).

The dev-insecure plain-WS dev path no longer applies under mTLS-only enrollment; restoring a one-command dev loop is tracked in VA-74.

#VA-67

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
David merged commit 989f14151a into main 2026-06-28 14:53:09 +02:00
David deleted branch feat/VA-67-token-enroll-mtls 2026-06-28 14:53:09 +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-agent!77
No description provided.