package intune import ( "crypto/x509" "errors" "flag" "os" "path/filepath" "strings" "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) } // Unknown-version fixture — same signing key + valid signature, but // the payload carries a `version: "v999"` claim that the dispatcher // does NOT have an unmarshaler for. ValidateChallenge MUST surface // ErrChallengeUnknownVersion; the unknown-version fixture pins the // dispatcher's defense against the inevitable Microsoft format // change (master prompt §13 line 1848). unknownVersionRaw := signGoldenChallengeAny(t, key, goldenUnknownVersionPayload()) if err := os.WriteFile( filepath.Join(testdataDir(t), "intune_challenge_golden_unknown_version.txt"), []byte(unknownVersionRaw+"\n"), 0o600, ); err != nil { t.Fatalf("write unknown-version fixture: %v", err) } t.Logf("regenerated 5 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, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: 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, ValidateOptions{Trust: trust, Now: 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, ValidateOptions{Trust: trust, ExpectedAudience: "https://certctl.example.com/scep/test", Now: 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, ValidateOptions{Trust: trust, ExpectedAudience: "https://attacker.example.com/scep/wrong", Now: 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, ValidateOptions{Trust: []*x509.Certificate{rotated.cert}, Now: goldenChallengeNow}) if !errors.Is(err, ErrChallengeSignature) { t.Fatalf("got %v, want errors.Is(ErrChallengeSignature) when validated against a rotated trust anchor", err) } } // TestGoldenChallenge_UnknownVersionRejected — master prompt §13 line // 1848 named acceptance criterion. A challenge whose payload carries a // `version: "v999"` claim (a value the dispatcher's // versionUnmarshalers map deliberately does NOT contain) MUST surface // ErrChallengeUnknownVersion regardless of whether the signature is // otherwise valid. This is the dispatcher's defense against the // inevitable Microsoft Connector format change — the day Microsoft // ships v2 and certctl's parser doesn't yet have a v2 unmarshaler, every // Intune enrollment lands here with a clear typed error rather than // crashing the SCEP handler with a confusing unmarshal panic. // // Why this test uses a fresh trust anchor instead of the on-disk // golden PEM: the on-disk PEM was generated with a Go-stdlib version // that produces different ECDSA key bytes from the current // generateGoldenTrustAnchor() call (the deterministic-PRNG + // ecdsa.GenerateKey pair has shifted across Go releases — the on-disk // public key bytes don't match what the current Go runtime regenerates // from the same seed). Rather than bake a stale trust anchor into the // regression, we generate a fresh ECDSA Connector keypair in-process // + use BOTH for signing AND for the validator's trust pool. The // regen target still emits a fixture file under testdata/ for the // operator-readable artifact; the test itself stays decoupled from // the on-disk PEM's drift. func TestGoldenChallenge_UnknownVersionRejected(t *testing.T) { conn := genTestECDSAConnector(t) raw := signTestChallengeES256_FixedWidth(t, conn, struct { Version string `json:"version"` challengePayloadV1 }{ Version: "v999", challengePayloadV1: goldenChallengePayload(), }) _, err := ValidateChallenge(raw, ValidateOptions{ Trust: []*x509.Certificate{conn.cert}, Now: goldenChallengeNow, }) if !errors.Is(err, ErrChallengeUnknownVersion) { t.Fatalf("got %v, want errors.Is(ErrChallengeUnknownVersion) for version=v999 claim", err) } // The error message MUST surface the specific version string so the // operator's audit log narrows the diagnosis to "Microsoft shipped // vN" rather than "something is wrong with the challenge." if !strings.Contains(err.Error(), "v999") { t.Errorf("error should contain the unknown version literal for operator audit log: %v", err) } }