mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:41:30 +00:00
43075a1b5c
+ profile-driven csrattrs + admin observability with per-status counters + reload-trust endpoint. Phase 5 — RFC 7030 §4.4 server-driven key generation: - internal/pkcs7/envelopeddata_builder.go is the inverse of the existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1 v1.5 keyTrans + per-call random IV. Round-trip pinned in test (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the original plaintext byte-for-byte). - ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client CSR → require RSA pubkey for keyTrans → resolve per-profile algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in- memory keygen → re-build CSR with server pubkey → run existing issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a synthetic recipient cert wrapping the device's CSR-supplied pubkey → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey- Encipherment / ErrServerKeygenUnsupportedAlgorithm / ErrServerKeygenDisabled. - ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2 multipart/mixed with random per-response boundary; per-profile SetServerKeygenEnabled gate returns 404 when off (defense in depth even if the route was registered). - New routes POST /.well-known/est/[<PathID>/]serverkeygen + /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml + openapi-parity guard updated. Phase 6 — Real csrattrs implementation: - New CertificateProfile.RequiredCSRAttributes []string + migration 000022_certificate_profiles_csrattrs.up.sql. The migration also lands the previously-unwired must_staple column (closes the 5.6 follow-up loop where the field shipped at the domain + service layer but the postgres scan/insert/update never persisted it). - domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-* EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985 PKCS#10 attributes + Microsoft Intune device-serial OID. - ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU / attribute strings dropped + warning-logged so a typo doesn't take down the entire endpoint. Phase 7 — Admin observability + counters + reload-trust: - internal/service/est_counters.go: estCounterTab (sync/atomic; 12 named labels) + ESTStatsSnapshot per-profile shape + ESTService.Stats(now) zero-allocation accessor + ReloadTrust() SIGHUP-equivalent + SetESTAdminMetadata setter. - Counter ticks wired into processEnrollment + SimpleServerKeygen at every success/failure leg. - internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim: Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both endpoints admin-gated (M-008 triplet pinned + admin_est.go added to AdminGatedHandlers). - New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/ est/reload-trust; openapi.yaml documented; openapi-parity guard reproduced clean. - cmd/server/main.go grows estServices map populated by the per- profile EST loop + handed to AdminEST. New MTLSTrust() + HasMTLSTrust() accessors on ESTHandler so main.go can pull the trust holder for the admin-metadata wire-up. - Per-profile counter isolation regression test (internal/service/est_profile_counter_isolation_test.go) proves a future shared-counter refactor would fail at compile-time pointer-identity check. Pre-commit verification (sandbox): gofmt clean, go vet clean (excluding repository/postgres which the sandbox can't build — disk-space testcontainers download), staticcheck clean across cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/ service/pkcs7/domain/cmd/server, go test -short -count=1 green for every non-postgres package. G-3 docs-drift guard reproduced locally clean (Phases 5-7 added zero new env vars; Phase 1 already documented per-profile SERVER_KEYGEN_ENABLED). Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases 8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation / docs/est.md / release prep) remain — post-2.1.0 work.
345 lines
13 KiB
Go
345 lines
13 KiB
Go
package handler
|
|
|
|
// Adversarial EST (RFC 7030) enrollment tests — Tier 1F.
|
|
//
|
|
// EST is the RFC 7030 protocol for certificate enrollment over HTTPS. The
|
|
// control-plane parser accepts PKCS#10 CSRs either as PEM or as base64-encoded
|
|
// DER, and it's a prime target for:
|
|
//
|
|
// * Malformed base64 / non-DER payloads
|
|
// * Valid base64 that doesn't decode to a valid CSR
|
|
// * PEM header spoofing (wrong block type)
|
|
// * Null bytes and control characters embedded in PEM or base64
|
|
// * Huge CSR bodies (we expect the handler's 1 MiB LimitReader to clamp them)
|
|
// * Truncated or partially-written PEM blocks
|
|
// * Unicode homoglyphs in PEM delimiters
|
|
// * Content-Type mismatch (handler ignores Content-Type, but attackers might
|
|
// still try header spoofing)
|
|
//
|
|
// The contract is the same as other adversarial tiers: the handler must never
|
|
// panic and must never return 500 for a malformed CSR (500 is reserved for
|
|
// issuer/service failures). For adversarial CSRs, the correct status is 400.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these
|
|
// should reach the underlying ESTService — they must be rejected by
|
|
// readCSRFromRequest with a 400 before any service call is made.
|
|
func adversarialCSRInputs() []struct {
|
|
name string
|
|
body string
|
|
} {
|
|
// A garbage base64 string that decodes cleanly but isn't a PKCS#10 CSR.
|
|
// base64 of "this is definitely not a CSR" = dGhpcyBpcyBkZWZpbml0ZWx5IG5vdCBhIENTUg==
|
|
nonCSRBase64 := base64.StdEncoding.EncodeToString([]byte("this is definitely not a CSR"))
|
|
|
|
return []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"garbage_string", "not-a-csr-at-all"},
|
|
{"base64_garbage", "!!!@@@###$$$%%%"},
|
|
{"base64_valid_non_csr", nonCSRBase64},
|
|
{"base64_very_short", "AA=="},
|
|
{"null_byte_only", "\x00"},
|
|
{"null_bytes_padding", "\x00\x00\x00\x00\x00\x00\x00\x00"},
|
|
{"control_chars", "\x01\x02\x03\x04\x05\x06\x07\x08"},
|
|
{"pem_wrong_block_type", "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"},
|
|
{"pem_wrong_header_close", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END PRIVATE KEY-----\n"},
|
|
{"pem_empty_block", "-----BEGIN CERTIFICATE REQUEST-----\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_garbage_body", "-----BEGIN CERTIFICATE REQUEST-----\n!!!not base64!!!\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_truncated", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCAT"},
|
|
{"pem_no_end_marker", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCATICAQAwFjEUMBIGA1UE\n"},
|
|
{"pem_header_injection", "-----BEGIN CERTIFICATE REQUEST-----\r\nHost: evil.com\r\n\r\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_embedded_null", "-----BEGIN CERTIFICATE\x00REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"unicode_homoglyph_pem", "-----BEGIN CERTIFICATE REQUEST─────\nMIIB\n─────END CERTIFICATE REQUEST-----\n"},
|
|
{"double_pem_block", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"json_body", `{"csr":"MIIB","common_name":"attacker.com"}`},
|
|
{"xml_body", `<?xml version="1.0"?><csr>MIIB</csr>`},
|
|
{"shell_metacharacters", "$(whoami); rm -rf / #"},
|
|
{"sql_injection", "' OR 1=1; DROP TABLE certificates;--"},
|
|
{"long_garbage_10k", strings.Repeat("A", 10000)},
|
|
{"long_base64_not_csr", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xFF}, 5000))},
|
|
{"base64_with_newlines_garbage", "AAAAAAAAAAAAAAAA\nBBBBBBBBBBBBBBBB\nCCCCCCCCCCCCCCCC"},
|
|
{"percent_encoded_pem", "%2D%2D%2D%2D%2DBEGIN+CERTIFICATE+REQUEST%2D%2D%2D%2D%2D"},
|
|
}
|
|
}
|
|
|
|
// assertESTErrorResponse enforces the EST handler contract for adversarial CSRs:
|
|
// no panic, no 500, body is valid JSON (since Error helper emits JSON errors).
|
|
func assertESTErrorResponse(t *testing.T, w *httptest.ResponseRecorder, label string) {
|
|
t.Helper()
|
|
|
|
// The handler must never reach a 500 for parser-rejected CSRs — that would
|
|
// indicate a service call slipped through.
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("%s: handler returned 500 body=%q — adversarial CSR should not reach the service layer",
|
|
label, w.Body.String())
|
|
}
|
|
|
|
// The handler should return 400 Bad Request for adversarial CSR inputs.
|
|
// A 405 (method not allowed) is impossible here because we always POST.
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: expected 400, got %d (body=%q)", label, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// newESTHandlerWithTrap returns an ESTHandler whose service panics if reached.
|
|
// This is the core invariant for Tier 1F: adversarial CSRs must be rejected at
|
|
// the parser, never reaching SimpleEnroll/SimpleReEnroll on the service.
|
|
func newESTHandlerWithTrap() (ESTHandler, *trappedESTService) {
|
|
svc := &trappedESTService{}
|
|
return NewESTHandler(svc), svc
|
|
}
|
|
|
|
// trappedESTService is a mock that fails the test if any service method is
|
|
// called with an adversarial CSR. The parser should reject these before they
|
|
// get here.
|
|
type trappedESTService struct {
|
|
serviceCalled bool
|
|
}
|
|
|
|
func (t *trappedESTService) GetCACerts(ctx context.Context) (string, error) {
|
|
t.serviceCalled = true
|
|
return "", errors.New("trap: GetCACerts should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: SimpleEnroll should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: SimpleReEnroll should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: GetCSRAttrs should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*domain.ESTServerKeygenResult, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: SimpleServerKeygen should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
// TestESTSimpleEnroll_AdversarialCSRs runs each adversarial CSR through the
|
|
// enrollment endpoint.
|
|
func TestESTSimpleEnroll_AdversarialCSRs(t *testing.T) {
|
|
for _, tc := range adversarialCSRInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(tc.body))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
assertESTErrorResponse(t, w, "SimpleEnroll/"+tc.name)
|
|
|
|
if svc.serviceCalled {
|
|
t.Errorf("SimpleEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
|
tc.name, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleReEnroll_AdversarialCSRs runs each adversarial CSR through the
|
|
// re-enrollment endpoint. Same contract as simpleenroll.
|
|
func TestESTSimpleReEnroll_AdversarialCSRs(t *testing.T) {
|
|
for _, tc := range adversarialCSRInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(tc.body))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleReEnroll(w, req)
|
|
|
|
assertESTErrorResponse(t, w, "SimpleReEnroll/"+tc.name)
|
|
|
|
if svc.serviceCalled {
|
|
t.Errorf("SimpleReEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
|
tc.name, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_HugeBody verifies the handler's 1 MiB limit truncates
|
|
// oversized requests at the LimitReader boundary. We send a 2 MiB body of
|
|
// base64 garbage and confirm the handler rejects it cleanly (400, no panic,
|
|
// no 500) and the service is never reached.
|
|
func TestESTSimpleEnroll_HugeBody(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on 2 MiB body: %v", r)
|
|
}
|
|
}()
|
|
|
|
// 2 MiB of base64-valid garbage: the LimitReader will truncate to 1 MiB, and
|
|
// the truncated base64 chunk won't parse as a valid PKCS#10 CSR.
|
|
huge := strings.Repeat("A", 2<<20)
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(huge))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
// Contract: 400 Bad Request (parser fail), no panic, no 500.
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("HugeBody: handler returned 500 for 2 MiB body (body=%q)", w.Body.String())
|
|
}
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("HugeBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Error("HugeBody: service was reached with 2 MiB adversarial body")
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_ExactlyAtLimit sends a body exactly at the 1 MiB
|
|
// LimitReader boundary. The body is still garbage (won't parse as CSR), but we
|
|
// verify the handler doesn't panic or hang on the boundary case.
|
|
func TestESTSimpleEnroll_ExactlyAtLimit(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on exact-limit body: %v", r)
|
|
}
|
|
}()
|
|
|
|
atLimit := strings.Repeat("A", 1<<20) // exactly 1 MiB
|
|
|
|
h, _ := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(atLimit))
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("ExactlyAtLimit: handler returned 500 (body=%q)", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_MultipartBody sends a multipart/form-data body that a
|
|
// naive parser might try to unwrap. The handler should treat the raw bytes as
|
|
// a CSR payload and reject them.
|
|
func TestESTSimpleEnroll_MultipartBody(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on multipart body: %v", r)
|
|
}
|
|
}()
|
|
|
|
multipart := "--boundary\r\nContent-Disposition: form-data; name=\"csr\"\r\n\r\nMIIB\r\n--boundary--\r\n"
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(multipart))
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("MultipartBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Error("MultipartBody: service was reached with multipart wrapper")
|
|
}
|
|
}
|
|
|
|
// TestESTCACerts_MethodAbuse verifies the /cacerts endpoint only accepts GET
|
|
// and rejects every other method cleanly. This is a small safety check for
|
|
// the spec invariant.
|
|
func TestESTCACerts_MethodAbuse(t *testing.T) {
|
|
methods := []string{
|
|
http.MethodPost, http.MethodPut, http.MethodDelete,
|
|
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
|
"TRACE", "CONNECT", "PROPFIND", "BOGUS",
|
|
}
|
|
|
|
for _, method := range methods {
|
|
t.Run(method, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on method %s: %v", method, r)
|
|
}
|
|
}()
|
|
|
|
h, _ := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(method, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
// HEAD on a GET handler in Go's stdlib is normally accepted, but
|
|
// this handler enforces strict GET-only — so HEAD should also get 405.
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_MethodAbuse verifies strict POST-only enforcement.
|
|
func TestESTSimpleEnroll_MethodAbuse(t *testing.T) {
|
|
methods := []string{
|
|
http.MethodGet, http.MethodPut, http.MethodDelete,
|
|
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
|
"TRACE", "CONNECT",
|
|
}
|
|
|
|
for _, method := range methods {
|
|
t.Run(method, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on method %s: %v", method, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(method, "/.well-known/est/simpleenroll", strings.NewReader("body"))
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Errorf("method %s: service was called for non-POST", method)
|
|
}
|
|
})
|
|
}
|
|
}
|