feat(scep): add RFC 8894 message-type constants + RA cert/key config

SCEP RFC 8894 + Intune master bundle — Phase 0 + Phase 1 of 14.

Phase 0 (recon, no code changes):
  Baseline tests green at HEAD 2519da8 (handler 79.0% / service 73.2% /
  pkcs7 100%). SCEPConfig actual line is 666, prompt cited 639 — used
  actual per the 'repo wins' operating rule.

Phase 1 (this commit):

internal/domain/scep.go
  * Added SCEPMessageTypeCertRep (3) — RFC 8894 §3.3.2 server response
    messageType. Clients pivot on this to extract a cert (Status=Success),
    surface a failInfo (Status=Failure), or poll (Status=Pending).
  * Added SCEPMessageTypeRenewalReq (17) — RFC 8894 §3.3.1.2
    re-enrollment with an existing valid cert; signerInfo signed by the
    existing cert (proving possession).
  * Added SCEPRequestEnvelope struct — parsed authenticated attributes
    from the inbound signerInfo (messageType / transactionID /
    senderNonce / signerCert).
  * Added SCEPResponseEnvelope struct — what the service hands back to
    the handler so the handler can build the CertRep PKIMessage with
    the correct status / failInfo / nonce echoes.
  * Existing constants preserved unchanged.

internal/config/config.go
  * SCEPConfig.RACertPath + RAKeyPath fields with the doc-comment density
    matching the existing ChallengePassword field.
  * Env-var loading: CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH.
  * Validate() refuse: SCEP enabled with empty RA pair fails loud at
    startup (defense-in-depth with the new preflight gate below).

cmd/server/main.go
  * preflightSCEPRACertKey: file existence, mode 0600 gate (refuses
    world-/group-readable RA key), tls.X509KeyPair-based parse + match
    + algorithm check (one stdlib call covers parse + cert-key match +
    pubkey alg in one shot), expiry check, RSA-or-ECDSA gate (RFC 8894
    §3.5.2 CMS signing requirement). Mirrors preflightSCEPChallenge-
    Password's no-op-when-disabled pattern; each failure returns a
    wrapped error so the caller (main) translates to a structured
    slog.Error + os.Exit(1).
  * Wired into the SCEP startup block immediately after the existing
    challenge-password preflight; if it errors, the server refuses to
    boot with a specific log line + the pointer to docs/legacy-est-scep.md
    for the openssl recipe.
  * Added crypto/tls + crypto/x509 imports.

