mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:41:30 +00:00
feat(scep-intune): golden-file tests + e2e harness against fixture trust anchor
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjE2NjAsImV4cCI6MTc2NzI2NTI2MCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLWV4cGlyZWQtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Kbu7e38_ENiEfcPKRXueu3XGnod557cE2vqX_B4pjnCsnoyZi0we7U_5ZeP3WhlB_fFmMmduEfYAbiSFylmuQw
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.2lzOwwFYjZzTkGDtK7sMv20XL-eIa8eX9jgcwtVff7ffcBXo4izw45mOMga3Vdan0JTdEkQykLzvisA1iju3Lg
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpbnR1bmUtY29ubmVjdG9yLWluc3RhbGxhdGlvbi1ndWlkLXRlc3QtZml4dHVyZSIsInN1YiI6ImRldmljZS1ndWlkLWZpeHR1cmUtMDAwMSIsImF1ZCI6Imh0dHBzOi8vY2VydGN0bC5leGFtcGxlLmNvbS9zY2VwL3Rlc3QiLCJpYXQiOjE3NjcyNjg4MDAsImV4cCI6MTc2NzI3MjQwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlLXN1Y2Nlc3MtMDAxIiwiZGV2aWNlX25hbWUiOiJmaXh0dXJlLWRldmljZS5leGFtcGxlLmNvbSIsInNhbl9kbnMiOlsiZml4dHVyZS1kZXZpY2UuZXhhbXBsZS5jb20iXSwic2FuX3JmYzgyMiI6WyJmaXh0dXJlLXVzZXJAZXhhbXBsZS5jb20iXX0.Npt7MAPBOln73QxsjzUHjpRB8dXLLPSFA8461pHAaLikkzlkaQlrwKwjDK0x4PBgsI2M84QoFj_RUyD-nABUMQ
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBSTCB76ADAgECAgEBMAoGCCqGSM49BAMCMCMxITAfBgNVBAMTGGludHVuZS1j
|
||||
b25uZWN0b3ItZml4dHVyZTAgFw0yNTAxMDEwMDAwMDBaGA8yMDU1MDEwMTAwMDAw
|
||||
MFowIzEhMB8GA1UEAxMYaW50dW5lLWNvbm5lY3Rvci1maXh0dXJlMFkwEwYHKoZI
|
||||
zj0CAQYIKoZIzj0DAQcDQgAENtxi3HwutH7U37ycdniZK8t84keB7GDz0C6wjY15
|
||||
IG8PtH8ob8yAMqjJujcC3c/k2KelFAb+xKT6BTKuJOXruaMSMBAwDgYDVR0PAQH/
|
||||
BAQDAgeAMAoGCCqGSM49BAMCA0kAMEYCIQDWprfO49J8Zm52u4Su4HiXxCufrnvQ
|
||||
sNjHNpGil502DgIhANe/OstPGojs/4TBM4+n5+3ROGdSnnLhhqWcUiqC5HEw
|
||||
-----END CERTIFICATE-----
|
||||
Reference in New Issue
Block a user