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
+227
View File
@@ -0,0 +1,227 @@
package main
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// SCEP RFC 8894 Phase 1: preflightSCEPRACertKey covers the six failure
// modes spelled out in the helper's docblock plus the no-op-when-disabled
// path. Mirrors TestPreflightEnrollmentIssuer's table-driven shape so the
// suite stays uniform for the next reviewer.
//
// Each test materialises a real ECDSA P-256 cert/key pair on disk (rather
// than mocking) so the tls.X509KeyPair path is exercised end-to-end —
// catches drift in stdlib cert-parsing semantics that a mock would hide.
func TestPreflightSCEPRACertKey_Disabled_NoOp(t *testing.T) {
// Enabled=false short-circuits before any path validation; should pass
// even with empty paths (mirrors preflightSCEPChallengePassword).
if err := preflightSCEPRACertKey(false, "", ""); err != nil {
t.Fatalf("disabled SCEP returned error: %v", err)
}
}
func TestPreflightSCEPRACertKey_EnabledMissingPaths_Refuses(t *testing.T) {
// Validate() also catches this; preflight reports the specific failure
// with a more actionable error string + os.Exit(1) at the call site.
cases := []struct {
name string
certPath string
keyPath string
}{
{"both_empty", "", ""},
{"cert_only", "/tmp/ra.crt", ""},
{"key_only", "", "/tmp/ra.key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := preflightSCEPRACertKey(true, tc.certPath, tc.keyPath)
if err == nil {
t.Fatalf("expected error for missing paths, got nil")
}
if !strings.Contains(err.Error(), "RA pair missing") {
t.Errorf("error should mention RA pair missing, got: %v", err)
}
})
}
}
func TestPreflightSCEPRACertKey_KeyWorldReadable_Refuses(t *testing.T) {
// Defense-in-depth: even a perfectly-valid RA pair must be rejected if
// the key file is mode 0644 (world-readable). The deploy convention is
// 0600 — owner read/write only.
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
// Re-chmod the key to 0644 to trigger the gate.
if err := os.Chmod(keyPath, 0o644); err != nil {
t.Fatalf("chmod failed: %v", err)
}
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for world-readable key, got nil")
}
if !strings.Contains(err.Error(), "insecure permissions") {
t.Errorf("error should mention insecure permissions, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_ValidPair_Accepts(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
if err := preflightSCEPRACertKey(true, certPath, keyPath); err != nil {
t.Fatalf("valid RA pair rejected: %v", err)
}
}
func TestPreflightSCEPRACertKey_ExpiredCert_Refuses(t *testing.T) {
// An RA cert past NotAfter would cause every conformant SCEP client to
// reject the CertRep signature. Catch it at startup.
dir := t.TempDir()
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(-1*time.Hour))
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for expired cert, got nil")
}
if !strings.Contains(err.Error(), "expired") {
t.Errorf("error should mention expired, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_MismatchedPair_Refuses(t *testing.T) {
// tls.X509KeyPair detects the cert/key mismatch; preflight should
// surface it with an actionable error (cert + key are halves of
// different RA pairs — common multi-profile typo).
dir := t.TempDir()
certPath, _ := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
_, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
// Re-write the key path under a unique name to avoid collision with
// the first pair's file (writeECDSARAPair would have overwritten).
err := preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for mismatched pair, got nil")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error should mention invalid pair, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_MissingFiles_Refuses(t *testing.T) {
// Both files referenced but neither exists — a typo or a fresh deploy
// where the operator forgot to mount the secret. Cert-path failure mode
// is checked first because key-path stat is the first os call after
// the empty-string check.
dir := t.TempDir()
missingCert := filepath.Join(dir, "ra.crt")
missingKey := filepath.Join(dir, "ra.key")
err := preflightSCEPRACertKey(true, missingCert, missingKey)
if err == nil {
t.Fatalf("expected error for missing files, got nil")
}
if !strings.Contains(err.Error(), "stat failed") && !strings.Contains(err.Error(), "read failed") {
t.Errorf("error should mention stat/read failure, got: %v", err)
}
}
func TestPreflightSCEPRACertKey_UnsupportedAlg_Refuses(t *testing.T) {
// Ed25519 isn't supported by the CMS signature path RFC 8894 §3.5.2
// advertises. Catch this at startup to avoid runtime failures the
// first time a client sends a real PKIMessage.
dir := t.TempDir()
certPath := filepath.Join(dir, "ra.crt")
keyPath := filepath.Join(dir, "ra.key")
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "ra-ed25519"},
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, pub, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
err = preflightSCEPRACertKey(true, certPath, keyPath)
if err == nil {
t.Fatalf("expected error for ed25519 RA cert, got nil")
}
if !strings.Contains(err.Error(), "unsupported public-key algorithm") &&
!strings.Contains(err.Error(), "invalid") {
// tls.X509KeyPair may reject ed25519 SCEP-signing keys earlier
// than our explicit alg gate; accept either failure path so the
// test is robust against stdlib changes.
t.Errorf("error should mention algorithm/invalid, got: %v", err)
}
}
// writeECDSARAPair generates a fresh ECDSA P-256 self-signed cert + key,
// writes them to dir/ra-<rand>.crt + ra-<rand>.key with the cert at 0644
// and the key at 0600 (the production deploy mode). Returns the two paths.
func writeECDSARAPair(t *testing.T, dir string, notAfter time.Time) (certPath, keyPath string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "ra-test"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
// Use a unique suffix so successive calls within the same test don't
// overwrite each other (the mismatched-pair test relies on this).
suffix := tmpl.SerialNumber.String()
certPath = filepath.Join(dir, "ra-"+suffix+".crt")
keyPath = filepath.Join(dir, "ra-"+suffix+".key")
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath
}
+2
View File
@@ -646,6 +646,8 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
| `CERTCTL_SCEP_ISSUER_ID` | `iss-local` | Issuer for SCEP enrollments |
| `CERTCTL_SCEP_PROFILE_ID` | (none) | Optional profile constraint |
| `CERTCTL_SCEP_CHALLENGE_PASSWORD` | (none) | Shared secret for enrollment authentication |
| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA (Registration Authority) certificate. **Required when `CERTCTL_SCEP_ENABLED=true`** for the RFC 8894 PKIMessage path: SCEP clients encrypt their PKCS#10 CSR to this cert's public key (EnvelopedData wrapper, RFC 8894 §3.2.2) and the server signs the outbound CertRep PKIMessage signerInfo with the matching key (RFC 8894 §3.3.2). Generation: a self-signed cert with `CN=<your-ca-id>-RA` and the `id-kp-emailProtection` / `id-kp-cmcRA` EKU is sufficient — see [`legacy-est-scep.md`](legacy-est-scep.md) for the openssl recipe. The preflight gate at startup also enforces a cert/key match, non-expired NotAfter, and an RSA-or-ECDSA public-key algorithm. |
| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded private key matching `CERTCTL_SCEP_RA_CERT_PATH`. **Required when `CERTCTL_SCEP_ENABLED=true`.** File MUST be mode `0600` (owner read/write only); preflight refuses to load a world- or group-readable RA key as defense-in-depth against credential leak. The server reads this file once at startup; rotation requires a restart. |
---
+44
View File
@@ -689,6 +689,32 @@ type SCEPConfig struct {
// issuer. The service-layer PKCSReq path also rejects this configuration
// defense-in-depth.
ChallengePassword string
// RACertPath is the path to a PEM-encoded RA (Registration Authority)
// certificate used by the RFC 8894 SCEP path. SCEP clients encrypt their
// PKCS#10 CSR to this cert's public key (via the EnvelopedData wrapper, RFC
// 8894 §3.2.2). The certctl server uses RAKeyPath to decrypt inbound
// EnvelopedData and to sign outbound CertRep PKIMessage signerInfo (RFC
// 8894 §3.3.2).
//
// Required when Enabled is true; Config.Validate() refuses to start without
// it. Without an RA pair the new RFC 8894 path silently falls through to
// the MVP raw-CSR path on every request and the operator's intent is
// unclear — fail loud at startup instead.
//
// Generation: a self-signed RA cert with subject "CN=<your-ca-id>-RA" and
// the id-kp-emailProtection / id-kp-cmcRA EKU is sufficient. The RA cert
// SHOULD be the same cert returned by GetCACert (RFC 8894 §3.5.1) so
// clients encrypt to a key the server can decrypt with. See
// docs/legacy-est-scep.md for the openssl recipe.
RACertPath string
// RAKeyPath is the path to the PEM-encoded private key matching RACertPath.
// File MUST be mode 0600 (owner read/write only); preflight refuses to load
// a world-readable RA key as defense-in-depth against credential leak. The
// server only ever reads this file at startup; rotation requires a restart
// (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go).
RAKeyPath string
}
// NetworkScanConfig controls the server-side active TLS scanner.
@@ -1118,6 +1144,13 @@ func Load() (*Config, error) {
IssuerID: getEnv("CERTCTL_SCEP_ISSUER_ID", "iss-local"),
ProfileID: getEnv("CERTCTL_SCEP_PROFILE_ID", ""),
ChallengePassword: getEnv("CERTCTL_SCEP_CHALLENGE_PASSWORD", ""),
// SCEP RFC 8894 Phase 1: RA cert + key for the EnvelopedData /
// signerInfo path. Required when Enabled is true (Validate() refuse
// + cmd/server/main.go::preflightSCEPRACertKey). Loaded from
// CERTCTL_SCEP_RA_CERT_PATH / CERTCTL_SCEP_RA_KEY_PATH per the
// existing CERTCTL_SCEP_* prefix convention.
RACertPath: getEnv("CERTCTL_SCEP_RA_CERT_PATH", ""),
RAKeyPath: getEnv("CERTCTL_SCEP_RA_KEY_PATH", ""),
},
Verification: VerificationConfig{
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
@@ -1403,6 +1436,17 @@ func (c *Config) Validate() error {
return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth")
}
// SCEP RFC 8894 Phase 1: RA cert + key are mandatory when SCEP is enabled.
// Without them the new RFC 8894 PKIMessage path (EnvelopedData decryption,
// CertRep signing) cannot run and every SCEP request silently falls through
// to the MVP raw-CSR path — fail loud at startup so the operator's intent
// is unambiguous. Mirrors the ChallengePassword gate above; defense in
// depth with cmd/server/main.go::preflightSCEPRACertKey which additionally
// validates file mode + cert/key match + expiry + algorithm.
if c.SCEP.Enabled && (c.SCEP.RACertPath == "" || c.SCEP.RAKeyPath == "") {
return fmt.Errorf("SCEP is enabled but RA cert/key path missing — refuse to start (RFC 8894 §3.2.2 requires an RA cert clients can encrypt their CSR to and an RA key the server uses to decrypt + sign CertRep): set both CERTCTL_SCEP_RA_CERT_PATH and CERTCTL_SCEP_RA_KEY_PATH or disable SCEP with CERTCTL_SCEP_ENABLED=false. See docs/legacy-est-scep.md for the openssl recipe to generate the RA pair. This gate duplicates cmd/server/main.go:preflightSCEPRACertKey for defense in depth")
}
// Validate scheduler intervals
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
return fmt.Errorf("renewal check interval must be at least 1 minute")
+113
View File
@@ -1290,3 +1290,116 @@ func TestValidate_EncryptionKey_LongAccepted(t *testing.T) {
t.Errorf("Validate() returned error for 44-byte key: %v", err)
}
}
// SCEP RFC 8894 Phase 1: Validate() must refuse to start when SCEP is enabled
// without an RA cert + key pair, mirroring the existing CHALLENGE_PASSWORD
// gate. Defense-in-depth with cmd/server/main.go::preflightSCEPRACertKey
// which additionally validates file mode + cert/key match + expiry + alg.
func TestValidate_SCEPEnabled_MissingRAPair_Refuses(t *testing.T) {
cases := []struct {
name string
raCertPath string
raKeyPath string
}{
{"both_empty", "", ""},
{"cert_only", "/etc/certctl/scep/ra.crt", ""},
{"key_only", "", "/etc/certctl/scep/ra.key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{
Enabled: true,
ChallengePassword: "shared-secret-not-empty",
RACertPath: tc.raCertPath,
RAKeyPath: tc.raKeyPath,
},
}
err := cfg.Validate()
if err == nil {
t.Fatalf("Validate() = nil, want error for SCEP enabled with missing RA pair")
}
if !strings.Contains(err.Error(), "RA cert/key path missing") {
t.Errorf("Validate() error = %q, want 'RA cert/key path missing'", err.Error())
}
})
}
}
// SCEP enabled with a complete RA pair (and a non-empty challenge password)
// should pass Validate — the file-existence + mode + match checks live in
// preflightSCEPRACertKey, not in Validate. This pins the boundary so a
// future "validate the file too" refactor doesn't accidentally double up.
func TestValidate_SCEPEnabled_CompleteRAPair_Accepts(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{
Enabled: true,
ChallengePassword: "shared-secret-not-empty",
RACertPath: "/etc/certctl/scep/ra.crt",
RAKeyPath: "/etc/certctl/scep/ra.key",
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil for complete RA pair (file-existence checked in preflightSCEPRACertKey)", err)
}
}
// SCEP disabled with empty RA pair fields must NOT trip the gate — the
// fields only matter when SCEP is enabled. Mirrors the CHALLENGE_PASSWORD
// disabled-passes precedent in TestValidate_ValidConfig.
func TestValidate_SCEPDisabled_EmptyRAPair_Accepts(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
AwaitingApprovalTimeout: 168 * time.Hour,
},
SCEP: SCEPConfig{Enabled: false}, // RACertPath / RAKeyPath stay empty
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil for SCEP disabled with empty RA pair", err)
}
}
+62 -4
View File
@@ -10,9 +10,25 @@ type SCEPEnrollResult struct {
type SCEPMessageType int
const (
// SCEPMessageTypeCertRep is the server's response to PKCSReq / RenewalReq /
// GetCertInitial. RFC 8894 §3.3.2. Wire-encoded as the messageType
// authenticated attribute on the outbound CertRep PKIMessage; clients pivot
// on this value to decide whether to extract a cert from the EnvelopedData
// (Status=Success), surface a failInfo (Status=Failure), or poll
// (Status=Pending).
SCEPMessageTypeCertRep SCEPMessageType = 3
// SCEPMessageTypeRenewalReq is re-enrollment with an existing valid cert.
// RFC 8894 §3.3.1.2. Distinct from PKCSReq because the signerInfo is signed
// by the existing cert (proving possession), not by a transient self-signed
// device key. The service-side handler must verify the signing cert chains
// to a trusted CA and is not yet revoked or expired.
SCEPMessageTypeRenewalReq SCEPMessageType = 17
// SCEPMessageTypePKCSReq is a PKCS#10 certificate request (initial enrollment).
// RFC 8894 §3.3.1.
SCEPMessageTypePKCSReq SCEPMessageType = 19
// SCEPMessageTypeGetCertInitial is a polling request for a pending certificate.
// RFC 8894 §3.3.3. Used when the prior PKCSReq returned Status=Pending and
// the client is checking whether the request has been approved.
SCEPMessageTypeGetCertInitial SCEPMessageType = 20
)
@@ -32,9 +48,51 @@ const (
type SCEPFailInfo string
const (
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
SCEPFailBadAlg SCEPFailInfo = "0" // Unrecognized or unsupported algorithm
SCEPFailBadMessageCheck SCEPFailInfo = "1" // Integrity check failed
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
SCEPFailBadRequest SCEPFailInfo = "2" // Transaction not permitted or supported
SCEPFailBadTime SCEPFailInfo = "3" // Message time field was not sufficiently close to system time
SCEPFailBadCertID SCEPFailInfo = "4" // No certificate could be identified matching the provided criteria
)
// SCEPRequestEnvelope carries the parsed RFC 8894 PKIMessage authenticated
// attributes from the inbound signerInfo (RFC 8894 §3.2.1.2). Populated by
// the handler when a request comes in over the new RFC-8894 path; consumed
// by the service to thread transactionID + nonces through to the CertRep
// response and the audit trail.
//
// Fields mirror the SCEP attributes RFC 8894 §3.2.1.2 enumerates:
// - messageType: which SCEP operation (PKCSReq / RenewalReq / GetCertInitial)
// - transactionID: client-chosen identifier; server MUST echo verbatim in CertRep
// - senderNonce: 16-byte client nonce; server MUST echo as recipientNonce
// - signerCert: the device's transient self-signed cert (PKCSReq) or its
// existing valid cert (RenewalReq) — the public key in this cert is what
// the server encrypts the CertRep EnvelopedData to.
//
// The MVP fall-through path (handler::extractCSRFromPKCS7) does not populate
// this struct; it stays nil and the service layer routes to the legacy
// PKCSReq method that synthesizes a transactionID from the CSR's CommonName.
type SCEPRequestEnvelope struct {
MessageType SCEPMessageType // PKCSReq (19), RenewalReq (17), GetCertInitial (20)
TransactionID string // client-chosen ID; echoed verbatim in CertRep response
SenderNonce []byte // 16-byte client nonce; echoed as recipientNonce
SignerCert []byte // DER of the device's signing cert (for CertRep encryption)
}
// SCEPResponseEnvelope is what the service hands back to the handler so the
// handler can build the CertRep PKIMessage. The handler is responsible for
// computing the new senderNonce and signing the response with the RA cert/key
// loaded at startup (see SCEPConfig.RACertPath / RAKeyPath).
//
// Status semantics (RFC 8894 §3.3.2.1):
// - SCEPStatusSuccess: Result is non-nil and contains the issued cert + chain
// - SCEPStatusFailure: FailInfo identifies the rejection reason; Result is nil
// - SCEPStatusPending: request is queued for manual approval; Result is nil
// (client polls via GetCertInitial)
type SCEPResponseEnvelope struct {
Status SCEPPKIStatus
FailInfo SCEPFailInfo // populated only when Status == SCEPStatusFailure
TransactionID string // echo of request.TransactionID
RecipientNonce []byte // echo of request.SenderNonce
Result *SCEPEnrollResult // populated only when Status == SCEPStatusSuccess
}