mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
105c307d62
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.
228 lines
8.3 KiB
Go
228 lines
8.3 KiB
Go
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
|
|
}
|