From a546a1bbefbb16691531744fc114e7fad7edacc2 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Wed, 29 Apr 2026 12:36:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(scep):=20EnvelopedData=20decrypt=20+=20sig?= =?UTF-8?q?nerInfo=20POPO=20verify=20(RFC=208894=20=C2=A73.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCEP RFC 8894 + Intune master bundle — Phase 2 of 14. Implements the new RFC 8894 PKIMessage parse path: EnvelopedData parser + decryptor, signerInfo parser + signature verifier, handler dispatch that tries the RFC 8894 path FIRST and falls through to the legacy MVP raw-CSR path on any parse failure. Backward compat with lightweight SCEP clients is preserved by design — no behavior change for any existing deploy that doesn't set CERTCTL_SCEP_RA_*. internal/pkcs7/envelopeddata.go (new, ~330 LoC) * ParseEnvelopedData: parses CMS EnvelopedData per RFC 5652 §6.1, with optional outer ContentInfo unwrapping. Handles SET OF RecipientInfo + IssuerAndSerial form rid (RFC 8894 §3.2.2). * EnvelopedData.Decrypt: RSA PKCS#1 v1.5 key-trans + AES-CBC (128/192/ 256) or DES-EDE3-CBC content decryption with **constant-time PKCS#7 padding strip** (no branch on padding-byte values; closes the padding-oracle leak surface). Recipient mismatch is BadMessageCheck per RFC 8894 §3.3.2.2 (NOT BadCertID); every failure mode returns the same ErrEnvelopedDataDecrypt sentinel to close timing-leak legs of Bleichenbacher attacks. * Equivalent to micromdm/scep's cryptoutil/cryptoutil.go::DecryptPKCS- Envelope (cited in code comments; not vendored — fuzz-target ownership stays in this sub-package per the operating rule). internal/pkcs7/signedinfo.go (new, ~370 LoC) * ParseSignedData / ParseSignerInfos: parses CMS SignedData per RFC 5652 §5.3. Resolves each SignerInfo's SID (IssuerAndSerial v1 OR [0] SubjectKeyId v3) against the SignedData certificates SET to pluck the device's transient signing cert. * SignerInfo.VerifySignature: re-serialises signedAttrs as the canonical SET OF Attribute (the RFC 5652 §5.4 quirk every CMS implementation hits — wire form is [0] IMPLICIT but the signature is over EXPLICIT SET OF). Hashes with SHA-1/SHA-256/SHA-512 + verifies via RSA PKCS1v15 or ECDSA per the cert's pubkey type. * Auth-attr extractors: GetMessageType (PrintableString-decimal), GetTransactionID, GetSenderNonce, GetMessageDigest. SCEP attr OIDs pinned (RFC 8894 §3.2.1.4). internal/pkcs7/{envelopeddata,signedinfo}_fuzz_test.go (new) * FuzzParseEnvelopedData / FuzzParseSignedData / FuzzParseSignerInfos / FuzzVerifySignerInfoSignature — every parser certctl adds gets a panic-safety fuzzer (the fuzz-target-ownership rule from cowork/CLAUDE.md::Operating Rules). Local 5s runs hit ~270k executions per parser without panic. Errors are expected for arbitrary inputs; only panics are bugs. internal/pkcs7/{envelopeddata,signedinfo}_test.go (new) * Round-trip tests that materialise real RSA/ECDSA pairs, hand-build the wire bytes, parse + decrypt + verify, and assert plaintext / auth-attr equality. The build helpers use this package's ASN1Wrap primitives directly (asn1.Marshal of structs containing nested asn1.RawValue is finicky for mixed Class/Tag); gives byte-level control matching what real SCEP clients emit. * Negative tests: tampered ciphertext / tampered auth-attrs / wrong RA / wrong key / mismatched recipients / random garbage all return the appropriate sentinel error without panic. internal/service/scep.go * PKCSReqWithEnvelope: RFC 8894 envelope-aware variant. Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because RFC 8894 §3.3 mandates a CertRep PKIMessage on every response, even failures — the handler shouldn't translate Go errors into SCEP failInfo codes. Returns nil to signal 'invalid challenge password' so the caller can translate to HTTP 403 (matches MVP path's wire shape; RFC 8894 §3.3.1 is silent on this case). * mapServiceErrorToFailInfo: exact mapping table from the prompt (CSR parse → BadRequest, CSR sig → BadMessageCheck, crypto policy → BadAlg, default → BadRequest). internal/api/handler/scep.go * SCEPService interface gains PKCSReqWithEnvelope. * SCEPHandler now optionally carries an RA cert + key pair. SetRAPair upgrades the handler to the RFC 8894 path; without that call the handler stays MVP-only (the v2.0.x behavior). * pkiOperation: tries the RFC 8894 path FIRST when the RA pair is set. tryParseRFC8894 helper does the full pipeline (ParseSignedData → VerifySignature → extract auth-attrs → ParseEnvelopedData → Decrypt → x509.ParseCertificateRequest the recovered bytes). On any failure it falls through to the legacy extractCSRFromPKCS7 MVP path — backward compat is non-negotiable. * Phase 2 emits the legacy certs-only response on RFC 8894 success; Phase 3 (next commit) swaps in writeCertRepPKIMessage with the proper status / failInfo / nonce-echo wire shape. cmd/server/main.go * Per-profile loop now calls loadSCEPRAPair after preflight to load the cert + key + inject via SetRAPair. crypto + crypto/tls imports added. * loadSCEPRAPair helper: tls.X509KeyPair-based parse + leaf cert extraction. Failures here indicate TOCTOU between preflight + load. internal/api/handler/scep_handler_test.go + internal/api/router/router_scep_profiles_test.go * mockSCEPService / scepProfileMockService gain PKCSReqWithEnvelope stubs to satisfy the extended interface. Existing test cases unchanged (they exercise the MVP path; RA pair is unset). Verification: * gofmt + go vet clean for the files I touched. * go test -short -count=1 green across pkcs7 / api/handler / api/router / service / cmd/server. * Coverage: pkcs7 78.4% (was 100% — drops because new code includes paths the round-trip tests don't yet hit, like decryption alg fall-through and v3 SubjectKeyId SID matching). * Fuzz-target seed-corpus runs (5s each, ~270k execs/parser): no panic. Pre-merge fuzz-time bumps to 30s per the prompt's verification gate. Phase 2 of 14 in SCEP RFC 8894 + Intune master bundle. Living progress at cowork/scep-rfc8894-intune/progress.md. --- cmd/server/main.go | 47 +- internal/api/handler/scep.go | 151 +++++- internal/api/handler/scep_handler_test.go | 23 + .../api/router/router_scep_profiles_test.go | 8 + internal/pkcs7/envelopeddata.go | 411 +++++++++++++++ internal/pkcs7/envelopeddata_fuzz_test.go | 33 ++ internal/pkcs7/envelopeddata_test.go | 286 +++++++++++ internal/pkcs7/signedinfo.go | 482 ++++++++++++++++++ internal/pkcs7/signedinfo_fuzz_test.go | 57 +++ internal/pkcs7/signedinfo_test.go | 360 +++++++++++++ internal/service/scep.go | 89 ++++ 11 files changed, 1943 insertions(+), 4 deletions(-) create mode 100644 internal/pkcs7/envelopeddata.go create mode 100644 internal/pkcs7/envelopeddata_fuzz_test.go create mode 100644 internal/pkcs7/envelopeddata_test.go create mode 100644 internal/pkcs7/signedinfo.go create mode 100644 internal/pkcs7/signedinfo_fuzz_test.go create mode 100644 internal/pkcs7/signedinfo_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7836bbd..fb7cf68 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto" "crypto/tls" "crypto/x509" "fmt" @@ -791,7 +792,19 @@ func main() { if 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" if profile.PathID != "" { endpoint = "/scep/" + profile.PathID @@ -1142,6 +1155,38 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro return 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 // path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled // pattern; otherwise the checks are: diff --git a/internal/api/handler/scep.go b/internal/api/handler/scep.go index 7fb5e03..f8515d2 100644 --- a/internal/api/handler/scep.go +++ b/internal/api/handler/scep.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto" "crypto/x509" "encoding/asn1" "encoding/base64" @@ -27,7 +28,17 @@ type SCEPService interface { 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 } // SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894). @@ -39,15 +50,34 @@ type SCEPService interface { // - 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 + 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. +// 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) { @@ -125,6 +155,22 @@ func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) { // 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) @@ -145,7 +191,38 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) { 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, ok := h.tryParseRFC8894(body); ok { + resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, "", envelope) + if resp == nil { + // nil signals 'invalid challenge password' — the service + // layer didn't find one in the request (envelope-path + // challenge password lives in the CSR's challengePassword + // attribute, extracted by the service). Treat as 403, + // matching the MVP path's wire shape. + ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID) + return + } + // Phase 2 emits the legacy certs-only response on success; + // Phase 3 swaps in writeCertRepPKIMessage. Failure responses + // are emitted as plain HTTP errors until Phase 3 lands the + // CertRep+failInfo wire shape. + if resp.Status == domain.SCEPStatusSuccess && resp.Result != nil { + h.writeSCEPResponse(w, resp.Result) + return + } + ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("SCEP enrollment failed (failInfo=%s)", resp.FailInfo), requestID) + 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) @@ -183,6 +260,74 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) { 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. PEM-encode the CSR for the service layer. +// +// Returns (envelope, csrPEM, 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, 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. + if _, err := x509.ParseCertificateRequest(csrDER); err != nil { + return nil, "", false + } + 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, true +} + +// silence unused-import warning if some narrow build excludes the path +// where crypto.PrivateKey is used (the RA key field below). +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 diff --git a/internal/api/handler/scep_handler_test.go b/internal/api/handler/scep_handler_test.go index d9e9982..e3c153e 100644 --- a/internal/api/handler/scep_handler_test.go +++ b/internal/api/handler/scep_handler_test.go @@ -36,6 +36,29 @@ func (m *mockSCEPService) PKCSReq(ctx context.Context, csrPEM string, challengeP 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, + } +} + func TestSCEP_GetCACaps_Success(t *testing.T) { svc := &mockSCEPService{} h := NewSCEPHandler(svc) diff --git a/internal/api/router/router_scep_profiles_test.go b/internal/api/router/router_scep_profiles_test.go index 9fe1e2c..0dafe79 100644 --- a/internal/api/router/router_scep_profiles_test.go +++ b/internal/api/router/router_scep_profiles_test.go @@ -43,6 +43,14 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do return nil, nil } +// PKCSReqWithEnvelope was added to the SCEPService interface in SCEP RFC 8894 +// + Intune master bundle Phase 2.4. The router-level tests don't drive the +// RFC 8894 path; this stub satisfies 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 TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) { r := New() svc := &scepProfileMockService{tag: "legacy"} diff --git a/internal/pkcs7/envelopeddata.go b/internal/pkcs7/envelopeddata.go new file mode 100644 index 0000000..ed37c14 --- /dev/null +++ b/internal/pkcs7/envelopeddata.go @@ -0,0 +1,411 @@ +// 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} +) + +// 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} diff --git a/internal/pkcs7/envelopeddata_fuzz_test.go b/internal/pkcs7/envelopeddata_fuzz_test.go new file mode 100644 index 0000000..2c6553b --- /dev/null +++ b/internal/pkcs7/envelopeddata_fuzz_test.go @@ -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) + }) +} diff --git a/internal/pkcs7/envelopeddata_test.go b/internal/pkcs7/envelopeddata_test.go new file mode 100644 index 0000000..83649e6 --- /dev/null +++ b/internal/pkcs7/envelopeddata_test.go @@ -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 +} diff --git a/internal/pkcs7/signedinfo.go b/internal/pkcs7/signedinfo.go new file mode 100644 index 0000000..134281d --- /dev/null +++ b/internal/pkcs7/signedinfo.go @@ -0,0 +1,482 @@ +// 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) + } + if len(out.SignerInfos) == 0 { + return nil, fmt.Errorf("signedData: no parseable signerInfos") + } + 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) diff --git a/internal/pkcs7/signedinfo_fuzz_test.go b/internal/pkcs7/signedinfo_fuzz_test.go new file mode 100644 index 0000000..c0699aa --- /dev/null +++ b/internal/pkcs7/signedinfo_fuzz_test.go @@ -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 + } + }) +} diff --git a/internal/pkcs7/signedinfo_test.go b/internal/pkcs7/signedinfo_test.go new file mode 100644 index 0000000..999e9a2 --- /dev/null +++ b/internal/pkcs7/signedinfo_test.go @@ -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) +} diff --git a/internal/service/scep.go b/internal/service/scep.go index 2e8d4b9..02fb44b 100644 --- a/internal/service/scep.go +++ b/internal/service/scep.go @@ -210,3 +210,92 @@ func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, tran ChainPEM: result.ChainPEM, }, 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 +}