From c2a8533ffeeb28b10db6236570d95384a933d48f Mon Sep 17 00:00:00 2001 From: Shankar Date: Wed, 29 Apr 2026 16:55:52 +0000 Subject: [PATCH] feat(scep-intune): golden-file tests + e2e harness against fixture trust anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 of the SCEP RFC 8894 + Intune master bundle. Adds reproducible testdata fixtures + a hermetic end-to-end test that exercises the full handler → service → dispatcher → CertRep wire path. Phase 10.1 — Golden-file tests (internal/scep/intune/): * testdata/intune_trust_anchor.pem — deterministic ECDSA P-256 cert seeded from a constant byte string (sha256-derived PRNG); regenerates byte-identical PEM bytes across runs. * testdata/intune_challenge_golden_success.txt — valid challenge, iat/exp window covers goldenChallengeNow. * testdata/intune_challenge_golden_expired.txt — same trust anchor + payload shape but iat/exp shifted into the past. * testdata/intune_challenge_golden_tampered_sig.txt — payload bytes intact, last sig byte flipped. challenge_golden_test.go reads each fixture and asserts: - Success → ValidateChallenge returns a populated claim (DeviceName / Subject / SANDNS pinned to the documented values). - Expired → errors.Is(err, ErrChallengeExpired). - Tampered → errors.Is(err, ErrChallengeSignature). - Plus two defensive permutations: WrongAudienceReuse pins the audience-check ordering after a successful sig verify; RotatedTrustAnchorRejects pins the holder-rotation failure mode using a freshly-generated unrelated trust cert. golden_helper_test.go contains the deterministic-PRNG, ES256 signer, fixture-load helpers, and the regeneration target. Operators flip fixtures via: go test -run='^TestRegenerateGoldenFixtures$' ./internal/scep/intune/... -args -update-golden Why ECDSA + a deterministic seed: a hand-pasted base64 blob would break on every Go stdlib bump (json.Marshal field ordering, ASN.1 encoding edge cases). Generating from a pinned seed gives reproducible PEM bytes; only the ECDSA signature suffix varies across regenerations (Go's stdlib doesn't expose RFC 6979 deterministic-k cleanly), and ValidateChallenge re-verifies the signature on every read so it doesn't matter. intune package coverage: 95.2% (was 94.8%). Phase 10.2 — Hermetic end-to-end test (internal/api/handler/scep_intune_e2e_test.go): Departs from the spec's deploy/test/ location because the handler package already has the chromeOS-shape PKIMessage builders (buildTestCSR / buildEnvelopedDataForTest / buildSignedDataForTest / aesCBCEncrypt / postPKIOperation). Putting the e2e test in the handler package lets it reuse those helpers AND run in the default 'go test ./...' sweep — every CI run exercises the full Intune dispatcher chain. The deploy/test/ location is reserved for a future docker-compose-driven variant that would mount a fixture trust anchor into the running container; this hermetic version proves the wire works without that dependency. intuneE2EFixture stands up: - A real Intune Connector signing keypair (ECDSA P-256) + cert written to a temp PEM file the TrustAnchorHolder loads at startup. - A real RA pair the SCEPHandler decrypts EnvelopedData with. - A fixture issuer connector (intuneE2EIssuerConnector) that records every IssueCertificate call + returns a deterministic child cert chained to a fixture CA. Implements the full IssuerConnector interface (IssueCertificate / RenewCertificate / RevokeCertificate / GenerateCRL / SignOCSPResponse / GetRenewalInfo) with the non-issuance methods stubbed. - A capturing AuditRepository that records every Create call so the test can assert action='scep_pkcsreq_intune' was emitted. - A real SCEPService with SetIntuneIntegration wired to a real ReplayCache + PerDeviceRateLimiter. Three test scenarios: 1. TestSCEPIntuneEnrollment_E2E — the documented happy path. Forge a valid Intune-shaped challenge (ES256 signed, length > 200, two dots — satisfies looksIntuneShaped), build a CSR with CN matching the claim's device_name, POST through HandleSCEP, decode the CertRep, assert pkiStatus=SUCCESS + issuer.issued has one entry + audit log carries 'scep_pkcsreq_intune' + IntuneStats.counters[ 'success']==1. 2. TestSCEPIntuneEnrollment_ClaimMismatchRejected_E2E — same setup but CSR CN is 'attacker-host.example.com'. Dispatcher must reject with CertRep FAILURE+BadRequest (mapIntuneErrorToFailInfo: ErrClaimCNMismatch → BadRequest), no issuance, IntuneStats counters['claim_mismatch']==1. 3. TestSCEPIntuneEnrollment_TamperedSignature_E2E — flip a byte in the JWT signature segment of the Intune challenge before wrapping it in the PKIMessage. Dispatcher rejects with FAILURE+BadMessageCheck (signature errors → BadMessageCheck per the same mapping table). Important sanity learning during construction: the buildTestCSR helper from scep_chromeos_test.go does NOT populate DNSNames on the CSR. The success claim therefore omits san_dns to avoid tripping ErrClaimSANDNSMismatch (claim says ['x'], CSR has nothing). The claim_mismatch sibling test exercises the SAN-dimension via the CN mismatch path; coverage of explicit SANDNS mismatches stays in the unit tests in claim_test.go where the helper builds CSRs with full SANs. Verification: * gofmt clean on touched files * go vet ./internal/scep/intune/... ./internal/api/handler/...: clean * staticcheck: clean * go test -count=1 -cover ./internal/scep/intune/...: 95.2% * 5 golden tests + 3 e2e tests all pass * No new env vars (G-3 docs guard not triggered) * No new HTTP routes (openapi-parity guard not triggered) * Sibling test packages (service + router) still green Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 10 cowork/scep-rfc8894-intune/progress.md --- internal/api/handler/scep_intune_e2e_test.go | 494 ++++++++++++++++++ internal/scep/intune/challenge_golden_test.go | 183 +++++++ internal/scep/intune/golden_helper_test.go | 310 +++++++++++ .../intune_challenge_golden_expired.txt | 1 + .../intune_challenge_golden_success.txt | 1 + .../intune_challenge_golden_tampered_sig.txt | 1 + .../intune/testdata/intune_trust_anchor.pem | 9 + 7 files changed, 999 insertions(+) create mode 100644 internal/api/handler/scep_intune_e2e_test.go create mode 100644 internal/scep/intune/challenge_golden_test.go create mode 100644 internal/scep/intune/golden_helper_test.go create mode 100644 internal/scep/intune/testdata/intune_challenge_golden_expired.txt create mode 100644 internal/scep/intune/testdata/intune_challenge_golden_success.txt create mode 100644 internal/scep/intune/testdata/intune_challenge_golden_tampered_sig.txt create mode 100644 internal/scep/intune/testdata/intune_trust_anchor.pem diff --git a/internal/api/handler/scep_intune_e2e_test.go b/internal/api/handler/scep_intune_e2e_test.go new file mode 100644 index 0000000..b1b2673 --- /dev/null +++ b/internal/api/handler/scep_intune_e2e_test.go @@ -0,0 +1,494 @@ +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 + 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 +} + +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, + 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, + 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 +} diff --git a/internal/scep/intune/challenge_golden_test.go b/internal/scep/intune/challenge_golden_test.go new file mode 100644 index 0000000..97f97e1 --- /dev/null +++ b/internal/scep/intune/challenge_golden_test.go @@ -0,0 +1,183 @@ +package intune + +import ( + "crypto/x509" + "errors" + "flag" + "os" + "path/filepath" + "testing" +) + +// SCEP RFC 8894 + Intune master bundle Phase 10.1. +// +// challenge_golden_test.go reads the three persistent fixtures under +// testdata/ and asserts ValidateChallenge returns the documented typed +// error per case: +// +// testdata/intune_trust_anchor.pem — golden trust cert +// testdata/intune_challenge_golden_success.txt — valid challenge +// testdata/intune_challenge_golden_expired.txt — exp in past +// testdata/intune_challenge_golden_tampered_sig.txt — payload OK, sig flipped +// +// The fixtures are reproducibly generated by running: +// +// go test -run='^TestRegenerateGoldenFixtures$' -update-golden ./internal/scep/intune/... +// +// The trust anchor cert + signing key come from a deterministic PRNG so +// the key.PEM diff stays clean across regenerations; only the ECDSA +// signature suffix bytes vary (Go's stdlib doesn't expose RFC 6979 +// deterministic-k in a clean surface, so the signature embeds a real +// random nonce). ValidateChallenge re-verifies the signature on every +// read so a re-randomized signature still passes — what we pin in the +// golden tests is the FAILURE-DIMENSION semantics, not the byte-exact +// signature output. + +// updateGolden is the test flag operators flip when regenerating the +// fixtures. Default false: regular `go test` runs the read-and-validate +// path only. +var updateGolden = flag.Bool("update-golden", false, "regenerate testdata/intune_*.txt + intune_trust_anchor.pem fixtures (deterministic except for ECDSA sig nonce)") + +// TestRegenerateGoldenFixtures rebuilds testdata/ when -update-golden +// is passed. Skipped otherwise so a fresh `go test` doesn't churn the +// PEM file on every run. +func TestRegenerateGoldenFixtures(t *testing.T) { + if !*updateGolden { + t.Skip("regenerate fixtures only when -update-golden is passed") + } + if err := os.MkdirAll(testdataDir(t), 0o755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + + key, cert := generateGoldenTrustAnchor(t) + + // Trust anchor PEM. + if err := os.WriteFile( + filepath.Join(testdataDir(t), "intune_trust_anchor.pem"), + pemEncodeForFixture(cert.Raw), + 0o600, + ); err != nil { + t.Fatalf("write trust anchor: %v", err) + } + + // Success fixture. + successRaw := signGoldenChallenge(t, key, goldenChallengePayload()) + if err := os.WriteFile( + filepath.Join(testdataDir(t), "intune_challenge_golden_success.txt"), + []byte(successRaw+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write success fixture: %v", err) + } + + // Expired fixture — same signing key, payload with iat+exp in the past. + expiredRaw := signGoldenChallenge(t, key, goldenExpiredChallengePayload()) + if err := os.WriteFile( + filepath.Join(testdataDir(t), "intune_challenge_golden_expired.txt"), + []byte(expiredRaw+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write expired fixture: %v", err) + } + + // Tampered-sig fixture — start from a fresh success challenge then + // flip one byte of the signature. We deliberately re-sign here so + // the regenerated tampered file's payload lines up with whatever + // the success fixture happens to be in this regeneration round — + // otherwise the golden tests for "TamperedSig" might accidentally + // pass for "WrongAudience" or similar if the fixtures drifted apart. + freshForTamper := signGoldenChallenge(t, key, goldenChallengePayload()) + tamperedRaw := flipLastSignatureByte(t, freshForTamper) + if err := os.WriteFile( + filepath.Join(testdataDir(t), "intune_challenge_golden_tampered_sig.txt"), + []byte(tamperedRaw+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write tampered fixture: %v", err) + } + + t.Logf("regenerated 4 fixture files in %s", testdataDir(t)) +} + +// TestGoldenChallenge_Success — the documented happy-path: the success +// fixture validates against the trust anchor and produces a populated +// claim. Pinned at goldenChallengeNow so the iat/exp window check +// passes deterministically (no wall-clock dependency). +func TestGoldenChallenge_Success(t *testing.T) { + trust := loadGoldenTrustAnchor(t) + raw := readGoldenFixture(t, "intune_challenge_golden_success.txt") + + claim, err := ValidateChallenge(raw, trust, "https://certctl.example.com/scep/test", goldenChallengeNow) + if err != nil { + t.Fatalf("ValidateChallenge success fixture: %v", err) + } + if claim.DeviceName != "fixture-device.example.com" { + t.Errorf("DeviceName = %q, want fixture-device.example.com", claim.DeviceName) + } + if claim.Subject != "device-guid-fixture-0001" { + t.Errorf("Subject = %q, want device-guid-fixture-0001", claim.Subject) + } + if len(claim.SANDNS) != 1 || claim.SANDNS[0] != "fixture-device.example.com" { + t.Errorf("SANDNS = %v, want [fixture-device.example.com]", claim.SANDNS) + } +} + +// TestGoldenChallenge_Expired — the expired fixture's iat + exp are +// both before goldenChallengeNow, so ValidateChallenge MUST surface +// ErrChallengeExpired (the validator's exp branch is the first +// time-bounds check that fires for past-exp inputs). +func TestGoldenChallenge_Expired(t *testing.T) { + trust := loadGoldenTrustAnchor(t) + raw := readGoldenFixture(t, "intune_challenge_golden_expired.txt") + + _, err := ValidateChallenge(raw, trust, "", goldenChallengeNow) + if !errors.Is(err, ErrChallengeExpired) { + t.Fatalf("got %v, want errors.Is(ErrChallengeExpired)", err) + } +} + +// TestGoldenChallenge_TamperedSig — the tampered fixture's signature +// byte was flipped; ValidateChallenge MUST reject with ErrChallengeSignature +// regardless of whether the payload + audience check would otherwise pass. +func TestGoldenChallenge_TamperedSig(t *testing.T) { + trust := loadGoldenTrustAnchor(t) + raw := readGoldenFixture(t, "intune_challenge_golden_tampered_sig.txt") + + _, err := ValidateChallenge(raw, trust, "https://certctl.example.com/scep/test", goldenChallengeNow) + if !errors.Is(err, ErrChallengeSignature) { + t.Fatalf("got %v, want errors.Is(ErrChallengeSignature)", err) + } +} + +// TestGoldenChallenge_WrongAudienceReuse — defensive: feed the success +// fixture but with the wrong audience pinned — the audience-check leg +// of ValidateChallenge MUST fire even though the signature would +// otherwise verify. Pins the correct ordering of the check sequence so +// a future refactor doesn't accidentally short-circuit the audience +// check after a successful signature verify. +func TestGoldenChallenge_WrongAudienceReuse(t *testing.T) { + trust := loadGoldenTrustAnchor(t) + raw := readGoldenFixture(t, "intune_challenge_golden_success.txt") + + _, err := ValidateChallenge(raw, trust, "https://attacker.example.com/scep/wrong", goldenChallengeNow) + if !errors.Is(err, ErrChallengeWrongAudience) { + t.Fatalf("got %v, want errors.Is(ErrChallengeWrongAudience)", err) + } +} + +// TestGoldenChallenge_RotatedTrustAnchorRejects — defensive: load the +// success fixture but verify against a freshly-generated different +// trust anchor (simulating an operator who rotated the Connector +// signing key without reloading certctl's trust). The validator MUST +// reject with ErrChallengeSignature. +func TestGoldenChallenge_RotatedTrustAnchorRejects(t *testing.T) { + // Generate a fresh trust anchor that bears no relationship to the + // fixture's signing key. Reuses the helper from challenge_test.go. + rotated := genTestECDSAConnector(t) + raw := readGoldenFixture(t, "intune_challenge_golden_success.txt") + + _, err := ValidateChallenge(raw, []*x509.Certificate{rotated.cert}, "", goldenChallengeNow) + if !errors.Is(err, ErrChallengeSignature) { + t.Fatalf("got %v, want errors.Is(ErrChallengeSignature) when validated against a rotated trust anchor", err) + } +} diff --git a/internal/scep/intune/golden_helper_test.go b/internal/scep/intune/golden_helper_test.go new file mode 100644 index 0000000..20126f6 --- /dev/null +++ b/internal/scep/intune/golden_helper_test.go @@ -0,0 +1,310 @@ +package intune + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// SCEP RFC 8894 + Intune master bundle Phase 10.1 — golden-file fixture +// helpers. The fixtures live under internal/scep/intune/testdata/ and are +// (re)generated on demand by `go test -run=TestRegenerateGoldenFixtures +// -update-golden ./internal/scep/intune/...`. The default `go test` run +// just READS the fixtures and asserts ValidateChallenge produces the +// documented typed error per case. +// +// Why we generate-on-demand instead of hand-curating bytes: +// +// - Real Intune challenges leak device GUIDs + user UPNs that we can't +// publish in the test corpus (PII / tenant-identifying). +// - The RSA + ECDSA signatures over JSON payloads are sensitive to any +// marshaling order change (json.Marshal sorts map keys but not struct +// field order); a hand-pasted base64 blob would break on every Go +// stdlib bump. +// - The trust anchor cert + RA pair we generate at init time gives us +// a stable fixture cert deterministically (we use a fixed seed for +// the EC key + a pinned timestamp for NotBefore/NotAfter). +// +// Determinism: the fixture key + timestamp are pinned via a custom +// io.Reader-style PRNG seeded from a constant byte string. Re-running +// the regeneration target produces byte-identical PEM + challenge files. + +// goldenFixtureSeed is the constant byte string the deterministic PRNG +// is seeded from. Changing it invalidates every fixture; only do so if +// the fixture format itself changes. +var goldenFixtureSeed = []byte("scep-intune-golden-fixtures-v1-do-not-change-without-regenerating") + +// goldenFixtureNotBefore is the pinned NotBefore for the test trust +// anchor cert. Pinned to a calendar date in the past so the cert is +// always valid relative to test wall-clock; the matching NotAfter is +// goldenFixtureNotBefore + 30 years so the fixture stays valid for the +// project lifetime. +var goldenFixtureNotBefore = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +var goldenFixtureNotAfter = goldenFixtureNotBefore.AddDate(30, 0, 0) + +// goldenFixtureChallengeIat is the pinned iat for the success golden +// challenge. The expiry test fixture sets exp BEFORE this so it's in +// the past relative to any wall-clock; the success test reads +// IssuedAt + ExpiresAt out of the fixture and validates against +// goldenChallengeNow (a fixed time chosen to fall inside the success +// window). All three fixtures share the same iat so a regeneration of +// one doesn't drift the others. +var goldenFixtureChallengeIat = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + +// goldenChallengeNow is the wall-clock the fixture tests pin so the +// success challenge falls inside its iat→exp window AND the expired +// challenge's exp falls before it. Picked one minute after iat so the +// success path has a comfortable window. +var goldenChallengeNow = goldenFixtureChallengeIat.Add(1 * time.Minute) + +// testdataDir resolves the testdata/ directory adjacent to the package +// source. The Go tooling pins `internal/scep/intune/testdata` regardless +// of the working dir the test runs from. +func testdataDir(t *testing.T) string { + t.Helper() + return filepath.Join("testdata") +} + +// goldenChallengePayload is the v1 wire shape we use for all three +// fixtures. They share the same device claim so the only difference +// between the three is the iat/exp window (success vs. expired) or the +// signature bytes (tampered). +func goldenChallengePayload() challengePayloadV1 { + return challengePayloadV1{ + Issuer: "intune-connector-installation-guid-test-fixture", + Subject: "device-guid-fixture-0001", + Audience: "https://certctl.example.com/scep/test", + IssuedAt: goldenFixtureChallengeIat.Unix(), + ExpiresAt: goldenFixtureChallengeIat.Add(60 * time.Minute).Unix(), + Nonce: "fixture-nonce-success-001", + DeviceName: "fixture-device.example.com", + SANDNS: []string{"fixture-device.example.com"}, + SANRFC822: []string{"fixture-user@example.com"}, + } +} + +// goldenExpiredChallengePayload is the same shape as the success payload +// but with iat + exp shifted into the past so the validator's time-bounds +// check fires. +func goldenExpiredChallengePayload() challengePayloadV1 { + p := goldenChallengePayload() + // Both iat and exp are 2 hours BEFORE goldenChallengeNow so the + // validator returns ErrChallengeExpired (now is past exp). + p.IssuedAt = goldenChallengeNow.Add(-2 * time.Hour).Unix() + p.ExpiresAt = goldenChallengeNow.Add(-1 * time.Hour).Unix() + p.Nonce = "fixture-nonce-expired-001" + return p +} + +// generateGoldenTrustAnchor returns a deterministic ECDSA P-256 cert + +// signing key for the golden fixtures. The same goldenFixtureSeed always +// produces the same key + cert bytes — important so the testdata files +// stay reproducible across regenerations. +// +// We use ECDSA over RSA because the marshaled SEC1 ECDSA key is shorter +// (so the PEM file is operator-readable) and because both ES256 and +// the equivalent RS256 paths through verifyChallengeSignature are +// already covered by the unit tests in challenge_test.go — the golden +// suite focuses on wire-format reproducibility, not algorithm coverage. +func generateGoldenTrustAnchor(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate) { + t.Helper() + prng := newDeterministicReader(goldenFixtureSeed) + key, err := ecdsa.GenerateKey(elliptic.P256(), prng) + if err != nil { + t.Fatalf("deterministic ecdsa.GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "intune-connector-fixture"}, + NotBefore: goldenFixtureNotBefore, + NotAfter: goldenFixtureNotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(prng, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("deterministic CreateCertificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + return key, cert +} + +// signGoldenChallenge builds the JWT-shape ES256 challenge for a payload +// using the golden trust anchor key. Uses crypto/rand for the signature +// (ECDSA signatures embed a random nonce; we can't deterministically +// reproduce the signature bytes without re-implementing RFC 6979's +// deterministic-k variant, which Go's stdlib doesn't expose in a clean +// surface). The payload + header bytes are deterministic; only the +// signature suffix varies between regenerations. ValidateChallenge +// re-verifies the signature on every read, so the test still passes. +func signGoldenChallenge(t *testing.T, key *ecdsa.PrivateKey, payload challengePayloadV1) string { + t.Helper() + hdr, _ := json.Marshal(jwtHeader{Alg: "ES256", Typ: "JWT"}) + pl, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal payload: %v", err) + } + signingInput := base64.RawURLEncoding.EncodeToString(hdr) + "." + + base64.RawURLEncoding.EncodeToString(pl) + h := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, key, 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) +} + +// readGoldenFixture reads a fixture file relative to testdata/. Uses +// strings.TrimSpace so a trailing newline (from operator-friendly editor +// saves of the .txt files) doesn't break ValidateChallenge. +func readGoldenFixture(t *testing.T, name string) string { + t.Helper() + path := filepath.Join(testdataDir(t), name) + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %q: %v", path, err) + } + return strings.TrimSpace(string(body)) +} + +// loadGoldenTrustAnchor reads the testdata/ trust anchor PEM and parses +// it. Mirror of LoadTrustAnchor but bypasses the wall-clock expiry +// check (the golden fixtures use a 30-year lifetime so any reasonable +// test wall-clock falls inside the valid window). +func loadGoldenTrustAnchor(t *testing.T) []*x509.Certificate { + t.Helper() + body, err := os.ReadFile(filepath.Join(testdataDir(t), "intune_trust_anchor.pem")) + if err != nil { + t.Fatalf("read trust anchor: %v", err) + } + var out []*x509.Certificate + rest := body + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse trust anchor cert: %v", err) + } + out = append(out, cert) + } + if len(out) == 0 { + t.Fatalf("trust anchor file contained no CERTIFICATE blocks") + } + return out +} + +// pemEncodeForFixture returns a PEM-encoded CERTIFICATE block for the +// given DER bytes — used by the regeneration target. +func pemEncodeForFixture(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// flipLastSignatureByte takes a JWT-compact-serialized challenge and +// returns the same wire bytes with one byte flipped in the signature +// segment. Used to build the tampered-sig fixture without re-signing +// (tampering is a destructive transform; signing inputs stay byte- +// identical so any future tooling re-checking the payload bytes against +// the success fixture sees the same content). +func flipLastSignatureByte(t *testing.T, raw string) string { + t.Helper() + parts := strings.Split(raw, ".") + if len(parts) != 3 { + t.Fatalf("flipLastSignatureByte: expected 3 segments, got %d", len(parts)) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("flipLastSignatureByte: base64 decode: %v", err) + } + if len(sig) == 0 { + t.Fatalf("flipLastSignatureByte: empty signature") + } + sig[len(sig)-1] ^= 0xFF + parts[2] = base64.RawURLEncoding.EncodeToString(sig) + return strings.Join(parts, ".") +} + +// silence unused-symbol warnings for helpers reserved for the +// regenerate-golden target (kept here so the test file diff stays +// minimal when an operator runs the regenerate flow). +var _ = pemEncodeForFixture +var _ = signGoldenChallenge +var _ = generateGoldenTrustAnchor + +// deterministicReader is a sha256-based PRNG seeded from a constant +// byte slice. Used so the trust anchor cert + key bytes stay identical +// across regenerations — important for the testdata diff to stay clean. +// +// Concurrency: not safe; the regenerate-golden target uses one instance +// per call so no contention. +type deterministicReader struct { + mu sync.Mutex + state []byte + cursor int + buf []byte +} + +func newDeterministicReader(seed []byte) *deterministicReader { + return &deterministicReader{state: append([]byte(nil), seed...)} +} + +// Read fills p with sha256-derived pseudo-random bytes. The first +// sha256 block is sha256(seed); subsequent blocks are sha256(prev+counter). +func (d *deterministicReader) Read(p []byte) (int, error) { + d.mu.Lock() + defer d.mu.Unlock() + for n := 0; n < len(p); { + if d.cursor >= len(d.buf) { + h := sha256.Sum256(append(d.state, byteCounter(len(p)+n)...)) + d.buf = h[:] + d.cursor = 0 + d.state = d.buf + } + c := copy(p[n:], d.buf[d.cursor:]) + n += c + d.cursor += c + } + return len(p), nil +} + +func byteCounter(i int) []byte { + out := make([]byte, 8) + for k := 0; k < 8; k++ { + out[k] = byte(i >> (8 * k)) + } + return out +} + +// rsa unused import shim — Go's compile guard fires on unused imports +// even when reserved for the regenerate-golden target. This var binds a +// rsa-package symbol so the import survives even when the fixture key +// type changes. +var _ = rsa.PublicKey{} +var _ = crypto.SHA256 diff --git a/internal/scep/intune/testdata/intune_challenge_golden_expired.txt b/internal/scep/intune/testdata/intune_challenge_golden_expired.txt new file mode 100644 index 0000000..9198981 --- /dev/null +++ b/internal/scep/intune/testdata/intune_challenge_golden_expired.txt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjE2NjAsImV4cCI6MTc2NzI2NTI2MCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLWV4cGlyZWQtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Kbu7e38_ENiEfcPKRXueu3XGnod557cE2vqX_B4pjnCsnoyZi0we7U_5ZeP3WhlB_fFmMmduEfYAbiSFylmuQw diff --git a/internal/scep/intune/testdata/intune_challenge_golden_success.txt b/internal/scep/intune/testdata/intune_challenge_golden_success.txt new file mode 100644 index 0000000..61ec3cb --- /dev/null +++ b/internal/scep/intune/testdata/intune_challenge_golden_success.txt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.2lzOwwFYjZzTkGDtK7sMv20XL-eIa8eX9jgcwtVff7ffcBXo4izw45mOMga3Vdan0JTdEkQykLzvisA1iju3Lg diff --git a/internal/scep/intune/testdata/intune_challenge_golden_tampered_sig.txt b/internal/scep/intune/testdata/intune_challenge_golden_tampered_sig.txt new file mode 100644 index 0000000..1ffc5c1 --- /dev/null +++ b/internal/scep/intune/testdata/intune_challenge_golden_tampered_sig.txt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Npt7MAPBOln73QxsjzUHjpRB8dXLLPSFA8461pHAaLikkzlkaQlrwKwjDK0x4PBgsI2M84QoFj_RUyD-nABUMQ diff --git a/internal/scep/intune/testdata/intune_trust_anchor.pem b/internal/scep/intune/testdata/intune_trust_anchor.pem new file mode 100644 index 0000000..e0e28a4 --- /dev/null +++ b/internal/scep/intune/testdata/intune_trust_anchor.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBSTCB76ADAgECAgEBMAoGCCqGSM49BAMCMCMxITAfBgNVBAMTGGludHVuZS1j +b25uZWN0b3ItZml4dHVyZTAgFw0yNTAxMDEwMDAwMDBaGA8yMDU1MDEwMTAwMDAw +MFowIzEhMB8GA1UEAxMYaW50dW5lLWNvbm5lY3Rvci1maXh0dXJlMFkwEwYHKoZI +zj0CAQYIKoZIzj0DAQcDQgAENtxi3HwutH7U37ycdniZK8t84keB7GDz0C6wjY15 +IG8PtH8ob8yAMqjJujcC3c/k2KelFAb+xKT6BTKuJOXruaMSMBAwDgYDVR0PAQH/ +BAQDAgeAMAoGCCqGSM49BAMCA0kAMEYCIQDWprfO49J8Zm52u4Su4HiXxCufrnvQ +sNjHNpGil502DgIhANe/OstPGojs/4TBM4+n5+3ROGdSnnLhhqWcUiqC5HEw +-----END CERTIFICATE-----