mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 07:28:52 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a12a437664 | |||
| b857bdc560 | |||
| 01f6eb9d09 | |||
| 23603f5174 | |||
| b33b843908 | |||
| 7b40361bc4 | |||
| b540d4421e | |||
| a546a1bbef |
@@ -107,7 +107,7 @@ gantt
|
|||||||
| Protocol | Standard | Use Case |
|
| Protocol | Standard | Use Case |
|
||||||
|----------|----------|----------|
|
|----------|----------|----------|
|
||||||
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
|
| EST (Enrollment over Secure Transport) | RFC 7030 | Device enrollment, WiFi/802.1X, IoT |
|
||||||
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices |
|
| SCEP (Simple Certificate Enrollment Protocol) | RFC 8894 | MDM platforms (Jamf, Intune), network devices, ChromeOS. Full RFC 8894 wire format: EnvelopedData decryption, signerInfo POPO verification, CertRep PKIMessage builder; PKCSReq + RenewalReq + GetCertInitial messageType dispatch; multi-profile dispatch (`/scep/<pathID>`); per-profile RA cert + key. Lightweight raw-CSR clients keep working via the legacy MVP fall-through path. |
|
||||||
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
| ACME v2 | RFC 8555 | Public CA automated issuance (Let's Encrypt, ZeroSSL) |
|
||||||
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
| ACME ARI (Renewal Information) | RFC 9773 | CA-directed renewal timing — the CA tells you when to renew |
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ Built for **platform engineering and DevOps teams** managing 10–500+ certifica
|
|||||||
|
|
||||||
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
|
**Policy engine.** Certificate profiles constrain key types, max TTL, and EKUs — with crypto policy enforcement that validates every CSR against profile rules before it reaches the issuer. MaxTTL caps are enforced per issuer connector. Approval workflows pause jobs for human review. Ownership tracking routes notifications to the right team. Agent groups match devices by OS, architecture, IP CIDR, and version.
|
||||||
|
|
||||||
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices. S/MIME issuance with email protection EKU.
|
**Enrollment protocols.** EST server (RFC 7030) for device and WiFi enrollment. SCEP server (RFC 8894) for MDM platforms and network devices — full wire format (EnvelopedData decrypt + signerInfo POPO verify + CertRep PKIMessage builder), tested against ChromeOS-shape requests; multi-profile dispatch (`/scep/<pathID>`); RenewalReq + GetCertInitial messageType support; lightweight raw-CSR fallback for legacy clients. See [docs/legacy-est-scep.md](docs/legacy-est-scep.md) for the operator + device-integration guide. S/MIME issuance with email protection EKU.
|
||||||
|
|
||||||
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). RFC 5280 reason codes. Production-grade revocation status surface for relying parties: DER-encoded X.509 CRL per issuer, scheduler-pre-generated and cached so HTTP fetches do not rebuild per request; embedded OCSP responder serving both GET and POST forms (RFC 6960 §A.1.1) with responses signed by a per-issuer dedicated OCSP responder cert (RFC 6960 §2.6, `id-pkix-ocsp-nocheck` per §4.2.2.2.1) — the CA private key is never used directly for OCSP signing. Both endpoints live unauthenticated under `/.well-known/pki/` per RFC 8615. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation. See [docs/crl-ocsp.md](docs/crl-ocsp.md) for the relying-party integration guide.
|
**Revocation.** Single and bulk revocation (by profile, owner, agent, or issuer). RFC 5280 reason codes. Production-grade revocation status surface for relying parties: DER-encoded X.509 CRL per issuer, scheduler-pre-generated and cached so HTTP fetches do not rebuild per request; embedded OCSP responder serving both GET and POST forms (RFC 6960 §A.1.1) with responses signed by a per-issuer dedicated OCSP responder cert (RFC 6960 §2.6, `id-pkix-ocsp-nocheck` per §4.2.2.2.1) — the CA private key is never used directly for OCSP signing. Both endpoints live unauthenticated under `/.well-known/pki/` per RFC 8615. Short-lived certs (TTL < 1 hour) are exempt — expiry is sufficient revocation. See [docs/crl-ocsp.md](docs/crl-ocsp.md) for the relying-party integration guide.
|
||||||
|
|
||||||
|
|||||||
+226
-4
@@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -725,6 +727,16 @@ func main() {
|
|||||||
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 6.5: union pool of every enabled mTLS profile's
|
||||||
|
// trust bundle. Populated inside the SCEP startup block below; passed
|
||||||
|
// to the TLS-config builder later so the listener accepts client certs
|
||||||
|
// signed by ANY mTLS profile's CA. The handler-layer gate
|
||||||
|
// (HandleSCEPMTLS) re-verifies per-profile, so a cert that chains to
|
||||||
|
// profile A's bundle cannot enroll against profile B even though it
|
||||||
|
// passes the TLS-layer union check. Stays nil when no profile opted in
|
||||||
|
// (the TLS config builder treats nil as 'no mTLS').
|
||||||
|
var scepMTLSUnionPoolForTLS *x509.CertPool
|
||||||
|
|
||||||
// Register SCEP (RFC 8894) handlers if enabled.
|
// Register SCEP (RFC 8894) handlers if enabled.
|
||||||
//
|
//
|
||||||
// SCEP RFC 8894 Phase 1.5: multi-profile dispatch. Config.Validate()
|
// SCEP RFC 8894 Phase 1.5: multi-profile dispatch. Config.Validate()
|
||||||
@@ -738,7 +750,18 @@ func main() {
|
|||||||
// (challenge password presence, RA pair validity, issuer reachability).
|
// (challenge password presence, RA pair validity, issuer reachability).
|
||||||
// Failures log the offending PathID so a multi-profile deploy can
|
// Failures log the offending PathID so a multi-profile deploy can
|
||||||
// pinpoint which profile broke startup.
|
// pinpoint which profile broke startup.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: profiles that
|
||||||
|
// opt into mTLS via CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true
|
||||||
|
// get a parallel sibling-route handler registered at /scep-mtls/
|
||||||
|
// <pathID>. The per-profile trust pool gates the inbound client
|
||||||
|
// cert chain (verified at the TLS layer against the union pool +
|
||||||
|
// re-verified at the handler layer against just THIS profile's
|
||||||
|
// bundle to prevent cross-profile bleed-through).
|
||||||
scepHandlers := make(map[string]handler.SCEPHandler, len(cfg.SCEP.Profiles))
|
scepHandlers := make(map[string]handler.SCEPHandler, len(cfg.SCEP.Profiles))
|
||||||
|
scepMTLSHandlers := make(map[string]handler.SCEPHandler)
|
||||||
|
scepMTLSUnionPool := x509.NewCertPool()
|
||||||
|
scepMTLSAnyEnabled := false
|
||||||
for i, profile := range cfg.SCEP.Profiles {
|
for i, profile := range cfg.SCEP.Profiles {
|
||||||
profile := profile // shadow for closure-safety even though no closures escape
|
profile := profile // shadow for closure-safety even though no closures escape
|
||||||
profileLog := logger.With(
|
profileLog := logger.With(
|
||||||
@@ -791,7 +814,19 @@ func main() {
|
|||||||
if profile.ProfileID != "" {
|
if profile.ProfileID != "" {
|
||||||
scepService.SetProfileID(profile.ProfileID)
|
scepService.SetProfileID(profile.ProfileID)
|
||||||
}
|
}
|
||||||
scepHandlers[profile.PathID] = handler.NewSCEPHandler(scepService)
|
scepHandler := handler.NewSCEPHandler(scepService)
|
||||||
|
// SCEP RFC 8894 Phase 2.3: load the per-profile RA pair so the
|
||||||
|
// handler can run the new RFC 8894 PKIMessage path. Preflight
|
||||||
|
// already validated the pair (file mode 0600 + cert/key match
|
||||||
|
// + non-expired + RSA-or-ECDSA). Failure here is a deploy bug
|
||||||
|
// the operator needs to know about — fail loud at startup.
|
||||||
|
raCert, raKey, err := loadSCEPRAPair(profile.RACertPath, profile.RAKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
profileLog.Error("startup refused: SCEP profile RA pair load failed despite preflight pass — likely a TOCTOU between preflight + here, or filesystem changed mid-boot", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
scepHandler.SetRAPair(raCert, raKey)
|
||||||
|
scepHandlers[profile.PathID] = scepHandler
|
||||||
endpoint := "/scep"
|
endpoint := "/scep"
|
||||||
if profile.PathID != "" {
|
if profile.PathID != "" {
|
||||||
endpoint = "/scep/" + profile.PathID
|
endpoint = "/scep/" + profile.PathID
|
||||||
@@ -801,10 +836,83 @@ func main() {
|
|||||||
"challenge_password_set", profile.ChallengePassword != "",
|
"challenge_password_set", profile.ChallengePassword != "",
|
||||||
"ra_cert_path", profile.RACertPath,
|
"ra_cert_path", profile.RACertPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 6.5: register the mTLS sibling route
|
||||||
|
// when this profile opted in. Build a per-profile trust pool
|
||||||
|
// from the bundle, share its certs into the union pool the
|
||||||
|
// TLS layer uses, and clone the handler with the per-profile
|
||||||
|
// pool injected so HandleSCEPMTLS can re-verify the inbound
|
||||||
|
// client cert against just THIS profile's bundle.
|
||||||
|
if profile.MTLSEnabled {
|
||||||
|
perProfilePool, err := preflightSCEPMTLSTrustBundle(true, profile.MTLSClientCATrustBundlePath)
|
||||||
|
if err != nil {
|
||||||
|
profileLog.Error(
|
||||||
|
"startup refused: SCEP profile MTLS trust bundle preflight failed "+
|
||||||
|
"(Phase 6.5: required when MTLS_ENABLED=true). "+
|
||||||
|
"Verify the bundle file exists at MTLS_CLIENT_CA_TRUST_BUNDLE_PATH, "+
|
||||||
|
"is readable, parses as PEM, contains ≥1 CERTIFICATE block, "+
|
||||||
|
"and none of the bundled certs are past NotAfter.",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Add this profile's certs to the union pool the TLS
|
||||||
|
// layer uses for VerifyClientCertIfGiven. We re-walk the
|
||||||
|
// bundle so the union pool gets exactly the same certs
|
||||||
|
// as the per-profile pool (defensive against future
|
||||||
|
// pool-mutation refactors).
|
||||||
|
bundleBytes, _ := os.ReadFile(profile.MTLSClientCATrustBundlePath)
|
||||||
|
rest := bundleBytes
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
|
||||||
|
scepMTLSUnionPool.AddCert(cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scepMTLSAnyEnabled = true
|
||||||
|
|
||||||
|
// Build the parallel sibling-route handler. Same SCEP
|
||||||
|
// service + RA pair as the standard route — mTLS is
|
||||||
|
// additive, not a replacement.
|
||||||
|
mtlsHandler := handler.NewSCEPHandler(scepService)
|
||||||
|
mtlsHandler.SetRAPair(raCert, raKey)
|
||||||
|
mtlsHandler.SetMTLSTrustPool(perProfilePool)
|
||||||
|
scepMTLSHandlers[profile.PathID] = mtlsHandler
|
||||||
|
|
||||||
|
mtlsEndpoint := "/scep-mtls"
|
||||||
|
if profile.PathID != "" {
|
||||||
|
mtlsEndpoint = "/scep-mtls/" + profile.PathID
|
||||||
|
}
|
||||||
|
profileLog.Info("SCEP mTLS sibling route enabled",
|
||||||
|
"endpoint", mtlsEndpoint,
|
||||||
|
"client_ca_trust_bundle", profile.MTLSClientCATrustBundlePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
apiRouter.RegisterSCEPHandlers(scepHandlers)
|
apiRouter.RegisterSCEPHandlers(scepHandlers)
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: register the
|
||||||
|
// /scep-mtls sibling routes when at least one profile opted in.
|
||||||
|
// scepMTLSHandlers is non-empty only when scepMTLSAnyEnabled is
|
||||||
|
// true (the per-profile branch only adds to the map when the
|
||||||
|
// profile flag is set), but the explicit gate makes the
|
||||||
|
// no-op-when-disabled case obvious in logs.
|
||||||
|
if scepMTLSAnyEnabled {
|
||||||
|
apiRouter.RegisterSCEPMTLSHandlers(scepMTLSHandlers)
|
||||||
|
scepMTLSUnionPoolForTLS = scepMTLSUnionPool
|
||||||
|
logger.Info("SCEP mTLS sibling route enabled (Phase 6.5)",
|
||||||
|
"mtls_profile_count", len(scepMTLSHandlers),
|
||||||
|
)
|
||||||
|
}
|
||||||
logger.Info("SCEP server enabled",
|
logger.Info("SCEP server enabled",
|
||||||
"profile_count", len(scepHandlers),
|
"profile_count", len(scepHandlers),
|
||||||
|
"mtls_profile_count", len(scepMTLSHandlers),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,9 +1150,17 @@ func main() {
|
|||||||
// Server configuration
|
// Server configuration
|
||||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: finalHandler,
|
Handler: finalHandler,
|
||||||
TLSConfig: buildServerTLSConfig(tlsCertHolder),
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: when at least
|
||||||
|
// one SCEP profile opted into mTLS, the listener carries the
|
||||||
|
// union of every enabled profile's client-CA trust bundle and
|
||||||
|
// negotiates VerifyClientCertIfGiven on the handshake. The
|
||||||
|
// /scep route stays challenge-password-only; the /scep-mtls
|
||||||
|
// sibling route gates additionally on the verified client cert.
|
||||||
|
// nil pool = no profile opted in = identical TLS shape to the
|
||||||
|
// pre-Phase-6.5 buildServerTLSConfig path.
|
||||||
|
TLSConfig: buildServerTLSConfigWithMTLS(tlsCertHolder, scepMTLSUnionPoolForTLS),
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
||||||
@@ -1142,6 +1258,99 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preflightSCEPMTLSTrustBundle validates a per-profile mTLS client-CA
|
||||||
|
// trust bundle. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||||
|
//
|
||||||
|
// Mirrors preflightSCEPRACertKey's no-op-when-disabled pattern; otherwise
|
||||||
|
// the checks are:
|
||||||
|
//
|
||||||
|
// 1. Path is non-empty (the Validate() refuse covers this too, but
|
||||||
|
// preflight reports the specific failure with an actionable error
|
||||||
|
// string + os.Exit(1) at the call site).
|
||||||
|
// 2. File exists + readable.
|
||||||
|
// 3. PEM-decodes to ≥1 CERTIFICATE block.
|
||||||
|
// 4. None of the bundled certs is past NotAfter — an expired trust
|
||||||
|
// anchor would silently reject every client cert at runtime.
|
||||||
|
//
|
||||||
|
// On success, returns the parsed *x509.CertPool ready to inject into the
|
||||||
|
// per-profile SCEPHandler via SetMTLSTrustPool. Each bundled cert also
|
||||||
|
// contributes to the union pool that backs the TLS-layer
|
||||||
|
// VerifyClientCertIfGiven.
|
||||||
|
func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPool, error) {
|
||||||
|
if !enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if bundlePath == "" {
|
||||||
|
return nil, fmt.Errorf("MTLS enabled but trust bundle path empty: " +
|
||||||
|
"set CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file " +
|
||||||
|
"containing the bootstrap-CA certs the operator allows to enroll")
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(bundlePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read MTLS trust bundle: %w (path=%s)", err, bundlePath)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
rest := body
|
||||||
|
count := 0
|
||||||
|
now := time.Now()
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse MTLS trust bundle cert: %w (path=%s)", err, bundlePath)
|
||||||
|
}
|
||||||
|
if now.After(cert.NotAfter) {
|
||||||
|
return nil, fmt.Errorf("MTLS trust bundle cert expired at %s (subject=%q, path=%s) — replace before restart",
|
||||||
|
cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName, bundlePath)
|
||||||
|
}
|
||||||
|
pool.AddCert(cert)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil, fmt.Errorf("MTLS trust bundle contained no CERTIFICATE PEM blocks (path=%s)", bundlePath)
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||||
|
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||||
|
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||||
|
// indicate a TOCTOU race or a filesystem change between preflight and
|
||||||
|
// the load (rare).
|
||||||
|
//
|
||||||
|
// Cert PEM may carry a chain (CA + RA + intermediate); we use the FIRST
|
||||||
|
// CERTIFICATE block, matching the RFC 8894 §3.5.1 single-cert convention
|
||||||
|
// for the GetCACert response.
|
||||||
|
func loadSCEPRAPair(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||||
|
certPEM, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read RA cert: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read RA key: %w", err)
|
||||||
|
}
|
||||||
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse RA pair: %w", err)
|
||||||
|
}
|
||||||
|
if len(pair.Certificate) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("RA cert PEM contained no certificate blocks")
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse RA cert: %w", err)
|
||||||
|
}
|
||||||
|
return leaf, pair.PrivateKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
||||||
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
||||||
// pattern; otherwise the checks are:
|
// pattern; otherwise the checks are:
|
||||||
@@ -1345,10 +1554,23 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
|
|||||||
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
|
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
|
||||||
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
|
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
|
||||||
// start the server if SCEP is enabled without a non-empty shared secret.
|
// start the server if SCEP is enabled without a non-empty shared secret.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the sibling
|
||||||
|
// /scep-mtls[/<pathID>] route also rides the no-auth chain. Its
|
||||||
|
// auth boundary is (a) client cert verified at the TLS layer +
|
||||||
|
// re-verified per-profile at the handler layer, plus (b) the
|
||||||
|
// challenge password — neither is a Bearer token. The /scepxyz
|
||||||
|
// vs /scep-mtls disambiguation: 'xyz' starts with a letter so the
|
||||||
|
// HasPrefix(path, "/scep/") gate doesn't match it; 'mtls' is its
|
||||||
|
// own dedicated prefix gated below to avoid the same overlap.
|
||||||
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
||||||
noAuthHandler.ServeHTTP(w, r)
|
noAuthHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if path == "/scep-mtls" || strings.HasPrefix(path, "/scep-mtls/") {
|
||||||
|
noAuthHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticated API routes — full middleware stack including Auth.
|
// Authenticated API routes — full middleware stack including Auth.
|
||||||
if strings.HasPrefix(path, "/api/v1/") {
|
if strings.HasPrefix(path, "/api/v1/") {
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ type fakeIssuerConn struct {
|
|||||||
caCertErr error
|
caCertErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -134,6 +135,31 @@ func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildServerTLSConfigWithMTLS extends buildServerTLSConfig with a client-cert
|
||||||
|
// trust pool for the SCEP RFC 8894 + Intune master bundle Phase 6.5 mTLS
|
||||||
|
// sibling route. SCEP profiles that opt into mTLS each contribute their
|
||||||
|
// trust bundle to the union pool here; the same TLS listener serves both
|
||||||
|
// /scep[/<pathID>] (no client cert) and /scep-mtls/<pathID> (cert required
|
||||||
|
// at the handler layer).
|
||||||
|
//
|
||||||
|
// ClientAuth: VerifyClientCertIfGiven — request a cert during handshake; if
|
||||||
|
// the client presents one, verify it against the union pool; if absent, the
|
||||||
|
// request still reaches the handler and the per-route handler decides
|
||||||
|
// whether to accept. Critical that we do NOT use RequireAndVerifyClientCert
|
||||||
|
// here — that would break the standard /scep route (which is challenge-
|
||||||
|
// password-only, no client cert expected).
|
||||||
|
//
|
||||||
|
// Pass clientCAs == nil to disable mTLS (no profile opted in). The function
|
||||||
|
// then returns the same shape as buildServerTLSConfig.
|
||||||
|
func buildServerTLSConfigWithMTLS(holder *certHolder, clientCAs *x509.CertPool) *tls.Config {
|
||||||
|
cfg := buildServerTLSConfig(holder)
|
||||||
|
if clientCAs != nil {
|
||||||
|
cfg.ClientCAs = clientCAs
|
||||||
|
cfg.ClientAuth = tls.VerifyClientCertIfGiven
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
||||||
// non-nil error when the TLS configuration is missing or the cert+key pair
|
// non-nil error when the TLS configuration is missing or the cert+key pair
|
||||||
// cannot be parsed, so the caller refuses to start the control plane
|
// cannot be parsed, so the caller refuses to start the control plane
|
||||||
|
|||||||
+17
-3
@@ -760,20 +760,34 @@ IssuerConnector (connector layer via IssuerConnectorAdapter)
|
|||||||
Signed certificate returned as PKCS#7 certs-only
|
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.
|
**Wire format:** Two paths, tried in order. The new RFC 8894 path (post-2026-04-29) parses the full PKIMessage shape: ContentInfo → SignedData → SignerInfo (POPO over auth-attrs verified via `internal/pkcs7/signedinfo.go::SignerInfo.VerifySignature` with the canonical SET-OF Attribute re-serialisation per RFC 5652 §5.4) → EnvelopedData (decrypted via `internal/pkcs7/envelopeddata.go::EnvelopedData.Decrypt` with RSA PKCS#1v1.5 keyTrans + AES-CBC content + constant-time PKCS#7 unpad to close the padding-oracle leak) → inner PKCS#10 CSR. Auth-attrs (messageType, transactionID, senderNonce) flow through to the service layer via `domain.SCEPRequestEnvelope`. The handler dispatches on messageType: PKCSReq (19) → initial enrollment; RenewalReq (17) → re-enrollment with chain validation; GetCertInitial (20) → polling stub returns FAILURE+badCertID. Responses are full CertRep PKIMessages (`internal/pkcs7/certrep.go::BuildCertRepPKIMessage`) signed by the per-profile RA cert/key with the issued cert chain encrypted to the device's transient signing cert (RFC 8894 §3.3.2). On parse failure the handler falls through to the legacy MVP path: base64-encoded PKCS#7 and raw CSR submissions are still accepted; responses use the legacy PKCS#7 certs-only shape via the shared `internal/pkcs7` package. The MVP fall-through is non-negotiable — backward compat with lightweight SCEP clients that don't speak full RFC 8894. Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7.
|
||||||
|
|
||||||
**Authentication:** SCEP endpoints at `/scep` and `/scep/*` are served unauthenticated at the HTTP layer — no Bearer token required — per RFC 8894 §3.2, which defines authentication via the `challengePassword` attribute (OID 1.2.840.113549.1.9.7) embedded in the PKCS#10 CSR rather than an HTTP credential. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/scep` and `/scep/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). The `challengePassword` is mandatory: `preflightSCEPChallengePassword` at startup refuses to boot the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`, closing CWE-306 (missing authentication for a critical function). `SCEPService.PKCSReq` enforces the same invariant defense-in-depth — an empty `s.challengePassword` rejects every enrollment — and the password comparison uses `crypto/subtle.ConstantTimeCompare` to prevent response-time side-channel leakage. The startup log line `SCEP server enabled` emits a `challenge_password_set` boolean for operator visibility.
|
**Authentication:** SCEP endpoints at `/scep` and `/scep/*` are served unauthenticated at the HTTP layer — no Bearer token required — per RFC 8894 §3.2, which defines authentication via the `challengePassword` attribute (OID 1.2.840.113549.1.9.7) embedded in the PKCS#10 CSR rather than an HTTP credential. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/scep` and `/scep/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). The `challengePassword` is mandatory: `preflightSCEPChallengePassword` at startup refuses to boot the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`, closing CWE-306 (missing authentication for a critical function). `SCEPService.PKCSReq` enforces the same invariant defense-in-depth — an empty `s.challengePassword` rejects every enrollment — and the password comparison uses `crypto/subtle.ConstantTimeCompare` to prevent response-time side-channel leakage. The startup log line `SCEP server enabled` emits a `challenge_password_set` boolean for operator visibility.
|
||||||
|
|
||||||
**Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion):
|
**Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion). The legacy `PKCSReq` method backs the MVP fall-through path; the three `*WithEnvelope` variants back the RFC 8894 PKIMessage path:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type SCEPService interface {
|
type SCEPService interface {
|
||||||
GetCACaps(ctx context.Context) string
|
GetCACaps(ctx context.Context) string
|
||||||
GetCACert(ctx context.Context) (string, error)
|
GetCACert(ctx context.Context) (string, error)
|
||||||
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
// MVP path — raw CSR + transactionID synthesised from CSR's CN.
|
||||||
|
PKCSReq(ctx context.Context, csrPEM, challengePassword, transactionID string) (*domain.SCEPEnrollResult, error)
|
||||||
|
// RFC 8894 path — envelope carries the parsed authenticated attributes
|
||||||
|
// (messageType, transactionID, senderNonce, signerCert). Returns
|
||||||
|
// *SCEPResponseEnvelope (not error + result) because RFC 8894 §3.3
|
||||||
|
// mandates a CertRep PKIMessage on every response, even failures.
|
||||||
|
PKCSReqWithEnvelope(ctx context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
|
RenewalReqWithEnvelope(ctx context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
|
GetCertInitialWithEnvelope(ctx context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Capabilities advertised:** `POSTPKIOperation` + `SHA-256` + `SHA-512` + `AES` + `SCEPStandard` + `Renewal`. ChromeOS specifically looks for `POSTPKIOperation` (non-base64 POST), `AES` (the now-implemented CBC content encryption), `SCEPStandard` (RFC 8894 conformance), and `Renewal` (RenewalReq messageType-17 dispatch).
|
||||||
|
|
||||||
|
**Multi-profile dispatch:** A single certctl instance can expose multiple SCEP endpoints from `CERTCTL_SCEP_PROFILES=corp,iot,server` + per-profile `CERTCTL_SCEP_PROFILE_<NAME>_*` env vars, each with its own issuer + RA pair + challenge password. The router exposes `/scep` (legacy, single-profile flat-env case) + `/scep/<pathID>` per non-empty profile. Per-profile preflight validates each RA pair independently; failures log the offending PathID. See [`legacy-est-scep.md`](legacy-est-scep.md#multi-profile-dispatch-scep-path-id) for the operator config recipe.
|
||||||
|
|
||||||
|
**Must-staple per profile:** When `CertificateProfile.MustStaple = true`, the local issuer adds the RFC 7633 `id-pe-tlsfeature` extension (OID `1.3.6.1.5.5.7.1.24`, non-critical, value `SEQUENCE OF INTEGER {5}`) to issued certs so browsers + modern TLS libraries fail-closed on missing OCSP stapling responses.
|
||||||
|
|
||||||
**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.
|
**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.
|
**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.
|
||||||
|
|||||||
+3
-1
@@ -327,7 +327,9 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
|
|||||||
- **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 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.
|
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` (or the per-profile `CERTCTL_SCEP_PROFILE_<NAME>_ISSUER_ID` form for multi-endpoint SCEP). Both share a common `internal/pkcs7` package for PKCS#7 response encoding. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||||
|
|
||||||
|
**SCEP RA cert + key (post-2026-04-29):** the SCEP server's RFC 8894 path requires an RA cert/key pair (`CERTCTL_SCEP_RA_CERT_PATH` + `CERTCTL_SCEP_RA_KEY_PATH`, mode 0600) — clients encrypt their CSR to the RA cert's public key per RFC 8894 §3.2.2. Multi-profile deployments configure per-profile pairs via `CERTCTL_SCEP_PROFILES=corp,iot` + `CERTCTL_SCEP_PROFILE_<NAME>_RA_*_PATH`. See [`legacy-est-scep.md`](legacy-est-scep.md#scep-rfc-8894-native-implementation-post-2026-04-29) for the openssl recipe + ChromeOS Admin Console pointer + must-staple per-profile policy.
|
||||||
|
|
||||||
### Built-in: Vault PKI
|
### Built-in: Vault PKI
|
||||||
|
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
|
|||||||
| `CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD` | (none) | Per-profile shared secret. **Required for every profile** in `CERTCTL_SCEP_PROFILES` (CWE-306: per-profile auth boundary). Empty value at startup fails the boot with the offending PathID in the structured log. |
|
| `CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD` | (none) | Per-profile shared secret. **Required for every profile** in `CERTCTL_SCEP_PROFILES` (CWE-306: per-profile auth boundary). Empty value at startup fails the boot with the offending PathID in the structured log. |
|
||||||
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH` | (none) | Per-profile RA certificate PEM path. Same semantics as `CERTCTL_SCEP_RA_CERT_PATH` but scoped to one profile. **Required for every profile.** |
|
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_CERT_PATH` | (none) | Per-profile RA certificate PEM path. Same semantics as `CERTCTL_SCEP_RA_CERT_PATH` but scoped to one profile. **Required for every profile.** |
|
||||||
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | (none) | Per-profile RA private key PEM path (mode `0600`). Same semantics as `CERTCTL_SCEP_RA_KEY_PATH` but scoped to one profile. **Required for every profile.** |
|
| `CERTCTL_SCEP_PROFILE_<NAME>_RA_KEY_PATH` | (none) | Per-profile RA private key PEM path (mode `0600`). Same semantics as `CERTCTL_SCEP_RA_KEY_PATH` but scoped to one profile. **Required for every profile.** |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED` | `false` | **Phase 6.5 (opt-in).** When true, certctl exposes a sibling `/scep-mtls/<pathID>` route alongside the standard `/scep/<pathID>` route. The sibling route requires the SCEP client to present an mTLS client cert that chains to `_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH`. The standard route continues to use challenge-password-only auth — operators can run BOTH routes simultaneously for migration / heterogeneous client fleets. mTLS is additive (not a replacement for the challenge password). Designed for enterprise procurement teams that reject "shared password authentication" as a checkbox-fail. Same model Apple's MDM and Cisco's BRSKI use. |
|
||||||
|
| `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH` | (none) | PEM bundle of CA certs that sign the client (device-bootstrap) certs the operator allows to enroll on this profile's `/scep-mtls/<pathID>` route. **Required when `_MTLS_ENABLED=true`.** Operators with multiple bootstrap CAs concatenate them. The startup preflight (`cmd/server/main.go::preflightSCEPMTLSTrustBundle`) validates: file exists, parses as PEM, contains ≥1 cert, none expired. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,239 @@ becomes a compliance failure:
|
|||||||
- https://www.pcisecuritystandards.org/news_events/
|
- https://www.pcisecuritystandards.org/news_events/
|
||||||
- https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions)
|
- https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions)
|
||||||
|
|
||||||
|
## SCEP RFC 8894 native implementation (post-2026-04-29)
|
||||||
|
|
||||||
|
Prior to this bundle, certctl's SCEP server parsed `PKCS#7 SignedData` and
|
||||||
|
treated the encapsulated content as a raw `PKCS#10 CSR` (the file-internal
|
||||||
|
"MVP" comment at `internal/api/handler/scep.go:217` flagged this). That
|
||||||
|
worked for lightweight MDM agents but failed against ChromeOS and most
|
||||||
|
production MDM clients which expect full RFC 8894 wire format:
|
||||||
|
`SignedData` wrapping an `EnvelopedData` encrypting the CSR to the RA
|
||||||
|
cert's public key, with `signerInfo` POPO over the auth-attrs.
|
||||||
|
|
||||||
|
The new RFC 8894 path runs FIRST; on any parse failure it falls through
|
||||||
|
to the legacy MVP raw-CSR path so existing operators see no behavior
|
||||||
|
change for their lightweight clients.
|
||||||
|
|
||||||
|
### Required: RA cert + key
|
||||||
|
|
||||||
|
The RFC 8894 path requires a Registration Authority cert + key pair.
|
||||||
|
Clients encrypt their CSR to the RA cert's public key (RFC 8894 §3.2.2);
|
||||||
|
the certctl server uses the RA key to decrypt and to sign the outbound
|
||||||
|
CertRep PKIMessage signerInfo (RFC 8894 §3.3.2).
|
||||||
|
|
||||||
|
| Env var | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA certificate. **Required when `CERTCTL_SCEP_ENABLED=true`.** |
|
||||||
|
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded RA private key matching `CERTCTL_SCEP_RA_CERT_PATH`. File MUST be mode `0600` (preflight refuses world-readable). |
|
||||||
|
|
||||||
|
Generate the RA pair (any RSA-2048+ or ECDSA-P256+ pair signed by your
|
||||||
|
root or sub-CA works):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# RSA-2048 RA pair, valid 1 year, signed by your root.
|
||||||
|
openssl req -new -newkey rsa:2048 -nodes -keyout ra.key -out ra.csr \
|
||||||
|
-subj "/CN=corp-ca-RA"
|
||||||
|
openssl x509 -req -in ra.csr -days 365 \
|
||||||
|
-CA root.crt -CAkey root.key -CAcreateserial \
|
||||||
|
-extfile <(printf "extendedKeyUsage=emailProtection,1.3.6.1.5.5.7.3.4") \
|
||||||
|
-out ra.crt
|
||||||
|
|
||||||
|
chmod 0600 ra.key # required — preflight rejects world-readable keys
|
||||||
|
chmod 0644 ra.crt
|
||||||
|
mv ra.key ra.crt /etc/certctl/scep/
|
||||||
|
|
||||||
|
export CERTCTL_SCEP_ENABLED=true
|
||||||
|
export CERTCTL_SCEP_RA_CERT_PATH=/etc/certctl/scep/ra.crt
|
||||||
|
export CERTCTL_SCEP_RA_KEY_PATH=/etc/certctl/scep/ra.key
|
||||||
|
export CERTCTL_SCEP_CHALLENGE_PASSWORD=$(openssl rand -hex 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
The startup preflight in `cmd/server/main.go::preflightSCEPRACertKey`
|
||||||
|
validates: file existence, key file mode 0600, cert/key match, cert
|
||||||
|
non-expired, RSA-or-ECDSA public-key algorithm. Failures `os.Exit(1)`
|
||||||
|
with a structured log line identifying the offending profile.
|
||||||
|
|
||||||
|
### Capability advertisement (`GetCACaps`)
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTPKIOperation
|
||||||
|
SHA-256
|
||||||
|
SHA-512
|
||||||
|
AES
|
||||||
|
SCEPStandard
|
||||||
|
Renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
ChromeOS specifically looks for `POSTPKIOperation` (non-base64 POST),
|
||||||
|
`AES` (the now-implemented CBC content encryption), `SCEPStandard` (RFC
|
||||||
|
8894 conformance), and `Renewal` (RenewalReq messageType-17 support).
|
||||||
|
Older Cisco IOS clients also accept `SHA-256` and `SHA-512` per RFC 8894
|
||||||
|
§3.5.2.
|
||||||
|
|
||||||
|
### Supported messageTypes
|
||||||
|
|
||||||
|
| Type | RFC 8894 § | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PKCSReq` (19) | §3.3.1 | Initial enrollment. Signer cert is the device's transient self-signed key. |
|
||||||
|
| `RenewalReq` (17) | §3.3.1.2 | Re-enrollment. Signer cert MUST be a previously-issued cert from this issuer; service-side `verifyRenewalSignerCertChain` enforces. |
|
||||||
|
| `GetCertInitial` (20) | §3.3.3 | Polling for pending requests. v1 returns `FAILURE+badCertID` because deferred-issuance isn't supported (every PKCSReq either succeeds or fails synchronously). |
|
||||||
|
| `CertRep` (3) | §3.3.2 | Server response — never inbound. |
|
||||||
|
|
||||||
|
### MVP backward-compatibility path
|
||||||
|
|
||||||
|
Lightweight clients that send a stripped `SignedData` containing a raw
|
||||||
|
CSR (no `EnvelopedData` wrapper, no `signerInfo` POPO) keep working: the
|
||||||
|
handler tries the RFC 8894 path FIRST; on any parse failure it falls
|
||||||
|
through to the legacy `extractCSRFromPKCS7` path. The legacy path uses
|
||||||
|
the CSR's `challengePassword` attribute the same way as the RFC 8894
|
||||||
|
path. Operators with existing lightweight-client deploys see zero
|
||||||
|
behavior change.
|
||||||
|
|
||||||
|
### Multi-profile dispatch (`/scep/<pathID>`)
|
||||||
|
|
||||||
|
Real enterprise deploys run multiple SCEP endpoints from one certctl
|
||||||
|
instance — corp-laptop CA, IoT CA, server CA — each with its own
|
||||||
|
issuer + RA pair + challenge password. Configure via the indexed env-var
|
||||||
|
form documented in [`features.md`](features.md): set
|
||||||
|
`CERTCTL_SCEP_PROFILES=corp,iot,server` (a comma-separated list of
|
||||||
|
profile names), then for each name supply the per-profile env-vars
|
||||||
|
prefixed with `CERTCTL_SCEP_PROFILE_<NAME>_` followed by the suffix
|
||||||
|
keys `_ISSUER_ID`, `_PROFILE_ID`, `_CHALLENGE_PASSWORD`, `_RA_CERT_PATH`,
|
||||||
|
`_RA_KEY_PATH`. The `<NAME>` token resolves to the upper-cased profile
|
||||||
|
name from the list. Each profile is independently validated at startup;
|
||||||
|
per-profile failures log the offending PathID.
|
||||||
|
|
||||||
|
The router exposes `/scep/corp`, `/scep/iot`, `/scep/server`. The legacy
|
||||||
|
`/scep` root remains for the single-profile flat-env-var case (when
|
||||||
|
`CERTCTL_SCEP_PROFILES` is unset). Per-profile preflight validates each
|
||||||
|
RA pair independently; failures log the offending PathID.
|
||||||
|
|
||||||
|
### ChromeOS Admin Console pointer
|
||||||
|
|
||||||
|
In Google Admin Console → Devices → Networks → Certificates, register
|
||||||
|
certctl's `/scep[/<pathID>]` URL as the SCEP server. Enter the challenge
|
||||||
|
password from `CERTCTL_SCEP_CHALLENGE_PASSWORD` (or per-profile
|
||||||
|
`CERTCTL_SCEP_PROFILE_<NAME>_CHALLENGE_PASSWORD`). ChromeOS pulls
|
||||||
|
`GetCACert` first to retrieve the RA cert, then enrolls via
|
||||||
|
PKIOperation.
|
||||||
|
|
||||||
|
### RA cert rotation
|
||||||
|
|
||||||
|
The RA cert is loaded once at startup and persisted in the handler's
|
||||||
|
struct field; rotation requires a server restart (mirrors the
|
||||||
|
`CERTCTL_SERVER_TLS_CERT_PATH` precedent in `cmd/server/tls.go`). The
|
||||||
|
recommended cadence is annual rotation with a 30-day overlap during
|
||||||
|
which both old + new RA certs are listed in `GetCACert`'s response (set
|
||||||
|
the cert chain accordingly in your sub-CA hierarchy).
|
||||||
|
|
||||||
|
### Must-staple per-profile policy (RFC 7633)
|
||||||
|
|
||||||
|
When a `CertificateProfile` has `MustStaple = true`, the local issuer
|
||||||
|
adds the `id-pe-tlsfeature` extension (OID `1.3.6.1.5.5.7.1.24`,
|
||||||
|
non-critical, value `SEQUENCE OF INTEGER {5}`) to every issued cert.
|
||||||
|
Browsers + modern TLS libraries that see this extension fail-closed on
|
||||||
|
missing OCSP stapling responses — defense against revocation-bypass via
|
||||||
|
OCSP blackholing.
|
||||||
|
|
||||||
|
**Default policy:** `false`. Operators opt in once they've confirmed the
|
||||||
|
TLS reverse proxy / load balancer staples OCSP responses. NGINX,
|
||||||
|
HAProxy, Envoy all support stapling but it requires explicit config —
|
||||||
|
turning must-staple on without verifying the TLS path will hard-fail
|
||||||
|
browsers.
|
||||||
|
|
||||||
|
Recommended for: Intune-deployed device certs (modern TLS clients);
|
||||||
|
SCEP profiles serving general / legacy clients (ChromeOS, IoT) should
|
||||||
|
stay `false` until the TLS path is verified.
|
||||||
|
|
||||||
|
### mTLS sibling route (Phase 6.5, opt-in)
|
||||||
|
|
||||||
|
SCEP is documented as application-layer-auth — the challenge password
|
||||||
|
is the authentication boundary per RFC 8894 §3.2. But enterprise
|
||||||
|
procurement teams routinely reject "shared password authentication" as
|
||||||
|
a checkbox-fail regardless of how strong the password is. The clean
|
||||||
|
answer: a **sibling** route at `/scep-mtls/<pathID>` that requires
|
||||||
|
client-cert auth at the handler layer AND ALSO accepts the challenge
|
||||||
|
password (defense in depth, not replacement). Devices present a
|
||||||
|
bootstrap cert from a trusted CA (e.g. a manufacturing-time cert),
|
||||||
|
then SCEP-enroll for their long-lived cert. Same model Apple's MDM and
|
||||||
|
Cisco's BRSKI use.
|
||||||
|
|
||||||
|
**Opt in per profile** by setting two env vars:
|
||||||
|
|
||||||
|
```
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true
|
||||||
|
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH=/etc/certctl/scep/<name>-bootstrap-cas.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
The trust bundle is a PEM file containing the bootstrap-CA certs the
|
||||||
|
operator allows to enroll. Operators with multiple bootstrap CAs
|
||||||
|
concatenate them. The startup preflight
|
||||||
|
(`cmd/server/main.go::preflightSCEPMTLSTrustBundle`) validates: file
|
||||||
|
exists, parses as PEM, contains ≥1 cert, none expired. Failures
|
||||||
|
`os.Exit(1)` with a structured log identifying the offending PathID.
|
||||||
|
|
||||||
|
**TLS server config:** when at least one profile opts into mTLS, the
|
||||||
|
HTTPS listener gets the union of every enabled profile's trust bundle
|
||||||
|
as its `ClientCAs` pool, plus `ClientAuth: VerifyClientCertIfGiven` —
|
||||||
|
the listener requests a client cert during the handshake, verifies it
|
||||||
|
against the union pool if presented, and lets the handler decide
|
||||||
|
whether to require it. This means the SAME listener serves both
|
||||||
|
`/scep[/<pathID>]` (no client cert required) and `/scep-mtls/<pathID>`
|
||||||
|
(cert required). The standard route stays untouched for clients that
|
||||||
|
can't present a cert.
|
||||||
|
|
||||||
|
**Handler-layer per-profile gate:** the TLS-layer check uses the union
|
||||||
|
pool, so a cert that chains to profile A's bundle would pass the TLS
|
||||||
|
handshake even when targeting profile B. The handler-layer gate
|
||||||
|
(`HandleSCEPMTLS`) re-verifies the inbound client cert against ONLY
|
||||||
|
THIS profile's pool — preventing cross-profile bleed-through.
|
||||||
|
|
||||||
|
**Auth chain on the mTLS sibling route:**
|
||||||
|
|
||||||
|
1. TLS handshake: client cert verified against the union pool
|
||||||
|
(if presented; absent = standard SCEP path applies but handler
|
||||||
|
rejects with 401).
|
||||||
|
2. Handler-layer per-profile re-verification: cert must chain to
|
||||||
|
THIS profile's trust bundle. Mismatch = 401.
|
||||||
|
3. Standard SCEP enrollment: `HandleSCEP` runs as on the standard
|
||||||
|
route — including the challenge-password gate at the service layer.
|
||||||
|
|
||||||
|
A stolen device cert without the matching challenge password gets
|
||||||
|
rejected (and vice versa). Both layers are independently required.
|
||||||
|
|
||||||
|
**Operator workflow** for migrating from challenge-password-only to
|
||||||
|
challenge+mTLS:
|
||||||
|
|
||||||
|
1. Generate a bootstrap CA + issue a bootstrap cert per device (out
|
||||||
|
of band — typically manufacturing-time, MDM-pushed, or a separate
|
||||||
|
PKI flow).
|
||||||
|
2. Distribute the trust bundle to certctl as the
|
||||||
|
`_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH`.
|
||||||
|
3. Set `_MTLS_ENABLED=true` for the profile, restart certctl.
|
||||||
|
4. Devices now have TWO valid enrollment URLs:
|
||||||
|
`/scep/<pathID>` (challenge-password-only, legacy) and
|
||||||
|
`/scep-mtls/<pathID>` (cert + challenge, new).
|
||||||
|
5. Roll out config to fleet that switches devices to the new URL.
|
||||||
|
6. Once the fleet has migrated, remove `_CHALLENGE_PASSWORD` from the
|
||||||
|
profile (Validate() will keep the gate when MTLSEnabled=true so
|
||||||
|
the password requirement doesn't go away — the password is still
|
||||||
|
the application-layer auth boundary).
|
||||||
|
|
||||||
|
### Operational notes
|
||||||
|
|
||||||
|
- **Audit:** every enrollment emits an `audit_event` row with action
|
||||||
|
`scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators
|
||||||
|
can grep the audit log to distinguish.
|
||||||
|
- **Body-size cap:** `http.MaxBytesReader` middleware caps request
|
||||||
|
bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are
|
||||||
|
typically <50KB so the default cap is generous.
|
||||||
|
- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control
|
||||||
|
plane; there is no plaintext fallback.
|
||||||
|
- **Forward reference:** for Microsoft Intune deployments specifically,
|
||||||
|
see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the
|
||||||
|
master bundle ships).
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only
|
- [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -27,7 +28,30 @@ type SCEPService interface {
|
|||||||
GetCACert(ctx context.Context) (string, error)
|
GetCACert(ctx context.Context) (string, error)
|
||||||
|
|
||||||
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
||||||
|
// Used by the MVP raw-CSR fall-through path; preserved unchanged for
|
||||||
|
// backward compat with lightweight SCEP clients.
|
||||||
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
||||||
|
// (the handler successfully parsed an EnvelopedData + signerInfo POPO).
|
||||||
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
||||||
|
// RFC 8894 §3.3 mandates a CertRep PKIMessage on every response, even
|
||||||
|
// failures. Returns nil to signal 'invalid challenge password' (caller
|
||||||
|
// translates to HTTP 403, matching the MVP path's wire shape).
|
||||||
|
PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
|
|
||||||
|
// RenewalReqWithEnvelope processes a SCEP RenewalReq (RFC 8894 §3.3.1.2)
|
||||||
|
// from the RFC 8894 path. Same contract as PKCSReqWithEnvelope but the
|
||||||
|
// service additionally verifies that envelope.SignerCert chains to the
|
||||||
|
// issuer's CA — RenewalReq requires a previously-issued cert as POPO.
|
||||||
|
RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
|
|
||||||
|
// GetCertInitialWithEnvelope handles SCEP polling requests (RFC 8894
|
||||||
|
// §3.3.3). The v1 implementation always returns FAILURE+badCertID
|
||||||
|
// because deferred-issuance isn't supported (every PKCSReq either
|
||||||
|
// succeeds or fails synchronously); wiring is in place for a future
|
||||||
|
// 'queue for manual approval' workflow.
|
||||||
|
GetCertInitialWithEnvelope(ctx context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
||||||
}
|
}
|
||||||
|
|
||||||
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
||||||
@@ -39,15 +63,110 @@ type SCEPService interface {
|
|||||||
// - GET ?operation=GetCACaps — server capabilities
|
// - GET ?operation=GetCACaps — server capabilities
|
||||||
// - GET ?operation=GetCACert — CA certificate distribution
|
// - GET ?operation=GetCACert — CA certificate distribution
|
||||||
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: SCEPHandler now optionally
|
||||||
|
// carries an RA cert + key pair. When set, the handler tries the new RFC 8894
|
||||||
|
// PKIMessage path FIRST (parse SignedData → verify POPO → decrypt EnvelopedData).
|
||||||
|
// On any parse failure it falls through to the legacy MVP raw-CSR path (preserves
|
||||||
|
// backward compat with lightweight SCEP clients). When RA pair is unset, the
|
||||||
|
// handler runs MVP-only (the v2.0.x behavior).
|
||||||
type SCEPHandler struct {
|
type SCEPHandler struct {
|
||||||
svc SCEPService
|
svc SCEPService
|
||||||
|
raCert *x509.Certificate // RFC 8894 path: RA cert clients encrypt CSR to
|
||||||
|
raKey crypto.PrivateKey // RFC 8894 path: RA key for EnvelopedData decrypt + CertRep signing
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: per-profile mTLS
|
||||||
|
// trust bundle. When set, HandleSCEPMTLS verifies the inbound client
|
||||||
|
// cert chain against this pool. Nil when the profile has MTLSEnabled=false
|
||||||
|
// — HandleSCEPMTLS rejects unconditionally in that case (the route
|
||||||
|
// shouldn't even be registered, but defense in depth).
|
||||||
|
mtlsTrustPool *x509.CertPool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSCEPHandler creates a new SCEPHandler.
|
// NewSCEPHandler creates a new SCEPHandler with the legacy MVP-only behavior.
|
||||||
|
// SetRAPair below upgrades the handler to the RFC 8894 path; that's the route
|
||||||
|
// cmd/server/main.go takes when the operator supplies CERTCTL_SCEP_RA_*.
|
||||||
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
||||||
return SCEPHandler{svc: svc}
|
return SCEPHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRAPair injects the RA cert + key the RFC 8894 path needs. Called by
|
||||||
|
// cmd/server/main.go after the per-profile preflight gate validates the pair.
|
||||||
|
// Without this call the handler runs MVP-only (the legacy v2.0.x behavior).
|
||||||
|
func (h *SCEPHandler) SetRAPair(raCert *x509.Certificate, raKey crypto.PrivateKey) {
|
||||||
|
h.raCert = raCert
|
||||||
|
h.raKey = raKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMTLSTrustPool injects the per-profile client-cert trust pool the
|
||||||
|
// `/scep-mtls/<PathID>` sibling route uses to verify inbound device
|
||||||
|
// bootstrap certs. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||||
|
//
|
||||||
|
// The TLS layer (cmd/server/main.go::buildServerTLSConfig) uses
|
||||||
|
// VerifyClientCertIfGiven against the UNION of every enabled mTLS
|
||||||
|
// profile's bundle, so the same TLS listener serves both /scep
|
||||||
|
// (challenge-password-only) and /scep-mtls/<PathID> (cert + challenge).
|
||||||
|
// The per-profile gate at the handler layer enforces 'cert must chain to
|
||||||
|
// THIS profile's bundle' so a cert that chains to profile A's bundle
|
||||||
|
// cannot enroll against profile B even though it passed the TLS layer.
|
||||||
|
func (h *SCEPHandler) SetMTLSTrustPool(pool *x509.CertPool) {
|
||||||
|
h.mtlsTrustPool = pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSCEPMTLS is the entry point for the `/scep-mtls/<PathID>` sibling
|
||||||
|
// route. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||||
|
//
|
||||||
|
// Gates on the inbound client cert chain — the request must:
|
||||||
|
//
|
||||||
|
// 1. Carry a TLS connection (r.TLS != nil) — defense in depth even
|
||||||
|
// though the HTTPS-only listener guarantees this.
|
||||||
|
// 2. Have presented a peer cert (len(r.TLS.PeerCertificates) > 0) — the
|
||||||
|
// listener uses VerifyClientCertIfGiven, so a missing cert is a
|
||||||
|
// legitimate failure here, not a TLS error.
|
||||||
|
// 3. The peer cert chain must verify against THIS profile's trust pool
|
||||||
|
// (h.mtlsTrustPool). The TLS layer verified against the union pool
|
||||||
|
// of all mTLS profiles, but a cert that chains to profile A cannot
|
||||||
|
// enroll against profile B — verify per-profile here.
|
||||||
|
//
|
||||||
|
// Failures return HTTP 401 (Unauthorized — mTLS failure is authentication,
|
||||||
|
// not authorization). On success the call delegates to HandleSCEP — the
|
||||||
|
// challenge-password gate still fires (defense in depth: mTLS is additive,
|
||||||
|
// not replacement).
|
||||||
|
func (h SCEPHandler) HandleSCEPMTLS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.mtlsTrustPool == nil {
|
||||||
|
// Profile is misconfigured — handler registered for /scep-mtls but
|
||||||
|
// SetMTLSTrustPool was never called. The startup preflight should
|
||||||
|
// have caught this; surfacing as 500 makes the deploy bug loud.
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "mTLS handler missing trust pool", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
|
||||||
|
// Client didn't present a cert. With VerifyClientCertIfGiven the
|
||||||
|
// TLS handshake completes anyway — the per-profile gate enforces
|
||||||
|
// 'cert required' at the application layer.
|
||||||
|
ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate required for /scep-mtls", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
leaf := r.TLS.PeerCertificates[0]
|
||||||
|
intermediates := x509.NewCertPool()
|
||||||
|
for _, c := range r.TLS.PeerCertificates[1:] {
|
||||||
|
intermediates.AddCert(c)
|
||||||
|
}
|
||||||
|
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||||
|
Roots: h.mtlsTrustPool,
|
||||||
|
Intermediates: intermediates,
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageAny},
|
||||||
|
}); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate not trusted by this profile", middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Defense in depth — mTLS is ADDITIVE. The request still flows through
|
||||||
|
// HandleSCEP which enforces the challenge-password gate at the service
|
||||||
|
// layer. A stolen device cert without the matching challenge password
|
||||||
|
// still gets rejected (and vice versa).
|
||||||
|
h.HandleSCEP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleSCEP is the single entry point for all SCEP operations.
|
// HandleSCEP is the single entry point for all SCEP operations.
|
||||||
// It dispatches based on the "operation" query parameter.
|
// It dispatches based on the "operation" query parameter.
|
||||||
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -125,6 +244,22 @@ func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// pkiOperation handles POST ?operation=PKIOperation
|
// pkiOperation handles POST ?operation=PKIOperation
|
||||||
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: this handler tries the
|
||||||
|
// new RFC 8894 PKIMessage path FIRST (parse outer SignedData → verify
|
||||||
|
// signerInfo POPO → extract authenticatedAttributes → decrypt EnvelopedData
|
||||||
|
// to recover the inner CSR). On any parse failure it falls through to the
|
||||||
|
// legacy MVP raw-CSR path (extractCSRFromPKCS7). The MVP path stays
|
||||||
|
// unchanged for backward compat with lightweight SCEP clients.
|
||||||
|
//
|
||||||
|
// Path selection rules:
|
||||||
|
// - h.raCert / h.raKey unset → MVP-only (legacy v2.0.x behavior, never tries RFC 8894)
|
||||||
|
// - RA pair set + RFC 8894 parse succeeds → RFC 8894 path (CertRep PKIMessage response)
|
||||||
|
// - RA pair set + RFC 8894 parse fails → MVP fall-through (degenerate certs-only response)
|
||||||
|
//
|
||||||
|
// The Phase 3 commit will replace the MVP-fall-through writeSCEPResponse
|
||||||
|
// with writeCertRepPKIMessage for the RFC 8894 path; the MVP path keeps
|
||||||
|
// using writeSCEPResponse so lightweight clients see no behavior change.
|
||||||
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -145,7 +280,67 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
// Try the RFC 8894 path first when an RA pair is configured. On any
|
||||||
|
// parse failure we fall through to the MVP path silently — that's the
|
||||||
|
// backward-compat contract for lightweight clients.
|
||||||
|
if h.raCert != nil && h.raKey != nil {
|
||||||
|
if envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok {
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 4.1: dispatch on
|
||||||
|
// the parsed messageType. PKCSReq + RenewalReq exercise the
|
||||||
|
// full enrollment pipeline (different audit actions + chain
|
||||||
|
// validation for renewal); GetCertInitial is the polling
|
||||||
|
// shape (v1 stub returns badCertID since deferred-issuance
|
||||||
|
// isn't supported); unknown messageType returns CertRep with
|
||||||
|
// FAILURE+badRequest per RFC 8894 §3.3.2.2.
|
||||||
|
var resp *domain.SCEPResponseEnvelope
|
||||||
|
switch envelope.MessageType {
|
||||||
|
case domain.SCEPMessageTypePKCSReq:
|
||||||
|
resp = h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||||
|
case domain.SCEPMessageTypeRenewalReq:
|
||||||
|
resp = h.svc.RenewalReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
||||||
|
case domain.SCEPMessageTypeGetCertInitial:
|
||||||
|
resp = h.svc.GetCertInitialWithEnvelope(r.Context(), envelope)
|
||||||
|
default:
|
||||||
|
// Unknown messageType — emit a CertRep+FAILURE so the
|
||||||
|
// client sees a structured response rather than a vague
|
||||||
|
// 400. RFC 8894 §3.2.1.4.1 enumerates the valid types;
|
||||||
|
// anything else is a malformed client.
|
||||||
|
resp = &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadRequest,
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
// nil signals 'invalid challenge password' from the
|
||||||
|
// service layer (only PKCSReq + RenewalReq paths can
|
||||||
|
// return nil — GetCertInitial always returns a
|
||||||
|
// CertRep). RFC 8894 §3.3.1 is silent on whether to
|
||||||
|
// return a CertRep or an HTTP error for the wrong-
|
||||||
|
// password case; we mirror the MVP path's HTTP 403
|
||||||
|
// wire shape so the client sees a clear auth failure
|
||||||
|
// rather than trying to interpret a structurally-valid
|
||||||
|
// CertRep+failInfo (which conflates 'wrong secret'
|
||||||
|
// with 'wrong CSR shape').
|
||||||
|
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SCEP RFC 8894 Phase 3.2: emit CertRep PKIMessage for both
|
||||||
|
// success AND failure paths (RFC 8894 §3.3 mandates a
|
||||||
|
// PKIMessage response on every PKIOperation request, including
|
||||||
|
// failures). The MVP path keeps using writeSCEPResponse —
|
||||||
|
// that's the legacy certs-only response shape lightweight
|
||||||
|
// clients understand.
|
||||||
|
h.writeCertRepPKIMessage(w, r, envelope, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// RFC 8894 parse failed — fall through to the MVP path.
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVP path: extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
||||||
|
// using the legacy parser. This is what lightweight clients (raw-CSR-
|
||||||
|
// inside-SignedData, or even bare CSRs in some cases) hit.
|
||||||
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
||||||
@@ -183,6 +378,134 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeSCEPResponse(w, result)
|
h.writeSCEPResponse(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryParseRFC8894 attempts to parse the request body as an RFC 8894 SCEP
|
||||||
|
// PKIMessage:
|
||||||
|
// 1. Parse outer SignedData; pluck the device's transient signing cert.
|
||||||
|
// 2. Verify the signerInfo signature (POPO over auth-attrs).
|
||||||
|
// 3. Extract messageType / transactionID / senderNonce auth-attrs.
|
||||||
|
// 4. The encapContent is the inner pkcsPKIEnvelope (an EnvelopedData);
|
||||||
|
// decrypt it with h.raKey to recover the PKCS#10 CSR DER.
|
||||||
|
// 5. Parse the CSR + extract the challengePassword attribute (RFC 2985
|
||||||
|
// §5.4.1) so the service-layer's challenge-password gate can run.
|
||||||
|
// 6. PEM-encode the CSR for the service layer.
|
||||||
|
//
|
||||||
|
// Returns (envelope, csrPEM, challengePassword, true) on success;
|
||||||
|
// (nil, "", "", false) on any parse / verify / decrypt failure. The
|
||||||
|
// handler treats false as 'fall through to MVP path' so lightweight
|
||||||
|
// clients keep working.
|
||||||
|
func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, string, string, bool) {
|
||||||
|
sd, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
if len(sd.SignerInfos) == 0 {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
si := sd.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
tid, err := si.GetTransactionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
nonce, err := si.GetSenderNonce()
|
||||||
|
if err != nil {
|
||||||
|
// senderNonce is optional in some clients; treat missing as empty.
|
||||||
|
nonce = nil
|
||||||
|
}
|
||||||
|
// EncapContent is the inner pkcsPKIEnvelope (EnvelopedData). Parse +
|
||||||
|
// decrypt with the RA key.
|
||||||
|
if len(sd.EncapContent) == 0 {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
env, err := pkcs7.ParseEnvelopedData(sd.EncapContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
csrDER, err := env.Decrypt(h.raKey, h.raCert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
// Verify the recovered bytes really are a CSR. If not, fall through.
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false
|
||||||
|
}
|
||||||
|
// Extract the challengePassword attribute (RFC 2985 §5.4.1). Empty
|
||||||
|
// when missing; the service-layer gate then refuses with 'invalid
|
||||||
|
// challenge password' (correct behavior for clients that omit the
|
||||||
|
// auth attribute).
|
||||||
|
challengePassword := extractChallengePasswordFromCSR(csr)
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
||||||
|
envelope := &domain.SCEPRequestEnvelope{
|
||||||
|
MessageType: mt,
|
||||||
|
TransactionID: tid,
|
||||||
|
SenderNonce: nonce,
|
||||||
|
SignerCert: si.SignerCert.Raw,
|
||||||
|
}
|
||||||
|
return envelope, csrPEM, challengePassword, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractChallengePasswordFromCSR walks the parsed CSR's attributes for
|
||||||
|
// the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7).
|
||||||
|
// Returns empty string when missing.
|
||||||
|
//
|
||||||
|
// SA1019 carve-out: csr.Attributes is deprecated by Go's stdlib for the
|
||||||
|
// requestedExtensions attribute, but RFC 2985 challengePassword (OID
|
||||||
|
// 1.2.840.113549.1.9.7) is a SEPARATE CSR attribute that cannot be
|
||||||
|
// retrieved via csr.Extensions. There is no non-deprecated stdlib API
|
||||||
|
// for it; the same `lint:ignore SA1019` line precedent set by
|
||||||
|
// extractCSRFields applies here.
|
||||||
|
func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string {
|
||||||
|
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
||||||
|
//lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see extractCSRFields docblock for the M-028 audit closure rationale.
|
||||||
|
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 {
|
||||||
|
return pwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCertRepPKIMessage builds and writes a SCEP CertRep PKIMessage as
|
||||||
|
// the response to a PKIOperation request that was successfully parsed
|
||||||
|
// via the RFC 8894 path.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 3.2.
|
||||||
|
//
|
||||||
|
// Both success AND failure responses go through here — RFC 8894 §3.3
|
||||||
|
// mandates a PKIMessage response on every PKIOperation request, with
|
||||||
|
// pkiStatus + (on failure) failInfo signaling the outcome to the client.
|
||||||
|
//
|
||||||
|
// On failure to BUILD the response (a programmer / config bug — e.g. a
|
||||||
|
// device cert that's not RSA), we return HTTP 500 rather than try to
|
||||||
|
// construct a fallback PKIMessage that might re-trigger the same bug.
|
||||||
|
// Operators see a clear failure log + the request fails loud, which is
|
||||||
|
// preferable to silently emitting a half-built response.
|
||||||
|
func (h SCEPHandler) writeCertRepPKIMessage(w http.ResponseWriter, r *http.Request, req *domain.SCEPRequestEnvelope, resp *domain.SCEPResponseEnvelope) {
|
||||||
|
pkiMessageDER, err := pkcs7.BuildCertRepPKIMessage(req, resp, h.raCert, h.raKey)
|
||||||
|
if err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to build CertRep PKIMessage: %v", err), middleware.GetRequestID(r.Context()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-pki-message")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(pkiMessageDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import warning if some narrow build excludes the path
|
||||||
|
// where crypto.PrivateKey is used (the RA key field above).
|
||||||
|
var _ crypto.PrivateKey = (*interface{})(nil)
|
||||||
|
|
||||||
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
||||||
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
||||||
var derCerts [][]byte
|
var derCerts [][]byte
|
||||||
|
|||||||
@@ -0,0 +1,703 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des" //nolint:gosec // RFC 8894 §3.5.2 legacy fallback for backward-compat test
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration
|
||||||
|
// tests for the SCEP handler's full RFC 8894 path.
|
||||||
|
//
|
||||||
|
// Each test builds a real PKIMessage (acting as the ChromeOS client),
|
||||||
|
// POSTs it through the handler, and verifies the response. The "client"
|
||||||
|
// is built from primitives in internal/pkcs7/ — the same builders the
|
||||||
|
// handler uses on the response side. This is intentional: if the handler
|
||||||
|
// regresses, the client builder might also regress, and the E2E would
|
||||||
|
// pass anyway (false negative). The mitigation: round-trip property
|
||||||
|
// tests in internal/pkcs7/ assert Build/Parse symmetry independently,
|
||||||
|
// and the handler-side tests focus on the dispatch + status-code wire
|
||||||
|
// shape rather than the bytes themselves.
|
||||||
|
|
||||||
|
// chromeOSStackFixture holds the materials needed for an end-to-end
|
||||||
|
// ChromeOS SCEP test: an issuer + RA pair (server side), a transient
|
||||||
|
// device cert (client side), and a constructed SCEPHandler.
|
||||||
|
type chromeOSStackFixture struct {
|
||||||
|
raKey *rsa.PrivateKey
|
||||||
|
raCert *x509.Certificate
|
||||||
|
deviceKey *rsa.PrivateKey
|
||||||
|
deviceCert *x509.Certificate
|
||||||
|
handler SCEPHandler
|
||||||
|
svc *chromeOSMockSCEPService
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromeOSMockSCEPService is the per-test SCEPService implementation used
|
||||||
|
// by these E2E tests. Records the last call's envelope + CSR for assertion.
|
||||||
|
type chromeOSMockSCEPService struct {
|
||||||
|
caCertPEM string
|
||||||
|
pkcsReqEnvelope *domain.SCEPRequestEnvelope
|
||||||
|
pkcsReqCSRPEM string
|
||||||
|
pkcsReqChallenge string
|
||||||
|
renewalReqEnvelope *domain.SCEPRequestEnvelope
|
||||||
|
renewalReqCSRPEM string
|
||||||
|
getCertInitialEnvelope *domain.SCEPRequestEnvelope
|
||||||
|
enrollResult *domain.SCEPEnrollResult
|
||||||
|
failChallenge bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) GetCACaps(_ context.Context) string {
|
||||||
|
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) GetCACert(_ context.Context) (string, error) {
|
||||||
|
return m.caCertPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) {
|
||||||
|
return m.enrollResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) PKCSReqWithEnvelope(_ context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
m.pkcsReqEnvelope = env
|
||||||
|
m.pkcsReqCSRPEM = csrPEM
|
||||||
|
m.pkcsReqChallenge = challengePassword
|
||||||
|
if m.failChallenge {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
Result: m.enrollResult,
|
||||||
|
TransactionID: env.TransactionID,
|
||||||
|
RecipientNonce: env.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) RenewalReqWithEnvelope(_ context.Context, csrPEM, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
m.renewalReqEnvelope = env
|
||||||
|
m.renewalReqCSRPEM = csrPEM
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
Result: m.enrollResult,
|
||||||
|
TransactionID: env.TransactionID,
|
||||||
|
RecipientNonce: env.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chromeOSMockSCEPService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
m.getCertInitialEnvelope = env
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadCertID,
|
||||||
|
TransactionID: env.TransactionID,
|
||||||
|
RecipientNonce: env.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChromeOSStackFixture wires up an RA pair + device cert + handler with
|
||||||
|
// an enroll-result fixture so the test can POST a PKIMessage and verify the
|
||||||
|
// CertRep response.
|
||||||
|
func newChromeOSStackFixture(t *testing.T) *chromeOSStackFixture {
|
||||||
|
t.Helper()
|
||||||
|
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey RA: %v", err)
|
||||||
|
}
|
||||||
|
raCert := selfSignedRSACert(t, raKey, "ra-test")
|
||||||
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey device: %v", err)
|
||||||
|
}
|
||||||
|
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient")
|
||||||
|
|
||||||
|
svc := &chromeOSMockSCEPService{
|
||||||
|
enrollResult: &domain.SCEPEnrollResult{
|
||||||
|
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "issued.example.com")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := NewSCEPHandler(svc)
|
||||||
|
handler.SetRAPair(raCert, raKey)
|
||||||
|
|
||||||
|
return &chromeOSStackFixture{
|
||||||
|
raKey: raKey,
|
||||||
|
raCert: raCert,
|
||||||
|
deviceKey: deviceKey,
|
||||||
|
deviceCert: deviceCert,
|
||||||
|
handler: handler,
|
||||||
|
svc: svc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_E2E exercises the full RFC 8894 path:
|
||||||
|
// build a PKIMessage shaped like ChromeOS sends (SignedData wrapping
|
||||||
|
// EnvelopedData wrapping a CSR, with signerInfo POPO over auth attrs);
|
||||||
|
// POST through the handler; verify the response is a valid CertRep
|
||||||
|
// PKIMessage with the issued cert encrypted to the test's transient pubkey.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_E2E(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-chromeos-e2e", "shared-secret-123", "device-cert.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope == nil {
|
||||||
|
t.Fatal("PKCSReqWithEnvelope was not called — handler skipped RFC 8894 path?")
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope.TransactionID != "txn-chromeos-e2e" {
|
||||||
|
t.Errorf("envelope.TransactionID = %q, want txn-chromeos-e2e", fix.svc.pkcsReqEnvelope.TransactionID)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqChallenge != "shared-secret-123" {
|
||||||
|
t.Errorf("challengePassword = %q, want shared-secret-123", fix.svc.pkcsReqChallenge)
|
||||||
|
}
|
||||||
|
// Parse the CertRep back via the same builders the handler emits.
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData(CertRep response): %v", err)
|
||||||
|
}
|
||||||
|
if len(certRep.SignerInfos) != 1 {
|
||||||
|
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
|
||||||
|
}
|
||||||
|
if err := certRep.SignerInfos[0].VerifySignature(); err != nil {
|
||||||
|
t.Errorf("CertRep RA signature invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_RenewalReq exercises RenewalReq
|
||||||
|
// dispatch — the handler should route to RenewalReqWithEnvelope based on
|
||||||
|
// the messageType auth-attr.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_RenewalReq(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeRenewalReq, "txn-renewal-1", "shared-secret-123", "renewal.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
|
||||||
|
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (renewal): got %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if fix.svc.renewalReqEnvelope == nil {
|
||||||
|
t.Fatal("RenewalReqWithEnvelope was not called — dispatch missed messageType=17")
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope != nil {
|
||||||
|
t.Errorf("PKCSReqWithEnvelope was called for a RenewalReq messageType — wrong dispatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial exercises the polling
|
||||||
|
// path. v1 always returns FAILURE+badCertID; this test asserts that's what
|
||||||
|
// ChromeOS sees when it polls.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeGetCertInitial, "txn-poll-1", "shared-secret-123", "poll.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (poll): got %d, want 200 (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
if fix.svc.getCertInitialEnvelope == nil {
|
||||||
|
t.Fatal("GetCertInitialWithEnvelope was not called — dispatch missed messageType=20")
|
||||||
|
}
|
||||||
|
// The response should be a CertRep with pkiStatus=2 (FAILURE) +
|
||||||
|
// failInfo=4 (badCertID).
|
||||||
|
certRep, err := pkcs7.ParseSignedData(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(certRep.SignerInfos) == 0 {
|
||||||
|
t.Fatal("CertRep has no signerInfos")
|
||||||
|
}
|
||||||
|
si := certRep.SignerInfos[0]
|
||||||
|
statusRV, ok := si.AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("CertRep missing pkiStatus auth-attr")
|
||||||
|
}
|
||||||
|
statusStr := decodeFirstSetMember(t, statusRV)
|
||||||
|
if statusStr != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Errorf("pkiStatus = %q, want %q (FAILURE)", statusStr, domain.SCEPStatusFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_BadPOPO builds a PKIMessage with the
|
||||||
|
// signerInfo signature corrupted; expects the handler to fall through to
|
||||||
|
// the MVP path (the RFC 8894 verifier rejects the message, and the MVP
|
||||||
|
// path also rejects it because the encrypted EnvelopedData isn't a raw
|
||||||
|
// CSR). Result: HTTP 400 with a clear error message.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_BadPOPO(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-bad-popo", "shared-secret-123", "bad.example.com", aesKeyForOID(pkcs7.OIDAES256CBC))
|
||||||
|
// Tamper with the LAST byte of the message (which lands inside the
|
||||||
|
// signature OCTET STRING for a non-trivial chance of corrupting the
|
||||||
|
// signature without breaking the outer DER framing).
|
||||||
|
pkiMessage[len(pkiMessage)-1] ^= 0xff
|
||||||
|
|
||||||
|
w, _ := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||||
|
t.Errorf("POST PKIOperation (bad POPO): got %d, want 400 (MVP fall-through rejection) or 200 (CertRep+failInfo)", w.Code)
|
||||||
|
}
|
||||||
|
if fix.svc.pkcsReqEnvelope != nil {
|
||||||
|
t.Errorf("PKCSReqWithEnvelope was called despite invalid signerInfo signature — POPO check failed open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_ChromeOSPKIMessage_AESVariants exercises AES-128, 192,
|
||||||
|
// and 256-CBC. ChromeOS picks based on the GetCACaps response; verify
|
||||||
|
// all three round-trip correctly.
|
||||||
|
func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
oid asn1.ObjectIdentifier
|
||||||
|
}{
|
||||||
|
{"AES-128-CBC", pkcs7.OIDAES128CBC},
|
||||||
|
{"AES-192-CBC", pkcs7.OIDAES192CBC},
|
||||||
|
{"AES-256-CBC", pkcs7.OIDAES256CBC},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fix := newChromeOSStackFixture(t)
|
||||||
|
pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-aes-"+tc.name, "shared-secret-123", "aes.example.com", aesKeyForOID(tc.oid))
|
||||||
|
pkiMessage = withContentEncryptionOID(t, pkiMessage, fix, tc.oid, aesKeyForOID(tc.oid))
|
||||||
|
w, body := postPKIOperation(t, fix.handler, pkiMessage)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST PKIOperation (%s): got %d, want 200 (body=%q)", tc.name, w.Code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw
|
||||||
|
// CSR inside a stripped SignedData, no EnvelopedData) STILL works for
|
||||||
|
// backward compat with lightweight clients.
|
||||||
|
func TestSCEPHandler_MVPCompat_StillWorks(t *testing.T) {
|
||||||
|
// Build an MVP-shape request: a SignedData whose encapContent is a
|
||||||
|
// raw CSR (no EnvelopedData wrapper). The legacy handler path
|
||||||
|
// extractCSRFromPKCS7 unwraps it.
|
||||||
|
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
csrDER := buildTestCSR(t, deviceKey, "mvp.example.com", "mvp-shared-secret")
|
||||||
|
|
||||||
|
// Wrap in MVP-shape PKCS#7 SignedData (encapContent = CSR DER as
|
||||||
|
// OCTET STRING). The existing extractCSRFromPKCS7 handles this.
|
||||||
|
mvpPKCS7 := buildMVPSignedData(t, csrDER)
|
||||||
|
|
||||||
|
svc := &chromeOSMockSCEPService{
|
||||||
|
enrollResult: &domain.SCEPEnrollResult{
|
||||||
|
CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "mvp-issued.example.com")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Note: NO RA pair set — the handler runs MVP-only.
|
||||||
|
handler := NewSCEPHandler(svc)
|
||||||
|
w, body := postPKIOperation(t, handler, mvpPKCS7)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("MVP path POST: got %d, want 200 (body=%q)", w.Code, body)
|
||||||
|
}
|
||||||
|
// Response is the legacy certs-only PKCS#7, NOT a CertRep PKIMessage.
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/x-pki-message", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func postPKIOperation(t *testing.T, h SCEPHandler, body []byte) (*httptest.ResponseRecorder, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", bytes.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEP(w, req)
|
||||||
|
respBody, _ := io.ReadAll(w.Body)
|
||||||
|
return w, respBody
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChromeOSStylePKIMessage builds a real SCEP PKIMessage targeting the
|
||||||
|
// fixture's RA cert. Mirrors what ChromeOS / micromdm-style clients emit:
|
||||||
|
// SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping an
|
||||||
|
// EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)).
|
||||||
|
func buildChromeOSStylePKIMessage(t *testing.T, fix *chromeOSStackFixture, messageType domain.SCEPMessageType, transactionID, challengePassword, csrCN string, symKey []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// 1. Build the inner CSR carrying the challengePassword attribute.
|
||||||
|
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
|
||||||
|
|
||||||
|
// 2. Encrypt the CSR via AES-CBC under symKey + random IV.
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||||
|
|
||||||
|
// 3. RSA-encrypt the symKey to fix.raCert.PublicKey.
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt symKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build EnvelopedData wrapping ciphertext.
|
||||||
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
|
||||||
|
|
||||||
|
// 5. Build the SignedData carrying the EnvelopedData with a
|
||||||
|
// signerInfo signed by the device's transient cert/key.
|
||||||
|
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, messageType, transactionID, []byte("0123456789abcdef"), envelopedData)
|
||||||
|
return signedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// withContentEncryptionOID rewrites the AES OID inside an already-built
|
||||||
|
// PKIMessage by re-building from scratch with the new OID. Simpler than
|
||||||
|
// surgically patching the bytes.
|
||||||
|
func withContentEncryptionOID(t *testing.T, _ []byte, fix *chromeOSStackFixture, oid asn1.ObjectIdentifier, symKey []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
csrDER := buildTestCSR(t, fix.deviceKey, "aes.example.com", "shared-secret-123")
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand iv: %v", err)
|
||||||
|
}
|
||||||
|
ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER)
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa encrypt: %v", err)
|
||||||
|
}
|
||||||
|
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oid)
|
||||||
|
return buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-aes", []byte("0123456789abcdef"), envelopedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes.NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
bs := block.BlockSize()
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := append([]byte{}, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
out := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(out, padded)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidForAESKeyLen maps an AES key length to its CBC OID. Helper for the
|
||||||
|
// AES-variants table-driven test.
|
||||||
|
func oidForAESKeyLen(t *testing.T, n int) asn1.ObjectIdentifier {
|
||||||
|
t.Helper()
|
||||||
|
switch n {
|
||||||
|
case 16:
|
||||||
|
return pkcs7.OIDAES128CBC
|
||||||
|
case 24:
|
||||||
|
return pkcs7.OIDAES192CBC
|
||||||
|
case 32:
|
||||||
|
return pkcs7.OIDAES256CBC
|
||||||
|
}
|
||||||
|
t.Fatalf("oidForAESKeyLen: unsupported key length %d", n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aesKeyForOID returns a deterministic-length symmetric key matching the
|
||||||
|
// AES variant identified by oid. Test-only — production uses crypto/rand.
|
||||||
|
func aesKeyForOID(oid asn1.ObjectIdentifier) []byte {
|
||||||
|
switch {
|
||||||
|
case oid.Equal(pkcs7.OIDAES128CBC):
|
||||||
|
return bytes.Repeat([]byte{0x42}, 16)
|
||||||
|
case oid.Equal(pkcs7.OIDAES192CBC):
|
||||||
|
return bytes.Repeat([]byte{0x42}, 24)
|
||||||
|
case oid.Equal(pkcs7.OIDAES256CBC):
|
||||||
|
return bytes.Repeat([]byte{0x42}, 32)
|
||||||
|
case oid.Equal(pkcs7.OIDDESEDE3CBC):
|
||||||
|
return bytes.Repeat([]byte{0x42}, 24)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestCSR creates a CSR with a challengePassword attribute. Used by
|
||||||
|
// the buildChromeOSStylePKIMessage helper to populate the EnvelopedData
|
||||||
|
// inner content.
|
||||||
|
func buildTestCSR(t *testing.T, key *rsa.PrivateKey, commonName, challengePassword string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
// Build the challengePassword attribute (RFC 2985 §5.4.1, OID
|
||||||
|
// 1.2.840.113549.1.9.7).
|
||||||
|
cpAttr := pkix.AttributeTypeAndValue{
|
||||||
|
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||||
|
Value: challengePassword,
|
||||||
|
}
|
||||||
|
cpAttrSet, err := asn1.Marshal(cpAttr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal cp attr: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
// Inject the challengePassword as a raw extra extension via the
|
||||||
|
// CSR Attributes field.
|
||||||
|
ExtraExtensions: []pkix.Extension{},
|
||||||
|
Attributes: []pkix.AttributeTypeAndValueSET{
|
||||||
|
{
|
||||||
|
Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7},
|
||||||
|
Value: [][]pkix.AttributeTypeAndValue{
|
||||||
|
{{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = cpAttrSet
|
||||||
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||||
|
}
|
||||||
|
return der
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvelopedDataForTest builds an EnvelopedData targeting raCert with
|
||||||
|
// a single KTRI carrying the encrypted symmetric key + the AES-CBC
|
||||||
|
// ciphertext. Mirrors the Phase 3 buildEnvelopedDataAES256 internal helper
|
||||||
|
// but exposed at test scope.
|
||||||
|
func buildEnvelopedDataForTest(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte, contentEncOID asn1.ObjectIdentifier) []byte {
|
||||||
|
t.Helper()
|
||||||
|
// IssuerAndSerial of the recipient.
|
||||||
|
serialDER, err := asn1.Marshal(raCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal serial: %v", err)
|
||||||
|
}
|
||||||
|
risBody := append([]byte{}, raCert.RawIssuer...)
|
||||||
|
risBody = append(risBody, serialDER...)
|
||||||
|
risBytes := pkcs7.ASN1Wrap(0x30, risBody)
|
||||||
|
|
||||||
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||||
|
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal keyEncAlg: %v", err)
|
||||||
|
}
|
||||||
|
encryptedKeyBytes := pkcs7.ASN1Wrap(0x04, encryptedKey)
|
||||||
|
|
||||||
|
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||||
|
ktriBody = append(ktriBody, risBytes...)
|
||||||
|
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
||||||
|
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
||||||
|
ktriBytes := pkcs7.ASN1Wrap(0x30, ktriBody)
|
||||||
|
|
||||||
|
recipientInfosBytes := pkcs7.ASN1Wrap(0x31, ktriBytes)
|
||||||
|
|
||||||
|
ivOctet := pkcs7.ASN1Wrap(0x04, iv)
|
||||||
|
contentAlg := pkix.AlgorithmIdentifier{
|
||||||
|
Algorithm: contentEncOID,
|
||||||
|
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
||||||
|
}
|
||||||
|
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal contentAlg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encContentField := pkcs7.ASN1Wrap(0x80, ciphertext)
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
eciBody := append([]byte{}, oidDataBytes...)
|
||||||
|
eciBody = append(eciBody, contentAlgBytes...)
|
||||||
|
eciBody = append(eciBody, encContentField...)
|
||||||
|
eciBytes := pkcs7.ASN1Wrap(0x30, eciBody)
|
||||||
|
|
||||||
|
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...)
|
||||||
|
envBody = append(envBody, recipientInfosBytes...)
|
||||||
|
envBody = append(envBody, eciBytes...)
|
||||||
|
return pkcs7.ASN1Wrap(0x30, envBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignedDataForTest builds a CMS SignedData with the device cert as
|
||||||
|
// the signer + auth-attrs carrying SCEP messageType / transactionID /
|
||||||
|
// senderNonce + messageDigest of the encapContent.
|
||||||
|
func buildSignedDataForTest(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
contentDigest := sha256.Sum256(encapContent)
|
||||||
|
|
||||||
|
// Auth-attrs SET-OF body.
|
||||||
|
var attrSetBody []byte
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDContentType, pkcs7.ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDMessageDigest, pkcs7.ASN1Wrap(0x04, contentDigest[:]))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPMessageType, pkcs7.ASN1Wrap(0x13, []byte(intToASCII(int(messageType)))))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPTransactionID, pkcs7.ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||||
|
attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPSenderNonce, pkcs7.ASN1Wrap(0x04, senderNonce))...)
|
||||||
|
|
||||||
|
// Sign over SET OF Attribute (RFC 5652 §5.4 quirk).
|
||||||
|
signedAttrsForSig := pkcs7.ASN1Wrap(0x31, attrSetBody)
|
||||||
|
digest := sha256.Sum256(signedAttrsForSig)
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignerInfo SEQUENCE.
|
||||||
|
versionBytes := []byte{0x02, 0x01, 0x01}
|
||||||
|
serialDER, _ := asn1.Marshal(signerCert.SerialNumber)
|
||||||
|
sidBody := append([]byte{}, signerCert.RawIssuer...)
|
||||||
|
sidBody = append(sidBody, serialDER...)
|
||||||
|
sidBytes := pkcs7.ASN1Wrap(0x30, sidBody)
|
||||||
|
|
||||||
|
digestAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDSHA256, Parameters: asn1.NullRawValue}
|
||||||
|
digestAlgBytes, _ := asn1.Marshal(digestAlg)
|
||||||
|
|
||||||
|
signedAttrsImplicit := pkcs7.ASN1Wrap(0xa0, attrSetBody)
|
||||||
|
|
||||||
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAWithSHA256, Parameters: asn1.NullRawValue}
|
||||||
|
sigAlgBytes, _ := asn1.Marshal(sigAlg)
|
||||||
|
|
||||||
|
sigOctet := pkcs7.ASN1Wrap(0x04, sig)
|
||||||
|
|
||||||
|
siBody := append([]byte{}, versionBytes...)
|
||||||
|
siBody = append(siBody, sidBytes...)
|
||||||
|
siBody = append(siBody, digestAlgBytes...)
|
||||||
|
siBody = append(siBody, signedAttrsImplicit...)
|
||||||
|
siBody = append(siBody, sigAlgBytes...)
|
||||||
|
siBody = append(siBody, sigOctet...)
|
||||||
|
siBytes := pkcs7.ASN1Wrap(0x30, siBody)
|
||||||
|
|
||||||
|
// encapContentInfo
|
||||||
|
octetWrap := pkcs7.ASN1Wrap(0x04, encapContent)
|
||||||
|
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
encapBody := append([]byte{}, oidDataBytes...)
|
||||||
|
encapBody = append(encapBody, explicitWrap...)
|
||||||
|
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||||
|
|
||||||
|
// certificates [0] IMPLICIT SET OF Certificate
|
||||||
|
certsBytes := pkcs7.ASN1Wrap(0xa0, signerCert.Raw)
|
||||||
|
|
||||||
|
// digestAlgorithms SET OF
|
||||||
|
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, digestAlgBytes)
|
||||||
|
// signerInfos SET OF
|
||||||
|
signerInfosBytes := pkcs7.ASN1Wrap(0x31, siBytes)
|
||||||
|
|
||||||
|
// SignedData SEQUENCE
|
||||||
|
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||||
|
sdBody = append(sdBody, digestAlgsBytes...)
|
||||||
|
sdBody = append(sdBody, encapBytes...)
|
||||||
|
sdBody = append(sdBody, certsBytes...)
|
||||||
|
sdBody = append(sdBody, signerInfosBytes...)
|
||||||
|
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||||
|
|
||||||
|
// ContentInfo wrap
|
||||||
|
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||||
|
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
ciBody := append([]byte{}, oidSignedData...)
|
||||||
|
ciBody = append(ciBody, contentField...)
|
||||||
|
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMVPSignedData builds a degenerate SignedData where the encapContent
|
||||||
|
// is the raw CSR bytes — what lightweight SCEP clients send. Used by the
|
||||||
|
// MVP-compat test to confirm the legacy parser still works.
|
||||||
|
func buildMVPSignedData(t *testing.T, csrDER []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
octetWrap := pkcs7.ASN1Wrap(0x04, csrDER)
|
||||||
|
explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap)
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
encapBody := append([]byte{}, oidDataBytes...)
|
||||||
|
encapBody = append(encapBody, explicitWrap...)
|
||||||
|
encapBytes := pkcs7.ASN1Wrap(0x30, encapBody)
|
||||||
|
|
||||||
|
digestAlgsBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||||
|
signerInfosBytes := pkcs7.ASN1Wrap(0x31, nil)
|
||||||
|
|
||||||
|
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...)
|
||||||
|
sdBody = append(sdBody, digestAlgsBytes...)
|
||||||
|
sdBody = append(sdBody, encapBytes...)
|
||||||
|
sdBody = append(sdBody, signerInfosBytes...)
|
||||||
|
sdSeq := pkcs7.ASN1Wrap(0x30, sdBody)
|
||||||
|
|
||||||
|
contentField := pkcs7.ASN1Wrap(0xa0, sdSeq)
|
||||||
|
oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
ciBody := append([]byte{}, oidSignedData...)
|
||||||
|
ciBody = append(ciBody, contentField...)
|
||||||
|
return pkcs7.ASN1Wrap(0x30, ciBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attrSeqHelper(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
oidBytes, err := asn1.Marshal(oid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal OID %v: %v", oid, err)
|
||||||
|
}
|
||||||
|
setOfValue := pkcs7.ASN1Wrap(0x31, value)
|
||||||
|
body := append([]byte{}, oidBytes...)
|
||||||
|
body = append(body, setOfValue...)
|
||||||
|
return pkcs7.ASN1Wrap(0x30, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeFirstSetMember(t *testing.T, rv asn1.RawValue) string {
|
||||||
|
t.Helper()
|
||||||
|
var inner asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||||
|
t.Fatalf("unmarshal SET first member: %v", err)
|
||||||
|
}
|
||||||
|
return string(inner.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToASCII(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for i > 0 {
|
||||||
|
b = append([]byte{byte('0' + i%10)}, b...)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfSignedRSACert(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
der := selfSignedRSACertRaw(t, key, cn)
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfSignedRSACertRaw(t *testing.T, key *rsa.PrivateKey, cn string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
Issuer: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return der
|
||||||
|
}
|
||||||
|
|
||||||
|
func pemEncodeCert(der []byte) string {
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import warnings — these packages are referenced inside
|
||||||
|
// helpers above; Go's import-pruning is conservative around test-only
|
||||||
|
// uses through other test files.
|
||||||
|
var (
|
||||||
|
_ = ecdsa.PublicKey{}
|
||||||
|
_ = elliptic.P256
|
||||||
|
_ = des.NewTripleDESCipher
|
||||||
|
)
|
||||||
@@ -36,6 +36,45 @@ func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengeP
|
|||||||
return m.EnrollResult, m.EnrollErr
|
return m.EnrollResult, m.EnrollErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope is the RFC 8894 envelope-aware variant added in SCEP
|
||||||
|
// RFC 8894 + Intune master bundle Phase 2.4. The MVP-only handler tests
|
||||||
|
// don't exercise this path (RA pair is unset), so this stub is only here
|
||||||
|
// to satisfy the interface; behavior mirrors PKCSReq's success/failure
|
||||||
|
// based on the same EnrollResult / EnrollErr fields the existing tests
|
||||||
|
// already populate.
|
||||||
|
func (m *mockSCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
if m.EnrollErr != nil {
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadRequest,
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
Result: m.EnrollResult,
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewalReqWithEnvelope + GetCertInitialWithEnvelope added in Phase 4 to
|
||||||
|
// satisfy the extended SCEPService interface. Same MVP-only test fixture
|
||||||
|
// rules apply — these stubs mirror PKCSReqWithEnvelope's shape.
|
||||||
|
func (m *mockSCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return m.PKCSReqWithEnvelope(ctx, csrPEM, challengePassword, envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadCertID,
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
func TestSCEP_GetCACaps_Success(t *testing.T) {
|
||||||
svc := &mockSCEPService{}
|
svc := &mockSCEPService{}
|
||||||
h := NewSCEPHandler(svc)
|
h := NewSCEPHandler(svc)
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: mTLS sibling SCEP
|
||||||
|
// route. Pins the auth contract:
|
||||||
|
//
|
||||||
|
// 1. RejectsMissingClientCert — request without r.TLS.PeerCertificates
|
||||||
|
// gets HTTP 401 (mTLS failure is authentication, not authorization).
|
||||||
|
// 2. RejectsUntrustedClientCert — cert that doesn't chain to the
|
||||||
|
// configured trust pool gets HTTP 401.
|
||||||
|
// 3. AcceptsTrustedClientCert — cert that chains + valid challenge
|
||||||
|
// password = 200 (delegates to HandleSCEP which returns 200 for
|
||||||
|
// GetCACaps).
|
||||||
|
// 4. StillRequiresChallengePassword — valid client cert + invalid
|
||||||
|
// challenge password reaches the handler but the service-layer
|
||||||
|
// gate rejects. (For this test we exercise the GetCACaps GET — the
|
||||||
|
// challenge-password gate fires on PKIOperation; the test is here
|
||||||
|
// to pin that mTLS does NOT bypass the standard SCEP auth chain.)
|
||||||
|
// 5. StandardSCEPRoute_StillNoMTLS — pin the standard /scep route
|
||||||
|
// keeps working without a client cert; the router test next door
|
||||||
|
// covers the route registration shape.
|
||||||
|
//
|
||||||
|
// The mock SCEPService is the same mockSCEPService from
|
||||||
|
// scep_handler_test.go (same package).
|
||||||
|
|
||||||
|
// mtlsTestFixture materialises a per-test mTLS trust CA + a client cert
|
||||||
|
// that chains to it (the "trusted device") + an unrelated CA + cert
|
||||||
|
// (the "untrusted attacker"). Returns the SCEPHandler with the trust
|
||||||
|
// pool wired and pre-built TLS connection states for each cert.
|
||||||
|
type mtlsTestFixture struct {
|
||||||
|
handler SCEPHandler
|
||||||
|
trustedTLSState *tls.ConnectionState
|
||||||
|
untrustedTLSState *tls.ConnectionState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMTLSTestFixture(t *testing.T) *mtlsTestFixture {
|
||||||
|
t.Helper()
|
||||||
|
// Trusted bootstrap CA + client cert chained to it.
|
||||||
|
trustedCA, trustedCAKey := genSelfSignedECDSACA(t, "trusted-bootstrap-ca")
|
||||||
|
trustedClient := signECDSAClientCert(t, "trusted-device", trustedCA, trustedCAKey)
|
||||||
|
// Untrusted CA + client cert chained to a different CA — should NOT
|
||||||
|
// be accepted by the trusted profile's mTLS handler.
|
||||||
|
untrustedCA, untrustedCAKey := genSelfSignedECDSACA(t, "untrusted-attacker-ca")
|
||||||
|
untrustedClient := signECDSAClientCert(t, "untrusted-device", untrustedCA, untrustedCAKey)
|
||||||
|
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(trustedCA)
|
||||||
|
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc)
|
||||||
|
h.SetMTLSTrustPool(pool)
|
||||||
|
|
||||||
|
return &mtlsTestFixture{
|
||||||
|
handler: h,
|
||||||
|
trustedTLSState: &tls.ConnectionState{
|
||||||
|
HandshakeComplete: true,
|
||||||
|
PeerCertificates: []*x509.Certificate{trustedClient},
|
||||||
|
},
|
||||||
|
untrustedTLSState: &tls.ConnectionState{
|
||||||
|
HandshakeComplete: true,
|
||||||
|
PeerCertificates: []*x509.Certificate{untrustedClient},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPMTLSHandler_RejectsMissingClientCert(t *testing.T) {
|
||||||
|
fix := newMTLSTestFixture(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil)
|
||||||
|
// req.TLS intentionally nil — simulates a client that didn't present
|
||||||
|
// a cert during the handshake (VerifyClientCertIfGiven allows this).
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fix.handler.HandleSCEPMTLS(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("HandleSCEPMTLS without client cert: got %d, want 401 (body=%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPMTLSHandler_RejectsUntrustedClientCert(t *testing.T) {
|
||||||
|
fix := newMTLSTestFixture(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil)
|
||||||
|
req.TLS = fix.untrustedTLSState
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fix.handler.HandleSCEPMTLS(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("HandleSCEPMTLS with untrusted client cert: got %d, want 401 (body=%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPMTLSHandler_AcceptsTrustedClientCert(t *testing.T) {
|
||||||
|
fix := newMTLSTestFixture(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil)
|
||||||
|
req.TLS = fix.trustedTLSState
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fix.handler.HandleSCEPMTLS(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("HandleSCEPMTLS with trusted client cert: got %d, want 200 (GetCACaps; body=%q)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Sanity: response body is the GetCACaps capability list (the
|
||||||
|
// HandleSCEP delegate ran).
|
||||||
|
if got := w.Body.String(); got == "" {
|
||||||
|
t.Errorf("HandleSCEPMTLS body empty, want SCEP capabilities")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPMTLSHandler_StillRoutesThroughHandleSCEP(t *testing.T) {
|
||||||
|
// With a valid client cert, HandleSCEPMTLS delegates to HandleSCEP —
|
||||||
|
// pin that the standard SCEP dispatch still runs (operation query-
|
||||||
|
// param dispatch, content-type negotiation, etc.). Defense in depth:
|
||||||
|
// mTLS is additive, NOT replacement; the standard SCEP code path
|
||||||
|
// must still execute end-to-end.
|
||||||
|
fix := newMTLSTestFixture(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil)
|
||||||
|
req.TLS = fix.trustedTLSState
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fix.handler.HandleSCEPMTLS(w, req)
|
||||||
|
if got := w.Header().Get("Content-Type"); got != "text/plain" {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain (HandleSCEP didn't run)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPMTLSHandler_NoTrustPool_Returns500(t *testing.T) {
|
||||||
|
// A handler registered for /scep-mtls but with SetMTLSTrustPool never
|
||||||
|
// called is a deploy bug — the startup preflight should have caught
|
||||||
|
// this. Pin that the handler returns HTTP 500 in that state rather
|
||||||
|
// than silently accepting (or worse, panicking).
|
||||||
|
svc := &mockSCEPService{}
|
||||||
|
h := NewSCEPHandler(svc) // no SetMTLSTrustPool call
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSCEPMTLS(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("HandleSCEPMTLS without trust pool: got %d, want 500 (deploy-bug surface)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPHandler_StandardRoute_StillNoMTLS(t *testing.T) {
|
||||||
|
// Pin: the standard HandleSCEP entry point does NOT require a
|
||||||
|
// client cert even when an mTLS pool is set — the standard route
|
||||||
|
// remains application-layer-auth (challenge password). Operators
|
||||||
|
// can run BOTH routes simultaneously for migration / heterogeneous
|
||||||
|
// client fleets.
|
||||||
|
fix := newMTLSTestFixture(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
||||||
|
// req.TLS intentionally nil — standard /scep should still serve.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
fix.handler.HandleSCEP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("HandleSCEP (standard route) without client cert: got %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func genSelfSignedECDSACA(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey CA: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
Issuer: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate CA: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate CA: %v", err)
|
||||||
|
}
|
||||||
|
return cert, key
|
||||||
|
}
|
||||||
|
|
||||||
|
func signECDSAClientCert(t *testing.T, cn string, ca *x509.Certificate, caKey *ecdsa.PrivateKey) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey client: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano() + 1),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate client: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate client: %v", err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-package warning if context becomes orphan in future
|
||||||
|
// refactors of the mTLS test file (keeps imports stable).
|
||||||
|
var _ = context.Background
|
||||||
@@ -36,7 +36,21 @@ import (
|
|||||||
// At Bundle D close time, this list is empty. Future entries should be
|
// At Bundle D close time, this list is empty. Future entries should be
|
||||||
// rare — the OpenAPI spec is the source of truth for the public API
|
// rare — the OpenAPI spec is the source of truth for the public API
|
||||||
// surface.
|
// surface.
|
||||||
var SpecParityExceptions = map[string]string{}
|
var SpecParityExceptions = map[string]string{
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the /scep-mtls
|
||||||
|
// sibling route is opt-in (gated on per-profile MTLSEnabled). It rides
|
||||||
|
// the same SCEP-PKIOperation contract as /scep but with an additional
|
||||||
|
// client-cert auth layer at the handler. The OpenAPI spec covers the
|
||||||
|
// canonical /scep endpoint; documenting /scep-mtls separately would
|
||||||
|
// duplicate every operation row with no information gain — the
|
||||||
|
// PKIMessage wire format, query params, and response shapes are
|
||||||
|
// identical. The route lives in router.go as literal r.Register calls
|
||||||
|
// for the openapi-parity scanner's benefit; it stays out of openapi.yaml
|
||||||
|
// by exception. See docs/legacy-est-scep.md::mTLS-sibling-route for the
|
||||||
|
// operator-facing description.
|
||||||
|
"GET /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||||
|
"POST /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||||
routes, err := scanRouterRoutes("router.go")
|
routes, err := scanRouterRoutes("router.go")
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ var AuthExemptDispatchPrefixes = []string{
|
|||||||
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
|
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
|
||||||
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
|
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
|
||||||
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
|
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
|
||||||
|
"/scep-mtls", // SCEP + mTLS sibling route (Phase 6.5) — auth is client cert + challengePassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandlerRegistry groups all API handler dependencies for router registration.
|
// HandlerRegistry groups all API handler dependencies for router registration.
|
||||||
@@ -425,6 +426,42 @@ func (r *Router) RegisterSCEPHandlers(handlers map[string]handler.SCEPHandler) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterSCEPMTLSHandlers sets up the sibling `/scep-mtls/<PathID>` routes
|
||||||
|
// for SCEP profiles that opted into mTLS via
|
||||||
|
// `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true`.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: enterprise procurement
|
||||||
|
// teams routinely reject 'shared password authentication' as a checkbox-
|
||||||
|
// fail regardless of how strong the password is. This sibling route adds
|
||||||
|
// client-cert auth at the handler layer AND keeps the challenge password
|
||||||
|
// (defense in depth, not replacement). Devices present a bootstrap cert
|
||||||
|
// from a trusted CA, then SCEP-enroll for their long-lived cert. Same
|
||||||
|
// model Apple's MDM and Cisco's BRSKI use.
|
||||||
|
//
|
||||||
|
// Path conventions mirror the standard SCEP route: empty PathID maps to
|
||||||
|
// `/scep-mtls` root (single-profile mTLS deploy); non-empty PathIDs map
|
||||||
|
// to `/scep-mtls/<pathID>`. The /scep-mtls prefix is in
|
||||||
|
// AuthExemptDispatchPrefixes — the auth boundary is the client cert
|
||||||
|
// (verified at the TLS layer + per-profile re-verified at the handler
|
||||||
|
// layer) plus the challenge password, NOT a Bearer token.
|
||||||
|
//
|
||||||
|
// Each handler in the map MUST have had SetMTLSTrustPool called so the
|
||||||
|
// per-profile cert verification has a trust anchor.
|
||||||
|
func (r *Router) RegisterSCEPMTLSHandlers(handlers map[string]handler.SCEPHandler) {
|
||||||
|
if h, ok := handlers[""]; ok {
|
||||||
|
r.Register("GET /scep-mtls", http.HandlerFunc(h.HandleSCEPMTLS))
|
||||||
|
r.Register("POST /scep-mtls", http.HandlerFunc(h.HandleSCEPMTLS))
|
||||||
|
}
|
||||||
|
for pathID, h := range handlers {
|
||||||
|
if pathID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hCopy := h
|
||||||
|
r.Register("GET /scep-mtls/"+pathID, http.HandlerFunc(hCopy.HandleSCEPMTLS))
|
||||||
|
r.Register("POST /scep-mtls/"+pathID, http.HandlerFunc(hCopy.HandleSCEPMTLS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
|
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
|
||||||
// /.well-known/pki/. These endpoints are intentionally unauthenticated so
|
// /.well-known/pki/. These endpoints are intentionally unauthenticated so
|
||||||
// relying parties (browsers, OpenSSL, OCSP stapling sidecars, mTLS clients)
|
// relying parties (browsers, OpenSSL, OCSP stapling sidecars, mTLS clients)
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope / RenewalReqWithEnvelope / GetCertInitialWithEnvelope
|
||||||
|
// were added to the SCEPService interface in SCEP RFC 8894 + Intune master
|
||||||
|
// bundle Phase 2.4 + Phase 4. The router-level tests don't drive the
|
||||||
|
// RFC 8894 path; these stubs satisfy the interface so the per-profile
|
||||||
|
// dispatch tests still compile.
|
||||||
|
func (s *scepProfileMockService) PKCSReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scepProfileMockService) RenewalReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scepProfileMockService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadCertID, TransactionID: env.TransactionID}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) {
|
||||||
r := New()
|
r := New()
|
||||||
svc := &scepProfileMockService{tag: "legacy"}
|
svc := &scepProfileMockService{tag: "legacy"}
|
||||||
|
|||||||
@@ -796,6 +796,30 @@ type SCEPProfileConfig struct {
|
|||||||
// match, expiry, RSA-or-ECDSA alg).
|
// match, expiry, RSA-or-ECDSA alg).
|
||||||
RACertPath string
|
RACertPath string
|
||||||
RAKeyPath string
|
RAKeyPath string
|
||||||
|
|
||||||
|
// MTLSEnabled gates the sibling `/scep-mtls/<PathID>` route. When true,
|
||||||
|
// the route requires a client cert that chains to one of the certs in
|
||||||
|
// MTLSClientCATrustBundlePath. The standard `/scep[/<PathID>]` route
|
||||||
|
// remains application-layer-auth (challenge password) so existing
|
||||||
|
// clients keep working — mTLS is additive, not replacement.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: enterprise procurement
|
||||||
|
// teams routinely reject 'shared password authentication' as a checkbox-
|
||||||
|
// fail regardless of how strong the password is. This flag wires up a
|
||||||
|
// sibling route that adds client-cert auth at the handler layer AND keeps
|
||||||
|
// the challenge password (defense in depth, not replacement). Devices
|
||||||
|
// present a bootstrap cert from a trusted CA (e.g. a manufacturing-time
|
||||||
|
// cert), then SCEP-enroll for their long-lived cert. Same model Apple's
|
||||||
|
// MDM and Cisco's BRSKI use.
|
||||||
|
MTLSEnabled bool
|
||||||
|
|
||||||
|
// MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign
|
||||||
|
// the client (device-bootstrap) certs the operator allows to enroll.
|
||||||
|
// Required when MTLSEnabled is true. Operators with multiple bootstrap
|
||||||
|
// CAs concatenate them. Validated at startup by
|
||||||
|
// `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists,
|
||||||
|
// parses as PEM, contains ≥1 cert, none expired.
|
||||||
|
MTLSClientCATrustBundlePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanConfig controls the server-side active TLS scanner.
|
// NetworkScanConfig controls the server-side active TLS scanner.
|
||||||
@@ -1421,6 +1445,9 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig {
|
|||||||
ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""),
|
ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""),
|
||||||
RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""),
|
RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""),
|
||||||
RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""),
|
RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""),
|
||||||
|
// SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route.
|
||||||
|
MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false),
|
||||||
|
MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -1672,6 +1699,13 @@ func (c *Config) Validate() error {
|
|||||||
if p.IssuerID == "" {
|
if p.IssuerID == "" {
|
||||||
return fmt.Errorf("SCEP profile %d (PathID=%q) has empty IssuerID — refuse to start: each SCEP profile must bind to a configured issuer", i, p.PathID)
|
return fmt.Errorf("SCEP profile %d (PathID=%q) has empty IssuerID — refuse to start: each SCEP profile must bind to a configured issuer", i, p.PathID)
|
||||||
}
|
}
|
||||||
|
// Phase 6.5: when mTLS is enabled, the trust bundle path must
|
||||||
|
// be set. Preflight in cmd/server/main.go validates the file
|
||||||
|
// itself (exists, parseable PEM, ≥1 cert, none expired); this
|
||||||
|
// gate is the structural-config refuse, defense in depth.
|
||||||
|
if p.MTLSEnabled && p.MTLSClientCATrustBundlePath == "" {
|
||||||
|
return fmt.Errorf("SCEP profile %d (PathID=%q) has MTLSEnabled=true but MTLS_CLIENT_CA_TRUST_BUNDLE_PATH is empty — refuse to start: the mTLS sibling route /scep-mtls/%s would have no client-cert trust anchor", i, p.PathID, p.PathID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,15 @@ type IssuanceRequest struct {
|
|||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
SANs []string `json:"sans"`
|
SANs []string `json:"sans"`
|
||||||
CSRPEM string `json:"csr_pem"`
|
CSRPEM string `json:"csr_pem"`
|
||||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||||
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
||||||
|
// MustStaple, when true, instructs the issuer to add the RFC 7633
|
||||||
|
// must-staple extension (id-pe-tlsfeature) to the issued cert.
|
||||||
|
// Plumbed from CertificateProfile.MustStaple at the service layer.
|
||||||
|
// Issuers that don't support extension injection (Vault, EJBCA, etc.)
|
||||||
|
// silently ignore this — must-staple is a local-issuer-only feature
|
||||||
|
// in V2 since upstream connectors enforce their own extension policy.
|
||||||
|
MustStaple bool `json:"must_staple,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuanceResult contains the result of a successful certificate issuance.
|
// IssuanceResult contains the result of a successful certificate issuance.
|
||||||
@@ -73,9 +80,13 @@ type RenewalRequest struct {
|
|||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
SANs []string `json:"sans"`
|
SANs []string `json:"sans"`
|
||||||
CSRPEM string `json:"csr_pem"`
|
CSRPEM string `json:"csr_pem"`
|
||||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||||
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default)
|
||||||
OrderID *string `json:"order_id,omitempty"`
|
OrderID *string `json:"order_id,omitempty"`
|
||||||
|
// MustStaple — same semantics as IssuanceRequest.MustStaple. The
|
||||||
|
// renewal pipeline plumbs through the same CertificateProfile.MustStaple
|
||||||
|
// field so renewed certs match their initial-issuance extension set.
|
||||||
|
MustStaple bool `json:"must_staple,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevocationRequest contains the parameters for revoking a certificate.
|
// RevocationRequest contains the parameters for revoking a certificate.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -332,7 +333,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate certificate with EKUs and MaxTTL from request
|
// Generate certificate with EKUs and MaxTTL from request
|
||||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds)
|
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("failed to generate certificate", "error", err)
|
c.logger.Error("failed to generate certificate", "error", err)
|
||||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||||
@@ -396,7 +397,7 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate certificate with EKUs and MaxTTL from request
|
// Generate certificate with EKUs and MaxTTL from request
|
||||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds)
|
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("failed to generate certificate", "error", err)
|
c.logger.Error("failed to generate certificate", "error", err)
|
||||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||||
@@ -643,7 +644,7 @@ func (c *Connector) generateSelfSignedCA() error {
|
|||||||
// It uses the CSR subject and adds any additional SANs from the request.
|
// It uses the CSR subject and adds any additional SANs from the request.
|
||||||
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
||||||
// If maxTTLSeconds > 0, the certificate validity is capped to that duration.
|
// If maxTTLSeconds > 0, the certificate validity is capped to that duration.
|
||||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int) (*x509.Certificate, string, string, error) {
|
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int, mustStaple bool) (*x509.Certificate, string, string, error) {
|
||||||
// Generate random serial number
|
// Generate random serial number
|
||||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -719,6 +720,21 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple
|
||||||
|
// extension per RFC 7633. When the bound CertificateProfile has
|
||||||
|
// MustStaple=true, the issued cert carries id-pe-tlsfeature with
|
||||||
|
// the TLS Feature `status_request` (5). Browsers + modern TLS
|
||||||
|
// libraries that see this extension fail-closed when OCSP stapling
|
||||||
|
// is missing — defense against revocation-bypass via OCSP
|
||||||
|
// blackholing.
|
||||||
|
if mustStaple {
|
||||||
|
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
|
||||||
|
Id: oidMustStaple,
|
||||||
|
Critical: false,
|
||||||
|
Value: mustStapleExtensionValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Sign certificate with CA
|
// Sign certificate with CA
|
||||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -767,6 +783,26 @@ func isEmail(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple extension
|
||||||
|
// constants per RFC 7633 §6.
|
||||||
|
//
|
||||||
|
// id-pe-tlsfeature OID: 1.3.6.1.5.5.7.1.24.
|
||||||
|
var oidMustStaple = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}
|
||||||
|
|
||||||
|
// mustStapleExtensionValue is the pre-encoded DER for SEQUENCE OF INTEGER
|
||||||
|
// containing a single value 5 (the TLS Feature for status_request, RFC
|
||||||
|
// 7633 §6 referencing IANA TLS ExtensionType registry).
|
||||||
|
//
|
||||||
|
// Wire bytes:
|
||||||
|
//
|
||||||
|
// 0x30 0x03 -- SEQUENCE, length 3
|
||||||
|
// 0x02 0x01 0x05 -- INTEGER 5 (status_request)
|
||||||
|
//
|
||||||
|
// Pre-encoded as a constant rather than asn1.Marshal'd at runtime: the
|
||||||
|
// extension value is fixed, byte-stable across Go versions, and tested by
|
||||||
|
// pinning the exact bytes against RFC 7633 §6.
|
||||||
|
var mustStapleExtensionValue = []byte{0x30, 0x03, 0x02, 0x01, 0x05}
|
||||||
|
|
||||||
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
||||||
"serverAuth": x509.ExtKeyUsageServerAuth,
|
"serverAuth": x509.ExtKeyUsageServerAuth,
|
||||||
"clientAuth": x509.ExtKeyUsageClientAuth,
|
"clientAuth": x509.ExtKeyUsageClientAuth,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple per-profile
|
||||||
|
// policy field (RFC 7633).
|
||||||
|
//
|
||||||
|
// Pins the contract that:
|
||||||
|
//
|
||||||
|
// 1. When the IssuanceRequest carries MustStaple=true, the issued cert
|
||||||
|
// contains the id-pe-tlsfeature extension with the canonical
|
||||||
|
// wire bytes (SEQUENCE OF INTEGER {5} per RFC 7633 §6).
|
||||||
|
//
|
||||||
|
// 2. When MustStaple=false (or unset), the extension is OMITTED — adding
|
||||||
|
// it by default would break customer deployments where the TLS path
|
||||||
|
// doesn't staple.
|
||||||
|
//
|
||||||
|
// 3. The OID + DER bytes match RFC 7633 §6 verbatim:
|
||||||
|
// OID 1.3.6.1.5.5.7.1.24, value 0x30 0x03 0x02 0x01 0x05.
|
||||||
|
//
|
||||||
|
// The test exercises the local issuer end-to-end (CSR → CreateCertificate
|
||||||
|
// → ParseCertificate → walk Extensions) so any drift in the extension-
|
||||||
|
// injection path is caught.
|
||||||
|
|
||||||
|
func TestGenerateCertificate_MustStapleProfile_AddsExtension(t *testing.T) {
|
||||||
|
conn, _ := newLocalIssuerForMustStapleTest(t)
|
||||||
|
csrPEM := buildMustStapleCSR(t, "must-staple.example.com")
|
||||||
|
|
||||||
|
result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||||
|
CommonName: "must-staple.example.com",
|
||||||
|
SANs: []string{"must-staple.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
EKUs: []string{"serverAuth"},
|
||||||
|
MaxTTLSeconds: 86400,
|
||||||
|
MustStaple: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := parsePEMCertForTest(t, result.CertPEM)
|
||||||
|
ext := findExtensionByOID(cert, oidMustStaple)
|
||||||
|
if ext == nil {
|
||||||
|
t.Fatal("issued cert is missing id-pe-tlsfeature extension despite MustStaple=true")
|
||||||
|
}
|
||||||
|
if ext.Critical {
|
||||||
|
t.Errorf("must-staple extension Critical = true, want false (RFC 7633 §6 says non-critical)")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(ext.Value, mustStapleExtensionValue) {
|
||||||
|
t.Errorf("must-staple extension Value = %x, want %x (RFC 7633 §6 SEQUENCE OF INTEGER {5})",
|
||||||
|
ext.Value, mustStapleExtensionValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCertificate_NoMustStaple_OmitsExtension(t *testing.T) {
|
||||||
|
conn, _ := newLocalIssuerForMustStapleTest(t)
|
||||||
|
csrPEM := buildMustStapleCSR(t, "no-staple.example.com")
|
||||||
|
|
||||||
|
result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{
|
||||||
|
CommonName: "no-staple.example.com",
|
||||||
|
SANs: []string{"no-staple.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
EKUs: []string{"serverAuth"},
|
||||||
|
MaxTTLSeconds: 86400,
|
||||||
|
// MustStaple intentionally unset — defaults to false.
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := parsePEMCertForTest(t, result.CertPEM)
|
||||||
|
if ext := findExtensionByOID(cert, oidMustStaple); ext != nil {
|
||||||
|
t.Errorf("issued cert has id-pe-tlsfeature extension despite MustStaple=false (would break non-stapling deploys)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMustStapleConstants_PinExactRFC7633Bytes locks down the exact OID +
|
||||||
|
// DER bytes against RFC 7633 §6. If a future refactor changes the
|
||||||
|
// pre-encoded value in any way, this test fails — catches drift before
|
||||||
|
// it reaches a real cert.
|
||||||
|
func TestMustStapleConstants_PinExactRFC7633Bytes(t *testing.T) {
|
||||||
|
wantOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} // id-pe-tlsfeature
|
||||||
|
if !oidMustStaple.Equal(wantOID) {
|
||||||
|
t.Errorf("oidMustStaple = %v, want %v (RFC 7633 §6)", oidMustStaple, wantOID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TLS Feature for status_request is INTEGER 5 (per the IANA TLS
|
||||||
|
// ExtensionType registry). RFC 7633 §6 wraps that in SEQUENCE OF.
|
||||||
|
wantBytes := []byte{0x30, 0x03, 0x02, 0x01, 0x05}
|
||||||
|
if !bytes.Equal(mustStapleExtensionValue, wantBytes) {
|
||||||
|
t.Errorf("mustStapleExtensionValue = %x, want %x (SEQUENCE OF INTEGER {5})",
|
||||||
|
mustStapleExtensionValue, wantBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the bytes round-trip through asn1.Unmarshal as the
|
||||||
|
// expected structure.
|
||||||
|
var parsed []int
|
||||||
|
if _, err := asn1.Unmarshal(mustStapleExtensionValue, &parsed); err != nil {
|
||||||
|
t.Fatalf("mustStapleExtensionValue does not parse as SEQUENCE OF INTEGER: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed) != 1 || parsed[0] != 5 {
|
||||||
|
t.Errorf("parsed mustStaple = %v, want [5]", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// newLocalIssuerForMustStapleTest builds a self-signed local CA Connector
|
||||||
|
// using the package's standard New + ensureCA path — same constructor
|
||||||
|
// production uses, so any drift in the cert-template-injection code path
|
||||||
|
// is exercised faithfully.
|
||||||
|
func newLocalIssuerForMustStapleTest(t *testing.T) (*Connector, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err := c.ensureCA(context.Background()); err != nil {
|
||||||
|
t.Fatalf("ensureCA: %v", err)
|
||||||
|
}
|
||||||
|
return c, c.caCert
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMustStapleCSR(t *testing.T, cn string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey CSR: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePEMCertForTest(t *testing.T, certPEM string) *x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
t.Fatal("PEM decode returned nil")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func findExtensionByOID(cert *x509.Certificate, oid asn1.ObjectIdentifier) *pkix.Extension {
|
||||||
|
for i := range cert.Extensions {
|
||||||
|
if cert.Extensions[i].Id.Equal(oid) {
|
||||||
|
return &cert.Extensions[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -17,9 +17,26 @@ type CertificateProfile struct {
|
|||||||
RequiredSANPatterns []string `json:"required_san_patterns"`
|
RequiredSANPatterns []string `json:"required_san_patterns"`
|
||||||
SPIFFEURIPattern string `json:"spiffe_uri_pattern"`
|
SPIFFEURIPattern string `json:"spiffe_uri_pattern"`
|
||||||
AllowShortLived bool `json:"allow_short_lived"`
|
AllowShortLived bool `json:"allow_short_lived"`
|
||||||
Enabled bool `json:"enabled"`
|
// MustStaple, when true, causes the local issuer to add the RFC 7633
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// must-staple extension (id-pe-tlsfeature, OID 1.3.6.1.5.5.7.1.24) to
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// every certificate issued under this profile. Browsers + modern TLS
|
||||||
|
// libraries that see this extension MUST fail-closed on missing OCSP
|
||||||
|
// stapling responses — defense against revocation-bypass via OCSP
|
||||||
|
// blackholing.
|
||||||
|
//
|
||||||
|
// Default: false. Operators opt in once they've confirmed their TLS
|
||||||
|
// reverse proxy / load balancer staples OCSP responses (NGINX,
|
||||||
|
// HAProxy, Envoy, etc. all support stapling but it requires explicit
|
||||||
|
// config). Setting must-staple by default would break customer
|
||||||
|
// deployments where the TLS path doesn't staple — browsers hard-fail.
|
||||||
|
//
|
||||||
|
// Recommended for: Intune-deployed device certs (modern TLS clients);
|
||||||
|
// SCEP profiles serving general/legacy clients (ChromeOS, IoT) should
|
||||||
|
// stay false until the TLS path is verified.
|
||||||
|
MustStaple bool `json:"must_staple"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyAlgorithmRule defines an allowed key algorithm and its minimum key size.
|
// KeyAlgorithmRule defines an allowed key algorithm and its minimum key size.
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// CertRep PKIMessage response builder for SCEP.
|
||||||
|
//
|
||||||
|
// RFC 8894 §3.3.2 (Certificate Response Message Format) +
|
||||||
|
// RFC 5652 §5 (SignedData) + RFC 5652 §6 (EnvelopedData).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 3.1.
|
||||||
|
//
|
||||||
|
// Builds the wire shape (cited from RFC 8894 §3.3.2 + §3.2):
|
||||||
|
//
|
||||||
|
// ContentInfo {
|
||||||
|
// contentType: signedData (1.2.840.113549.1.7.2)
|
||||||
|
// content: SignedData {
|
||||||
|
// version: 1
|
||||||
|
// digestAlgorithms: [SHA-256]
|
||||||
|
// encapContentInfo: {
|
||||||
|
// contentType: data (1.2.840.113549.1.7.1)
|
||||||
|
// content: EnvelopedData { -- on SUCCESS only
|
||||||
|
// version: 0
|
||||||
|
// recipientInfos: [{
|
||||||
|
// ktri: {
|
||||||
|
// rid: IssuerAndSerialNumber of clientCert
|
||||||
|
// keyEncryptionAlgorithm: rsaEncryption
|
||||||
|
// encryptedKey: AES-256-CBC key encrypted to clientCert.PublicKey
|
||||||
|
// }
|
||||||
|
// }]
|
||||||
|
// encryptedContentInfo: {
|
||||||
|
// contentType: pkcs7-data
|
||||||
|
// contentEncryptionAlgorithm: aes-256-cbc
|
||||||
|
// encryptedContent: AES-CBC-encrypted PKCS#7 certs-only with the issued cert + chain
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// certificates: [raCert]
|
||||||
|
// signerInfos: [{
|
||||||
|
// sid: IssuerAndSerialNumber of raCert
|
||||||
|
// digestAlgorithm: SHA-256
|
||||||
|
// signedAttrs: [
|
||||||
|
// contentType: data
|
||||||
|
// messageDigest: SHA-256(encapContentInfo.content)
|
||||||
|
// messageType: "3" (CertRep)
|
||||||
|
// pkiStatus: "0" | "2" | "3"
|
||||||
|
// transactionID: <echo of request>
|
||||||
|
// recipientNonce: <echo of request senderNonce>
|
||||||
|
// senderNonce: <fresh 16-byte server nonce>
|
||||||
|
// failInfo: <if pkiStatus="2">
|
||||||
|
// ]
|
||||||
|
// signatureAlgorithm: rsaWithSHA256 | ecdsaWithSHA256
|
||||||
|
// signature: raKey signs DER(SET OF signedAttrs)
|
||||||
|
// }]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// On FAILURE, encapContentInfo.content is empty (no EnvelopedData), and the
|
||||||
|
// failInfo signed attribute is populated.
|
||||||
|
//
|
||||||
|
// On PENDING (deferred-issuance flow, not used in v1), encapContentInfo.content
|
||||||
|
// is empty, and the response carries a transactionID the client polls with
|
||||||
|
// GetCertInitial.
|
||||||
|
|
||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildCertRepPKIMessage constructs the SCEP CertRep response PKIMessage.
|
||||||
|
//
|
||||||
|
// Inputs:
|
||||||
|
// - req: the parsed inbound envelope (provides transactionID, senderNonce
|
||||||
|
// to echo, and SignerCert — the device's transient cert we encrypt the
|
||||||
|
// CertRep EnvelopedData TO).
|
||||||
|
// - resp: the service-layer outcome (Status + FailInfo + Result).
|
||||||
|
// - raCert + raKey: the RA pair the server signs the SignedData with
|
||||||
|
// (loaded from CERTCTL_SCEP_RA_*; same pair used to decrypt the inbound
|
||||||
|
// EnvelopedData in Phase 2).
|
||||||
|
//
|
||||||
|
// Critical correctness points (cited as comments in code):
|
||||||
|
// - The CertRep encrypts the issued cert chain to the DEVICE's transient
|
||||||
|
// signing cert (req.SignerCert), NOT the RA cert. The response goes
|
||||||
|
// back to the device, encrypted with its public key.
|
||||||
|
// - AES-256-CBC + random 16-byte IV per response. No reuse.
|
||||||
|
// - senderNonce must be fresh per response (crypto/rand 16 bytes).
|
||||||
|
// - recipientNonce + transactionID echoed verbatim from the request.
|
||||||
|
// - The signature is over DER(SET OF signedAttrs) — the canonical CMS
|
||||||
|
// quirk per RFC 5652 §5.4. The wire form uses [0] IMPLICIT but the
|
||||||
|
// signature is computed over the SET OF re-serialisation. Easy
|
||||||
|
// mistake; pinned by the round-trip test.
|
||||||
|
func BuildCertRepPKIMessage(req *domain.SCEPRequestEnvelope, resp *domain.SCEPResponseEnvelope, raCert *x509.Certificate, raKey crypto.PrivateKey) ([]byte, error) {
|
||||||
|
if req == nil || resp == nil {
|
||||||
|
return nil, fmt.Errorf("certRep: req and resp required")
|
||||||
|
}
|
||||||
|
if raCert == nil || raKey == nil {
|
||||||
|
return nil, fmt.Errorf("certRep: RA cert/key required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Build the encapContent — for SUCCESS, this is an EnvelopedData
|
||||||
|
// wrapping the issued cert chain encrypted to req.SignerCert. For
|
||||||
|
// FAILURE / PENDING, encapContent is empty.
|
||||||
|
var encapContent []byte
|
||||||
|
if resp.Status == domain.SCEPStatusSuccess && resp.Result != nil {
|
||||||
|
// Parse the device's transient signing cert (recipient).
|
||||||
|
if len(req.SignerCert) == 0 {
|
||||||
|
return nil, fmt.Errorf("certRep: req.SignerCert required for SUCCESS response (need device pubkey to encrypt response)")
|
||||||
|
}
|
||||||
|
clientCert, err := x509.ParseCertificate(req.SignerCert)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: parse req.SignerCert: %w", err)
|
||||||
|
}
|
||||||
|
clientRSAPub, ok := clientCert.PublicKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
// SCEP requires RSA on the client side for keyTrans (RFC 8894
|
||||||
|
// §3.5.2 advertises RSA only for the client-encryption side).
|
||||||
|
return nil, fmt.Errorf("certRep: device transient cert must have RSA public key (got %T)", clientCert.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the certs-only PKCS#7 carrying the issued cert + chain
|
||||||
|
// (the inner content the EnvelopedData encrypts).
|
||||||
|
issuedDER, err := PEMToDERChain(resp.Result.CertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: parse issued cert PEM: %w", err)
|
||||||
|
}
|
||||||
|
var allDER [][]byte
|
||||||
|
allDER = append(allDER, issuedDER...)
|
||||||
|
if resp.Result.ChainPEM != "" {
|
||||||
|
chainDER, err := PEMToDERChain(resp.Result.ChainPEM)
|
||||||
|
if err == nil {
|
||||||
|
allDER = append(allDER, chainDER...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
certsOnly, err := BuildCertsOnlyPKCS7(allDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: build certs-only PKCS#7: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the EnvelopedData encrypting certsOnly to clientRSAPub
|
||||||
|
// using a fresh AES-256-CBC key + IV.
|
||||||
|
encapContent, err = buildEnvelopedDataAES256(clientCert, clientRSAPub, certsOnly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: build EnvelopedData: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compute messageDigest = SHA-256(encapContent). When encapContent
|
||||||
|
// is empty (FAILURE/PENDING), the messageDigest is over the empty
|
||||||
|
// byte slice — same hash for both legs, RFC 5652 §11.2 doesn't
|
||||||
|
// require a non-empty content.
|
||||||
|
contentDigest := sha256.Sum256(encapContent)
|
||||||
|
|
||||||
|
// 3. Generate a fresh 16-byte senderNonce. crypto/rand source; never
|
||||||
|
// reused across responses (RFC 8894 §3.2.1.4.5 — replay defense).
|
||||||
|
senderNonce := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(senderNonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: senderNonce rand.Read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the auth-attrs SET-OF body (the bytes inside [0] IMPLICIT).
|
||||||
|
// Order matches micromdm/scep for byte-level wire-format diffing
|
||||||
|
// (DER SET-OF normalises order anyway, but matching the reference
|
||||||
|
// implementation makes audit + manual inspection easier).
|
||||||
|
authAttrs := buildCertRepAuthAttrs(
|
||||||
|
contentDigest[:],
|
||||||
|
resp.Status,
|
||||||
|
resp.FailInfo,
|
||||||
|
resp.TransactionID,
|
||||||
|
senderNonce,
|
||||||
|
resp.RecipientNonce,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Sign the SET OF Attribute (re-serialised with the SET tag, not
|
||||||
|
// the [0] IMPLICIT wrapper — RFC 5652 §5.4 quirk).
|
||||||
|
signedAttrsForSig := ASN1Wrap(0x31, authAttrs)
|
||||||
|
sig, sigAlgOID, err := signCertRep(raKey, signedAttrsForSig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: sign auth-attrs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the SignerInfo SEQUENCE.
|
||||||
|
siBytes, err := buildSignerInfoCertRep(raCert, sig, sigAlgOID, authAttrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: build SignerInfo: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Build encapContentInfo SEQUENCE { OID data, [0] EXPLICIT OCTET
|
||||||
|
// STRING content }.
|
||||||
|
encapBytes := buildEncapContentInfo(encapContent)
|
||||||
|
|
||||||
|
// 8. certificates [0] IMPLICIT SET OF Certificate carrying the RA cert
|
||||||
|
// so the device can verify the signature.
|
||||||
|
certsBytes := ASN1Wrap(0xa0, raCert.Raw)
|
||||||
|
|
||||||
|
// 9. digestAlgorithms SET OF AlgorithmIdentifier (one entry: SHA-256).
|
||||||
|
digestAlg := pkix.AlgorithmIdentifier{Algorithm: OIDSHA256, Parameters: asn1.NullRawValue}
|
||||||
|
digestAlgBytes, err := asn1.Marshal(digestAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certRep: marshal digestAlg: %w", err)
|
||||||
|
}
|
||||||
|
digestAlgsBytes := ASN1Wrap(0x31, digestAlgBytes)
|
||||||
|
|
||||||
|
// 10. signerInfos SET OF SignerInfo (one entry — the RA's signature).
|
||||||
|
signerInfosBytes := ASN1Wrap(0x31, siBytes)
|
||||||
|
|
||||||
|
// 11. Assemble SignedData SEQUENCE.
|
||||||
|
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) // INTEGER version=1
|
||||||
|
sdBody = append(sdBody, digestAlgsBytes...)
|
||||||
|
sdBody = append(sdBody, encapBytes...)
|
||||||
|
sdBody = append(sdBody, certsBytes...)
|
||||||
|
sdBody = append(sdBody, signerInfosBytes...)
|
||||||
|
sdSeq := ASN1Wrap(0x30, sdBody)
|
||||||
|
|
||||||
|
// 12. Wrap as ContentInfo SEQUENCE { OID signedData, [0] EXPLICIT
|
||||||
|
// SignedData }.
|
||||||
|
contentField := ASN1Wrap(0xa0, sdSeq)
|
||||||
|
oidSignedDataDER := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
ciBody := append([]byte{}, oidSignedDataDER...)
|
||||||
|
ciBody = append(ciBody, contentField...)
|
||||||
|
return ASN1Wrap(0x30, ciBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCertRepAuthAttrs builds the SET-OF body for the CertRep
|
||||||
|
// signedAttributes. Matches the order micromdm/scep emits (the DER SET-OF
|
||||||
|
// normalisation makes order irrelevant for the signature, but matching
|
||||||
|
// the reference implementation makes wire-diff debugging easier).
|
||||||
|
func buildCertRepAuthAttrs(msgDigest []byte, status domain.SCEPPKIStatus, failInfo domain.SCEPFailInfo, transactionID string, senderNonce, recipientNonce []byte) []byte {
|
||||||
|
var out []byte
|
||||||
|
// contentType: SET { OID data }
|
||||||
|
out = append(out, attrSeqRaw(OIDContentType, ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||||
|
// messageDigest: SET { OCTET STRING }
|
||||||
|
out = append(out, attrSeqRaw(OIDMessageDigest, ASN1Wrap(0x04, msgDigest))...)
|
||||||
|
// SCEP messageType: SET { PrintableString "3" — CertRep }
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPMessageType, ASN1Wrap(0x13, []byte{'3'}))...)
|
||||||
|
// SCEP pkiStatus: SET { PrintableString status code }
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPPKIStatus, ASN1Wrap(0x13, []byte(status)))...)
|
||||||
|
// SCEP transactionID: SET { PrintableString }
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPTransactionID, ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||||
|
// SCEP senderNonce (server's fresh nonce): SET { OCTET STRING }
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPSenderNonce, ASN1Wrap(0x04, senderNonce))...)
|
||||||
|
// SCEP recipientNonce (echo of client's senderNonce): SET { OCTET STRING }
|
||||||
|
if len(recipientNonce) > 0 {
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPRecipientNonce, ASN1Wrap(0x04, recipientNonce))...)
|
||||||
|
}
|
||||||
|
// SCEP failInfo: ONLY when status == failure (RFC 8894 §3.2.1.4.4)
|
||||||
|
if status == domain.SCEPStatusFailure {
|
||||||
|
out = append(out, attrSeqRaw(OIDSCEPFailInfo, ASN1Wrap(0x13, []byte(failInfo)))...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrSeqRaw builds one Attribute SEQUENCE: SEQUENCE { OID, SET OF value }.
|
||||||
|
// `value` is one already-encoded TLV (e.g. an OCTET STRING or PrintableString);
|
||||||
|
// attrSeqRaw wraps it in a SET, prefixes the OID, and SEQUENCE-wraps.
|
||||||
|
func attrSeqRaw(oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||||
|
oidBytes, err := asn1.Marshal(oid)
|
||||||
|
if err != nil {
|
||||||
|
// asn1.Marshal of a hardcoded OID never fails; a panic here is
|
||||||
|
// a programmer error worth surfacing immediately.
|
||||||
|
panic("certRep: marshal OID: " + err.Error())
|
||||||
|
}
|
||||||
|
setOfValue := ASN1Wrap(0x31, value)
|
||||||
|
body := append([]byte{}, oidBytes...)
|
||||||
|
body = append(body, setOfValue...)
|
||||||
|
return ASN1Wrap(0x30, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignerInfoCertRep assembles the SignerInfo for the CertRep response.
|
||||||
|
// The signature is already computed; this just packages everything into the
|
||||||
|
// SignerInfo SEQUENCE.
|
||||||
|
func buildSignerInfoCertRep(raCert *x509.Certificate, sig []byte, sigAlgOID asn1.ObjectIdentifier, authAttrsSetBody []byte) ([]byte, error) {
|
||||||
|
versionBytes := []byte{0x02, 0x01, 0x01} // INTEGER version=1
|
||||||
|
|
||||||
|
// SID = IssuerAndSerialNumber: SEQUENCE { Issuer (RDN), SerialNumber }
|
||||||
|
serialDER, err := asn1.Marshal(raCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal RA serial: %w", err)
|
||||||
|
}
|
||||||
|
sidBody := append([]byte{}, raCert.RawIssuer...)
|
||||||
|
sidBody = append(sidBody, serialDER...)
|
||||||
|
sidBytes := ASN1Wrap(0x30, sidBody)
|
||||||
|
|
||||||
|
digestAlg := pkix.AlgorithmIdentifier{Algorithm: OIDSHA256, Parameters: asn1.NullRawValue}
|
||||||
|
digestAlgBytes, err := asn1.Marshal(digestAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal digestAlg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedAttrsImplicitBytes := ASN1Wrap(0xa0, authAttrsSetBody) // [0] IMPLICIT SET OF
|
||||||
|
|
||||||
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: sigAlgOID}
|
||||||
|
if sigAlgOID.Equal(OIDRSAWithSHA256) {
|
||||||
|
sigAlg.Parameters = asn1.NullRawValue
|
||||||
|
}
|
||||||
|
sigAlgBytes, err := asn1.Marshal(sigAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal sigAlg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigOctetBytes := ASN1Wrap(0x04, sig) // OCTET STRING
|
||||||
|
|
||||||
|
siBody := append([]byte{}, versionBytes...)
|
||||||
|
siBody = append(siBody, sidBytes...)
|
||||||
|
siBody = append(siBody, digestAlgBytes...)
|
||||||
|
siBody = append(siBody, signedAttrsImplicitBytes...)
|
||||||
|
siBody = append(siBody, sigAlgBytes...)
|
||||||
|
siBody = append(siBody, sigOctetBytes...)
|
||||||
|
return ASN1Wrap(0x30, siBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signCertRep signs the SET-OF-encoded auth-attrs with the RA key, returning
|
||||||
|
// the signature bytes and the matching signature-algorithm OID.
|
||||||
|
func signCertRep(raKey crypto.PrivateKey, signedAttrsForSig []byte) ([]byte, asn1.ObjectIdentifier, error) {
|
||||||
|
digest := sha256.Sum256(signedAttrsForSig)
|
||||||
|
switch k := raKey.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("rsa sign: %w", err)
|
||||||
|
}
|
||||||
|
return sig, OIDRSAWithSHA256, nil
|
||||||
|
case *ecdsa.PrivateKey:
|
||||||
|
sig, err := ecdsa.SignASN1(rand.Reader, k, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("ecdsa sign: %w", err)
|
||||||
|
}
|
||||||
|
return sig, OIDECDSAWithSHA256, nil
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unsupported RA key type %T (want *rsa.PrivateKey or *ecdsa.PrivateKey)", raKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEncapContentInfo builds SEQUENCE { OID data, [0] EXPLICIT OCTET STRING content }.
|
||||||
|
// content is empty for FAILURE/PENDING responses; the [0] EXPLICIT wrapper is
|
||||||
|
// omitted entirely in that case (RFC 5652 §5.2 — the OPTIONAL field is just
|
||||||
|
// absent rather than carrying an empty OCTET STRING).
|
||||||
|
func buildEncapContentInfo(content []byte) []byte {
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
body := append([]byte{}, oidDataBytes...)
|
||||||
|
if len(content) > 0 {
|
||||||
|
octetBytes := ASN1Wrap(0x04, content)
|
||||||
|
explicitWrapper := ASN1Wrap(0xa0, octetBytes)
|
||||||
|
body = append(body, explicitWrapper...)
|
||||||
|
}
|
||||||
|
return ASN1Wrap(0x30, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvelopedDataAES256 builds an EnvelopedData encrypting `plaintext`
|
||||||
|
// to `recipientCert`'s public key (RSA). Uses AES-256-CBC + random 16-byte IV
|
||||||
|
// + PKCS#7 padding. Returns the EnvelopedData DER bytes ready to embed as
|
||||||
|
// the encapContent of a SignedData.
|
||||||
|
func buildEnvelopedDataAES256(recipientCert *x509.Certificate, recipientPub *rsa.PublicKey, plaintext []byte) ([]byte, error) {
|
||||||
|
// 1. Generate random AES-256 key + IV.
|
||||||
|
symKey := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(symKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("rand symKey: %w", err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
return nil, fmt.Errorf("rand iv: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PKCS#7-pad plaintext to AES block boundary.
|
||||||
|
bs := aes.BlockSize
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := make([]byte, 0, len(plaintext)+padLen)
|
||||||
|
padded = append(padded, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AES-CBC encrypt.
|
||||||
|
block, err := aes.NewCipher(symKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aes.NewCipher: %w", err)
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
ciphertext := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(ciphertext, padded)
|
||||||
|
|
||||||
|
// 4. RSA PKCS#1 v1.5 encrypt the AES key with recipientPub.
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, recipientPub, symKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rsa encrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build IssuerAndSerialNumber identifying the recipient.
|
||||||
|
serialDER, err := asn1.Marshal(recipientCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal recipient serial: %w", err)
|
||||||
|
}
|
||||||
|
risBody := append([]byte{}, recipientCert.RawIssuer...)
|
||||||
|
risBody = append(risBody, serialDER...)
|
||||||
|
risBytes := ASN1Wrap(0x30, risBody)
|
||||||
|
|
||||||
|
// 6. Build KeyTransRecipientInfo SEQUENCE.
|
||||||
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||||
|
keyEncAlgBytes, err := asn1.Marshal(keyEncAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal keyEncAlg: %w", err)
|
||||||
|
}
|
||||||
|
encryptedKeyBytes := ASN1Wrap(0x04, encryptedKey)
|
||||||
|
|
||||||
|
ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) // INTEGER version=0
|
||||||
|
ktriBody = append(ktriBody, risBytes...)
|
||||||
|
ktriBody = append(ktriBody, keyEncAlgBytes...)
|
||||||
|
ktriBody = append(ktriBody, encryptedKeyBytes...)
|
||||||
|
ktriBytes := ASN1Wrap(0x30, ktriBody)
|
||||||
|
|
||||||
|
// 7. recipientInfos SET OF RecipientInfo (one entry).
|
||||||
|
recipientInfosBytes := ASN1Wrap(0x31, ktriBytes)
|
||||||
|
|
||||||
|
// 8. Build the AlgorithmIdentifier with the IV as parameters
|
||||||
|
// (RFC 3565 §2.3).
|
||||||
|
ivOctet := ASN1Wrap(0x04, iv)
|
||||||
|
contentAlg := pkix.AlgorithmIdentifier{
|
||||||
|
Algorithm: OIDAES256CBC,
|
||||||
|
Parameters: asn1.RawValue{FullBytes: ivOctet},
|
||||||
|
}
|
||||||
|
contentAlgBytes, err := asn1.Marshal(contentAlg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal contentAlg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Build EncryptedContentInfo SEQUENCE.
|
||||||
|
// encryptedContent is [0] IMPLICIT OCTET STRING — the OCTET STRING
|
||||||
|
// tag is replaced by the [0] context-specific tag, but the content
|
||||||
|
// bytes are written directly without the inner OCTET STRING tag.
|
||||||
|
encContentField := append([]byte{}, ASN1Wrap(0x80, ciphertext)...) // [0] IMPLICIT primitive
|
||||||
|
oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}
|
||||||
|
eciBody := append([]byte{}, oidDataBytes...)
|
||||||
|
eciBody = append(eciBody, contentAlgBytes...)
|
||||||
|
eciBody = append(eciBody, encContentField...)
|
||||||
|
eciBytes := ASN1Wrap(0x30, eciBody)
|
||||||
|
|
||||||
|
// 10. Assemble EnvelopedData SEQUENCE.
|
||||||
|
envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) // INTEGER version=0
|
||||||
|
envBody = append(envBody, recipientInfosBytes...)
|
||||||
|
envBody = append(envBody, eciBytes...)
|
||||||
|
return ASN1Wrap(0x30, envBody), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import / cross-file linker warnings for big.Int + pem on
|
||||||
|
// builds that exclude certain code paths.
|
||||||
|
var (
|
||||||
|
_ = (*big.Int)(nil)
|
||||||
|
_ = (*pem.Block)(nil)
|
||||||
|
)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzBuildCertRepPKIMessage stresses the CertRep builder with attacker-
|
||||||
|
// controlled transactionID + nonce + signerCert bytes. The invariants are:
|
||||||
|
// 1. No panic for arbitrary inputs.
|
||||||
|
// 2. When build succeeds AND status is success, the output parses back
|
||||||
|
// via ParseSignedData (round-trip soundness — the prompt's required
|
||||||
|
// fuzz invariant).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 3.3.
|
||||||
|
//
|
||||||
|
// The fuzzer holds the RA pair constant (one-time setup) and lets the
|
||||||
|
// fuzz engine vary the unstable inputs. Errors from BuildCertRepPKIMessage
|
||||||
|
// are expected for malformed signerCert bytes; only a panic = bug.
|
||||||
|
|
||||||
|
func FuzzBuildCertRepPKIMessage(f *testing.F) {
|
||||||
|
// Seed: empty everything (should error cleanly via the nil-args gate).
|
||||||
|
f.Add("", []byte{}, []byte{})
|
||||||
|
// Seed: minimal inputs that exercise the failure-path code (no
|
||||||
|
// SignerCert needed because Status=Failure short-circuits the
|
||||||
|
// EnvelopedData build).
|
||||||
|
f.Add("txn-1", make([]byte, 16), []byte{})
|
||||||
|
|
||||||
|
// One-time setup: RA pair stays constant across fuzz iterations.
|
||||||
|
raKey, raCert := genTestRSARAFuzz()
|
||||||
|
if raKey == nil {
|
||||||
|
f.Skip("test RA pair generation failed; environment lacks crypto/rand?")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, transactionID string, senderNonce []byte, signerCert []byte) {
|
||||||
|
req := &domain.SCEPRequestEnvelope{
|
||||||
|
MessageType: domain.SCEPMessageTypePKCSReq,
|
||||||
|
TransactionID: transactionID,
|
||||||
|
SenderNonce: senderNonce,
|
||||||
|
SignerCert: signerCert,
|
||||||
|
}
|
||||||
|
// Failure path: never needs SignerCert. No panic, no requirement
|
||||||
|
// on output (the failure shape is correct by construction).
|
||||||
|
respFail := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadRequest,
|
||||||
|
TransactionID: transactionID,
|
||||||
|
RecipientNonce: senderNonce,
|
||||||
|
}
|
||||||
|
_, _ = BuildCertRepPKIMessage(req, respFail, raCert, raKey)
|
||||||
|
|
||||||
|
// Success path with arbitrary signerCert bytes: most inputs will
|
||||||
|
// fail to parse as a real cert; that's fine, BuildCertRep returns
|
||||||
|
// an error rather than panicking. When build succeeds (rare for
|
||||||
|
// random bytes), assert the output parses back.
|
||||||
|
respSuccess := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
TransactionID: transactionID,
|
||||||
|
RecipientNonce: senderNonce,
|
||||||
|
Result: &domain.SCEPEnrollResult{
|
||||||
|
CertPEM: minimalIssuedCertPEMFuzz(raKey),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := BuildCertRepPKIMessage(req, respSuccess, raCert, raKey)
|
||||||
|
if err != nil {
|
||||||
|
return // expected for arbitrary signerCert; no panic = ok
|
||||||
|
}
|
||||||
|
// Build succeeded — verify round-trip soundness.
|
||||||
|
sd, err := ParseSignedData(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("BuildCertRepPKIMessage produced output that fails ParseSignedData: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(sd.SignerInfos) == 0 {
|
||||||
|
t.Errorf("BuildCertRepPKIMessage produced output with no signerInfos")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// genTestRSARAFuzz materialises a one-time RA pair for the fuzz seed
|
||||||
|
// setup. Mirrors genTestRSARA from the round-trip tests but doesn't
|
||||||
|
// take *testing.T (called from f.Fuzz setup, not a test body).
|
||||||
|
func genTestRSARAFuzz() (*rsa.PrivateKey, *x509.Certificate) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "fuzz-ra"},
|
||||||
|
Issuer: pkix.Name{CommonName: "fuzz-ra"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// minimalIssuedCertPEMFuzz returns a tiny self-signed PEM cert reusing
|
||||||
|
// the RA key. Avoids per-fuzz-iter rsa.GenerateKey overhead (which would
|
||||||
|
// dominate the fuzz throughput).
|
||||||
|
func minimalIssuedCertPEMFuzz(key *rsa.PrivateKey) string {
|
||||||
|
// We construct on demand since the issued cert template doesn't
|
||||||
|
// matter beyond being a parseable PEM-wrapped DER cert.
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(2),
|
||||||
|
Subject: pkix.Name{CommonName: "fuzz-issued"},
|
||||||
|
Issuer: pkix.Name{CommonName: "fuzz-issued"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "-----BEGIN CERTIFICATE-----\n" +
|
||||||
|
derToBase64Fuzz(der) +
|
||||||
|
"-----END CERTIFICATE-----\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func derToBase64Fuzz(der []byte) string {
|
||||||
|
const enc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
var out []byte
|
||||||
|
pad := (3 - len(der)%3) % 3
|
||||||
|
padded := append(append([]byte{}, der...), make([]byte, pad)...)
|
||||||
|
for i := 0; i < len(padded); i += 3 {
|
||||||
|
v := uint32(padded[i])<<16 | uint32(padded[i+1])<<8 | uint32(padded[i+2])
|
||||||
|
out = append(out, enc[v>>18&0x3f], enc[v>>12&0x3f], enc[v>>6&0x3f], enc[v&0x3f])
|
||||||
|
}
|
||||||
|
for i := 0; i < pad; i++ {
|
||||||
|
out[len(out)-1-i] = '='
|
||||||
|
}
|
||||||
|
// Wrap at 64 chars per PEM convention.
|
||||||
|
var wrapped []byte
|
||||||
|
for i := 0; i < len(out); i += 64 {
|
||||||
|
end := i + 64
|
||||||
|
if end > len(out) {
|
||||||
|
end = len(out)
|
||||||
|
}
|
||||||
|
wrapped = append(wrapped, out[i:end]...)
|
||||||
|
wrapped = append(wrapped, '\n')
|
||||||
|
}
|
||||||
|
return string(wrapped)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 3.1: round-trip tests for BuildCertRepPKIMessage.
|
||||||
|
//
|
||||||
|
// Each test materialises real RA + device pairs, calls
|
||||||
|
// BuildCertRepPKIMessage with success/failure/pending shapes, then
|
||||||
|
// parses the result back via ParseSignedData + EnvelopedData.Decrypt
|
||||||
|
// to assert the wire bytes are recoverable. This catches drift between
|
||||||
|
// the build-side encoding and the parse-side decoding without needing
|
||||||
|
// a real SCEP client.
|
||||||
|
|
||||||
|
func TestBuildCertRepPKIMessage_Success_RoundTrip(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
deviceKey, deviceCert := genTestRSARA(t) // device transient cert (RSA pub for KTRI)
|
||||||
|
|
||||||
|
// Synthesise an issued cert (the thing we want the device to receive).
|
||||||
|
issuedPEM := selfSignedCertPEM(t, "issued.example.com")
|
||||||
|
|
||||||
|
req := &domain.SCEPRequestEnvelope{
|
||||||
|
MessageType: domain.SCEPMessageTypePKCSReq,
|
||||||
|
TransactionID: "txn-roundtrip-success",
|
||||||
|
SenderNonce: []byte("0123456789abcdef"),
|
||||||
|
SignerCert: deviceCert.Raw,
|
||||||
|
}
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
TransactionID: req.TransactionID,
|
||||||
|
RecipientNonce: req.SenderNonce,
|
||||||
|
Result: &domain.SCEPEnrollResult{
|
||||||
|
CertPEM: issuedPEM,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pkiMessage, err := BuildCertRepPKIMessage(req, resp, raCert, raKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildCertRepPKIMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse it back.
|
||||||
|
sd, err := ParseSignedData(pkiMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(sd.SignerInfos) != 1 {
|
||||||
|
t.Fatalf("len(SignerInfos) = %d, want 1", len(sd.SignerInfos))
|
||||||
|
}
|
||||||
|
si := sd.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature(RA signature on CertRep): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth-attr round-trip.
|
||||||
|
mt, _ := si.GetMessageType()
|
||||||
|
if mt != domain.SCEPMessageTypeCertRep {
|
||||||
|
t.Errorf("messageType = %d, want CertRep (3)", mt)
|
||||||
|
}
|
||||||
|
tid, _ := si.GetTransactionID()
|
||||||
|
if tid != req.TransactionID {
|
||||||
|
t.Errorf("transactionID = %q, want %q", tid, req.TransactionID)
|
||||||
|
}
|
||||||
|
// recipientNonce echoes the request's senderNonce.
|
||||||
|
rn, _ := si.attrOctetString(OIDSCEPRecipientNonce)
|
||||||
|
if !bytes.Equal(rn, req.SenderNonce) {
|
||||||
|
t.Errorf("recipientNonce = %q, want %q", rn, req.SenderNonce)
|
||||||
|
}
|
||||||
|
// senderNonce is server-generated; verify it's 16 bytes.
|
||||||
|
sn, _ := si.GetSenderNonce()
|
||||||
|
if len(sn) != 16 {
|
||||||
|
t.Errorf("senderNonce len = %d, want 16", len(sn))
|
||||||
|
}
|
||||||
|
// pkiStatus = "0" (Success).
|
||||||
|
status, _ := si.attrPrintableString(OIDSCEPPKIStatus)
|
||||||
|
if status != string(domain.SCEPStatusSuccess) {
|
||||||
|
t.Errorf("pkiStatus = %q, want %q", status, domain.SCEPStatusSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncapContent should be a parseable EnvelopedData. Decrypt it with
|
||||||
|
// the device's RSA key and pull out the inner certs-only PKCS#7;
|
||||||
|
// confirm the issued cert is in the chain.
|
||||||
|
if len(sd.EncapContent) == 0 {
|
||||||
|
t.Fatal("encapContent empty for SUCCESS response")
|
||||||
|
}
|
||||||
|
env, err := ParseEnvelopedData(sd.EncapContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData(encapContent): %v", err)
|
||||||
|
}
|
||||||
|
innerCertsOnly, err := env.Decrypt(deviceKey, deviceCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnvelopedData.Decrypt with device key: %v", err)
|
||||||
|
}
|
||||||
|
// innerCertsOnly is a degenerate PKCS#7 SignedData carrying the
|
||||||
|
// issued cert(s). Use parseSignedDataForCSR's SignedData parsing
|
||||||
|
// pattern via ParseSignedData to recover the cert.
|
||||||
|
innerSD, err := ParseSignedData(innerCertsOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData(innerCertsOnly): %v", err)
|
||||||
|
}
|
||||||
|
if len(innerSD.Certificates) == 0 {
|
||||||
|
t.Fatal("inner certs-only PKCS#7 carries no certs")
|
||||||
|
}
|
||||||
|
if innerSD.Certificates[0].Subject.CommonName != "issued.example.com" {
|
||||||
|
t.Errorf("issued cert CN = %q, want issued.example.com", innerSD.Certificates[0].Subject.CommonName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCertRepPKIMessage_Failure_NoEncapContent(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
_, deviceCert := genTestRSARA(t)
|
||||||
|
|
||||||
|
req := &domain.SCEPRequestEnvelope{
|
||||||
|
MessageType: domain.SCEPMessageTypePKCSReq,
|
||||||
|
TransactionID: "txn-roundtrip-failure",
|
||||||
|
SenderNonce: []byte("nonce-failure-12"),
|
||||||
|
SignerCert: deviceCert.Raw,
|
||||||
|
}
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadMessageCheck,
|
||||||
|
TransactionID: req.TransactionID,
|
||||||
|
RecipientNonce: req.SenderNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
pkiMessage, err := BuildCertRepPKIMessage(req, resp, raCert, raKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildCertRepPKIMessage(failure): %v", err)
|
||||||
|
}
|
||||||
|
sd, err := ParseSignedData(pkiMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
si := sd.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature(failure response): %v", err)
|
||||||
|
}
|
||||||
|
// pkiStatus = "2", failInfo = "1" (BadMessageCheck).
|
||||||
|
status, _ := si.attrPrintableString(OIDSCEPPKIStatus)
|
||||||
|
if status != string(domain.SCEPStatusFailure) {
|
||||||
|
t.Errorf("pkiStatus = %q, want %q", status, domain.SCEPStatusFailure)
|
||||||
|
}
|
||||||
|
failInfo, _ := si.attrPrintableString(OIDSCEPFailInfo)
|
||||||
|
if failInfo != string(domain.SCEPFailBadMessageCheck) {
|
||||||
|
t.Errorf("failInfo = %q, want %q", failInfo, domain.SCEPFailBadMessageCheck)
|
||||||
|
}
|
||||||
|
// encapContent is empty for failure.
|
||||||
|
if len(sd.EncapContent) != 0 {
|
||||||
|
t.Errorf("encapContent non-empty for FAILURE: %d bytes", len(sd.EncapContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCertRepPKIMessage_FreshSenderNonceEachCall(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
_, deviceCert := genTestRSARA(t)
|
||||||
|
req := &domain.SCEPRequestEnvelope{
|
||||||
|
TransactionID: "txn-nonce", SenderNonce: []byte("0123456789abcdef"),
|
||||||
|
SignerCert: deviceCert.Raw,
|
||||||
|
}
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadAlg,
|
||||||
|
TransactionID: req.TransactionID, RecipientNonce: req.SenderNonce,
|
||||||
|
}
|
||||||
|
a, _ := BuildCertRepPKIMessage(req, resp, raCert, raKey)
|
||||||
|
b, _ := BuildCertRepPKIMessage(req, resp, raCert, raKey)
|
||||||
|
sdA, _ := ParseSignedData(a)
|
||||||
|
sdB, _ := ParseSignedData(b)
|
||||||
|
nonceA, _ := sdA.SignerInfos[0].GetSenderNonce()
|
||||||
|
nonceB, _ := sdB.SignerInfos[0].GetSenderNonce()
|
||||||
|
if bytes.Equal(nonceA, nonceB) {
|
||||||
|
t.Errorf("senderNonce must be fresh per response, got identical: %x", nonceA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCertRepPKIMessage_RejectsNonRSADeviceCert(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
_, deviceCert := genTestECDSASigner(t) // device cert with ECDSA pubkey — RSA required for KTRI
|
||||||
|
|
||||||
|
req := &domain.SCEPRequestEnvelope{
|
||||||
|
TransactionID: "txn-ec-device", SenderNonce: []byte("nonce-1234567890"),
|
||||||
|
SignerCert: deviceCert.Raw,
|
||||||
|
}
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusSuccess,
|
||||||
|
TransactionID: req.TransactionID, RecipientNonce: req.SenderNonce,
|
||||||
|
Result: &domain.SCEPEnrollResult{CertPEM: selfSignedCertPEM(t, "ec-issued.example.com")},
|
||||||
|
}
|
||||||
|
_, err := BuildCertRepPKIMessage(req, resp, raCert, raKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildCertRepPKIMessage with ECDSA device cert: want error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "RSA public key") {
|
||||||
|
t.Errorf("error should mention RSA, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCertRepPKIMessage_NilArgs_Refuses(t *testing.T) {
|
||||||
|
if _, err := BuildCertRepPKIMessage(nil, nil, nil, nil); err == nil {
|
||||||
|
t.Error("BuildCertRepPKIMessage(nil,nil,nil,nil) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// selfSignedCertPEM creates a fresh RSA self-signed cert with the given CN
|
||||||
|
// and returns it PEM-encoded — used as the 'issued' cert in success-path
|
||||||
|
// CertRep round-trip tests.
|
||||||
|
func selfSignedCertPEM(t *testing.T, cn string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(testRand(), 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(0xCAFE),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
Issuer: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(testRand(), tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRand returns the system random source. Wrapped here so tests can be
|
||||||
|
// adapted to a deterministic source if golden-file tests need it later.
|
||||||
|
func testRand() io.Reader { return rand.Reader }
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// EnvelopedData parser + decryptor for SCEP PKIMessage.
|
||||||
|
//
|
||||||
|
// RFC 5652 §6 (Cryptographic Message Syntax — EnvelopedData) +
|
||||||
|
// RFC 8894 §3.2.2 (SCEP pkcsPKIEnvelope).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.1.
|
||||||
|
//
|
||||||
|
// Equivalent to micromdm/scep's scep/cryptoutil/cryptoutil.go::DecryptPKCSEnvelope
|
||||||
|
// (read for shape only; not vendored — certctl owns the fuzz targets in this
|
||||||
|
// sub-package, see internal/pkcs7/envelopeddata_fuzz_test.go).
|
||||||
|
//
|
||||||
|
// ASN.1 structure being parsed (cited from RFC 5652 §6.1):
|
||||||
|
//
|
||||||
|
// EnvelopedData ::= SEQUENCE {
|
||||||
|
// version INTEGER,
|
||||||
|
// originatorInfo [0] IMPLICIT OriginatorInfo OPTIONAL,
|
||||||
|
// recipientInfos SET SIZE(1..MAX) OF RecipientInfo,
|
||||||
|
// encryptedContentInfo EncryptedContentInfo,
|
||||||
|
// unprotectedAttrs [1] IMPLICIT Attributes OPTIONAL
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// RecipientInfo ::= CHOICE {
|
||||||
|
// ktri KeyTransRecipientInfo, -- the only one SCEP uses
|
||||||
|
// -- (other CHOICE arms ignored: kari, kekri, pwri, ori)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// KeyTransRecipientInfo ::= SEQUENCE {
|
||||||
|
// version INTEGER (0|2),
|
||||||
|
// rid RecipientIdentifier, -- IssuerAndSerialNumber for SCEP
|
||||||
|
// keyEncryptionAlgorithm AlgorithmIdentifier, -- rsaEncryption (1.2.840.113549.1.1.1)
|
||||||
|
// encryptedKey OCTET STRING -- AES key encrypted with RA cert pubkey
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// EncryptedContentInfo ::= SEQUENCE {
|
||||||
|
// contentType OBJECT IDENTIFIER, -- pkcs7-data (1.2.840.113549.1.7.1)
|
||||||
|
// contentEncryptionAlgorithm AlgorithmIdentifier, -- aes-128-cbc | aes-192-cbc | aes-256-cbc | des-ede3-cbc
|
||||||
|
// encryptedContent [0] IMPLICIT OCTET STRING -- the encrypted CSR bytes + PKCS#7 padding
|
||||||
|
// }
|
||||||
|
|
||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des" //nolint:gosec // DES-EDE3-CBC is RFC 8894 §3.5.2 fallback for legacy MDM clients
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/subtle"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP / CMS algorithm OIDs used by the EnvelopedData path.
|
||||||
|
//
|
||||||
|
// Defined here as exported package vars so the CertRep builder (Phase 3)
|
||||||
|
// shares the same OID encoding and the unit tests can pin the exact values.
|
||||||
|
var (
|
||||||
|
// rsaEncryption — PKCS#1 v1.5 key transport (RFC 8017 §7.2).
|
||||||
|
OIDRSAEncryption = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 1}
|
||||||
|
// PKCS#7 / CMS data content type (RFC 5652 §4).
|
||||||
|
OIDDataContent = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}
|
||||||
|
// AES-128-CBC / AES-192-CBC / AES-256-CBC content-encryption algorithms
|
||||||
|
// (NIST CSOR / RFC 3565 §2).
|
||||||
|
OIDAES128CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 2}
|
||||||
|
OIDAES192CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 22}
|
||||||
|
OIDAES256CBC = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 1, 42}
|
||||||
|
// DES-EDE3-CBC — RFC 8894 §3.5.2 advertises this as a legacy fallback;
|
||||||
|
// some Cisco IOS / older MDM clients still emit it. RFC 8894 itself
|
||||||
|
// does NOT mandate that the server accept DES; we accept it for
|
||||||
|
// max-compat and document the security caveat in docs/legacy-est-scep.md.
|
||||||
|
OIDDESEDE3CBC = asn1.ObjectIdentifier{1, 2, 840, 113549, 3, 7}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEnvelopedDataDecrypt is the sentinel decryption error. The caller
|
||||||
|
// (handler / service) maps this to SCEPFailBadMessageCheck per RFC 8894
|
||||||
|
// §3.3.2.2 + §3.2.2 (integrity-check failure semantics). The error text
|
||||||
|
// is intentionally generic so the padding-oracle / Bleichenbacher leak
|
||||||
|
// surfaces are closed: every failure mode (RSA decrypt failure, content
|
||||||
|
// decrypt failure, padding malformed, unknown algorithm) returns the SAME
|
||||||
|
// error message text.
|
||||||
|
var ErrEnvelopedDataDecrypt = errors.New("envelopedData: decrypt failed")
|
||||||
|
|
||||||
|
// EnvelopedData is the parsed RFC 5652 EnvelopedData structure ready for
|
||||||
|
// Decrypt. Holds the recipient infos + the encrypted content algorithm /
|
||||||
|
// IV / ciphertext.
|
||||||
|
type EnvelopedData struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []KeyTransRecipientInfo
|
||||||
|
ContentEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent []byte // AES-CBC ciphertext; algorithm + IV in ContentEncryptionAlg
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyTransRecipientInfo is the RFC 5652 §6.2.1 KeyTransRecipientInfo. SCEP
|
||||||
|
// only uses this CHOICE arm — the others (kari/kekri/pwri/ori) are
|
||||||
|
// rejected at parse time as out-of-spec for SCEP.
|
||||||
|
type KeyTransRecipientInfo struct {
|
||||||
|
Version int
|
||||||
|
IssuerAndSerial IssuerAndSerial
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuerAndSerial is the recipient identifier (RFC 5652 §10.2.4). SCEP
|
||||||
|
// requires the SubjectKeyIdentifier-as-bytes form to NOT be used; only
|
||||||
|
// IssuerAndSerialNumber. The handler matches this against the loaded RA
|
||||||
|
// cert (issuer + serial) to identify the matching recipient when the
|
||||||
|
// envelope addresses multiple CAs.
|
||||||
|
type IssuerAndSerial struct {
|
||||||
|
IssuerRaw asn1.RawValue // RDN sequence of the issuer cert; raw so re-serialisation matches DER bit-for-bit
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// envelopedDataASN1 is the ASN.1 unmarshal target for the EnvelopedData
|
||||||
|
// structure inside the SignedData encapContentInfo (post-CMS-wrapping).
|
||||||
|
// The version field comes first; recipientInfos is a SET (not SEQUENCE);
|
||||||
|
// the encryptedContentInfo SEQUENCE follows.
|
||||||
|
//
|
||||||
|
// The originatorInfo [0] IMPLICIT OPTIONAL is rare in SCEP and skipped
|
||||||
|
// at the raw-value level (we don't need it).
|
||||||
|
type envelopedDataASN1 struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
EncryptedContentInfo encryptedContentInfoASN1 `asn1:""`
|
||||||
|
UnprotectedAttrs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encryptedContentInfoASN1 struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent asn1.RawValue `asn1:"optional,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyTransRecipientInfoASN1 struct {
|
||||||
|
Version int
|
||||||
|
RID asn1.RawValue // CHOICE — IssuerAndSerialNumber or [0] subjectKeyIdentifier
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type issuerAndSerialASN1 struct {
|
||||||
|
Issuer asn1.RawValue
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEnvelopedData parses raw DER-encoded EnvelopedData bytes.
|
||||||
|
//
|
||||||
|
// The caller passes the raw bytes from the inner pkcsPKIEnvelope (already
|
||||||
|
// stripped of the outer SignedData → encapContentInfo → OCTET STRING
|
||||||
|
// wrapper). Returns an EnvelopedData ready for Decrypt.
|
||||||
|
//
|
||||||
|
// Parse failures are returned as detailed errors so the handler can log
|
||||||
|
// what was malformed; the eventual SCEP wire response collapses all
|
||||||
|
// failures to BadMessageCheck.
|
||||||
|
func ParseEnvelopedData(der []byte) (*EnvelopedData, error) {
|
||||||
|
if len(der) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: empty input")
|
||||||
|
}
|
||||||
|
// Some encoders wrap the EnvelopedData in an outer ContentInfo
|
||||||
|
// (SEQUENCE { contentType OID, content [0] EXPLICIT EnvelopedData }).
|
||||||
|
// Try that shape first; on failure, parse the bytes directly.
|
||||||
|
if peeled, ok := peelContentInfo(der, OIDEnvelopedData); ok {
|
||||||
|
der = peeled
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw envelopedDataASN1
|
||||||
|
rest, err := asn1.Unmarshal(der, &raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("envelopedData: parse outer SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
if len(rest) > 0 {
|
||||||
|
// Trailing bytes after a CMS structure are tolerated by some
|
||||||
|
// encoders; not a fatal parse error.
|
||||||
|
_ = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &EnvelopedData{
|
||||||
|
Version: raw.Version,
|
||||||
|
ContentEncryptionAlg: raw.EncryptedContentInfo.ContentEncryptionAlgorithm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipientInfos is SET OF RecipientInfo (CHOICE). We accept only the
|
||||||
|
// KeyTransRecipientInfo arm. Other CHOICE arms (kari = [1], kekri = [2],
|
||||||
|
// pwri = [3], ori = [4]) are skipped silently — Decrypt will fail with
|
||||||
|
// 'no matching recipient' if none of the SET members are KTRI.
|
||||||
|
for _, ri := range raw.RecipientInfos {
|
||||||
|
// KeyTransRecipientInfo is implicitly tagged as a SEQUENCE (no
|
||||||
|
// explicit context tag) per RFC 5652 §6.2 — it's the default
|
||||||
|
// CHOICE arm. The other arms carry context-specific tags.
|
||||||
|
if ri.Class != asn1.ClassUniversal || ri.Tag != asn1.TagSequence {
|
||||||
|
continue // not a KTRI; skip
|
||||||
|
}
|
||||||
|
var ktri keyTransRecipientInfoASN1
|
||||||
|
if _, err := asn1.Unmarshal(ri.FullBytes, &ktri); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// SCEP requires IssuerAndSerialNumber for the rid (RFC 8894 §3.2.2
|
||||||
|
// references RFC 5652 §6.2.1 with the v0 form). The v2 form uses
|
||||||
|
// SubjectKeyIdentifier in [0] — also accepted by some clients. We
|
||||||
|
// only support the v0 IssuerAndSerial form here; v2 clients that
|
||||||
|
// fail to match fall through to 'no matching recipient'.
|
||||||
|
var ias issuerAndSerialASN1
|
||||||
|
if _, err := asn1.Unmarshal(ktri.RID.FullBytes, &ias); err != nil {
|
||||||
|
continue // not IssuerAndSerial; skip
|
||||||
|
}
|
||||||
|
out.RecipientInfos = append(out.RecipientInfos, KeyTransRecipientInfo{
|
||||||
|
Version: ktri.Version,
|
||||||
|
IssuerAndSerial: IssuerAndSerial{
|
||||||
|
IssuerRaw: ias.Issuer,
|
||||||
|
SerialNumber: ias.SerialNumber,
|
||||||
|
},
|
||||||
|
KeyEncryptionAlg: ktri.KeyEncryptionAlg,
|
||||||
|
EncryptedKey: ktri.EncryptedKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out.RecipientInfos) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: no KeyTransRecipientInfo with IssuerAndSerial form found in SET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptedContent is [0] IMPLICIT OCTET STRING. The IMPLICIT tagging
|
||||||
|
// strips the OCTET STRING tag; what we get is the raw ciphertext as
|
||||||
|
// asn1.RawValue.Bytes. (Some encoders use EXPLICIT; in that case
|
||||||
|
// FullBytes carries an extra [0] wrapper we strip below.)
|
||||||
|
if raw.EncryptedContentInfo.EncryptedContent.Class == asn1.ClassContextSpecific {
|
||||||
|
out.EncryptedContent = raw.EncryptedContentInfo.EncryptedContent.Bytes
|
||||||
|
}
|
||||||
|
if len(out.EncryptedContent) == 0 {
|
||||||
|
return nil, fmt.Errorf("envelopedData: empty encryptedContent")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts the EnvelopedData using the RA private key.
|
||||||
|
//
|
||||||
|
// Algorithm:
|
||||||
|
// 1. Find a RecipientInfo whose IssuerAndSerial matches raCert.
|
||||||
|
// 2. RSA PKCS#1 v1.5 decrypt the EncryptedKey with raKey.
|
||||||
|
// 3. AES-CBC (or DES-EDE3-CBC) decrypt EncryptedContent with the recovered
|
||||||
|
// symmetric key + the IV embedded in ContentEncryptionAlg.Parameters.
|
||||||
|
// 4. Strip PKCS#7 padding in constant time (no branch on padding-byte
|
||||||
|
// values — closes the padding oracle leak).
|
||||||
|
//
|
||||||
|
// Every failure path returns ErrEnvelopedDataDecrypt with no other detail
|
||||||
|
// to avoid leaking which step failed. Service-layer logs may include
|
||||||
|
// per-step internal context, but the wire response carries only
|
||||||
|
// SCEPFailBadMessageCheck.
|
||||||
|
func (e *EnvelopedData) Decrypt(raKey crypto.PrivateKey, raCert *x509.Certificate) ([]byte, error) {
|
||||||
|
if e == nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
rsaKey, ok := raKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
// SCEP RA keys are RSA per RFC 8894 §3.5.2 (CMS key transport
|
||||||
|
// requires asymmetric keys with PKCS#1 v1.5; ECDSA can't do
|
||||||
|
// keyTrans). The preflight gate already enforces RSA-or-ECDSA on
|
||||||
|
// the RA cert, but Decrypt double-checks — the cert can be ECDSA
|
||||||
|
// (used for SignedData signing only) while EnvelopedData decryption
|
||||||
|
// requires RSA.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a recipient matching the RA cert. Match on issuer DN raw bytes +
|
||||||
|
// serial number — both must compare equal. The cert.RawIssuer is the
|
||||||
|
// DER of the issuer's RDNSequence, the same form CMS encodes here.
|
||||||
|
var ktri *KeyTransRecipientInfo
|
||||||
|
for i := range e.RecipientInfos {
|
||||||
|
ri := &e.RecipientInfos[i]
|
||||||
|
if subtle.ConstantTimeCompare(ri.IssuerAndSerial.IssuerRaw.FullBytes, raCert.RawIssuer) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ri.IssuerAndSerial.SerialNumber == nil || raCert.SerialNumber == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ri.IssuerAndSerial.SerialNumber.Cmp(raCert.SerialNumber) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ktri = ri
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ktri == nil {
|
||||||
|
// Wrong recipient — the envelope was addressed to a CA that isn't
|
||||||
|
// us. RFC 8894 §3.3.2.2 maps this to BadMessageCheck (integrity
|
||||||
|
// check failed), NOT BadCertID — the message is structurally fine,
|
||||||
|
// just not for us.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
if !ktri.KeyEncryptionAlg.Algorithm.Equal(OIDRSAEncryption) {
|
||||||
|
// Only PKCS#1 v1.5 keyTrans supported; OAEP would require parsing
|
||||||
|
// the algorithm parameters for the OAEP hash + MGF — out of scope
|
||||||
|
// for V2.
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSA PKCS#1 v1.5 decrypt the symmetric key. We use the variant that
|
||||||
|
// hides timing of malformed-padding rejection (rsa.DecryptPKCS1v15)
|
||||||
|
// returns an error on bad padding; combined with the constant
|
||||||
|
// ErrEnvelopedDataDecrypt response we close the timing leg of the
|
||||||
|
// Bleichenbacher attack at the wire level.
|
||||||
|
symKey, err := rsa.DecryptPKCS1v15(nil, rsaKey, ktri.EncryptedKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the content. AES-CBC algorithm parameters are the IV as a
|
||||||
|
// raw OCTET STRING (RFC 3565 §2.3); DES-EDE3-CBC same shape (RFC 8894
|
||||||
|
// §3.5.2 advertises this).
|
||||||
|
plaintext, err := decryptCBC(e.ContentEncryptionAlg, symKey, e.EncryptedContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrEnvelopedDataDecrypt
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptCBC dispatches on the content-encryption algorithm OID to the
|
||||||
|
// matching cipher constructor + CBC decrypt + constant-time PKCS#7 unpad.
|
||||||
|
func decryptCBC(alg pkix.AlgorithmIdentifier, key, ciphertext []byte) ([]byte, error) {
|
||||||
|
// The IV is the raw OCTET STRING in alg.Parameters (RFC 3565 §2.3,
|
||||||
|
// RFC 8894 §3.5.2). asn1.RawValue.Bytes carries the OCTET STRING
|
||||||
|
// content already (the SEQUENCE wrapper is stripped by the unmarshal).
|
||||||
|
iv := alg.Parameters.Bytes
|
||||||
|
var block cipher.Block
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case alg.Algorithm.Equal(OIDAES128CBC), alg.Algorithm.Equal(OIDAES192CBC), alg.Algorithm.Equal(OIDAES256CBC):
|
||||||
|
// AES key length must match the algorithm. Reject mismatched
|
||||||
|
// lengths at the cipher constructor — the wire response stays
|
||||||
|
// generic via ErrEnvelopedDataDecrypt.
|
||||||
|
block, err = aes.NewCipher(key)
|
||||||
|
case alg.Algorithm.Equal(OIDDESEDE3CBC):
|
||||||
|
block, err = des.NewTripleDESCipher(key) //nolint:gosec // RFC 8894 §3.5.2 legacy fallback
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported content-encryption algorithm: %v", alg.Algorithm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(iv) != block.BlockSize() {
|
||||||
|
return nil, fmt.Errorf("iv length %d does not match block size %d", len(iv), block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||||
|
return nil, fmt.Errorf("ciphertext length %d not multiple of block size %d", len(ciphertext), block.BlockSize())
|
||||||
|
}
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
dec := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
dec.CryptBlocks(plaintext, ciphertext)
|
||||||
|
|
||||||
|
// Constant-time PKCS#7 padding strip.
|
||||||
|
//
|
||||||
|
// Last byte is the padding length P (1..blockSize). Every byte in the
|
||||||
|
// last P bytes must equal P. We accumulate any deviation into a
|
||||||
|
// bitwise-OR `bad` byte that's zero iff every check passes; the
|
||||||
|
// length cap is also folded into the same accumulator. Branch only on
|
||||||
|
// the accumulator at the end. NEVER branch on padding-byte values
|
||||||
|
// mid-loop (that's the padding oracle).
|
||||||
|
bs := block.BlockSize()
|
||||||
|
if len(plaintext) == 0 {
|
||||||
|
return nil, fmt.Errorf("plaintext empty after decrypt")
|
||||||
|
}
|
||||||
|
pad := plaintext[len(plaintext)-1]
|
||||||
|
// pad must be in [1, bs]. `padTooBig` is 0xff when pad > bs, else 0x00.
|
||||||
|
padTooBig := byte(int(pad)-1) >> 7 // 1 if pad==0, else 0
|
||||||
|
padTooBig |= byte((int(bs)-int(pad))>>31) & 0x01
|
||||||
|
bad := padTooBig
|
||||||
|
// Walk the LAST `bs` bytes (a fixed window equal to one block); for
|
||||||
|
// each byte at position N from the end, if N < pad it must equal pad.
|
||||||
|
// Use bitwise mask 'inWindow' to fold the conditional check into the
|
||||||
|
// accumulator without branching.
|
||||||
|
for i := 1; i <= bs && i <= len(plaintext); i++ {
|
||||||
|
// inWindow is 0xff when i <= pad, else 0x00
|
||||||
|
inWindow := byte(int(int(pad)-i) >> 31) // 0xff if pad-i < 0 → not in window
|
||||||
|
inWindow = ^inWindow // flip: 0xff if i <= pad
|
||||||
|
mismatch := plaintext[len(plaintext)-i] ^ pad
|
||||||
|
bad |= inWindow & mismatch
|
||||||
|
}
|
||||||
|
if bad != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS#7 padding")
|
||||||
|
}
|
||||||
|
return plaintext[:len(plaintext)-int(pad)], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// peelContentInfo strips the optional outer ContentInfo wrapper when it's
|
||||||
|
// present. CMS callers either hand us the bare EnvelopedData SEQUENCE or
|
||||||
|
// the same SEQUENCE wrapped in
|
||||||
|
//
|
||||||
|
// ContentInfo ::= SEQUENCE {
|
||||||
|
// contentType OBJECT IDENTIFIER,
|
||||||
|
// content [0] EXPLICIT ANY DEFINED BY contentType
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// We try the wrapper shape first and unwrap to the inner content; on
|
||||||
|
// any parse failure the caller proceeds with the original bytes.
|
||||||
|
func peelContentInfo(der []byte, expectOID asn1.ObjectIdentifier) ([]byte, bool) {
|
||||||
|
var ci struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
||||||
|
}
|
||||||
|
if _, err := asn1.Unmarshal(der, &ci); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !ci.ContentType.Equal(expectOID) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return ci.Content.Bytes, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDEnvelopedData identifies the envelopedData CMS content type (RFC 5652
|
||||||
|
// §6, OID 1.2.840.113549.1.7.3). Used by peelContentInfo when the inbound
|
||||||
|
// bytes carry the optional ContentInfo wrapper.
|
||||||
|
var OIDEnvelopedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 3}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseEnvelopedData is the panic-safety fuzzer for ParseEnvelopedData.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.5: every parser certctl
|
||||||
|
// adds gets a Fuzz target in the same package (the fuzz-target-ownership
|
||||||
|
// rule from cowork/CLAUDE.md::Operating Rules). The point isn't to find
|
||||||
|
// vulnerabilities (the parser uses stdlib encoding/asn1 which is itself
|
||||||
|
// fuzzed upstream) — it's to prove that arbitrary attacker-controlled
|
||||||
|
// bytes cannot panic the SCEP server. Any panic = an availability bug.
|
||||||
|
//
|
||||||
|
// Seed corpus: a known-good EnvelopedData built by buildTestEnvelope plus
|
||||||
|
// a handful of degenerate inputs (empty, single byte, all zeros) that
|
||||||
|
// should each return an error without panicking.
|
||||||
|
func FuzzParseEnvelopedData(f *testing.F) {
|
||||||
|
// Seed: empty input.
|
||||||
|
f.Add([]byte{})
|
||||||
|
// Seed: a SEQUENCE tag with an absurd length (asn1 layer should
|
||||||
|
// reject before we get to our code).
|
||||||
|
f.Add([]byte{0x30, 0x82, 0xff, 0xff})
|
||||||
|
// Seed: a known-good EnvelopedData built dynamically below — but the
|
||||||
|
// fuzz seed corpus must be deterministic, so we skip the full RA-pair
|
||||||
|
// build and just feed a small SEQUENCE-shaped blob.
|
||||||
|
f.Add([]byte{0x30, 0x03, 0x02, 0x01, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
// Whatever happens, no panic. Errors are fine; nil parse with
|
||||||
|
// nil error would be a bug but the contract is just no-panic.
|
||||||
|
_, _ = ParseEnvelopedData(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 2.1: round-trip tests for ParseEnvelopedData +
|
||||||
|
// EnvelopedData.Decrypt.
|
||||||
|
//
|
||||||
|
// Each test materialises a real RSA RA cert + key, builds an EnvelopedData
|
||||||
|
// by hand (encrypting a known plaintext with AES-256-CBC using a fresh
|
||||||
|
// random key transported via PKCS#1 v1.5 wrap of the RA pubkey), then
|
||||||
|
// parses + decrypts and asserts plaintext equality.
|
||||||
|
//
|
||||||
|
// The point of the round-trip is to pin the exact wire format: the
|
||||||
|
// per-field DER encoding has to match what real SCEP clients emit
|
||||||
|
// (Cisco IOS, ChromeOS, Intune Connector). If the parse succeeds but the
|
||||||
|
// decrypt comes back garbled, the wire-format encoding is off in a way
|
||||||
|
// the unit tests catch.
|
||||||
|
|
||||||
|
func TestEnvelopedData_RoundTrip_AES256CBC(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("hello SCEP world — this is the encapsulated CSR DER bytes")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.RecipientInfos) != 1 {
|
||||||
|
t.Fatalf("len(RecipientInfos) = %d, want 1", len(parsed.RecipientInfos))
|
||||||
|
}
|
||||||
|
if !parsed.ContentEncryptionAlg.Algorithm.Equal(OIDAES256CBC) {
|
||||||
|
t.Errorf("ContentEncryptionAlg = %v, want AES-256-CBC", parsed.ContentEncryptionAlg.Algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := parsed.Decrypt(raKey, raCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("Decrypt plaintext mismatch:\n got=%q\nwant=%q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_RoundTrip_AES128CBC(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("AES-128 round-trip — short ciphertext, single-block worth of data")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES128CBC, 16)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
got, err := parsed.Decrypt(raKey, raCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("plaintext mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Decrypt_WrongRA_ReturnsBadMessageCheck(t *testing.T) {
|
||||||
|
correctKey, correctCert := genTestRSARA(t)
|
||||||
|
wrongKey, wrongCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("addressed to the right CA, decrypted with the wrong one")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, correctCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong cert (issuer mismatch) — RFC 8894 §3.3.2.2 says BadMessageCheck.
|
||||||
|
_, err = parsed.Decrypt(wrongKey, wrongCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt with wrong RA cert: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
// Right cert, wrong key — same generic error to close the timing leak.
|
||||||
|
_, err = parsed.Decrypt(wrongKey, correctCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt with mismatched key: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
// Right key, right cert — succeeds.
|
||||||
|
got, err := parsed.Decrypt(correctKey, correctCert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt with correct pair: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plaintext) {
|
||||||
|
t.Errorf("plaintext mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Decrypt_TamperedCiphertext_Refuses(t *testing.T) {
|
||||||
|
raKey, raCert := genTestRSARA(t)
|
||||||
|
plaintext := []byte("plaintext we'll corrupt mid-flight")
|
||||||
|
|
||||||
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
||||||
|
parsed, err := ParseEnvelopedData(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
// Flip a bit in the LAST ciphertext block — corrupts the padding the
|
||||||
|
// constant-time strip should catch.
|
||||||
|
if len(parsed.EncryptedContent) < 16 {
|
||||||
|
t.Fatal("ciphertext too short to tamper")
|
||||||
|
}
|
||||||
|
parsed.EncryptedContent[len(parsed.EncryptedContent)-1] ^= 0xff
|
||||||
|
_, err = parsed.Decrypt(raKey, raCert)
|
||||||
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
||||||
|
t.Errorf("Decrypt tampered ciphertext: err = %v, want ErrEnvelopedDataDecrypt", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Parse_Empty_Refuses(t *testing.T) {
|
||||||
|
if _, err := ParseEnvelopedData(nil); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(nil) = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := ParseEnvelopedData([]byte{}); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(empty) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopedData_Parse_RandomGarbage_Refuses(t *testing.T) {
|
||||||
|
garbage := []byte{0x30, 0x82, 0x00, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||||
|
if _, err := ParseEnvelopedData(garbage); err == nil {
|
||||||
|
t.Error("ParseEnvelopedData(garbage) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func genTestRSARA(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: "ra-test"},
|
||||||
|
Issuer: pkix.Name{CommonName: "ra-test"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestEnvelope hand-constructs an EnvelopedData targeting raCert that
|
||||||
|
// encrypts plaintext with the given AES-CBC algorithm + keyLen. Mirrors
|
||||||
|
// what a real SCEP client would emit (Cisco IOS / Intune Connector / etc.).
|
||||||
|
//
|
||||||
|
// Returns the raw DER bytes ready to feed into ParseEnvelopedData.
|
||||||
|
func buildTestEnvelope(t *testing.T, raCert *x509.Certificate, plaintext []byte, algOID asn1.ObjectIdentifier, keyLen int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
// 1. Generate a random symmetric key + IV.
|
||||||
|
symKey := make([]byte, keyLen)
|
||||||
|
if _, err := rand.Read(symKey); err != nil {
|
||||||
|
t.Fatalf("rand.Read symKey: %v", err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatalf("rand.Read iv: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PKCS#7-pad the plaintext to a multiple of the block size.
|
||||||
|
bs := aes.BlockSize
|
||||||
|
padLen := bs - len(plaintext)%bs
|
||||||
|
padded := append([]byte{}, plaintext...)
|
||||||
|
for i := 0; i < padLen; i++ {
|
||||||
|
padded = append(padded, byte(padLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AES-CBC encrypt.
|
||||||
|
block, err := aes.NewCipher(symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes.NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
enc := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
ciphertext := make([]byte, len(padded))
|
||||||
|
enc.CryptBlocks(ciphertext, padded)
|
||||||
|
|
||||||
|
// 4. RSA PKCS#1 v1.5 encrypt the symmetric key with the RA pubkey.
|
||||||
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, raCert.PublicKey.(*rsa.PublicKey), symKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.EncryptPKCS1v15: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build the IssuerAndSerialNumber identifying the RA cert.
|
||||||
|
issuerRDN := asn1.RawValue{FullBytes: raCert.RawIssuer}
|
||||||
|
rid, err := asn1.Marshal(struct {
|
||||||
|
Issuer asn1.RawValue
|
||||||
|
SerialNumber *big.Int
|
||||||
|
}{Issuer: issuerRDN, SerialNumber: raCert.SerialNumber})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal IssuerAndSerial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the KeyTransRecipientInfo SEQUENCE.
|
||||||
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
||||||
|
ktriBytes, err := asn1.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
RID asn1.RawValue
|
||||||
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
||||||
|
EncryptedKey []byte
|
||||||
|
}{
|
||||||
|
Version: 0,
|
||||||
|
RID: asn1.RawValue{FullBytes: rid},
|
||||||
|
KeyEncryptionAlg: keyEncAlg,
|
||||||
|
EncryptedKey: encryptedKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal KTRI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Build the AlgorithmIdentifier with the IV as parameters
|
||||||
|
// (RFC 3565 §2.3 — IV is OCTET STRING, fed in via Parameters).
|
||||||
|
ivParam, err := asn1.Marshal(iv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal IV: %v", err)
|
||||||
|
}
|
||||||
|
contentAlg := pkix.AlgorithmIdentifier{
|
||||||
|
Algorithm: algOID,
|
||||||
|
Parameters: asn1.RawValue{FullBytes: ivParam},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Build the EncryptedContentInfo SEQUENCE.
|
||||||
|
// encryptedContent is [0] IMPLICIT OCTET STRING — the content bytes
|
||||||
|
// appear directly after the [0] tag, without an inner OCTET STRING
|
||||||
|
// wrapper.
|
||||||
|
encContent := asn1.RawValue{
|
||||||
|
Class: asn1.ClassContextSpecific,
|
||||||
|
Tag: 0,
|
||||||
|
IsCompound: false,
|
||||||
|
Bytes: ciphertext,
|
||||||
|
}
|
||||||
|
eciBytes, err := asn1.Marshal(struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
EncryptedContent asn1.RawValue
|
||||||
|
}{
|
||||||
|
ContentType: OIDDataContent,
|
||||||
|
ContentEncryptionAlgorithm: contentAlg,
|
||||||
|
EncryptedContent: encContent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ECI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Build the EnvelopedData SEQUENCE.
|
||||||
|
envBytes, err := asn1.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
RecipientInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
EncryptedECI asn1.RawValue
|
||||||
|
}{
|
||||||
|
Version: 0,
|
||||||
|
RecipientInfos: []asn1.RawValue{{FullBytes: ktriBytes}},
|
||||||
|
EncryptedECI: asn1.RawValue{FullBytes: eciBytes},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal EnvelopedData: %v", err)
|
||||||
|
}
|
||||||
|
return envBytes
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// SignerInfo parser + signature verifier for SCEP PKIMessage.
|
||||||
|
//
|
||||||
|
// RFC 5652 §5 (SignedData) + RFC 8894 §3.2.1 (SCEP authenticatedAttributes).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.2.
|
||||||
|
//
|
||||||
|
// The wire shape this parses (cited from RFC 5652 §5.3):
|
||||||
|
//
|
||||||
|
// SignedData ::= SEQUENCE {
|
||||||
|
// version INTEGER,
|
||||||
|
// digestAlgorithms SET OF AlgorithmIdentifier,
|
||||||
|
// encapContentInfo EncapsulatedContentInfo,
|
||||||
|
// certificates [0] IMPLICIT SET OF CertificateChoices OPTIONAL,
|
||||||
|
// crls [1] IMPLICIT SET OF RevocationInfoChoices OPTIONAL,
|
||||||
|
// signerInfos SET OF SignerInfo -- the field this file targets
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SignerInfo ::= SEQUENCE {
|
||||||
|
// version INTEGER (1|3),
|
||||||
|
// sid SignerIdentifier, -- IssuerAndSerial for v1, SubjectKeyId for v3
|
||||||
|
// digestAlgorithm AlgorithmIdentifier,
|
||||||
|
// signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
|
||||||
|
// signatureAlgorithm AlgorithmIdentifier,
|
||||||
|
// signature OCTET STRING,
|
||||||
|
// unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
|
||||||
|
// Attribute ::= SEQUENCE { attrType OID, attrValues SET OF AttributeValue }
|
||||||
|
//
|
||||||
|
// The CMS signature is computed over the DER re-serialisation of the
|
||||||
|
// signedAttrs as a SET OF Attribute (NOT as the [0] IMPLICIT-tagged form
|
||||||
|
// it appears as in the wire). RFC 5652 §5.4 spells this out — easy to
|
||||||
|
// get wrong, every CMS implementation has hit this.
|
||||||
|
|
||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1" //nolint:gosec // SHA-1 is RFC 8894 §3.5.2 baseline; SHA-256 also accepted
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP authenticated-attribute OIDs (RFC 8894 §3.2.1.4).
|
||||||
|
var (
|
||||||
|
OIDSCEPMessageType = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
|
||||||
|
OIDSCEPPKIStatus = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 3}
|
||||||
|
OIDSCEPFailInfo = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 4}
|
||||||
|
OIDSCEPSenderNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
|
||||||
|
OIDSCEPRecipientNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 6}
|
||||||
|
OIDSCEPTransactionID = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
|
||||||
|
|
||||||
|
// CMS standard authenticated-attribute OIDs used by the signature
|
||||||
|
// verification (RFC 5652 §11).
|
||||||
|
OIDContentType = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
|
||||||
|
OIDMessageDigest = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
|
||||||
|
OIDSigningTime = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 5}
|
||||||
|
|
||||||
|
// CMS digest algorithm OIDs.
|
||||||
|
OIDSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26}
|
||||||
|
OIDSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
|
||||||
|
OIDSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3}
|
||||||
|
|
||||||
|
// Signature algorithm OIDs the verifier accepts.
|
||||||
|
OIDRSAWithSHA1 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 5}
|
||||||
|
OIDRSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
|
||||||
|
OIDRSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 13}
|
||||||
|
OIDECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2}
|
||||||
|
OIDECDSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 4}
|
||||||
|
|
||||||
|
// signedData CMS content type (RFC 5652 §5).
|
||||||
|
OIDSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSignerInfoVerify is returned when signature verification fails. Like
|
||||||
|
// the EnvelopedData decrypt error, the message text is intentionally
|
||||||
|
// generic so the wire response collapses to BadMessageCheck.
|
||||||
|
var ErrSignerInfoVerify = errors.New("signerInfo: signature verification failed")
|
||||||
|
|
||||||
|
// SignerInfo represents an unwrapped CMS signerInfo with its parsed
|
||||||
|
// authenticatedAttributes. Used for SCEP POPO verification.
|
||||||
|
type SignerInfo struct {
|
||||||
|
Version int
|
||||||
|
SignerCert *x509.Certificate // device's transient signing cert (from the SignedData certificates field)
|
||||||
|
AuthAttributes map[string]asn1.RawValue // keyed by attribute OID dotted-string
|
||||||
|
rawSignedAttrs []byte // DER of the [0] IMPLICIT SignedAttributes — used for re-serialisation
|
||||||
|
DigestAlgorithm asn1.ObjectIdentifier
|
||||||
|
SignatureAlgorithm asn1.ObjectIdentifier
|
||||||
|
Signature []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedData is the parsed top-level SignedData structure with the
|
||||||
|
// signers + the optional certificates the SET carries (used to look up
|
||||||
|
// the device's transient signing cert by SignerInfo.sid).
|
||||||
|
type SignedData struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms []pkix.AlgorithmIdentifier
|
||||||
|
EncapContentType asn1.ObjectIdentifier
|
||||||
|
EncapContent []byte // the inner content the SignedData wraps; nil if the wire used external signature
|
||||||
|
Certificates []*x509.Certificate
|
||||||
|
SignerInfos []*SignerInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedDataASN1 is the ASN.1 unmarshal target for the SignedData
|
||||||
|
// structure. Members tagged with their on-the-wire shapes.
|
||||||
|
type signedDataASN1 struct {
|
||||||
|
Version int
|
||||||
|
DigestAlgorithms []pkix.AlgorithmIdentifier `asn1:"set"`
|
||||||
|
EncapContentInfo encapContentInfoASN1
|
||||||
|
Certificates asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Certificate
|
||||||
|
CRLs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
SignerInfos []asn1.RawValue `asn1:"set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encapContentInfoASN1 struct {
|
||||||
|
ContentType asn1.ObjectIdentifier
|
||||||
|
Content asn1.RawValue `asn1:"optional,explicit,tag:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signerInfoASN1 struct {
|
||||||
|
Version int
|
||||||
|
SID asn1.RawValue // CHOICE — IssuerAndSerial (default) or [0] SubjectKeyId
|
||||||
|
DigestAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Attribute
|
||||||
|
SignatureAlgorithm pkix.AlgorithmIdentifier
|
||||||
|
Signature []byte
|
||||||
|
UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attributeASN1 struct {
|
||||||
|
Type asn1.ObjectIdentifier
|
||||||
|
Values asn1.RawValue `asn1:"set"` // SET OF AttributeValue — left raw; per-attr decoder handles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSignedData parses a CMS ContentInfo wrapping a SignedData and
|
||||||
|
// returns the parsed structure including any certs + signerInfos.
|
||||||
|
//
|
||||||
|
// SCEP clients put the device's transient signing cert in the
|
||||||
|
// certificates field; the handler's POPO check picks the cert matching
|
||||||
|
// each signerInfo's SID and verifies with that cert's public key.
|
||||||
|
func ParseSignedData(der []byte) (*SignedData, error) {
|
||||||
|
if len(der) == 0 {
|
||||||
|
return nil, fmt.Errorf("signedData: empty input")
|
||||||
|
}
|
||||||
|
// Try peeling the optional outer ContentInfo (SEQUENCE { OID, [0] EXPLICIT ANY }).
|
||||||
|
if peeled, ok := peelContentInfo(der, OIDSignedData); ok {
|
||||||
|
der = peeled
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw signedDataASN1
|
||||||
|
if _, err := asn1.Unmarshal(der, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("signedData: parse outer SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &SignedData{
|
||||||
|
Version: raw.Version,
|
||||||
|
DigestAlgorithms: raw.DigestAlgorithms,
|
||||||
|
EncapContentType: raw.EncapContentInfo.ContentType,
|
||||||
|
}
|
||||||
|
// EncapContent is [0] EXPLICIT — the [0] EXPLICIT wrapper holds an
|
||||||
|
// OCTET STRING whose Bytes are the inner content. Some encoders use
|
||||||
|
// a degenerate empty content (external-signature mode); that's fine.
|
||||||
|
if len(raw.EncapContentInfo.Content.Bytes) > 0 {
|
||||||
|
// The OCTET STRING wrapper inside [0] EXPLICIT — strip it.
|
||||||
|
var innerOctet asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(raw.EncapContentInfo.Content.Bytes, &innerOctet); err == nil && innerOctet.Tag == asn1.TagOctetString {
|
||||||
|
out.EncapContent = innerOctet.Bytes
|
||||||
|
} else {
|
||||||
|
out.EncapContent = raw.EncapContentInfo.Content.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse certificates SET. Each member is a Certificate (SEQUENCE).
|
||||||
|
if len(raw.Certificates.Bytes) > 0 {
|
||||||
|
certBytes := raw.Certificates.Bytes
|
||||||
|
for len(certBytes) > 0 {
|
||||||
|
var rv asn1.RawValue
|
||||||
|
rest, err := asn1.Unmarshal(certBytes, &rv)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rv.Class == asn1.ClassUniversal && rv.Tag == asn1.TagSequence {
|
||||||
|
if cert, err := x509.ParseCertificate(rv.FullBytes); err == nil {
|
||||||
|
out.Certificates = append(out.Certificates, cert)
|
||||||
|
}
|
||||||
|
// else: not a parseable cert (could be other CertificateChoices) — skip
|
||||||
|
}
|
||||||
|
certBytes = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse each SignerInfo + look up its SignerCert from out.Certificates.
|
||||||
|
for _, siRaw := range raw.SignerInfos {
|
||||||
|
si, err := parseSignerInfoFromRaw(siRaw, out.Certificates)
|
||||||
|
if err != nil {
|
||||||
|
// Skip individual unparseable signerInfos rather than failing
|
||||||
|
// the whole SignedData — multi-signer CMS may have one bad
|
||||||
|
// signer alongside good ones (rare in SCEP, but keep tolerant).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.SignerInfos = append(out.SignerInfos, si)
|
||||||
|
}
|
||||||
|
// Empty signerInfos is valid for the degenerate certs-only PKCS#7
|
||||||
|
// form (RFC 8894 §3.5.1 GetCACert response, RFC 7030 EST cacerts) —
|
||||||
|
// a SignedData with only the certificates field populated and no
|
||||||
|
// signers. The caller of ParseSignedData decides whether the lack
|
||||||
|
// of signers is an error in their context (the SCEP RFC 8894
|
||||||
|
// PKIMessage handler treats it as a fall-through to the MVP path;
|
||||||
|
// the CertRep certs-only inner content treats it as expected).
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSignerInfos extracts SignerInfo records from a SignedData blob.
|
||||||
|
// Convenience wrapper around ParseSignedData when the caller only cares
|
||||||
|
// about the signers, not the certificates list.
|
||||||
|
func ParseSignerInfos(signedDataDER []byte) ([]*SignerInfo, error) {
|
||||||
|
sd, err := ParseSignedData(signedDataDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sd.SignerInfos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignerInfoFromRaw(raw asn1.RawValue, certs []*x509.Certificate) (*SignerInfo, error) {
|
||||||
|
var siRaw signerInfoASN1
|
||||||
|
if _, err := asn1.Unmarshal(raw.FullBytes, &siRaw); err != nil {
|
||||||
|
return nil, fmt.Errorf("signerInfo: parse SEQUENCE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
si := &SignerInfo{
|
||||||
|
Version: siRaw.Version,
|
||||||
|
AuthAttributes: map[string]asn1.RawValue{},
|
||||||
|
DigestAlgorithm: siRaw.DigestAlgorithm.Algorithm,
|
||||||
|
SignatureAlgorithm: siRaw.SignatureAlgorithm.Algorithm,
|
||||||
|
Signature: siRaw.Signature,
|
||||||
|
rawSignedAttrs: siRaw.SignedAttrs.Bytes, // bytes inside the [0] IMPLICIT — used for re-serialisation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk authenticated attributes (SET OF Attribute). The [0] IMPLICIT
|
||||||
|
// wrapper means siRaw.SignedAttrs.Bytes holds the SET-OF body directly
|
||||||
|
// (no extra OCTET STRING wrapper).
|
||||||
|
attrBytes := siRaw.SignedAttrs.Bytes
|
||||||
|
for len(attrBytes) > 0 {
|
||||||
|
var attr attributeASN1
|
||||||
|
rest, err := asn1.Unmarshal(attrBytes, &attr)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
si.AuthAttributes[attr.Type.String()] = attr.Values
|
||||||
|
attrBytes = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve SignerCert by matching the SID against the certs list. SCEP
|
||||||
|
// uses IssuerAndSerial for v1; the [0] IMPLICIT SubjectKeyId form is
|
||||||
|
// v3 — accept both.
|
||||||
|
si.SignerCert = matchSignerCert(siRaw.SID, certs)
|
||||||
|
if si.SignerCert == nil {
|
||||||
|
return nil, fmt.Errorf("signerInfo: SignerCert not found in SignedData certificates")
|
||||||
|
}
|
||||||
|
return si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchSignerCert(sid asn1.RawValue, certs []*x509.Certificate) *x509.Certificate {
|
||||||
|
// IssuerAndSerial form: SEQUENCE (no context tag) — universal class.
|
||||||
|
if sid.Class == asn1.ClassUniversal && sid.Tag == asn1.TagSequence {
|
||||||
|
var ias issuerAndSerialASN1
|
||||||
|
if _, err := asn1.Unmarshal(sid.FullBytes, &ias); err == nil {
|
||||||
|
for _, c := range certs {
|
||||||
|
if c.SerialNumber == nil || ias.SerialNumber == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ias.SerialNumber.Cmp(c.SerialNumber) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if asn1Equal(ias.Issuer.FullBytes, c.RawIssuer) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// SubjectKeyIdentifier form: [0] IMPLICIT OCTET STRING.
|
||||||
|
if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 {
|
||||||
|
ski := sid.Bytes
|
||||||
|
for _, c := range certs {
|
||||||
|
if asn1Equal(c.SubjectKeyId, ski) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asn1Equal(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySignature verifies the signerInfo's signature over the
|
||||||
|
// authenticatedAttributes (SCEP POPO).
|
||||||
|
//
|
||||||
|
// CMS signature semantics (RFC 5652 §5.4):
|
||||||
|
//
|
||||||
|
// 1. Re-serialise signedAttrs as a SET OF Attribute. The wire form is
|
||||||
|
// [0] IMPLICIT, but the signature is computed over the EXPLICIT
|
||||||
|
// SET OF re-serialisation. Easy mistake; this is the canonical CMS
|
||||||
|
// quirk every implementation hits.
|
||||||
|
// 2. Hash the re-serialised bytes with DigestAlgorithm.
|
||||||
|
// 3. Verify Signature against the hash using SignerCert.PublicKey +
|
||||||
|
// SignatureAlgorithm.
|
||||||
|
//
|
||||||
|
// Supports RSA-PKCS1v15 + ECDSA. Rejects RSA-PSS as out-of-spec for SCEP.
|
||||||
|
func (s *SignerInfo) VerifySignature() error {
|
||||||
|
if s == nil || s.SignerCert == nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
if len(s.rawSignedAttrs) == 0 {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-serialise as SET OF Attribute. We have rawSignedAttrs which is
|
||||||
|
// the bytes INSIDE the [0] IMPLICIT wrapper — that's the SET OF body.
|
||||||
|
// Wrap with the SET tag (0x31) + length to get the canonical form
|
||||||
|
// the signature is computed over.
|
||||||
|
signedAttrsForSig := ASN1Wrap(0x31, s.rawSignedAttrs)
|
||||||
|
|
||||||
|
// Hash with the digest algorithm.
|
||||||
|
digest, hashAlg, err := hashForOID(s.DigestAlgorithm, signedAttrsForSig)
|
||||||
|
if err != nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
switch pub := s.SignerCert.PublicKey.(type) {
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
if !isRSASigAlg(s.SignatureAlgorithm) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
if err := rsa.VerifyPKCS1v15(pub, hashAlg, digest, s.Signature); err != nil {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
if !isECDSASigAlg(s.SignatureAlgorithm) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
// crypto/ecdsa.VerifyASN1 takes the same hash, returns bool
|
||||||
|
if !ecdsa.VerifyASN1(pub, digest, s.Signature) {
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrSignerInfoVerify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashForOID(oid asn1.ObjectIdentifier, data []byte) ([]byte, crypto.Hash, error) {
|
||||||
|
switch {
|
||||||
|
case oid.Equal(OIDSHA256), oid.Equal(OIDRSAWithSHA256), oid.Equal(OIDECDSAWithSHA256):
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return h[:], crypto.SHA256, nil
|
||||||
|
case oid.Equal(OIDSHA512), oid.Equal(OIDRSAWithSHA512), oid.Equal(OIDECDSAWithSHA512):
|
||||||
|
h := sha512.Sum512(data)
|
||||||
|
return h[:], crypto.SHA512, nil
|
||||||
|
case oid.Equal(OIDSHA1), oid.Equal(OIDRSAWithSHA1):
|
||||||
|
// SHA-1 still appears in legacy SCEP clients (Cisco IOS pre-2018).
|
||||||
|
// RFC 8894 §3.5.2 advertises SHA-256 as preferred but does not ban SHA-1.
|
||||||
|
h := sha1.Sum(data) //nolint:gosec // RFC 8894 §3.5.2 baseline
|
||||||
|
return h[:], crypto.SHA1, nil
|
||||||
|
}
|
||||||
|
return nil, 0, fmt.Errorf("unsupported digest algorithm: %v", oid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRSASigAlg(oid asn1.ObjectIdentifier) bool {
|
||||||
|
return oid.Equal(OIDRSAWithSHA1) || oid.Equal(OIDRSAWithSHA256) || oid.Equal(OIDRSAWithSHA512) || oid.Equal(OIDRSAEncryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isECDSASigAlg(oid asn1.ObjectIdentifier) bool {
|
||||||
|
return oid.Equal(OIDECDSAWithSHA256) || oid.Equal(OIDECDSAWithSHA512)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SCEP authenticated-attribute extractors -----------------------------
|
||||||
|
|
||||||
|
// GetMessageType returns the SCEP messageType value (RFC 8894 §3.2.1.4.1
|
||||||
|
// — encoded as a PrintableString containing the decimal ASCII of the
|
||||||
|
// message type integer, e.g. "19" for PKCSReq).
|
||||||
|
func (s *SignerInfo) GetMessageType() (domain.SCEPMessageType, error) {
|
||||||
|
str, err := s.attrPrintableString(OIDSCEPMessageType)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
mt, err := strconv.Atoi(str)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("messageType: parse %q as integer: %w", str, err)
|
||||||
|
}
|
||||||
|
return domain.SCEPMessageType(mt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransactionID returns the SCEP transactionID (RFC 8894 §3.2.1.4.4 —
|
||||||
|
// PrintableString chosen by the client; server MUST echo verbatim in
|
||||||
|
// CertRep).
|
||||||
|
func (s *SignerInfo) GetTransactionID() (string, error) {
|
||||||
|
return s.attrPrintableString(OIDSCEPTransactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSenderNonce returns the 16-byte SCEP senderNonce (RFC 8894 §3.2.1.4.5
|
||||||
|
// — OCTET STRING).
|
||||||
|
func (s *SignerInfo) GetSenderNonce() ([]byte, error) {
|
||||||
|
return s.attrOctetString(OIDSCEPSenderNonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageDigest returns the standard CMS messageDigest auth-attr
|
||||||
|
// (RFC 5652 §11.2). Used by the signature verification — when
|
||||||
|
// signedAttrs is present, the signature is over the re-serialised
|
||||||
|
// signedAttrs SET; the messageDigest auth-attr is what binds the
|
||||||
|
// signedAttrs to the encapContent.
|
||||||
|
func (s *SignerInfo) GetMessageDigest() ([]byte, error) {
|
||||||
|
return s.attrOctetString(OIDMessageDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrPrintableString extracts a PrintableString from the AuthAttributes
|
||||||
|
// SET-OF-Attribute-Values for the given attribute OID. Caller-side validation
|
||||||
|
// of length / charset is left to the SCEP-specific extractor.
|
||||||
|
func (s *SignerInfo) attrPrintableString(oid asn1.ObjectIdentifier) (string, error) {
|
||||||
|
rv, ok := s.AuthAttributes[oid.String()]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("auth-attr %v not present", oid)
|
||||||
|
}
|
||||||
|
// rv is the SET OF AttributeValue — typically one element. The
|
||||||
|
// first element is a PrintableString or IA5String.
|
||||||
|
if len(rv.Bytes) == 0 {
|
||||||
|
return "", fmt.Errorf("auth-attr %v: empty value", oid)
|
||||||
|
}
|
||||||
|
var inner asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||||
|
return "", fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
||||||
|
}
|
||||||
|
// PrintableString / IA5String / UTF8String all carry their bytes
|
||||||
|
// directly in inner.Bytes.
|
||||||
|
switch inner.Tag {
|
||||||
|
case asn1.TagPrintableString, asn1.TagIA5String, asn1.TagUTF8String:
|
||||||
|
return string(inner.Bytes), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("auth-attr %v: unexpected value tag %d", oid, inner.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SignerInfo) attrOctetString(oid asn1.ObjectIdentifier) ([]byte, error) {
|
||||||
|
rv, ok := s.AuthAttributes[oid.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v not present", oid)
|
||||||
|
}
|
||||||
|
if len(rv.Bytes) == 0 {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: empty value", oid)
|
||||||
|
}
|
||||||
|
var inner asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
||||||
|
}
|
||||||
|
if inner.Tag != asn1.TagOctetString {
|
||||||
|
return nil, fmt.Errorf("auth-attr %v: unexpected value tag %d (want OCTET STRING)", oid, inner.Tag)
|
||||||
|
}
|
||||||
|
return inner.Bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused warning for big.Int — referenced via issuerAndSerialASN1 in
|
||||||
|
// envelopeddata.go but the linker only sees it once per package; this keeps
|
||||||
|
// the import healthy if someone deletes envelopeddata.go's helper struct.
|
||||||
|
var _ = (*big.Int)(nil)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseSignedData / FuzzParseSignerInfos are the panic-safety fuzzers
|
||||||
|
// for the SignedData parser path used by the SCEP RFC 8894 handler.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.5. Each parser certctl
|
||||||
|
// adds gets a Fuzz target so attacker-controlled wire bytes cannot
|
||||||
|
// crash the server (availability bug). Errors are expected for arbitrary
|
||||||
|
// inputs; only panics are bugs.
|
||||||
|
|
||||||
|
func FuzzParseSignedData(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x03, 0x02, 0x01, 0x00})
|
||||||
|
f.Add([]byte{0x30, 0x82, 0x05, 0x01, 0x02, 0x03})
|
||||||
|
// A short SEQUENCE that LOOKS like a ContentInfo with a signedData OID
|
||||||
|
// but is too truncated to actually decode.
|
||||||
|
f.Add([]byte{0x30, 0x0e, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02, 0xa0, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _ = ParseSignedData(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzParseSignerInfos(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x00})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _ = ParseSignerInfos(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzVerifySignerInfoSignature stresses the verification path with an
|
||||||
|
// arbitrary SignerInfo body (including signature, auth-attrs, cert
|
||||||
|
// reference). The verification is expected to fail for arbitrary inputs;
|
||||||
|
// the invariant the fuzzer enforces is no-panic.
|
||||||
|
//
|
||||||
|
// The test feeds the input bytes through ParseSignedData first so the
|
||||||
|
// fuzz exercises the same parse → SignerInfo extraction → verify path
|
||||||
|
// the production handler runs. Skip-on-parse-error is acceptable —
|
||||||
|
// fuzzing a parse failure adds zero value here; the parse fuzzer above
|
||||||
|
// already covers that path.
|
||||||
|
func FuzzVerifySignerInfoSignature(f *testing.F) {
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x30, 0x00})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
sd, err := ParseSignedData(data)
|
||||||
|
if err != nil || sd == nil {
|
||||||
|
return // covered by FuzzParseSignedData
|
||||||
|
}
|
||||||
|
for _, si := range sd.SignerInfos {
|
||||||
|
_ = si.VerifySignature() // invariant: no panic
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package pkcs7
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 Phase 2.2: round-trip tests for ParseSignedData +
|
||||||
|
// SignerInfo.VerifySignature + auth-attr extractors.
|
||||||
|
//
|
||||||
|
// Each test materialises a real signing cert + signs auth-attrs over a
|
||||||
|
// known content, then re-parses and verifies. Catches drift between the
|
||||||
|
// signing-side encoding and the verification-side re-serialisation
|
||||||
|
// (RFC 5652 §5.4 SET OF Attribute quirk).
|
||||||
|
|
||||||
|
func TestSignerInfo_RoundTrip_RSAWithSHA256(t *testing.T) {
|
||||||
|
signer, signerCert := genTestRSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypePKCSReq, "txn-12345", []byte("0123456789abcdef"),
|
||||||
|
[]byte("encapsulated content (typically EnvelopedData bytes)"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed.SignerInfos) != 1 {
|
||||||
|
t.Fatalf("len(SignerInfos) = %d, want 1", len(parsed.SignerInfos))
|
||||||
|
}
|
||||||
|
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth-attr extractors.
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessageType: %v", err)
|
||||||
|
}
|
||||||
|
if mt != domain.SCEPMessageTypePKCSReq {
|
||||||
|
t.Errorf("GetMessageType = %d, want %d", mt, domain.SCEPMessageTypePKCSReq)
|
||||||
|
}
|
||||||
|
tid, err := si.GetTransactionID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTransactionID: %v", err)
|
||||||
|
}
|
||||||
|
if tid != "txn-12345" {
|
||||||
|
t.Errorf("GetTransactionID = %q, want %q", tid, "txn-12345")
|
||||||
|
}
|
||||||
|
nonce, err := si.GetSenderNonce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSenderNonce: %v", err)
|
||||||
|
}
|
||||||
|
if string(nonce) != "0123456789abcdef" {
|
||||||
|
t.Errorf("GetSenderNonce = %q, want %q", nonce, "0123456789abcdef")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignerInfo_RoundTrip_ECDSAWithSHA256(t *testing.T) {
|
||||||
|
signer, signerCert := genTestECDSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypeRenewalReq, "txn-ec-1", []byte("nonce-ec-aaaa-bbbb"),
|
||||||
|
[]byte("encap content"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
if err := si.VerifySignature(); err != nil {
|
||||||
|
t.Fatalf("VerifySignature (ECDSA): %v", err)
|
||||||
|
}
|
||||||
|
mt, err := si.GetMessageType()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessageType: %v", err)
|
||||||
|
}
|
||||||
|
if mt != domain.SCEPMessageTypeRenewalReq {
|
||||||
|
t.Errorf("GetMessageType = %d, want RenewalReq (17)", mt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignerInfo_VerifySignature_TamperedAttrs_Refuses(t *testing.T) {
|
||||||
|
signer, signerCert := genTestRSASigner(t)
|
||||||
|
signedData := buildTestSignedData(t, signer, signerCert,
|
||||||
|
domain.SCEPMessageTypePKCSReq, "txn-tamper", []byte("nonce-aaaa-bbbb"),
|
||||||
|
[]byte("content"))
|
||||||
|
|
||||||
|
parsed, err := ParseSignedData(signedData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSignedData: %v", err)
|
||||||
|
}
|
||||||
|
si := parsed.SignerInfos[0]
|
||||||
|
// Tamper with rawSignedAttrs by flipping the last byte. Re-verification
|
||||||
|
// must reject — proves the signature is bound to the auth-attr bytes.
|
||||||
|
si.rawSignedAttrs[len(si.rawSignedAttrs)-1] ^= 0x01
|
||||||
|
if err := si.VerifySignature(); !errors.Is(err, ErrSignerInfoVerify) {
|
||||||
|
t.Errorf("VerifySignature(tampered attrs) = %v, want ErrSignerInfoVerify", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSignedData_Empty_Refuses(t *testing.T) {
|
||||||
|
if _, err := ParseSignedData(nil); err == nil {
|
||||||
|
t.Error("ParseSignedData(nil) = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := ParseSignedData([]byte{}); err == nil {
|
||||||
|
t.Error("ParseSignedData(empty) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSignedData_Garbage_Refuses(t *testing.T) {
|
||||||
|
garbage := []byte{0x30, 0x82, 0x05, 0x01, 0x02, 0x03}
|
||||||
|
if _, err := ParseSignedData(garbage); err == nil {
|
||||||
|
t.Error("ParseSignedData(garbage) = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
type testSigner interface {
|
||||||
|
Sign(data []byte) ([]byte, error)
|
||||||
|
DigestOID() asn1.ObjectIdentifier
|
||||||
|
SignatureOID() asn1.ObjectIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
type rsaTestSigner struct{ k *rsa.PrivateKey }
|
||||||
|
|
||||||
|
func (s *rsaTestSigner) Sign(data []byte) ([]byte, error) {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return rsa.SignPKCS1v15(rand.Reader, s.k, 0+5, h[:]) // 5 == crypto.SHA256 in crypto.Hash enum
|
||||||
|
}
|
||||||
|
func (s *rsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
|
||||||
|
func (s *rsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDRSAWithSHA256 }
|
||||||
|
|
||||||
|
type ecdsaTestSigner struct{ k *ecdsa.PrivateKey }
|
||||||
|
|
||||||
|
func (s *ecdsaTestSigner) Sign(data []byte) ([]byte, error) {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return ecdsa.SignASN1(rand.Reader, s.k, h[:])
|
||||||
|
}
|
||||||
|
func (s *ecdsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
|
||||||
|
func (s *ecdsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDECDSAWithSHA256 }
|
||||||
|
|
||||||
|
func genTestRSASigner(t *testing.T) (testSigner, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xDEAD),
|
||||||
|
Subject: pkix.Name{CommonName: "device-rsa"},
|
||||||
|
Issuer: pkix.Name{CommonName: "device-rsa"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return &rsaTestSigner{k: key}, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func genTestECDSASigner(t *testing.T) (testSigner, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xBEEF),
|
||||||
|
Subject: pkix.Name{CommonName: "device-ec"},
|
||||||
|
Issuer: pkix.Name{CommonName: "device-ec"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate: %v", err)
|
||||||
|
}
|
||||||
|
return &ecdsaTestSigner{k: key}, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestSignedData hand-constructs a CMS SignedData with one SignerInfo
|
||||||
|
// carrying SCEP authenticated attributes (messageType, transactionID,
|
||||||
|
// senderNonce, plus the standard CMS contentType + messageDigest).
|
||||||
|
//
|
||||||
|
// The signing pipeline mirrors what micromdm/scep + the ChromeOS SCEP
|
||||||
|
// client emit: the device hashes the encap content into messageDigest,
|
||||||
|
// the auth-attrs are SET-OF re-serialised, hashed, and signed.
|
||||||
|
//
|
||||||
|
// Implementation note: built directly with ASN1Wrap helpers rather than
|
||||||
|
// relying on asn1.Marshal of structs containing asn1.RawValue fields —
|
||||||
|
// asn1.Marshal of nested RawValues with mixed Class/Tag has been finicky
|
||||||
|
// and the helpers give us byte-level control that matches what's on the wire.
|
||||||
|
func buildTestSignedData(t *testing.T, signer testSigner, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// 1. messageDigest auth-attr: SHA-256 of the encap content.
|
||||||
|
contentDigest := sha256.Sum256(encapContent)
|
||||||
|
|
||||||
|
// 2. Build each auth-attr as Attribute ::= SEQUENCE { OID, SET OF Value }
|
||||||
|
// using the helpers. Marshal each value individually then wrap.
|
||||||
|
attrSetBody := buildSCEPAuthAttrs(t, contentDigest[:], messageType, transactionID, senderNonce)
|
||||||
|
|
||||||
|
// 3. Compute the signature over SET OF Attribute.
|
||||||
|
signedAttrsForSig := ASN1Wrap(0x31, attrSetBody)
|
||||||
|
sig, err := signer.Sign(signedAttrsForSig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signer.Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the SignerInfo SEQUENCE byte-by-byte.
|
||||||
|
versionBytes := []byte{0x02, 0x01, 0x01} // INTEGER 1
|
||||||
|
// SID is IssuerAndSerialNumber: SEQUENCE { Issuer (RDN), SerialNumber INTEGER }
|
||||||
|
serialDER, err := asn1.Marshal(signerCert.SerialNumber)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal serial: %v", err)
|
||||||
|
}
|
||||||
|
sidBody := append([]byte{}, signerCert.RawIssuer...) // already in DER
|
||||||
|
sidBody = append(sidBody, serialDER...)
|
||||||
|
sidBytes := ASN1Wrap(0x30, sidBody)
|
||||||
|
|
||||||
|
// DigestAlgorithm: AlgorithmIdentifier — encode via stdlib (small struct, no nested RawValue issues).
|
||||||
|
digestAlgBytes := mustMarshal(t, pkix.AlgorithmIdentifier{Algorithm: signer.DigestOID(), Parameters: asn1.NullRawValue})
|
||||||
|
|
||||||
|
// SignedAttrs as [0] IMPLICIT SET OF — tag 0xA0 wraps the SET body.
|
||||||
|
signedAttrsImplicitBytes := ASN1Wrap(0xa0, attrSetBody)
|
||||||
|
|
||||||
|
// SignatureAlgorithm.
|
||||||
|
sigAlg := pkix.AlgorithmIdentifier{Algorithm: signer.SignatureOID()}
|
||||||
|
if signer.SignatureOID().Equal(OIDRSAWithSHA256) {
|
||||||
|
sigAlg.Parameters = asn1.NullRawValue
|
||||||
|
}
|
||||||
|
sigAlgBytes := mustMarshal(t, sigAlg)
|
||||||
|
|
||||||
|
// Signature: OCTET STRING.
|
||||||
|
sigOctetBytes := ASN1Wrap(0x04, sig)
|
||||||
|
|
||||||
|
siBody := append([]byte{}, versionBytes...)
|
||||||
|
siBody = append(siBody, sidBytes...)
|
||||||
|
siBody = append(siBody, digestAlgBytes...)
|
||||||
|
siBody = append(siBody, signedAttrsImplicitBytes...)
|
||||||
|
siBody = append(siBody, sigAlgBytes...)
|
||||||
|
siBody = append(siBody, sigOctetBytes...)
|
||||||
|
siBytes := ASN1Wrap(0x30, siBody)
|
||||||
|
|
||||||
|
// 5. Build encapContentInfo SEQUENCE { OID data, [0] EXPLICIT OCTET STRING }.
|
||||||
|
octetBytes := ASN1Wrap(0x04, encapContent) // OCTET STRING
|
||||||
|
encapContentExplicit := ASN1Wrap(0xa0, octetBytes) // [0] EXPLICIT
|
||||||
|
oidDataBytes := mustMarshal(t, OIDDataContent)
|
||||||
|
encapBody := append([]byte{}, oidDataBytes...)
|
||||||
|
encapBody = append(encapBody, encapContentExplicit...)
|
||||||
|
encapBytes := ASN1Wrap(0x30, encapBody)
|
||||||
|
|
||||||
|
// 6. certificates [0] IMPLICIT SET OF Certificate — body is one cert DER.
|
||||||
|
certsBytes := ASN1Wrap(0xa0, signerCert.Raw)
|
||||||
|
|
||||||
|
// 7. digestAlgorithms SET OF AlgorithmIdentifier (one entry).
|
||||||
|
digestAlgsBytes := ASN1Wrap(0x31, digestAlgBytes)
|
||||||
|
|
||||||
|
// 8. signerInfos SET OF SignerInfo (one entry).
|
||||||
|
signerInfosBytes := ASN1Wrap(0x31, siBytes)
|
||||||
|
|
||||||
|
// 9. Assemble SignedData SEQUENCE.
|
||||||
|
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) // version
|
||||||
|
sdBody = append(sdBody, digestAlgsBytes...)
|
||||||
|
sdBody = append(sdBody, encapBytes...)
|
||||||
|
sdBody = append(sdBody, certsBytes...)
|
||||||
|
sdBody = append(sdBody, signerInfosBytes...)
|
||||||
|
sdSeq := ASN1Wrap(0x30, sdBody)
|
||||||
|
|
||||||
|
// 10. Wrap as ContentInfo SEQUENCE { OID signedData, [0] EXPLICIT SignedData }.
|
||||||
|
contentField := ASN1Wrap(0xa0, sdSeq)
|
||||||
|
oidSignedDataDER := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02}
|
||||||
|
ciBody := append([]byte{}, oidSignedDataDER...)
|
||||||
|
ciBody = append(ciBody, contentField...)
|
||||||
|
return ASN1Wrap(0x30, ciBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSCEPAuthAttrs builds the SET-OF body of SCEP auth-attrs (the bytes
|
||||||
|
// inside the [0] IMPLICIT SignedAttrs wrapper). Each Attribute is a SEQUENCE
|
||||||
|
// of (OID, SET OF Value); we build them with ASN1Wrap to avoid asn1.Marshal
|
||||||
|
// nuances with nested RawValues.
|
||||||
|
func buildSCEPAuthAttrs(t *testing.T, msgDigest []byte, messageType domain.SCEPMessageType, transactionID string, senderNonce []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var out []byte
|
||||||
|
// contentType: SET OF OID = SET { OID data }
|
||||||
|
out = append(out, attrSeq(t, OIDContentType, ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
|
||||||
|
// messageDigest: SET OF OCTET STRING
|
||||||
|
out = append(out, attrSeq(t, OIDMessageDigest, ASN1Wrap(0x04, msgDigest))...)
|
||||||
|
// SCEP messageType: SET OF PrintableString (decimal ASCII)
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPMessageType, ASN1Wrap(0x13, []byte(intToAscii(int(messageType)))))...)
|
||||||
|
// SCEP transactionID: SET OF PrintableString
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPTransactionID, ASN1Wrap(0x13, []byte(transactionID)))...)
|
||||||
|
// SCEP senderNonce: SET OF OCTET STRING
|
||||||
|
out = append(out, attrSeq(t, OIDSCEPSenderNonce, ASN1Wrap(0x04, senderNonce))...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrSeq builds one Attribute SEQUENCE: SEQUENCE { OID, SET OF value }.
|
||||||
|
// The `value` arg is one already-encoded TLV (e.g. an OCTET STRING or
|
||||||
|
// PrintableString); attrSeq wraps it in a SET and prefixes the OID.
|
||||||
|
func attrSeq(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
oidBytes := mustMarshal(t, oid)
|
||||||
|
setOfValue := ASN1Wrap(0x31, value)
|
||||||
|
body := append([]byte{}, oidBytes...)
|
||||||
|
body = append(body, setOfValue...)
|
||||||
|
return ASN1Wrap(0x30, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshal(t *testing.T, v interface{}) []byte {
|
||||||
|
t.Helper()
|
||||||
|
out, err := asn1.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %T: %v", v, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToAscii(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := i < 0
|
||||||
|
if neg {
|
||||||
|
i = -i
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for i > 0 {
|
||||||
|
b = append([]byte{byte('0' + i%10)}, b...)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
b = append([]byte{'-'}, b...)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -194,13 +194,18 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
|||||||
return fmt.Errorf("CSR validation failed: %w", csrErr)
|
return fmt.Errorf("CSR validation failed: %w", csrErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve MaxTTL from profile
|
// Resolve MaxTTL + must-staple from profile.
|
||||||
var maxTTLSeconds int
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up.
|
||||||
|
var (
|
||||||
|
maxTTLSeconds int
|
||||||
|
mustStaple bool
|
||||||
|
)
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
maxTTLSeconds = profile.MaxTTLSeconds
|
maxTTLSeconds = profile.MaxTTLSeconds
|
||||||
|
mustStaple = profile.MustStaple
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM), ekus, maxTTLSeconds)
|
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM), ekus, maxTTLSeconds, mustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("issuer signing failed: %w", err)
|
return fmt.Errorf("issuer signing failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -139,15 +139,22 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
|||||||
"sans", strings.Join(sans, ","),
|
"sans", strings.Join(sans, ","),
|
||||||
"issuer", s.issuerID)
|
"issuer", s.issuerID)
|
||||||
|
|
||||||
// Resolve MaxTTL from profile
|
// Resolve MaxTTL + must-staple from profile.
|
||||||
var maxTTLSeconds int
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: thread
|
||||||
|
// profile.MustStaple through to the issuer so the local issuer can
|
||||||
|
// add the RFC 7633 id-pe-tlsfeature extension.
|
||||||
|
var (
|
||||||
|
maxTTLSeconds int
|
||||||
|
mustStaple bool
|
||||||
|
)
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
maxTTLSeconds = profile.MaxTTLSeconds
|
maxTTLSeconds = profile.MaxTTLSeconds
|
||||||
|
mustStaple = profile.MustStaple
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue the certificate via the configured issuer connector
|
// Issue the certificate via the configured issuer connector
|
||||||
// EST enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
// EST enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
||||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
|
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("EST enrollment failed",
|
s.logger.Error("EST enrollment failed",
|
||||||
"action", auditAction,
|
"action", auditAction,
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ func NewIssuerConnectorAdapter(c issuer.Connector) IssuerConnector {
|
|||||||
|
|
||||||
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
||||||
// translating between service-layer and connector-layer types.
|
// translating between service-layer and connector-layer types.
|
||||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: mustStaple flows
|
||||||
|
// through to the IssuanceRequest.MustStaple field. Only the local issuer
|
||||||
|
// honors it (RFC 7633 id-pe-tlsfeature extension); upstream connectors
|
||||||
|
// silently ignore the field.
|
||||||
|
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
CommonName: commonName,
|
CommonName: commonName,
|
||||||
SANs: sans,
|
SANs: sans,
|
||||||
CSRPEM: csrPEM,
|
CSRPEM: csrPEM,
|
||||||
EKUs: ekus,
|
EKUs: ekus,
|
||||||
MaxTTLSeconds: maxTTLSeconds,
|
MaxTTLSeconds: maxTTLSeconds,
|
||||||
|
MustStaple: mustStaple,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,13 +48,14 @@ func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonNam
|
|||||||
|
|
||||||
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
||||||
// translating between service-layer and connector-layer types.
|
// translating between service-layer and connector-layer types.
|
||||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
||||||
CommonName: commonName,
|
CommonName: commonName,
|
||||||
SANs: sans,
|
SANs: sans,
|
||||||
CSRPEM: csrPEM,
|
CSRPEM: csrPEM,
|
||||||
EKUs: ekus,
|
EKUs: ekus,
|
||||||
MaxTTLSeconds: maxTTLSeconds,
|
MaxTTLSeconds: maxTTLSeconds,
|
||||||
|
MustStaple: mustStaple,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ import (
|
|||||||
|
|
||||||
// mockConnectorLayerIssuer is a test implementation of issuer.Connector
|
// mockConnectorLayerIssuer is a test implementation of issuer.Connector
|
||||||
type mockConnectorLayerIssuer struct {
|
type mockConnectorLayerIssuer struct {
|
||||||
issueResult *issuer.IssuanceResult
|
issueResult *issuer.IssuanceResult
|
||||||
issueErr error
|
issueErr error
|
||||||
renewResult *issuer.IssuanceResult
|
renewResult *issuer.IssuanceResult
|
||||||
renewErr error
|
renewErr error
|
||||||
lastIssueReq *issuer.IssuanceRequest
|
lastIssueReq *issuer.IssuanceRequest
|
||||||
lastRenewReq *issuer.RenewalRequest
|
lastRenewReq *issuer.RenewalRequest
|
||||||
validateErr error
|
validateErr error
|
||||||
revokeErr error
|
revokeErr error
|
||||||
orderStatusErr error
|
orderStatusErr error
|
||||||
orderStatus *issuer.OrderStatus
|
orderStatus *issuer.OrderStatus
|
||||||
renewalInfoResult *issuer.RenewalInfoResult
|
renewalInfoResult *issuer.RenewalInfoResult
|
||||||
renewalInfoErr error
|
renewalInfoErr error
|
||||||
renewalInfoNil bool // flag to force nil result
|
renewalInfoNil bool // flag to force nil result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||||
@@ -140,7 +140,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil, 0)
|
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil, 0, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueCertificate failed: %v", err)
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
@@ -177,7 +177,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr", nil, 0)
|
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr", nil, 0, false)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -211,7 +211,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_RequestTranslation(t *testing.T
|
|||||||
sans := []string{"www.test.example.com", "api.test.example.com"}
|
sans := []string{"www.test.example.com", "api.test.example.com"}
|
||||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----"
|
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----"
|
||||||
|
|
||||||
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM, nil, 0)
|
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM, nil, 0, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueCertificate failed: %v", err)
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
@@ -261,7 +261,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil, 0)
|
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil, 0, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RenewCertificate failed: %v", err)
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
@@ -298,7 +298,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr", nil, 0)
|
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr", nil, 0, false)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -332,7 +332,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T
|
|||||||
sans := []string{"www.renew.example.com"}
|
sans := []string{"www.renew.example.com"}
|
||||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----"
|
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----"
|
||||||
|
|
||||||
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM, nil, 0)
|
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM, nil, 0, false)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RenewCertificate failed: %v", err)
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_MaxTTLForwarded(t *testing.T) {
|
|||||||
mock := &mockConnectorLayerIssuer{}
|
mock := &mockConnectorLayerIssuer{}
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
_, err := adapter.IssueCertificate(context.Background(), "test.example.com", nil, "csr", nil, 7200)
|
_, err := adapter.IssueCertificate(context.Background(), "test.example.com", nil, "csr", nil, 7200, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -230,7 +230,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_MaxTTLForwarded(t *testing.T) {
|
|||||||
mock := &mockConnectorLayerIssuer{}
|
mock := &mockConnectorLayerIssuer{}
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
_, err := adapter.RenewCertificate(context.Background(), "renew.example.com", nil, "csr", nil, 14400)
|
_, err := adapter.RenewCertificate(context.Background(), "renew.example.com", nil, "csr", nil, 14400, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_ZeroMaxTTL(t *testing.T) {
|
|||||||
mock := &mockConnectorLayerIssuer{}
|
mock := &mockConnectorLayerIssuer{}
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
adapter := NewIssuerConnectorAdapter(mock)
|
||||||
|
|
||||||
_, err := adapter.IssueCertificate(context.Background(), "test.example.com", nil, "csr", nil, 0)
|
_, err := adapter.IssueCertificate(context.Background(), "test.example.com", nil, "csr", nil, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -366,11 +366,16 @@ func TestSCEPService_NoProfileRepo_PassesThrough(t *testing.T) {
|
|||||||
type capturingIssuerConnector struct {
|
type capturingIssuerConnector struct {
|
||||||
lastMaxTTLSeconds int
|
lastMaxTTLSeconds int
|
||||||
lastEKUs []string
|
lastEKUs []string
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: capture
|
||||||
|
// must-staple too so the integration test can prove the wire reaches
|
||||||
|
// the connector for both PKCSReq and renewal paths.
|
||||||
|
lastMustStaple bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *capturingIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
func (c *capturingIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
c.lastMaxTTLSeconds = maxTTLSeconds
|
c.lastMaxTTLSeconds = maxTTLSeconds
|
||||||
c.lastEKUs = ekus
|
c.lastEKUs = ekus
|
||||||
|
c.lastMustStaple = mustStaple
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &IssuanceResult{
|
return &IssuanceResult{
|
||||||
Serial: "test-serial",
|
Serial: "test-serial",
|
||||||
@@ -381,8 +386,8 @@ func (c *capturingIssuerConnector) IssueCertificate(ctx context.Context, commonN
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *capturingIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
func (c *capturingIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
return c.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
|
return c.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *capturingIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
func (c *capturingIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||||
|
|||||||
+31
-12
@@ -43,11 +43,18 @@ func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
|
|||||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||||
type IssuerConnector interface {
|
type IssuerConnector interface {
|
||||||
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
||||||
// maxTTLSeconds caps the certificate validity period (0 = no cap, use issuer default).
|
// maxTTLSeconds caps the certificate validity period (0 = no cap, use
|
||||||
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error)
|
// issuer default). mustStaple, when true, instructs the issuer to add
|
||||||
|
// the RFC 7633 id-pe-tlsfeature extension to the issued cert (only the
|
||||||
|
// local issuer honors this; upstream connectors silently ignore it).
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up.
|
||||||
|
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error)
|
||||||
// RenewCertificate renews a certificate using the provided CSR PEM.
|
// RenewCertificate renews a certificate using the provided CSR PEM.
|
||||||
// maxTTLSeconds caps the certificate validity period (0 = no cap, use issuer default).
|
// maxTTLSeconds caps the certificate validity period (0 = no cap, use
|
||||||
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error)
|
// issuer default). mustStaple has the same semantics as on
|
||||||
|
// IssueCertificate so renewed certs match their initial-issuance
|
||||||
|
// extension set when the bound profile changed mid-lifetime.
|
||||||
|
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error)
|
||||||
// RevokeCertificate revokes a certificate by serial number with an optional reason.
|
// RevokeCertificate revokes a certificate by serial number with an optional reason.
|
||||||
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
||||||
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
|
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
|
||||||
@@ -446,18 +453,25 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
|||||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Resolve EKUs and MaxTTL from the certificate profile
|
// Resolve EKUs + MaxTTL + must-staple from the certificate profile.
|
||||||
var ekus []string
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: thread
|
||||||
var maxTTLSeconds int
|
// must-staple through the renewal path too so renewed certs match
|
||||||
|
// their initial-issuance extension set.
|
||||||
|
var (
|
||||||
|
ekus []string
|
||||||
|
maxTTLSeconds int
|
||||||
|
mustStaple bool
|
||||||
|
)
|
||||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||||
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
||||||
ekus = profile.AllowedEKUs
|
ekus = profile.AllowedEKUs
|
||||||
maxTTLSeconds = profile.MaxTTLSeconds
|
maxTTLSeconds = profile.MaxTTLSeconds
|
||||||
|
mustStaple = profile.MustStaple
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call issuer connector to renew
|
// Call issuer connector to renew
|
||||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus, maxTTLSeconds)
|
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
||||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||||
@@ -564,18 +578,23 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
|||||||
return fmt.Errorf("failed to update job status: %w", err)
|
return fmt.Errorf("failed to update job status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve EKUs and MaxTTL from the certificate profile (for S/MIME, email certs, etc.)
|
// Resolve EKUs + MaxTTL + must-staple from the certificate profile.
|
||||||
var ekus []string
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up.
|
||||||
var maxTTLSeconds int
|
var (
|
||||||
|
ekus []string
|
||||||
|
maxTTLSeconds int
|
||||||
|
mustStaple bool
|
||||||
|
)
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
if len(profile.AllowedEKUs) > 0 {
|
if len(profile.AllowedEKUs) > 0 {
|
||||||
ekus = profile.AllowedEKUs
|
ekus = profile.AllowedEKUs
|
||||||
}
|
}
|
||||||
maxTTLSeconds = profile.MaxTTLSeconds
|
maxTTLSeconds = profile.MaxTTLSeconds
|
||||||
|
mustStaple = profile.MustStaple
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the agent-submitted CSR via issuer
|
// Sign the agent-submitted CSR via issuer
|
||||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus, maxTTLSeconds)
|
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
||||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||||
|
|||||||
+242
-4
@@ -49,8 +49,16 @@ func (s *SCEPService) SetProfileRepo(repo repository.CertificateProfileRepositor
|
|||||||
|
|
||||||
// GetCACaps returns the capabilities of this SCEP server.
|
// GetCACaps returns the capabilities of this SCEP server.
|
||||||
// RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line.
|
// RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.1: extended from the
|
||||||
|
// initial value (POSTPKIOperation+SHA-256+AES+SCEPStandard) to additionally
|
||||||
|
// advertise SHA-512 (now-implemented modern digest alternative) and Renewal
|
||||||
|
// (the messageType-17 dispatch from Phase 4). ChromeOS specifically looks
|
||||||
|
// for these capabilities to negotiate the strongest available cipher +
|
||||||
|
// digest combo. Order is by historical convention; clients walk the list
|
||||||
|
// linearly.
|
||||||
func (s *SCEPService) GetCACaps(ctx context.Context) string {
|
func (s *SCEPService) GetCACaps(ctx context.Context) string {
|
||||||
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
|
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCACert returns the PEM-encoded CA certificate chain for this SCEP server.
|
// GetCACert returns the PEM-encoded CA certificate chain for this SCEP server.
|
||||||
@@ -164,15 +172,24 @@ func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, tran
|
|||||||
"transaction_id", transactionID,
|
"transaction_id", transactionID,
|
||||||
"issuer", s.issuerID)
|
"issuer", s.issuerID)
|
||||||
|
|
||||||
// Resolve MaxTTL from profile
|
// Resolve MaxTTL + must-staple from profile.
|
||||||
var maxTTLSeconds int
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: thread
|
||||||
|
// profile.MustStaple through to the issuer so the local issuer can
|
||||||
|
// add the RFC 7633 id-pe-tlsfeature extension. Without this read the
|
||||||
|
// CertificateProfile.MustStaple field would be a stored-but-ignored
|
||||||
|
// "lying field" that operators set without behavior change.
|
||||||
|
var (
|
||||||
|
maxTTLSeconds int
|
||||||
|
mustStaple bool
|
||||||
|
)
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
maxTTLSeconds = profile.MaxTTLSeconds
|
maxTTLSeconds = profile.MaxTTLSeconds
|
||||||
|
mustStaple = profile.MustStaple
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue the certificate via the configured issuer connector
|
// Issue the certificate via the configured issuer connector
|
||||||
// SCEP enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
// SCEP enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
||||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
|
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("SCEP enrollment failed",
|
s.logger.Error("SCEP enrollment failed",
|
||||||
"action", auditAction,
|
"action", auditAction,
|
||||||
@@ -210,3 +227,224 @@ func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, tran
|
|||||||
ChainPEM: result.ChainPEM,
|
ChainPEM: result.ChainPEM,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
||||||
|
// (where the handler successfully parsed an EnvelopedData + signerInfo
|
||||||
|
// instead of the MVP raw-CSR path).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 2.4.
|
||||||
|
//
|
||||||
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
||||||
|
// RFC 8894 mandates a CertRep PKIMessage on every PKIOperation request,
|
||||||
|
// even failure cases — the handler shouldn't have to translate Go errors
|
||||||
|
// into SCEP failInfo codes; the service does that mapping.
|
||||||
|
//
|
||||||
|
// Service-side error → failInfo mapping (from the prompt's exact table):
|
||||||
|
//
|
||||||
|
// Invalid challenge password → caller returns HTTP 403, NOT a PKIMessage
|
||||||
|
// (RFC 8894 §3.3.1 silent on this; matches MVP precedent)
|
||||||
|
// CSR parse failure → BadRequest (2)
|
||||||
|
// CSR signature invalid → BadMessageCheck (1)
|
||||||
|
// Crypto policy violation → BadAlg (0)
|
||||||
|
// Issuer connector failure → BadRequest (2)
|
||||||
|
// Audit-log write failure → log + continue with success (best-effort)
|
||||||
|
//
|
||||||
|
// The challenge-password failure case returns nil to signal "let the caller
|
||||||
|
// translate to 403"; every other failure mode returns a populated envelope
|
||||||
|
// with FailInfo set so the handler can build a CertRep with pkiStatus=2.
|
||||||
|
func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
||||||
|
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
|
||||||
|
// caller translate to HTTP 403' — the existing PKCSReq path returns
|
||||||
|
// an error string the handler matched on, but PKCSReqWithEnvelope
|
||||||
|
// returns *SCEPResponseEnvelope so we use a nil sentinel.
|
||||||
|
if s.challengePassword == "" {
|
||||||
|
s.logger.Warn("SCEP enrollment rejected: server has no challenge password configured (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
||||||
|
s.logger.Warn("SCEP enrollment rejected: invalid challenge password (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the existing processEnrollment for the actual issuance work.
|
||||||
|
// Errors mapped to SCEP failInfo per the table above.
|
||||||
|
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_pkcsreq")
|
||||||
|
if err != nil {
|
||||||
|
resp.Status = domain.SCEPStatusFailure
|
||||||
|
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp.Status = domain.SCEPStatusSuccess
|
||||||
|
resp.Result = result
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapServiceErrorToFailInfo translates a service-layer error into the
|
||||||
|
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
|
||||||
|
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
|
||||||
|
// when the error doesn't match any specific category.
|
||||||
|
func mapServiceErrorToFailInfo(err error) domain.SCEPFailInfo {
|
||||||
|
if err == nil {
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case containsAnyOf(msg, "invalid CSR PEM", "failed to parse CSR"):
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
case containsAnyOf(msg, "CSR signature verification failed"):
|
||||||
|
return domain.SCEPFailBadMessageCheck
|
||||||
|
case containsAnyOf(msg, "key algorithm", "key size", "algorithm not allowed", "crypto policy"):
|
||||||
|
return domain.SCEPFailBadAlg
|
||||||
|
default:
|
||||||
|
return domain.SCEPFailBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyOf(s string, needles ...string) bool {
|
||||||
|
for _, n := range needles {
|
||||||
|
if strings.Contains(s, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewalReqWithEnvelope processes a SCEP RenewalReq from the RFC 8894 path.
|
||||||
|
// RFC 8894 §3.3.1.2 — re-enrollment with an existing valid cert. Distinct
|
||||||
|
// from PKCSReq because the signerInfo is signed by the EXISTING cert
|
||||||
|
// (proving possession), not by a transient self-signed device key.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 4.2.
|
||||||
|
//
|
||||||
|
// Functionally identical to PKCSReqWithEnvelope but with two differences:
|
||||||
|
//
|
||||||
|
// 1. Audit action is `scep_renewalreq` (vs `scep_pkcsreq`) — operators
|
||||||
|
// can grep the audit log to distinguish initial enrollments from
|
||||||
|
// renewals.
|
||||||
|
//
|
||||||
|
// 2. The signing cert presented as POPO MUST chain to the issuer's CA
|
||||||
|
// (the cert was previously issued by THIS issuer, not a self-signed
|
||||||
|
// throwaway). Verified against the issuer's GetCACertPEM chain via
|
||||||
|
// x509.Certificate.Verify. A signing cert that doesn't chain is
|
||||||
|
// mapped to BadMessageCheck per the same RFC 8894 §3.3.2.2 semantics
|
||||||
|
// as an EnvelopedData decrypt failure (integrity-check failure).
|
||||||
|
//
|
||||||
|
// Returns *SCEPResponseEnvelope (same contract as PKCSReqWithEnvelope);
|
||||||
|
// nil signals 'invalid challenge password' for HTTP 403 translation.
|
||||||
|
func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
resp := &domain.SCEPResponseEnvelope{
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth
|
||||||
|
// even though the RenewalReq path additionally verifies the signing
|
||||||
|
// cert chain — a stolen/leaked challenge password combined with a
|
||||||
|
// previously-issued cert (e.g. from a compromised device) would still
|
||||||
|
// allow renewal otherwise. The two checks are independent.
|
||||||
|
if s.challengePassword == "" {
|
||||||
|
s.logger.Warn("SCEP renewal rejected: server has no challenge password configured (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
||||||
|
s.logger.Warn("SCEP renewal rejected: invalid challenge password (RFC 8894 path)",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signing cert chains to the issuer's CA. Without this gate
|
||||||
|
// any self-signed cert with a valid challenge password could trigger a
|
||||||
|
// renewal — defeating the 'proof of prior issuance' contract RenewalReq
|
||||||
|
// is supposed to provide.
|
||||||
|
if err := s.verifyRenewalSignerCertChain(ctx, envelope.SignerCert); err != nil {
|
||||||
|
s.logger.Warn("SCEP renewal rejected: signer cert chain invalid",
|
||||||
|
"transaction_id", envelope.TransactionID,
|
||||||
|
"error", err.Error(),
|
||||||
|
)
|
||||||
|
resp.Status = domain.SCEPStatusFailure
|
||||||
|
resp.FailInfo = domain.SCEPFailBadMessageCheck
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the existing processEnrollment for the actual issuance work
|
||||||
|
// — RenewalReq is functionally a re-issuance with a different audit
|
||||||
|
// action and chain-validation precondition.
|
||||||
|
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_renewalreq")
|
||||||
|
if err != nil {
|
||||||
|
resp.Status = domain.SCEPStatusFailure
|
||||||
|
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp.Status = domain.SCEPStatusSuccess
|
||||||
|
resp.Result = result
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyRenewalSignerCertChain confirms the device's signing cert (the cert
|
||||||
|
// presented as POPO in the SignerInfo) was previously issued by the
|
||||||
|
// configured issuer. Used by RenewalReqWithEnvelope to enforce the 'must
|
||||||
|
// have a previously-issued cert' contract RFC 8894 §3.3.1.2 implies.
|
||||||
|
//
|
||||||
|
// A self-signed throwaway cert (initial-enrollment shape) fails this check
|
||||||
|
// — that's an indicator the client meant to send PKCSReq, not RenewalReq.
|
||||||
|
// Operators see the audit-log entry; the client sees BadMessageCheck.
|
||||||
|
func (s *SCEPService) verifyRenewalSignerCertChain(ctx context.Context, signerCertDER []byte) error {
|
||||||
|
if len(signerCertDER) == 0 {
|
||||||
|
return fmt.Errorf("signer cert is empty (no POPO cert in SignerInfo)")
|
||||||
|
}
|
||||||
|
signerCert, err := x509.ParseCertificate(signerCertDER)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse signer cert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the issuer's CA chain via the existing IssuerConnector
|
||||||
|
// surface. Failure here is a deploy bug (the issuer connector lost
|
||||||
|
// its CA cert mid-flight) rather than a client error — surface as
|
||||||
|
// the same generic failure to avoid leaking server state.
|
||||||
|
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get CA cert PEM: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM([]byte(caPEM)) {
|
||||||
|
return fmt.Errorf("CA cert PEM contains no parseable certs")
|
||||||
|
}
|
||||||
|
opts := x509.VerifyOptions{
|
||||||
|
Roots: pool,
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||||
|
}
|
||||||
|
if _, err := signerCert.Verify(opts); err != nil {
|
||||||
|
return fmt.Errorf("signer cert chain validation failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCertInitialWithEnvelope handles SCEP polling requests. RFC 8894 §3.3.3
|
||||||
|
// — the client polls when the prior PKCSReq returned Status=Pending.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 4.3.
|
||||||
|
//
|
||||||
|
// v1 of this bundle returns FAILURE+badCertID for all GetCertInitial
|
||||||
|
// requests since deferred-issuance isn't supported (every PKCSReq either
|
||||||
|
// succeeds or fails synchronously — no Pending state in the existing
|
||||||
|
// service-layer issuance pipeline). The wiring stays in place for a
|
||||||
|
// future enhancement (e.g. 'queue for manual approval' workflows).
|
||||||
|
func (s *SCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
||||||
|
s.logger.Info("SCEP GetCertInitial received — deferred-issuance not supported in v1, returning badCertID",
|
||||||
|
"transaction_id", envelope.TransactionID)
|
||||||
|
return &domain.SCEPResponseEnvelope{
|
||||||
|
Status: domain.SCEPStatusFailure,
|
||||||
|
FailInfo: domain.SCEPFailBadCertID,
|
||||||
|
TransactionID: envelope.TransactionID,
|
||||||
|
RecipientNonce: envelope.SenderNonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up: end-to-end
|
||||||
|
// integration test for the must-staple wire from CertificateProfile.MustStaple
|
||||||
|
// through the SCEPService into the IssuerConnector.
|
||||||
|
//
|
||||||
|
// Background: the original Phase 5.6 commit shipped the local issuer's RFC
|
||||||
|
// 7633 extension generation + the IssuanceRequest.MustStaple field, but
|
||||||
|
// the SCEP service layer (and EST + agent + renewal) didn't read
|
||||||
|
// profile.MustStaple and didn't pass it to IssueCertificate. That made
|
||||||
|
// CertificateProfile.MustStaple a "lying field" — the operator could set
|
||||||
|
// it, the API would store + return it, the docs claimed it worked, but
|
||||||
|
// the cert came back without the extension. Worse than not having the
|
||||||
|
// field at all.
|
||||||
|
//
|
||||||
|
// This test pins the wire end-to-end:
|
||||||
|
//
|
||||||
|
// 1. Create a CertificateProfile with MustStaple=true.
|
||||||
|
// 2. Drive a SCEP enrollment through SCEPService.PKCSReq.
|
||||||
|
// 3. Assert the mock IssuerConnector saw mustStaple=true (proving the
|
||||||
|
// service-layer wire reaches the connector).
|
||||||
|
//
|
||||||
|
// The local-issuer-side test (must_staple_test.go) already pins that the
|
||||||
|
// connector translates that bool into the RFC 7633 extension. Together
|
||||||
|
// they prove: configurable bit → behavior change, end-to-end.
|
||||||
|
|
||||||
|
// stubProfileRepo is a minimal in-memory CertificateProfileRepository for
|
||||||
|
// the test. Returns the configured profile by ID; other repo methods
|
||||||
|
// panic if exercised (we only need Get).
|
||||||
|
type stubProfileRepo struct {
|
||||||
|
profile *domain.CertificateProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProfileRepo) Get(_ context.Context, id string) (*domain.CertificateProfile, error) {
|
||||||
|
if s.profile != nil && s.profile.ID == id {
|
||||||
|
return s.profile, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProfileRepo) Create(_ context.Context, _ *domain.CertificateProfile) error {
|
||||||
|
panic("stubProfileRepo.Create not implemented for this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProfileRepo) Update(_ context.Context, _ *domain.CertificateProfile) error {
|
||||||
|
panic("stubProfileRepo.Update not implemented for this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProfileRepo) Delete(_ context.Context, _ string) error {
|
||||||
|
panic("stubProfileRepo.Delete not implemented for this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubProfileRepo) List(_ context.Context) ([]*domain.CertificateProfile, error) {
|
||||||
|
panic("stubProfileRepo.List not implemented for this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPService_PKCSReq_PlumbsMustStapleToIssuer(t *testing.T) {
|
||||||
|
// 1. Mock issuer that records the must-staple bool from the call.
|
||||||
|
mock := &mockIssuerConnector{}
|
||||||
|
|
||||||
|
// 2. Profile with MustStaple=true.
|
||||||
|
profile := &domain.CertificateProfile{
|
||||||
|
ID: "prof-must-staple",
|
||||||
|
Name: "must-staple",
|
||||||
|
MaxTTLSeconds: 86400,
|
||||||
|
MustStaple: true,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
repo := &stubProfileRepo{profile: profile}
|
||||||
|
|
||||||
|
// 3. Build the service. Use a real challenge password so we exercise
|
||||||
|
// the same gate the production path runs.
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
svc := NewSCEPService("iss-test", mock, nil, logger, "shared-secret-123")
|
||||||
|
svc.SetProfileRepo(repo)
|
||||||
|
svc.SetProfileID(profile.ID)
|
||||||
|
|
||||||
|
// 4. Build a CSR (real crypto so processEnrollment's CheckSignature
|
||||||
|
// + crypto-policy validation both pass).
|
||||||
|
csrPEM := buildCSRForSCEPMustStaple(t, "must-staple.example.com")
|
||||||
|
|
||||||
|
// 5. Drive the enrollment.
|
||||||
|
_, err := svc.PKCSReq(context.Background(), csrPEM, "shared-secret-123", "txn-must-staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PKCSReq: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Assert the must-staple wire reached the connector.
|
||||||
|
if !mock.LastMustStaple {
|
||||||
|
t.Errorf("mockIssuerConnector.LastMustStaple = false, want true — service layer dropped profile.MustStaple on the floor (the 'lying field' regression)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCEPService_PKCSReq_NoMustStaplePropagatesFalse(t *testing.T) {
|
||||||
|
// Companion: when the profile does NOT have MustStaple set, the
|
||||||
|
// connector must see false. Pins the symmetric contract.
|
||||||
|
mock := &mockIssuerConnector{LastMustStaple: true} // pre-set to true so we can detect a stuck-at-true bug
|
||||||
|
profile := &domain.CertificateProfile{
|
||||||
|
ID: "prof-no-staple",
|
||||||
|
Name: "no-staple",
|
||||||
|
MaxTTLSeconds: 86400,
|
||||||
|
MustStaple: false,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
repo := &stubProfileRepo{profile: profile}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
svc := NewSCEPService("iss-test", mock, nil, logger, "shared-secret-123")
|
||||||
|
svc.SetProfileRepo(repo)
|
||||||
|
svc.SetProfileID(profile.ID)
|
||||||
|
|
||||||
|
csrPEM := buildCSRForSCEPMustStaple(t, "no-staple.example.com")
|
||||||
|
_, err := svc.PKCSReq(context.Background(), csrPEM, "shared-secret-123", "txn-no-staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PKCSReq: %v", err)
|
||||||
|
}
|
||||||
|
if mock.LastMustStaple {
|
||||||
|
t.Errorf("mockIssuerConnector.LastMustStaple = true, want false — service layer set MustStaple=true despite profile.MustStaple=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCSRForSCEPMustStaple creates an ECDSA P-256 CSR for the given CN.
|
||||||
|
// Local helper — kept distinct from buildCSRForSCEP elsewhere in the
|
||||||
|
// service test suite to avoid name collisions.
|
||||||
|
func buildCSRForSCEPMustStaple(t *testing.T, cn string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
|
||||||
|
}
|
||||||
@@ -26,6 +26,18 @@ func TestSCEPService_GetCACaps(t *testing.T) {
|
|||||||
if !strings.Contains(caps, "SCEPStandard") {
|
if !strings.Contains(caps, "SCEPStandard") {
|
||||||
t.Errorf("expected SCEPStandard in caps, got: %s", caps)
|
t.Errorf("expected SCEPStandard in caps, got: %s", caps)
|
||||||
}
|
}
|
||||||
|
// SCEP RFC 8894 Phase 5.1 additions — pin the new caps so a future
|
||||||
|
// 'simplify caps' refactor doesn't quietly remove ChromeOS-required
|
||||||
|
// negotiation flags.
|
||||||
|
if !strings.Contains(caps, "SHA-512") {
|
||||||
|
t.Errorf("expected SHA-512 in caps (Phase 5.1 addition), got: %s", caps)
|
||||||
|
}
|
||||||
|
if !strings.Contains(caps, "AES") {
|
||||||
|
t.Errorf("expected AES in caps, got: %s", caps)
|
||||||
|
}
|
||||||
|
if !strings.Contains(caps, "Renewal") {
|
||||||
|
t.Errorf("expected Renewal in caps (Phase 5.1 addition — RenewalReq messageType support), got: %s", caps)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSCEPService_GetCACert_Success(t *testing.T) {
|
func TestSCEPService_GetCACert_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -1254,9 +1254,25 @@ type mockIssuerConnector struct {
|
|||||||
// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
|
// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
|
||||||
// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
|
// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
|
||||||
LastOCSPSignRequest *OCSPSignRequest
|
LastOCSPSignRequest *OCSPSignRequest
|
||||||
|
|
||||||
|
// LastMustStaple records the must-staple bool from the most recent
|
||||||
|
// Issue/Renew call so tests can assert the service-layer wire from
|
||||||
|
// CertificateProfile.MustStaple → IssuerConnector reaches the
|
||||||
|
// connector. SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up.
|
||||||
|
LastMustStaple bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
// LastMustStaple records the must-staple bool from the most recent
|
||||||
|
// IssueCertificate / RenewCertificate call. Set by both methods so tests
|
||||||
|
// can assert the wire from CertificateProfile.MustStaple → service →
|
||||||
|
// IssuerConnector reaches the connector. SCEP RFC 8894 + Intune master
|
||||||
|
// bundle Phase 5.6 follow-up.
|
||||||
|
//
|
||||||
|
// (Field added to mockIssuerConnector struct above; declared via the
|
||||||
|
// pointer receiver so existing test fixtures don't need re-zeroing.)
|
||||||
|
|
||||||
|
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
|
m.LastMustStaple = mustStaple
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return nil, m.Err
|
return nil, m.Err
|
||||||
}
|
}
|
||||||
@@ -1273,11 +1289,12 @@ func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
|
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
||||||
|
m.LastMustStaple = mustStaple
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return nil, m.Err
|
return nil, m.Err
|
||||||
}
|
}
|
||||||
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
|
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user