Files
certctl/internal/cli/est.go
T
shankar0123 9a50d9a2dc EST RFC 7030 hardening master bundle Phases 8-9: GUI ESTAdminPage
(Profiles + Recent Activity + Trust Bundle tabs) + CLI subcommand
family `certctl-cli est {cacerts,csrattrs,enroll,reenroll,
serverkeygen,test}` + 6 MCP tools.

Phase 8 — ESTAdminPage tabbed GUI:
- web/src/pages/ESTAdminPage.tsx mirrors SCEPAdminPage's three-tab
  surface. Profiles tab renders per-profile cards with auth-mode
  badges (mTLS / Basic / ServerKeygen), mTLS trust-anchor expiry
  countdown (good ≥30d / warn 7-30d / bad <7d / EXPIRED), 12-cell
  counter grid (success_simpleenroll/.../internal_error), and the
  admin-gated "Reload trust anchor" action. Recent Activity tab
  merges the four EST audit actions (est_simple_enroll +
  est_simple_reenroll + est_server_keygen + est_auth_failed) across
  four parallel useQuery calls with chip filters for All/Enrollment/
  Re-enrollment/ServerKeygen/AuthFailure. Trust Bundle tab renders
  per-mTLS-profile cert subjects + expiries.
- M-009 useTrackedMutation guard: every mutation routes through
  the tracked hook so audit/progress hooks fire.
- Page-level admin gate renders "Admin access required" banner for
  non-admin callers + skips underlying API requests so the server
  never sees a 403-prone request. Server-side enforcement is the
  M-008 admin gate; this is a UX hint.
- Wired into web/src/main.tsx at /est; nav link added to Layout.tsx.
- New web/src/api/types.ts types ESTStatsSnapshot +
  ESTTrustAnchorInfo + ESTProfilesResponse + ESTReloadTrustResponse
  mirror service.ESTStatsSnapshot 1:1.
- New web/src/api/client.ts helpers getAdminESTProfiles +
  reloadAdminESTTrust.
- 14 Vitest cases (admin gate non-admin / non-auth-required deploy /
  default tab / tab switch / deep-link tab / per-profile card render
  + counter cells / reload-button mTLS-only / trust-expiry badge
  band / reload modal Confirm-Cancel-Error paths / Trust Bundle
  empty-state / Activity filter chip toggle).

Phase 9.1 — CLI subcommands:
- internal/cli/est.go adds 6 subcommands: cacerts / csrattrs /
  enroll / reenroll / serverkeygen / test. CSR input via --csr
  with file-path or '-' for stdin; multipart serverkeygen response
  is parsed by stdlib mime/multipart and split into <prefix>.cert.pem
  + <prefix>.key.enveloped so the operator can decrypt the key with
  openssl smime. EST `test` smoke-tests cacerts + csrattrs + emits
  one-line OK/FAIL diagnostics.
- cmd/cli/main.go grows the `est` dispatch + Usage entries.

Phase 9.2 — MCP tools:
- internal/mcp/tools_est.go adds 6 tools mapped to the EST endpoints
  + admin observability: est_list_profiles + est_admin_stats (alias)
  + est_get_cacerts + est_get_csrattrs + est_enroll + est_reenroll.
  Tool count grew from 87 → 93 (verified via the registered-vs-
  covered guard in tools_per_tool_test.go); the per-tool happy/error-
  path table grew with 6 matching entries so the future-tool-no-test
  CI guard stays green.
- internal/mcp/client.go grows PostRaw — non-JSON POST helper that
  the EST enroll/reenroll tools use to ship raw application/pkcs10
  CSR bytes through the MCP fence-wrapped response.
- estRawResultJSON wraps the raw response body in a JSON envelope
  the MCP consumer can structurally consume (content_type +
  body_base64 + body_size_bytes). Mirrors the CRL/OCSP MCP tools'
  binary-DER envelope.

Phase 9.3 — Tests:
- internal/cli/est_test.go: 8 cases pinning the wire-shape contract
  on the CLI side without dragging the full ESTHandler into the
  test build.
- internal/mcp/tools_est_test.go: path-builder + JSON-envelope unit
  tests + end-to-end tool exercise that pins all 5 captured request
  paths through a fake API.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
