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