mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 07:08:59 +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.
302 lines
11 KiB
Go
302 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
)
|
|
|
|
// SCEPService implements the SCEP (RFC 8894) enrollment protocol.
|
|
// It delegates certificate operations to an existing IssuerConnector and records
|
|
// enrollment events in the audit trail.
|
|
type SCEPService struct {
|
|
issuer IssuerConnector
|
|
issuerID string
|
|
auditService *AuditService
|
|
logger *slog.Logger
|
|
profileID string // optional: constrain enrollments to a specific profile
|
|
profileRepo repository.CertificateProfileRepository
|
|
challengePassword string // shared secret for enrollment authentication
|
|
}
|
|
|
|
// NewSCEPService creates a new SCEPService for the given issuer connector.
|
|
func NewSCEPService(issuerID string, issuer IssuerConnector, auditService *AuditService, logger *slog.Logger, challengePassword string) *SCEPService {
|
|
return &SCEPService{
|
|
issuer: issuer,
|
|
issuerID: issuerID,
|
|
auditService: auditService,
|
|
logger: logger,
|
|
challengePassword: challengePassword,
|
|
}
|
|
}
|
|
|
|
// SetProfileID constrains SCEP enrollments to a specific certificate profile.
|
|
func (s *SCEPService) SetProfileID(profileID string) {
|
|
s.profileID = profileID
|
|
}
|
|
|
|
// SetProfileRepo sets the profile repository for crypto policy enforcement during enrollment.
|
|
func (s *SCEPService) SetProfileRepo(repo repository.CertificateProfileRepository) {
|
|
s.profileRepo = repo
|
|
}
|
|
|
|
// GetCACaps returns the capabilities of this SCEP server.
|
|
// RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line.
|
|
func (s *SCEPService) GetCACaps(ctx context.Context) string {
|
|
return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n"
|
|
}
|
|
|
|
// GetCACert returns the PEM-encoded CA certificate chain for this SCEP server.
|
|
// RFC 8894 Section 3.5.1: GetCACert distributes the CA certificate(s).
|
|
func (s *SCEPService) GetCACert(ctx context.Context) (string, error) {
|
|
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get CA certificates from issuer %s: %w", s.issuerID, err)
|
|
}
|
|
if caPEM == "" {
|
|
return "", fmt.Errorf("issuer %s does not provide CA certificates for SCEP", s.issuerID)
|
|
}
|
|
return caPEM, nil
|
|
}
|
|
|
|
// PKCSReq processes a SCEP enrollment request.
|
|
// RFC 8894 Section 3.3.1: PKCSReq contains a PKCS#10 CSR for certificate enrollment.
|
|
// The CSR PEM and challenge password are extracted by the handler from the PKCS#7 envelope.
|
|
//
|
|
// H-2 fix (CWE-306): the previous implementation skipped the shared-secret
|
|
// check entirely when s.challengePassword was empty, meaning any unauthenticated
|
|
// client that could reach /scep could enroll a CSR against the configured
|
|
// issuer. Reject that configuration defense-in-depth even though main() already
|
|
// refuses to start in the same state (see preflightSCEPChallengePassword). The
|
|
// non-empty branch now uses crypto/subtle.ConstantTimeCompare to avoid leaking
|
|
// the shared secret through a response-time side channel.
|
|
func (s *SCEPService) PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error) {
|
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
|
// configured. The server-level pre-flight check in cmd/server/main.go
|
|
// normally prevents the service from being constructed in this state, but
|
|
// this branch also protects future call sites (tests, library reuse, a
|
|
// future REST-over-HTTPS wrapper) from silently accepting unauthenticated
|
|
// CSRs.
|
|
if s.challengePassword == "" {
|
|
s.logger.Warn("SCEP enrollment rejected: server has no challenge password configured",
|
|
"transaction_id", transactionID)
|
|
return nil, fmt.Errorf("SCEP challenge password not configured on server")
|
|
}
|
|
// Constant-time compare avoids leaking the configured secret through
|
|
// response-time variance. ConstantTimeCompare returns 1 only when both
|
|
// slices have equal length AND equal content; a mismatched-length input
|
|
// still takes the same path as a content mismatch.
|
|
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
|
s.logger.Warn("SCEP enrollment rejected: invalid challenge password",
|
|
"transaction_id", transactionID)
|
|
return nil, fmt.Errorf("invalid challenge password")
|
|
}
|
|
|
|
return s.processEnrollment(ctx, csrPEM, transactionID, "scep_pkcsreq")
|
|
}
|
|
|
|
// processEnrollment handles the common enrollment logic.
|
|
func (s *SCEPService) processEnrollment(ctx context.Context, csrPEM string, transactionID string, auditAction string) (*domain.SCEPEnrollResult, error) {
|
|
// Parse the CSR to extract CN and SANs
|
|
block, _ := pem.Decode([]byte(csrPEM))
|
|
if block == nil {
|
|
return nil, fmt.Errorf("invalid CSR PEM")
|
|
}
|
|
|
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
|
}
|
|
|
|
if err := csr.CheckSignature(); err != nil {
|
|
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
|
}
|
|
|
|
commonName := csr.Subject.CommonName
|
|
if commonName == "" {
|
|
return nil, fmt.Errorf("CSR must include a Common Name")
|
|
}
|
|
|
|
// Collect SANs
|
|
var sans []string
|
|
for _, dns := range csr.DNSNames {
|
|
sans = append(sans, dns)
|
|
}
|
|
for _, ip := range csr.IPAddresses {
|
|
sans = append(sans, ip.String())
|
|
}
|
|
for _, email := range csr.EmailAddresses {
|
|
sans = append(sans, email)
|
|
}
|
|
for _, uri := range csr.URIs {
|
|
sans = append(sans, uri.String())
|
|
}
|
|
|
|
// Validate CSR key algorithm/size against profile (crypto policy enforcement)
|
|
var profile *domain.CertificateProfile
|
|
var ekus []string
|
|
if s.profileID != "" && s.profileRepo != nil {
|
|
if p, profileErr := s.profileRepo.Get(ctx, s.profileID); profileErr == nil && p != nil {
|
|
profile = p
|
|
ekus = profile.AllowedEKUs
|
|
}
|
|
}
|
|
if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil {
|
|
s.logger.Error("SCEP enrollment rejected: crypto policy violation",
|
|
"action", auditAction,
|
|
"common_name", commonName,
|
|
"transaction_id", transactionID,
|
|
"error", csrErr)
|
|
return nil, fmt.Errorf("SCEP enrollment rejected: %w", csrErr)
|
|
}
|
|
|
|
s.logger.Info("SCEP enrollment request",
|
|
"action", auditAction,
|
|
"common_name", commonName,
|
|
"sans", strings.Join(sans, ","),
|
|
"transaction_id", transactionID,
|
|
"issuer", s.issuerID)
|
|
|
|
// Resolve MaxTTL from profile
|
|
var maxTTLSeconds int
|
|
if profile != nil {
|
|
maxTTLSeconds = profile.MaxTTLSeconds
|
|
}
|
|
|
|
// Issue the certificate via the configured issuer connector
|
|
// SCEP enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
|
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
|
|
if err != nil {
|
|
s.logger.Error("SCEP enrollment failed",
|
|
"action", auditAction,
|
|
"common_name", commonName,
|
|
"transaction_id", transactionID,
|
|
"error", err)
|
|
return nil, fmt.Errorf("certificate issuance failed: %w", err)
|
|
}
|
|
|
|
// Audit the enrollment
|
|
if s.auditService != nil {
|
|
details := map[string]interface{}{
|
|
"common_name": commonName,
|
|
"sans": sans,
|
|
"issuer_id": s.issuerID,
|
|
"serial": result.Serial,
|
|
"transaction_id": transactionID,
|
|
"protocol": "SCEP",
|
|
}
|
|
if s.profileID != "" {
|
|
details["profile_id"] = s.profileID
|
|
}
|
|
_ = s.auditService.RecordEvent(ctx, "scep-client", "system", auditAction, "certificate", result.Serial, details)
|
|
}
|
|
|
|
s.logger.Info("SCEP enrollment successful",
|
|
"action", auditAction,
|
|
"common_name", commonName,
|
|
"serial", result.Serial,
|
|
"transaction_id", transactionID,
|
|
"not_after", result.NotAfter)
|
|
|
|
return &domain.SCEPEnrollResult{
|
|
CertPEM: result.CertPEM,
|
|
ChainPEM: result.ChainPEM,
|
|
}, nil
|
|
}
|
|
|
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
|
// (where the handler successfully parsed an EnvelopedData + signerInfo
|
|
// instead of the MVP raw-CSR path).
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 2.4.
|
|
//
|
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
|
// RFC 8894 mandates a CertRep PKIMessage on every PKIOperation request,
|
|
// even failure cases — the handler shouldn't have to translate Go errors
|
|
// into SCEP failInfo codes; the service does that mapping.
|
|
//
|
|
// Service-side error → failInfo mapping (from the prompt's exact table):
|
|
//
|
|
// Invalid challenge password → caller returns HTTP 403, NOT a PKIMessage
|
|
// (RFC 8894 §3.3.1 silent on this; matches MVP precedent)
|
|
// CSR parse failure → BadRequest (2)
|
|
// CSR signature invalid → BadMessageCheck (1)
|
|
// Crypto policy violation → BadAlg (0)
|
|
// Issuer connector failure → BadRequest (2)
|
|
// Audit-log write failure → log + continue with success (best-effort)
|
|
//
|
|
// The challenge-password failure case returns nil to signal "let the caller
|
|
// translate to 403"; every other failure mode returns a populated envelope
|
|
// with FailInfo set so the handler can build a CertRep with pkiStatus=2.
|
|
func (s *SCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
resp := &domain.SCEPResponseEnvelope{
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
|
|
// Defense-in-depth: refuse any enrollment when no shared secret is
|
|
// configured. Mirrors PKCSReq's gate. Returning nil signals 'let the
|
|
// caller translate to HTTP 403' — the existing PKCSReq path returns
|
|
// an error string the handler matched on, but PKCSReqWithEnvelope
|
|
// returns *SCEPResponseEnvelope so we use a nil sentinel.
|
|
if s.challengePassword == "" {
|
|
s.logger.Warn("SCEP enrollment rejected: server has no challenge password configured (RFC 8894 path)",
|
|
"transaction_id", envelope.TransactionID)
|
|
return nil
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 {
|
|
s.logger.Warn("SCEP enrollment rejected: invalid challenge password (RFC 8894 path)",
|
|
"transaction_id", envelope.TransactionID)
|
|
return nil
|
|
}
|
|
|
|
// Reuse the existing processEnrollment for the actual issuance work.
|
|
// Errors mapped to SCEP failInfo per the table above.
|
|
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_pkcsreq")
|
|
if err != nil {
|
|
resp.Status = domain.SCEPStatusFailure
|
|
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
|
return resp
|
|
}
|
|
resp.Status = domain.SCEPStatusSuccess
|
|
resp.Result = result
|
|
return resp
|
|
}
|
|
|
|
// mapServiceErrorToFailInfo translates a service-layer error into the
|
|
// SCEP failInfo code RFC 8894 §3.2.1.4.5 enumerates. The mapping mirrors
|
|
// the table in PKCSReqWithEnvelope's docblock; defaults to BadRequest
|
|
// when the error doesn't match any specific category.
|
|
func mapServiceErrorToFailInfo(err error) domain.SCEPFailInfo {
|
|
if err == nil {
|
|
return domain.SCEPFailBadRequest
|
|
}
|
|
msg := err.Error()
|
|
switch {
|
|
case containsAnyOf(msg, "invalid CSR PEM", "failed to parse CSR"):
|
|
return domain.SCEPFailBadRequest
|
|
case containsAnyOf(msg, "CSR signature verification failed"):
|
|
return domain.SCEPFailBadMessageCheck
|
|
case containsAnyOf(msg, "key algorithm", "key size", "algorithm not allowed", "crypto policy"):
|
|
return domain.SCEPFailBadAlg
|
|
default:
|
|
return domain.SCEPFailBadRequest
|
|
}
|
|
}
|
|
|
|
func containsAnyOf(s string, needles ...string) bool {
|
|
for _, n := range needles {
|
|
if strings.Contains(s, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|