Files
certctl/internal/pkcs7/signedinfo_test.go
T
shankar0123 a546a1bbef feat(scep): EnvelopedData decrypt + signerInfo POPO verify (RFC 8894 §3.2)
SCEP RFC 8894 + Intune master bundle — Phase 2 of 14.

Implements the new RFC 8894 PKIMessage parse path: EnvelopedData parser
+ decryptor, signerInfo parser + signature verifier, handler dispatch
that tries the RFC 8894 path FIRST and falls through to the legacy MVP
raw-CSR path on any parse failure. Backward compat with lightweight SCEP
clients is preserved by design — no behavior change for any existing
deploy that doesn't set CERTCTL_SCEP_RA_*.

internal/pkcs7/envelopeddata.go (new, ~330 LoC)
  * ParseEnvelopedData: parses CMS EnvelopedData per RFC 5652 §6.1, with
    optional outer ContentInfo unwrapping. Handles SET OF RecipientInfo
    + IssuerAndSerial form rid (RFC 8894 §3.2.2).
  * EnvelopedData.Decrypt: RSA PKCS#1 v1.5 key-trans + AES-CBC (128/192/
    256) or DES-EDE3-CBC content decryption with **constant-time PKCS#7
    padding strip** (no branch on padding-byte values; closes the
    padding-oracle leak surface). Recipient mismatch is BadMessageCheck
    per RFC 8894 §3.3.2.2 (NOT BadCertID); every failure mode returns
    the same ErrEnvelopedDataDecrypt sentinel to close timing-leak legs
    of Bleichenbacher attacks.
  * Equivalent to micromdm/scep's cryptoutil/cryptoutil.go::DecryptPKCS-
    Envelope (cited in code comments; not vendored — fuzz-target
    ownership stays in this sub-package per the operating rule).

internal/pkcs7/signedinfo.go (new, ~370 LoC)
  * ParseSignedData / ParseSignerInfos: parses CMS SignedData per RFC
    5652 §5.3. Resolves each SignerInfo's SID (IssuerAndSerial v1 OR
    [0] SubjectKeyId v3) against the SignedData certificates SET to
    pluck the device's transient signing cert.
  * SignerInfo.VerifySignature: re-serialises signedAttrs as the
    canonical SET OF Attribute (the RFC 5652 §5.4 quirk every CMS
    implementation hits — wire form is [0] IMPLICIT but the signature
    is over EXPLICIT SET OF). Hashes with SHA-1/SHA-256/SHA-512 +
    verifies via RSA PKCS1v15 or ECDSA per the cert's pubkey type.
  * Auth-attr extractors: GetMessageType (PrintableString-decimal),
    GetTransactionID, GetSenderNonce, GetMessageDigest. SCEP attr OIDs
    pinned (RFC 8894 §3.2.1.4).

internal/pkcs7/{envelopeddata,signedinfo}_fuzz_test.go (new)
  * FuzzParseEnvelopedData / FuzzParseSignedData / FuzzParseSignerInfos
    / FuzzVerifySignerInfoSignature — every parser certctl adds gets a
    panic-safety fuzzer (the fuzz-target-ownership rule from
    cowork/CLAUDE.md::Operating Rules). Local 5s runs hit ~270k
    executions per parser without panic. Errors are expected for
    arbitrary inputs; only panics are bugs.

internal/pkcs7/{envelopeddata,signedinfo}_test.go (new)
  * Round-trip tests that materialise real RSA/ECDSA pairs, hand-build
    the wire bytes, parse + decrypt + verify, and assert plaintext /
    auth-attr equality. The build helpers use this package's ASN1Wrap
    primitives directly (asn1.Marshal of structs containing nested
    asn1.RawValue is finicky for mixed Class/Tag); gives byte-level
    control matching what real SCEP clients emit.
  * Negative tests: tampered ciphertext / tampered auth-attrs / wrong
    RA / wrong key / mismatched recipients / random garbage all return
    the appropriate sentinel error without panic.

