mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
530593507b
Closes the eleven gaps identified in the pre-v2.1.0 audit of the SCEP
RFC 8894 + Intune master bundle (cowork/scep-bundle-gap-closure-prompt.md).
Constitutional rule from cowork/CLAUDE.md::Operating Rules — 'Always
take the complete path, not the easy path' — drove this closure: each
gap was a load-bearing wire that crossed multiple layers (config →
validator → service wire-up → tests → docs) and shipping the bundle
without them would have produced lying-field footguns where operator-
visible config options stored values without affecting behavior.
WHAT LANDS:
Phase A — Clock-skew tolerance (master prompt §15 hazard closure)
internal/scep/intune/challenge.go: ValidateChallenge migrated from
positional args to ValidateOptions{} struct; new ClockSkewTolerance
field with default 0 (strict). 24 call sites updated mechanically.
Asymmetric application: now+tolerance >= iat AND now-tolerance < exp.
internal/config/config.go: SCEPIntuneProfileConfig.ClockSkewTolerance
default 60s + Validate() refusal when >= ChallengeValidity.
cmd/server/main.go: SetIntuneIntegration signature extended;
per-profile env-var loader honors CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
internal/service/scep.go: intuneClockSkew field + IntuneStatsSnapshot
surfaces clock_skew_tolerance_ns. web/src/api/types.ts mirrors.
4 new tests in challenge_test.go covering accept-within-tolerance,
reject-beyond-tolerance, accept-expired-within-tolerance,
negative-treated-as-zero defensive normalization.
docs/scep-intune.md updated with the new env var + time-bounds rule.
Phase B — unknown-version-rejected golden test
internal/scep/intune/golden_helper_test.go: goldenUnknownVersionPayload
helper + signGoldenChallengeAny generic signer.
challenge_golden_test.go: TestGoldenChallenge_UnknownVersionRejected
uses an in-process ECDSA fixture (the on-disk PEM was generated with
a Go-stdlib version that produces different ecdsa.GenerateKey bytes
from the current call). TestRegenerateGoldenFixtures emits the new
unknown_version fixture file too.
Phase C — Two named Intune e2e tests
internal/api/handler/scep_intune_e2e_test.go:
TestSCEPIntuneEnrollment_RateLimited_E2E (cap=2 + 3 attempts; 3rd
returns FAILURE+badRequest with rate_limited counter ticked)
TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E (rotate
on-disk PEM + holder.Reload(); old-key challenge fails with
badMessageCheck; signature_invalid counter ticked)
intuneE2EFixture struct extended with trustHolder + trustPath fields
so tests can rotate.
Phase D — Four new ChromeOS hermetic tests (10 total now)
internal/api/handler/scep_chromeos_test.go:
_RAKeyMismatch — PKIMessage encrypted to wrong RA cert; handler
rejects without reaching service.
_3DESBackwardCompat — RFC 8894 §3.5.2 legacy fallback verified.
_RSACSR + _ECDSACSR — explicit matrix-pair pinning.
buildTestECDSACSR helper for ECDSA P-256 CSR construction;
tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES-CBC;
assertChromeOSPositiveCertRep shared assertion.
Phase E — Per-profile counter isolation test
internal/api/handler/scep_profile_counter_isolation_test.go:
TestSCEPHandler_PerProfileIntuneCountersIsolated wires two
SCEPService instances + drives distinct PKIMessages + asserts
counter isolation. Guards against a future cmd/server/main.go
refactor that shares a *intuneCounterTab across profiles.
buildPerProfileIntuneFixture parameterized helper.
Phase F — Server-boot regression tests
cmd/server/preflight_scep_intune_test.go: 3 named tests covering
disabled-backward-compat, broken-config-with-PathID, expired-cert
refusal. preflightSCEPIntuneTrustAnchor signature extended with
pathID arg so error messages carry PathID= for operator log-grep.
Phase G — docs/connectors.md
Four new subsections under §EST/SCEP Integration: multi-profile
dispatch + mTLS sibling route + Intune Connector dispatcher + SCEP
probe in network scanner. Each has a one-paragraph operator
explanation + an env-var or endpoint table.
Phase H — Coverage uplift
internal/service/scep_probe_persist_test.go: 5 unit tests on
persistProbeResult (nil-safe + nil-repo-safe + repo-error swallow +
nil-logger guard) + ListRecentSCEPProbes (empty-slice-not-nil + repo
pass-through) + describeCertAlgorithm (RSA/ECDSA/QF1008-nil-curve
defensive branch/Ed25519/DSA/empty). CI gates (service ≥70, handler
≥75) PASS at 70.9% / 79.3%.
Phase I — deploy/test integration variant
deploy/test/scep_intune_e2e_test.go (//go:build integration):
TestSCEPIntuneEnrollment_Integration + _RateLimited_Integration
against the live docker-compose certctl container. Skip-when-
stack-missing semantics so sandbox + CI both work.
deploy/docker-compose.test.yml: new e2eintune SCEP profile env
vars + bind-mount of deploy/test/fixtures/.
deploy/test/fixtures/README.md: documents the deterministic trust
anchor regeneration recipe.
VERIFICATION (sandbox):
gofmt -d — clean for all changed files
staticcheck — clean for intune + handler + config + service +
cmd/server packages
go vet — clean for the same packages
go test -short — green for intune (95.3% cov), service (70.9%),
handler (79.3%), config (94.0%), cmd/server (boot
path; my preflight tests cover the directly-
testable function), pkcs7 (80.5% informational)
DEFERRED (per closure prompt §7 out-of-scope):
- V3-Pro Conditional Access gating + Microsoft Graph integration
- Standalone certctl-scan CLI binary
- OCSP rate-limiting, OCSP stapling, delta CRLs
Spec preserved at cowork/scep-bundle-gap-closure-prompt.md;
journal at cowork/scep-rfc8894-intune/progress.md (audit-closure
section appended).
345 lines
13 KiB
Go
345 lines
13 KiB
Go
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
|
|
}
|
|
|
|
// goldenUnknownVersionPayload wraps the success v1 payload in a
|
|
// version-bearing prelude where Version="v999" — a value the
|
|
// versionUnmarshalers map does NOT contain. ValidateChallenge MUST
|
|
// surface ErrChallengeUnknownVersion when given this payload.
|
|
//
|
|
// Master prompt §13 line 1848 (golden test acceptance) specifically
|
|
// names "unknown-version-rejected" alongside success / expired /
|
|
// tampered_sig as a required golden case; this helper materializes the
|
|
// fixture from the same deterministic seed as the others so the
|
|
// regenerated fixture file diff stays clean.
|
|
type goldenUnknownVersionWire struct {
|
|
Version string `json:"version"`
|
|
challengePayloadV1
|
|
}
|
|
|
|
func goldenUnknownVersionPayload() goldenUnknownVersionWire {
|
|
return goldenUnknownVersionWire{
|
|
Version: "v999",
|
|
challengePayloadV1: goldenChallengePayload(),
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
return signGoldenChallengeAny(t, key, payload)
|
|
}
|
|
|
|
// signGoldenChallengeAny mirrors signGoldenChallenge for any
|
|
// JSON-marshalable payload type. The goldenUnknownVersionWire fixture
|
|
// embeds the v1 payload inside a version-bearing prelude, so the typed
|
|
// helper above can't reach it without a cast — this any-typed sibling
|
|
// keeps the typed entrypoint stable while letting the regen target +
|
|
// the unknown-version-rejected golden test pass an embedded struct.
|
|
func signGoldenChallengeAny(t *testing.T, key *ecdsa.PrivateKey, payload any) 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 _ = signGoldenChallengeAny
|
|
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
|