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:
shankar0123
2026-04-15 16:47:18 -04:00
parent 75cf8475f5
commit bcefb11e65
16 changed files with 1390 additions and 173 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ For a detailed comparison with other competitors and enterprise platforms, see [
- **Everything is auditable.** Immutable append-only audit trail records every lifecycle action, every API call, and every approval decision. Certificate digest emails deliver daily briefings. Prometheus metrics endpoint for Grafana dashboards.
- **Standards-based protocol support.** EST server (RFC 7030) for device and WiFi certificate enrollment. ACME ARI (RFC 9773) for CA-directed renewal timing. S/MIME certificate issuance with email protection EKU for end-to-end encrypted email. DER-encoded X.509 CRL and embedded OCSP responder for revocation infrastructure.
- **Standards-based protocol support.** EST server (RFC 7030) for device and WiFi certificate enrollment. SCEP server (RFC 8894) for MDM platforms and network device enrollment. ACME ARI (RFC 9773) for CA-directed renewal timing. S/MIME certificate issuance with email protection EKU for end-to-end encrypted email. DER-encoded X.509 CRL and embedded OCSP responder for revocation infrastructure.
- **Multiple interfaces for different workflows.** REST API (107 routes) for automation, CLI for scripting, MCP server for AI assistants (Claude, Cursor, Windsurf), Helm chart for Kubernetes, and the web dashboard (24 pages) for day-to-day operations.
+20
View File
@@ -339,6 +339,26 @@ func main() {
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
}
// Register SCEP (RFC 8894) handlers if enabled
if cfg.SCEP.Enabled {
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
if !ok {
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
os.Exit(1)
}
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
if cfg.SCEP.ProfileID != "" {
scepService.SetProfileID(cfg.SCEP.ProfileID)
}
scepHandler := handler.NewSCEPHandler(scepService)
apiRouter.RegisterSCEPHandlers(scepHandler)
logger.Info("SCEP server enabled",
"issuer_id", cfg.SCEP.IssuerID,
"profile_id", cfg.SCEP.ProfileID,
"challenge_password_set", cfg.SCEP.ChallengePassword != "",
"endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}")
}
logger.Info("registered all API handlers")
// Build middleware stack
+40
View File
@@ -673,6 +673,46 @@ type ESTService interface {
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
### SCEP Server (RFC 8894)
The SCEP (Simple Certificate Enrollment Protocol) server provides certificate enrollment for MDM platforms and network devices. It runs at `/scep` with operation-based dispatch via query parameters per RFC 8894.
**Architecture:** SCEP follows the exact same layering as EST — a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. The `SCEPService` bridges the `SCEPHandler` to whichever issuer connector is configured via `CERTCTL_SCEP_ISSUER_ID`.
```
Client (MDM, network device, SCEP client)
SCEPHandler (handler layer)
│ PKCS#7 envelope parsing, CSR extraction, challenge password extraction
SCEPService (service layer)
│ Challenge password validation, CSR validation, CN/SAN extraction, audit recording
IssuerConnector (connector layer via IssuerConnectorAdapter)
│ Certificate signing (Local CA, step-ca, etc.)
Signed certificate returned as PKCS#7 certs-only
```
**Wire format:** SCEP clients wrap CSRs in PKCS#7 SignedData envelopes. The handler parses the outer ASN.1 ContentInfo → SignedData → EncapsulatedContentInfo to extract the CSR bytes. Fallback paths handle base64-encoded PKCS#7 and raw CSR submissions (for simpler clients). Responses use PKCS#7 certs-only via the shared `internal/pkcs7` package (same as EST). Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7.
**Authentication:** SCEP uses challenge passwords embedded in CSR attributes (OID 1.2.840.113549.1.9.7) rather than TLS client certificates. The server validates the challenge password against `CERTCTL_SCEP_CHALLENGE_PASSWORD`. When no challenge password is configured, any value is accepted.
**Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion):
```go
type SCEPService interface {
GetCACaps(ctx context.Context) string
GetCACert(ctx context.Context) (string, error)
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
}
```
**Shared PKCS#7 package:** Both EST and SCEP handlers share a common `internal/pkcs7` package for building PKCS#7 certs-only responses and PEM-to-DER chain conversion, eliminating code duplication between the two enrollment protocols.
**Audit:** Every SCEP enrollment is recorded in the audit trail with `protocol: "SCEP"`, the CN, SANs, issuer ID, serial number, transaction ID, and optional profile ID.
## Security Model
### Private Key Management
+4 -4
View File
@@ -314,16 +314,16 @@ Each issuer handles revocation differently:
- **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status.
- **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument.
### EST Integration (GetCACertPEM)
### EST/SCEP Integration (GetCACertPEM)
The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) to distribute the CA chain to enrolling devices. Each issuer handles this differently:
The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by both the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) and the SCEP server's `GetCACert` operation (RFC 8894) to distribute the CA chain to enrolling devices. Each issuer handles this differently:
- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST issuer.
- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST/SCEP issuer.
- **ACME**: Returns error — ACME CAs provide chains per-issuance, not statically.
- **step-ca**: Returns error — step-ca serves its own `/root` endpoint for CA distribution.
- **OpenSSL/Custom CA**: Returns error — custom script-based CAs have no CA cert access through certctl.
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
Note: EST and SCEP are not connectorsthey are protocol handlers (`internal/api/handler/est.go` and `internal/api/handler/scep.go`) that delegate certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID` or `CERTCTL_SCEP_ISSUER_ID`. Both share a common `internal/pkcs7` package for PKCS#7 response encoding. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
### Built-in: Vault PKI
+21
View File
@@ -444,6 +444,27 @@ Accepts both base64-encoded DER (EST standard) and PEM-encoded PKCS#10 CSR input
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer for EST enrollments |
| `CERTCTL_EST_PROFILE_ID` | (none) | Optional profile constraint |
### SCEP Server (RFC 8894)
<!-- Source: internal/service/scep.go, internal/api/handler/scep.go -->
Simple Certificate Enrollment Protocol for MDM platforms and network devices. Single endpoint with operation-based dispatch:
| Operation | Method | Description |
|---|---|---|
| `GetCACaps` | GET | Server capabilities (plaintext, one per line) |
| `GetCACert` | GET | CA certificate (DER for single cert, PKCS#7 for chain) |
| `PKIOperation` | POST | Certificate enrollment (PKCS#7-wrapped or raw CSR) |
SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSRs from PKCS#7 SignedData envelopes, with fallback support for base64-encoded and raw CSR submissions. Challenge password authentication via CSR attributes (OID 1.2.840.113549.1.9.7). Responses are PKCS#7 certs-only (same shared `internal/pkcs7` package as EST).
| Env Var | Default | Description |
|---|---|---|
| `CERTCTL_SCEP_ENABLED` | `false` | Enable SCEP endpoint |
| `CERTCTL_SCEP_ISSUER_ID` | `iss-local` | Issuer for SCEP enrollments |
| `CERTCTL_SCEP_PROFILE_ID` | (none) | Optional profile constraint |
| `CERTCTL_SCEP_CHALLENGE_PASSWORD` | (none) | Shared secret for enrollment authentication |
---
## ACME Renewal Information (RFC 9773)
+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)
}
}
+9
View File
@@ -238,6 +238,15 @@ func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs))
}
// RegisterSCEPHandlers sets up SCEP (RFC 8894) routes.
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
// Authentication is via challenge password in the CSR, not TLS client certs or API keys.
func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
// SCEP uses a single path; the handler dispatches on ?operation= query param
r.Register("GET /scep", http.HandlerFunc(scep.HandleSCEP))
r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
func (r *Router) GetMux() *http.ServeMux {
return r.mux
+27
View File
@@ -23,6 +23,7 @@ type Config struct {
Notifiers NotifierConfig
NetworkScan NetworkScanConfig
EST ESTConfig
SCEP SCEPConfig
Verification VerificationConfig
ACME ACMEConfig
Vault VaultConfig
@@ -417,6 +418,26 @@ type ESTConfig struct {
ProfileID string
}
// SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server.
type SCEPConfig struct {
// Enabled controls whether SCEP endpoints are available for device enrollment.
// Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/.
Enabled bool
// IssuerID selects which issuer connector processes SCEP certificate requests.
// Default: "iss-local". Must reference a configured issuer.
IssuerID string
// ProfileID optionally constrains SCEP enrollments to a specific certificate profile.
// Leave empty to allow SCEP to use any configured issuer's defaults.
ProfileID string
// ChallengePassword is the shared secret used to authenticate SCEP enrollment requests.
// Clients include this in the PKCS#10 CSR challengePassword attribute.
// Required when SCEP is enabled.
ChallengePassword string
}
// NetworkScanConfig controls the server-side active TLS scanner.
type NetworkScanConfig struct {
Enabled bool // Enable network scanning (default false)
@@ -594,6 +615,12 @@ func Load() (*Config, error) {
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
},
SCEP: SCEPConfig{
Enabled: getEnvBool("CERTCTL_SCEP_ENABLED", false),
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
},
Verification: VerificationConfig{
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
+40
View File
@@ -0,0 +1,40 @@
package domain
// SCEPEnrollResult holds the result of a SCEP (RFC 8894) enrollment operation.
type SCEPEnrollResult struct {
CertPEM string `json:"cert_pem"` // PEM-encoded signed certificate
ChainPEM string `json:"chain_pem"` // PEM-encoded CA chain
}
// SCEPMessageType identifies the type of SCEP PKI message.
type SCEPMessageType int
const (
// SCEPMessageTypePKCSReq is a PKCS#10 certificate request (initial enrollment).
SCEPMessageTypePKCSReq SCEPMessageType = 19
// SCEPMessageTypeGetCertInitial is a polling request for a pending certificate.
SCEPMessageTypeGetCertInitial SCEPMessageType = 20
)
// SCEPPKIStatus represents the status of a SCEP PKI operation.
type SCEPPKIStatus string
const (
// SCEPStatusSuccess indicates the request was granted.
SCEPStatusSuccess SCEPPKIStatus = "0"
// SCEPStatusFailure indicates the request was rejected.
SCEPStatusFailure SCEPPKIStatus = "2"
// SCEPStatusPending indicates the request is pending manual approval.
SCEPStatusPending SCEPPKIStatus = "3"
)
// SCEPFailInfo represents the reason for a SCEP failure.
type SCEPFailInfo string
const (
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
SCEPFailBadMessageCheck SCEPFailInfo = "1" // Integrity check failed
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
)
+136
View File
@@ -0,0 +1,136 @@
// Package pkcs7 provides ASN.1 helpers for building PKCS#7 structures.
// Used by EST (RFC 7030) and SCEP (RFC 8894) protocol handlers.
// No external dependencies — hand-rolled ASN.1 encoding only.
package pkcs7
import (
"encoding/pem"
"fmt"
)
// 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, and used by SCEP (RFC 8894) for GetCACert 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) {
// 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
}
// 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
}
// 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...)
}
+104
View File
@@ -0,0 +1,104 @@
package pkcs7
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
)
func generateTestCertPEM(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
}
func TestBuildCertsOnlyPKCS7(t *testing.T) {
dummyCert := []byte{0x30, 0x82, 0x01, 0x00}
result, err := BuildCertsOnlyPKCS7([][]byte{dummyCert})
if err != nil {
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty PKCS#7 output")
}
if result[0] != 0x30 {
t.Errorf("expected SEQUENCE tag (0x30), got 0x%02x", result[0])
}
}
func TestBuildCertsOnlyPKCS7_MultipleCerts(t *testing.T) {
cert1 := []byte{0x30, 0x82, 0x01, 0x00}
cert2 := []byte{0x30, 0x82, 0x02, 0x00}
result, err := BuildCertsOnlyPKCS7([][]byte{cert1, cert2})
if err != nil {
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty PKCS#7 output")
}
}
func TestPEMToDERChain_Success(t *testing.T) {
pemData := generateTestCertPEM(t)
certs, err := PEMToDERChain(pemData)
if err != nil {
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")
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])
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
package service
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"log/slog"
"strings"
"github.com/shankar0123/certctl/internal/domain"
)
// SCEPService implements the SCEP (RFC 8894) enrollment protocol.
// It delegates certificate operations to an existing IssuerConnector and records
// enrollment events in the audit trail.
type SCEPService struct {
issuer IssuerConnector
issuerID string
auditService *AuditService
logger *slog.Logger
profileID string // optional: constrain enrollments to a specific profile
challengePassword string // shared secret for enrollment authentication
}
// NewSCEPService creates a new SCEPService for the given issuer connector.
func NewSCEPService(issuerID string, issuer IssuerConnector, auditService *AuditService, logger *slog.Logger, challengePassword string) *SCEPService {
return &SCEPService{
issuer: issuer,
issuerID: issuerID,
auditService: auditService,
logger: logger,
challengePassword: challengePassword,
}
}
// SetProfileID constrains SCEP enrollments to a specific certificate profile.
func (s *SCEPService) SetProfileID(profileID string) {
s.profileID = profileID
}
// GetCACaps returns the capabilities of this SCEP server.
// RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line.
func (s *SCEPService) GetCACaps(ctx context.Context) string {
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
}
// GetCACert returns the PEM-encoded CA certificate chain for this SCEP server.
// RFC 8894 Section 3.5.1: GetCACert distributes the CA certificate(s).
func (s *SCEPService) GetCACert(ctx context.Context) (string, error) {
caPEM, err := s.issuer.GetCACertPEM(ctx)
if err != nil {
return "", fmt.Errorf("failed to get CA certificates from issuer %s: %w", s.issuerID, err)
}
if caPEM == "" {
return "", fmt.Errorf("issuer %s does not provide CA certificates for SCEP", s.issuerID)
}
return caPEM, nil
}
// PKCSReq processes a SCEP enrollment request.
// RFC 8894 Section 3.3.1: PKCSReq contains a PKCS#10 CSR for certificate enrollment.
// The CSR PEM and challenge password are extracted by the handler from the PKCS#7 envelope.
func (s *SCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
// Validate challenge password
if s.challengePassword != "" {
if challengePassword != s.challengePassword {
s.logger.Warn("SCEP enrollment rejected: invalid challenge password",
"transaction_id", transactionID)
return nil, fmt.Errorf("invalid challenge password")
}
}
return s.processEnrollment(ctx, csrPEM, transactionID, "scep_pkcsreq")
}
// processEnrollment handles the common enrollment logic.
func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, transactionID string, auditAction string) (*domain.SCEPEnrollResult, error) {
// Parse the CSR to extract CN and SANs
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
return nil, fmt.Errorf("invalid CSR PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
commonName := csr.Subject.CommonName
if commonName == "" {
return nil, fmt.Errorf("CSR must include a Common Name")
}
// Collect SANs
var sans []string
for _, dns := range csr.DNSNames {
sans = append(sans, dns)
}
for _, ip := range csr.IPAddresses {
sans = append(sans, ip.String())
}
for _, email := range csr.EmailAddresses {
sans = append(sans, email)
}
for _, uri := range csr.URIs {
sans = append(sans, uri.String())
}
s.logger.Info("SCEP enrollment request",
"action", auditAction,
"common_name", commonName,
"sans", strings.Join(sans, ","),
"transaction_id", transactionID,
"issuer", s.issuerID)
// Issue the certificate via the configured issuer connector
// SCEP enrollments use default EKUs (nil = serverAuth + clientAuth fallback in connector)
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
if err != nil {
s.logger.Error("SCEP enrollment failed",
"action", auditAction,
"common_name", commonName,
"transaction_id", transactionID,
"error", err)
return nil, fmt.Errorf("certificate issuance failed: %w", err)
}
// Audit the enrollment
if s.auditService != nil {
details := map[string]interface{}{
"common_name": commonName,
"sans": sans,
"issuer_id": s.issuerID,
"serial": result.Serial,
"transaction_id": transactionID,
"protocol": "SCEP",
}
if s.profileID != "" {
details["profile_id"] = s.profileID
}
_ = s.auditService.RecordEvent(ctx, "scep-client", "system", auditAction, "certificate", result.Serial, details)
}
s.logger.Info("SCEP enrollment successful",
"action", auditAction,
"common_name", commonName,
"serial", result.Serial,
"transaction_id", transactionID,
"not_after", result.NotAfter)
return &domain.SCEPEnrollResult{
CertPEM: result.CertPEM,
ChainPEM: result.ChainPEM,
}, nil
}
+195
View File
@@ -0,0 +1,195 @@
package service
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"testing"
)
func TestSCEPService_GetCACaps(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
caps := svc.GetCACaps(context.Background())
if caps == "" {
t.Error("expected non-empty capabilities")
}
if !strings.Contains(caps, "POSTPKIOperation") {
t.Errorf("expected POSTPKIOperation in caps, got: %s", caps)
}
if !strings.Contains(caps, "SHA-256") {
t.Errorf("expected SHA-256 in caps, got: %s", caps)
}
if !strings.Contains(caps, "SCEPStandard") {
t.Errorf("expected SCEPStandard in caps, got: %s", caps)
}
}
func TestSCEPService_GetCACert_Success(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
caPEM, err := svc.GetCACert(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if caPEM == "" {
t.Error("expected non-empty CA PEM")
}
}
func TestSCEPService_GetCACert_IssuerError(t *testing.T) {
mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
_, err := svc.GetCACert(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "CA unavailable") {
t.Errorf("expected error to contain 'CA unavailable', got: %v", err)
}
}
func TestSCEPService_PKCSReq_Success(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
result, err := svc.PKCSReq(context.Background(), csrPEM, "", "txn-001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.CertPEM == "" {
t.Error("expected non-empty CertPEM")
}
// Verify audit event was recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
}
func TestSCEPService_PKCSReq_InvalidCSR(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
_, err := svc.PKCSReq(context.Background(), "not-valid-pem", "", "txn-002")
if err == nil {
t.Fatal("expected error for invalid CSR")
}
}
func TestSCEPService_PKCSReq_MissingCN(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
csrPEM := generateCSRPEM(t, "", []string{"test.example.com"})
_, err := svc.PKCSReq(context.Background(), csrPEM, "", "txn-003")
if err == nil {
t.Fatal("expected error for missing CN")
}
if !strings.Contains(err.Error(), "Common Name") {
t.Errorf("expected 'Common Name' in error, got: %v", err)
}
}
func TestSCEPService_PKCSReq_IssuerError(t *testing.T) {
mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
csrPEM := generateCSRPEM(t, "test.example.com", nil)
_, err := svc.PKCSReq(context.Background(), csrPEM, "", "txn-004")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "issuance failed") {
t.Errorf("expected 'issuance failed', got: %v", err)
}
}
func TestSCEPService_PKCSReq_ChallengePassword_Valid(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "mdm-device.example.com", nil)
result, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-005")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestSCEPService_PKCSReq_ChallengePassword_Invalid(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "mdm-device.example.com", nil)
_, err := svc.PKCSReq(context.Background(), csrPEM, "wrong-password", "txn-006")
if err == nil {
t.Fatal("expected error for invalid challenge password")
}
if !strings.Contains(err.Error(), "challenge password") {
t.Errorf("expected 'challenge password' in error, got: %v", err)
}
}
func TestSCEPService_PKCSReq_ChallengePassword_NotRequired(t *testing.T) {
// When server has no challenge password configured, any value should be accepted
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
csrPEM := generateCSRPEM(t, "device.example.com", nil)
result, err := svc.PKCSReq(context.Background(), csrPEM, "any-value", "txn-007")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestSCEPService_PKCSReq_WithProfile(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
svc.SetProfileID("profile-mdm-device")
csrPEM := generateCSRPEM(t, "device.example.com", nil)
result, err := svc.PKCSReq(context.Background(), csrPEM, "", "txn-008")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
// Verify audit event includes profile_id
if len(auditRepo.Events) == 0 {
t.Fatal("expected audit event")
}
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
if lastEvent.Details == nil {
t.Fatal("expected audit details")
}
}