// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // 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/certctl-io/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) )