mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
36885da2da
(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.
210 lines
7.4 KiB
Go
210 lines
7.4 KiB
Go
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}}
|
|
}
|