cmd/server/preflight_scep_ra_test.go (new)
  * Seven hermetic table-driven test cases covering each failure mode
    spelled out in the helper's docblock plus the no-op-when-disabled
    path. Each case materialises a real ECDSA P-256 cert/key pair on
    disk so the tls.X509KeyPair path is exercised end-to-end (catches
    drift in stdlib cert-parsing semantics that a mock would hide):
      - disabled SCEP no-op
      - missing paths (3 sub-cases: both empty, cert only, key only)
      - world-readable key (chmod 0644)
      - valid pair (happy path)
      - expired cert (NotAfter in past)
      - mismatched pair (cert from one ECDSA pair, key from another)
      - missing files (paths set but files don't exist)
      - ed25519 RA key (unsupported alg per RFC 8894 §3.5.2)
  * writeECDSARAPair helper materialises a fresh ECDSA pair under the
    test temp dir with the cert at 0644 and the key at 0600 (production
    deploy mode).

internal/config/config_test.go
  * TestValidate_SCEPEnabled_MissingRAPair_Refuses — 3 sub-cases pin
    the new Validate() refuse path (both empty, cert only, key only).
  * TestValidate_SCEPEnabled_CompleteRAPair_Accepts — pins the boundary
    that file-existence is the preflight's job, NOT Validate's.
  * TestValidate_SCEPDisabled_EmptyRAPair_Accepts — pins that the gate
    only fires when SCEP is enabled (mirrors the CHALLENGE_PASSWORD
    disabled-passes precedent).

docs/features.md
  * SCEP env-vars table extended with CERTCTL_SCEP_RA_CERT_PATH and
    CERTCTL_SCEP_RA_KEY_PATH (with the prod 'MUST set' callout +
    file-mode 0600 requirement). Closes the G-3 'env var defined in Go
    but never documented' CI guard for the new vars.

Verification:
  * gofmt clean for the files I touched (preflight_scep_ra_test.go +
    config.go + scep.go); pre-existing gofmt drift in unrelated files
    not in scope.
  * go vet ./internal/domain/... ./internal/config/... ./cmd/server/...
    clean.
  * go test -short -count=1 ./internal/domain/... ./internal/config/...
    ./cmd/server/... green.
  * Coverage held at handler 79.0% / service 73.2% / pkcs7 100% /
    config 96.1% / domain 88.6%.
  * Local G-3 set difference (Go-defined env vars ∖ docs-mentioned env
    vars) empty.

No behavior change for operators who don't enable SCEP. New behavior
gated by CERTCTL_SCEP_ENABLED=true + the new RA env vars. The MVP
raw-CSR fall-through path stays unchanged — Phase 2 will add the
RFC 8894 EnvelopedData decryption that consumes the RA pair.

Phase 1 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
This commit is contained in:
shankar0123
2026-04-29 03:35:11 +00:00
parent 2519da85f0
commit 105c307d62
6 changed files with 569 additions and 4 deletions
+121
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"net"
@@ -743,6 +745,25 @@ func main() {
)
os.Exit(1)
}
// SCEP RFC 8894 Phase 1: validate the RA cert/key pair before booting.
// Without a valid pair the new RFC 8894 PKIMessage path (EnvelopedData
// decryption + CertRep signing) cannot run; fail loud at startup rather
// than silently falling through to the MVP raw-CSR path on every
// request. preflightSCEPRACertKey checks: file existence, key file mode
// 0600 (defense-in-depth against world-readable RA key), cert/key
// algorithm match, RA cert not expired, RA cert public-key algorithm is
// CMS-compatible (RSA or ECDSA per RFC 8894 §3.5.2). Mirrors
// preflightSCEPChallengePassword's fail-loud-then-os.Exit(1) pattern.
if err := preflightSCEPRACertKey(cfg.SCEP.Enabled, cfg.SCEP.RACertPath, cfg.SCEP.RAKeyPath); err != nil {
logger.Error(
"startup refused: SCEP RA cert/key preflight failed "+
"(RFC 8894 §3.2.2 EnvelopedData + §3.3.2 CertRep require an RA pair). "+
"Generate the RA pair per docs/legacy-est-scep.md, set "+
"CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH, then restart.",
"error", err,
)
os.Exit(1)
}
issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID)
if !ok {
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
@@ -1105,6 +1126,106 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
return nil
}
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
// pattern; otherwise the checks are:
//
// 1. Both paths are non-empty (the Validate() refuse covers this too,
// but preflight reports the specific failure mode + os.Exit(1) so the
// operator sees a clear log line in addition to the config error).
// 2. The key file mode is 0600 (refuse world-/group-readable RA key —
// defense-in-depth against credential leak via a misconfigured
// deploy that leaves /etc/certctl/scep/*.key as 0644).
// 3. Cert PEM parses to exactly one x509.Certificate.
// 4. Key PEM parses to a Go crypto.Signer (RSA or ECDSA — RFC 8894
// §3.5.2 advertises those as the CMS-compatible algorithms).
// 5. The cert's PublicKey matches the key's Public() — refuses pairs
// accidentally swapped between profiles in a multi-profile config.
// 6. The cert's NotAfter is in the future — an expired RA cert would
// fail TLS handshake on EnvelopedData decryption per RFC 5652.
//
// Each check returns a wrapped error; the caller (main) is responsible for
// translating to a structured slog.Error + os.Exit(1) so the helper stays
// unit-testable without booting the full server.
func preflightSCEPRACertKey(enabled bool, raCertPath, raKeyPath string) error {
if !enabled {
return nil
}
if raCertPath == "" || raKeyPath == "" {
return fmt.Errorf("SCEP enabled but RA pair missing: " +
"set CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH " +
"(RFC 8894 §3.2.2 requires an RA pair so clients can encrypt the " +
"CSR to the RA cert and the server can sign the CertRep response)")
}
// File mode check FIRST so a world-readable key never gets read into the
// process address space. Ignored on Windows (Stat().Mode() doesn't carry
// POSIX bits there); the production deploy is Linux per the Dockerfile.
keyInfo, err := os.Stat(raKeyPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH stat failed: %w (path=%s)", err, raKeyPath)
}
mode := keyInfo.Mode().Perm()
if mode&0o077 != 0 {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH has insecure permissions %#o; "+
"RA private key must be mode 0600 (owner read/write only) — "+
"chmod 0600 %s and restart", mode, raKeyPath)
}
certPEM, err := os.ReadFile(raCertPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_CERT_PATH read failed: %w (path=%s)", err, raCertPath)
}
keyPEM, err := os.ReadFile(raKeyPath)
if err != nil {
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH read failed: %w (path=%s)", err, raKeyPath)
}
// tls.X509KeyPair validates that the cert + key parse, share an algorithm,
// and the cert's PublicKey matches the key's Public() — three of our six
// checks in a single stdlib call, so we use it rather than re-implementing.
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("RA cert/key pair invalid: %w "+
"(cert=%s key=%s) — verify the cert and key are matching halves of "+
"the same RA pair, both PEM-encoded, with the cert containing exactly "+
"one CERTIFICATE block and the key containing one PRIVATE KEY block",
err, raCertPath, raKeyPath)
}
if len(pair.Certificate) == 0 {
// Defensive — tls.X509KeyPair already errors on this, but the contract
// for the next x509.ParseCertificate call needs the slice non-empty.
return fmt.Errorf("RA cert PEM at %s contains no certificate blocks", raCertPath)
}
// Re-parse the leaf so we can read NotAfter + the public-key alg.
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return fmt.Errorf("RA cert at %s does not parse as x509: %w", raCertPath, err)
}
if time.Now().After(leaf.NotAfter) {
return fmt.Errorf("RA cert at %s expired at %s — "+
"generate a fresh RA pair (the SCEP CertRep signature would be "+
"rejected by every conformant client)", raCertPath, leaf.NotAfter.Format(time.RFC3339))
}
// CMS-compatible public-key algorithm gate. RFC 8894 §3.5.2 advertises RSA
// and AES; the responder cert algorithm pertains to the signature scheme
// used on the CertRep, which means the cert's PublicKey must be RSA or
// ECDSA. Catches pre-shared Ed25519 dev keys that micromdm/scep clients
// reject.
switch leaf.PublicKeyAlgorithm {
case x509.RSA, x509.ECDSA:
// ok — supported by golang.org/x/crypto/ocsp + every SCEP client
default:
return fmt.Errorf("RA cert at %s uses unsupported public-key algorithm %s — "+
"RFC 8894 §3.5.2 CMS signing requires RSA or ECDSA",
raCertPath, leaf.PublicKeyAlgorithm)
}
return nil
}
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
// can actually serve a CA certificate. This closes audit finding L-005:
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the