mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 12:18:52 +00:00
e0d00717c7
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
184 lines
7.5 KiB
Go
184 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|