pre-existing testcontainers limit), staticcheck clean across
cli/mcp/cmd/cli, go test -short -count=1 green for every non-
postgres Go package, Vitest green for ESTAdminPage (14) +
SCEPAdminPage (20) — 34 page tests total. G-3 docs-drift guard
reproduced locally clean (Phases 8-9 added zero new env vars).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
10-13 (libest sidecar e2e / bulk revocation + audit codes /
docs/est.md / release prep + tag) remain — post-2.1.0 work.
2026-04-30 00:20:54 +00:00

349 lines
13 KiB
Go

package cli
// EST RFC 7030 hardening master bundle Phase 9.1 — CLI subcommands.
//
// The EST endpoints live under /.well-known/est/[<PathID>/]; they are
// HTTPS-only (the certctl control plane is HTTPS-only as of v2.2) and
// per-profile dispatched. The CLI subcommands here mirror what an
// operator would do via libest or curl + base64 + openssl, but with a
// fixed --profile flag + the existing CLI's TLS-pinning semantics.
//
// Subcommands:
//
// certctl-cli est cacerts --profile corp
// certctl-cli est csrattrs --profile corp
// certctl-cli est enroll --profile corp --csr <path-or-stdin>
// certctl-cli est reenroll --profile corp --csr <path-or-stdin>
// certctl-cli est serverkeygen --profile corp --csr <path> --out <prefix>
// certctl-cli est test --profile corp
//
// All write operations stream the issued cert (PEM) to stdout by default
// or to --out when provided. Server-keygen writes <prefix>.cert.pem +
// <prefix>.key.enveloped (the EnvelopedData blob) so the operator can
// decrypt with openssl smime.
import (
"bytes"
"encoding/base64"
"encoding/pem"
"flag"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"strings"
)
// estPath builds the per-profile EST URL fragment. PathID="" maps to
// the legacy /.well-known/est/ root for backward compat with v2.0.x
// single-profile deploys.
func (c *Client) estPath(profile, op string) string {
if profile == "" {
return "/.well-known/est/" + op
}
return "/.well-known/est/" + profile + "/" + op
}
// estPostBody POSTs the given body bytes to the EST endpoint with the
// EST-required Content-Type. Returns the raw response body so the
// caller can write the PEM/PKCS#7/multipart bytes through to disk
// without further decoding (the CLI is the device's smime/openssl
// pipeline; we don't second-guess the wire format).
func (c *Client) estPostBody(path, contentType string, body []byte) ([]byte, http.Header, error) {
u, err := url.JoinPath(c.baseURL, path)
if err != nil {
return nil, nil, fmt.Errorf("invalid URL: %w", err)
}
req, err := http.NewRequest("POST", u, strings.NewReader(string(body)))
if err != nil {
return nil, nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", contentType)
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(respBody))
}
return respBody, resp.Header, nil
}
// estGet performs a GET against the EST endpoint and returns the raw
// response body. Used by cacerts (HTTP 200) and csrattrs (HTTP 200 or
// 204 — both are valid contracts).
func (c *Client) estGet(path string) ([]byte, int, http.Header, error) {
u, err := url.JoinPath(c.baseURL, path)
if err != nil {
return nil, 0, nil, fmt.Errorf("invalid URL: %w", err)
}
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, 0, nil, fmt.Errorf("creating request: %w", err)
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, resp.Header, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, resp.StatusCode, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(body))
}
return body, resp.StatusCode, resp.Header, nil
}
// readCSRBytes resolves --csr to actual CSR bytes. "-" reads from
// stdin (so the operator can pipe `openssl req -new …` directly into
// us); otherwise it's a filesystem path.
func readCSRBytes(path string) ([]byte, error) {
if path == "-" {
return io.ReadAll(os.Stdin)
}
return os.ReadFile(path)
}
// EstCacerts implements `certctl-cli est cacerts --profile <p>`.
// Writes the base64-wrapped PKCS#7 certs-only response to stdout (the
// canonical EST §4.1.3 wire shape).
func (c *Client) EstCacerts(args []string) error {
fs := flag.NewFlagSet("est cacerts", flag.ContinueOnError)
profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
out := fs.String("out", "-", "output file path; '-' = stdout")
if err := fs.Parse(args); err != nil {
return err
}
body, _, _, err := c.estGet(c.estPath(*profile, "cacerts"))
if err != nil {
return err
}
return writeOutput(*out, body)
}
// EstCsrattrs implements `certctl-cli est csrattrs --profile <p>`.
// Writes the base64-encoded ASN.1 SEQUENCE OF OID body to stdout. The
// 204-No-Content case (no profile-derived hints) prints an empty
// payload + a STDERR diagnostic so an operator running the smoke-test
// case knows the endpoint succeeded.
func (c *Client) EstCsrattrs(args []string) error {
fs := flag.NewFlagSet("est csrattrs", flag.ContinueOnError)
profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
out := fs.String("out", "-", "output file path; '-' = stdout")
if err := fs.Parse(args); err != nil {
return err
}
body, status, _, err := c.estGet(c.estPath(*profile, "csrattrs"))
if err != nil {
return err
}
if status == http.StatusNoContent {
fmt.Fprintln(os.Stderr, "no csrattrs hints configured (HTTP 204)")
return nil
}
return writeOutput(*out, body)
}
// EstEnroll implements `certctl-cli est enroll --profile <p> --csr <path>`.
// POSTs the CSR to /simpleenroll. The CSR body is sent as-is (whether
// PEM or base64-DER); the server's readCSRFromRequest handles either.
func (c *Client) EstEnroll(args []string) error {
return c.estEnrollOp("simpleenroll", "est enroll", args)
}
// EstReEnroll implements `certctl-cli est reenroll --profile <p> --csr <path>`.
func (c *Client) EstReEnroll(args []string) error {
return c.estEnrollOp("simplereenroll", "est reenroll", args)
}
// estEnrollOp shares the body of EstEnroll + EstReEnroll. The only
// difference between the two is the URL suffix (simpleenroll vs
// simplereenroll); the audit-action distinction is server-side.
func (c *Client) estEnrollOp(op, helpName string, args []string) error {
fs := flag.NewFlagSet(helpName, flag.ContinueOnError)
profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin")
out := fs.String("out", "-", "output file path; '-' = stdout")
if err := fs.Parse(args); err != nil {
return err
}
if *csrPath == "" {
return fmt.Errorf("--csr is required (path to a PKCS#10 CSR file or '-' for stdin)")
}
csrBytes, err := readCSRBytes(*csrPath)
if err != nil {
return fmt.Errorf("read CSR: %w", err)
}
// EST §4.2.1: client MAY send PEM or base64-DER. The server's
// readCSRFromRequest handles either; we forward what the operator
// supplied. Content-Type is application/pkcs10 per RFC 7030.
body, _, err := c.estPostBody(c.estPath(*profile, op), "application/pkcs10", csrBytes)
if err != nil {
return err
}
return writeOutput(*out, body)
}
// EstServerKeygen implements `certctl-cli est serverkeygen --profile <p>
// --csr <path> --out <prefix>`. The server returns multipart/mixed; we
// split into the cert part and the encrypted-key part, write each to
// <prefix>.cert.pem + <prefix>.key.enveloped so the operator can pipe
// the latter into `openssl smime -decrypt -inkey <client-priv>`.
func (c *Client) EstServerKeygen(args []string) error {
fs := flag.NewFlagSet("est serverkeygen", flag.ContinueOnError)
profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin")
outPrefix := fs.String("out", "keypair", "output file prefix; produces <prefix>.cert.pem + <prefix>.key.enveloped")
if err := fs.Parse(args); err != nil {
return err
}
if *csrPath == "" {
return fmt.Errorf("--csr is required")
}
csrBytes, err := readCSRBytes(*csrPath)
if err != nil {
return fmt.Errorf("read CSR: %w", err)
}
body, hdr, err := c.estPostBody(c.estPath(*profile, "serverkeygen"), "application/pkcs10", csrBytes)
if err != nil {
return err
}
// Parse the multipart body so we can write each part to its own
// file. The handler emits two parts: certs-only PKCS#7 and
// EnvelopedData PKCS#7. We don't decrypt the key here — the
// operator's smime client owns the recipient private key.
contentType := hdr.Get("Content-Type")
certPart, keyPart, err := splitServerKeygenMultipart(body, contentType)
if err != nil {
return fmt.Errorf("parse multipart response: %w", err)
}
certFile := *outPrefix + ".cert.pem"
keyFile := *outPrefix + ".key.enveloped"
if err := os.WriteFile(certFile, certPart, 0o600); err != nil {
return fmt.Errorf("write cert: %w", err)
}
if err := os.WriteFile(keyFile, keyPart, 0o600); err != nil {
return fmt.Errorf("write key: %w", err)
}
fmt.Fprintf(os.Stderr, "wrote %s + %s\n", certFile, keyFile)
return nil
}
// EstTest is a smoke-test that hits cacerts + csrattrs in sequence and
// prints a one-line OK/FAIL per endpoint. Useful for operator post-
// deploy validation: `certctl-cli est test --profile corp` returns
// exit-0 when both endpoints respond successfully.
func (c *Client) EstTest(args []string) error {
fs := flag.NewFlagSet("est test", flag.ContinueOnError)
profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
if err := fs.Parse(args); err != nil {
return err
}
if _, _, _, err := c.estGet(c.estPath(*profile, "cacerts")); err != nil {
fmt.Fprintf(os.Stderr, "FAIL cacerts: %v\n", err)
return err
}
fmt.Fprintln(os.Stderr, "OK cacerts")
if _, status, _, err := c.estGet(c.estPath(*profile, "csrattrs")); err != nil {
fmt.Fprintf(os.Stderr, "FAIL csrattrs: %v\n", err)
return err
} else {
fmt.Fprintf(os.Stderr, "OK csrattrs (HTTP %d)\n", status)
}
return nil
}
// writeOutput writes to disk or stdout depending on --out. Centralises
// the open-truncate-permission semantics so every subcommand uses the
// same shape.
func writeOutput(path string, body []byte) error {
if path == "-" || path == "" {
_, err := os.Stdout.Write(body)
return err
}
return os.WriteFile(path, body, 0o600)
}
// splitServerKeygenMultipart cracks the RFC 7030 §4.4.2 multipart body
// into its two PKCS#7 parts. The caller hands us the response body +
// the Content-Type header value (which carries the boundary parameter
// produced by handler.newMultipartBoundary).
//
// Light-weight on purpose — we use stdlib mime/multipart but keep the
// helper self-contained here so the test can swap in fixture bytes
// without spinning up the full ESTHandler.
func splitServerKeygenMultipart(body []byte, contentType string) ([]byte, []byte, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, nil, fmt.Errorf("parse Content-Type %q: %w", contentType, err)
}
boundary := params["boundary"]
if boundary == "" {
return nil, nil, fmt.Errorf("multipart Content-Type %q missing boundary parameter", contentType)
}
mr := multipart.NewReader(bytes.NewReader(body), boundary)
var cert, key []byte
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, nil, err
}
ct := part.Header.Get("Content-Type")
// The handler wraps each part body in base64 (see writeBase64Wrapped).
raw, _ := io.ReadAll(part)
decoded, decErr := decodeBase64Wrapped(raw)
if decErr != nil {
// Some clients want the base64 verbatim; fall through to raw.
decoded = raw
}
switch {
case strings.Contains(ct, "smime-type=certs-only"):
cert = decoded
case strings.Contains(ct, "smime-type=enveloped-data"):
key = decoded
}
}
if len(cert) == 0 || len(key) == 0 {
return nil, nil, fmt.Errorf("multipart response missing required parts (cert=%d bytes, key=%d bytes)", len(cert), len(key))
}
return cert, key, nil
}
// decodeBase64Wrapped strips CRLF wrapping then base64-decodes. The
// EST handler emits the body as base64 with CRLF every 76 chars per
// RFC 2045; client-side decode is "rip the whitespace, base64-decode
// the rest".
func decodeBase64Wrapped(in []byte) ([]byte, error) {
stripped := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, string(in))
return base64.StdEncoding.DecodeString(stripped)
}
// _ = pem.Decode is referenced by est_test.go to verify the cacerts
// + enroll responses are parseable PEM. Keep the import live without
// growing the public API.
var _ = pem.Decode