Files
certctl/internal/service/scep_test.go
T
shankar0123 b33b843908 feat(scep): RenewalReq + GetCertInitial + ChromeOS E2E + caps + must-staple
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.
2026-04-29 13:16:09 +00:00

240 lines
8.9 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"testing"
)
func TestSCEPService_GetCACaps(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
caps := svc.GetCACaps(context.Background())
if caps == "" {
t.Error("expected non-empty capabilities")
}
if !strings.Contains(caps, "POSTPKIOperation") {
t.Errorf("expected POSTPKIOperation in caps, got: %s", caps)
}
if !strings.Contains(caps, "SHA-256") {
t.Errorf("expected SHA-256 in caps, got: %s", caps)
}
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) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
caPEM, err := svc.GetCACert(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if caPEM == "" {
t.Error("expected non-empty CA PEM")
}
}
func TestSCEPService_GetCACert_IssuerError(t *testing.T) {
mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
_, err := svc.GetCACert(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "CA unavailable") {
t.Errorf("expected error to contain 'CA unavailable', got: %v", err)
}
}
func TestSCEPService_PKCSReq_Success(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
// H-2: SCEPService now requires a configured challenge password; the happy
// path exercises a matching client-submitted password.
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "device.example.com", []string{"device.example.com"})
result, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.CertPEM == "" {
t.Error("expected non-empty CertPEM")
}
// Verify audit event was recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
}
func TestSCEPService_PKCSReq_InvalidCSR(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
_, err := svc.PKCSReq(context.Background(), "not-valid-pem", "secret123", "txn-002")
if err == nil {
t.Fatal("expected error for invalid CSR")
}
}
func TestSCEPService_PKCSReq_MissingCN(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "", []string{"test.example.com"})
_, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-003")
if err == nil {
t.Fatal("expected error for missing CN")
}
if !strings.Contains(err.Error(), "Common Name") {
t.Errorf("expected 'Common Name' in error, got: %v", err)
}
}
func TestSCEPService_PKCSReq_IssuerError(t *testing.T) {
mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "test.example.com", nil)
_, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-004")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "issuance failed") {
t.Errorf("expected 'issuance failed', got: %v", err)
}
}
func TestSCEPService_PKCSReq_ChallengePassword_Valid(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "mdm-device.example.com", nil)
result, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-005")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestSCEPService_PKCSReq_ChallengePassword_Invalid(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "mdm-device.example.com", nil)
_, err := svc.PKCSReq(context.Background(), csrPEM, "wrong-password", "txn-006")
if err == nil {
t.Fatal("expected error for invalid challenge password")
}
if !strings.Contains(err.Error(), "challenge password") {
t.Errorf("expected 'challenge password' in error, got: %v", err)
}
}
// TestSCEPService_PKCSReq_ChallengePassword_EmptyServerConfigRejected is the
// H-2 regression guard. Before the fix (internal/service/scep.go:72-79 skipped
// the password check when s.challengePassword was empty), an unconfigured
// server accepted any enrollment (CWE-306). The service now rejects PKCSReq
// defense-in-depth even if main()'s pre-flight is somehow bypassed.
func TestSCEPService_PKCSReq_ChallengePassword_EmptyServerConfigRejected(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "")
csrPEM := generateCSRPEM(t, "device.example.com", nil)
// Any client-submitted password (including empty) must be rejected when
// the server has no shared secret configured.
for _, clientPassword := range []string{"", "any-value", "guess"} {
_, err := svc.PKCSReq(context.Background(), csrPEM, clientPassword, "txn-empty")
if err == nil {
t.Fatalf("expected rejection when server challenge password is empty (client=%q)", clientPassword)
}
if !strings.Contains(err.Error(), "not configured") {
t.Errorf("expected 'not configured' in error, got: %v", err)
}
}
}
// TestSCEPService_PKCSReq_ChallengePassword_ConstantTimeLengthIndependence
// guards against regression from crypto/subtle.ConstantTimeCompare to a
// short-circuiting byte compare. ConstantTimeCompare returns 0 whenever the
// two slices differ in length OR content, so a same-prefix-but-longer input
// must be rejected the same way as a completely different string.
func TestSCEPService_PKCSReq_ChallengePassword_ConstantTimeLengthIndependence(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
svc := NewSCEPService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
csrPEM := generateCSRPEM(t, "device.example.com", nil)
for _, bad := range []string{"secret", "secret12", "secret1234", "SECRET123", "wrong"} {
_, err := svc.PKCSReq(context.Background(), csrPEM, bad, "txn-ct")
if err == nil {
t.Fatalf("expected rejection for bad password %q", bad)
}
if !strings.Contains(err.Error(), "invalid challenge password") {
t.Errorf("expected 'invalid challenge password' for %q, got: %v", bad, err)
}
}
}
func TestSCEPService_PKCSReq_WithProfile(t *testing.T) {
mockIssuer := &mockIssuerConnector{}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
svc := NewSCEPService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), "secret123")
svc.SetProfileID("profile-mdm-device")
csrPEM := generateCSRPEM(t, "device.example.com", nil)
result, err := svc.PKCSReq(context.Background(), csrPEM, "secret123", "txn-008")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
// Verify audit event includes profile_id
if len(auditRepo.Events) == 0 {
t.Fatal("expected audit event")
}
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
if lastEvent.Details == nil {
t.Fatal("expected audit details")
}
}