mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
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:
+8
-134
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user