mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 13:48:52 +00:00
b33b843908
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.
184 lines
7.2 KiB
Go
184 lines
7.2 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/api/handler"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 1.5: per-issuer profiles router
|
|
// registration. Pins:
|
|
//
|
|
// 1. Empty PathID maps to /scep root (legacy backward-compat).
|
|
// 2. Non-empty PathID maps to /scep/<pathID>.
|
|
// 3. Multi-profile registration produces 2N routes (GET + POST per profile).
|
|
// 4. Each registered route reaches the right handler instance — no
|
|
// cross-profile bleed-through (proven by the per-profile mock counters).
|
|
//
|
|
// The mock service is a minimal SCEPService implementation that records
|
|
// which profile served the request via the GetCACaps capability string —
|
|
// the test asserts it sees the right per-profile string echoed back, which
|
|
// would only happen if the right handler was wired to the right path.
|
|
|
|
// scepProfileMockService is a per-profile-tagged mock SCEPService for
|
|
// router-level tests. The CACaps string carries the profile tag so the
|
|
// caller can verify which profile's handler served a given request.
|
|
type scepProfileMockService struct {
|
|
tag string
|
|
}
|
|
|
|
func (s *scepProfileMockService) GetCACaps(_ context.Context) string {
|
|
return "POSTPKIOperation\nSHA-256\nPROFILE=" + s.tag + "\n"
|
|
}
|
|
|
|
func (s *scepProfileMockService) GetCACert(_ context.Context) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// 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"}
|
|
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
|
|
"": handler.NewSCEPHandler(svc),
|
|
})
|
|
|
|
// GetCACaps is GET-only per RFC 8894 §3.5.2. The router registers BOTH
|
|
// GET and POST; the handler decides what each operation accepts. We
|
|
// exercise GET here (POST PKIOperation is exercised by the existing
|
|
// internal/api/handler tests and by the e2e suite).
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /scep — code %d, want 200 (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); !contains(got, "PROFILE=legacy") {
|
|
t.Errorf("GET /scep body = %q, want contains PROFILE=legacy", got)
|
|
}
|
|
// Confirm POST /scep IS registered at the router level (the handler
|
|
// will respond 405 for GetCACaps because it's GET-only, but the route
|
|
// has to exist or we'd get a 404 from the mux instead).
|
|
req = httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /scep?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath(t *testing.T) {
|
|
r := New()
|
|
svc := &scepProfileMockService{tag: "corp"}
|
|
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
|
|
"corp": handler.NewSCEPHandler(svc),
|
|
})
|
|
|
|
// GET /scep/corp?operation=GetCACaps reaches the corp handler.
|
|
req := httptest.NewRequest(http.MethodGet, "/scep/corp?operation=GetCACaps", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /scep/corp — code %d, want 200 (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); !contains(got, "PROFILE=corp") {
|
|
t.Errorf("GET /scep/corp body = %q, want contains PROFILE=corp", got)
|
|
}
|
|
// POST /scep/corp must also be registered (the handler will reject
|
|
// GetCACaps as 405; we just confirm the route exists).
|
|
req = httptest.NewRequest(http.MethodPost, "/scep/corp?operation=GetCACaps", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /scep/corp?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code)
|
|
}
|
|
// /scep root must NOT be registered when only non-empty PathIDs exist.
|
|
req = httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("/scep without legacy profile — code %d, want 404 or 405 (no handler should be registered)", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed(t *testing.T) {
|
|
r := New()
|
|
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{
|
|
"": handler.NewSCEPHandler(&scepProfileMockService{tag: "default"}),
|
|
"corp": handler.NewSCEPHandler(&scepProfileMockService{tag: "corp"}),
|
|
"iot": handler.NewSCEPHandler(&scepProfileMockService{tag: "iot"}),
|
|
})
|
|
|
|
cases := []struct {
|
|
path string
|
|
wantTag string
|
|
}{
|
|
{"/scep?operation=GetCACaps", "default"},
|
|
{"/scep/corp?operation=GetCACaps", "corp"},
|
|
{"/scep/iot?operation=GetCACaps", "iot"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code %d, want 200", w.Code)
|
|
}
|
|
if got := w.Body.String(); !contains(got, "PROFILE="+tc.wantTag) {
|
|
t.Errorf("body = %q, want contains PROFILE=%s", got, tc.wantTag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes(t *testing.T) {
|
|
r := New()
|
|
r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("/scep with no profiles registered — code %d, want 404 or 405", w.Code)
|
|
}
|
|
}
|
|
|
|
// Tiny helper local to this file to avoid importing strings just for one
|
|
// substring check; keeps the test file's import surface minimal.
|
|
func contains(haystack, needle string) bool {
|
|
if len(needle) == 0 {
|
|
return true
|
|
}
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|