From 105c307d62e8d607babff88a0819f3bb453675bb Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Wed, 29 Apr 2026 03:35:11 +0000 Subject: [PATCH] feat(scep): add RFC 8894 message-type constants + RA cert/key config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/main.go | 121 ++++++++++++++ cmd/server/preflight_scep_ra_test.go | 227 +++++++++++++++++++++++++++ docs/features.md | 2 + internal/config/config.go | 44 ++++++ internal/config/config_test.go | 113 +++++++++++++ internal/domain/scep.go | 66 +++++++- 6 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 cmd/server/preflight_scep_ra_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 843643a..9863ea8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/cmd/server/preflight_scep_ra_test.go b/cmd/server/preflight_scep_ra_test.go new file mode 100644 index 0000000..85a78aa --- /dev/null +++ b/cmd/server/preflight_scep_ra_test.go @@ -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-.crt + ra-.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 +} diff --git a/docs/features.md b/docs/features.md index 7b39c54..332f9bb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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=-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. | --- diff --git a/internal/config/config.go b/internal/config/config.go index 42a1d2b..dea1f4c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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=-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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b55a103..1e4caf7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/domain/scep.go b/internal/domain/scep.go index 3606b0e..ddeecc4 100644 --- a/internal/domain/scep.go +++ b/internal/domain/scep.go @@ -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 +}