mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
aa139ee0d9
route + RFC 9266 channel binding + HTTP Basic enrollment-password +
per-source-IP failed-auth limit + per-(CN, sourceIP) sliding-window cap.
Two new shared packages so EST + Intune share infrastructure:
- internal/cms/ — RFC 9266 tls-exporter extractor (ExtractTLSExporter
with stdlib-panic recovery for synthetic ConnectionStates) +
CSR-side channel-binding parser via raw TBSCertificationRequestInfo
walk (the stdlib's csr.Attributes can't represent the OCTET STRING
binding value), VerifyChannelBinding composite, EmbedChannel-
BindingAttribute fixture helper, typed sentinel errors for missing
/ mismatch / not-TLS-1.3 mapped to HTTP 400 / 409 / 426 in handler.
- internal/trustanchor/ — extracted from scep/intune/trust_anchor*.go
so the EST mTLS sibling route + Intune dispatcher share the same
SIGHUP-reloadable PEM bundle primitive. intune.TrustAnchorHolder
is now `= trustanchor.Holder` (type alias) + NewTrustAnchorHolder =
trustanchor.New (function alias) — every existing call site compiles
unchanged. Intune's LoadTrustAnchor is a thin wrapper over
trustanchor.LoadBundle. White-box tests moved to the new package.
- internal/ratelimit/ — extracted from scep/intune/rate_limit.go (this
was Phase 4.1, in the same bundle). intune.PerDeviceRateLimiter
is now a thin wrapper preserving the (subject, issuer)→key
composition; EST handler reaches for SlidingWindowLimiter directly.
ESTHandler grew six optional fields wired by per-profile setters
(SetMTLSTrust / SetChannelBindingRequired / SetEnrollmentPassword /
SetSourceIPRateLimiter / SetPerPrincipalRateLimiter / SetLabelForLog)
plus four new mTLS-route methods (CACertsMTLS / SimpleEnrollMTLS /
SimpleReEnrollMTLS / CSRAttrsMTLS); shared internal pipeline
handleEnrollOrReEnroll(reEnroll, viaMTLS) keeps the auth/binding/
rate-limit gates DRY. New router method RegisterESTMTLSHandlers
registers /.well-known/est-mtls/<PathID>/{cacerts,simpleenroll,
simplereenroll,csrattrs}; AuthExemptDispatchPrefixes extends the
no-auth chain to /.well-known/est-mtls.
cmd/server/main.go's EST loop wires per-profile mTLS holder +
channel-binding policy + per-principal limiter + (when EnrollmentPassword
non-empty) Basic + source-IP limiter; new preflightESTMTLSClientCATrust-
Bundle returns *trustanchor.Holder so SIGHUP rotates the EST mTLS
bundle live without restart. SCEP + EST mTLS profiles now share a
single union mtlsUnionPoolForTLS passed to buildServerTLSConfigWithMTLS
(replaces the protocol-specific scepMTLSUnionPoolForTLS); per-handler
re-verify enforces "cert must chain to THIS profile's bundle" so
cross-protocol bleed is blocked at the application layer even though
the TLS layer trusts certs from either pool's union.
Phase 3.3 source-IP failed-Basic limiter defaults: 10 attempts / 1h
/ 50k tracked IPs (no env var; tunable in a follow-up). Phase 4.2
per-principal limiter cap from CERTCTL_EST_PROFILE_<NAME>_RATE_
LIMIT_PER_PRINCIPAL_24H (existing field, Phase 1 shipped).
New tests:
- internal/cms/channelbinding_test.go: extractor + CSR-side parser +
composite + TLS-1.3 round-trip end-to-end + EmbedChannelBinding-
Attribute round-trip
- internal/trustanchor/holder_test.go: parseBundlePEM white-box +
LoadBundle + Holder Get/Pool/SetLabelForLog/Reload-happy/
Reload-keeps-old-on-failure/Reload-keeps-old-on-expired/
WatchSIGHUP-reloads-pool/WatchSIGHUP-stop-clean
- internal/api/handler/est_hardening_test.go: 16 named cases covering
mTLS no-trust-pool 500 + no-cert 401 + cross-profile cert 401 +
happy-path 200 + CACertsMTLS auth gate + CSRAttrsMTLS auth gate +
channel-binding required-absent-rejected + not-required-absent-
allowed + writeChannelBindingError mapping + Basic no-header 401
+ Basic wrong-password 401 + Basic correct-200 + Basic-no-password
no-gate + per-IP failed-attempt lockout 429 + per-principal
blocks-after-cap + different-principals-independent + no-limiter-
unbounded.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean for
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
cmd/server, go test -short -count=1 green for cms/trustanchor/
api/handler/api/router/scep/intune/ratelimit/service. G-3
docs-drift guard reproduced locally clean (Phase 1 already
documented every new env var; Phases 2-4 added zero new env vars).
165 lines
5.2 KiB
Go
165 lines
5.2 KiB
Go
package intune
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPerDeviceRateLimiter_AllowsUpToCap(t *testing.T) {
|
|
l := NewPerDeviceRateLimiter(3, 24*time.Hour, 10)
|
|
now := time.Now()
|
|
for i := 0; i < 3; i++ {
|
|
if err := l.Allow("device-1", "issuer-A", now.Add(time.Duration(i)*time.Minute)); err != nil {
|
|
t.Fatalf("call %d should be allowed: %v", i+1, err)
|
|
}
|
|
}
|
|
if err := l.Allow("device-1", "issuer-A", now.Add(4*time.Minute)); !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("4th call should be rate-limited; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_DistinctKeysIndependent(t *testing.T) {
|
|
l := NewPerDeviceRateLimiter(1, 24*time.Hour, 10)
|
|
now := time.Now()
|
|
|
|
if err := l.Allow("device-1", "issuer-A", now); err != nil {
|
|
t.Fatalf("first allow: %v", err)
|
|
}
|
|
// Different subject — independent bucket.
|
|
if err := l.Allow("device-2", "issuer-A", now); err != nil {
|
|
t.Fatalf("different subject must have its own bucket: %v", err)
|
|
}
|
|
// Different issuer — also independent.
|
|
if err := l.Allow("device-1", "issuer-B", now); err != nil {
|
|
t.Fatalf("different issuer must have its own bucket: %v", err)
|
|
}
|
|
// Same key as call 1 — must be limited.
|
|
if err := l.Allow("device-1", "issuer-A", now.Add(1*time.Second)); !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("repeat key should be limited; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_WindowExpiry(t *testing.T) {
|
|
l := NewPerDeviceRateLimiter(2, 1*time.Hour, 10)
|
|
now := time.Now()
|
|
|
|
if err := l.Allow("dev", "iss", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := l.Allow("dev", "iss", now.Add(30*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Inside window — limited.
|
|
if err := l.Allow("dev", "iss", now.Add(45*time.Minute)); !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("inside-window 3rd call should be limited: %v", err)
|
|
}
|
|
// Past window — slots reopen.
|
|
if err := l.Allow("dev", "iss", now.Add(2*time.Hour)); err != nil {
|
|
t.Fatalf("past-window call should be allowed (window reset): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_DisabledBypass(t *testing.T) {
|
|
l := NewPerDeviceRateLimiter(0, 24*time.Hour, 10) // maxN=0 → disabled
|
|
if !l.Disabled() {
|
|
t.Fatal("limiter with maxN=0 must report Disabled()=true")
|
|
}
|
|
now := time.Now()
|
|
for i := 0; i < 100; i++ {
|
|
if err := l.Allow("dev", "iss", now); err != nil {
|
|
t.Fatalf("disabled limiter must allow everything: %v", err)
|
|
}
|
|
}
|
|
// Disabled limiter doesn't track buckets.
|
|
if got := l.Len(); got != 0 {
|
|
t.Errorf("disabled limiter Len() = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_NegativeCapDisabled(t *testing.T) {
|
|
l := NewPerDeviceRateLimiter(-1, 24*time.Hour, 10)
|
|
if !l.Disabled() {
|
|
t.Fatal("negative maxN must produce a disabled limiter")
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_EmptySubjectShortCircuits(t *testing.T) {
|
|
// Empty subject is the caller's defense-in-depth case (claim validation
|
|
// upstream should reject empty-subject claims first). Limiter must not
|
|
// build a single shared bucket keyed by empty-subject — that would
|
|
// be a fleet-wide chokepoint.
|
|
l := NewPerDeviceRateLimiter(1, 24*time.Hour, 10)
|
|
now := time.Now()
|
|
for i := 0; i < 50; i++ {
|
|
if err := l.Allow("", "iss", now); err != nil {
|
|
t.Fatalf("empty subject must short-circuit (call %d): %v", i, err)
|
|
}
|
|
}
|
|
if got := l.Len(); got != 0 {
|
|
t.Errorf("Len after 50 empty-subject calls = %d, want 0 (no bucket created)", got)
|
|
}
|
|
}
|
|
|
|
// TestPerDeviceRateLimiter_DefaultCapsHonored — moved to
|
|
// internal/ratelimit/sliding_window_test.go::TestSlidingWindowLimiter_DefaultCapsHonored
|
|
// in EST RFC 7030 hardening Phase 4.1 (the white-box test reads private
|
|
// fields that no longer exist on the wrapper). The shared package owns
|
|
// the field-default contract.
|
|
|
|
func TestPerDeviceRateLimiter_MapCapEvictsOldest(t *testing.T) {
|
|
// Cap of 3 keys to exercise the eviction branch deterministically.
|
|
l := NewPerDeviceRateLimiter(2, 1*time.Hour, 3)
|
|
now := time.Now()
|
|
|
|
// Insert 3 distinct keys with increasing timestamps.
|
|
for i := 0; i < 3; i++ {
|
|
key := fmt.Sprintf("dev-%d", i)
|
|
if err := l.Allow(key, "iss", now.Add(time.Duration(i)*time.Minute)); err != nil {
|
|
t.Fatalf("insert %d: %v", i, err)
|
|
}
|
|
}
|
|
if l.Len() != 3 {
|
|
t.Fatalf("Len = %d, want 3", l.Len())
|
|
}
|
|
|
|
// 4th key forces eviction of dev-0 (its newest timestamp is oldest).
|
|
if err := l.Allow("dev-3", "iss", now.Add(10*time.Minute)); err != nil {
|
|
t.Fatalf("4th-key insert: %v", err)
|
|
}
|
|
if l.Len() != 3 {
|
|
t.Errorf("Len after at-cap insert = %d, want 3 (cap honored)", l.Len())
|
|
}
|
|
}
|
|
|
|
func TestPerDeviceRateLimiter_ConcurrentRaceFree(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("race-style test under -short")
|
|
}
|
|
l := NewPerDeviceRateLimiter(50, 24*time.Hour, 10000)
|
|
var wg sync.WaitGroup
|
|
for g := 0; g < 20; g++ {
|
|
wg.Add(1)
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
now := time.Now()
|
|
key := fmt.Sprintf("dev-%d", id)
|
|
for i := 0; i < 30; i++ {
|
|
_ = l.Allow(key, "iss", now)
|
|
}
|
|
}(g)
|
|
}
|
|
wg.Wait()
|
|
if got := l.Len(); got != 20 {
|
|
t.Errorf("expected 20 distinct keys; got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestPruneOlderThan + TestPruneOlderThan_NoOpWhenNothingToPrune — moved
|
|
// to internal/ratelimit/sliding_window_test.go in EST RFC 7030 hardening
|
|
// Phase 4.1. pruneOlderThan is now an unexported helper of the shared
|
|
// ratelimit package (the implementation moved there); the white-box
|
|
// tests follow.
|