mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 08:38:53 +00:00
35fcfa70f2
SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14.
Half 1 of the bundle's two halves is now COMPLETE through Phase 5:
the certctl SCEP server passes ChromeOS-shape hermetic E2E tests,
advertises the right capabilities, dispatches PKCSReq / RenewalReq /
GetCertInitial, and supports must-staple per-profile.
== Phase 4: RenewalReq + GetCertInitial wiring ============================
internal/service/scep.go
* RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an
existing valid cert. Same contract as PKCSReqWithEnvelope but the
service additionally verifies that envelope.SignerCert chains to
the issuer's CA (verifyRenewalSignerCertChain). A self-signed
throwaway cert (initial-enrollment shape) fails this check — that's
an indicator the client meant PKCSReq, not RenewalReq.
* GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub.
Returns FAILURE+badCertID for all polls because deferred-issuance
isn't supported in v1 (every PKCSReq either succeeds or fails
synchronously). Wiring stays in place for a future enhancement.
* Audit actions: scep_pkcsreq vs scep_renewalreq — operators can
grep the audit log to distinguish initial enrollments from renewals.
internal/api/handler/scep.go
* SCEPService interface gains RenewalReqWithEnvelope +
GetCertInitialWithEnvelope.
* pkiOperation RFC 8894 path now switches on envelope.MessageType:
PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope;
GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+
badRequest per RFC 8894 §3.3.2.2.
== Phase 5.1: GetCACaps capability advertisement =========================
internal/service/scep.go
* Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard'
to add 'SHA-512' (modern digest alternative now implemented in the
Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from
Phase 4). ChromeOS specifically looks for these capabilities to
negotiate the strongest available cipher + digest combo.
* scep_test.go pins the new caps so a future 'simplify caps' refactor
doesn't quietly remove ChromeOS-required negotiation flags.
== Phase 5.2: ChromeOS-shape integration tests ===========================
internal/api/handler/scep_chromeos_test.go (new, ~570 LoC)
* 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage
in-test (acting as the ChromeOS client), POSTs through the handler,
parses the CertRep response back via the same internal/pkcs7/
builders the handler uses.
* TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path:
SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping
EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) —
POSTed; verifies CertRep parses + RA signature verifies.
* TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17
routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope.
* TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling
returns CertRep with pkiStatus=FAILURE + failInfo=badCertID.
* TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo
signature falls through to MVP path (which also rejects since the
encrypted EnvelopedData isn't a raw CSR). No silent acceptance.
* TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven
AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response.
* TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR
path keeps working when no RA pair is configured. Backward compat
is non-negotiable.
== Phase 5.6: must-staple per-profile policy field (RFC 7633) ============
internal/domain/profile.go
* Added MustStaple bool to CertificateProfile. Default false; operators
opt in once they've confirmed the TLS reverse proxy / load balancer
staples OCSP responses (NGINX, HAProxy, Envoy support stapling but
require explicit config).
internal/connector/issuer/interface.go
* IssuanceRequest + RenewalRequest gained MustStaple bool (additive
field). Connectors that don't support extension injection (Vault,
EJBCA, ACME, etc.) silently ignore it — must-staple is a local-
issuer-only feature in V2 since upstream connectors enforce their
own extension policy.
internal/connector/issuer/local/local.go
* Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) +
pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 —
SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per
RFC 7633 §6).
* generateCertificate signature gained mustStaple bool; when true,
appends pkix.Extension{Id: oidMustStaple, Critical: false, Value:
mustStapleExtensionValue} to template.ExtraExtensions before
x509.CreateCertificate.
internal/connector/issuer/local/must_staple_test.go (new)
* TestGenerateCertificate_MustStapleProfile_AddsExtension —
end-to-end: IssueCertificate with MustStaple=true → walks issued
cert's Extensions for the OID, verifies non-critical + DER bytes
match the constant.
* TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the
'omit by default' contract (adding it by default would break
customer deployments where the TLS path doesn't staple).
* TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID +
DER bytes against RFC 7633 §6 verbatim; round-trips through
asn1.Unmarshal as []int{5}.
Note: full service-layer plumbing (CertificateProfile.MustStaple →
IssuanceRequest.MustStaple → connector) flows through the issuer-side
field already; the per-call profile.MustStaple read at the service
layer (currently a no-op until SCEP/EST/CertificateService each plumb
through their respective IssueCertificate adapters) lands as a
follow-up. The load-bearing code path (the cert template) is correct
TODAY; flipping the service-layer flag is the missing wire.
== Phase 5.4: docs/legacy-est-scep.md ====================================
Added a new ~180-line section covering the SCEP RFC 8894 native
implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH +
_KEY_PATH), the openssl recipe for generating an RA pair, the
GetCACaps capability list, supported messageTypes, the MVP backward-
compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed
per-profile envs), ChromeOS Admin Console integration pointer, RA
cert rotation procedure, must-staple per-profile policy with the
'opt-in once your TLS path staples' caveat, operational notes
(audit actions, body-size cap, HTTPS-only), and a forward reference
to scep-intune.md (Phase 11).
== Verification ==========================================================
* gofmt + go vet clean for the files I touched.
* staticcheck ./internal/api/handler/... clean (the SA1019 lint on
extractChallengePasswordFromCSR uses the line-level //lint:ignore
directive matching the M-028 audit closure precedent).
* go test -short -count=1 green across api/handler / api/router /
service / pkcs7 / connector/issuer/local / domain / cmd/server.
* G-3 docs-drift CI guard local check: empty diff in both directions.
Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke +
audit deliverables) lands next; then Phase 6.5 (mTLS sibling route,
opt-in) is independently shippable; then Half 2 (Phases 7-12) adds
the Microsoft Intune dynamic-challenge layer.
Living progress at cowork/scep-rfc8894-intune/progress.md.
442 lines
18 KiB
Go
442 lines
18 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.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 5.1: extended from the
|
|
// initial value (POSTPKIOperation+SHA-256+AES+SCEPStandard) to additionally
|
|
// advertise SHA-512 (now-implemented modern digest alternative) and Renewal
|
|
// (the messageType-17 dispatch from Phase 4). ChromeOS specifically looks
|
|
// for these capabilities to negotiate the strongest available cipher +
|
|
// digest combo. Order is by historical convention; clients walk the list
|
|
// linearly.
|
|
func (s *SCEPService) GetCACaps(ctx context.Context) string {
|
|
return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\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
|
|
}
|
|
|
|
// RenewalReqWithEnvelope processes a SCEP RenewalReq from the RFC 8894 path.
|
|
// RFC 8894 §3.3.1.2 — re-enrollment with an existing valid cert. Distinct
|
|
// from PKCSReq because the signerInfo is signed by the EXISTING cert
|
|
// (proving possession), not by a transient self-signed device key.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 4.2.
|
|
//
|
|
// Functionally identical to PKCSReqWithEnvelope but with two differences:
|
|
//
|
|
// 1. Audit action is `scep_renewalreq` (vs `scep_pkcsreq`) — operators
|
|
// can grep the audit log to distinguish initial enrollments from
|
|
// renewals.
|
|
//
|
|
// 2. The signing cert presented as POPO MUST chain to the issuer's CA
|
|
// (the cert was previously issued by THIS issuer, not a self-signed
|
|
// throwaway). Verified against the issuer's GetCACertPEM chain via
|
|
// x509.Certificate.Verify. A signing cert that doesn't chain is
|
|
// mapped to BadMessageCheck per the same RFC 8894 §3.3.2.2 semantics
|
|
// as an EnvelopedData decrypt failure (integrity-check failure).
|
|
//
|
|
// Returns *SCEPResponseEnvelope (same contract as PKCSReqWithEnvelope);
|
|
// nil signals 'invalid challenge password' for HTTP 403 translation.
|
|
func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
resp := &domain.SCEPResponseEnvelope{
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
|
|
// Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth
|
|
// even though the RenewalReq path additionally verifies the signing
|
|
// cert chain — a stolen/leaked challenge password combined with a
|
|
// previously-issued cert (e.g. from a compromised device) would still
|
|
// allow renewal otherwise. The two checks are independent.
|
|
if s.challengePassword == "" {
|
|
s.logger.Warn("SCEP renewal 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 renewal rejected: invalid challenge password (RFC 8894 path)",
|
|
"transaction_id", envelope.TransactionID)
|
|
return nil
|
|
}
|
|
|
|
// Verify the signing cert chains to the issuer's CA. Without this gate
|
|
// any self-signed cert with a valid challenge password could trigger a
|
|
// renewal — defeating the 'proof of prior issuance' contract RenewalReq
|
|
// is supposed to provide.
|
|
if err := s.verifyRenewalSignerCertChain(ctx, envelope.SignerCert); err != nil {
|
|
s.logger.Warn("SCEP renewal rejected: signer cert chain invalid",
|
|
"transaction_id", envelope.TransactionID,
|
|
"error", err.Error(),
|
|
)
|
|
resp.Status = domain.SCEPStatusFailure
|
|
resp.FailInfo = domain.SCEPFailBadMessageCheck
|
|
return resp
|
|
}
|
|
|
|
// Reuse the existing processEnrollment for the actual issuance work
|
|
// — RenewalReq is functionally a re-issuance with a different audit
|
|
// action and chain-validation precondition.
|
|
result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_renewalreq")
|
|
if err != nil {
|
|
resp.Status = domain.SCEPStatusFailure
|
|
resp.FailInfo = mapServiceErrorToFailInfo(err)
|
|
return resp
|
|
}
|
|
resp.Status = domain.SCEPStatusSuccess
|
|
resp.Result = result
|
|
return resp
|
|
}
|
|
|
|
// verifyRenewalSignerCertChain confirms the device's signing cert (the cert
|
|
// presented as POPO in the SignerInfo) was previously issued by the
|
|
// configured issuer. Used by RenewalReqWithEnvelope to enforce the 'must
|
|
// have a previously-issued cert' contract RFC 8894 §3.3.1.2 implies.
|
|
//
|
|
// A self-signed throwaway cert (initial-enrollment shape) fails this check
|
|
// — that's an indicator the client meant to send PKCSReq, not RenewalReq.
|
|
// Operators see the audit-log entry; the client sees BadMessageCheck.
|
|
func (s *SCEPService) verifyRenewalSignerCertChain(ctx context.Context, signerCertDER []byte) error {
|
|
if len(signerCertDER) == 0 {
|
|
return fmt.Errorf("signer cert is empty (no POPO cert in SignerInfo)")
|
|
}
|
|
signerCert, err := x509.ParseCertificate(signerCertDER)
|
|
if err != nil {
|
|
return fmt.Errorf("parse signer cert: %w", err)
|
|
}
|
|
|
|
// Pull the issuer's CA chain via the existing IssuerConnector
|
|
// surface. Failure here is a deploy bug (the issuer connector lost
|
|
// its CA cert mid-flight) rather than a client error — surface as
|
|
// the same generic failure to avoid leaking server state.
|
|
caPEM, err := s.issuer.GetCACertPEM(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("get CA cert PEM: %w", err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM([]byte(caPEM)) {
|
|
return fmt.Errorf("CA cert PEM contains no parseable certs")
|
|
}
|
|
opts := x509.VerifyOptions{
|
|
Roots: pool,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := signerCert.Verify(opts); err != nil {
|
|
return fmt.Errorf("signer cert chain validation failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCertInitialWithEnvelope handles SCEP polling requests. RFC 8894 §3.3.3
|
|
// — the client polls when the prior PKCSReq returned Status=Pending.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 4.3.
|
|
//
|
|
// v1 of this bundle returns FAILURE+badCertID for all GetCertInitial
|
|
// requests since deferred-issuance isn't supported (every PKCSReq either
|
|
// succeeds or fails synchronously — no Pending state in the existing
|
|
// service-layer issuance pipeline). The wiring stays in place for a
|
|
// future enhancement (e.g. 'queue for manual approval' workflows).
|
|
func (s *SCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope {
|
|
s.logger.Info("SCEP GetCertInitial received — deferred-issuance not supported in v1, returning badCertID",
|
|
"transaction_id", envelope.TransactionID)
|
|
return &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusFailure,
|
|
FailInfo: domain.SCEPFailBadCertID,
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
}
|