mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user