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) } }