internal/service/scep.go
  * PKCSReqWithEnvelope: RFC 8894 envelope-aware variant. Returns
    *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because RFC
    8894 §3.3 mandates a CertRep PKIMessage on every response, even
    failures — the handler shouldn't translate Go errors into SCEP
    failInfo codes. Returns nil to signal 'invalid challenge password'
    so the caller can translate to HTTP 403 (matches MVP path's wire
    shape; RFC 8894 §3.3.1 is silent on this case).
  * mapServiceErrorToFailInfo: exact mapping table from the prompt
    (CSR parse → BadRequest, CSR sig → BadMessageCheck, crypto policy
    → BadAlg, default → BadRequest).

internal/api/handler/scep.go
  * SCEPService interface gains PKCSReqWithEnvelope.
  * SCEPHandler now optionally carries an RA cert + key pair. SetRAPair
    upgrades the handler to the RFC 8894 path; without that call the
    handler stays MVP-only (the v2.0.x behavior).
  * pkiOperation: tries the RFC 8894 path FIRST when the RA pair is
    set. tryParseRFC8894 helper does the full pipeline (ParseSignedData
    → VerifySignature → extract auth-attrs → ParseEnvelopedData → Decrypt
    → x509.ParseCertificateRequest the recovered bytes). On any failure
    it falls through to the legacy extractCSRFromPKCS7 MVP path —
    backward compat is non-negotiable.
  * Phase 2 emits the legacy certs-only response on RFC 8894 success;
    Phase 3 (next commit) swaps in writeCertRepPKIMessage with the
    proper status / failInfo / nonce-echo wire shape.

cmd/server/main.go
  * Per-profile loop now calls loadSCEPRAPair after preflight to load
    the cert + key + inject via SetRAPair. crypto + crypto/tls imports
    added.
  * loadSCEPRAPair helper: tls.X509KeyPair-based parse + leaf cert
    extraction. Failures here indicate TOCTOU between preflight + load.

internal/api/handler/scep_handler_test.go +
internal/api/router/router_scep_profiles_test.go
  * mockSCEPService / scepProfileMockService gain PKCSReqWithEnvelope
    stubs to satisfy the extended interface. Existing test cases
    unchanged (they exercise the MVP path; RA pair is unset).

Verification:
  * gofmt + go vet clean for the files I touched.
  * go test -short -count=1 green across pkcs7 / api/handler /
    api/router / service / cmd/server.
  * Coverage: pkcs7 78.4% (was 100% — drops because new code includes
    paths the round-trip tests don't yet hit, like decryption alg
    fall-through and v3 SubjectKeyId SID matching).
  * Fuzz-target seed-corpus runs (5s each, ~270k execs/parser): no
    panic. Pre-merge fuzz-time bumps to 30s per the prompt's
    verification gate.

Phase 2 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 12:36:27 +00:00

361 lines
13 KiB
Go

