mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
feat(scep): EnvelopedData decrypt + signerInfo POPO verify (RFC 8894 §3.2)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user