Files
certctl/internal/api/handler/scep.go
T
shankar0123 b33b843908 feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.

Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.

== Phase 4: RenewalReq + GetCertInitial wiring ============================

internal/service/scep.go
  * RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
    existing valid cert. Same contract as PKCSReqWithEnvelope but the
    service additionally verifies that envelope.SignerCert chains to
    the issuer's CA (verifyRenewalSignerCertChain). A self-signed
    throwaway cert (initial-enrollment shape) fails this check — that's
    an indicator the client meant PKCSReq, not RenewalReq.
  * GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
    Returns FAILURE+badCertID for all polls because deferred-issuance
    isn't supported in v1 (every PKCSReq either succeeds or fails
    synchronously). Wiring stays in place for a future enhancement.
  * Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
    grep the audit log to distinguish initial enrollments from renewals.

internal/api/handler/scep.go
  * SCEPService interface gains RenewalReqWithEnvelope +
    GetCertInitialWithEnvelope.
  * pkiOperation RFC 8894 path now switches on envelope.MessageType:
    PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
    GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
    badRequest per RFC 8894 §3.3.2.2.

== Phase 5.1: GetCACaps capability advertisement =========================

internal/service/scep.go
  * Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
    to add 'SHA-512' (modern digest alternative now implemented in the
    Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
    Phase 4). ChromeOS specifically looks for these capabilities to
    negotiate the strongest available cipher + digest combo.
  * scep_test.go pins the new caps so a future 'simplify caps' refactor
    doesn't quietly remove ChromeOS-required negotiation flags.

== Phase 5.2: ChromeOS-shape integration tests ===========================

internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
  * 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
    in-test (acting as the ChromeOS client), POSTs through the handler,
    parses the CertRep response back via the same internal/pkcs7/
    builders the handler uses.
  * TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
    SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
    EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
    POSTed; verifies CertRep parses + RA signature verifies.
  * TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
    routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
  * TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
    returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
  * TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
    signature falls through to MVP path (which also rejects since the
    encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
  * TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
    AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
  * TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
    path keeps working when no RA pair is configured. Backward compat
    is non-negotiable.

== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============

internal/domain/profile.go
  * Added MustStaple bool to CertificateProfile. Default false; operators
    opt in once they've confirmed the TLS reverse proxy / load balancer
    staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
    require explicit config).

internal/connector/issuer/interface.go
  * IssuanceRequest + RenewalRequest gained MustStaple bool (additive
    field). Connectors that don't support extension injection (Vault,
    EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
    issuer-only feature in V2 since upstream connectors enforce their
    own extension policy.

internal/connector/issuer/local/local.go
  * Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
    pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
    SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
    RFC 7633 §6).
  * generateCertificate signature gained mustStaple bool; when true,
    appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
    mustStapleExtensionValue} to template.ExtraExtensions before
    x509.CreateCertificate.

internal/connector/issuer/local/must_staple_test.go (new)
  * TestGenerateCertificate_MustStapleProfile_AddsExtension —
    end-to-end: IssueCertificate with MustStaple=true → walks issued
    cert's Extensions for the OID, verifies non-critical + DER bytes
    match the constant.
  * TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
    'omit by default' contract (adding it by default would break
    customer deployments where the TLS path doesn't staple).
  * TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
    DER bytes against RFC 7633 §6 verbatim; round-trips through
    asn1.Unmarshal as []int{5}.

Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.

== Phase 5.4: docs/legacy-est-scep.md ====================================

Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).

== Verification ==========================================================

  * gofmt + go vet clean for the files I touched.
  * staticcheck ./internal/api/handler/... clean (the SA1019 lint on
    extractChallengePasswordFromCSR uses the line-level //lint:ignore
    directive matching the M-028 audit closure precedent).
  * go test -short -count=1 green across api/handler / api/router /
    service / pkcs7 / connector/issuer/local / domain / cmd/server.
  * G-3 docs-drift CI guard local check: empty diff in both directions.

Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.

Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 13:16:09 +00:00

613 lines
24 KiB
Go

package handler
import (
"context"
"crypto"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
)
// SCEPService defines the service interface for SCEP enrollment operations.
// SCEP (RFC 8894) is a protocol for certificate enrollment used by MDM platforms
// and network devices.
type SCEPService interface {
// GetCACaps returns the SCEP server capabilities as a newline-separated string.
GetCACaps(ctx context.Context) string
// GetCACert returns the PEM-encoded CA certificate chain.
GetCACert(ctx context.Context) (string, error)
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
// 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
}
// 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
}
// 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
}