Files
certctl/internal/api/handler/scep_intune_e2e_test.go
T
shankar0123 b0efdbe2f8 repo,service: introduce WithinTx and atomic audit rows for issue/renew/revoke
Closes the #3 acquisition-readiness blocker from the 2026-05-01 issuer
coverage audit (Part 1.5 finding #1: audit row not transactional with
issuance). AuditRepository.Create previously ran on the package-level
*sql.DB while the certificate insert / version insert / revocation
insert ran on independent connections — a failed audit INSERT after
a successful operation INSERT was silently lost. SOX §404 over IT
general controls, PCI-DSS §10 audit logging, HIPAA §164.312(b) audit
controls, and CA/B Forum Baseline Requirements §5.4.1 audit log
records all presume audit-with-operation atomicity.

Design — Option A (Querier abstraction). The chosen pattern: a shared
repository.Querier interface (subset of *sql.DB and *sql.Tx) plus a
postgres.WithinTx helper that begins a tx, runs fn, commits on nil
error, rolls back on error or panic, and returns the wrapped result.
Repository methods that participate in a service-layer transaction
expose a *WithTx variant taking repository.Querier; the bare methods
remain for stand-alone use. A repository.Transactor abstracts the
"begin tx, run fn, commit/rollback" lifecycle so service-layer code
runs multi-write operations atomically without holding *sql.DB
directly. Option B (UnitOfWork) was considered but adds boilerplate
without behavioral benefit for the current scope. Option C
(context-carried tx) was explicitly rejected — it hides the
transactional boundary from the type system, reproducing the class
of bug we're fixing.

This commit:
- Adds internal/repository/querier.go with the Querier interface
  (compile-time guards that *sql.DB and *sql.Tx satisfy it) and the
  Transactor interface for service-layer use.
- Adds internal/repository/postgres/tx.go with the WithinTx helper
  (begin/fn/commit/rollback with panic recovery) and a transactor
  type that satisfies repository.Transactor.
- Adds CreateWithTx variants on AuditRepository, CertificateRepository
  (Create + Update + CreateVersion), and RevocationRepository.
  Existing bare methods now delegate to the *WithTx variant using
  the package-level *sql.DB so existing call sites are
  behavior-preserving.
- Updates repository/interfaces.go: AuditRepository, CertificateRepository,
  and RevocationRepository declare the new *WithTx methods. Adds an
  atomicity contract doc-comment on AuditRepository pointing at
  WithinTx + the audit blocker.
- Adds AuditService.RecordEventWithTx, mirroring RecordEvent but
  routing through CreateWithTx so the audit row is part of the
  caller's transaction. Same redaction + marshalling contract.
- Refactors three audit-emitting service paths to use Transactor.WithinTx
  when SetTransactor was wired, with a legacy fallback for backward
  compat:
    * CertificateService.Create — cert insert + audit row in one tx.
    * RevocationSvc.RevokeCertificateWithActor — cert status update +
      revocation row + audit row in one tx. The OCSP cache invalidate
      remains best-effort (out of scope per the prompt).
    * RenewalService CompleteServerRenewal — cert version insert +
      cert update + audit row in one tx. Job status update stays
      outside the audit-atomicity scope (job state lives outside
      the operator-facing audit trail).
- Adds SetTransactor on CertificateService, RevocationSvc, and
  RenewalService. cmd/server/main.go wires a single Transactor
  instance shared across all three so all audit-emitting paths run
  their writes in transactions backed by the same *sql.DB handle.
- Updates 5 mock implementations to satisfy the new interface methods:
  mockCertRepo (testutil_test.go), mockCertRepoWithGetError
  (shortlived_test.go), fakeRevocationRepo (crl_cache_test.go),
  intuneE2EAuditRepo (scep_intune_e2e_test.go), and the integration-
  test mocks (lifecycle_test.go: mockCertificateRepository,
  mockAuditRepository, mockRevocationRepository). All *WithTx mocks
  ignore the Querier and delegate to the bare method (mocks have no
  DB; in-memory state is shared regardless of "tx").
- Adds a service-layer test mockTransactor with BeginTxErr and
  CommitErr knobs so the atomic-audit tests can assert error
  propagation through the transactional boundary.
- Adds internal/repository/postgres/tx_test.go: unit-level test that
  WithinTx surfaces "begin tx" wrap when BeginTx fails, and that
  Transactor.WithinTx delegates correctly. Real-Postgres rollback
  semantics are covered by the testcontainers tests in the postgres
  package — sandbox disk pressure prevented adding a sqlmock dep
  for the in-fn / commit-failure unit test, so those scenarios are
  exercised through atomic_audit_test.go using the mockTransactor's
  CommitErr / BeginTxErr fields.
- Adds internal/service/atomic_audit_test.go:
    * TestCertificateService_Create_AtomicWithTx — asserts audit
      insert failure inside the tx surfaces as the operation's error
      (closes the blocker contract).
    * TestCertificateService_Create_LegacyPathLogs — pins the
      backward-compat behavior when SetTransactor isn't wired:
      audit failure is logged-not-failed, matching pre-fix.
    * TestCertificateService_Create_TransactorBeginFailure — BeginTx
      error path: operation fails, no cert insert, no audit insert.
    * TestCertificateService_Create_TransactorCommitFailure —
      Commit error after successful in-fn writes surfaces as the
      operation's error. Real Postgres can fail Commit on
      serialization conflicts; the service must report this.

Out of scope (separate follow-up commits, same shape):
- Issuer CRUD audit atomicity.
- Target CRUD audit atomicity.
- Agent retire (already transactional via RetireAgentWithCascade;
  verified, not changed).
- Renewal-policy CRUD audit atomicity.
- Owner/team/agent-group CRUD audit atomicity.
- Discovery / health-check audit atomicity.

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck ./... clean
- golangci-lint run --timeout 5m ./... → 0 issues
- go test -short -count=1 ./internal/service/ green
- go test -short -count=1 ./internal/api/handler/ green
- go test -short -count=1 ./internal/integration/ green
- go test -short -count=1 ./internal/repository/postgres/ green
- go build ./... success

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #3 (Part 3, narrative section).
2026-05-02 00:29:09 +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/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
"github.com/shankar0123/certctl/internal/repository"
"github.com/shankar0123/certctl/internal/scep/intune"
"github.com/shankar0123/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)
}
}