Files
certctl/internal/api/router/router_scep_profiles_test.go
T
shankar0123 a546a1bbef feat(scep): EnvelopedData decrypt + signerInfo POPO verify (RFC 8894 §3.2)
SCEP RFC 8894 + Intune master bundle — Phase 2 of 14.

Implements the new RFC 8894 PKIMessage parse path: EnvelopedData parser
+ decryptor, signerInfo parser + signature verifier, handler dispatch
that tries the RFC 8894 path FIRST and falls through to the legacy MVP
raw-CSR path on any parse failure. Backward compat with lightweight SCEP
clients is preserved by design — no behavior change for any existing
deploy that doesn't set CERTCTL_SCEP_RA_*.

internal/pkcs7/envelopeddata.go (new, ~330 LoC)
  * ParseEnvelopedData: parses CMS EnvelopedData per RFC 5652 §6.1, with
    optional outer ContentInfo unwrapping. Handles SET OF RecipientInfo
    + IssuerAndSerial form rid (RFC 8894 §3.2.2).
  * EnvelopedData.Decrypt: RSA PKCS#1 v1.5 key-trans + AES-CBC (128/192/
    256) or DES-EDE3-CBC content decryption with **constant-time PKCS#7
    padding strip** (no branch on padding-byte values; closes the
    padding-oracle leak surface). Recipient mismatch is BadMessageCheck
    per RFC 8894 §3.3.2.2 (NOT BadCertID); every failure mode returns
    the same ErrEnvelopedDataDecrypt sentinel to close timing-leak legs
    of Bleichenbacher attacks.
  * Equivalent to micromdm/scep's cryptoutil/cryptoutil.go::DecryptPKCS-
    Envelope (cited in code comments; not vendored — fuzz-target
    ownership stays in this sub-package per the operating rule).

internal/pkcs7/signedinfo.go (new, ~370 LoC)
  * ParseSignedData / ParseSignerInfos: parses CMS SignedData per RFC
    5652 §5.3. Resolves each SignerInfo's SID (IssuerAndSerial v1 OR
    [0] SubjectKeyId v3) against the SignedData certificates SET to
    pluck the device's transient signing cert.
  * SignerInfo.VerifySignature: re-serialises signedAttrs as the
    canonical SET OF Attribute (the RFC 5652 §5.4 quirk every CMS
    implementation hits — wire form is [0] IMPLICIT but the signature
    is over EXPLICIT SET OF). Hashes with SHA-1/SHA-256/SHA-512 +
    verifies via RSA PKCS1v15 or ECDSA per the cert's pubkey type.
  * Auth-attr extractors: GetMessageType (PrintableString-decimal),
    GetTransactionID, GetSenderNonce, GetMessageDigest. SCEP attr OIDs
    pinned (RFC 8894 §3.2.1.4).

internal/pkcs7/{envelopeddata,signedinfo}_fuzz_test.go (new)
  * FuzzParseEnvelopedData / FuzzParseSignedData / FuzzParseSignerInfos
    / FuzzVerifySignerInfoSignature — every parser certctl adds gets a
    panic-safety fuzzer (the fuzz-target-ownership rule from
    cowork/CLAUDE.md::Operating Rules). Local 5s runs hit ~270k
    executions per parser without panic. Errors are expected for
    arbitrary inputs; only panics are bugs.

internal/pkcs7/{envelopeddata,signedinfo}_test.go (new)
  * Round-trip tests that materialise real RSA/ECDSA pairs, hand-build
    the wire bytes, parse + decrypt + verify, and assert plaintext /
    auth-attr equality. The build helpers use this package's ASN1Wrap
    primitives directly (asn1.Marshal of structs containing nested
    asn1.RawValue is finicky for mixed Class/Tag); gives byte-level
    control matching what real SCEP clients emit.
  * Negative tests: tampered ciphertext / tampered auth-attrs / wrong
    RA / wrong key / mismatched recipients / random garbage all return
    the appropriate sentinel error without panic.

internal/service/scep.go
  * PKCSReqWithEnvelope: RFC 8894 envelope-aware variant. Returns
    *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because RFC
    8894 §3.3 mandates a CertRep PKIMessage on every response, even
    failures — the handler shouldn't translate Go errors into SCEP
    failInfo codes. Returns nil to signal 'invalid challenge password'
    so the caller can translate to HTTP 403 (matches MVP path's wire
    shape; RFC 8894 §3.3.1 is silent on this case).
  * mapServiceErrorToFailInfo: exact mapping table from the prompt
    (CSR parse → BadRequest, CSR sig → BadMessageCheck, crypto policy
    → BadAlg, default → BadRequest).

internal/api/handler/scep.go
  * SCEPService interface gains PKCSReqWithEnvelope.
  * SCEPHandler now optionally carries an RA cert + key pair. SetRAPair
    upgrades the handler to the RFC 8894 path; without that call the
    handler stays MVP-only (the v2.0.x behavior).
  * pkiOperation: tries the RFC 8894 path FIRST when the RA pair is
    set. tryParseRFC8894 helper does the full pipeline (ParseSignedData
    → VerifySignature → extract auth-attrs → ParseEnvelopedData → Decrypt
    → x509.ParseCertificateRequest the recovered bytes). On any failure
    it falls through to the legacy extractCSRFromPKCS7 MVP path —
    backward compat is non-negotiable.
  * Phase 2 emits the legacy certs-only response on RFC 8894 success;
    Phase 3 (next commit) swaps in writeCertRepPKIMessage with the
    proper status / failInfo / nonce-echo wire shape.

cmd/server/main.go
  * Per-profile loop now calls loadSCEPRAPair after preflight to load
    the cert + key + inject via SetRAPair. crypto + crypto/tls imports
    added.
  * loadSCEPRAPair helper: tls.X509KeyPair-based parse + leaf cert
    extraction. Failures here indicate TOCTOU between preflight + load.

internal/api/handler/scep_handler_test.go +
internal/api/router/router_scep_profiles_test.go
  * mockSCEPService / scepProfileMockService gain PKCSReqWithEnvelope
    stubs to satisfy the extended interface. Existing test cases
    unchanged (they exercise the MVP path; RA pair is unset).

Verification:
  * gofmt + go vet clean for the files I touched.
  * go test -short -count=1 green across pkcs7 / api/handler /
    api/router / service / cmd/server.
  * Coverage: pkcs7 78.4% (was 100% — drops because new code includes
    paths the round-trip tests don't yet hit, like decryption alg
    fall-through and v3 SubjectKeyId SID matching).
  * Fuzz-target seed-corpus runs (5s each, ~270k execs/parser): no
    panic. Pre-merge fuzz-time bumps to 30s per the prompt's
    verification gate.

Phase 2 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
2026-04-29 12:36:27 +00:00

175 lines
6.6 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 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
// 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 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
}