mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +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:
+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