mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 23:28:57 +00:00
f5a20a6be2
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.
483 lines
18 KiB
Go
483 lines
18 KiB
Go
// SignerInfo parser + signature verifier for SCEP PKIMessage.
|
|
//
|
|
// RFC 5652 §5 (SignedData) + RFC 8894 §3.2.1 (SCEP authenticatedAttributes).
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 2.2.
|
|
//
|
|
// The wire shape this parses (cited from RFC 5652 §5.3):
|
|
//
|
|
// SignedData ::= SEQUENCE {
|
|
// version INTEGER,
|
|
// digestAlgorithms SET OF AlgorithmIdentifier,
|
|
// encapContentInfo EncapsulatedContentInfo,
|
|
// certificates [0] IMPLICIT SET OF CertificateChoices OPTIONAL,
|
|
// crls [1] IMPLICIT SET OF RevocationInfoChoices OPTIONAL,
|
|
// signerInfos SET OF SignerInfo -- the field this file targets
|
|
// }
|
|
//
|
|
// SignerInfo ::= SEQUENCE {
|
|
// version INTEGER (1|3),
|
|
// sid SignerIdentifier, -- IssuerAndSerial for v1, SubjectKeyId for v3
|
|
// digestAlgorithm AlgorithmIdentifier,
|
|
// signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
|
|
// signatureAlgorithm AlgorithmIdentifier,
|
|
// signature OCTET STRING,
|
|
// unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL
|
|
// }
|
|
//
|
|
// SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
|
|
// Attribute ::= SEQUENCE { attrType OID, attrValues SET OF AttributeValue }
|
|
//
|
|
// The CMS signature is computed over the DER re-serialisation of the
|
|
// signedAttrs as a SET OF Attribute (NOT as the [0] IMPLICIT-tagged form
|
|
// it appears as in the wire). RFC 5652 §5.4 spells this out — easy to
|
|
// get wrong, every CMS implementation has hit this.
|
|
|
|
package pkcs7
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"crypto/sha1" //nolint:gosec // SHA-1 is RFC 8894 §3.5.2 baseline; SHA-256 also accepted
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strconv"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// SCEP authenticated-attribute OIDs (RFC 8894 §3.2.1.4).
|
|
var (
|
|
OIDSCEPMessageType = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 2}
|
|
OIDSCEPPKIStatus = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 3}
|
|
OIDSCEPFailInfo = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 4}
|
|
OIDSCEPSenderNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 5}
|
|
OIDSCEPRecipientNonce = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 6}
|
|
OIDSCEPTransactionID = asn1.ObjectIdentifier{2, 16, 840, 1, 113733, 1, 9, 7}
|
|
|
|
// CMS standard authenticated-attribute OIDs used by the signature
|
|
// verification (RFC 5652 §11).
|
|
OIDContentType = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
|
|
OIDMessageDigest = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
|
|
OIDSigningTime = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 5}
|
|
|
|
// CMS digest algorithm OIDs.
|
|
OIDSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26}
|
|
OIDSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
|
|
OIDSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3}
|
|
|
|
// Signature algorithm OIDs the verifier accepts.
|
|
OIDRSAWithSHA1 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 5}
|
|
OIDRSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
|
|
OIDRSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 13}
|
|
OIDECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2}
|
|
OIDECDSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 4}
|
|
|
|
// signedData CMS content type (RFC 5652 §5).
|
|
OIDSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
|
|
)
|
|
|
|
// ErrSignerInfoVerify is returned when signature verification fails. Like
|
|
// the EnvelopedData decrypt error, the message text is intentionally
|
|
// generic so the wire response collapses to BadMessageCheck.
|
|
var ErrSignerInfoVerify = errors.New("signerInfo: signature verification failed")
|
|
|
|
// SignerInfo represents an unwrapped CMS signerInfo with its parsed
|
|
// authenticatedAttributes. Used for SCEP POPO verification.
|
|
type SignerInfo struct {
|
|
Version int
|
|
SignerCert *x509.Certificate // device's transient signing cert (from the SignedData certificates field)
|
|
AuthAttributes map[string]asn1.RawValue // keyed by attribute OID dotted-string
|
|
rawSignedAttrs []byte // DER of the [0] IMPLICIT SignedAttributes — used for re-serialisation
|
|
DigestAlgorithm asn1.ObjectIdentifier
|
|
SignatureAlgorithm asn1.ObjectIdentifier
|
|
Signature []byte
|
|
}
|
|
|
|
// SignedData is the parsed top-level SignedData structure with the
|
|
// signers + the optional certificates the SET carries (used to look up
|
|
// the device's transient signing cert by SignerInfo.sid).
|
|
type SignedData struct {
|
|
Version int
|
|
DigestAlgorithms []pkix.AlgorithmIdentifier
|
|
EncapContentType asn1.ObjectIdentifier
|
|
EncapContent []byte // the inner content the SignedData wraps; nil if the wire used external signature
|
|
Certificates []*x509.Certificate
|
|
SignerInfos []*SignerInfo
|
|
}
|
|
|
|
// signedDataASN1 is the ASN.1 unmarshal target for the SignedData
|
|
// structure. Members tagged with their on-the-wire shapes.
|
|
type signedDataASN1 struct {
|
|
Version int
|
|
DigestAlgorithms []pkix.AlgorithmIdentifier `asn1:"set"`
|
|
EncapContentInfo encapContentInfoASN1
|
|
Certificates asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Certificate
|
|
CRLs asn1.RawValue `asn1:"optional,tag:1"`
|
|
SignerInfos []asn1.RawValue `asn1:"set"`
|
|
}
|
|
|
|
type encapContentInfoASN1 struct {
|
|
ContentType asn1.ObjectIdentifier
|
|
Content asn1.RawValue `asn1:"optional,explicit,tag:0"`
|
|
}
|
|
|
|
type signerInfoASN1 struct {
|
|
Version int
|
|
SID asn1.RawValue // CHOICE — IssuerAndSerial (default) or [0] SubjectKeyId
|
|
DigestAlgorithm pkix.AlgorithmIdentifier
|
|
SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` // [0] IMPLICIT SET OF Attribute
|
|
SignatureAlgorithm pkix.AlgorithmIdentifier
|
|
Signature []byte
|
|
UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"`
|
|
}
|
|
|
|
type attributeASN1 struct {
|
|
Type asn1.ObjectIdentifier
|
|
Values asn1.RawValue `asn1:"set"` // SET OF AttributeValue — left raw; per-attr decoder handles
|
|
}
|
|
|
|
// ParseSignedData parses a CMS ContentInfo wrapping a SignedData and
|
|
// returns the parsed structure including any certs + signerInfos.
|
|
//
|
|
// SCEP clients put the device's transient signing cert in the
|
|
// certificates field; the handler's POPO check picks the cert matching
|
|
// each signerInfo's SID and verifies with that cert's public key.
|
|
func ParseSignedData(der []byte) (*SignedData, error) {
|
|
if len(der) == 0 {
|
|
return nil, fmt.Errorf("signedData: empty input")
|
|
}
|
|
// Try peeling the optional outer ContentInfo (SEQUENCE { OID, [0] EXPLICIT ANY }).
|
|
if peeled, ok := peelContentInfo(der, OIDSignedData); ok {
|
|
der = peeled
|
|
}
|
|
|
|
var raw signedDataASN1
|
|
if _, err := asn1.Unmarshal(der, &raw); err != nil {
|
|
return nil, fmt.Errorf("signedData: parse outer SEQUENCE: %w", err)
|
|
}
|
|
|
|
out := &SignedData{
|
|
Version: raw.Version,
|
|
DigestAlgorithms: raw.DigestAlgorithms,
|
|
EncapContentType: raw.EncapContentInfo.ContentType,
|
|
}
|
|
// EncapContent is [0] EXPLICIT — the [0] EXPLICIT wrapper holds an
|
|
// OCTET STRING whose Bytes are the inner content. Some encoders use
|
|
// a degenerate empty content (external-signature mode); that's fine.
|
|
if len(raw.EncapContentInfo.Content.Bytes) > 0 {
|
|
// The OCTET STRING wrapper inside [0] EXPLICIT — strip it.
|
|
var innerOctet asn1.RawValue
|
|
if _, err := asn1.Unmarshal(raw.EncapContentInfo.Content.Bytes, &innerOctet); err == nil && innerOctet.Tag == asn1.TagOctetString {
|
|
out.EncapContent = innerOctet.Bytes
|
|
} else {
|
|
out.EncapContent = raw.EncapContentInfo.Content.Bytes
|
|
}
|
|
}
|
|
|
|
// Parse certificates SET. Each member is a Certificate (SEQUENCE).
|
|
if len(raw.Certificates.Bytes) > 0 {
|
|
certBytes := raw.Certificates.Bytes
|
|
for len(certBytes) > 0 {
|
|
var rv asn1.RawValue
|
|
rest, err := asn1.Unmarshal(certBytes, &rv)
|
|
if err != nil {
|
|
break
|
|
}
|
|
if rv.Class == asn1.ClassUniversal && rv.Tag == asn1.TagSequence {
|
|
if cert, err := x509.ParseCertificate(rv.FullBytes); err == nil {
|
|
out.Certificates = append(out.Certificates, cert)
|
|
}
|
|
// else: not a parseable cert (could be other CertificateChoices) — skip
|
|
}
|
|
certBytes = rest
|
|
}
|
|
}
|
|
|
|
// Parse each SignerInfo + look up its SignerCert from out.Certificates.
|
|
for _, siRaw := range raw.SignerInfos {
|
|
si, err := parseSignerInfoFromRaw(siRaw, out.Certificates)
|
|
if err != nil {
|
|
// Skip individual unparseable signerInfos rather than failing
|
|
// the whole SignedData — multi-signer CMS may have one bad
|
|
// signer alongside good ones (rare in SCEP, but keep tolerant).
|
|
continue
|
|
}
|
|
out.SignerInfos = append(out.SignerInfos, si)
|
|
}
|
|
if len(out.SignerInfos) == 0 {
|
|
return nil, fmt.Errorf("signedData: no parseable signerInfos")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ParseSignerInfos extracts SignerInfo records from a SignedData blob.
|
|
// Convenience wrapper around ParseSignedData when the caller only cares
|
|
// about the signers, not the certificates list.
|
|
func ParseSignerInfos(signedDataDER []byte) ([]*SignerInfo, error) {
|
|
sd, err := ParseSignedData(signedDataDER)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sd.SignerInfos, nil
|
|
}
|
|
|
|
func parseSignerInfoFromRaw(raw asn1.RawValue, certs []*x509.Certificate) (*SignerInfo, error) {
|
|
var siRaw signerInfoASN1
|
|
if _, err := asn1.Unmarshal(raw.FullBytes, &siRaw); err != nil {
|
|
return nil, fmt.Errorf("signerInfo: parse SEQUENCE: %w", err)
|
|
}
|
|
|
|
si := &SignerInfo{
|
|
Version: siRaw.Version,
|
|
AuthAttributes: map[string]asn1.RawValue{},
|
|
DigestAlgorithm: siRaw.DigestAlgorithm.Algorithm,
|
|
SignatureAlgorithm: siRaw.SignatureAlgorithm.Algorithm,
|
|
Signature: siRaw.Signature,
|
|
rawSignedAttrs: siRaw.SignedAttrs.Bytes, // bytes inside the [0] IMPLICIT — used for re-serialisation
|
|
}
|
|
|
|
// Walk authenticated attributes (SET OF Attribute). The [0] IMPLICIT
|
|
// wrapper means siRaw.SignedAttrs.Bytes holds the SET-OF body directly
|
|
// (no extra OCTET STRING wrapper).
|
|
attrBytes := siRaw.SignedAttrs.Bytes
|
|
for len(attrBytes) > 0 {
|
|
var attr attributeASN1
|
|
rest, err := asn1.Unmarshal(attrBytes, &attr)
|
|
if err != nil {
|
|
break
|
|
}
|
|
si.AuthAttributes[attr.Type.String()] = attr.Values
|
|
attrBytes = rest
|
|
}
|
|
|
|
// Resolve SignerCert by matching the SID against the certs list. SCEP
|
|
// uses IssuerAndSerial for v1; the [0] IMPLICIT SubjectKeyId form is
|
|
// v3 — accept both.
|
|
si.SignerCert = matchSignerCert(siRaw.SID, certs)
|
|
if si.SignerCert == nil {
|
|
return nil, fmt.Errorf("signerInfo: SignerCert not found in SignedData certificates")
|
|
}
|
|
return si, nil
|
|
}
|
|
|
|
func matchSignerCert(sid asn1.RawValue, certs []*x509.Certificate) *x509.Certificate {
|
|
// IssuerAndSerial form: SEQUENCE (no context tag) — universal class.
|
|
if sid.Class == asn1.ClassUniversal && sid.Tag == asn1.TagSequence {
|
|
var ias issuerAndSerialASN1
|
|
if _, err := asn1.Unmarshal(sid.FullBytes, &ias); err == nil {
|
|
for _, c := range certs {
|
|
if c.SerialNumber == nil || ias.SerialNumber == nil {
|
|
continue
|
|
}
|
|
if ias.SerialNumber.Cmp(c.SerialNumber) != 0 {
|
|
continue
|
|
}
|
|
if asn1Equal(ias.Issuer.FullBytes, c.RawIssuer) {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
// SubjectKeyIdentifier form: [0] IMPLICIT OCTET STRING.
|
|
if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 {
|
|
ski := sid.Bytes
|
|
for _, c := range certs {
|
|
if asn1Equal(c.SubjectKeyId, ski) {
|
|
return c
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asn1Equal(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// VerifySignature verifies the signerInfo's signature over the
|
|
// authenticatedAttributes (SCEP POPO).
|
|
//
|
|
// CMS signature semantics (RFC 5652 §5.4):
|
|
//
|
|
// 1. Re-serialise signedAttrs as a SET OF Attribute. The wire form is
|
|
// [0] IMPLICIT, but the signature is computed over the EXPLICIT
|
|
// SET OF re-serialisation. Easy mistake; this is the canonical CMS
|
|
// quirk every implementation hits.
|
|
// 2. Hash the re-serialised bytes with DigestAlgorithm.
|
|
// 3. Verify Signature against the hash using SignerCert.PublicKey +
|
|
// SignatureAlgorithm.
|
|
//
|
|
// Supports RSA-PKCS1v15 + ECDSA. Rejects RSA-PSS as out-of-spec for SCEP.
|
|
func (s *SignerInfo) VerifySignature() error {
|
|
if s == nil || s.SignerCert == nil {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
if len(s.rawSignedAttrs) == 0 {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
|
|
// Re-serialise as SET OF Attribute. We have rawSignedAttrs which is
|
|
// the bytes INSIDE the [0] IMPLICIT wrapper — that's the SET OF body.
|
|
// Wrap with the SET tag (0x31) + length to get the canonical form
|
|
// the signature is computed over.
|
|
signedAttrsForSig := ASN1Wrap(0x31, s.rawSignedAttrs)
|
|
|
|
// Hash with the digest algorithm.
|
|
digest, hashAlg, err := hashForOID(s.DigestAlgorithm, signedAttrsForSig)
|
|
if err != nil {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
|
|
switch pub := s.SignerCert.PublicKey.(type) {
|
|
case *rsa.PublicKey:
|
|
if !isRSASigAlg(s.SignatureAlgorithm) {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
if err := rsa.VerifyPKCS1v15(pub, hashAlg, digest, s.Signature); err != nil {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
return nil
|
|
case *ecdsa.PublicKey:
|
|
if !isECDSASigAlg(s.SignatureAlgorithm) {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
// crypto/ecdsa.VerifyASN1 takes the same hash, returns bool
|
|
if !ecdsa.VerifyASN1(pub, digest, s.Signature) {
|
|
return ErrSignerInfoVerify
|
|
}
|
|
return nil
|
|
default:
|
|
return ErrSignerInfoVerify
|
|
}
|
|
}
|
|
|
|
func hashForOID(oid asn1.ObjectIdentifier, data []byte) ([]byte, crypto.Hash, error) {
|
|
switch {
|
|
case oid.Equal(OIDSHA256), oid.Equal(OIDRSAWithSHA256), oid.Equal(OIDECDSAWithSHA256):
|
|
h := sha256.Sum256(data)
|
|
return h[:], crypto.SHA256, nil
|
|
case oid.Equal(OIDSHA512), oid.Equal(OIDRSAWithSHA512), oid.Equal(OIDECDSAWithSHA512):
|
|
h := sha512.Sum512(data)
|
|
return h[:], crypto.SHA512, nil
|
|
case oid.Equal(OIDSHA1), oid.Equal(OIDRSAWithSHA1):
|
|
// SHA-1 still appears in legacy SCEP clients (Cisco IOS pre-2018).
|
|
// RFC 8894 §3.5.2 advertises SHA-256 as preferred but does not ban SHA-1.
|
|
h := sha1.Sum(data) //nolint:gosec // RFC 8894 §3.5.2 baseline
|
|
return h[:], crypto.SHA1, nil
|
|
}
|
|
return nil, 0, fmt.Errorf("unsupported digest algorithm: %v", oid)
|
|
}
|
|
|
|
func isRSASigAlg(oid asn1.ObjectIdentifier) bool {
|
|
return oid.Equal(OIDRSAWithSHA1) || oid.Equal(OIDRSAWithSHA256) || oid.Equal(OIDRSAWithSHA512) || oid.Equal(OIDRSAEncryption)
|
|
}
|
|
|
|
func isECDSASigAlg(oid asn1.ObjectIdentifier) bool {
|
|
return oid.Equal(OIDECDSAWithSHA256) || oid.Equal(OIDECDSAWithSHA512)
|
|
}
|
|
|
|
// --- SCEP authenticated-attribute extractors -----------------------------
|
|
|
|
// GetMessageType returns the SCEP messageType value (RFC 8894 §3.2.1.4.1
|
|
// — encoded as a PrintableString containing the decimal ASCII of the
|
|
// message type integer, e.g. "19" for PKCSReq).
|
|
func (s *SignerInfo) GetMessageType() (domain.SCEPMessageType, error) {
|
|
str, err := s.attrPrintableString(OIDSCEPMessageType)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
mt, err := strconv.Atoi(str)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("messageType: parse %q as integer: %w", str, err)
|
|
}
|
|
return domain.SCEPMessageType(mt), nil
|
|
}
|
|
|
|
// GetTransactionID returns the SCEP transactionID (RFC 8894 §3.2.1.4.4 —
|
|
// PrintableString chosen by the client; server MUST echo verbatim in
|
|
// CertRep).
|
|
func (s *SignerInfo) GetTransactionID() (string, error) {
|
|
return s.attrPrintableString(OIDSCEPTransactionID)
|
|
}
|
|
|
|
// GetSenderNonce returns the 16-byte SCEP senderNonce (RFC 8894 §3.2.1.4.5
|
|
// — OCTET STRING).
|
|
func (s *SignerInfo) GetSenderNonce() ([]byte, error) {
|
|
return s.attrOctetString(OIDSCEPSenderNonce)
|
|
}
|
|
|
|
// GetMessageDigest returns the standard CMS messageDigest auth-attr
|
|
// (RFC 5652 §11.2). Used by the signature verification — when
|
|
// signedAttrs is present, the signature is over the re-serialised
|
|
// signedAttrs SET; the messageDigest auth-attr is what binds the
|
|
// signedAttrs to the encapContent.
|
|
func (s *SignerInfo) GetMessageDigest() ([]byte, error) {
|
|
return s.attrOctetString(OIDMessageDigest)
|
|
}
|
|
|
|
// attrPrintableString extracts a PrintableString from the AuthAttributes
|
|
// SET-OF-Attribute-Values for the given attribute OID. Caller-side validation
|
|
// of length / charset is left to the SCEP-specific extractor.
|
|
func (s *SignerInfo) attrPrintableString(oid asn1.ObjectIdentifier) (string, error) {
|
|
rv, ok := s.AuthAttributes[oid.String()]
|
|
if !ok {
|
|
return "", fmt.Errorf("auth-attr %v not present", oid)
|
|
}
|
|
// rv is the SET OF AttributeValue — typically one element. The
|
|
// first element is a PrintableString or IA5String.
|
|
if len(rv.Bytes) == 0 {
|
|
return "", fmt.Errorf("auth-attr %v: empty value", oid)
|
|
}
|
|
var inner asn1.RawValue
|
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
|
return "", fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
|
}
|
|
// PrintableString / IA5String / UTF8String all carry their bytes
|
|
// directly in inner.Bytes.
|
|
switch inner.Tag {
|
|
case asn1.TagPrintableString, asn1.TagIA5String, asn1.TagUTF8String:
|
|
return string(inner.Bytes), nil
|
|
}
|
|
return "", fmt.Errorf("auth-attr %v: unexpected value tag %d", oid, inner.Tag)
|
|
}
|
|
|
|
func (s *SignerInfo) attrOctetString(oid asn1.ObjectIdentifier) ([]byte, error) {
|
|
rv, ok := s.AuthAttributes[oid.String()]
|
|
if !ok {
|
|
return nil, fmt.Errorf("auth-attr %v not present", oid)
|
|
}
|
|
if len(rv.Bytes) == 0 {
|
|
return nil, fmt.Errorf("auth-attr %v: empty value", oid)
|
|
}
|
|
var inner asn1.RawValue
|
|
if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil {
|
|
return nil, fmt.Errorf("auth-attr %v: unmarshal value: %w", oid, err)
|
|
}
|
|
if inner.Tag != asn1.TagOctetString {
|
|
return nil, fmt.Errorf("auth-attr %v: unexpected value tag %d (want OCTET STRING)", oid, inner.Tag)
|
|
}
|
|
return inner.Bytes, nil
|
|
}
|
|
|
|
// silence unused warning for big.Int — referenced via issuerAndSerialASN1 in
|
|
// envelopeddata.go but the linker only sees it once per package; this keeps
|
|
// the import healthy if someone deletes envelopeddata.go's helper struct.
|
|
var _ = (*big.Int)(nil)
|