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.
This commit is contained in:
shankar0123
2026-04-30 00:20:54 +00:00
parent 43075a1b5c
commit 36885da2da
14 changed files with 1931 additions and 1 deletions
+33
View File
@@ -92,6 +92,39 @@ func (c *Client) DeleteWithQuery(path string, query url.Values) (json.RawMessage
return c.do("DELETE", path, query, nil)
}
// PostRaw performs an HTTP POST with a non-JSON body and returns the raw
// response bytes + content type. Used by EST enroll / reenroll where the
// body is `application/pkcs10` (CSR bytes) and the response is
// `application/pkcs7-mime; smime-type=certs-only` (base64-wrapped). EST
// RFC 7030 hardening master bundle Phase 9.2.
func (c *Client) PostRaw(path, contentType string, body []byte) ([]byte, string, error) {
u, err := url.JoinPath(c.baseURL, path)
if err != nil {
return nil, "", fmt.Errorf("invalid URL: %w", err)
}
req, err := http.NewRequest("POST", u, bytes.NewReader(body))
if err != nil {
return 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, "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data))
}
return data, resp.Header.Get("Content-Type"), nil
}
// GetRaw performs an HTTP GET and returns the raw response body bytes and content type.
// Used for binary responses (DER CRL, OCSP).
func (c *Client) GetRaw(path string) ([]byte, string, error) {
+1
View File
@@ -29,6 +29,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
registerMetricsTools(s, client)
registerDigestTools(s, client)
registerHealthTools(s, client)
registerESTTools(s, client)
}
// ── Helpers ─────────────────────────────────────────────────────────
+142
View File
@@ -0,0 +1,142 @@
package mcp
// EST RFC 7030 hardening master bundle Phase 9.2 — MCP tools.
//
// 6 tools mapped to the EST endpoints + admin observability:
//
// est_list_profiles → GET /api/v1/admin/est/profiles (M-008 admin-gated)
// est_get_cacerts → GET /.well-known/est/[<PathID>/]cacerts
// est_get_csrattrs → GET /.well-known/est/[<PathID>/]csrattrs
// est_enroll → POST /.well-known/est/[<PathID>/]simpleenroll
// est_reenroll → POST /.well-known/est/[<PathID>/]simplereenroll
// est_admin_stats → alias of est_list_profiles for parity with the
// SCEP admin tool naming (admin GUI uses both
// names interchangeably; we expose both for
// LLM-friendly discovery).
//
// Each tool returns the raw response body wrapped via textResult so
// the MCP fence semantics apply (LLM consumers see the body as
// untrusted data, not instructions).
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
// ── Input types ─────────────────────────────────────────────────────
type ESTProfileInput struct {
Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"`
}
type ESTEnrollInput struct {
Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"`
CSR string `json:"csr" jsonschema:"PKCS#10 CSR — PEM-encoded or base64-DER. Required."`
}
// ── Tool registration ──────────────────────────────────────────────
func registerESTTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_list_profiles",
Description: "List per-profile EST observability snapshot (counters + mTLS trust-anchor expiries + auth-mode posture). Admin-gated. Returns one snapshot per configured EST profile.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) {
data, err := c.Get("/api/v1/admin/est/profiles", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_admin_stats",
Description: "Alias of est_list_profiles — returns the same per-profile EST stats snapshot. Provided so LLM tool discovery surfaces both naming conventions (mirrors the SCEP admin tool naming).",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) {
data, err := c.Get("/api/v1/admin/est/profiles", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_get_cacerts",
Description: "EST GET /.well-known/est/[<profile>/]cacerts (RFC 7030 §4.1). Returns the base64-wrapped PKCS#7 certs-only response carrying the CA certificate chain. The response body is opaque from the MCP-consumer perspective; pipe into openssl smime / openssl pkcs7 to extract the chain.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) {
body, contentType, err := c.GetRaw(estPathFor(input.Profile, "cacerts"))
if err != nil {
return errorResult(err)
}
return textResult(estRawResultJSON(body, contentType))
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_get_csrattrs",
Description: "EST GET /.well-known/est/[<profile>/]csrattrs (RFC 7030 §4.5). Returns the base64-encoded ASN.1 SEQUENCE OF OID hint list the server wants the client to include in subsequent enrollments. Empty body (HTTP 204) when no profile-derived hints are configured.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) {
body, contentType, err := c.GetRaw(estPathFor(input.Profile, "csrattrs"))
if err != nil {
return errorResult(err)
}
return textResult(estRawResultJSON(body, contentType))
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_enroll",
Description: "EST POST /.well-known/est/[<profile>/]simpleenroll (RFC 7030 §4.2). Submits a PKCS#10 CSR (PEM or base64-DER) and receives the issued certificate chain as a base64-wrapped PKCS#7 certs-only response.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) {
if strings.TrimSpace(input.CSR) == "" {
return errorResult(fmt.Errorf("csr is required (PEM-encoded or base64-DER PKCS#10)"))
}
body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simpleenroll"),
"application/pkcs10", []byte(input.CSR))
if err != nil {
return errorResult(err)
}
return textResult(estRawResultJSON(body, contentType))
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "est_reenroll",
Description: "EST POST /.well-known/est/[<profile>/]simplereenroll (RFC 7030 §4.2.2). Same wire shape as est_enroll; the audit log distinguishes initial-vs-renewal under the `est_simple_reenroll` action code.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) {
if strings.TrimSpace(input.CSR) == "" {
return errorResult(fmt.Errorf("csr is required"))
}
body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simplereenroll"),
"application/pkcs10", []byte(input.CSR))
if err != nil {
return errorResult(err)
}
return textResult(estRawResultJSON(body, contentType))
})
}
// estPathFor builds the per-profile EST URL path. Empty profile maps
// to the legacy root for backward compat with v2.0.x deploys.
func estPathFor(profile, op string) string {
if profile == "" {
return "/.well-known/est/" + op
}
return "/.well-known/est/" + profile + "/" + op
}
// estRawResultJSON wraps the raw EST response body in a JSON envelope
// the MCP consumer can structurally consume. The body itself is base64-
// encoded so the LLM doesn't have to handle binary-safe transport;
// content_type is preserved verbatim. Mirrors the shape the CRL/OCSP
// MCP tools use for their binary DER responses.
func estRawResultJSON(body []byte, contentType string) json.RawMessage {
out := map[string]any{
"content_type": contentType,
"body_base64": base64.StdEncoding.EncodeToString(body),
"body_size_bytes": len(body),
}
raw, _ := json.Marshal(out)
return raw
}
+168
View File
@@ -0,0 +1,168 @@
package mcp
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// EST RFC 7030 hardening master bundle Phase 9.3 — MCP tool tests.
// Mirror the SCEP/Intune tool test pattern: spin up a fake API, exercise
// each tool's HTTP path, assert the request shape (method + path +
// optional Content-Type/body) + the wrapped JSON response.
// mockESTAPI returns a test server that records EST + admin EST requests.
// Differs from mockCertctlAPI by handling the raw (non-JSON) /.well-known/est/*
// surfaces — it returns binary-friendly bodies + the EST Content-Type
// the CLI wire-format tests pin.
func mockESTAPI(log *requestLog) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := ""
if r.Body != nil {
buf := make([]byte, 8192)
n, _ := r.Body.Read(buf)
body = string(buf[:n])
}
log.add(capturedRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Body: body,
})
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/admin/est/profiles"):
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"profiles": []map[string]any{{"path_id": "corp", "issuer_id": "iss-corp"}},
"profile_count": 1,
"generated_at": "2026-04-29T00:00:00Z",
})
case strings.HasSuffix(r.URL.Path, "/cacerts"):
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
w.Write([]byte("CACERTS-PKCS7-BYTES"))
case strings.HasSuffix(r.URL.Path, "/csrattrs"):
w.Header().Set("Content-Type", "application/csrattrs")
w.Write([]byte("CSRATTRS-DER"))
case strings.HasSuffix(r.URL.Path, "/simpleenroll"):
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
w.Write([]byte("ENROLLED-CERT-BYTES"))
case strings.HasSuffix(r.URL.Path, "/simplereenroll"):
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
w.Write([]byte("REENROLLED-CERT-BYTES"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestEstPathFor(t *testing.T) {
cases := []struct {
profile string
op string
want string
}{
{"corp", "cacerts", "/.well-known/est/corp/cacerts"},
{"", "cacerts", "/.well-known/est/cacerts"},
{"iot", "simpleenroll", "/.well-known/est/iot/simpleenroll"},
}
for _, c := range cases {
got := estPathFor(c.profile, c.op)
if got != c.want {
t.Errorf("estPathFor(%q, %q) = %q, want %q", c.profile, c.op, got, c.want)
}
}
}
func TestEstRawResultJSON_EmbedsBase64Body(t *testing.T) {
body := []byte("\x00\x01\x02\x03binary")
raw := estRawResultJSON(body, "application/pkcs7-mime")
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["content_type"] != "application/pkcs7-mime" {
t.Errorf("content_type = %v", got["content_type"])
}
if got["body_base64"] != base64.StdEncoding.EncodeToString(body) {
t.Errorf("body_base64 mismatch")
}
if int(got["body_size_bytes"].(float64)) != len(body) {
t.Errorf("body_size_bytes = %v, want %d", got["body_size_bytes"], len(body))
}
}
// TestRegisterESTTools_HitsExpectedPaths exercises each EST tool by
// driving its handler through the registered mcp.Server's transport
// surface. We use the Client directly (not the gomcp dispatch layer)
// because the per-tool handlers are closures over the Client; the
// request-shape assertion is what matters here.
func TestRegisterESTTools_AllPathsExercised(t *testing.T) {
log := &requestLog{}
api := mockESTAPI(log)
defer api.Close()
client, err := NewClient(api.URL, "test-key", "", false)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
// est_list_profiles + est_admin_stats both hit the admin endpoint.
if _, err := client.Get("/api/v1/admin/est/profiles", nil); err != nil {
t.Errorf("admin profiles: %v", err)
}
// est_get_cacerts via GetRaw.
if body, _, err := client.GetRaw(estPathFor("corp", "cacerts")); err != nil {
t.Errorf("cacerts GET: %v", err)
} else if string(body) != "CACERTS-PKCS7-BYTES" {
t.Errorf("cacerts body = %q, want CACERTS-PKCS7-BYTES", body)
}
// est_get_csrattrs via GetRaw.
if body, _, err := client.GetRaw(estPathFor("corp", "csrattrs")); err != nil {
t.Errorf("csrattrs GET: %v", err)
} else if string(body) != "CSRATTRS-DER" {
t.Errorf("csrattrs body = %q, want CSRATTRS-DER", body)
}
// est_enroll via PostRaw.
if body, _, err := client.PostRaw(estPathFor("corp", "simpleenroll"), "application/pkcs10",
[]byte("CSR-PEM-BYTES")); err != nil {
t.Errorf("simpleenroll POST: %v", err)
} else if string(body) != "ENROLLED-CERT-BYTES" {
t.Errorf("simpleenroll body = %q, want ENROLLED-CERT-BYTES", body)
}
// est_reenroll via PostRaw.
if body, _, err := client.PostRaw(estPathFor("corp", "simplereenroll"), "application/pkcs10",
[]byte("CSR-PEM-BYTES")); err != nil {
t.Errorf("simplereenroll POST: %v", err)
} else if string(body) != "REENROLLED-CERT-BYTES" {
t.Errorf("simplereenroll body = %q, want REENROLLED-CERT-BYTES", body)
}
// Pin every captured path so a future refactor that reroutes one
// tool gets caught.
wantPaths := []string{
"/api/v1/admin/est/profiles",
"/.well-known/est/corp/cacerts",
"/.well-known/est/corp/csrattrs",
"/.well-known/est/corp/simpleenroll",
"/.well-known/est/corp/simplereenroll",
}
gotPaths := make([]string, 0, len(log.requests))
for _, r := range log.requests {
gotPaths = append(gotPaths, r.Path)
}
for _, want := range wantPaths {
found := false
for _, got := range gotPaths {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("missing request to %s; got %v", want, gotPaths)
}
}
}
+8
View File
@@ -359,6 +359,14 @@ var allHappyPathCases = []toolCase{
{"certctl_ready", map[string]any{}, http.MethodGet, "/ready"},
{"certctl_auth_check", map[string]any{}, http.MethodGet, "/api/v1/auth/check"},
{"certctl_auth_info", map[string]any{}, http.MethodGet, "/api/v1/auth/whoami"},
// EST RFC 7030 hardening Phase 9.2 — 6 EST tools.
{"est_list_profiles", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"},
{"est_admin_stats", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"},
{"est_get_cacerts", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/cacerts"},
{"est_get_csrattrs", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/csrattrs"},
{"est_enroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simpleenroll"},
{"est_reenroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simplereenroll"},
}
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in