package pkcs7
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
"math/big"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// SCEP RFC 8894 Phase 2.2: round-trip tests for ParseSignedData +
// SignerInfo.VerifySignature + auth-attr extractors.
//
// Each test materialises a real signing cert + signs auth-attrs over a
// known content, then re-parses and verifies. Catches drift between the
// signing-side encoding and the verification-side re-serialisation
// (RFC 5652 §5.4 SET OF Attribute quirk).
func TestSignerInfo_RoundTrip_RSAWithSHA256(t *testing.T) {
signer, signerCert := genTestRSASigner(t)
signedData := buildTestSignedData(t, signer, signerCert,
domain.SCEPMessageTypePKCSReq, "txn-12345", []byte("0123456789abcdef"),
[]byte("encapsulated content (typically EnvelopedData bytes)"))
parsed, err := ParseSignedData(signedData)
if err != nil {
t.Fatalf("ParseSignedData: %v", err)
}
if len(parsed.SignerInfos) != 1 {
t.Fatalf("len(SignerInfos) = %d, want 1", len(parsed.SignerInfos))
}
si := parsed.SignerInfos[0]
if err := si.VerifySignature(); err != nil {
t.Fatalf("VerifySignature: %v", err)
}
// Auth-attr extractors.
mt, err := si.GetMessageType()
if err != nil {
t.Fatalf("GetMessageType: %v", err)
}
if mt != domain.SCEPMessageTypePKCSReq {
t.Errorf("GetMessageType = %d, want %d", mt, domain.SCEPMessageTypePKCSReq)
}
tid, err := si.GetTransactionID()
if err != nil {
t.Fatalf("GetTransactionID: %v", err)
}
if tid != "txn-12345" {
t.Errorf("GetTransactionID = %q, want %q", tid, "txn-12345")
}
nonce, err := si.GetSenderNonce()
if err != nil {
t.Fatalf("GetSenderNonce: %v", err)
}
if string(nonce) != "0123456789abcdef" {
t.Errorf("GetSenderNonce = %q, want %q", nonce, "0123456789abcdef")
}
}
func TestSignerInfo_RoundTrip_ECDSAWithSHA256(t *testing.T) {
signer, signerCert := genTestECDSASigner(t)
signedData := buildTestSignedData(t, signer, signerCert,
domain.SCEPMessageTypeRenewalReq, "txn-ec-1", []byte("nonce-ec-aaaa-bbbb"),
[]byte("encap content"))
parsed, err := ParseSignedData(signedData)
if err != nil {
t.Fatalf("ParseSignedData: %v", err)
}
si := parsed.SignerInfos[0]
if err := si.VerifySignature(); err != nil {
t.Fatalf("VerifySignature (ECDSA): %v", err)
}
mt, err := si.GetMessageType()
if err != nil {
t.Fatalf("GetMessageType: %v", err)
}
if mt != domain.SCEPMessageTypeRenewalReq {
t.Errorf("GetMessageType = %d, want RenewalReq (17)", mt)
}
}
func TestSignerInfo_VerifySignature_TamperedAttrs_Refuses(t *testing.T) {
signer, signerCert := genTestRSASigner(t)
signedData := buildTestSignedData(t, signer, signerCert,
domain.SCEPMessageTypePKCSReq, "txn-tamper", []byte("nonce-aaaa-bbbb"),
[]byte("content"))
parsed, err := ParseSignedData(signedData)
if err != nil {
t.Fatalf("ParseSignedData: %v", err)
}
si := parsed.SignerInfos[0]
// Tamper with rawSignedAttrs by flipping the last byte. Re-verification
// must reject — proves the signature is bound to the auth-attr bytes.
si.rawSignedAttrs[len(si.rawSignedAttrs)-1] ^= 0x01
if err := si.VerifySignature(); !errors.Is(err, ErrSignerInfoVerify) {
t.Errorf("VerifySignature(tampered attrs) = %v, want ErrSignerInfoVerify", err)
}
}
func TestParseSignedData_Empty_Refuses(t *testing.T) {
if _, err := ParseSignedData(nil); err == nil {
t.Error("ParseSignedData(nil) = nil, want error")
}
if _, err := ParseSignedData([]byte{}); err == nil {
t.Error("ParseSignedData(empty) = nil, want error")
}
}
func TestParseSignedData_Garbage_Refuses(t *testing.T) {
garbage := []byte{0x30, 0x82, 0x05, 0x01, 0x02, 0x03}
if _, err := ParseSignedData(garbage); err == nil {
t.Error("ParseSignedData(garbage) = nil, want error")
}
}
// --- helpers -------------------------------------------------------------
type testSigner interface {
Sign(data []byte) ([]byte, error)
DigestOID() asn1.ObjectIdentifier
SignatureOID() asn1.ObjectIdentifier
}
type rsaTestSigner struct{ k *rsa.PrivateKey }
func (s *rsaTestSigner) Sign(data []byte) ([]byte, error) {
h := sha256.Sum256(data)
return rsa.SignPKCS1v15(rand.Reader, s.k, 0+5, h[:]) // 5 == crypto.SHA256 in crypto.Hash enum
}
func (s *rsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
func (s *rsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDRSAWithSHA256 }
type ecdsaTestSigner struct{ k *ecdsa.PrivateKey }
func (s *ecdsaTestSigner) Sign(data []byte) ([]byte, error) {
h := sha256.Sum256(data)
return ecdsa.SignASN1(rand.Reader, s.k, h[:])
}
func (s *ecdsaTestSigner) DigestOID() asn1.ObjectIdentifier { return OIDSHA256 }
func (s *ecdsaTestSigner) SignatureOID() asn1.ObjectIdentifier { return OIDECDSAWithSHA256 }
func genTestRSASigner(t *testing.T) (testSigner, *x509.Certificate) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xDEAD),
Subject: pkix.Name{CommonName: "device-rsa"},
Issuer: pkix.Name{CommonName: "device-rsa"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return &rsaTestSigner{k: key}, cert
}
func genTestECDSASigner(t *testing.T) (testSigner, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano() ^ 0xBEEF),
Subject: pkix.Name{CommonName: "device-ec"},
Issuer: pkix.Name{CommonName: "device-ec"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return &ecdsaTestSigner{k: key}, cert
}
// buildTestSignedData hand-constructs a CMS SignedData with one SignerInfo
// carrying SCEP authenticated attributes (messageType, transactionID,
// senderNonce, plus the standard CMS contentType + messageDigest).
//
// The signing pipeline mirrors what micromdm/scep + the ChromeOS SCEP
// client emit: the device hashes the encap content into messageDigest,
// the auth-attrs are SET-OF re-serialised, hashed, and signed.
//
// Implementation note: built directly with ASN1Wrap helpers rather than
// relying on asn1.Marshal of structs containing asn1.RawValue fields —
// asn1.Marshal of nested RawValues with mixed Class/Tag has been finicky
// and the helpers give us byte-level control that matches what's on the wire.
func buildTestSignedData(t *testing.T, signer testSigner, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte {
t.Helper()
// 1. messageDigest auth-attr: SHA-256 of the encap content.
contentDigest := sha256.Sum256(encapContent)
// 2. Build each auth-attr as Attribute ::= SEQUENCE { OID, SET OF Value }
// using the helpers. Marshal each value individually then wrap.
attrSetBody := buildSCEPAuthAttrs(t, contentDigest[:], messageType, transactionID, senderNonce)
// 3. Compute the signature over SET OF Attribute.
signedAttrsForSig := ASN1Wrap(0x31, attrSetBody)
sig, err := signer.Sign(signedAttrsForSig)
if err != nil {
t.Fatalf("signer.Sign: %v", err)
}
// 4. Build the SignerInfo SEQUENCE byte-by-byte.
versionBytes := []byte{0x02, 0x01, 0x01} // INTEGER 1
// SID is IssuerAndSerialNumber: SEQUENCE { Issuer (RDN), SerialNumber INTEGER }
serialDER, err := asn1.Marshal(signerCert.SerialNumber)
if err != nil {
t.Fatalf("marshal serial: %v", err)
}
sidBody := append([]byte{}, signerCert.RawIssuer...) // already in DER
sidBody = append(sidBody, serialDER...)
sidBytes := ASN1Wrap(0x30, sidBody)
// DigestAlgorithm: AlgorithmIdentifier — encode via stdlib (small struct, no nested RawValue issues).
digestAlgBytes := mustMarshal(t, pkix.AlgorithmIdentifier{Algorithm: signer.DigestOID(), Parameters: asn1.NullRawValue})
// SignedAttrs as [0] IMPLICIT SET OF — tag 0xA0 wraps the SET body.
signedAttrsImplicitBytes := ASN1Wrap(0xa0, attrSetBody)
// SignatureAlgorithm.
sigAlg := pkix.AlgorithmIdentifier{Algorithm: signer.SignatureOID()}
if signer.SignatureOID().Equal(OIDRSAWithSHA256) {
sigAlg.Parameters = asn1.NullRawValue
}
sigAlgBytes := mustMarshal(t, sigAlg)
// Signature: OCTET STRING.
sigOctetBytes := ASN1Wrap(0x04, sig)
siBody := append([]byte{}, versionBytes...)
siBody = append(siBody, sidBytes...)
siBody = append(siBody, digestAlgBytes...)
siBody = append(siBody, signedAttrsImplicitBytes...)
siBody = append(siBody, sigAlgBytes...)
siBody = append(siBody, sigOctetBytes...)
siBytes := ASN1Wrap(0x30, siBody)
// 5. Build encapContentInfo SEQUENCE { OID data, [0] EXPLICIT OCTET STRING }.
octetBytes := ASN1Wrap(0x04, encapContent) // OCTET STRING
encapContentExplicit := ASN1Wrap(0xa0, octetBytes) // [0] EXPLICIT
oidDataBytes := mustMarshal(t, OIDDataContent)
encapBody := append([]byte{}, oidDataBytes...)
encapBody = append(encapBody, encapContentExplicit...)
encapBytes := ASN1Wrap(0x30, encapBody)
// 6. certificates [0] IMPLICIT SET OF Certificate — body is one cert DER.
certsBytes := ASN1Wrap(0xa0, signerCert.Raw)
// 7. digestAlgorithms SET OF AlgorithmIdentifier (one entry).
digestAlgsBytes := ASN1Wrap(0x31, digestAlgBytes)
// 8. signerInfos SET OF SignerInfo (one entry).
signerInfosBytes := ASN1Wrap(0x31, siBytes)
// 9. Assemble SignedData SEQUENCE.
sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) // version
sdBody = append(sdBody, digestAlgsBytes...)
sdBody = append(sdBody, encapBytes...)
sdBody = append(sdBody, certsBytes...)
sdBody = append(sdBody, signerInfosBytes...)
sdSeq := ASN1Wrap(0x30, sdBody)
// 10. 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)
}
// buildSCEPAuthAttrs builds the SET-OF body of SCEP auth-attrs (the bytes
// inside the [0] IMPLICIT SignedAttrs wrapper). Each Attribute is a SEQUENCE
// of (OID, SET OF Value); we build them with ASN1Wrap to avoid asn1.Marshal
// nuances with nested RawValues.
func buildSCEPAuthAttrs(t *testing.T, msgDigest []byte, messageType domain.SCEPMessageType, transactionID string, senderNonce []byte) []byte {
t.Helper()
var out []byte
// contentType: SET OF OID = SET { OID data }
out = append(out, attrSeq(t, OIDContentType, ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...)
// messageDigest: SET OF OCTET STRING
out = append(out, attrSeq(t, OIDMessageDigest, ASN1Wrap(0x04, msgDigest))...)
// SCEP messageType: SET OF PrintableString (decimal ASCII)
out = append(out, attrSeq(t, OIDSCEPMessageType, ASN1Wrap(0x13, []byte(intToAscii(int(messageType)))))...)
// SCEP transactionID: SET OF PrintableString
out = append(out, attrSeq(t, OIDSCEPTransactionID, ASN1Wrap(0x13, []byte(transactionID)))...)
// SCEP senderNonce: SET OF OCTET STRING
out = append(out, attrSeq(t, OIDSCEPSenderNonce, ASN1Wrap(0x04, senderNonce))...)
return out
}
// attrSeq builds one Attribute SEQUENCE: SEQUENCE { OID, SET OF value }.
// The `value` arg is one already-encoded TLV (e.g. an OCTET STRING or
// PrintableString); attrSeq wraps it in a SET and prefixes the OID.
func attrSeq(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte {
t.Helper()
oidBytes := mustMarshal(t, oid)
setOfValue := ASN1Wrap(0x31, value)
body := append([]byte{}, oidBytes...)
body = append(body, setOfValue...)
return ASN1Wrap(0x30, body)
}
func mustMarshal(t *testing.T, v interface{}) []byte {
t.Helper()
out, err := asn1.Marshal(v)
if err != nil {
t.Fatalf("marshal %T: %v", v, err)
}
return out
}
func intToAscii(i int) string {
if i == 0 {
return "0"
}
neg := i < 0
if neg {
i = -i
}
var b []byte
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
if neg {
b = append([]byte{'-'}, b...)
}
return string(b)
}