Files
certctl/internal/api/handler/est.go
T
shankar0123 bcefb11e65 feat(M51): add SCEP server (RFC 8894) for MDM and network device enrollment
Implements Simple Certificate Enrollment Protocol with single-endpoint
operation-based dispatch (GetCACaps, GetCACert, PKIOperation), PKCS#7
SignedData CSR extraction with fallback for raw/base64 CSR, challenge
password authentication via CSR attributes, and shared internal/pkcs7
package extracted from EST handler to eliminate code duplication.

24 new tests (11 service + 13 handler) plus 5 shared pkcs7 package tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:47:18 -04:00

279 lines
9.0 KiB
Go

package handler
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
)
// ESTService defines the service interface for EST enrollment operations.
// EST (RFC 7030) is a protocol for certificate enrollment over HTTPS.
type ESTService interface {
// GetCACerts returns the PEM-encoded CA certificate chain for the EST issuer.
GetCACerts(ctx context.Context) (string, error)
// SimpleEnroll processes a PKCS#10 CSR and returns a signed certificate.
SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
// SimpleReEnroll processes a re-enrollment CSR (same as enroll for our purposes).
SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error)
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
GetCSRAttrs(ctx context.Context) ([]byte, error)
}
// ESTHandler handles HTTP requests for the EST protocol (RFC 7030).
//
// EST endpoints are served under /.well-known/est/ per the RFC.
// Wire format: base64-encoded DER (PKCS#7 for certs, PKCS#10 for CSRs).
//
// Supported operations:
// - GET /.well-known/est/cacerts — CA certificate distribution
// - POST /.well-known/est/simpleenroll — initial enrollment
// - POST /.well-known/est/simplereenroll — re-enrollment
// - GET /.well-known/est/csrattrs — CSR attributes
type ESTHandler struct {
svc ESTService
}
// NewESTHandler creates a new ESTHandler.
func NewESTHandler(svc ESTService) ESTHandler {
return ESTHandler{svc: svc}
}
// CACerts handles GET /.well-known/est/cacerts
// Returns the CA certificate chain as base64-encoded PKCS#7 (certs-only).
// Per RFC 7030 Section 4.1, this is a "certs-only" CMC Simple PKI Response.
// For simplicity and broad client compatibility, we return base64-encoded DER certificates.
func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
caCertPEM, err := h.svc.GetCACerts(r.Context())
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificates: %v", err), requestID)
return
}
// Parse PEM to DER for PKCS#7 encoding
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
return
}
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
return
}
// RFC 7030 Section 4.1.3: response is base64-encoded application/pkcs7-mime
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
w.Header().Set("Content-Transfer-Encoding", "base64")
w.WriteHeader(http.StatusOK)
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
// Write base64 with line breaks at 76 chars per RFC 2045
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
w.Write([]byte(encoded[i:end]))
w.Write([]byte("\r\n"))
}
}
// SimpleEnroll handles POST /.well-known/est/simpleenroll
// Accepts a base64-encoded PKCS#10 CSR and returns a base64-encoded PKCS#7 certificate.
func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
requestID := middleware.GetRequestID(r.Context())
csrPEM, err := h.readCSRFromRequest(r)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
return
}
result, err := h.svc.SimpleEnroll(r.Context(), csrPEM)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
return
}
h.writeCertResponse(w, result)
}
// SimpleReEnroll handles POST /.well-known/est/simplereenroll
// Same as SimpleEnroll but for re-enrollment (certificate renewal).
func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
requestID := middleware.GetRequestID(r.Context())
csrPEM, err := h.readCSRFromRequest(r)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
return
}
result, err := h.svc.SimpleReEnroll(r.Context(), csrPEM)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Re-enrollment failed: %v", err), requestID)
return
}
h.writeCertResponse(w, result)
}
// CSRAttrs handles GET /.well-known/est/csrattrs
// Returns the CSR attributes the server wants the client to include in enrollment requests.
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
attrs, err := h.svc.GetCSRAttrs(r.Context())
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID)
return
}
if len(attrs) == 0 {
// No specific attributes required — return 204
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/csrattrs")
w.Header().Set("Content-Transfer-Encoding", "base64")
w.WriteHeader(http.StatusOK)
w.Write([]byte(base64.StdEncoding.EncodeToString(attrs)))
}
// readCSRFromRequest reads and decodes the CSR from an EST enrollment request.
// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10.
func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
return "", fmt.Errorf("failed to read request body: %w", err)
}
defer r.Body.Close()
if len(body) == 0 {
return "", fmt.Errorf("empty request body")
}
// Check if it's already PEM-encoded (some clients send PEM directly)
bodyStr := strings.TrimSpace(string(body))
if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") {
// Validate it parses
block, _ := pem.Decode([]byte(bodyStr))
if block == nil {
return "", fmt.Errorf("invalid PEM-encoded CSR")
}
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
return "", fmt.Errorf("invalid CSR: %w", err)
}
return bodyStr, nil
}
// EST standard: base64-encoded DER PKCS#10
derBytes, err := base64.StdEncoding.DecodeString(bodyStr)
if err != nil {
// Try with padding/whitespace stripped
cleaned := strings.Map(func(r rune) rune {
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
return -1
}
return r
}, bodyStr)
derBytes, err = base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return "", fmt.Errorf("failed to decode base64 CSR: %w", err)
}
}
// Validate it's a valid PKCS#10 CSR
if _, err := x509.ParseCertificateRequest(derBytes); err != nil {
return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err)
}
// Convert DER to PEM for internal use (certctl services expect PEM)
csrPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: derBytes,
})
return string(csrPEM), nil
}
// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7.
func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) {
// Parse cert and chain PEM to DER
var derCerts [][]byte
// Add the issued certificate
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
if err != nil || len(certDER) == 0 {
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
return
}
derCerts = append(derCerts, certDER...)
// Add the CA chain if present
if result.ChainPEM != "" {
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
if err == nil {
derCerts = append(derCerts, chainDER...)
}
}
// Build PKCS#7 certs-only
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
if err != nil {
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
w.Header().Set("Content-Transfer-Encoding", "base64")
w.WriteHeader(http.StatusOK)
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
w.Write([]byte(encoded[i:end]))
w.Write([]byte("\r\n"))
}
}
// NOTE: PKCS#7 helpers (BuildCertsOnlyPKCS7, PEMToDERChain, ASN.1 wrappers)
// are in the shared internal/pkcs7 package, used by both EST and SCEP handlers.