mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 14:18:57 +00:00
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:
@@ -0,0 +1,348 @@
|
||||
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
|
||||
@@ -0,0 +1,209 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 9.3 — CLI subcommand tests.
|
||||
// Exercise each EST CLI subcommand against an httptest server that
|
||||
// asserts request shape (method + path + Content-Type) + emits a
|
||||
// canned response body. This pins the wire-format contract on the
|
||||
// CLI side without dragging the full ESTHandler into the test build.
|
||||
|
||||
func newESTTestClient(t *testing.T, server *httptest.Server) *Client {
|
||||
t.Helper()
|
||||
// CLI defaults to TLS-1.3-min; the httptest TLS server uses an
|
||||
// auto-generated leaf cert, so we set InsecureSkipVerify (the
|
||||
// CLI's --insecure equivalent) for the test.
|
||||
c := &Client{
|
||||
baseURL: server.URL,
|
||||
apiKey: "",
|
||||
format: "table",
|
||||
httpClient: server.Client(),
|
||||
}
|
||||
// Force TLS 1.3 to mirror NewClient's production setting.
|
||||
if t, ok := c.httpClient.Transport.(*http.Transport); ok && t.TLSClientConfig != nil {
|
||||
t.TLSClientConfig.MinVersion = tls.VersionTLS13
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEstCacerts_GetsBaseRoute(t *testing.T) {
|
||||
wantPath := "/.well-known/est/corp/cacerts"
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != wantPath {
|
||||
t.Errorf("got %s %s, want GET %s", r.Method, r.URL.Path, wantPath)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Write([]byte("MIIBaseinPKCS7Body"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
tmp := filepath.Join(t.TempDir(), "ca.p7")
|
||||
if err := c.EstCacerts([]string{"--profile", "corp", "--out", tmp}); err != nil {
|
||||
t.Fatalf("EstCacerts: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("read out: %v", err)
|
||||
}
|
||||
if string(got) != "MIIBaseinPKCS7Body" {
|
||||
t.Errorf("body = %q, want MIIBaseinPKCS7Body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstCsrattrs_204IsNotAnError(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/est/corp/csrattrs" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
if err := c.EstCsrattrs([]string{"--profile", "corp"}); err != nil {
|
||||
t.Errorf("204 should not surface as an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstEnroll_PostsCSRAsApplicationPKCS10(t *testing.T) {
|
||||
wantBody := "-----BEGIN CERTIFICATE REQUEST-----\nXXXX\n-----END CERTIFICATE REQUEST-----"
|
||||
csrPath := filepath.Join(t.TempDir(), "device.csr")
|
||||
if err := os.WriteFile(csrPath, []byte(wantBody), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/.well-known/est/corp/simpleenroll" {
|
||||
t.Errorf("got %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/pkcs10" {
|
||||
t.Errorf("Content-Type = %q, want application/pkcs10", got)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != wantBody {
|
||||
t.Errorf("body = %q, want %q", body, wantBody)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Write([]byte("ISSUEDCERT"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
out := filepath.Join(t.TempDir(), "issued.p7")
|
||||
if err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil {
|
||||
t.Fatalf("EstEnroll: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(out)
|
||||
if string(got) != "ISSUEDCERT" {
|
||||
t.Errorf("issued body = %q, want ISSUEDCERT", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstReEnroll_HitsRenewalPath(t *testing.T) {
|
||||
csrPath := filepath.Join(t.TempDir(), "device.csr")
|
||||
os.WriteFile(csrPath, []byte("dummy-csr"), 0o600)
|
||||
called := false
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/est/corp/simplereenroll" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
called = true
|
||||
w.Write([]byte("RENEWED"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
out := filepath.Join(t.TempDir(), "renewed.p7")
|
||||
if err := c.EstReEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil {
|
||||
t.Fatalf("EstReEnroll: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("server never received the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstEnroll_RequiresCSR(t *testing.T) {
|
||||
c := newESTTestClient(t, httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
|
||||
err := c.EstEnroll([]string{"--profile", "corp"})
|
||||
if err == nil || !strings.Contains(err.Error(), "csr") {
|
||||
t.Errorf("expected --csr-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstEnroll_ServerErrorMappedToFailure(t *testing.T) {
|
||||
csrPath := filepath.Join(t.TempDir(), "device.csr")
|
||||
os.WriteFile(csrPath, []byte("dummy"), 0o600)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", filepath.Join(t.TempDir(), "out")})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
t.Errorf("expected HTTP 500 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitServerKeygenMultipart_RoundTrip(t *testing.T) {
|
||||
// Build a multipart body with two base64-wrapped parts and assert
|
||||
// the split helper hands back the matching bytes. This pins the
|
||||
// CLI's parser against the handler's writer (handler emits via
|
||||
// mime/multipart's same boundary semantics).
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
certPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=certs-only"))
|
||||
certPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("CERT_BYTES"))))
|
||||
keyPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=enveloped-data"))
|
||||
keyPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("KEY_BYTES"))))
|
||||
w.Close()
|
||||
contentType := fmt.Sprintf("multipart/mixed; boundary=%s", w.Boundary())
|
||||
cert, key, err := splitServerKeygenMultipart(buf.Bytes(), contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("splitServerKeygenMultipart: %v", err)
|
||||
}
|
||||
if string(cert) != "CERT_BYTES" {
|
||||
t.Errorf("cert part = %q, want CERT_BYTES", cert)
|
||||
}
|
||||
if string(key) != "KEY_BYTES" {
|
||||
t.Errorf("key part = %q, want KEY_BYTES", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstTest_HitsBothEndpoints(t *testing.T) {
|
||||
hits := map[string]int{}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits[r.URL.Path]++
|
||||
if strings.HasSuffix(r.URL.Path, "/csrattrs") {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := newESTTestClient(t, server)
|
||||
if err := c.EstTest([]string{"--profile", "corp"}); err != nil {
|
||||
t.Fatalf("EstTest: %v", err)
|
||||
}
|
||||
if hits["/.well-known/est/corp/cacerts"] != 1 {
|
||||
t.Errorf("cacerts hit count = %d, want 1", hits["/.well-known/est/corp/cacerts"])
|
||||
}
|
||||
if hits["/.well-known/est/corp/csrattrs"] != 1 {
|
||||
t.Errorf("csrattrs hit count = %d, want 1", hits["/.well-known/est/corp/csrattrs"])
|
||||
}
|
||||
}
|
||||
|
||||
// textproto builds the small multipart-header form mime/multipart's
|
||||
// CreatePart wants (it's a textproto.MIMEHeader). Pulled out as a tiny
|
||||
// helper so the test reads cleanly.
|
||||
func textproto(contentType string) map[string][]string {
|
||||
return map[string][]string{"Content-Type": {contentType}}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -29,6 +29,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
|
||||
registerMetricsTools(s, client)
|
||||
registerDigestTools(s, client)
|
||||
registerHealthTools(s, client)
|
||||
registerESTTools(s, client)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user