package handler import ( "context" "crypto" "crypto/x509" "encoding/asn1" "encoding/base64" "encoding/pem" "fmt" "io" "net/http" "strings" "github.com/certctl-io/certctl/internal/api/middleware" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/pkcs7" ) // SCEPService defines the service interface for SCEP enrollment operations. // SCEP (RFC 8894) is a protocol for certificate enrollment used by MDM platforms // and network devices. type SCEPService interface { // GetCACaps returns the SCEP server capabilities as a newline-separated string. GetCACaps(ctx context.Context) string // GetCACert returns the PEM-encoded CA certificate chain. GetCACert(ctx context.Context) (string, error) // PKCSReq processes a PKCS#10 CSR and returns a signed certificate. // 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) // 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). // // SCEP uses a single endpoint with operation-based dispatch via query parameters. // All operations use GET or POST to the same path. // // Supported operations: // - GET ?operation=GetCACaps — server capabilities // - GET ?operation=GetCACert — CA certificate distribution // - POST ?operation=PKIOperation — certificate enrollment (PKCSReq) // // 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 { 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 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 { 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/` 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/ (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/` 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. // It dispatches based on the "operation" query parameter. func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) { operation := r.URL.Query().Get("operation") switch operation { case "GetCACaps": h.getCACaps(w, r) case "GetCACert": h.getCACert(w, r) case "PKIOperation": h.pkiOperation(w, r) default: http.Error(w, fmt.Sprintf("Unknown SCEP operation: %s", operation), http.StatusBadRequest) } } // getCACaps handles GET ?operation=GetCACaps // Returns the SCEP server capabilities as plaintext, one per line. func (h SCEPHandler) getCACaps(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } caps := h.svc.GetCACaps(r.Context()) w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) w.Write([]byte(caps)) } // getCACert handles GET ?operation=GetCACert // Returns the CA certificate(s). Single cert as DER, chain as PKCS#7. func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } caCertPEM, err := h.svc.GetCACert(r.Context()) if err != nil { requestID := middleware.GetRequestID(r.Context()) ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificate: %v", err), requestID) return } // Parse PEM to DER chain derCerts, err := pkcs7.PEMToDERChain(caCertPEM) if err != nil { requestID := middleware.GetRequestID(r.Context()) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to parse CA certificates", requestID) return } if len(derCerts) == 1 { // Single CA cert — return as raw DER w.Header().Set("Content-Type", "application/x-x509-ca-cert") w.WriteHeader(http.StatusOK) w.Write(derCerts[0]) return } // Multiple certs (CA + RA or chain) — return as PKCS#7 pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts) if err != nil { requestID := middleware.GetRequestID(r.Context()) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID) return } w.Header().Set("Content-Type", "application/x-x509-ca-ra-cert") w.WriteHeader(http.StatusOK) w.Write(pkcs7Data) } // pkiOperation handles POST ?operation=PKIOperation // Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR. // // 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) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } requestID := middleware.GetRequestID(r.Context()) body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID) return } defer r.Body.Close() if len(body) == 0 { ErrorWithRequestID(w, http.StatusBadRequest, "Empty request body", requestID) return } // 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) if err != nil { ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID) return } // Validate the CSR csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID) return } if err := csr.CheckSignature(); err != nil { ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("CSR signature invalid: %v", err), requestID) return } // Convert DER CSR to PEM for the service layer csrPEM := string(pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", Bytes: csrDER, })) result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID) if err != nil { if strings.Contains(err.Error(), "challenge password") { ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID) return } ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID) return } // Build response: issued cert wrapped in PKCS#7 certs-only h.writeSCEPResponse(w, result) } // 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). func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) { var derCerts [][]byte certDER, err := pkcs7.PEMToDERChain(result.CertPEM) if err != nil || len(certDER) == 0 { http.Error(w, "Failed to encode certificate", http.StatusInternalServerError) return } derCerts = append(derCerts, certDER...) if result.ChainPEM != "" { chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM) if err == nil { derCerts = append(derCerts, chainDER...) } } pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts) if err != nil { http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-pki-message") w.WriteHeader(http.StatusOK) w.Write(pkcs7Data) } // extractCSRFromPKCS7 extracts a PKCS#10 CSR from a SCEP PKCS#7 SignedData envelope. // // SCEP clients wrap the CSR in a PKCS#7 SignedData structure. For the MVP, we parse // the outer ASN.1 structure to find the encapsulated content (the CSR bytes), and // extract the challenge password from the CSR attributes. // // Returns: csrDER, challengePassword, transactionID, error func extractCSRFromPKCS7(data []byte) ([]byte, string, string, error) { // Try to decode as PKCS#7 SignedData csrDER, err := parseSignedDataForCSR(data) if err != nil { // Fallback: some clients send the CSR directly (not wrapped in PKCS#7) // or send base64-encoded data decoded, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) if decErr == nil { // Try the decoded data as PKCS#7 csrDER2, err2 := parseSignedDataForCSR(decoded) if err2 == nil { return extractCSRFields(csrDER2) } // Maybe the decoded data IS the CSR directly if _, parseErr := x509.ParseCertificateRequest(decoded); parseErr == nil { return extractCSRFields(decoded) } } // Maybe the raw data IS the CSR directly (no PKCS#7 wrapping) if _, parseErr := x509.ParseCertificateRequest(data); parseErr == nil { return extractCSRFields(data) } return nil, "", "", fmt.Errorf("failed to extract CSR from PKCS#7: %w", err) } return extractCSRFields(csrDER) } // extractCSRFields extracts the challenge password and transaction ID from CSR attributes. func extractCSRFields(csrDER []byte) ([]byte, string, string, error) { csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { return nil, "", "", fmt.Errorf("invalid CSR: %w", err) } challengePassword := "" transactionID := "" // OID for challengePassword: 1.2.840.113549.1.9.7 oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7} // Extract challenge password from parsed CSR attributes. // Attributes is []pkix.AttributeTypeAndValueSET where each has Type (OID) // and Value ([][]pkix.AttributeTypeAndValue). The challenge password value // is stored as a string in the inner AttributeTypeAndValue.Value field. // // Audit M-028 carve-out: Go's stdlib deprecates `csr.Attributes` for the // specific use case of parsing the "requestedExtensions" CSR attribute // (OID 1.2.840.113549.1.9.14), pointing callers at `csr.Extensions` / // `csr.ExtraExtensions`. challengePassword (OID 1.2.840.113549.1.9.7) // per RFC 2985 §5.4.1 is a SEPARATE CSR attribute that cannot be // retrieved via Extensions. There is no non-deprecated stdlib API for // it; callers either accept the deprecation warning or parse the raw // `csr.RawAttributes` ASN.1 themselves. We accept the warning; the // staticcheck.conf and golangci-lint rules suppress SA1019 for this // specific line per the audit closure note. //lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see comment above. for _, attr := range csr.Attributes { if attr.Type.Equal(oidChallengePassword) { if len(attr.Value) > 0 && len(attr.Value[0]) > 0 { if pwd, ok := attr.Value[0][0].Value.(string); ok { challengePassword = pwd } } } } // Use CN as fallback transaction ID if not found in attributes if transactionID == "" && csr.Subject.CommonName != "" { transactionID = csr.Subject.CommonName } return csrDER, challengePassword, transactionID, nil } // pkcs7ContentInfo represents the outer ContentInfo structure. type pkcs7ContentInfo struct { ContentType asn1.ObjectIdentifier Content asn1.RawValue `asn1:"explicit,tag:0"` } // pkcs7SignedData represents a simplified SignedData structure for CSR extraction. type pkcs7SignedData struct { Version int DigestAlgorithms asn1.RawValue EncapContentInfo asn1.RawValue } // pkcs7EncapContent represents the EncapsulatedContentInfo. type pkcs7EncapContent struct { ContentType asn1.ObjectIdentifier Content asn1.RawValue `asn1:"explicit,optional,tag:0"` } // parseSignedDataForCSR extracts the encapsulated content (CSR) from PKCS#7 SignedData. func parseSignedDataForCSR(data []byte) ([]byte, error) { var contentInfo pkcs7ContentInfo rest, err := asn1.Unmarshal(data, &contentInfo) if err != nil { return nil, fmt.Errorf("failed to parse ContentInfo: %w", err) } if len(rest) > 0 { // Trailing data is OK for some implementations } // OID for signedData: 1.2.840.113549.1.7.2 oidSignedData := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} if !contentInfo.ContentType.Equal(oidSignedData) { return nil, fmt.Errorf("not SignedData: got OID %v", contentInfo.ContentType) } // Parse the SignedData var signedData pkcs7SignedData _, err = asn1.Unmarshal(contentInfo.Content.Bytes, &signedData) if err != nil { return nil, fmt.Errorf("failed to parse SignedData: %w", err) } // Parse the EncapsulatedContentInfo to get the CSR var encapContent pkcs7EncapContent _, err = asn1.Unmarshal(signedData.EncapContentInfo.FullBytes, &encapContent) if err != nil { return nil, fmt.Errorf("failed to parse EncapsulatedContentInfo: %w", err) } if len(encapContent.Content.Bytes) == 0 { return nil, fmt.Errorf("empty encapsulated content") } // The content may be wrapped in an OCTET STRING var csrBytes []byte var octetString asn1.RawValue if _, err := asn1.Unmarshal(encapContent.Content.Bytes, &octetString); err == nil && octetString.Tag == asn1.TagOctetString { csrBytes = octetString.Bytes } else { csrBytes = encapContent.Content.Bytes } // Validate it's a parseable CSR if _, err := x509.ParseCertificateRequest(csrBytes); err != nil { return nil, fmt.Errorf("extracted content is not a valid CSR: %w", err) } return csrBytes, nil }