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>
This commit is contained in:
Shankar
2026-04-15 16:47:18 -04:00
parent 8a27557773
commit 98bb57e6b4
16 changed files with 1390 additions and 173 deletions
+8 -134
View File
@@ -12,6 +12,7 @@ import (
"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.
@@ -67,7 +68,7 @@ func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
}
// Parse PEM to DER for PKCS#7 encoding
derCerts, err := pemToDERChain(caCertPEM)
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
@@ -75,7 +76,7 @@ func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
}
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
@@ -237,7 +238,7 @@ func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTE
var derCerts [][]byte
// Add the issued certificate
certDER, err := pemToDERChain(result.CertPEM)
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
if err != nil || len(certDER) == 0 {
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
return
@@ -246,14 +247,14 @@ func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTE
// Add the CA chain if present
if result.ChainPEM != "" {
chainDER, err := pemToDERChain(result.ChainPEM)
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
if err == nil {
derCerts = append(derCerts, chainDER...)
}
}
// Build PKCS#7 certs-only
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
if err != nil {
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
return
@@ -273,132 +274,5 @@ func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTE
}
}
// pemToDERChain converts PEM-encoded certificates to a slice of DER-encoded certificates.
func pemToDERChain(pemData string) ([][]byte, error) {
var derCerts [][]byte
rest := []byte(pemData)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
derCerts = append(derCerts, block.Bytes)
}
}
if len(derCerts) == 0 {
return nil, fmt.Errorf("no certificates found in PEM data")
}
return derCerts, nil
}
// buildCertsOnlyPKCS7 creates a degenerate PKCS#7 SignedData structure containing only certificates.
// This is the "certs-only" format specified in RFC 7030 Section 4.1.3 for /cacerts responses
// and enrollment responses.
//
// ASN.1 structure (simplified):
//
// ContentInfo {
// contentType: signedData (1.2.840.113549.1.7.2)
// content: SignedData {
// version: 1
// digestAlgorithms: {} (empty)
// encapContentInfo: { contentType: data (1.2.840.113549.1.7.1) }
// certificates: [cert1, cert2, ...]
// signerInfos: {} (empty)
// }
// }
func buildCertsOnlyPKCS7(derCerts [][]byte) ([]byte, error) {
// We build the ASN.1 manually to avoid pulling in a PKCS#7 library.
// This is a well-defined, static structure — no signing needed.
// OID for signedData: 1.2.840.113549.1.7.2
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
// OID for data: 1.2.840.113549.1.7.1
oidData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
// Build certificates [0] IMPLICIT SET OF Certificate
var certsContent []byte
for _, cert := range derCerts {
certsContent = append(certsContent, cert...)
}
certsField := asn1WrapImplicit(0, certsContent)
// Build encapContentInfo: SEQUENCE { OID data }
encapContentInfo := asn1WrapSequence(oidData)
// Build digestAlgorithms: SET {} (empty)
digestAlgorithms := asn1WrapSet(nil)
// Build signerInfos: SET {} (empty)
signerInfos := asn1WrapSet(nil)
// Version: INTEGER 1
version := []byte{0x02, 0x01, 0x01}
// Build SignedData SEQUENCE
var signedDataContent []byte
signedDataContent = append(signedDataContent, version...)
signedDataContent = append(signedDataContent, digestAlgorithms...)
signedDataContent = append(signedDataContent, encapContentInfo...)
signedDataContent = append(signedDataContent, certsField...)
signedDataContent = append(signedDataContent, signerInfos...)
signedData := asn1WrapSequence(signedDataContent)
// Wrap in [0] EXPLICIT for ContentInfo.content
contentField := asn1WrapExplicit(0, signedData)
// Build ContentInfo SEQUENCE
var contentInfoContent []byte
contentInfoContent = append(contentInfoContent, oidSignedData...)
contentInfoContent = append(contentInfoContent, contentField...)
contentInfo := asn1WrapSequence(contentInfoContent)
return contentInfo, nil
}
// asn1WrapSequence wraps content in an ASN.1 SEQUENCE tag (0x30).
func asn1WrapSequence(content []byte) []byte {
return asn1Wrap(0x30, content)
}
// asn1WrapSet wraps content in an ASN.1 SET tag (0x31).
func asn1WrapSet(content []byte) []byte {
return asn1Wrap(0x31, content)
}
// asn1WrapExplicit wraps content in an ASN.1 context-specific EXPLICIT tag.
func asn1WrapExplicit(tag int, content []byte) []byte {
return asn1Wrap(byte(0xa0|tag), content)
}
// asn1WrapImplicit wraps content in an ASN.1 context-specific IMPLICIT CONSTRUCTED tag.
func asn1WrapImplicit(tag int, content []byte) []byte {
return asn1Wrap(byte(0xa0|tag), content)
}
// asn1Wrap wraps content with an ASN.1 tag and length.
func asn1Wrap(tag byte, content []byte) []byte {
length := len(content)
var result []byte
result = append(result, tag)
result = append(result, asn1EncodeLength(length)...)
result = append(result, content...)
return result
}
// asn1EncodeLength encodes a length in ASN.1 DER format.
func asn1EncodeLength(length int) []byte {
if length < 0x80 {
return []byte{byte(length)}
}
// Long form
var lengthBytes []byte
l := length
for l > 0 {
lengthBytes = append([]byte{byte(l & 0xff)}, lengthBytes...)
l >>= 8
}
return append([]byte{byte(0x80 | len(lengthBytes))}, lengthBytes...)
}
// NOTE: PKCS#7 helpers (BuildCertsOnlyPKCS7, PEMToDERChain, ASN.1 wrappers)
// are in the shared internal/pkcs7 package, used by both EST and SCEP handlers.
+10 -34
View File
@@ -18,6 +18,7 @@ import (
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
)
// mockESTService implements ESTService for testing.
@@ -338,12 +339,12 @@ func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) {
}
}
func TestBuildCertsOnlyPKCS7(t *testing.T) {
// Test with a dummy DER certificate
func TestBuildCertsOnlyPKCS7_ViaSharedPackage(t *testing.T) {
// Test with a dummy DER certificate via shared pkcs7 package
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
result, err := buildCertsOnlyPKCS7([][]byte{dummyCert})
result, err := pkcs7.BuildCertsOnlyPKCS7([][]byte{dummyCert})
if err != nil {
t.Fatalf("buildCertsOnlyPKCS7 failed: %v", err)
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty PKCS#7 output")
@@ -354,49 +355,24 @@ func TestBuildCertsOnlyPKCS7(t *testing.T) {
}
}
func TestPemToDERChain(t *testing.T) {
func TestPemToDERChain_ViaSharedPackage(t *testing.T) {
pemData := generateTestCertPEM(t)
certs, err := pemToDERChain(pemData)
certs, err := pkcs7.PEMToDERChain(pemData)
if err != nil {
t.Fatalf("pemToDERChain failed: %v", err)
t.Fatalf("PEMToDERChain failed: %v", err)
}
if len(certs) != 1 {
t.Errorf("expected 1 cert, got %d", len(certs))
}
}
func TestPemToDERChain_NoCerts(t *testing.T) {
_, err := pemToDERChain("not a PEM")
func TestPemToDERChain_NoCerts_ViaSharedPackage(t *testing.T) {
_, err := pkcs7.PEMToDERChain("not a PEM")
if err == nil {
t.Error("expected error for invalid PEM")
}
}
func TestASN1EncodeLength(t *testing.T) {
tests := []struct {
length int
expected []byte
}{
{0, []byte{0x00}},
{1, []byte{0x01}},
{127, []byte{0x7f}},
{128, []byte{0x81, 0x80}},
{256, []byte{0x82, 0x01, 0x00}},
}
for _, tt := range tests {
result := asn1EncodeLength(tt.length)
if len(result) != len(tt.expected) {
t.Errorf("asn1EncodeLength(%d): expected %d bytes, got %d", tt.length, len(tt.expected), len(result))
continue
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("asn1EncodeLength(%d): byte %d: expected 0x%02x, got 0x%02x", tt.length, i, tt.expected[i], result[i])
}
}
}
}
func TestESTCSRAttrs_ServiceError(t *testing.T) {
svc := &mockESTService{
CSRAttrsErr: errors.New("service error"),
+353
View File
@@ -0,0 +1,353 @@
package handler
import (
"context"
"crypto/x509"
"encoding/asn1"
"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"
)
// SCEPService defines the service interface for SCEP enrollment operations.
// SCEP (RFC 8894) is a protocol for certificate enrollment used by MDM platforms
// and network devices.
type SCEPService interface {
// GetCACaps returns the SCEP server capabilities as a newline-separated string.
GetCACaps(ctx context.Context) string
// GetCACert returns the PEM-encoded CA certificate chain.
GetCACert(ctx context.Context) (string, error)
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
}
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
//
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
// All operations use GET or POST to the same path.
//
// Supported operations:
// - GET ?operation=GetCACaps — server capabilities
// - GET ?operation=GetCACert — CA certificate distribution
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
type SCEPHandler struct {
svc SCEPService
}
// NewSCEPHandler creates a new SCEPHandler.
func NewSCEPHandler(svc SCEPService) SCEPHandler {
return SCEPHandler{svc: svc}
}
// HandleSCEP is the single entry point for all SCEP operations.
// It dispatches based on the "operation" query parameter.
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
operation := r.URL.Query().Get("operation")
switch operation {
case "GetCACaps":
h.getCACaps(w, r)
case "GetCACert":
h.getCACert(w, r)
case "PKIOperation":
h.pkiOperation(w, r)
default:
http.Error(w, fmt.Sprintf("Unknown SCEP operation: %s", operation), http.StatusBadRequest)
}
}
// getCACaps handles GET ?operation=GetCACaps
// Returns the SCEP server capabilities as plaintext, one per line.
func (h SCEPHandler) getCACaps(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
caps := h.svc.GetCACaps(r.Context())
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(caps))
}
// getCACert handles GET ?operation=GetCACert
// Returns the CA certificate(s). Single cert as DER, chain as PKCS#7.
func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
caCertPEM, err := h.svc.GetCACert(r.Context())
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificate: %v", err), requestID)
return
}
// Parse PEM to DER chain
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
if err != nil {
requestID := middleware.GetRequestID(r.Context())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to parse CA certificates", requestID)
return
}
if len(derCerts) == 1 {
// Single CA cert — return as raw DER
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
w.WriteHeader(http.StatusOK)
w.Write(derCerts[0])
return
}
// Multiple certs (CA + RA or chain) — return as PKCS#7
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
}
w.Header().Set("Content-Type", "application/x-x509-ca-ra-cert")
w.WriteHeader(http.StatusOK)
w.Write(pkcs7Data)
}
// pkiOperation handles POST ?operation=PKIOperation
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
func (h SCEPHandler) pkiOperation(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())
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID)
return
}
defer r.Body.Close()
if len(body) == 0 {
ErrorWithRequestID(w, http.StatusBadRequest, "Empty request body", requestID)
return
}
// Extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
return
}
// Validate the CSR
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
return
}
if err := csr.CheckSignature(); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("CSR signature invalid: %v", err), requestID)
return
}
// Convert DER CSR to PEM for the service layer
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrDER,
}))
result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID)
if err != nil {
if strings.Contains(err.Error(), "challenge password") {
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
return
}
// Build response: issued cert wrapped in PKCS#7 certs-only
h.writeSCEPResponse(w, result)
}
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
var derCerts [][]byte
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...)
if result.ChainPEM != "" {
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
if err == nil {
derCerts = append(derCerts, chainDER...)
}
}
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/x-pki-message")
w.WriteHeader(http.StatusOK)
w.Write(pkcs7Data)
}
// extractCSRFromPKCS7 extracts a PKCS#10 CSR from a SCEP PKCS#7 SignedData envelope.
//
// SCEP clients wrap the CSR in a PKCS#7 SignedData structure. For the MVP, we parse
// the outer ASN.1 structure to find the encapsulated content (the CSR bytes), and
// extract the challenge password from the CSR attributes.
//
// Returns: csrDER, challengePassword, transactionID, error
func extractCSRFromPKCS7(data []byte) ([]byte, string, string, error) {
// Try to decode as PKCS#7 SignedData
csrDER, err := parseSignedDataForCSR(data)
if err != nil {
// Fallback: some clients send the CSR directly (not wrapped in PKCS#7)
// or send base64-encoded data
decoded, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
if decErr == nil {
// Try the decoded data as PKCS#7
csrDER2, err2 := parseSignedDataForCSR(decoded)
if err2 == nil {
return extractCSRFields(csrDER2)
}
// Maybe the decoded data IS the CSR directly
if _, parseErr := x509.ParseCertificateRequest(decoded); parseErr == nil {
return extractCSRFields(decoded)
}
}
// Maybe the raw data IS the CSR directly (no PKCS#7 wrapping)
if _, parseErr := x509.ParseCertificateRequest(data); parseErr == nil {
return extractCSRFields(data)
}
return nil, "", "", fmt.Errorf("failed to extract CSR from PKCS#7: %w", err)
}
return extractCSRFields(csrDER)
}
// extractCSRFields extracts the challenge password and transaction ID from CSR attributes.
func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return nil, "", "", fmt.Errorf("invalid CSR: %w", err)
}
challengePassword := ""
transactionID := ""
// OID for challengePassword: 1.2.840.113549.1.9.7
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
// Extract challenge password from parsed CSR attributes.
// Attributes is []pkix.AttributeTypeAndValueSET where each has Type (OID)
// and Value ([][]pkix.AttributeTypeAndValue). The challenge password value
// is stored as a string in the inner AttributeTypeAndValue.Value field.
for _, attr := range csr.Attributes {
if attr.Type.Equal(oidChallengePassword) {
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
if pwd, ok := attr.Value[0][0].Value.(string); ok {
challengePassword = pwd
}
}
}
}
// Use CN as fallback transaction ID if not found in attributes
if transactionID == "" && csr.Subject.CommonName != "" {
transactionID = csr.Subject.CommonName
}
return csrDER, challengePassword, transactionID, nil
}
// pkcs7ContentInfo represents the outer ContentInfo structure.
type pkcs7ContentInfo struct {
ContentType asn1.ObjectIdentifier
Content asn1.RawValue `asn1:"explicit,tag:0"`
}
// pkcs7SignedData represents a simplified SignedData structure for CSR extraction.
type pkcs7SignedData struct {
Version int
DigestAlgorithms asn1.RawValue
EncapContentInfo asn1.RawValue
}
// pkcs7EncapContent represents the EncapsulatedContentInfo.
type pkcs7EncapContent struct {
ContentType asn1.ObjectIdentifier
Content asn1.RawValue `asn1:"explicit,optional,tag:0"`
}
// parseSignedDataForCSR extracts the encapsulated content (CSR) from PKCS#7 SignedData.
func parseSignedDataForCSR(data []byte) ([]byte, error) {
var contentInfo pkcs7ContentInfo
rest, err := asn1.Unmarshal(data, &contentInfo)
if err != nil {
return nil, fmt.Errorf("failed to parse ContentInfo: %w", err)
}
if len(rest) > 0 {
// Trailing data is OK for some implementations
}
// OID for signedData: 1.2.840.113549.1.7.2
oidSignedData := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
if !contentInfo.ContentType.Equal(oidSignedData) {
return nil, fmt.Errorf("not SignedData: got OID %v", contentInfo.ContentType)
}
// Parse the SignedData
var signedData pkcs7SignedData
_, err = asn1.Unmarshal(contentInfo.Content.Bytes, &signedData)
if err != nil {
return nil, fmt.Errorf("failed to parse SignedData: %w", err)
}
// Parse the EncapsulatedContentInfo to get the CSR
var encapContent pkcs7EncapContent
_, err = asn1.Unmarshal(signedData.EncapContentInfo.FullBytes, &encapContent)
if err != nil {
return nil, fmt.Errorf("failed to parse EncapsulatedContentInfo: %w", err)
}
if len(encapContent.Content.Bytes) == 0 {
return nil, fmt.Errorf("empty encapsulated content")
}
// The content may be wrapped in an OCTET STRING
var csrBytes []byte
var octetString asn1.RawValue
if _, err := asn1.Unmarshal(encapContent.Content.Bytes, &octetString); err == nil && octetString.Tag == asn1.TagOctetString {
csrBytes = octetString.Bytes
} else {
csrBytes = encapContent.Content.Bytes
}
// Validate it's a parseable CSR
if _, err := x509.ParseCertificateRequest(csrBytes); err != nil {
return nil, fmt.Errorf("extracted content is not a valid CSR: %w", err)
}
return csrBytes, nil
}
+262
View File
@@ -0,0 +1,262 @@
package handler
import (
"context"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// mockSCEPService implements SCEPService for testing.
type mockSCEPService struct {
CACaps string
CACertPEM string
CACertErr error
EnrollResult *domain.SCEPEnrollResult
EnrollErr error
}
func (m *mockSCEPService) GetCACaps(ctx context.Context) string {
if m.CACaps != "" {
return m.CACaps
}
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
}
func (m *mockSCEPService) GetCACert(ctx context.Context) (string, error) {
return m.CACertPEM, m.CACertErr
}
func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
return m.EnrollResult, m.EnrollErr
}
func TestSCEP_GetCACaps_Success(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "text/plain" {
t.Errorf("expected text/plain, got %s", ct)
}
body := w.Body.String()
if !strings.Contains(body, "POSTPKIOperation") {
t.Errorf("expected POSTPKIOperation in response, got: %s", body)
}
if !strings.Contains(body, "SHA-256") {
t.Errorf("expected SHA-256 in response, got: %s", body)
}
}
func TestSCEP_GetCACaps_MethodNotAllowed(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestSCEP_GetCACert_Success_SingleCert(t *testing.T) {
certPEM := generateTestCertPEM(t)
svc := &mockSCEPService{
CACertPEM: certPEM,
}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "application/x-x509-ca-cert" {
t.Errorf("expected application/x-x509-ca-cert, got %s", ct)
}
if w.Body.Len() == 0 {
t.Error("expected non-empty body")
}
}
func TestSCEP_GetCACert_MethodNotAllowed(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACert", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestSCEP_GetCACert_ServiceError(t *testing.T) {
svc := &mockSCEPService{
CACertErr: errors.New("CA unavailable"),
}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestSCEP_PKIOperation_MethodNotAllowed(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep?operation=PKIOperation", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestSCEP_PKIOperation_EmptyBody(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(""))
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestSCEP_PKIOperation_InvalidBody(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader("not-valid-asn1-or-csr"))
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSCEP_PKIOperation_ServiceError(t *testing.T) {
svc := &mockSCEPService{
EnrollErr: errors.New("enrollment failed"),
}
h := NewSCEPHandler(svc)
// Generate a valid raw CSR DER to send as body (fallback path)
csrPEM := generateTestCSRPEM(t)
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
t.Fatal("failed to decode CSR PEM")
}
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestSCEP_PKIOperation_Success_RawCSR(t *testing.T) {
certPEM := generateTestCertPEM(t)
svc := &mockSCEPService{
EnrollResult: &domain.SCEPEnrollResult{
CertPEM: certPEM,
ChainPEM: "",
},
}
h := NewSCEPHandler(svc)
csrPEM := generateTestCSRPEM(t)
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
t.Fatal("failed to decode CSR PEM")
}
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "application/x-pki-message" {
t.Errorf("expected application/x-pki-message, got %s", ct)
}
}
func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
svc := &mockSCEPService{
EnrollErr: errors.New("invalid challenge password"),
}
h := NewSCEPHandler(svc)
csrPEM := generateTestCSRPEM(t)
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
t.Fatal("failed to decode CSR PEM")
}
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestSCEP_UnknownOperation(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep?operation=UnknownOp", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestSCEP_MissingOperation(t *testing.T) {
svc := &mockSCEPService{}
h := NewSCEPHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/scep", nil)
w := httptest.NewRecorder()
h.HandleSCEP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}