Files
certctl/internal/domain/scep.go
T
shankar0123 105c307d62 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.
2026-04-29 03:35:11 +00:00

99 lines
4.9 KiB
Go

package domain
// SCEPEnrollResult holds the result of a SCEP (RFC 8894) enrollment operation.
type SCEPEnrollResult struct {
CertPEM string `json:"cert_pem"` // PEM-encoded signed certificate
ChainPEM string `json:"chain_pem"` // PEM-encoded CA chain
}
// SCEPMessageType identifies the type of SCEP PKI message.
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
)
// SCEPPKIStatus represents the status of a SCEP PKI operation.
type SCEPPKIStatus string
const (
// SCEPStatusSuccess indicates the request was granted.
SCEPStatusSuccess SCEPPKIStatus = "0"
// SCEPStatusFailure indicates the request was rejected.
SCEPStatusFailure SCEPPKIStatus = "2"
// SCEPStatusPending indicates the request is pending manual approval.
SCEPStatusPending SCEPPKIStatus = "3"
)
// SCEPFailInfo represents the reason for a SCEP failure.
type SCEPFailInfo string
const (
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
)
// 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
}