diff --git a/README.md b/README.md index 6d92000..53f755d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/server/main.go b/cmd/server/main.go index a50d1f1..d27f8ea 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 2be4ce8..349f3db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/connectors.md b/docs/connectors.md index a12dd73..0c11dc9 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -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 connectors — they 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 diff --git a/docs/features.md b/docs/features.md index 9aab2ee..b8b1c8a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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) + + + +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) diff --git a/internal/api/handler/est.go b/internal/api/handler/est.go index d71fffd..36d9e61 100644 --- a/internal/api/handler/est.go +++ b/internal/api/handler/est.go @@ -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. diff --git a/internal/api/handler/est_handler_test.go b/internal/api/handler/est_handler_test.go index ddcb618..11a33b9 100644 --- a/internal/api/handler/est_handler_test.go +++ b/internal/api/handler/est_handler_test.go @@ -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"), diff --git a/internal/api/handler/scep.go b/internal/api/handler/scep.go new file mode 100644 index 0000000..da91ede --- /dev/null +++ b/internal/api/handler/scep.go @@ -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 +} diff --git a/internal/api/handler/scep_handler_test.go b/internal/api/handler/scep_handler_test.go new file mode 100644 index 0000000..d9e9982 --- /dev/null +++ b/internal/api/handler/scep_handler_test.go @@ -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) + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 9074998..519dccf 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index a5c06f8..b7dce48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), diff --git a/internal/domain/scep.go b/internal/domain/scep.go new file mode 100644 index 0000000..3606b0e --- /dev/null +++ b/internal/domain/scep.go @@ -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 +) diff --git a/internal/pkcs7/pkcs7.go b/internal/pkcs7/pkcs7.go new file mode 100644 index 0000000..35fefd0 --- /dev/null +++ b/internal/pkcs7/pkcs7.go @@ -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...) +} diff --git a/internal/pkcs7/pkcs7_test.go b/internal/pkcs7/pkcs7_test.go new file mode 100644 index 0000000..37287b1 --- /dev/null +++ b/internal/pkcs7/pkcs7_test.go @@ -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]) + } + } + } +} diff --git a/internal/service/scep.go b/internal/service/scep.go new file mode 100644 index 0000000..7ffc999 --- /dev/null +++ b/internal/service/scep.go @@ -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 +} diff --git a/internal/service/scep_test.go b/internal/service/scep_test.go new file mode 100644 index 0000000..f99784f --- /dev/null +++ b/internal/service/scep_test.go @@ -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") + } +}