Files
certctl/internal/api/handler/scep_intune_e2e_test.go
T
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

683 lines
28 KiB
Go

package handler
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"log/slog"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/pkcs7"
"github.com/certctl-io/certctl/internal/repository"
"github.com/certctl-io/certctl/internal/scep/intune"
"github.com/certctl-io/certctl/internal/service"
)
// SCEP RFC 8894 + Intune master bundle Phase 10.2 — hermetic end-to-end
// test for the Intune dispatcher running through the full handler →
// service → validator → CertRep wire path.
//
// What this test exercises (top to bottom):
//
// 1. Real SCEPService instance with SetIntuneIntegration wired to a
// real intune.TrustAnchorHolder (loaded from a temp PEM file).
// 2. Real intune.ReplayCache + intune.PerDeviceRateLimiter.
// 3. Real SCEPHandler with RA cert/key + service injected.
// 4. Real PKIMessage built via the existing chromeOS-shape builders
// (SignedData wrapping EnvelopedData wrapping a CSR carrying the
// Intune-shaped challengePassword attribute).
// 5. POST through HandleSCEP — handler runs tryParseRFC8894 →
// service.PKCSReqWithEnvelope → dispatchIntuneChallenge →
// ValidateChallenge → DeviceMatchesCSR → replay → rate-limit →
// processEnrollment → CertRep PKIMessage response.
// 6. Decode the CertRep response and assert pkiStatus=Success.
//
// What this test deliberately does NOT do:
//
// - Boot docker-compose.test.yml. The spec's deploy/test/ variant
// reserves that for a future enhancement that mounts a fixture
// trust anchor into the running container; this hermetic version
// runs in the default `go test ./...` sweep so every CI run
// exercises the full Intune chain.
// - Hit a real issuer connector. The IssuerConnector is a fixture
// mock (intuneE2EIssuerConnector below) that returns a deterministic
// issued cert so the test can assert its own CN/SANs without
// spinning up a CA.
// intuneE2EFixture wires up a real SCEPService with the Intune dispatcher
// enabled, a real handler, plus a forged Intune Connector signing
// keypair the test uses to mint valid challenges.
type intuneE2EFixture struct {
connectorKey *ecdsa.PrivateKey
connectorDir string // dir holding the trust-anchor PEM (for SIGHUP-reload tests)
trustPath string // PEM file the holder watches; rewriting + Reload simulates SIGHUP
trustHolder *intune.TrustAnchorHolder
raKey *rsa.PrivateKey
raCert *x509.Certificate
deviceKey *rsa.PrivateKey
deviceCert *x509.Certificate
issuer *intuneE2EIssuerConnector
auditRepo *intuneE2EAuditRepo
scepService *service.SCEPService
handler SCEPHandler
}
// intuneE2EIssuerConnector is a minimal IssuerConnector that returns a
// deterministic fake-issued cert. We don't need a real CA for this test
// — the goal is to verify the handler→service→dispatcher chain end to
// end, NOT to verify cert issuance (which is covered in the local
// issuer's own tests).
type intuneE2EIssuerConnector struct {
mu sync.Mutex
caPEM string
signKey *rsa.PrivateKey
caCert *x509.Certificate
issued []intuneE2EIssuance
}
type intuneE2EIssuance struct {
commonName string
sans []string
mustStaple bool
}
func (i *intuneE2EIssuerConnector) GetCACertPEM(_ context.Context) (string, error) {
return i.caPEM, nil
}
func (i *intuneE2EIssuerConnector) IssueCertificate(_ context.Context, commonName string, sans []string, _ string, _ []string, _ int, mustStaple bool) (*service.IssuanceResult, error) {
i.mu.Lock()
defer i.mu.Unlock()
i.issued = append(i.issued, intuneE2EIssuance{commonName: commonName, sans: sans, mustStaple: mustStaple})
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(int64(len(i.issued)) + 1),
Subject: pkix.Name{CommonName: commonName},
DNSNames: sans,
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, i.caCert, &i.signKey.PublicKey, i.signKey)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
return &service.IssuanceResult{
CertPEM: string(certPEM),
ChainPEM: i.caPEM,
Serial: tmpl.SerialNumber.String(),
NotAfter: tmpl.NotAfter,
}, nil
}
func (i *intuneE2EIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
return i.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
}
func (i *intuneE2EIssuerConnector) RevokeCertificate(_ context.Context, _ string, _ string) error {
return nil
}
func (i *intuneE2EIssuerConnector) GenerateCRL(_ context.Context, _ []service.CRLEntry) ([]byte, error) {
return nil, nil
}
func (i *intuneE2EIssuerConnector) SignOCSPResponse(_ context.Context, _ service.OCSPSignRequest) ([]byte, error) {
return nil, nil
}
func (i *intuneE2EIssuerConnector) GetRenewalInfo(_ context.Context, _ string) (*service.RenewalInfoResult, error) {
return nil, nil
}
// intuneE2EAuditRepo captures audit events so the test can assert the
// dispatcher emitted scep_pkcsreq_intune.
type intuneE2EAuditRepo struct {
mu sync.Mutex
events []domain.AuditEvent
}
func (r *intuneE2EAuditRepo) Create(_ context.Context, e *domain.AuditEvent) error {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, *e)
return nil
}
// CreateWithTx mirrors Create — handler-test mocks have no DB; the
// Querier is ignored.
func (r *intuneE2EAuditRepo) CreateWithTx(ctx context.Context, _ repository.Querier, e *domain.AuditEvent) error {
return r.Create(ctx, e)
}
func (r *intuneE2EAuditRepo) List(_ context.Context, _ *repository.AuditFilter) ([]*domain.AuditEvent, error) {
return nil, nil
}
func (r *intuneE2EAuditRepo) actions() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.events))
for _, e := range r.events {
out = append(out, e.Action)
}
return out
}
// newIntuneE2EFixture wires up the full Intune-mode SCEP stack.
func newIntuneE2EFixture(t *testing.T) *intuneE2EFixture {
t.Helper()
// 1. Forge a Connector signing keypair + self-signed cert. This is
// what an operator would extract from their installed Intune
// Certificate Connector and configure as INTUNE_CONNECTOR_CERT_PATH.
connectorKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("connector key: %v", err)
}
connectorCert := selfSignedECCertForIntuneE2E(t, connectorKey, "intune-connector-test")
// 2. Write the Connector cert to a temp PEM file so the
// TrustAnchorHolder loads it the same way it would in production.
dir := t.TempDir()
trustPath := filepath.Join(dir, "intune-trust.pem")
if err := os.WriteFile(trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: connectorCert.Raw}), 0o600); err != nil {
t.Fatalf("write trust anchor: %v", err)
}
trustHolder, err := intune.NewTrustAnchorHolder(trustPath, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10})))
if err != nil {
t.Fatalf("NewTrustAnchorHolder: %v", err)
}
// 3. Build a fixture issuer + RA pair (RA cert/key the SCEP handler
// uses to decrypt EnvelopedData). The RA cert and the issuer's
// fake CA are independent — RA is a SCEP-protocol artifact, the
// CA cert is what the issuer connector returns from GetCACertPEM.
raKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ra key: %v", err)
}
raCert := selfSignedRSACert(t, raKey, "ra-intune-e2e")
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ca key: %v", err)
}
caCert := selfSignedRSACert(t, caKey, "test-fixture-ca")
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
issuer := &intuneE2EIssuerConnector{
caPEM: string(caPEM),
signKey: caKey,
caCert: caCert,
}
// 4. Build a real SCEPService with intune integration wired in.
auditRepo := &intuneE2EAuditRepo{}
auditSvc := service.NewAuditService(auditRepo)
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
scepSvc := service.NewSCEPService("iss-test", issuer, auditSvc, logger, "static-fallback-secret")
scepSvc.SetPathID("test")
replayCache := intune.NewReplayCache(60*time.Minute, 100)
rateLimiter := intune.NewPerDeviceRateLimiter(3, 24*time.Hour, 100)
scepSvc.SetIntuneIntegration(
trustHolder,
"https://certctl.example.com/scep/test",
60*time.Minute,
0, // ClockSkewTolerance — strict (the e2e fixture uses time.Now() consistently so no drift to absorb)
replayCache,
rateLimiter,
)
// 5. Build a transient device cert/key. The device wraps its CSR in
// EnvelopedData and signs the SCEP signerInfo with this transient
// key (the same shape ChromeOS / Intune-managed devices use).
deviceKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("device key: %v", err)
}
deviceCert := selfSignedRSACert(t, deviceKey, "device-transient-intune")
// 6. Build the SCEP handler.
handler := NewSCEPHandler(scepSvc)
handler.SetRAPair(raCert, raKey)
return &intuneE2EFixture{
connectorKey: connectorKey,
connectorDir: dir,
trustPath: trustPath,
trustHolder: trustHolder,
raKey: raKey,
raCert: raCert,
deviceKey: deviceKey,
deviceCert: deviceCert,
issuer: issuer,
auditRepo: auditRepo,
scepService: scepSvc,
handler: handler,
}
}
// selfSignedECCertForIntuneE2E mirrors the existing selfSignedRSACert
// helper for an ECDSA P-256 keypair. Used for the fixture Connector
// signing cert. Distinct name to avoid colliding with selfSignedRSACert
// in the same package.
func selfSignedECCertForIntuneE2E(t *testing.T, key *ecdsa.PrivateKey, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return cert
}
// signIntuneChallengeES256 builds a real Intune-shaped challenge that
// the Connector would emit. RFC 7515 §3.4 fixed-width r||s ES256 form
// because that's the canonical JOSE shape.
func signIntuneChallengeES256(t *testing.T, connectorKey *ecdsa.PrivateKey, payload map[string]any) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "ES256", "typ": "JWT"})
pl, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl)
h := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, connectorKey, h[:])
if err != nil {
t.Fatalf("ecdsa.Sign: %v", err)
}
rb, sb := r.Bytes(), s.Bytes()
sig := make([]byte, 64)
copy(sig[32-len(rb):], rb)
copy(sig[64-len(sb):], sb)
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// validIntuneE2EClaim returns a claim payload that matches a CSR with
// CN=device-corp-001.example.com — the dispatcher's DeviceMatchesCSR
// uses set-equality semantics, so we only pin device_name (CN). The
// CSR builder helper buildTestCSR doesn't populate DNSNames so we
// deliberately leave san_dns out of the claim — adding it would trip
// ErrClaimSANDNSMismatch (claim says ['x'], CSR has no DNS SANs).
// The claim_mismatch sibling test exercises the SAN-dimension failure
// path via the claim_mismatch counter.
func validIntuneE2EClaim(now time.Time, nonce string) map[string]any {
return map[string]any{
"iss": "intune-connector-installation-fixture",
"sub": "device-guid-corp-001",
"aud": "https://certctl.example.com/scep/test",
"iat": now.Add(-1 * time.Minute).Unix(),
"exp": now.Add(59 * time.Minute).Unix(),
"nonce": nonce,
"device_name": "device-corp-001.example.com",
}
}
// TestSCEPIntuneEnrollment_E2E walks the full Phase 10.2 spec scenario:
// boot the stack (in-process), forge a valid challenge, build a CSR
// matching the claim, POST through the handler, decode the CertRep
// response, assert success + audit log + counter increment.
func TestSCEPIntuneEnrollment_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-nonce-001"))
if !strings.Contains(intuneChallenge, ".") || len(intuneChallenge) <= 200 {
t.Fatalf("forged challenge doesn't satisfy looksIntuneShaped: len=%d", len(intuneChallenge))
}
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-e2e-001", intuneChallenge, "device-corp-001.example.com")
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)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData(CertRep): %v", err)
}
if len(certRep.SignerInfos) != 1 {
t.Fatalf("CertRep has %d signers, want 1", len(certRep.SignerInfos))
}
statusRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()]
if !ok {
t.Fatal("CertRep missing pkiStatus auth-attr")
}
statusStr := decodeFirstSetMember(t, statusRV)
if statusStr != string(domain.SCEPStatusSuccess) {
t.Errorf("pkiStatus = %q, want %q (SUCCESS)", statusStr, domain.SCEPStatusSuccess)
}
if len(fix.issuer.issued) != 1 {
t.Fatalf("issuer received %d issuances, want 1", len(fix.issuer.issued))
}
if fix.issuer.issued[0].commonName != "device-corp-001.example.com" {
t.Errorf("issued CN = %q, want device-corp-001.example.com", fix.issuer.issued[0].commonName)
}
foundIntune := false
for _, a := range fix.auditRepo.actions() {
if a == "scep_pkcsreq_intune" {
foundIntune = true
break
}
}
if !foundIntune {
t.Errorf("expected an audit_event with action=scep_pkcsreq_intune; got actions=%v", fix.auditRepo.actions())
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["success"]; got != 1 {
t.Errorf("IntuneStats.counters[success] = %d, want 1", got)
}
}
// TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E builds a CSR whose
// CN does NOT match the claim's device_name. The dispatcher should
// reject with a CertRep FAILURE+BadRequest rather than issuing the
// cert. Per Phase 8 + the spec's claim-mismatch failInfo mapping
// (mapIntuneErrorToFailInfo).
func TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
intuneChallenge := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-mismatch-001"))
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-mismatch", intuneChallenge, "attacker-host.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation (mismatch): got %d, want 200 (CertRep+failInfo wire shape, body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData(CertRep): %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("pkiStatus = %q, want %q (FAILURE) for claim-mismatched CSR", statusStr, domain.SCEPStatusFailure)
}
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
if !ok {
t.Fatal("CertRep missing failInfo auth-attr on a FAILURE response")
}
failStr := decodeFirstSetMember(t, failRV)
if failStr != string(domain.SCEPFailBadRequest) {
t.Errorf("failInfo = %q, want %q (BadRequest) for claim mismatch", failStr, domain.SCEPFailBadRequest)
}
if len(fix.issuer.issued) != 0 {
t.Errorf("issuer should NOT have issued a cert for a claim-mismatched CSR; got %d issuances", len(fix.issuer.issued))
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["claim_mismatch"]; got != 1 {
t.Errorf("IntuneStats.counters[claim_mismatch] = %d, want 1", got)
}
}
// TestSCEPIntuneEnrollment_TamperedSignature_E2E flips a byte in the
// JWT signature segment of the Intune challenge before wrapping it in
// the PKIMessage. The dispatcher should reject with FAILURE+BadMessageCheck
// (mapIntuneErrorToFailInfo: signature errors → BadMessageCheck).
func TestSCEPIntuneEnrollment_TamperedSignature_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
good := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-tamper-001"))
parts := strings.Split(good, ".")
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 0xFF
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
tampered := strings.Join(parts, ".")
pkiMessage := buildIntuneE2EPKIMessage(t, fix, "txn-intune-tamper", tampered, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("POST PKIOperation (tampered): got %d, want 200 with FAILURE pkiStatus (body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Errorf("pkiStatus = %q, want FAILURE for tampered Intune sig", statusStr)
}
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
if failStr != string(domain.SCEPFailBadMessageCheck) {
t.Errorf("failInfo = %q, want BadMessageCheck for tampered Intune sig", failStr)
}
}
// buildIntuneE2EPKIMessage builds a real SCEP PKIMessage that wraps the
// given Intune-shaped challenge as challengePassword inside an
// EnvelopedData(KTRI(raCert), AES-256-CBC(CSR + challengePassword)).
// Mirrors buildChromeOSStylePKIMessage but lets the test override the
// challengePassword to an Intune-shaped JWT-like blob.
func buildIntuneE2EPKIMessage(t *testing.T, fix *intuneE2EFixture, transactionID, challengePassword, csrCN string) []byte {
t.Helper()
csrDER := buildTestCSR(t, fix.deviceKey, csrCN, challengePassword)
symKey := aesKeyForOID(pkcs7.OIDAES256CBC)
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 symKey: %v", err)
}
envelopedData := buildEnvelopedDataForTest(t, fix.raCert, encryptedKey, iv, ciphertext, oidForAESKeyLen(t, len(symKey)))
signedData := buildSignedDataForTest(t, fix.deviceKey, fix.deviceCert, domain.SCEPMessageTypePKCSReq, transactionID, []byte("0123456789abcdef"), envelopedData)
return signedData
}
// =============================================================================
// SCEP RFC 8894 + Intune master-prompt §13 line 1849 acceptance — the two
// remaining e2e named tests: _RateLimited_E2E + _TrustAnchorSIGHUPReload_E2E.
// Closed in the 2026-04-29 audit-closure bundle.
// =============================================================================
// TestSCEPIntuneEnrollment_RateLimited_E2E exercises the full
// handler→service→dispatcher chain past the per-device rate-limit cap.
// The fixture's default cap (3) is too high for a quick test; we
// re-inject a fresh limiter with cap=2 so the 3rd attempt for the same
// (Subject, Issuer) returns FAILURE+BadRequest with rate_limited
// counter ticked. Each PKIMessage carries a distinct nonce (replay
// cache otherwise rejects on duplicate-nonce well before the limiter
// fires), and a distinct transactionID so the audit-log shape is
// inspectable per attempt.
func TestSCEPIntuneEnrollment_RateLimited_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
// Re-wire SetIntuneIntegration with a stricter cap so the test
// stays fast. Also a fresh replay cache so a previous attempt's
// state doesn't leak into this test if Go ever reorders test
// execution within the package.
tightLimiter := intune.NewPerDeviceRateLimiter(2, 24*time.Hour, 100)
freshReplay := intune.NewReplayCache(60*time.Minute, 100)
fix.scepService.SetIntuneIntegration(
fix.trustHolder,
"https://certctl.example.com/scep/test",
60*time.Minute,
0, // ClockSkewTolerance — strict (we mint claims at time.Now())
freshReplay,
tightLimiter,
)
now := time.Now()
// First two attempts succeed (cap=2 means ≤2 issuances per 24h).
for i := 0; i < 2; i++ {
nonce := "e2e-rate-allow-" + string(rune('a'+i))
ch := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, nonce))
txn := "txn-rate-allow-" + string(rune('a'+i))
pkiMessage := buildIntuneE2EPKIMessage(t, fix, txn, ch, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, pkiMessage)
if w.Code != http.StatusOK {
t.Fatalf("attempt %d: HTTP %d (body=%q)", i+1, w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("attempt %d: ParseSignedData: %v", i+1, err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusSuccess) {
t.Fatalf("attempt %d: pkiStatus = %q, want SUCCESS (the allowed first %d/%d)", i+1, statusStr, i+1, 2)
}
}
// 3rd attempt for the SAME (Subject, Issuer) MUST be rate-limited.
tripCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-rate-deny-c"))
tripMsg := buildIntuneE2EPKIMessage(t, fix, "txn-rate-deny-c", tripCh, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, tripMsg)
if w.Code != http.StatusOK {
t.Fatalf("rate-limited attempt: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep on every PKIOperation, including failures", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("rate-limited attempt: ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("rate-limited pkiStatus = %q, want FAILURE", statusStr)
}
failRV, ok := certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()]
if !ok {
t.Fatal("rate-limited CertRep missing failInfo auth-attr")
}
failStr := decodeFirstSetMember(t, failRV)
if failStr != string(domain.SCEPFailBadRequest) {
t.Errorf("rate-limited failInfo = %q, want BadRequest (mapIntuneErrorToFailInfo: rate_limit → BadRequest)", failStr)
}
// The fixture's issuer should have seen exactly 2 issuances (the
// allowed pair) — the 3rd was blocked at the dispatcher gate.
if got, want := len(fix.issuer.issued), 2; got != want {
t.Errorf("issuer issuances = %d, want %d (rate-limited 3rd should not reach the issuer)", got, want)
}
// Audit log — at least one rate-limited entry. The dispatcher's
// audit action is "scep_pkcsreq_intune" for both successes and
// failures; we inspect the counter table for the rate_limited tick.
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["rate_limited"]; got != 1 {
t.Errorf("IntuneStats.counters[rate_limited] = %d, want 1", got)
}
if got := stats.Counters["success"]; got != 2 {
t.Errorf("IntuneStats.counters[success] = %d, want 2 (cap=2 allowed pair)", got)
}
}
// TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E proves the full
// SIGHUP-reload contract end-to-end: an enrollment that succeeds against
// the original trust anchor MUST fail after the operator rotates the
// on-disk file + reloads, when the device tries to enroll with the OLD
// connector key.
//
// Why we call holder.Reload() directly instead of os.Process.Signal(SIGHUP):
// signal delivery in tests is flaky (signals to the test process can
// race with t.Parallel(), and signal.Notify is global). The SIGHUP
// goroutine's only job is to call Reload, so calling Reload directly is
// the equivalent contract — and stable in tests. Phase B frozen
// decision #3 in cowork/scep-bundle-gap-closure-prompt.md.
func TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E(t *testing.T) {
fix := newIntuneE2EFixture(t)
now := time.Now()
// Step 1: a valid enrollment against the original trust anchor.
originalCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-pre"))
originalMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-pre", originalCh, "device-corp-001.example.com")
w, body := postPKIOperation(t, fix.handler, originalMsg)
if w.Code != http.StatusOK {
t.Fatalf("pre-rotation enrollment: HTTP %d (body=%q)", w.Code, body)
}
certRep, err := pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("pre-rotation ParseSignedData: %v", err)
}
statusStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusSuccess) {
t.Fatalf("pre-rotation pkiStatus = %q, want SUCCESS", statusStr)
}
// Step 2: operator rotates the trust anchor — write a fresh signing
// cert from a NEW key into the same path. Holder.Reload() then
// swaps the in-memory pool to the new bundle. The OLD key
// (fix.connectorKey) is now disowned.
rotatedKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("rotated key: %v", err)
}
rotatedCert := selfSignedECCertForIntuneE2E(t, rotatedKey, "intune-connector-rotated")
if err := os.WriteFile(fix.trustPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rotatedCert.Raw}), 0o600); err != nil {
t.Fatalf("rewrite trust anchor file: %v", err)
}
if err := fix.trustHolder.Reload(); err != nil {
t.Fatalf("trustHolder.Reload (post-rotation): %v", err)
}
// Step 3: a device that signs with the OLD connector key MUST be
// rejected — the holder no longer recognizes the signature.
staleCh := signIntuneChallengeES256(t, fix.connectorKey, validIntuneE2EClaim(now, "e2e-sighup-stale"))
staleMsg := buildIntuneE2EPKIMessage(t, fix, "txn-sighup-stale", staleCh, "device-corp-001.example.com")
w, body = postPKIOperation(t, fix.handler, staleMsg)
if w.Code != http.StatusOK {
t.Fatalf("stale-key enrollment: HTTP %d (body=%q) — RFC 8894 §3.3 mandates a CertRep+failInfo wire shape", w.Code, body)
}
certRep, err = pkcs7.ParseSignedData(body)
if err != nil {
t.Fatalf("stale-key ParseSignedData: %v", err)
}
statusStr = decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPPKIStatus.String()])
if statusStr != string(domain.SCEPStatusFailure) {
t.Fatalf("stale-key pkiStatus = %q, want FAILURE after trust-anchor rotation", statusStr)
}
failStr := decodeFirstSetMember(t, certRep.SignerInfos[0].AuthAttributes[pkcs7.OIDSCEPFailInfo.String()])
if failStr != string(domain.SCEPFailBadMessageCheck) {
t.Errorf("stale-key failInfo = %q, want BadMessageCheck (mapIntuneErrorToFailInfo: sig errors → BadMessageCheck)", failStr)
}
stats := fix.scepService.IntuneStats(time.Now())
if got := stats.Counters["signature_invalid"]; got != 1 {
t.Errorf("IntuneStats.counters[signature_invalid] = %d, want 1 (post-rotation stale-key attempt)", got)
}
if got := stats.Counters["success"]; got != 1 {
t.Errorf("IntuneStats.counters[success] = %d, want 1 (only the pre-rotation attempt)", got)
}
}