mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
feat(M51): add SCEP server (RFC 8894) for MDM and network device enrollment
Implements Simple Certificate Enrollment Protocol with single-endpoint operation-based dispatch (GetCACaps, GetCACert, PKIOperation), PKCS#7 SignedData CSR extraction with fallback for raw/base64 CSR, challenge password authentication via CSR attributes, and shared internal/pkcs7 package extracted from EST handler to eliminate code duplication. 24 new tests (11 service + 13 handler) plus 5 shared pkcs7 package tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,26 @@ func main() {
|
|||||||
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
"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")
|
logger.Info("registered all API handlers")
|
||||||
|
|
||||||
// Build middleware stack
|
// Build middleware stack
|
||||||
|
|||||||
@@ -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.
|
**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
|
## Security Model
|
||||||
|
|
||||||
### Private Key Management
|
### Private Key Management
|
||||||
|
|||||||
+4
-4
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
### Built-in: Vault PKI
|
||||||
|
|
||||||
|
|||||||
@@ -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_ISSUER_ID` | `iss-local` | Issuer for EST enrollments |
|
||||||
| `CERTCTL_EST_PROFILE_ID` | (none) | Optional profile constraint |
|
| `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)
|
## ACME Renewal Information (RFC 9773)
|
||||||
|
|||||||
+8
-134
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ESTService defines the service interface for EST enrollment operations.
|
// 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
|
// Parse PEM to DER for PKCS#7 encoding
|
||||||
derCerts, err := pemToDERChain(caCertPEM)
|
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestID := middleware.GetRequestID(r.Context())
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
|
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
|
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
|
||||||
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestID := middleware.GetRequestID(r.Context())
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
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
|
var derCerts [][]byte
|
||||||
|
|
||||||
// Add the issued certificate
|
// Add the issued certificate
|
||||||
certDER, err := pemToDERChain(result.CertPEM)
|
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
|
||||||
if err != nil || len(certDER) == 0 {
|
if err != nil || len(certDER) == 0 {
|
||||||
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -246,14 +247,14 @@ func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTE
|
|||||||
|
|
||||||
// Add the CA chain if present
|
// Add the CA chain if present
|
||||||
if result.ChainPEM != "" {
|
if result.ChainPEM != "" {
|
||||||
chainDER, err := pemToDERChain(result.ChainPEM)
|
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
derCerts = append(derCerts, chainDER...)
|
derCerts = append(derCerts, chainDER...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build PKCS#7 certs-only
|
// Build PKCS#7 certs-only
|
||||||
pkcs7Data, err := buildCertsOnlyPKCS7(derCerts)
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||||
return
|
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.
|
// NOTE: PKCS#7 helpers (BuildCertsOnlyPKCS7, PEMToDERChain, ASN.1 wrappers)
|
||||||
func pemToDERChain(pemData string) ([][]byte, error) {
|
// are in the shared internal/pkcs7 package, used by both EST and SCEP handlers.
|
||||||
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...)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockESTService implements ESTService for testing.
|
// mockESTService implements ESTService for testing.
|
||||||
@@ -338,12 +339,12 @@ func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCertsOnlyPKCS7(t *testing.T) {
|
func TestBuildCertsOnlyPKCS7_ViaSharedPackage(t *testing.T) {
|
||||||
// Test with a dummy DER certificate
|
// Test with a dummy DER certificate via shared pkcs7 package
|
||||||
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
|
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
|
||||||
result, err := buildCertsOnlyPKCS7([][]byte{dummyCert})
|
result, err := pkcs7.BuildCertsOnlyPKCS7([][]byte{dummyCert})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildCertsOnlyPKCS7 failed: %v", err)
|
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
t.Error("expected non-empty PKCS#7 output")
|
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)
|
pemData := generateTestCertPEM(t)
|
||||||
certs, err := pemToDERChain(pemData)
|
certs, err := pkcs7.PEMToDERChain(pemData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pemToDERChain failed: %v", err)
|
t.Fatalf("PEMToDERChain failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(certs) != 1 {
|
if len(certs) != 1 {
|
||||||
t.Errorf("expected 1 cert, got %d", len(certs))
|
t.Errorf("expected 1 cert, got %d", len(certs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPemToDERChain_NoCerts(t *testing.T) {
|
func TestPemToDERChain_NoCerts_ViaSharedPackage(t *testing.T) {
|
||||||
_, err := pemToDERChain("not a PEM")
|
_, err := pkcs7.PEMToDERChain("not a PEM")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid PEM")
|
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) {
|
func TestESTCSRAttrs_ServiceError(t *testing.T) {
|
||||||
svc := &mockESTService{
|
svc := &mockESTService{
|
||||||
CSRAttrsErr: errors.New("service error"),
|
CSRAttrsErr: errors.New("service error"),
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEPService defines the service interface for SCEP enrollment operations.
|
||||||
|
// SCEP (RFC 8894) is a protocol for certificate enrollment used by MDM platforms
|
||||||
|
// and network devices.
|
||||||
|
type SCEPService interface {
|
||||||
|
// GetCACaps returns the SCEP server capabilities as a newline-separated string.
|
||||||
|
GetCACaps(ctx context.Context) string
|
||||||
|
|
||||||
|
// GetCACert returns the PEM-encoded CA certificate chain.
|
||||||
|
GetCACert(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
||||||
|
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
||||||
|
//
|
||||||
|
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
|
||||||
|
// All operations use GET or POST to the same path.
|
||||||
|
//
|
||||||
|
// Supported operations:
|
||||||
|
// - GET ?operation=GetCACaps — server capabilities
|
||||||
|
// - GET ?operation=GetCACert — CA certificate distribution
|
||||||
|
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
||||||
|
type SCEPHandler struct {
|
||||||
|
svc SCEPService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSCEPHandler creates a new SCEPHandler.
|
||||||
|
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
||||||
|
return SCEPHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSCEP is the single entry point for all SCEP operations.
|
||||||
|
// It dispatches based on the "operation" query parameter.
|
||||||
|
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
operation := r.URL.Query().Get("operation")
|
||||||
|
|
||||||
|
switch operation {
|
||||||
|
case "GetCACaps":
|
||||||
|
h.getCACaps(w, r)
|
||||||
|
case "GetCACert":
|
||||||
|
h.getCACert(w, r)
|
||||||
|
case "PKIOperation":
|
||||||
|
h.pkiOperation(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, fmt.Sprintf("Unknown SCEP operation: %s", operation), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCACaps handles GET ?operation=GetCACaps
|
||||||
|
// Returns the SCEP server capabilities as plaintext, one per line.
|
||||||
|
func (h SCEPHandler) getCACaps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := h.svc.GetCACaps(r.Context())
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(caps))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCACert handles GET ?operation=GetCACert
|
||||||
|
// Returns the CA certificate(s). Single cert as DER, chain as PKCS#7.
|
||||||
|
func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPEM, err := h.svc.GetCACert(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificate: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PEM to DER chain
|
||||||
|
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to parse CA certificates", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derCerts) == 1 {
|
||||||
|
// Single CA cert — return as raw DER
|
||||||
|
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(derCerts[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple certs (CA + RA or chain) — return as PKCS#7
|
||||||
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||||
|
if err != nil {
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-x509-ca-ra-cert")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(pkcs7Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkiOperation handles POST ?operation=PKIOperation
|
||||||
|
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
||||||
|
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := middleware.GetRequestID(r.Context())
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Empty request body", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
||||||
|
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CSR
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("CSR signature invalid: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert DER CSR to PEM for the service layer
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrDER,
|
||||||
|
}))
|
||||||
|
|
||||||
|
result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "challenge password") {
|
||||||
|
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response: issued cert wrapped in PKCS#7 certs-only
|
||||||
|
h.writeSCEPResponse(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
||||||
|
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
||||||
|
var derCerts [][]byte
|
||||||
|
|
||||||
|
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
|
||||||
|
if err != nil || len(certDER) == 0 {
|
||||||
|
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
derCerts = append(derCerts, certDER...)
|
||||||
|
|
||||||
|
if result.ChainPEM != "" {
|
||||||
|
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
|
||||||
|
if err == nil {
|
||||||
|
derCerts = append(derCerts, chainDER...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-pki-message")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(pkcs7Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCSRFromPKCS7 extracts a PKCS#10 CSR from a SCEP PKCS#7 SignedData envelope.
|
||||||
|
//
|
||||||
|
// SCEP clients wrap the CSR in a PKCS#7 SignedData structure. For the MVP, we parse
|
||||||
|
// the outer ASN.1 structure to find the encapsulated content (the CSR bytes), and
|
||||||
|
// extract the challenge password from the CSR attributes.
|
||||||
|
//
|
||||||
|
// Returns: csrDER, challengePassword, transactionID, error
|
||||||
|
func extractCSRFromPKCS7(data []byte) ([]byte, string, string, error) {
|
||||||
|
// Try to decode as PKCS#7 SignedData
|
||||||
|
csrDER, err := parseSignedDataForCSR(data)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: some clients send the CSR directly (not wrapped in PKCS#7)
|
||||||
|
// or send base64-encoded data
|
||||||
|
decoded, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
|
||||||
|
if decErr == nil {
|
||||||
|
// Try the decoded data as PKCS#7
|
||||||
|
csrDER2, err2 := parseSignedDataForCSR(decoded)
|
||||||
|
if err2 == nil {
|
||||||
|
return extractCSRFields(csrDER2)
|
||||||
|
}
|
||||||
|
// Maybe the decoded data IS the CSR directly
|
||||||
|
if _, parseErr := x509.ParseCertificateRequest(decoded); parseErr == nil {
|
||||||
|
return extractCSRFields(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Maybe the raw data IS the CSR directly (no PKCS#7 wrapping)
|
||||||
|
if _, parseErr := x509.ParseCertificateRequest(data); parseErr == nil {
|
||||||
|
return extractCSRFields(data)
|
||||||
|
}
|
||||||
|
return nil, "", "", fmt.Errorf("failed to extract CSR from PKCS#7: %w", err)
|
||||||
|
}
|
||||||
|
return extractCSRFields(csrDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCSRFields extracts the challenge password and transaction ID from CSR attributes.
|
||||||
|
func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("invalid CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
challengePassword := ""
|
||||||
|
transactionID := ""
|
||||||
|
|
||||||
|
// OID for challengePassword: 1.2.840.113549.1.9.7
|
||||||
|
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
||||||
|
|
||||||
|
// Extract challenge password from parsed CSR attributes.
|
||||||
|
// Attributes is []pkix.AttributeTypeAndValueSET where each has Type (OID)
|
||||||
|
// and Value ([][]pkix.AttributeTypeAndValue). The challenge password value
|
||||||
|
// is stored as a string in the inner AttributeTypeAndValue.Value field.
|
||||||
|
for _, attr := range csr.Attributes {
|
||||||
|
if attr.Type.Equal(oidChallengePassword) {
|
||||||
|
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
|
||||||
|
if pwd, ok := attr.Value[0][0].Value.(string); ok {
|
||||||
|
challengePassword = pwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CN as fallback transaction ID if not found in attributes
|
||||||
|
if transactionID == "" && csr.Subject.CommonName != "" {
|
||||||
|
transactionID = csr.Subject.CommonName
|
||||||
|
}
|
||||||
|
|
||||||
|
return csrDER, challengePassword, transactionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7ContentInfo represents the outer ContentInfo structure.
|
||||||
|
type pkcs7ContentInfo struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7SignedData represents a simplified SignedData structure for CSR extraction.
|
||||||
|
type pkcs7SignedData struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms asn1.RawValue
|
||||||
|
EncapContentInfo asn1.RawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7EncapContent represents the EncapsulatedContentInfo.
|
||||||
|
type pkcs7EncapContent struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"explicit,optional,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSignedDataForCSR extracts the encapsulated content (CSR) from PKCS#7 SignedData.
|
||||||
|
func parseSignedDataForCSR(data []byte) ([]byte, error) {
|
||||||
|
var contentInfo pkcs7ContentInfo
|
||||||
|
rest, err := asn1.Unmarshal(data, &contentInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse ContentInfo: %w", err)
|
||||||
|
}
|
||||||
|
if len(rest) > 0 {
|
||||||
|
// Trailing data is OK for some implementations
|
||||||
|
}
|
||||||
|
|
||||||
|
// OID for signedData: 1.2.840.113549.1.7.2
|
||||||
|
oidSignedData := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
|
||||||
|
if !contentInfo.ContentType.Equal(oidSignedData) {
|
||||||
|
return nil, fmt.Errorf("not SignedData: got OID %v", contentInfo.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the SignedData
|
||||||
|
var signedData pkcs7SignedData
|
||||||
|
_, err = asn1.Unmarshal(contentInfo.Content.Bytes, &signedData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse SignedData: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the EncapsulatedContentInfo to get the CSR
|
||||||
|
var encapContent pkcs7EncapContent
|
||||||
|
_, err = asn1.Unmarshal(signedData.EncapContentInfo.FullBytes, &encapContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse EncapsulatedContentInfo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(encapContent.Content.Bytes) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty encapsulated content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The content may be wrapped in an OCTET STRING
|
||||||
|
var csrBytes []byte
|
||||||
|
var octetString asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(encapContent.Content.Bytes, &octetString); err == nil && octetString.Tag == asn1.TagOctetString {
|
||||||
|
csrBytes = octetString.Bytes
|
||||||
|
} else {
|
||||||
|
csrBytes = encapContent.Content.Bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it's a parseable CSR
|
||||||
|
if _, err := x509.ParseCertificateRequest(csrBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("extracted content is not a valid CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return csrBytes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockSCEPService implements SCEPService for testing.
|
||||||
|
type mockSCEPService struct {
|
||||||
|
CACaps string
|
||||||
|
CACertPEM string
|
||||||
|
CACertErr error
|
||||||
|
EnrollResult *domain.SCEPEnrollResult
|
||||||
|
EnrollErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSCEPService) GetCACaps(ctx context.Context) string {
|
||||||
|
if m.CACaps != "" {
|
||||||
|
return m.CACaps
|
||||||
|
}
|
||||||
|
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSCEPService) GetCACert(ctx context.Context) (string, error) {
|
||||||
|
return m.CACertPEM, m.CACertErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
|
||||||
|
return m.EnrollResult, m.EnrollErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if ct != "text/plain" {
|
||||||
|
t.Errorf("expected text/plain, got %s", ct)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "POSTPKIOperation") {
|
||||||
|
t.Errorf("expected POSTPKIOperation in response, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "SHA-256") {
|
||||||
|
t.Errorf("expected SHA-256 in response, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_GetCACaps_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_GetCACert_Success_SingleCert(t *testing.T) {
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockSCEPService{
|
||||||
|
CACertPEM: certPEM,
|
||||||
|
}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if ct != "application/x-x509-ca-cert" {
|
||||||
|
t.Errorf("expected application/x-x509-ca-cert, got %s", ct)
|
||||||
|
}
|
||||||
|
if w.Body.Len() == 0 {
|
||||||
|
t.Error("expected non-empty body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_GetCACert_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACert", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_GetCACert_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{
|
||||||
|
CACertErr: errors.New("CA unavailable"),
|
||||||
|
}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACert", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_MethodNotAllowed(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=PKIOperation", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_EmptyBody(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(""))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_InvalidBody(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader("not-valid-asn1-or-csr"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_ServiceError(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{
|
||||||
|
EnrollErr: errors.New("enrollment failed"),
|
||||||
|
}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
// Generate a valid raw CSR DER to send as body (fallback path)
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil {
|
||||||
|
t.Fatal("failed to decode CSR PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_Success_RawCSR(t *testing.T) {
|
||||||
|
certPEM := generateTestCertPEM(t)
|
||||||
|
svc := &mockSCEPService{
|
||||||
|
EnrollResult: &domain.SCEPEnrollResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil {
|
||||||
|
t.Fatal("failed to decode CSR PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if ct != "application/x-pki-message" {
|
||||||
|
t.Errorf("expected application/x-pki-message, got %s", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_PKIOperation_ChallengePasswordRejected(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{
|
||||||
|
EnrollErr: errors.New("invalid challenge password"),
|
||||||
|
}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
csrPEM := generateTestCSRPEM(t)
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil {
|
||||||
|
t.Fatal("failed to decode CSR PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", strings.NewReader(string(block.Bytes)))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_UnknownOperation(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=UnknownOp", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEP_MissingOperation(t *testing.T) {
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,15 @@ func (r *Router) RegisterESTHandlers(est handler.ESTHandler) {
|
|||||||
r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs))
|
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.
|
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||||
func (r *Router) GetMux() *http.ServeMux {
|
func (r *Router) GetMux() *http.ServeMux {
|
||||||
return r.mux
|
return r.mux
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
Notifiers NotifierConfig
|
Notifiers NotifierConfig
|
||||||
NetworkScan NetworkScanConfig
|
NetworkScan NetworkScanConfig
|
||||||
EST ESTConfig
|
EST ESTConfig
|
||||||
|
SCEP SCEPConfig
|
||||||
Verification VerificationConfig
|
Verification VerificationConfig
|
||||||
ACME ACMEConfig
|
ACME ACMEConfig
|
||||||
Vault VaultConfig
|
Vault VaultConfig
|
||||||
@@ -417,6 +418,26 @@ type ESTConfig struct {
|
|||||||
ProfileID string
|
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.
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||||
type NetworkScanConfig struct {
|
type NetworkScanConfig struct {
|
||||||
Enabled bool // Enable network scanning (default false)
|
Enabled bool // Enable network scanning (default false)
|
||||||
@@ -594,6 +615,12 @@ func Load() (*Config, error) {
|
|||||||
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
||||||
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
|
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{
|
Verification: VerificationConfig{
|
||||||
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
||||||
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user