mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:42:00 +00:00
b540d4421e
SCEP RFC 8894 + Intune master bundle — Phase 3 of 14.
Implements the SCEP CertRep response builder + wires it into the handler's
RFC 8894 path. After this commit, certctl emits proper CertRep PKIMessage
responses (signed by the RA key, with EnvelopedData encrypting the issued
cert chain to the device's transient signing cert) for both success and
failure outcomes — RFC 8894 §3.3 mandates a PKIMessage response on every
PKIOperation request, including failure cases that carry pkiStatus=2 +
failInfo.
internal/pkcs7/certrep.go (new, ~370 LoC)
* BuildCertRepPKIMessage: assembles the full ContentInfo → SignedData →
{certs, signerInfo, encapContent} structure per RFC 8894 §3.3.2 +
RFC 5652 §5+§6.
* Success path: encrypts the issued cert chain (PKCS#7 certs-only)
INSIDE an EnvelopedData targeting req.SignerCert (the device's
transient cert, NOT the RA cert — response goes back to the device
encrypted with its public key). AES-256-CBC + random 16-byte IV +
PKCS#7 padding + RSA PKCS#1v1.5 keyTrans.
* Failure path: encapContent is empty (no EnvelopedData); the failInfo
auth-attr is populated.
* Pending path: encapContent is empty; client polls via GetCertInitial.
* Auth-attr ordering 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).
* senderNonce is freshly generated from crypto/rand on every call.
* RA key signs the canonical SET OF Attribute re-serialisation (RFC
5652 §5.4 quirk every CMS implementation hits — wire form is [0]
IMPLICIT but the signature is computed over EXPLICIT SET OF).
* Helper functions: buildCertRepAuthAttrs, buildSignerInfoCertRep,
signCertRep, buildEncapContentInfo, buildEnvelopedDataAES256, all
constructed via this package's existing ASN1Wrap primitives (avoids
asn1.Marshal nuances with nested RawValues — same pattern Phase 2
settled on).
internal/pkcs7/signedinfo.go (1-line tweak)
* ParseSignedData no longer refuses when SignerInfos is empty. The
degenerate certs-only SignedData form (RFC 8894 §3.5.1 GetCACert
response, RFC 7030 EST cacerts, AND now the encrypted certs-only
inner content of the CertRep EnvelopedData) is structurally valid
with zero signers. Caller decides whether the lack of signers is
an error in their context.
internal/pkcs7/certrep_test.go (new, ~230 LoC)
* TestBuildCertRepPKIMessage_Success_RoundTrip — full pipeline
round-trip: build → ParseSignedData → VerifySignature → auth-attr
extractors → ParseEnvelopedData(encapContent) → Decrypt with device
key → ParseSignedData(innerCertsOnly) → assert issued cert CN.
Catches drift between the build-side encoding and the parse-side
decoding.
* TestBuildCertRepPKIMessage_Failure_NoEncapContent — pkiStatus=2 +
failInfo populated; encapContent empty.
* TestBuildCertRepPKIMessage_FreshSenderNonceEachCall — pins the
'never reuse senderNonce' invariant from RFC 8894 §3.2.1.4.5
(replay defense).
* TestBuildCertRepPKIMessage_RejectsNonRSADeviceCert — pins the
RSA-only requirement on the device's transient cert (KTRI requires
RSA pubkey for keyTrans encryption).
* TestBuildCertRepPKIMessage_NilArgs_Refuses.
internal/pkcs7/certrep_fuzz_test.go (new, ~150 LoC)
* FuzzBuildCertRepPKIMessage — varies transactionID + senderNonce +
signerCert; asserts no panic. When build succeeds for the success
path, asserts round-trip soundness (output parses back via
ParseSignedData). 6s seed-corpus run hit no panics.
internal/api/handler/scep.go
* pkiOperation now emits writeCertRepPKIMessage for the RFC 8894
path (both success AND failure). MVP path keeps writeSCEPResponse
for backward compat with lightweight clients.
* tryParseRFC8894 extended to extract the RFC 2985 §5.4.1
challengePassword attribute from the recovered CSR, so the
service-layer's challenge-password gate can run on the RFC 8894
path the same way it does on the MVP path. Returns
(envelope, csrPEM, challengePassword, ok) — was 3-tuple before.
* extractChallengePasswordFromCSR helper mirrors the MVP path's
extractCSRFields logic; same staticcheck SA1019 carve-out for
the deprecated csr.Attributes API (RFC 2985 challengePassword
has no non-deprecated stdlib API per the M-028 audit closure).
* writeCertRepPKIMessage helper wraps pkcs7.BuildCertRepPKIMessage;
on build failure (programmer/config bug) returns HTTP 500 rather
than try a fallback PKIMessage that might re-trigger the same bug.
Verification:
* gofmt + go vet clean across pkcs7 / api/handler.
* go test -short -count=1 green across pkcs7 / api/handler /
api/router / service / cmd/server.
* Coverage: pkcs7 80.5% (was 78.4% before Phase 3). Handler/service
held steady.
* Fuzz seed-corpus (6s): FuzzBuildCertRepPKIMessage — no panic;
round-trip soundness invariant held for every successful build.
Phase 3 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
161 lines
5.4 KiB
Go
161 lines
5.4 KiB
Go
package pkcs7
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// FuzzBuildCertRepPKIMessage stresses the CertRep builder with attacker-
|
|
// controlled transactionID + nonce + signerCert bytes. The invariants are:
|
|
// 1. No panic for arbitrary inputs.
|
|
// 2. When build succeeds AND status is success, the output parses back
|
|
// via ParseSignedData (round-trip soundness — the prompt's required
|
|
// fuzz invariant).
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 3.3.
|
|
//
|
|
// The fuzzer holds the RA pair constant (one-time setup) and lets the
|
|
// fuzz engine vary the unstable inputs. Errors from BuildCertRepPKIMessage
|
|
// are expected for malformed signerCert bytes; only a panic = bug.
|
|
|
|
func FuzzBuildCertRepPKIMessage(f *testing.F) {
|
|
// Seed: empty everything (should error cleanly via the nil-args gate).
|
|
f.Add("", []byte{}, []byte{})
|
|
// Seed: minimal inputs that exercise the failure-path code (no
|
|
// SignerCert needed because Status=Failure short-circuits the
|
|
// EnvelopedData build).
|
|
f.Add("txn-1", make([]byte, 16), []byte{})
|
|
|
|
// One-time setup: RA pair stays constant across fuzz iterations.
|
|
raKey, raCert := genTestRSARAFuzz()
|
|
if raKey == nil {
|
|
f.Skip("test RA pair generation failed; environment lacks crypto/rand?")
|
|
}
|
|
|
|
f.Fuzz(func(t *testing.T, transactionID string, senderNonce []byte, signerCert []byte) {
|
|
req := &domain.SCEPRequestEnvelope{
|
|
MessageType: domain.SCEPMessageTypePKCSReq,
|
|
TransactionID: transactionID,
|
|
SenderNonce: senderNonce,
|
|
SignerCert: signerCert,
|
|
}
|
|
// Failure path: never needs SignerCert. No panic, no requirement
|
|
// on output (the failure shape is correct by construction).
|
|
respFail := &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusFailure,
|
|
FailInfo: domain.SCEPFailBadRequest,
|
|
TransactionID: transactionID,
|
|
RecipientNonce: senderNonce,
|
|
}
|
|
_, _ = BuildCertRepPKIMessage(req, respFail, raCert, raKey)
|
|
|
|
// Success path with arbitrary signerCert bytes: most inputs will
|
|
// fail to parse as a real cert; that's fine, BuildCertRep returns
|
|
// an error rather than panicking. When build succeeds (rare for
|
|
// random bytes), assert the output parses back.
|
|
respSuccess := &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusSuccess,
|
|
TransactionID: transactionID,
|
|
RecipientNonce: senderNonce,
|
|
Result: &domain.SCEPEnrollResult{
|
|
CertPEM: minimalIssuedCertPEMFuzz(raKey),
|
|
},
|
|
}
|
|
out, err := BuildCertRepPKIMessage(req, respSuccess, raCert, raKey)
|
|
if err != nil {
|
|
return // expected for arbitrary signerCert; no panic = ok
|
|
}
|
|
// Build succeeded — verify round-trip soundness.
|
|
sd, err := ParseSignedData(out)
|
|
if err != nil {
|
|
t.Errorf("BuildCertRepPKIMessage produced output that fails ParseSignedData: %v", err)
|
|
return
|
|
}
|
|
if len(sd.SignerInfos) == 0 {
|
|
t.Errorf("BuildCertRepPKIMessage produced output with no signerInfos")
|
|
}
|
|
})
|
|
}
|
|
|
|
// genTestRSARAFuzz materialises a one-time RA pair for the fuzz seed
|
|
// setup. Mirrors genTestRSARA from the round-trip tests but doesn't
|
|
// take *testing.T (called from f.Fuzz setup, not a test body).
|
|
func genTestRSARAFuzz() (*rsa.PrivateKey, *x509.Certificate) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "fuzz-ra"},
|
|
Issuer: pkix.Name{CommonName: "fuzz-ra"},
|
|
NotBefore: time.Now().Add(-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 {
|
|
return nil, nil
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
return key, cert
|
|
}
|
|
|
|
// minimalIssuedCertPEMFuzz returns a tiny self-signed PEM cert reusing
|
|
// the RA key. Avoids per-fuzz-iter rsa.GenerateKey overhead (which would
|
|
// dominate the fuzz throughput).
|
|
func minimalIssuedCertPEMFuzz(key *rsa.PrivateKey) string {
|
|
// We construct on demand since the issued cert template doesn't
|
|
// matter beyond being a parseable PEM-wrapped DER cert.
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(2),
|
|
Subject: pkix.Name{CommonName: "fuzz-issued"},
|
|
Issuer: pkix.Name{CommonName: "fuzz-issued"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return "-----BEGIN CERTIFICATE-----\n" +
|
|
derToBase64Fuzz(der) +
|
|
"-----END CERTIFICATE-----\n"
|
|
}
|
|
|
|
func derToBase64Fuzz(der []byte) string {
|
|
const enc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
var out []byte
|
|
pad := (3 - len(der)%3) % 3
|
|
padded := append(append([]byte{}, der...), make([]byte, pad)...)
|
|
for i := 0; i < len(padded); i += 3 {
|
|
v := uint32(padded[i])<<16 | uint32(padded[i+1])<<8 | uint32(padded[i+2])
|
|
out = append(out, enc[v>>18&0x3f], enc[v>>12&0x3f], enc[v>>6&0x3f], enc[v&0x3f])
|
|
}
|
|
for i := 0; i < pad; i++ {
|
|
out[len(out)-1-i] = '='
|
|
}
|
|
// Wrap at 64 chars per PEM convention.
|
|
var wrapped []byte
|
|
for i := 0; i < len(out); i += 64 {
|
|
end := i + 64
|
|
if end > len(out) {
|
|
end = len(out)
|
|
}
|
|
wrapped = append(wrapped, out[i:end]...)
|
|
wrapped = append(wrapped, '\n')
|
|
}
|
|
return string(wrapped)
|
|
}
|