mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
a546a1bbef
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.
287 lines
9.2 KiB
Go
287 lines
9.2 KiB
Go
package pkcs7
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// SCEP RFC 8894 Phase 2.1: round-trip tests for ParseEnvelopedData +
|
|
// EnvelopedData.Decrypt.
|
|
//
|
|
// Each test materialises a real RSA RA cert + key, builds an EnvelopedData
|
|
// by hand (encrypting a known plaintext with AES-256-CBC using a fresh
|
|
// random key transported via PKCS#1 v1.5 wrap of the RA pubkey), then
|
|
// parses + decrypts and asserts plaintext equality.
|
|
//
|
|
// The point of the round-trip is to pin the exact wire format: the
|
|
// per-field DER encoding has to match what real SCEP clients emit
|
|
// (Cisco IOS, ChromeOS, Intune Connector). If the parse succeeds but the
|
|
// decrypt comes back garbled, the wire-format encoding is off in a way
|
|
// the unit tests catch.
|
|
|
|
func TestEnvelopedData_RoundTrip_AES256CBC(t *testing.T) {
|
|
raKey, raCert := genTestRSARA(t)
|
|
plaintext := []byte("hello SCEP world — this is the encapsulated CSR DER bytes")
|
|
|
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
|
|
|
parsed, err := ParseEnvelopedData(envelope)
|
|
if err != nil {
|
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
|
}
|
|
if len(parsed.RecipientInfos) != 1 {
|
|
t.Fatalf("len(RecipientInfos) = %d, want 1", len(parsed.RecipientInfos))
|
|
}
|
|
if !parsed.ContentEncryptionAlg.Algorithm.Equal(OIDAES256CBC) {
|
|
t.Errorf("ContentEncryptionAlg = %v, want AES-256-CBC", parsed.ContentEncryptionAlg.Algorithm)
|
|
}
|
|
|
|
got, err := parsed.Decrypt(raKey, raCert)
|
|
if err != nil {
|
|
t.Fatalf("Decrypt: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Errorf("Decrypt plaintext mismatch:\n got=%q\nwant=%q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
func TestEnvelopedData_RoundTrip_AES128CBC(t *testing.T) {
|
|
raKey, raCert := genTestRSARA(t)
|
|
plaintext := []byte("AES-128 round-trip — short ciphertext, single-block worth of data")
|
|
|
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES128CBC, 16)
|
|
parsed, err := ParseEnvelopedData(envelope)
|
|
if err != nil {
|
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
|
}
|
|
got, err := parsed.Decrypt(raKey, raCert)
|
|
if err != nil {
|
|
t.Fatalf("Decrypt: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Errorf("plaintext mismatch")
|
|
}
|
|
}
|
|
|
|
func TestEnvelopedData_Decrypt_WrongRA_ReturnsBadMessageCheck(t *testing.T) {
|
|
correctKey, correctCert := genTestRSARA(t)
|
|
wrongKey, wrongCert := genTestRSARA(t)
|
|
plaintext := []byte("addressed to the right CA, decrypted with the wrong one")
|
|
|
|
envelope := buildTestEnvelope(t, correctCert, plaintext, OIDAES256CBC, 32)
|
|
parsed, err := ParseEnvelopedData(envelope)
|
|
if err != nil {
|
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
|
}
|
|
|
|
// Wrong cert (issuer mismatch) — RFC 8894 §3.3.2.2 says BadMessageCheck.
|
|
_, err = parsed.Decrypt(wrongKey, wrongCert)
|
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
|
t.Errorf("Decrypt with wrong RA cert: err = %v, want ErrEnvelopedDataDecrypt", err)
|
|
}
|
|
// Right cert, wrong key — same generic error to close the timing leak.
|
|
_, err = parsed.Decrypt(wrongKey, correctCert)
|
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
|
t.Errorf("Decrypt with mismatched key: err = %v, want ErrEnvelopedDataDecrypt", err)
|
|
}
|
|
// Right key, right cert — succeeds.
|
|
got, err := parsed.Decrypt(correctKey, correctCert)
|
|
if err != nil {
|
|
t.Fatalf("Decrypt with correct pair: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Errorf("plaintext mismatch")
|
|
}
|
|
}
|
|
|
|
func TestEnvelopedData_Decrypt_TamperedCiphertext_Refuses(t *testing.T) {
|
|
raKey, raCert := genTestRSARA(t)
|
|
plaintext := []byte("plaintext we'll corrupt mid-flight")
|
|
|
|
envelope := buildTestEnvelope(t, raCert, plaintext, OIDAES256CBC, 32)
|
|
parsed, err := ParseEnvelopedData(envelope)
|
|
if err != nil {
|
|
t.Fatalf("ParseEnvelopedData: %v", err)
|
|
}
|
|
// Flip a bit in the LAST ciphertext block — corrupts the padding the
|
|
// constant-time strip should catch.
|
|
if len(parsed.EncryptedContent) < 16 {
|
|
t.Fatal("ciphertext too short to tamper")
|
|
}
|
|
parsed.EncryptedContent[len(parsed.EncryptedContent)-1] ^= 0xff
|
|
_, err = parsed.Decrypt(raKey, raCert)
|
|
if !errors.Is(err, ErrEnvelopedDataDecrypt) {
|
|
t.Errorf("Decrypt tampered ciphertext: err = %v, want ErrEnvelopedDataDecrypt", err)
|
|
}
|
|
}
|
|
|
|
func TestEnvelopedData_Parse_Empty_Refuses(t *testing.T) {
|
|
if _, err := ParseEnvelopedData(nil); err == nil {
|
|
t.Error("ParseEnvelopedData(nil) = nil, want error")
|
|
}
|
|
if _, err := ParseEnvelopedData([]byte{}); err == nil {
|
|
t.Error("ParseEnvelopedData(empty) = nil, want error")
|
|
}
|
|
}
|
|
|
|
func TestEnvelopedData_Parse_RandomGarbage_Refuses(t *testing.T) {
|
|
garbage := []byte{0x30, 0x82, 0x00, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05}
|
|
if _, err := ParseEnvelopedData(garbage); err == nil {
|
|
t.Error("ParseEnvelopedData(garbage) = nil, want error")
|
|
}
|
|
}
|
|
|
|
// --- helpers -------------------------------------------------------------
|
|
|
|
func genTestRSARA(t *testing.T) (*rsa.PrivateKey, *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()),
|
|
Subject: pkix.Name{CommonName: "ra-test"},
|
|
Issuer: pkix.Name{CommonName: "ra-test"},
|
|
NotBefore: time.Now().Add(-1 * 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 {
|
|
t.Fatalf("CreateCertificate: %v", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatalf("ParseCertificate: %v", err)
|
|
}
|
|
return key, cert
|
|
}
|
|
|
|
// buildTestEnvelope hand-constructs an EnvelopedData targeting raCert that
|
|
// encrypts plaintext with the given AES-CBC algorithm + keyLen. Mirrors
|
|
// what a real SCEP client would emit (Cisco IOS / Intune Connector / etc.).
|
|
//
|
|
// Returns the raw DER bytes ready to feed into ParseEnvelopedData.
|
|
func buildTestEnvelope(t *testing.T, raCert *x509.Certificate, plaintext []byte, algOID asn1.ObjectIdentifier, keyLen int) []byte {
|
|
t.Helper()
|
|
// 1. Generate a random symmetric key + IV.
|
|
symKey := make([]byte, keyLen)
|
|
if _, err := rand.Read(symKey); err != nil {
|
|
t.Fatalf("rand.Read symKey: %v", err)
|
|
}
|
|
iv := make([]byte, aes.BlockSize)
|
|
if _, err := rand.Read(iv); err != nil {
|
|
t.Fatalf("rand.Read iv: %v", err)
|
|
}
|
|
|
|
// 2. PKCS#7-pad the plaintext to a multiple of the block size.
|
|
bs := aes.BlockSize
|
|
padLen := bs - len(plaintext)%bs
|
|
padded := append([]byte{}, plaintext...)
|
|
for i := 0; i < padLen; i++ {
|
|
padded = append(padded, byte(padLen))
|
|
}
|
|
|
|
// 3. AES-CBC encrypt.
|
|
block, err := aes.NewCipher(symKey)
|
|
if err != nil {
|
|
t.Fatalf("aes.NewCipher: %v", err)
|
|
}
|
|
enc := cipher.NewCBCEncrypter(block, iv)
|
|
ciphertext := make([]byte, len(padded))
|
|
enc.CryptBlocks(ciphertext, padded)
|
|
|
|
// 4. RSA PKCS#1 v1.5 encrypt the symmetric key with the RA pubkey.
|
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, raCert.PublicKey.(*rsa.PublicKey), symKey)
|
|
if err != nil {
|
|
t.Fatalf("rsa.EncryptPKCS1v15: %v", err)
|
|
}
|
|
|
|
// 5. Build the IssuerAndSerialNumber identifying the RA cert.
|
|
issuerRDN := asn1.RawValue{FullBytes: raCert.RawIssuer}
|
|
rid, err := asn1.Marshal(struct {
|
|
Issuer asn1.RawValue
|
|
SerialNumber *big.Int
|
|
}{Issuer: issuerRDN, SerialNumber: raCert.SerialNumber})
|
|
if err != nil {
|
|
t.Fatalf("marshal IssuerAndSerial: %v", err)
|
|
}
|
|
|
|
// 6. Build the KeyTransRecipientInfo SEQUENCE.
|
|
keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: OIDRSAEncryption, Parameters: asn1.NullRawValue}
|
|
ktriBytes, err := asn1.Marshal(struct {
|
|
Version int
|
|
RID asn1.RawValue
|
|
KeyEncryptionAlg pkix.AlgorithmIdentifier
|
|
EncryptedKey []byte
|
|
}{
|
|
Version: 0,
|
|
RID: asn1.RawValue{FullBytes: rid},
|
|
KeyEncryptionAlg: keyEncAlg,
|
|
EncryptedKey: encryptedKey,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal KTRI: %v", err)
|
|
}
|
|
|
|
// 7. Build the AlgorithmIdentifier with the IV as parameters
|
|
// (RFC 3565 §2.3 — IV is OCTET STRING, fed in via Parameters).
|
|
ivParam, err := asn1.Marshal(iv)
|
|
if err != nil {
|
|
t.Fatalf("marshal IV: %v", err)
|
|
}
|
|
contentAlg := pkix.AlgorithmIdentifier{
|
|
Algorithm: algOID,
|
|
Parameters: asn1.RawValue{FullBytes: ivParam},
|
|
}
|
|
|
|
// 8. Build the EncryptedContentInfo SEQUENCE.
|
|
// encryptedContent is [0] IMPLICIT OCTET STRING — the content bytes
|
|
// appear directly after the [0] tag, without an inner OCTET STRING
|
|
// wrapper.
|
|
encContent := asn1.RawValue{
|
|
Class: asn1.ClassContextSpecific,
|
|
Tag: 0,
|
|
IsCompound: false,
|
|
Bytes: ciphertext,
|
|
}
|
|
eciBytes, err := asn1.Marshal(struct {
|
|
ContentType asn1.ObjectIdentifier
|
|
ContentEncryptionAlgorithm pkix.AlgorithmIdentifier
|
|
EncryptedContent asn1.RawValue
|
|
}{
|
|
ContentType: OIDDataContent,
|
|
ContentEncryptionAlgorithm: contentAlg,
|
|
EncryptedContent: encContent,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal ECI: %v", err)
|
|
}
|
|
|
|
// 9. Build the EnvelopedData SEQUENCE.
|
|
envBytes, err := asn1.Marshal(struct {
|
|
Version int
|
|
RecipientInfos []asn1.RawValue `asn1:"set"`
|
|
EncryptedECI asn1.RawValue
|
|
}{
|
|
Version: 0,
|
|
RecipientInfos: []asn1.RawValue{{FullBytes: ktriBytes}},
|
|
EncryptedECI: asn1.RawValue{FullBytes: eciBytes},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal EnvelopedData: %v", err)
|
|
}
|
|
return envBytes
|
|
}
|