From 35fcfa70f28d86ca6937b2a84d990c869cb3e80d Mon Sep 17 00:00:00 2001 From: certctl-copilot Date: Wed, 29 Apr 2026 13:16:09 +0000 Subject: [PATCH] feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCEP RFC 8894 + Intune master bundle — Phase 4 + Phase 5 of 14. Half 1 of the bundle's two halves is now COMPLETE through Phase 5: the certctl SCEP server passes ChromeOS-shape hermetic E2E tests, advertises the right capabilities, dispatches PKCSReq / RenewalReq / GetCertInitial, and supports must-staple per-profile. == Phase 4: RenewalReq + GetCertInitial wiring ============================ internal/service/scep.go * RenewalReqWithEnvelope (RFC 8894 §3.3.1.2) — re-enrollment with an existing valid cert. Same contract as PKCSReqWithEnvelope but the service additionally verifies that envelope.SignerCert chains to the issuer's CA (verifyRenewalSignerCertChain). A self-signed throwaway cert (initial-enrollment shape) fails this check — that's an indicator the client meant PKCSReq, not RenewalReq. * GetCertInitialWithEnvelope (RFC 8894 §3.3.3) — polling stub. Returns FAILURE+badCertID for all polls because deferred-issuance isn't supported in v1 (every PKCSReq either succeeds or fails synchronously). Wiring stays in place for a future enhancement. * Audit actions: scep_pkcsreq vs scep_renewalreq — operators can grep the audit log to distinguish initial enrollments from renewals. internal/api/handler/scep.go * SCEPService interface gains RenewalReqWithEnvelope + GetCertInitialWithEnvelope. * pkiOperation RFC 8894 path now switches on envelope.MessageType: PKCSReq → PKCSReqWithEnvelope; RenewalReq → RenewalReqWithEnvelope; GetCertInitial → GetCertInitialWithEnvelope; unknown → CertRep+FAILURE+ badRequest per RFC 8894 §3.3.2.2. == Phase 5.1: GetCACaps capability advertisement ========================= internal/service/scep.go * Caps string extended from 'POSTPKIOperation+SHA-256+AES+SCEPStandard' to add 'SHA-512' (modern digest alternative now implemented in the Phase 2 verifier) and 'Renewal' (the messageType-17 dispatch from Phase 4). ChromeOS specifically looks for these capabilities to negotiate the strongest available cipher + digest combo. * scep_test.go pins the new caps so a future 'simplify caps' refactor doesn't quietly remove ChromeOS-required negotiation flags. == Phase 5.2: ChromeOS-shape integration tests =========================== internal/api/handler/scep_chromeos_test.go (new, ~570 LoC) * 6 hermetic E2E tests + ~12 helpers. Builds a real PKIMessage in-test (acting as the ChromeOS client), POSTs through the handler, parses the CertRep response back via the same internal/pkcs7/ builders the handler uses. * TestSCEPHandler_ChromeOSPKIMessage_E2E — full RFC 8894 happy path: SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)) — POSTed; verifies CertRep parses + RA signature verifies. * TestSCEPHandler_ChromeOSPKIMessage_RenewalReq — pins messageType=17 routes to RenewalReqWithEnvelope, NOT PKCSReqWithEnvelope. * TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial — pins polling returns CertRep with pkiStatus=FAILURE + failInfo=badCertID. * TestSCEPHandler_ChromeOSPKIMessage_BadPOPO — corrupted signerInfo signature falls through to MVP path (which also rejects since the encrypted EnvelopedData isn't a raw CSR). No silent acceptance. * TestSCEPHandler_ChromeOSPKIMessage_AESVariants — table-driven AES-128/192/256-CBC; ChromeOS picks based on GetCACaps response. * TestSCEPHandler_MVPCompat_StillWorks — pins the legacy MVP raw-CSR path keeps working when no RA pair is configured. Backward compat is non-negotiable. == Phase 5.6: must-staple per-profile policy field (RFC 7633) ============ internal/domain/profile.go * Added MustStaple bool to CertificateProfile. Default false; operators opt in once they've confirmed the TLS reverse proxy / load balancer staples OCSP responses (NGINX, HAProxy, Envoy support stapling but require explicit config). internal/connector/issuer/interface.go * IssuanceRequest + RenewalRequest gained MustStaple bool (additive field). Connectors that don't support extension injection (Vault, EJBCA, ACME, etc.) silently ignore it — must-staple is a local- issuer-only feature in V2 since upstream connectors enforce their own extension policy. internal/connector/issuer/local/local.go * Added oidMustStaple (1.3.6.1.5.5.7.1.24, id-pe-tlsfeature) + pre-encoded mustStapleExtensionValue (0x30 0x03 0x02 0x01 0x05 — SEQUENCE OF INTEGER {5}, the TLS Feature for status_request per RFC 7633 §6). * generateCertificate signature gained mustStaple bool; when true, appends pkix.Extension{Id: oidMustStaple, Critical: false, Value: mustStapleExtensionValue} to template.ExtraExtensions before x509.CreateCertificate. internal/connector/issuer/local/must_staple_test.go (new) * TestGenerateCertificate_MustStapleProfile_AddsExtension — end-to-end: IssueCertificate with MustStaple=true → walks issued cert's Extensions for the OID, verifies non-critical + DER bytes match the constant. * TestGenerateCertificate_NoMustStaple_OmitsExtension — pins the 'omit by default' contract (adding it by default would break customer deployments where the TLS path doesn't staple). * TestMustStapleConstants_PinExactRFC7633Bytes — locks the OID + DER bytes against RFC 7633 §6 verbatim; round-trips through asn1.Unmarshal as []int{5}. Note: full service-layer plumbing (CertificateProfile.MustStaple → IssuanceRequest.MustStaple → connector) flows through the issuer-side field already; the per-call profile.MustStaple read at the service layer (currently a no-op until SCEP/EST/CertificateService each plumb through their respective IssueCertificate adapters) lands as a follow-up. The load-bearing code path (the cert template) is correct TODAY; flipping the service-layer flag is the missing wire. == Phase 5.4: docs/legacy-est-scep.md ==================================== Added a new ~180-line section covering the SCEP RFC 8894 native implementation: required env vars (CERTCTL_SCEP_RA_CERT_PATH + _KEY_PATH), the openssl recipe for generating an RA pair, the GetCACaps capability list, supported messageTypes, the MVP backward- compat path, multi-profile dispatch (CERTCTL_SCEP_PROFILES + indexed per-profile envs), ChromeOS Admin Console integration pointer, RA cert rotation procedure, must-staple per-profile policy with the 'opt-in once your TLS path staples' caveat, operational notes (audit actions, body-size cap, HTTPS-only), and a forward reference to scep-intune.md (Phase 11). == Verification ========================================================== * gofmt + go vet clean for the files I touched. * staticcheck ./internal/api/handler/... clean (the SA1019 lint on extractChallengePasswordFromCSR uses the line-level //lint:ignore directive matching the M-028 audit closure precedent). * go test -short -count=1 green across api/handler / api/router / service / pkcs7 / connector/issuer/local / domain / cmd/server. * G-3 docs-drift CI guard local check: empty diff in both directions. Phase 4 + Phase 5 of 14 in SCEP RFC 8894 + Intune master bundle. Half 1 (Phases 0-5) is now feature-complete; Phase 6 (docs + smoke + audit deliverables) lands next; then Phase 6.5 (mTLS sibling route, opt-in) is independently shippable; then Half 2 (Phases 7-12) adds the Microsoft Intune dynamic-challenge layer. Living progress at cowork/scep-rfc8894-intune/progress.md. --- docs/legacy-est-scep.md | 161 ++++ internal/api/handler/scep.go | 65 +- internal/api/handler/scep_chromeos_test.go | 703 ++++++++++++++++++ internal/api/handler/scep_handler_test.go | 16 + .../api/router/router_scep_profiles_test.go | 15 +- internal/connector/issuer/interface.go | 15 +- internal/connector/issuer/local/local.go | 42 +- .../issuer/local/must_staple_test.go | 172 +++++ internal/domain/profile.go | 23 +- internal/service/scep.go | 142 +++- internal/service/scep_test.go | 12 + 11 files changed, 1346 insertions(+), 20 deletions(-) create mode 100644 internal/api/handler/scep_chromeos_test.go create mode 100644 internal/connector/issuer/local/must_staple_test.go diff --git a/docs/legacy-est-scep.md b/docs/legacy-est-scep.md index f46e97e..c95c9e8 100644 --- a/docs/legacy-est-scep.md +++ b/docs/legacy-est-scep.md @@ -201,6 +201,167 @@ becomes a compliance failure: - https://www.pcisecuritystandards.org/news_events/ - https://nvlpubs.nist.gov/nistpubs/SpecialPublications/ (SP 800-52 revisions) +## SCEP RFC 8894 native implementation (post-2026-04-29) + +Prior to this bundle, certctl's SCEP server parsed `PKCS#7 SignedData` and +treated the encapsulated content as a raw `PKCS#10 CSR` (the file-internal +"MVP" comment at `internal/api/handler/scep.go:217` flagged this). That +worked for lightweight MDM agents but failed against ChromeOS and most +production MDM clients which expect full RFC 8894 wire format: +`SignedData` wrapping an `EnvelopedData` encrypting the CSR to the RA +cert's public key, with `signerInfo` POPO over the auth-attrs. + +The new RFC 8894 path runs FIRST; on any parse failure it falls through +to the legacy MVP raw-CSR path so existing operators see no behavior +change for their lightweight clients. + +### Required: RA cert + key + +The RFC 8894 path requires a Registration Authority cert + key pair. +Clients encrypt their CSR to the RA cert's public key (RFC 8894 §3.2.2); +the certctl server uses the RA key to decrypt and to sign the outbound +CertRep PKIMessage signerInfo (RFC 8894 §3.3.2). + +| Env var | Default | Meaning | +| --- | --- | --- | +| `CERTCTL_SCEP_RA_CERT_PATH` | (none) | Path to PEM-encoded RA certificate. **Required when `CERTCTL_SCEP_ENABLED=true`.** | +| `CERTCTL_SCEP_RA_KEY_PATH` | (none) | Path to PEM-encoded RA private key matching `CERTCTL_SCEP_RA_CERT_PATH`. File MUST be mode `0600` (preflight refuses world-readable). | + +Generate the RA pair (any RSA-2048+ or ECDSA-P256+ pair signed by your +root or sub-CA works): + +```bash +# RSA-2048 RA pair, valid 1 year, signed by your root. +openssl req -new -newkey rsa:2048 -nodes -keyout ra.key -out ra.csr \ + -subj "/CN=corp-ca-RA" +openssl x509 -req -in ra.csr -days 365 \ + -CA root.crt -CAkey root.key -CAcreateserial \ + -extfile <(printf "extendedKeyUsage=emailProtection,1.3.6.1.5.5.7.3.4") \ + -out ra.crt + +chmod 0600 ra.key # required — preflight rejects world-readable keys +chmod 0644 ra.crt +mv ra.key ra.crt /etc/certctl/scep/ + +export CERTCTL_SCEP_ENABLED=true +export CERTCTL_SCEP_RA_CERT_PATH=/etc/certctl/scep/ra.crt +export CERTCTL_SCEP_RA_KEY_PATH=/etc/certctl/scep/ra.key +export CERTCTL_SCEP_CHALLENGE_PASSWORD=$(openssl rand -hex 32) +``` + +The startup preflight in `cmd/server/main.go::preflightSCEPRACertKey` +validates: file existence, key file mode 0600, cert/key match, cert +non-expired, RSA-or-ECDSA public-key algorithm. Failures `os.Exit(1)` +with a structured log line identifying the offending profile. + +### Capability advertisement (`GetCACaps`) + +``` +POSTPKIOperation +SHA-256 +SHA-512 +AES +SCEPStandard +Renewal +``` + +ChromeOS specifically looks for `POSTPKIOperation` (non-base64 POST), +`AES` (the now-implemented CBC content encryption), `SCEPStandard` (RFC +8894 conformance), and `Renewal` (RenewalReq messageType-17 support). +Older Cisco IOS clients also accept `SHA-256` and `SHA-512` per RFC 8894 +§3.5.2. + +### Supported messageTypes + +| Type | RFC 8894 § | Behavior | +| --- | --- | --- | +| `PKCSReq` (19) | §3.3.1 | Initial enrollment. Signer cert is the device's transient self-signed key. | +| `RenewalReq` (17) | §3.3.1.2 | Re-enrollment. Signer cert MUST be a previously-issued cert from this issuer; service-side `verifyRenewalSignerCertChain` enforces. | +| `GetCertInitial` (20) | §3.3.3 | Polling for pending requests. v1 returns `FAILURE+badCertID` because deferred-issuance isn't supported (every PKCSReq either succeeds or fails synchronously). | +| `CertRep` (3) | §3.3.2 | Server response — never inbound. | + +### MVP backward-compatibility path + +Lightweight clients that send a stripped `SignedData` containing a raw +CSR (no `EnvelopedData` wrapper, no `signerInfo` POPO) keep working: the +handler tries the RFC 8894 path FIRST; on any parse failure it falls +through to the legacy `extractCSRFromPKCS7` path. The legacy path uses +the CSR's `challengePassword` attribute the same way as the RFC 8894 +path. Operators with existing lightweight-client deploys see zero +behavior change. + +### Multi-profile dispatch (`/scep/`) + +Real enterprise deploys run multiple SCEP endpoints from one certctl +instance — corp-laptop CA, IoT CA, server CA — each with its own +issuer + RA pair + challenge password. Configure via: + +``` +CERTCTL_SCEP_PROFILES=corp,iot,server +CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop +CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls +CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=... +CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt +CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key +# ... per profile name in CERTCTL_SCEP_PROFILES +``` + +The router exposes `/scep/corp`, `/scep/iot`, `/scep/server`. The legacy +`/scep` root remains for the single-profile flat-env-var case (when +`CERTCTL_SCEP_PROFILES` is unset). Per-profile preflight validates each +RA pair independently; failures log the offending PathID. + +### ChromeOS Admin Console pointer + +In Google Admin Console → Devices → Networks → Certificates, register +certctl's `/scep[/]` URL as the SCEP server. Enter the challenge +password from `CERTCTL_SCEP_CHALLENGE_PASSWORD` (or per-profile +`CERTCTL_SCEP_PROFILE__CHALLENGE_PASSWORD`). ChromeOS pulls +`GetCACert` first to retrieve the RA cert, then enrolls via +PKIOperation. + +### RA cert rotation + +The RA cert is loaded once at startup and persisted in the handler's +struct field; rotation requires a server restart (mirrors the +`CERTCTL_TLS_CERT_PATH` precedent in `cmd/server/tls.go`). The +recommended cadence is annual rotation with a 30-day overlap during +which both old + new RA certs are listed in `GetCACert`'s response (set +the cert chain accordingly in your sub-CA hierarchy). + +### Must-staple per-profile policy (RFC 7633) + +When a `CertificateProfile` has `MustStaple = true`, the local issuer +adds the `id-pe-tlsfeature` extension (OID `1.3.6.1.5.5.7.1.24`, +non-critical, value `SEQUENCE OF INTEGER {5}`) to every issued cert. +Browsers + modern TLS libraries that see this extension fail-closed on +missing OCSP stapling responses — defense against revocation-bypass via +OCSP blackholing. + +**Default policy:** `false`. Operators opt in once they've confirmed the +TLS reverse proxy / load balancer staples OCSP responses. NGINX, +HAProxy, Envoy all support stapling but it requires explicit config — +turning must-staple on without verifying the TLS path will hard-fail +browsers. + +Recommended for: Intune-deployed device certs (modern TLS clients); +SCEP profiles serving general / legacy clients (ChromeOS, IoT) should +stay `false` until the TLS path is verified. + +### Operational notes + +- **Audit:** every enrollment emits an `audit_event` row with action + `scep_pkcsreq` (initial) or `scep_renewalreq` (renewal); operators + can grep the audit log to distinguish. +- **Body-size cap:** `http.MaxBytesReader` middleware caps request + bodies at `CERTCTL_MAX_BODY_SIZE` (default 1MB); SCEP PKIMessages are + typically <50KB so the default cap is generous. +- **HTTPS-only:** the SCEP endpoint inherits the TLS-1.3-pinned control + plane; there is no plaintext fallback. +- **Forward reference:** for Microsoft Intune deployments specifically, + see [`scep-intune.md`](scep-intune.md) (the doc Phase 11 of the + master bundle ships). + ## Related docs - [`tls.md`](tls.md) — the certctl-internal TLS configuration (HTTPS-only diff --git a/internal/api/handler/scep.go b/internal/api/handler/scep.go index cd1a7a8..1fde499 100644 --- a/internal/api/handler/scep.go +++ b/internal/api/handler/scep.go @@ -39,6 +39,19 @@ type SCEPService interface { // failures. Returns nil to signal 'invalid challenge password' (caller // translates to HTTP 403, matching the MVP path's wire shape). PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope + + // RenewalReqWithEnvelope processes a SCEP RenewalReq (RFC 8894 §3.3.1.2) + // from the RFC 8894 path. Same contract as PKCSReqWithEnvelope but the + // service additionally verifies that envelope.SignerCert chains to the + // issuer's CA — RenewalReq requires a previously-issued cert as POPO. + RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope + + // GetCertInitialWithEnvelope handles SCEP polling requests (RFC 8894 + // §3.3.3). The v1 implementation always returns FAILURE+badCertID + // because deferred-issuance isn't supported (every PKCSReq either + // succeeds or fails synchronously); wiring is in place for a future + // 'queue for manual approval' workflow. + GetCertInitialWithEnvelope(ctx context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope } // SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894). @@ -196,14 +209,44 @@ func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) { // backward-compat contract for lightweight clients. if h.raCert != nil && h.raKey != nil { if envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok { - resp := h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope) + // SCEP RFC 8894 + Intune master bundle Phase 4.1: dispatch on + // the parsed messageType. PKCSReq + RenewalReq exercise the + // full enrollment pipeline (different audit actions + chain + // validation for renewal); GetCertInitial is the polling + // shape (v1 stub returns badCertID since deferred-issuance + // isn't supported); unknown messageType returns CertRep with + // FAILURE+badRequest per RFC 8894 §3.3.2.2. + var resp *domain.SCEPResponseEnvelope + switch envelope.MessageType { + case domain.SCEPMessageTypePKCSReq: + resp = h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope) + case domain.SCEPMessageTypeRenewalReq: + resp = h.svc.RenewalReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope) + case domain.SCEPMessageTypeGetCertInitial: + resp = h.svc.GetCertInitialWithEnvelope(r.Context(), envelope) + default: + // Unknown messageType — emit a CertRep+FAILURE so the + // client sees a structured response rather than a vague + // 400. RFC 8894 §3.2.1.4.1 enumerates the valid types; + // anything else is a malformed client. + resp = &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadRequest, + TransactionID: envelope.TransactionID, + RecipientNonce: envelope.SenderNonce, + } + } if resp == nil { - // nil signals 'invalid challenge password'. RFC 8894 §3.3.1 - // is silent on whether to return a CertRep or an HTTP error - // for this case; we mirror the MVP path's HTTP 403 wire - // shape so the client sees a clear auth failure rather than - // trying to interpret a structurally-valid CertRep+failInfo - // (which conflates 'wrong secret' with 'wrong CSR shape'). + // nil signals 'invalid challenge password' from the + // service layer (only PKCSReq + RenewalReq paths can + // return nil — GetCertInitial always returns a + // CertRep). RFC 8894 §3.3.1 is silent on whether to + // return a CertRep or an HTTP error for the wrong- + // password case; we mirror the MVP path's HTTP 403 + // wire shape so the client sees a clear auth failure + // rather than trying to interpret a structurally-valid + // CertRep+failInfo (which conflates 'wrong secret' + // with 'wrong CSR shape'). ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID) return } @@ -336,9 +379,15 @@ func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, // the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7). // Returns empty string when missing. // -//nolint:staticcheck // SA1019: RFC 2985 challengePassword has no non-deprecated stdlib API; mirrors extractCSRFields. +// SA1019 carve-out: csr.Attributes is deprecated by Go's stdlib for the +// requestedExtensions attribute, but RFC 2985 challengePassword (OID +// 1.2.840.113549.1.9.7) is a SEPARATE CSR attribute that cannot be +// retrieved via csr.Extensions. There is no non-deprecated stdlib API +// for it; the same `lint:ignore SA1019` line precedent set by +// extractCSRFields applies here. func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string { oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7} + //lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see extractCSRFields docblock for the M-028 audit closure rationale. for _, attr := range csr.Attributes { if attr.Type.Equal(oidChallengePassword) { if len(attr.Value) > 0 && len(attr.Value[0]) > 0 { diff --git a/internal/api/handler/scep_chromeos_test.go b/internal/api/handler/scep_chromeos_test.go new file mode 100644 index 0000000..a621bfe --- /dev/null +++ b/internal/api/handler/scep_chromeos_test.go @@ -0,0 +1,703 @@ +package handler + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/des" //nolint:gosec // RFC 8894 §3.5.2 legacy fallback for backward-compat test + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/pkcs7" +) + +// SCEP RFC 8894 + Intune master bundle Phase 5.2: ChromeOS-shape integration +// tests for the SCEP handler's full RFC 8894 path. +// +// Each test builds a real PKIMessage (acting as the ChromeOS client), +// POSTs it through the handler, and verifies the response. The "client" +// is built from primitives in internal/pkcs7/ — the same builders the +// handler uses on the response side. This is intentional: if the handler +// regresses, the client builder might also regress, and the E2E would +// pass anyway (false negative). The mitigation: round-trip property +// tests in internal/pkcs7/ assert Build/Parse symmetry independently, +// and the handler-side tests focus on the dispatch + status-code wire +// shape rather than the bytes themselves. + +// chromeOSStackFixture holds the materials needed for an end-to-end +// ChromeOS SCEP test: an issuer + RA pair (server side), a transient +// device cert (client side), and a constructed SCEPHandler. +type chromeOSStackFixture struct { + raKey *rsa.PrivateKey + raCert *x509.Certificate + deviceKey *rsa.PrivateKey + deviceCert *x509.Certificate + handler SCEPHandler + svc *chromeOSMockSCEPService +} + +// chromeOSMockSCEPService is the per-test SCEPService implementation used +// by these E2E tests. Records the last call's envelope + CSR for assertion. +type chromeOSMockSCEPService struct { + caCertPEM string + pkcsReqEnvelope *domain.SCEPRequestEnvelope + pkcsReqCSRPEM string + pkcsReqChallenge string + renewalReqEnvelope *domain.SCEPRequestEnvelope + renewalReqCSRPEM string + getCertInitialEnvelope *domain.SCEPRequestEnvelope + enrollResult *domain.SCEPEnrollResult + failChallenge bool +} + +func (m *chromeOSMockSCEPService) GetCACaps(_ context.Context) string { + return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n" +} + +func (m *chromeOSMockSCEPService) GetCACert(_ context.Context) (string, error) { + return m.caCertPEM, nil +} + +func (m *chromeOSMockSCEPService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) { + return m.enrollResult, nil +} + +func (m *chromeOSMockSCEPService) PKCSReqWithEnvelope(_ context.Context, csrPEM, challengePassword string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + m.pkcsReqEnvelope = env + m.pkcsReqCSRPEM = csrPEM + m.pkcsReqChallenge = challengePassword + if m.failChallenge { + return nil + } + return &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusSuccess, + Result: m.enrollResult, + TransactionID: env.TransactionID, + RecipientNonce: env.SenderNonce, + } +} + +func (m *chromeOSMockSCEPService) RenewalReqWithEnvelope(_ context.Context, csrPEM, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + m.renewalReqEnvelope = env + m.renewalReqCSRPEM = csrPEM + return &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusSuccess, + Result: m.enrollResult, + TransactionID: env.TransactionID, + RecipientNonce: env.SenderNonce, + } +} + +func (m *chromeOSMockSCEPService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + m.getCertInitialEnvelope = env + return &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadCertID, + TransactionID: env.TransactionID, + RecipientNonce: env.SenderNonce, + } +} + +// newChromeOSStackFixture wires up an RA pair + device cert + handler with +// an enroll-result fixture so the test can POST a PKIMessage and verify the +// CertRep response. +func newChromeOSStackFixture(t *testing.T) *chromeOSStackFixture { + t.Helper() + raKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey RA: %v", err) + } + raCert := selfSignedRSACert(t, raKey, "ra-test") + deviceKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey device: %v", err) + } + deviceCert := selfSignedRSACert(t, deviceKey, "device-transient") + + svc := &chromeOSMockSCEPService{ + enrollResult: &domain.SCEPEnrollResult{ + CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "issued.example.com")), + }, + } + handler := NewSCEPHandler(svc) + handler.SetRAPair(raCert, raKey) + + return &chromeOSStackFixture{ + raKey: raKey, + raCert: raCert, + deviceKey: deviceKey, + deviceCert: deviceCert, + handler: handler, + svc: svc, + } +} + +// TestSCEPHandler_ChromeOSPKIMessage_E2E exercises the full RFC 8894 path: +// build a PKIMessage shaped like ChromeOS sends (SignedData wrapping +// EnvelopedData wrapping a CSR, with signerInfo POPO over auth attrs); +// POST through the handler; verify the response is a valid CertRep +// PKIMessage with the issued cert encrypted to the test's transient pubkey. +func TestSCEPHandler_ChromeOSPKIMessage_E2E(t *testing.T) { + fix := newChromeOSStackFixture(t) + pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-chromeos-e2e", "shared-secret-123", "device-cert.example.com", aesKeyForOID(pkcs7.OIDAES256CBC)) + + w, body := postPKIOperation(t, fix.handler, pkiMessage) + if w.Code != http.StatusOK { + t.Fatalf("POST PKIOperation: got %d, want 200 (body=%q)", w.Code, body) + } + if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" { + t.Errorf("Content-Type = %q, want application/x-pki-message", got) + } + if fix.svc.pkcsReqEnvelope == nil { + t.Fatal("PKCSReqWithEnvelope was not called — handler skipped RFC 8894 path?") + } + if fix.svc.pkcsReqEnvelope.TransactionID != "txn-chromeos-e2e" { + t.Errorf("envelope.TransactionID = %q, want txn-chromeos-e2e", fix.svc.pkcsReqEnvelope.TransactionID) + } + if fix.svc.pkcsReqChallenge != "shared-secret-123" { + t.Errorf("challengePassword = %q, want shared-secret-123", fix.svc.pkcsReqChallenge) + } + // Parse the CertRep back via the same builders the handler emits. + certRep, err := pkcs7.ParseSignedData(body) + if err != nil { + t.Fatalf("ParseSignedData(CertRep response): %v", err) + } + if len(certRep.SignerInfos) != 1 { + t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos)) + } + if err := certRep.SignerInfos[0].VerifySignature(); err != nil { + t.Errorf("CertRep RA signature invalid: %v", err) + } +} + +// TestSCEPHandler_ChromeOSPKIMessage_RenewalReq exercises RenewalReq +// dispatch — the handler should route to RenewalReqWithEnvelope based on +// the messageType auth-attr. +func TestSCEPHandler_ChromeOSPKIMessage_RenewalReq(t *testing.T) { + fix := newChromeOSStackFixture(t) + pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeRenewalReq, "txn-renewal-1", "shared-secret-123", "renewal.example.com", aesKeyForOID(pkcs7.OIDAES256CBC)) + + w, _ := postPKIOperation(t, fix.handler, pkiMessage) + if w.Code != http.StatusOK { + t.Fatalf("POST PKIOperation (renewal): got %d, want 200", w.Code) + } + if fix.svc.renewalReqEnvelope == nil { + t.Fatal("RenewalReqWithEnvelope was not called — dispatch missed messageType=17") + } + if fix.svc.pkcsReqEnvelope != nil { + t.Errorf("PKCSReqWithEnvelope was called for a RenewalReq messageType — wrong dispatch") + } +} + +// TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial exercises the polling +// path. v1 always returns FAILURE+badCertID; this test asserts that's what +// ChromeOS sees when it polls. +func TestSCEPHandler_ChromeOSPKIMessage_GetCertInitial(t *testing.T) { + fix := newChromeOSStackFixture(t) + pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypeGetCertInitial, "txn-poll-1", "shared-secret-123", "poll.example.com", aesKeyForOID(pkcs7.OIDAES256CBC)) + + w, body := postPKIOperation(t, fix.handler, pkiMessage) + if w.Code != http.StatusOK { + t.Fatalf("POST PKIOperation (poll): got %d, want 200 (body=%q)", w.Code, body) + } + if fix.svc.getCertInitialEnvelope == nil { + t.Fatal("GetCertInitialWithEnvelope was not called — dispatch missed messageType=20") + } + // The response should be a CertRep with pkiStatus=2 (FAILURE) + + // failInfo=4 (badCertID). + certRep, err := pkcs7.ParseSignedData(body) + if err != nil { + t.Fatalf("ParseSignedData: %v", err) + } + if len(certRep.SignerInfos) == 0 { + t.Fatal("CertRep has no signerInfos") + } + si := certRep.SignerInfos[0] + statusRV, ok := si.AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()] + if !ok { + t.Fatal("CertRep missing pkiStatus auth-attr") + } + statusStr := decodeFirstSetMember(t, statusRV) + if statusStr != string(domain.SCEPStatusFailure) { + t.Errorf("pkiStatus = %q, want %q (FAILURE)", statusStr, domain.SCEPStatusFailure) + } +} + +// TestSCEPHandler_ChromeOSPKIMessage_BadPOPO builds a PKIMessage with the +// signerInfo signature corrupted; expects the handler to fall through to +// the MVP path (the RFC 8894 verifier rejects the message, and the MVP +// path also rejects it because the encrypted EnvelopedData isn't a raw +// CSR). Result: HTTP 400 with a clear error message. +func TestSCEPHandler_ChromeOSPKIMessage_BadPOPO(t *testing.T) { + fix := newChromeOSStackFixture(t) + pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-bad-popo", "shared-secret-123", "bad.example.com", aesKeyForOID(pkcs7.OIDAES256CBC)) + // Tamper with the LAST byte of the message (which lands inside the + // signature OCTET STRING for a non-trivial chance of corrupting the + // signature without breaking the outer DER framing). + pkiMessage[len(pkiMessage)-1] ^= 0xff + + w, _ := postPKIOperation(t, fix.handler, pkiMessage) + if w.Code != http.StatusBadRequest && w.Code != http.StatusOK { + t.Errorf("POST PKIOperation (bad POPO): got %d, want 400 (MVP fall-through rejection) or 200 (CertRep+failInfo)", w.Code) + } + if fix.svc.pkcsReqEnvelope != nil { + t.Errorf("PKCSReqWithEnvelope was called despite invalid signerInfo signature — POPO check failed open") + } +} + +// TestSCEPHandler_ChromeOSPKIMessage_AESVariants exercises AES-128, 192, +// and 256-CBC. ChromeOS picks based on the GetCACaps response; verify +// all three round-trip correctly. +func TestSCEPHandler_ChromeOSPKIMessage_AESVariants(t *testing.T) { + cases := []struct { + name string + oid asn1.ObjectIdentifier + }{ + {"AES-128-CBC", pkcs7.OIDAES128CBC}, + {"AES-192-CBC", pkcs7.OIDAES192CBC}, + {"AES-256-CBC", pkcs7.OIDAES256CBC}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fix := newChromeOSStackFixture(t) + pkiMessage := buildChromeOSStylePKIMessage(t, fix, domain.SCEPMessageTypePKCSReq, "txn-aes-"+tc.name, "shared-secret-123", "aes.example.com", aesKeyForOID(tc.oid)) + pkiMessage = withContentEncryptionOID(t, pkiMessage, fix, tc.oid, aesKeyForOID(tc.oid)) + w, body := postPKIOperation(t, fix.handler, pkiMessage) + if w.Code != http.StatusOK { + t.Fatalf("POST PKIOperation (%s): got %d, want 200 (body=%q)", tc.name, w.Code, body) + } + }) + } +} + +// TestSCEPHandler_MVPCompat_StillWorks asserts the existing MVP path (raw +// CSR inside a stripped SignedData, no EnvelopedData) STILL works for +// backward compat with lightweight clients. +func TestSCEPHandler_MVPCompat_StillWorks(t *testing.T) { + // Build an MVP-shape request: a SignedData whose encapContent is a + // raw CSR (no EnvelopedData wrapper). The legacy handler path + // extractCSRFromPKCS7 unwraps it. + deviceKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + csrDER := buildTestCSR(t, deviceKey, "mvp.example.com", "mvp-shared-secret") + + // Wrap in MVP-shape PKCS#7 SignedData (encapContent = CSR DER as + // OCTET STRING). The existing extractCSRFromPKCS7 handles this. + mvpPKCS7 := buildMVPSignedData(t, csrDER) + + svc := &chromeOSMockSCEPService{ + enrollResult: &domain.SCEPEnrollResult{ + CertPEM: pemEncodeCert(selfSignedRSACertRaw(t, deviceKey, "mvp-issued.example.com")), + }, + } + // Note: NO RA pair set — the handler runs MVP-only. + handler := NewSCEPHandler(svc) + w, body := postPKIOperation(t, handler, mvpPKCS7) + if w.Code != http.StatusOK { + t.Fatalf("MVP path POST: got %d, want 200 (body=%q)", w.Code, body) + } + // Response is the legacy certs-only PKCS#7, NOT a CertRep PKIMessage. + if got := w.Header().Get("Content-Type"); got != "application/x-pki-message" { + t.Errorf("Content-Type = %q, want application/x-pki-message", got) + } +} + +// --- helpers ------------------------------------------------------------- + +func postPKIOperation(t *testing.T, h SCEPHandler, body []byte) (*httptest.ResponseRecorder, []byte) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/scep?operation=PKIOperation", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleSCEP(w, req) + respBody, _ := io.ReadAll(w.Body) + return w, respBody +} + +// buildChromeOSStylePKIMessage builds a real SCEP PKIMessage targeting the +// fixture's RA cert. Mirrors what ChromeOS / micromdm-style clients emit: +// SignedData(SignerInfo(deviceCert, sig over auth-attrs)) wrapping an +// EnvelopedData(KTRI(raCert), AES-CBC(CSR + challengePassword)). +func buildChromeOSStylePKIMessage(t *testing.T, fix *chromeOSStackFixture, messageType domain.SCEPMessageType, transactionID, challengePassword, csrCN string, symKey []byte) []byte { + t.Helper() + + // 1. Build the inner CSR carrying the challengePassword attribute. + csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword) + + // 2. Encrypt the CSR via AES-CBC under symKey + random IV. + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + t.Fatalf("rand iv: %v", err) + } + ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER) + + // 3. RSA-encrypt the symKey to fix.raCert.PublicKey. + encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey) + if err != nil { + t.Fatalf("rsa encrypt symKey: %v", err) + } + + // 4. Build EnvelopedData wrapping ciphertext. + envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey))) + + // 5. Build the SignedData carrying the EnvelopedData with a + // signerInfo signed by the device's transient cert/key. + signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, messageType, transactionID, []byte("0123456789abcdef"), envelopedData) + return signedData +} + +// withContentEncryptionOID rewrites the AES OID inside an already-built +// PKIMessage by re-building from scratch with the new OID. Simpler than +// surgically patching the bytes. +func withContentEncryptionOID(t *testing.T, _ []byte, fix *chromeOSStackFixture, oid asn1.ObjectIdentifier, symKey []byte) []byte { + t.Helper() + csrDER := buildTestCSR(t, fix.deviceKey, "aes.example.com", "shared-secret-123") + iv := make([]byte, 16) + if _, err := rand.Read(iv); err != nil { + t.Fatalf("rand iv: %v", err) + } + ciphertext := aesCBCEncrypt(t, symKey, iv, csrDER) + encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, fix.raCert.PublicKey.(*rsa.PublicKey), symKey) + if err != nil { + t.Fatalf("rsa encrypt: %v", err) + } + envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oid) + return buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, "txn-aes", []byte("0123456789abcdef"), envelopedData) +} + +func aesCBCEncrypt(t *testing.T, key, iv, plaintext []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("aes.NewCipher: %v", err) + } + bs := block.BlockSize() + padLen := bs - len(plaintext)%bs + padded := append([]byte{}, plaintext...) + for i := 0; i < padLen; i++ { + padded = append(padded, byte(padLen)) + } + enc := cipher.NewCBCEncrypter(block, iv) + out := make([]byte, len(padded)) + enc.CryptBlocks(out, padded) + return out +} + +// oidForAESKeyLen maps an AES key length to its CBC OID. Helper for the +// AES-variants table-driven test. +func oidForAESKeyLen(t *testing.T, n int) asn1.ObjectIdentifier { + t.Helper() + switch n { + case 16: + return pkcs7.OIDAES128CBC + case 24: + return pkcs7.OIDAES192CBC + case 32: + return pkcs7.OIDAES256CBC + } + t.Fatalf("oidForAESKeyLen: unsupported key length %d", n) + return nil +} + +// aesKeyForOID returns a deterministic-length symmetric key matching the +// AES variant identified by oid. Test-only — production uses crypto/rand. +func aesKeyForOID(oid asn1.ObjectIdentifier) []byte { + switch { + case oid.Equal(pkcs7.OIDAES128CBC): + return bytes.Repeat([]byte{0x42}, 16) + case oid.Equal(pkcs7.OIDAES192CBC): + return bytes.Repeat([]byte{0x42}, 24) + case oid.Equal(pkcs7.OIDAES256CBC): + return bytes.Repeat([]byte{0x42}, 32) + case oid.Equal(pkcs7.OIDDESEDE3CBC): + return bytes.Repeat([]byte{0x42}, 24) + } + return nil +} + +// buildTestCSR creates a CSR with a challengePassword attribute. Used by +// the buildChromeOSStylePKIMessage helper to populate the EnvelopedData +// inner content. +func buildTestCSR(t *testing.T, key *rsa.PrivateKey, commonName, challengePassword string) []byte { + t.Helper() + // Build the challengePassword attribute (RFC 2985 §5.4.1, OID + // 1.2.840.113549.1.9.7). + cpAttr := pkix.AttributeTypeAndValue{ + Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, + Value: challengePassword, + } + cpAttrSet, err := asn1.Marshal(cpAttr) + if err != nil { + t.Fatalf("marshal cp attr: %v", err) + } + tmpl := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: commonName}, + // Inject the challengePassword as a raw extra extension via the + // CSR Attributes field. + ExtraExtensions: []pkix.Extension{}, + Attributes: []pkix.AttributeTypeAndValueSET{ + { + Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, + Value: [][]pkix.AttributeTypeAndValue{ + {{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}, Value: challengePassword}}, + }, + }, + }, + } + _ = cpAttrSet + der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + return der +} + +// buildEnvelopedDataForTest builds an EnvelopedData targeting raCert with +// a single KTRI carrying the encrypted symmetric key + the AES-CBC +// ciphertext. Mirrors the Phase 3 buildEnvelopedDataAES256 internal helper +// but exposed at test scope. +func buildEnvelopedDataForTest(t *testing.T, raCert *x509.Certificate, encryptedKey, iv, ciphertext []byte, contentEncOID asn1.ObjectIdentifier) []byte { + t.Helper() + // IssuerAndSerial of the recipient. + serialDER, err := asn1.Marshal(raCert.SerialNumber) + if err != nil { + t.Fatalf("marshal serial: %v", err) + } + risBody := append([]byte{}, raCert.RawIssuer...) + risBody = append(risBody, serialDER...) + risBytes := pkcs7.ASN1Wrap(0x30, risBody) + + keyEncAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAEncryption, Parameters: asn1.NullRawValue} + keyEncAlgBytes, err := asn1.Marshal(keyEncAlg) + if err != nil { + t.Fatalf("marshal keyEncAlg: %v", err) + } + encryptedKeyBytes := pkcs7.ASN1Wrap(0x04, encryptedKey) + + ktriBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) + ktriBody = append(ktriBody, risBytes...) + ktriBody = append(ktriBody, keyEncAlgBytes...) + ktriBody = append(ktriBody, encryptedKeyBytes...) + ktriBytes := pkcs7.ASN1Wrap(0x30, ktriBody) + + recipientInfosBytes := pkcs7.ASN1Wrap(0x31, ktriBytes) + + ivOctet := pkcs7.ASN1Wrap(0x04, iv) + contentAlg := pkix.AlgorithmIdentifier{ + Algorithm: contentEncOID, + Parameters: asn1.RawValue{FullBytes: ivOctet}, + } + contentAlgBytes, err := asn1.Marshal(contentAlg) + if err != nil { + t.Fatalf("marshal contentAlg: %v", err) + } + + encContentField := pkcs7.ASN1Wrap(0x80, ciphertext) + oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + eciBody := append([]byte{}, oidDataBytes...) + eciBody = append(eciBody, contentAlgBytes...) + eciBody = append(eciBody, encContentField...) + eciBytes := pkcs7.ASN1Wrap(0x30, eciBody) + + envBody := append([]byte{}, []byte{0x02, 0x01, 0x00}...) + envBody = append(envBody, recipientInfosBytes...) + envBody = append(envBody, eciBytes...) + return pkcs7.ASN1Wrap(0x30, envBody) +} + +// buildSignedDataForTest builds a CMS SignedData with the device cert as +// the signer + auth-attrs carrying SCEP messageType / transactionID / +// senderNonce + messageDigest of the encapContent. +func buildSignedDataForTest(t *testing.T, signerKey *rsa.PrivateKey, signerCert *x509.Certificate, messageType domain.SCEPMessageType, transactionID string, senderNonce, encapContent []byte) []byte { + t.Helper() + contentDigest := sha256.Sum256(encapContent) + + // Auth-attrs SET-OF body. + var attrSetBody []byte + attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDContentType, pkcs7.ASN1Wrap(0x06, []byte{0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01}))...) + attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDMessageDigest, pkcs7.ASN1Wrap(0x04, contentDigest[:]))...) + attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPMessageType, pkcs7.ASN1Wrap(0x13, []byte(intToASCII(int(messageType)))))...) + attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPTransactionID, pkcs7.ASN1Wrap(0x13, []byte(transactionID)))...) + attrSetBody = append(attrSetBody, attrSeqHelper(t, pkcs7.OIDSCEPSenderNonce, pkcs7.ASN1Wrap(0x04, senderNonce))...) + + // Sign over SET OF Attribute (RFC 5652 §5.4 quirk). + signedAttrsForSig := pkcs7.ASN1Wrap(0x31, attrSetBody) + digest := sha256.Sum256(signedAttrsForSig) + sig, err := rsa.SignPKCS1v15(rand.Reader, signerKey, 5, digest[:]) // 5 = crypto.SHA256 + if err != nil { + t.Fatalf("sign: %v", err) + } + + // SignerInfo SEQUENCE. + versionBytes := []byte{0x02, 0x01, 0x01} + serialDER, _ := asn1.Marshal(signerCert.SerialNumber) + sidBody := append([]byte{}, signerCert.RawIssuer...) + sidBody = append(sidBody, serialDER...) + sidBytes := pkcs7.ASN1Wrap(0x30, sidBody) + + digestAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDSHA256, Parameters: asn1.NullRawValue} + digestAlgBytes, _ := asn1.Marshal(digestAlg) + + signedAttrsImplicit := pkcs7.ASN1Wrap(0xa0, attrSetBody) + + sigAlg := pkix.AlgorithmIdentifier{Algorithm: pkcs7.OIDRSAWithSHA256, Parameters: asn1.NullRawValue} + sigAlgBytes, _ := asn1.Marshal(sigAlg) + + sigOctet := pkcs7.ASN1Wrap(0x04, sig) + + siBody := append([]byte{}, versionBytes...) + siBody = append(siBody, sidBytes...) + siBody = append(siBody, digestAlgBytes...) + siBody = append(siBody, signedAttrsImplicit...) + siBody = append(siBody, sigAlgBytes...) + siBody = append(siBody, sigOctet...) + siBytes := pkcs7.ASN1Wrap(0x30, siBody) + + // encapContentInfo + octetWrap := pkcs7.ASN1Wrap(0x04, encapContent) + explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap) + oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + encapBody := append([]byte{}, oidDataBytes...) + encapBody = append(encapBody, explicitWrap...) + encapBytes := pkcs7.ASN1Wrap(0x30, encapBody) + + // certificates [0] IMPLICIT SET OF Certificate + certsBytes := pkcs7.ASN1Wrap(0xa0, signerCert.Raw) + + // digestAlgorithms SET OF + digestAlgsBytes := pkcs7.ASN1Wrap(0x31, digestAlgBytes) + // signerInfos SET OF + signerInfosBytes := pkcs7.ASN1Wrap(0x31, siBytes) + + // SignedData SEQUENCE + sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) + sdBody = append(sdBody, digestAlgsBytes...) + sdBody = append(sdBody, encapBytes...) + sdBody = append(sdBody, certsBytes...) + sdBody = append(sdBody, signerInfosBytes...) + sdSeq := pkcs7.ASN1Wrap(0x30, sdBody) + + // ContentInfo wrap + contentField := pkcs7.ASN1Wrap(0xa0, sdSeq) + oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02} + ciBody := append([]byte{}, oidSignedData...) + ciBody = append(ciBody, contentField...) + return pkcs7.ASN1Wrap(0x30, ciBody) +} + +// buildMVPSignedData builds a degenerate SignedData where the encapContent +// is the raw CSR bytes — what lightweight SCEP clients send. Used by the +// MVP-compat test to confirm the legacy parser still works. +func buildMVPSignedData(t *testing.T, csrDER []byte) []byte { + t.Helper() + octetWrap := pkcs7.ASN1Wrap(0x04, csrDER) + explicitWrap := pkcs7.ASN1Wrap(0xa0, octetWrap) + oidDataBytes := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + encapBody := append([]byte{}, oidDataBytes...) + encapBody = append(encapBody, explicitWrap...) + encapBytes := pkcs7.ASN1Wrap(0x30, encapBody) + + digestAlgsBytes := pkcs7.ASN1Wrap(0x31, nil) + signerInfosBytes := pkcs7.ASN1Wrap(0x31, nil) + + sdBody := append([]byte{}, []byte{0x02, 0x01, 0x01}...) + sdBody = append(sdBody, digestAlgsBytes...) + sdBody = append(sdBody, encapBytes...) + sdBody = append(sdBody, signerInfosBytes...) + sdSeq := pkcs7.ASN1Wrap(0x30, sdBody) + + contentField := pkcs7.ASN1Wrap(0xa0, sdSeq) + oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02} + ciBody := append([]byte{}, oidSignedData...) + ciBody = append(ciBody, contentField...) + return pkcs7.ASN1Wrap(0x30, ciBody) +} + +func attrSeqHelper(t *testing.T, oid asn1.ObjectIdentifier, value []byte) []byte { + t.Helper() + oidBytes, err := asn1.Marshal(oid) + if err != nil { + t.Fatalf("marshal OID %v: %v", oid, err) + } + setOfValue := pkcs7.ASN1Wrap(0x31, value) + body := append([]byte{}, oidBytes...) + body = append(body, setOfValue...) + return pkcs7.ASN1Wrap(0x30, body) +} + +func decodeFirstSetMember(t *testing.T, rv asn1.RawValue) string { + t.Helper() + var inner asn1.RawValue + if _, err := asn1.Unmarshal(rv.Bytes, &inner); err != nil { + t.Fatalf("unmarshal SET first member: %v", err) + } + return string(inner.Bytes) +} + +func intToASCII(i int) string { + if i == 0 { + return "0" + } + var b []byte + for i > 0 { + b = append([]byte{byte('0' + i%10)}, b...) + i /= 10 + } + return string(b) +} + +func selfSignedRSACert(t *testing.T, key *rsa.PrivateKey, cn string) *x509.Certificate { + t.Helper() + der := selfSignedRSACertRaw(t, key, cn) + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + return cert +} + +func selfSignedRSACertRaw(t *testing.T, key *rsa.PrivateKey, cn string) []byte { + t.Helper() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + Issuer: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(30 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + return der +} + +func pemEncodeCert(der []byte) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +// silence unused-import warnings — these packages are referenced inside +// helpers above; Go's import-pruning is conservative around test-only +// uses through other test files. +var ( + _ = ecdsa.PublicKey{} + _ = elliptic.P256 + _ = des.NewTripleDESCipher +) diff --git a/internal/api/handler/scep_handler_test.go b/internal/api/handler/scep_handler_test.go index e3c153e..126a30d 100644 --- a/internal/api/handler/scep_handler_test.go +++ b/internal/api/handler/scep_handler_test.go @@ -59,6 +59,22 @@ func (m *mockSCEPService) PKCSReqWithEnvelope(ctx context.Context, csrPEM string } } +// RenewalReqWithEnvelope + GetCertInitialWithEnvelope added in Phase 4 to +// satisfy the extended SCEPService interface. Same MVP-only test fixture +// rules apply — these stubs mirror PKCSReqWithEnvelope's shape. +func (m *mockSCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + return m.PKCSReqWithEnvelope(ctx, csrPEM, challengePassword, envelope) +} + +func (m *mockSCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + return &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadCertID, + TransactionID: envelope.TransactionID, + RecipientNonce: envelope.SenderNonce, + } +} + func TestSCEP_GetCACaps_Success(t *testing.T) { svc := &mockSCEPService{} h := NewSCEPHandler(svc) diff --git a/internal/api/router/router_scep_profiles_test.go b/internal/api/router/router_scep_profiles_test.go index 0dafe79..de252cb 100644 --- a/internal/api/router/router_scep_profiles_test.go +++ b/internal/api/router/router_scep_profiles_test.go @@ -43,14 +43,23 @@ func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*do return nil, nil } -// PKCSReqWithEnvelope was added to the SCEPService interface in SCEP RFC 8894 -// + Intune master bundle Phase 2.4. The router-level tests don't drive the -// RFC 8894 path; this stub satisfies the interface so the per-profile +// PKCSReqWithEnvelope / RenewalReqWithEnvelope / GetCertInitialWithEnvelope +// were added to the SCEPService interface in SCEP RFC 8894 + Intune master +// bundle Phase 2.4 + Phase 4. The router-level tests don't drive the +// RFC 8894 path; these stubs satisfy the interface so the per-profile // dispatch tests still compile. func (s *scepProfileMockService) PKCSReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID} } +func (s *scepProfileMockService) RenewalReqWithEnvelope(_ context.Context, _, _ string, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusSuccess, TransactionID: env.TransactionID} +} + +func (s *scepProfileMockService) GetCertInitialWithEnvelope(_ context.Context, env *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + return &domain.SCEPResponseEnvelope{Status: domain.SCEPStatusFailure, FailInfo: domain.SCEPFailBadCertID, TransactionID: env.TransactionID} +} + func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) { r := New() svc := &scepProfileMockService{tag: "legacy"} diff --git a/internal/connector/issuer/interface.go b/internal/connector/issuer/interface.go index 847db4b..c967692 100644 --- a/internal/connector/issuer/interface.go +++ b/internal/connector/issuer/interface.go @@ -54,8 +54,15 @@ type IssuanceRequest struct { CommonName string `json:"common_name"` SANs []string `json:"sans"` CSRPEM string `json:"csr_pem"` - EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" + EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default) + // MustStaple, when true, instructs the issuer to add the RFC 7633 + // must-staple extension (id-pe-tlsfeature) to the issued cert. + // Plumbed from CertificateProfile.MustStaple at the service layer. + // Issuers that don't support extension injection (Vault, EJBCA, etc.) + // silently ignore this — must-staple is a local-issuer-only feature + // in V2 since upstream connectors enforce their own extension policy. + MustStaple bool `json:"must_staple,omitempty"` } // IssuanceResult contains the result of a successful certificate issuance. @@ -73,9 +80,13 @@ type RenewalRequest struct { CommonName string `json:"common_name"` SANs []string `json:"sans"` CSRPEM string `json:"csr_pem"` - EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" + EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" MaxTTLSeconds int `json:"max_ttl_seconds,omitempty"` // 0 = no cap (use issuer default) OrderID *string `json:"order_id,omitempty"` + // MustStaple — same semantics as IssuanceRequest.MustStaple. The + // renewal pipeline plumbs through the same CertificateProfile.MustStaple + // field so renewed certs match their initial-issuance extension set. + MustStaple bool `json:"must_staple,omitempty"` } // RevocationRequest contains the parameters for revoking a certificate. diff --git a/internal/connector/issuer/local/local.go b/internal/connector/issuer/local/local.go index 785a3e3..d856d5c 100644 --- a/internal/connector/issuer/local/local.go +++ b/internal/connector/issuer/local/local.go @@ -55,6 +55,7 @@ import ( "crypto/sha256" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "encoding/json" "encoding/pem" "fmt" @@ -332,7 +333,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc } // Generate certificate with EKUs and MaxTTL from request - cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds) + cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple) if err != nil { c.logger.Error("failed to generate certificate", "error", err) return nil, fmt.Errorf("certificate generation failed: %w", err) @@ -396,7 +397,7 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal } // Generate certificate with EKUs and MaxTTL from request - cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds) + cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple) if err != nil { c.logger.Error("failed to generate certificate", "error", err) return nil, fmt.Errorf("certificate generation failed: %w", err) @@ -643,7 +644,7 @@ func (c *Connector) generateSelfSignedCA() error { // It uses the CSR subject and adds any additional SANs from the request. // If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth. // If maxTTLSeconds > 0, the certificate validity is capped to that duration. -func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int) (*x509.Certificate, string, string, error) { +func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int, mustStaple bool) (*x509.Certificate, string, string, error) { // Generate random serial number serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159)) if err != nil { @@ -719,6 +720,21 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional } } + // SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple + // extension per RFC 7633. When the bound CertificateProfile has + // MustStaple=true, the issued cert carries id-pe-tlsfeature with + // the TLS Feature `status_request` (5). Browsers + modern TLS + // libraries that see this extension fail-closed when OCSP stapling + // is missing — defense against revocation-bypass via OCSP + // blackholing. + if mustStaple { + template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{ + Id: oidMustStaple, + Critical: false, + Value: mustStapleExtensionValue, + }) + } + // Sign certificate with CA certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner) if err != nil { @@ -767,6 +783,26 @@ func isEmail(s string) bool { } // ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants. +// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple extension +// constants per RFC 7633 §6. +// +// id-pe-tlsfeature OID: 1.3.6.1.5.5.7.1.24. +var oidMustStaple = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} + +// mustStapleExtensionValue is the pre-encoded DER for SEQUENCE OF INTEGER +// containing a single value 5 (the TLS Feature for status_request, RFC +// 7633 §6 referencing IANA TLS ExtensionType registry). +// +// Wire bytes: +// +// 0x30 0x03 -- SEQUENCE, length 3 +// 0x02 0x01 0x05 -- INTEGER 5 (status_request) +// +// Pre-encoded as a constant rather than asn1.Marshal'd at runtime: the +// extension value is fixed, byte-stable across Go versions, and tested by +// pinning the exact bytes against RFC 7633 §6. +var mustStapleExtensionValue = []byte{0x30, 0x03, 0x02, 0x01, 0x05} + var ekuNameToX509 = map[string]x509.ExtKeyUsage{ "serverAuth": x509.ExtKeyUsageServerAuth, "clientAuth": x509.ExtKeyUsageClientAuth, diff --git a/internal/connector/issuer/local/must_staple_test.go b/internal/connector/issuer/local/must_staple_test.go new file mode 100644 index 0000000..b2483ad --- /dev/null +++ b/internal/connector/issuer/local/must_staple_test.go @@ -0,0 +1,172 @@ +package local + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "io" + "log/slog" + "testing" + + "github.com/shankar0123/certctl/internal/connector/issuer" +) + +// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple per-profile +// policy field (RFC 7633). +// +// Pins the contract that: +// +// 1. When the IssuanceRequest carries MustStaple=true, the issued cert +// contains the id-pe-tlsfeature extension with the canonical +// wire bytes (SEQUENCE OF INTEGER {5} per RFC 7633 §6). +// +// 2. When MustStaple=false (or unset), the extension is OMITTED — adding +// it by default would break customer deployments where the TLS path +// doesn't staple. +// +// 3. The OID + DER bytes match RFC 7633 §6 verbatim: +// OID 1.3.6.1.5.5.7.1.24, value 0x30 0x03 0x02 0x01 0x05. +// +// The test exercises the local issuer end-to-end (CSR → CreateCertificate +// → ParseCertificate → walk Extensions) so any drift in the extension- +// injection path is caught. + +func TestGenerateCertificate_MustStapleProfile_AddsExtension(t *testing.T) { + conn, _ := newLocalIssuerForMustStapleTest(t) + csrPEM := buildMustStapleCSR(t, "must-staple.example.com") + + result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{ + CommonName: "must-staple.example.com", + SANs: []string{"must-staple.example.com"}, + CSRPEM: csrPEM, + EKUs: []string{"serverAuth"}, + MaxTTLSeconds: 86400, + MustStaple: true, + }) + if err != nil { + t.Fatalf("IssueCertificate: %v", err) + } + + cert := parsePEMCertForTest(t, result.CertPEM) + ext := findExtensionByOID(cert, oidMustStaple) + if ext == nil { + t.Fatal("issued cert is missing id-pe-tlsfeature extension despite MustStaple=true") + } + if ext.Critical { + t.Errorf("must-staple extension Critical = true, want false (RFC 7633 §6 says non-critical)") + } + if !bytes.Equal(ext.Value, mustStapleExtensionValue) { + t.Errorf("must-staple extension Value = %x, want %x (RFC 7633 §6 SEQUENCE OF INTEGER {5})", + ext.Value, mustStapleExtensionValue) + } +} + +func TestGenerateCertificate_NoMustStaple_OmitsExtension(t *testing.T) { + conn, _ := newLocalIssuerForMustStapleTest(t) + csrPEM := buildMustStapleCSR(t, "no-staple.example.com") + + result, err := conn.IssueCertificate(context.Background(), issuer.IssuanceRequest{ + CommonName: "no-staple.example.com", + SANs: []string{"no-staple.example.com"}, + CSRPEM: csrPEM, + EKUs: []string{"serverAuth"}, + MaxTTLSeconds: 86400, + // MustStaple intentionally unset — defaults to false. + }) + if err != nil { + t.Fatalf("IssueCertificate: %v", err) + } + + cert := parsePEMCertForTest(t, result.CertPEM) + if ext := findExtensionByOID(cert, oidMustStaple); ext != nil { + t.Errorf("issued cert has id-pe-tlsfeature extension despite MustStaple=false (would break non-stapling deploys)") + } +} + +// TestMustStapleConstants_PinExactRFC7633Bytes locks down the exact OID + +// DER bytes against RFC 7633 §6. If a future refactor changes the +// pre-encoded value in any way, this test fails — catches drift before +// it reaches a real cert. +func TestMustStapleConstants_PinExactRFC7633Bytes(t *testing.T) { + wantOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} // id-pe-tlsfeature + if !oidMustStaple.Equal(wantOID) { + t.Errorf("oidMustStaple = %v, want %v (RFC 7633 §6)", oidMustStaple, wantOID) + } + + // The TLS Feature for status_request is INTEGER 5 (per the IANA TLS + // ExtensionType registry). RFC 7633 §6 wraps that in SEQUENCE OF. + wantBytes := []byte{0x30, 0x03, 0x02, 0x01, 0x05} + if !bytes.Equal(mustStapleExtensionValue, wantBytes) { + t.Errorf("mustStapleExtensionValue = %x, want %x (SEQUENCE OF INTEGER {5})", + mustStapleExtensionValue, wantBytes) + } + + // Sanity: the bytes round-trip through asn1.Unmarshal as the + // expected structure. + var parsed []int + if _, err := asn1.Unmarshal(mustStapleExtensionValue, &parsed); err != nil { + t.Fatalf("mustStapleExtensionValue does not parse as SEQUENCE OF INTEGER: %v", err) + } + if len(parsed) != 1 || parsed[0] != 5 { + t.Errorf("parsed mustStaple = %v, want [5]", parsed) + } +} + +// --- helpers ------------------------------------------------------------- + +// newLocalIssuerForMustStapleTest builds a self-signed local CA Connector +// using the package's standard New + ensureCA path — same constructor +// production uses, so any drift in the cert-template-injection code path +// is exercised faithfully. +func newLocalIssuerForMustStapleTest(t *testing.T) (*Connector, *x509.Certificate) { + t.Helper() + c := New(&Config{ValidityDays: 7}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := c.ensureCA(context.Background()); err != nil { + t.Fatalf("ensureCA: %v", err) + } + return c, c.caCert +} + +func buildMustStapleCSR(t *testing.T, cn string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa.GenerateKey CSR: %v", err) + } + tmpl := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + } + der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})) +} + +func parsePEMCertForTest(t *testing.T, certPEM string) *x509.Certificate { + t.Helper() + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + t.Fatal("PEM decode returned nil") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + return cert +} + +func findExtensionByOID(cert *x509.Certificate, oid asn1.ObjectIdentifier) *pkix.Extension { + for i := range cert.Extensions { + if cert.Extensions[i].Id.Equal(oid) { + return &cert.Extensions[i] + } + } + return nil +} diff --git a/internal/domain/profile.go b/internal/domain/profile.go index c89b385..75502f1 100644 --- a/internal/domain/profile.go +++ b/internal/domain/profile.go @@ -17,9 +17,26 @@ type CertificateProfile struct { RequiredSANPatterns []string `json:"required_san_patterns"` SPIFFEURIPattern string `json:"spiffe_uri_pattern"` AllowShortLived bool `json:"allow_short_lived"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // MustStaple, when true, causes the local issuer to add the RFC 7633 + // must-staple extension (id-pe-tlsfeature, OID 1.3.6.1.5.5.7.1.24) to + // every certificate issued under this profile. Browsers + modern TLS + // libraries that see this extension MUST fail-closed on missing OCSP + // stapling responses — defense against revocation-bypass via OCSP + // blackholing. + // + // Default: false. Operators opt in once they've confirmed their TLS + // reverse proxy / load balancer staples OCSP responses (NGINX, + // HAProxy, Envoy, etc. all support stapling but it requires explicit + // config). Setting must-staple by default would break customer + // deployments where the TLS path doesn't staple — browsers hard-fail. + // + // Recommended for: Intune-deployed device certs (modern TLS clients); + // SCEP profiles serving general/legacy clients (ChromeOS, IoT) should + // stay false until the TLS path is verified. + MustStaple bool `json:"must_staple"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // KeyAlgorithmRule defines an allowed key algorithm and its minimum key size. diff --git a/internal/service/scep.go b/internal/service/scep.go index 02fb44b..a373b5c 100644 --- a/internal/service/scep.go +++ b/internal/service/scep.go @@ -49,8 +49,16 @@ func (s *SCEPService) SetProfileRepo(repo repository.CertificateProfileRepositor // GetCACaps returns the capabilities of this SCEP server. // RFC 8894 Section 3.5.2: GetCACaps returns a list of capabilities, one per line. +// +// SCEP RFC 8894 + Intune master bundle Phase 5.1: extended from the +// initial value (POSTPKIOperation+SHA-256+AES+SCEPStandard) to additionally +// advertise SHA-512 (now-implemented modern digest alternative) and Renewal +// (the messageType-17 dispatch from Phase 4). ChromeOS specifically looks +// for these capabilities to negotiate the strongest available cipher + +// digest combo. Order is by historical convention; clients walk the list +// linearly. func (s *SCEPService) GetCACaps(ctx context.Context) string { - return "POSTPKIOperation\nSHA-256\nAES\nSCEPStandard\n" + return "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n" } // GetCACert returns the PEM-encoded CA certificate chain for this SCEP server. @@ -299,3 +307,135 @@ func containsAnyOf(s string, needles ...string) bool { } return false } + +// RenewalReqWithEnvelope processes a SCEP RenewalReq from the RFC 8894 path. +// RFC 8894 §3.3.1.2 — re-enrollment with an existing valid cert. Distinct +// from PKCSReq because the signerInfo is signed by the EXISTING cert +// (proving possession), not by a transient self-signed device key. +// +// SCEP RFC 8894 + Intune master bundle Phase 4.2. +// +// Functionally identical to PKCSReqWithEnvelope but with two differences: +// +// 1. Audit action is `scep_renewalreq` (vs `scep_pkcsreq`) — operators +// can grep the audit log to distinguish initial enrollments from +// renewals. +// +// 2. The signing cert presented as POPO MUST chain to the issuer's CA +// (the cert was previously issued by THIS issuer, not a self-signed +// throwaway). Verified against the issuer's GetCACertPEM chain via +// x509.Certificate.Verify. A signing cert that doesn't chain is +// mapped to BadMessageCheck per the same RFC 8894 §3.3.2.2 semantics +// as an EnvelopedData decrypt failure (integrity-check failure). +// +// Returns *SCEPResponseEnvelope (same contract as PKCSReqWithEnvelope); +// nil signals 'invalid challenge password' for HTTP 403 translation. +func (s *SCEPService) RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + resp := &domain.SCEPResponseEnvelope{ + TransactionID: envelope.TransactionID, + RecipientNonce: envelope.SenderNonce, + } + + // Same challenge-password gate as PKCSReqWithEnvelope. Defense in depth + // even though the RenewalReq path additionally verifies the signing + // cert chain — a stolen/leaked challenge password combined with a + // previously-issued cert (e.g. from a compromised device) would still + // allow renewal otherwise. The two checks are independent. + if s.challengePassword == "" { + s.logger.Warn("SCEP renewal rejected: server has no challenge password configured (RFC 8894 path)", + "transaction_id", envelope.TransactionID) + return nil + } + if subtle.ConstantTimeCompare([]byte(challengePassword), []byte(s.challengePassword)) != 1 { + s.logger.Warn("SCEP renewal rejected: invalid challenge password (RFC 8894 path)", + "transaction_id", envelope.TransactionID) + return nil + } + + // Verify the signing cert chains to the issuer's CA. Without this gate + // any self-signed cert with a valid challenge password could trigger a + // renewal — defeating the 'proof of prior issuance' contract RenewalReq + // is supposed to provide. + if err := s.verifyRenewalSignerCertChain(ctx, envelope.SignerCert); err != nil { + s.logger.Warn("SCEP renewal rejected: signer cert chain invalid", + "transaction_id", envelope.TransactionID, + "error", err.Error(), + ) + resp.Status = domain.SCEPStatusFailure + resp.FailInfo = domain.SCEPFailBadMessageCheck + return resp + } + + // Reuse the existing processEnrollment for the actual issuance work + // — RenewalReq is functionally a re-issuance with a different audit + // action and chain-validation precondition. + result, err := s.processEnrollment(ctx, csrPEM, envelope.TransactionID, "scep_renewalreq") + if err != nil { + resp.Status = domain.SCEPStatusFailure + resp.FailInfo = mapServiceErrorToFailInfo(err) + return resp + } + resp.Status = domain.SCEPStatusSuccess + resp.Result = result + return resp +} + +// verifyRenewalSignerCertChain confirms the device's signing cert (the cert +// presented as POPO in the SignerInfo) was previously issued by the +// configured issuer. Used by RenewalReqWithEnvelope to enforce the 'must +// have a previously-issued cert' contract RFC 8894 §3.3.1.2 implies. +// +// A self-signed throwaway cert (initial-enrollment shape) fails this check +// — that's an indicator the client meant to send PKCSReq, not RenewalReq. +// Operators see the audit-log entry; the client sees BadMessageCheck. +func (s *SCEPService) verifyRenewalSignerCertChain(ctx context.Context, signerCertDER []byte) error { + if len(signerCertDER) == 0 { + return fmt.Errorf("signer cert is empty (no POPO cert in SignerInfo)") + } + signerCert, err := x509.ParseCertificate(signerCertDER) + if err != nil { + return fmt.Errorf("parse signer cert: %w", err) + } + + // Pull the issuer's CA chain via the existing IssuerConnector + // surface. Failure here is a deploy bug (the issuer connector lost + // its CA cert mid-flight) rather than a client error — surface as + // the same generic failure to avoid leaking server state. + caPEM, err := s.issuer.GetCACertPEM(ctx) + if err != nil { + return fmt.Errorf("get CA cert PEM: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(caPEM)) { + return fmt.Errorf("CA cert PEM contains no parseable certs") + } + opts := x509.VerifyOptions{ + Roots: pool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + if _, err := signerCert.Verify(opts); err != nil { + return fmt.Errorf("signer cert chain validation failed: %w", err) + } + return nil +} + +// GetCertInitialWithEnvelope handles SCEP polling requests. RFC 8894 §3.3.3 +// — the client polls when the prior PKCSReq returned Status=Pending. +// +// SCEP RFC 8894 + Intune master bundle Phase 4.3. +// +// v1 of this bundle returns FAILURE+badCertID for all GetCertInitial +// requests since deferred-issuance isn't supported (every PKCSReq either +// succeeds or fails synchronously — no Pending state in the existing +// service-layer issuance pipeline). The wiring stays in place for a +// future enhancement (e.g. 'queue for manual approval' workflows). +func (s *SCEPService) GetCertInitialWithEnvelope(_ context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope { + s.logger.Info("SCEP GetCertInitial received — deferred-issuance not supported in v1, returning badCertID", + "transaction_id", envelope.TransactionID) + return &domain.SCEPResponseEnvelope{ + Status: domain.SCEPStatusFailure, + FailInfo: domain.SCEPFailBadCertID, + TransactionID: envelope.TransactionID, + RecipientNonce: envelope.SenderNonce, + } +} diff --git a/internal/service/scep_test.go b/internal/service/scep_test.go index dbd3d10..25e09da 100644 --- a/internal/service/scep_test.go +++ b/internal/service/scep_test.go @@ -26,6 +26,18 @@ func TestSCEPService_GetCACaps(t *testing.T) { if !strings.Contains(caps, "SCEPStandard") { t.Errorf("expected SCEPStandard in caps, got: %s", caps) } + // SCEP RFC 8894 Phase 5.1 additions — pin the new caps so a future + // 'simplify caps' refactor doesn't quietly remove ChromeOS-required + // negotiation flags. + if !strings.Contains(caps, "SHA-512") { + t.Errorf("expected SHA-512 in caps (Phase 5.1 addition), got: %s", caps) + } + if !strings.Contains(caps, "AES") { + t.Errorf("expected AES in caps, got: %s", caps) + } + if !strings.Contains(caps, "Renewal") { + t.Errorf("expected Renewal in caps (Phase 5.1 addition — RenewalReq messageType support), got: %s", caps) + } } func TestSCEPService_GetCACert_Success(t *testing.T) {