diff --git a/internal/api/handler/scep.go b/internal/api/handler/scep.go index f8515d2..cd1a7a8 100644 --- a/internal/api/handler/scep.go +++ b/internal/api/handler/scep.go @@ -195,26 +195,25 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) { // 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 envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok { + resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, 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. + // nil signals 'invalid challenge password'. RFC 8894 §3.3.1 + // is silent on whether to return a CertRep or an HTTP error + // for this case; we mirror the MVP path's HTTP 403 wire + // shape so the client sees a clear auth failure rather than + // trying to interpret a structurally-valid CertRep+failInfo + // (which conflates 'wrong secret' with 'wrong CSR shape'). ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID) return } - // 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) + // SCEP RFC 8894 Phase 3.2: emit CertRep PKIMessage for both + // success AND failure paths (RFC 8894 §3.3 mandates a + // PKIMessage response on every PKIOperation request, including + // failures). The MVP path keeps using writeSCEPResponse — + // that's the legacy certs-only response shape lightweight + // clients understand. + h.writeCertRepPKIMessage(w, r, envelope, resp) return } // RFC 8894 parse failed — fall through to the MVP path. @@ -267,30 +266,33 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) { // 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. +// 5. Parse the CSR + extract the challengePassword attribute (RFC 2985 +// §5.4.1) so the service-layer's challenge-password gate can run. +// 6. PEM-encode the CSR for the service layer. // -// Returns (envelope, csrPEM, 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) { +// Returns (envelope, csrPEM, challengePassword, true) on success; +// (nil, "", "", false) on any parse / verify / decrypt failure. The +// handler treats false as 'fall through to MVP path' so lightweight +// clients keep working. +func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, string, string, bool) { sd, err := pkcs7.ParseSignedData(body) if err != nil { - return nil, "", false + return nil, "", "", false } if len(sd.SignerInfos) == 0 { - return nil, "", false + return nil, "", "", false } si := sd.SignerInfos[0] if err := si.VerifySignature(); err != nil { - return nil, "", false + return nil, "", "", false } mt, err := si.GetMessageType() if err != nil { - return nil, "", false + return nil, "", "", false } tid, err := si.GetTransactionID() if err != nil { - return nil, "", false + return nil, "", "", false } nonce, err := si.GetSenderNonce() if err != nil { @@ -300,20 +302,26 @@ func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, // EncapContent is the inner pkcsPKIEnvelope (EnvelopedData). Parse + // decrypt with the RA key. if len(sd.EncapContent) == 0 { - return nil, "", false + return nil, "", "", false } env, err := pkcs7.ParseEnvelopedData(sd.EncapContent) if err != nil { - return nil, "", false + return nil, "", "", false } csrDER, err := env.Decrypt(h.raKey, h.raCert) if err != nil { - return nil, "", false + 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 + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + return nil, "", "", false } + // Extract the challengePassword attribute (RFC 2985 §5.4.1). Empty + // when missing; the service-layer gate then refuses with 'invalid + // challenge password' (correct behavior for clients that omit the + // auth attribute). + challengePassword := extractChallengePasswordFromCSR(csr) csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})) envelope := &domain.SCEPRequestEnvelope{ MessageType: mt, @@ -321,11 +329,56 @@ func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, SenderNonce: nonce, SignerCert: si.SignerCert.Raw, } - return envelope, csrPEM, true + return envelope, csrPEM, challengePassword, true +} + +// extractChallengePasswordFromCSR walks the parsed CSR's attributes for +// the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7). +// Returns empty string when missing. +// +//nolint:staticcheck // SA1019: RFC 2985 challengePassword has no non-deprecated stdlib API; mirrors extractCSRFields. +func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string { + oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7} + for _, attr := range csr.Attributes { + if attr.Type.Equal(oidChallengePassword) { + if len(attr.Value) > 0 && len(attr.Value[0]) > 0 { + if pwd, ok := attr.Value[0][0].Value.(string); ok { + return pwd + } + } + } + } + return "" +} + +// writeCertRepPKIMessage builds and writes a SCEP CertRep PKIMessage as +// the response to a PKIOperation request that was successfully parsed +// via the RFC 8894 path. +// +// SCEP RFC 8894 + Intune master bundle Phase 3.2. +// +// Both success AND failure responses go through here — RFC 8894 §3.3 +// mandates a PKIMessage response on every PKIOperation request, with +// pkiStatus + (on failure) failInfo signaling the outcome to the client. +// +// On failure to BUILD the response (a programmer / config bug — e.g. a +// device cert that's not RSA), we return HTTP 500 rather than try to +// construct a fallback PKIMessage that might re-trigger the same bug. +// Operators see a clear failure log + the request fails loud, which is +// preferable to silently emitting a half-built response. +func (h SCEPHandler) writeCertRepPKIMessage(w http.ResponseWriter, r *http.Request, req *domain.SCEPRequestEnvelope, resp *domain.SCEPResponseEnvelope) { + pkiMessageDER, err := pkcs7.BuildCertRepPKIMessage(req, resp, h.raCert, h.raKey) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to build CertRep PKIMessage: %v", err), middleware.GetRequestID(r.Context())) + return + } + w.Header().Set("Content-Type", "application/x-pki-message") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(pkiMessageDER) } // silence unused-import warning if some narrow build excludes the path -// where crypto.PrivateKey is used (the RA key field below). +// where crypto.PrivateKey is used (the RA key field above). var _ crypto.PrivateKey = (*interface{})(nil) // writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER). diff --git a/internal/pkcs7/certrep.go b/internal/pkcs7/certrep.go new file mode 100644 index 0000000..b9e81b4 --- /dev/null +++ b/internal/pkcs7/certrep.go @@ -0,0 +1,458 @@ +// CertRep PKIMessage response builder for SCEP. +// +// RFC 8894 §3.3.2 (Certificate Response Message Format) + +// RFC 5652 §5 (SignedData) + RFC 5652 §6 (EnvelopedData). +// +// SCEP RFC 8894 + Intune master bundle Phase 3.1. +// +// Builds the wire shape (cited from RFC 8894 §3.3.2 + §3.2): +// +// ContentInfo { +// contentType: signedData (1.2.840.113549.1.7.2) +// content: SignedData { +// version: 1 +// digestAlgorithms: [SHA-256] +// encapContentInfo: { +// contentType: data (1.2.840.113549.1.7.1) +// content: EnvelopedData { -- on SUCCESS only +// version: 0 +// recipientInfos: [{ +// ktri: { +// rid: IssuerAndSerialNumber of clientCert +// keyEncryptionAlgorithm: rsaEncryption +// encryptedKey: AES-256-CBC key encrypted to clientCert.PublicKey +// } +// }] +// encryptedContentInfo: { +// contentType: pkcs7-data +// contentEncryptionAlgorithm: aes-256-cbc +// encryptedContent: AES-CBC-encrypted PKCS#7 certs-only with the issued cert + chain +// } +// } +// } +// certificates: [raCert] +// signerInfos: [{ +// sid: IssuerAndSerialNumber of raCert +// digestAlgorithm: SHA-256 +// signedAttrs: [ +// contentType: data +// messageDigest: SHA-256(encapContentInfo.content) +// messageType: "3" (CertRep) +// pkiStatus: "0" | "2" | "3" +// transactionID: +// recipientNonce: +// senderNonce: +// failInfo: +// ] +// signatureAlgorithm: rsaWithSHA256 | ecdsaWithSHA256 +// signature: raKey signs DER(SET OF signedAttrs) +// }] +// } +// } +// +// On FAILURE, encapContentInfo.content is empty (no EnvelopedData), and the +// failInfo signed attribute is populated. +// +// On PENDING (deferred-issuance flow, not used in v1), encapContentInfo.content +// is empty, and the response carries a transactionID the client polls with +// GetCertInitial. + +package pkcs7 + +import ( + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "math/big" + + "github.com/shankar0123/certctl/internal/domain" +) + +// BuildCertRepPKIMessage constructs the SCEP CertRep response PKIMessage. +// +// Inputs: +// - req: the parsed inbound envelope (provides transactionID, senderNonce +// to echo, and SignerCert — the device's transient cert we encrypt the +// CertRep EnvelopedData TO). +// - resp: the service-layer outcome (Status + FailInfo + Result). +// - raCert + raKey: the RA pair the server signs the SignedData with +// (loaded from CERTCTL_SCEP_RA_*; same pair used to decrypt the inbound +// EnvelopedData in Phase 2). +// +// Critical correctness points (cited as comments in code): +// - The CertRep encrypts the issued cert chain to the DEVICE's transient +// signing cert (req.SignerCert), NOT the RA cert. The response goes +// back to the device, encrypted with its public key. +// - AES-256-CBC + random 16-byte IV per response. No reuse. +// - senderNonce must be fresh per response (crypto/rand 16 bytes). +// - recipientNonce + transactionID echoed verbatim from the request. +// - The signature is over DER(SET OF signedAttrs) — the canonical CMS +// quirk per RFC 5652 §5.4. The wire form uses [0] IMPLICIT but the +// signature is computed over the SET OF re-serialisation. Easy +// mistake; pinned by the round-trip test. +func BuildCertRepPKIMessage(req *domain.SCEPRequestEnvelope, resp *domain.SCEPResponseEnvelope, raCert *x509.Certificate, raKey crypto.PrivateKey) ([]byte, error) { + if req == nil || resp == nil { + return nil, fmt.Errorf("certRep: req and resp required") + } + if raCert == nil || raKey == nil { + return nil, fmt.Errorf("certRep: RA cert/key required") + } + + // 1. Build the encapContent — for SUCCESS, this is an EnvelopedData + // wrapping the issued cert chain encrypted to req.SignerCert. For + // FAILURE / PENDING, encapContent is empty. + var encapContent []byte + if resp.Status == domain.SCEPStatusSuccess && resp.Result != nil { + // Parse the device's transient signing cert (recipient). + if len(req.SignerCert) == 0 { + return nil, fmt.Errorf("certRep: req.SignerCert required for SUCCESS response (need device pubkey to encrypt response)") + } + clientCert, err := x509.ParseCertificate(req.SignerCert) + if err != nil { + return nil, fmt.Errorf("certRep: parse req.SignerCert: %w", err) + } + clientRSAPub, ok := clientCert.PublicKey.(*rsa.PublicKey) + if !ok { + // SCEP requires RSA on the client side for keyTrans (RFC 8894 + // §3.5.2 advertises RSA only for the client-encryption side). + return nil, fmt.Errorf("certRep: device transient cert must have RSA public key (got %T)", clientCert.PublicKey) + } + + // Build the certs-only PKCS#7 carrying the issued cert + chain + // (the inner content the EnvelopedData encrypts). + issuedDER, err := PEMToDERChain(resp.Result.CertPEM) + if err != nil { + return nil, fmt.Errorf("certRep: parse issued cert PEM: %w", err) + } + var allDER [][]byte + allDER = append(allDER, issuedDER...) + if resp.Result.ChainPEM != "" { + chainDER, err := PEMToDERChain(resp.Result.ChainPEM) + if err == nil { + allDER = append(allDER, chainDER...) + } + } + certsOnly, err := BuildCertsOnlyPKCS7(allDER) + if err != nil { + return nil, fmt.Errorf("certRep: build certs-only PKCS#7: %w", err) + } + + // Build the EnvelopedData encrypting certsOnly to clientRSAPub + // using a fresh AES-256-CBC key + IV. + encapContent, err = buildEnvelopedDataAES256(clientCert, clientRSAPub, certsOnly) + if err != nil { + return nil, fmt.Errorf("certRep: build EnvelopedData: %w", err) + } + } + + // 2. Compute messageDigest = SHA-256(encapContent). When encapContent + // is empty (FAILURE/PENDING), the messageDigest is over the empty + // byte slice — same hash for both legs, RFC 5652 §11.2 doesn't + // require a non-empty content. + contentDigest := sha256.Sum256(encapContent) + + // 3. Generate a fresh 16-byte senderNonce. crypto/rand source; never + // reused across responses (RFC 8894 §3.2.1.4.5 — replay defense). + senderNonce := make([]byte, 16) + if _, err := rand.Read(senderNonce); err != nil { + return nil, fmt.Errorf("certRep: senderNonce rand.Read: %w", err) + } + + // 4. Build the auth-attrs SET-OF body (the bytes inside [0] IMPLICIT). + // Order matches micromdm/scep for byte-level wire-format diffing + // (DER SET-OF normalises order anyway, but matching the reference + // implementation makes audit + manual inspection easier). + authAttrs := buildCertRepAuthAttrs( + contentDigest[:], + resp.Status, + resp.FailInfo, + resp.TransactionID, + senderNonce, + resp.RecipientNonce, + ) + + // 5. Sign the SET OF Attribute (re-serialised with the SET tag, not + // the [0] IMPLICIT wrapper — RFC 5652 §5.4 quirk). + signedAttrsForSig := ASN1Wrap(0x31, authAttrs) + sig, sigAlgOID, err := signCertRep(raKey, signedAttrsForSig) + if err != nil { + return nil, fmt.Errorf("certRep: sign auth-attrs: %w", err) + } + + // 6. Build the SignerInfo SEQUENCE. + siBytes, err := buildSignerInfoCertRep(raCert, sig, sigAlgOID, authAttrs) + if err != nil { + return nil, fmt.Errorf("certRep: build SignerInfo: %w", err) + } + + // 7. Build encapContentInfo SEQUENCE { OID data, [0] EXPLICIT OCTET + // STRING content }. + encapBytes := buildEncapContentInfo(encapContent) + + // 8. certificates [0] IMPLICIT SET OF Certificate carrying the RA cert + // so the device can verify the signature. + certsBytes := ASN1Wrap(0xa0, raCert.Raw) + + // 9. digestAlgorithms SET OF AlgorithmIdentifier (one entry: SHA-256). + digestAlg := pkix.AlgorithmIdentifier{Algorithm: OIDSHA256, Parameters: asn1.NullRawValue} + digestAlgBytes, err := asn1.Marshal(digestAlg) + if err != nil { + return nil, fmt.Errorf("certRep: marshal digestAlg: %w", err) + } + digestAlgsBytes := ASN1Wrap(0x31, digestAlgBytes) + + // 10. signerInfos SET OF SignerInfo (one entry — the RA's signature). + signerInfosBytes := ASN1Wrap(0x31, siBytes) + + // 11. Assemble SignedData SEQUENCE. + sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) // INTEGER version=1 + sdBody = append(sdBody, digestAlgsBytes...) + sdBody = append(sdBody, encapBytes...) + sdBody = append(sdBody, certsBytes...) + sdBody = append(sdBody, signerInfosBytes...) + sdSeq := ASN1Wrap(0x30, sdBody) + + // 12. Wrap as ContentInfo SEQUENCE { OID signedData, [0] EXPLICIT + // SignedData }. + contentField := ASN1Wrap(0xa0, sdSeq) + oidSignedDataDER := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02} + ciBody := append([]byte{}, oidSignedDataDER...) + ciBody = append(ciBody, contentField...) + return ASN1Wrap(0x30, ciBody), nil +} + +// buildCertRepAuthAttrs builds the SET-OF body for the CertRep +// signedAttributes. Matches the order micromdm/scep emits (the DER SET-OF +// normalisation makes order irrelevant for the signature, but matching +// the reference implementation makes wire-diff debugging easier). +func buildCertRepAuthAttrs(msgDigest []byte, status domain.SCEPPKIStatus, failInfo domain.SCEPFailInfo, transactionID string, senderNonce, recipientNonce []byte) []byte { + var out []byte + // contentType: SET { OID data } + out = append(out, attrSeqRaw(OIDContentType, ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...) + // messageDigest: SET { OCTET STRING } + out = append(out, attrSeqRaw(OIDMessageDigest, ASN1Wrap(0x04, msgDigest))...) + // SCEP messageType: SET { PrintableString "3" — CertRep } + out = append(out, attrSeqRaw(OIDSCEPMessageType, ASN1Wrap(0x13, []byte{'3'}))...) + // SCEP pkiStatus: SET { PrintableString status code } + out = append(out, attrSeqRaw(OIDSCEPPKIStatus, ASN1Wrap(0x13, []byte(status)))...) + // SCEP transactionID: SET { PrintableString } + out = append(out, attrSeqRaw(OIDSCEPTransactionID, ASN1Wrap(0x13, []byte(transactionID)))...) + // SCEP senderNonce (server's fresh nonce): SET { OCTET STRING } + out = append(out, attrSeqRaw(OIDSCEPSenderNonce, ASN1Wrap(0x04, senderNonce))...) + // SCEP recipientNonce (echo of client's senderNonce): SET { OCTET STRING } + if len(recipientNonce) > 0 { + out = append(out, attrSeqRaw(OIDSCEPRecipientNonce, ASN1Wrap(0x04, recipientNonce))...) + } + // SCEP failInfo: ONLY when status == failure (RFC 8894 §3.2.1.4.4) + if status == domain.SCEPStatusFailure { + out = append(out, attrSeqRaw(OIDSCEPFailInfo, ASN1Wrap(0x13, []byte(failInfo)))...) + } + return out +} + +// attrSeqRaw builds one Attribute SEQUENCE: SEQUENCE { OID, SET OF value }. +// `value` is one already-encoded TLV (e.g. an OCTET STRING or PrintableString); +// attrSeqRaw wraps it in a SET, prefixes the OID, and SEQUENCE-wraps. +func attrSeqRaw(oid asn1.ObjectIdentifier, value []byte) []byte { + oidBytes, err := asn1.Marshal(oid) + if err != nil { + // asn1.Marshal of a hardcoded OID never fails; a panic here is + // a programmer error worth surfacing immediately. + panic("certRep: marshal OID: " + err.Error()) + } + setOfValue := ASN1Wrap(0x31, value) + body := append([]byte{}, oidBytes...) + body = append(body, setOfValue...) + return ASN1Wrap(0x30, body) +} + +// buildSignerInfoCertRep assembles the SignerInfo for the CertRep response. +// The signature is already computed; this just packages everything into the +// SignerInfo SEQUENCE. +func buildSignerInfoCertRep(raCert *x509.Certificate, sig []byte, sigAlgOID asn1.ObjectIdentifier, authAttrsSetBody []byte) ([]byte, error) { + versionBytes := []byte{0x02, 0x01, 0x01} // INTEGER version=1 + + // SID = IssuerAndSerialNumber: SEQUENCE { Issuer (RDN), SerialNumber } + serialDER, err := asn1.Marshal(raCert.SerialNumber) + if err != nil { + return nil, fmt.Errorf("marshal RA serial: %w", err) + } + sidBody := append([]byte{}, raCert.RawIssuer...) + sidBody = append(sidBody, serialDER...) + sidBytes := ASN1Wrap(0x30, sidBody) + + digestAlg := pkix.AlgorithmIdentifier{Algorithm: OIDSHA256, Parameters: asn1.NullRawValue} + digestAlgBytes, err := asn1.Marshal(digestAlg) + if err != nil { + return nil, fmt.Errorf("marshal digestAlg: %w", err) + } + + signedAttrsImplicitBytes := ASN1Wrap(0xa0, authAttrsSetBody) // [0] IMPLICIT SET OF + + sigAlg := pkix.AlgorithmIdentifier{Algorithm: sigAlgOID} + if sigAlgOID.Equal(OIDRSAWithSHA256) { + sigAlg.Parameters = asn1.NullRawValue + } + sigAlgBytes, err := asn1.Marshal(sigAlg) + if err != nil { + return nil, fmt.Errorf("marshal sigAlg: %w", err) + } + + sigOctetBytes := ASN1Wrap(0x04, sig) // OCTET STRING + + siBody := append([]byte{}, versionBytes...) + siBody = append(siBody, sidBytes...) + siBody = append(siBody, digestAlgBytes...) + siBody = append(siBody, signedAttrsImplicitBytes...) + siBody = append(siBody, sigAlgBytes...) + siBody = append(siBody, sigOctetBytes...) + return ASN1Wrap(0x30, siBody), nil +} + +// signCertRep signs the SET-OF-encoded auth-attrs with the RA key, returning +// the signature bytes and the matching signature-algorithm OID. +func signCertRep(raKey crypto.PrivateKey, signedAttrsForSig []byte) ([]byte, asn1.ObjectIdentifier, error) { + digest := sha256.Sum256(signedAttrsForSig) + switch k := raKey.(type) { + case *rsa.PrivateKey: + sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, digest[:]) + if err != nil { + return nil, nil, fmt.Errorf("rsa sign: %w", err) + } + return sig, OIDRSAWithSHA256, nil + case *ecdsa.PrivateKey: + sig, err := ecdsa.SignASN1(rand.Reader, k, digest[:]) + if err != nil { + return nil, nil, fmt.Errorf("ecdsa sign: %w", err) + } + return sig, OIDECDSAWithSHA256, nil + default: + return nil, nil, fmt.Errorf("unsupported RA key type %T (want *rsa.PrivateKey or *ecdsa.PrivateKey)", raKey) + } +} + +// buildEncapContentInfo builds SEQUENCE { OID data, [0] EXPLICIT OCTET STRING content }. +// content is empty for FAILURE/PENDING responses; the [0] EXPLICIT wrapper is +// omitted entirely in that case (RFC 5652 §5.2 — the OPTIONAL field is just +// absent rather than carrying an empty OCTET STRING). +func buildEncapContentInfo(content []byte) []byte { + oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + body := append([]byte{}, oidDataBytes...) + if len(content) > 0 { + octetBytes := ASN1Wrap(0x04, content) + explicitWrapper := ASN1Wrap(0xa0, octetBytes) + body = append(body, explicitWrapper...) + } + return ASN1Wrap(0x30, body) +} + +// buildEnvelopedDataAES256 builds an EnvelopedData encrypting `plaintext` +// to `recipientCert`'s public key (RSA). Uses AES-256-CBC + random 16-byte IV +// + PKCS#7 padding. Returns the EnvelopedData DER bytes ready to embed as +// the encapContent of a SignedData. +func buildEnvelopedDataAES256(recipientCert *x509.Certificate, recipientPub *rsa.PublicKey, plaintext []byte) ([]byte, error) { + // 1. Generate random AES-256 key + IV. + symKey := make([]byte, 32) + if _, err := rand.Read(symKey); err != nil { + return nil, fmt.Errorf("rand symKey: %w", err) + } + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return nil, fmt.Errorf("rand iv: %w", err) + } + + // 2. PKCS#7-pad plaintext to AES block boundary. + bs := aes.BlockSize + padLen := bs - len(plaintext)%bs + padded := make([]byte, 0, len(plaintext)+padLen) + padded = append(padded, plaintext...) + for i := 0; i < padLen; i++ { + padded = append(padded, byte(padLen)) + } + + // 3. AES-CBC encrypt. + block, err := aes.NewCipher(symKey) + if err != nil { + return nil, fmt.Errorf("aes.NewCipher: %w", err) + } + enc := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + enc.CryptBlocks(ciphertext, padded) + + // 4. RSA PKCS#1 v1.5 encrypt the AES key with recipientPub. + encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, recipientPub, symKey) + if err != nil { + return nil, fmt.Errorf("rsa encrypt: %w", err) + } + + // 5. Build IssuerAndSerialNumber identifying the recipient. + serialDER, err := asn1.Marshal(recipientCert.SerialNumber) + if err != nil { + return nil, fmt.Errorf("marshal recipient serial: %w", err) + } + risBody := append([]byte{}, recipientCert.RawIssuer...) + risBody = append(risBody, serialDER...) + risBytes := ASN1Wrap(0x30, risBody) + + // 6. Build KeyTransRecipientInfo SEQUENCE. + keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: OIDRSAEncryption, Parameters: asn1.NullRawValue} + keyEncAlgBytes, err := asn1.Marshal(keyEncAlg) + if err != nil { + return nil, fmt.Errorf("marshal keyEncAlg: %w", err) + } + encryptedKeyBytes := ASN1Wrap(0x04, encryptedKey) + + ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) // INTEGER version=0 + ktriBody = append(ktriBody, risBytes...) + ktriBody = append(ktriBody, keyEncAlgBytes...) + ktriBody = append(ktriBody, encryptedKeyBytes...) + ktriBytes := ASN1Wrap(0x30, ktriBody) + + // 7. recipientInfos SET OF RecipientInfo (one entry). + recipientInfosBytes := ASN1Wrap(0x31, ktriBytes) + + // 8. Build the AlgorithmIdentifier with the IV as parameters + // (RFC 3565 §2.3). + ivOctet := ASN1Wrap(0x04, iv) + contentAlg := pkix.AlgorithmIdentifier{ + Algorithm: OIDAES256CBC, + Parameters: asn1.RawValue{FullBytes: ivOctet}, + } + contentAlgBytes, err := asn1.Marshal(contentAlg) + if err != nil { + return nil, fmt.Errorf("marshal contentAlg: %w", err) + } + + // 9. Build EncryptedContentInfo SEQUENCE. + // encryptedContent is [0] IMPLICIT OCTET STRING — the OCTET STRING + // tag is replaced by the [0] context-specific tag, but the content + // bytes are written directly without the inner OCTET STRING tag. + encContentField := append([]byte{}, ASN1Wrap(0x80, ciphertext)...) // [0] IMPLICIT primitive + oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + eciBody := append([]byte{}, oidDataBytes...) + eciBody = append(eciBody, contentAlgBytes...) + eciBody = append(eciBody, encContentField...) + eciBytes := ASN1Wrap(0x30, eciBody) + + // 10. Assemble EnvelopedData SEQUENCE. + envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) // INTEGER version=0 + envBody = append(envBody, recipientInfosBytes...) + envBody = append(envBody, eciBytes...) + return ASN1Wrap(0x30, envBody), nil +} + +// silence unused-import / cross-file linker warnings for big.Int + pem on +// builds that exclude certain code paths. +var ( + _ = (*big.Int)(nil) + _ = (*pem.Block)(nil) +) diff --git a/internal/pkcs7/certrep_fuzz_test.go b/internal/pkcs7/certrep_fuzz_test.go new file mode 100644 index 0000000..3d56d6f --- /dev/null +++ b/internal/pkcs7/certrep_fuzz_test.go @@ -0,0 +1,160 @@ +package pkcs7 + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// FuzzBuildCertRepPKIMessage stresses the CertRep builder with attacker- +// controlled transactionID + nonce + signerCert bytes. The invariants are: +// 1. No panic for arbitrary inputs. +// 2. When build succeeds AND status is success, the output parses back +// via ParseSignedData (round-trip soundness — the prompt's required +// fuzz invariant). +// +// SCEP RFC 8894 + Intune master bundle Phase 3.3. +// +// The fuzzer holds the RA pair constant (one-time setup) and lets the +// fuzz engine vary the unstable inputs. Errors from BuildCertRepPKIMessage +// are expected for malformed signerCert bytes; only a panic = bug. + +func FuzzBuildCertRepPKIMessage(f *testing.F) { + // Seed: empty everything (should error cleanly via the nil-args gate). + f.Add("", []byte{}, []byte{}) + // Seed: minimal inputs that exercise the failure-path code (no + // SignerCert needed because Status=Failure short-circuits the + // EnvelopedData build). + f.Add("txn-1", make([]byte, 16), []byte{}) + + // One-time setup: RA pair stays constant across fuzz iterations. + raKey, raCert := genTestRSARAFuzz() + if raKey == nil { + f.Skip("test RA pair generation failed; environment lacks crypto/rand?") + } + + f.Fuzz(func(t *testing.T, transactionID string, senderNonce []byte, signerCert []byte) { + req := &domain.SCEPRequestEnvelope{ + MessageType: domain.SCEPMessageTypePKCSReq, + TransactionID: transactionID, + SenderNonce: senderNonce, + SignerCert: signerCert, + } + // Failure path: never needs SignerCert. No panic, no requirement + // on output (the failure shape is correct by construction). + respFail := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadRequest, + TransactionID: transactionID, + RecipientNonce: senderNonce, + } + _, _ = BuildCertRepPKIMessage(req, respFail, raCert, raKey) + + // Success path with arbitrary signerCert bytes: most inputs will + // fail to parse as a real cert; that's fine, BuildCertRep returns + // an error rather than panicking. When build succeeds (rare for + // random bytes), assert the output parses back. + respSuccess := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusSuccess, + TransactionID: transactionID, + RecipientNonce: senderNonce, + Result: &domain.SCEPEnrollResult{ + CertPEM: minimalIssuedCertPEMFuzz(raKey), + }, + } + out, err := BuildCertRepPKIMessage(req, respSuccess, raCert, raKey) + if err != nil { + return // expected for arbitrary signerCert; no panic = ok + } + // Build succeeded — verify round-trip soundness. + sd, err := ParseSignedData(out) + if err != nil { + t.Errorf("BuildCertRepPKIMessage produced output that fails ParseSignedData: %v", err) + return + } + if len(sd.SignerInfos) == 0 { + t.Errorf("BuildCertRepPKIMessage produced output with no signerInfos") + } + }) +} + +// genTestRSARAFuzz materialises a one-time RA pair for the fuzz seed +// setup. Mirrors genTestRSARA from the round-trip tests but doesn't +// take *testing.T (called from f.Fuzz setup, not a test body). +func genTestRSARAFuzz() (*rsa.PrivateKey, *x509.Certificate) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "fuzz-ra"}, + Issuer: pkix.Name{CommonName: "fuzz-ra"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(30 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, nil + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, nil + } + return key, cert +} + +// minimalIssuedCertPEMFuzz returns a tiny self-signed PEM cert reusing +// the RA key. Avoids per-fuzz-iter rsa.GenerateKey overhead (which would +// dominate the fuzz throughput). +func minimalIssuedCertPEMFuzz(key *rsa.PrivateKey) string { + // We construct on demand since the issued cert template doesn't + // matter beyond being a parseable PEM-wrapped DER cert. + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "fuzz-issued"}, + Issuer: pkix.Name{CommonName: "fuzz-issued"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return "" + } + return "-----BEGIN CERTIFICATE-----\n" + + derToBase64Fuzz(der) + + "-----END CERTIFICATE-----\n" +} + +func derToBase64Fuzz(der []byte) string { + const enc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + var out []byte + pad := (3 - len(der)%3) % 3 + padded := append(append([]byte{}, der...), make([]byte, pad)...) + for i := 0; i < len(padded); i += 3 { + v := uint32(padded[i])<<16 | uint32(padded[i+1])<<8 | uint32(padded[i+2]) + out = append(out, enc[v>>18&0x3f], enc[v>>12&0x3f], enc[v>>6&0x3f], enc[v&0x3f]) + } + for i := 0; i < pad; i++ { + out[len(out)-1-i] = '=' + } + // Wrap at 64 chars per PEM convention. + var wrapped []byte + for i := 0; i < len(out); i += 64 { + end := i + 64 + if end > len(out) { + end = len(out) + } + wrapped = append(wrapped, out[i:end]...) + wrapped = append(wrapped, '\n') + } + return string(wrapped) +} diff --git a/internal/pkcs7/certrep_test.go b/internal/pkcs7/certrep_test.go new file mode 100644 index 0000000..50c4b3f --- /dev/null +++ b/internal/pkcs7/certrep_test.go @@ -0,0 +1,247 @@ +package pkcs7 + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "strings" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// SCEP RFC 8894 Phase 3.1: round-trip tests for BuildCertRepPKIMessage. +// +// Each test materialises real RA + device pairs, calls +// BuildCertRepPKIMessage with success/failure/pending shapes, then +// parses the result back via ParseSignedData + EnvelopedData.Decrypt +// to assert the wire bytes are recoverable. This catches drift between +// the build-side encoding and the parse-side decoding without needing +// a real SCEP client. + +func TestBuildCertRepPKIMessage_Success_RoundTrip(t *testing.T) { + raKey, raCert := genTestRSARA(t) + deviceKey, deviceCert := genTestRSARA(t) // device transient cert (RSA pub for KTRI) + + // Synthesise an issued cert (the thing we want the device to receive). + issuedPEM := selfSignedCertPEM(t, "issued.example.com") + + req := &domain.SCEPRequestEnvelope{ + MessageType: domain.SCEPMessageTypePKCSReq, + TransactionID: "txn-roundtrip-success", + SenderNonce: []byte("0123456789abcdef"), + SignerCert: deviceCert.Raw, + } + resp := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusSuccess, + TransactionID: req.TransactionID, + RecipientNonce: req.SenderNonce, + Result: &domain.SCEPEnrollResult{ + CertPEM: issuedPEM, + }, + } + + pkiMessage, err := BuildCertRepPKIMessage(req, resp, raCert, raKey) + if err != nil { + t.Fatalf("BuildCertRepPKIMessage: %v", err) + } + + // Parse it back. + sd, err := ParseSignedData(pkiMessage) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if len(sd.SignerInfos) != 1 { + t.Fatalf("len(SignerInfos) = %d, want 1", len(sd.SignerInfos)) + } + si := sd.SignerInfos[0] + if err := si.VerifySignature(); err != nil { + t.Fatalf("VerifySignature(RA signature on CertRep): %v", err) + } + + // Auth-attr round-trip. + mt, _ := si.GetMessageType() + if mt != domain.SCEPMessageTypeCertRep { + t.Errorf("messageType = %d, want CertRep (3)", mt) + } + tid, _ := si.GetTransactionID() + if tid != req.TransactionID { + t.Errorf("transactionID = %q, want %q", tid, req.TransactionID) + } + // recipientNonce echoes the request's senderNonce. + rn, _ := si.attrOctetString(OIDSCEPRecipientNonce) + if !bytes.Equal(rn, req.SenderNonce) { + t.Errorf("recipientNonce = %q, want %q", rn, req.SenderNonce) + } + // senderNonce is server-generated; verify it's 16 bytes. + sn, _ := si.GetSenderNonce() + if len(sn) != 16 { + t.Errorf("senderNonce len = %d, want 16", len(sn)) + } + // pkiStatus = "0" (Success). + status, _ := si.attrPrintableString(OIDSCEPPKIStatus) + if status != string(domain.SCEPStatusSuccess) { + t.Errorf("pkiStatus = %q, want %q", status, domain.SCEPStatusSuccess) + } + + // EncapContent should be a parseable EnvelopedData. Decrypt it with + // the device's RSA key and pull out the inner certs-only PKCS#7; + // confirm the issued cert is in the chain. + if len(sd.EncapContent) == 0 { + t.Fatal("encapContent empty for SUCCESS response") + } + env, err := ParseEnvelopedData(sd.EncapContent) + if err != nil { + t.Fatalf("ParseEnvelopedData(encapContent): %v", err) + } + innerCertsOnly, err := env.Decrypt(deviceKey, deviceCert) + if err != nil { + t.Fatalf("EnvelopedData.Decrypt with device key: %v", err) + } + // innerCertsOnly is a degenerate PKCS#7 SignedData carrying the + // issued cert(s). Use parseSignedDataForCSR's SignedData parsing + // pattern via ParseSignedData to recover the cert. + innerSD, err := ParseSignedData(innerCertsOnly) + if err != nil { + t.Fatalf("ParseSignedData(innerCertsOnly): %v", err) + } + if len(innerSD.Certificates) == 0 { + t.Fatal("inner certs-only PKCS#7 carries no certs") + } + if innerSD.Certificates[0].Subject.CommonName != "issued.example.com" { + t.Errorf("issued cert CN = %q, want issued.example.com", innerSD.Certificates[0].Subject.CommonName) + } +} + +func TestBuildCertRepPKIMessage_Failure_NoEncapContent(t *testing.T) { + raKey, raCert := genTestRSARA(t) + _, deviceCert := genTestRSARA(t) + + req := &domain.SCEPRequestEnvelope{ + MessageType: domain.SCEPMessageTypePKCSReq, + TransactionID: "txn-roundtrip-failure", + SenderNonce: []byte("nonce-failure-12"), + SignerCert: deviceCert.Raw, + } + resp := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadMessageCheck, + TransactionID: req.TransactionID, + RecipientNonce: req.SenderNonce, + } + + pkiMessage, err := BuildCertRepPKIMessage(req, resp, raCert, raKey) + if err != nil { + t.Fatalf("BuildCertRepPKIMessage(failure): %v", err) + } + sd, err := ParseSignedData(pkiMessage) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + si := sd.SignerInfos[0] + if err := si.VerifySignature(); err != nil { + t.Fatalf("VerifySignature(failure response): %v", err) + } + // pkiStatus = "2", failInfo = "1" (BadMessageCheck). + status, _ := si.attrPrintableString(OIDSCEPPKIStatus) + if status != string(domain.SCEPStatusFailure) { + t.Errorf("pkiStatus = %q, want %q", status, domain.SCEPStatusFailure) + } + failInfo, _ := si.attrPrintableString(OIDSCEPFailInfo) + if failInfo != string(domain.SCEPFailBadMessageCheck) { + t.Errorf("failInfo = %q, want %q", failInfo, domain.SCEPFailBadMessageCheck) + } + // encapContent is empty for failure. + if len(sd.EncapContent) != 0 { + t.Errorf("encapContent non-empty for FAILURE: %d bytes", len(sd.EncapContent)) + } +} + +func TestBuildCertRepPKIMessage_FreshSenderNonceEachCall(t *testing.T) { + raKey, raCert := genTestRSARA(t) + _, deviceCert := genTestRSARA(t) + req := &domain.SCEPRequestEnvelope{ + TransactionID: "txn-nonce", SenderNonce: []byte("0123456789abcdef"), + SignerCert: deviceCert.Raw, + } + resp := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadAlg, + TransactionID: req.TransactionID, RecipientNonce: req.SenderNonce, + } + a, _ := BuildCertRepPKIMessage(req, resp, raCert, raKey) + b, _ := BuildCertRepPKIMessage(req, resp, raCert, raKey) + sdA, _ := ParseSignedData(a) + sdB, _ := ParseSignedData(b) + nonceA, _ := sdA.SignerInfos[0].GetSenderNonce() + nonceB, _ := sdB.SignerInfos[0].GetSenderNonce() + if bytes.Equal(nonceA, nonceB) { + t.Errorf("senderNonce must be fresh per response, got identical: %x", nonceA) + } +} + +func TestBuildCertRepPKIMessage_RejectsNonRSADeviceCert(t *testing.T) { + raKey, raCert := genTestRSARA(t) + _, deviceCert := genTestECDSASigner(t) // device cert with ECDSA pubkey — RSA required for KTRI + + req := &domain.SCEPRequestEnvelope{ + TransactionID: "txn-ec-device", SenderNonce: []byte("nonce-1234567890"), + SignerCert: deviceCert.Raw, + } + resp := &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusSuccess, + TransactionID: req.TransactionID, RecipientNonce: req.SenderNonce, + Result: &domain.SCEPEnrollResult{CertPEM: selfSignedCertPEM(t, "ec-issued.example.com")}, + } + _, err := BuildCertRepPKIMessage(req, resp, raCert, raKey) + if err == nil { + t.Fatal("BuildCertRepPKIMessage with ECDSA device cert: want error, got nil") + } + if !strings.Contains(err.Error(), "RSA public key") { + t.Errorf("error should mention RSA, got: %v", err) + } +} + +func TestBuildCertRepPKIMessage_NilArgs_Refuses(t *testing.T) { + if _, err := BuildCertRepPKIMessage(nil, nil, nil, nil); err == nil { + t.Error("BuildCertRepPKIMessage(nil,nil,nil,nil) = nil, want error") + } +} + +// --- helpers ------------------------------------------------------------- + +// selfSignedCertPEM creates a fresh RSA self-signed cert with the given CN +// and returns it PEM-encoded — used as the 'issued' cert in success-path +// CertRep round-trip tests. +func selfSignedCertPEM(t *testing.T, cn string) string { + t.Helper() + key, err := rsa.GenerateKey(testRand(), 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(0xCAFE), + Subject: pkix.Name{CommonName: cn}, + Issuer: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(30 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(testRand(), tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +// testRand returns the system random source. Wrapped here so tests can be +// adapted to a deterministic source if golden-file tests need it later. +func testRand() io.Reader { return rand.Reader } + +func nowMinus1Hour() time.Time { return time.Now().Add(-time.Hour) } +func nowPlus30Days() time.Time { return time.Now().Add(30 * 24 * time.Hour) } diff --git a/internal/pkcs7/signedinfo.go b/internal/pkcs7/signedinfo.go index 134281d..247df4c 100644 --- a/internal/pkcs7/signedinfo.go +++ b/internal/pkcs7/signedinfo.go @@ -212,9 +212,13 @@ func ParseSignedData(der []byte) (*SignedData, error) { } out.SignerInfos = append(out.SignerInfos, si) } - if len(out.SignerInfos) == 0 { - return nil, fmt.Errorf("signedData: no parseable signerInfos") - } + // Empty signerInfos is valid for the degenerate certs-only PKCS#7 + // form (RFC 8894 §3.5.1 GetCACert response, RFC 7030 EST cacerts) — + // a SignedData with only the certificates field populated and no + // signers. The caller of ParseSignedData decides whether the lack + // of signers is an error in their context (the SCEP RFC 8894 + // PKIMessage handler treats it as a fall-through to the MVP path; + // the CertRep certs-only inner content treats it as expected). return out, nil }