mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 16:38:52 +00:00
EST RFC 7030 hardening master bundle Phases 2-4: end-to-end mTLS sibling
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).
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// Package trustanchor provides a SIGHUP-reloadable PEM-bundle trust pool
|
||||
// shared by the SCEP/Intune dispatcher (per-profile Microsoft Intune
|
||||
// Connector signing-cert anchor), the EST mTLS sibling route (per-profile
|
||||
// client-CA trust bundle for /.well-known/est-mtls/<pathID>/), and any
|
||||
// future caller that needs the same pattern (operator rotates an on-disk
|
||||
// PEM bundle, sends SIGHUP, certctl swaps the in-memory pool atomically
|
||||
// without a restart).
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phase 2.1: extracted from
|
||||
// internal/scep/intune/trust_anchor_holder.go where it originally lived.
|
||||
// The intune package preserves a thin alias-style wrapper for back-compat
|
||||
// (existing intune.TrustAnchorHolder + NewTrustAnchorHolder + LoadTrustAnchor
|
||||
// callers compile unchanged); new callers SHOULD import this package
|
||||
// directly.
|
||||
//
|
||||
// Concurrency contract:
|
||||
//
|
||||
// - Get returns the pool slice header by value; the slice itself is
|
||||
// immutable per-snapshot (Reload swaps a fresh slice rather than
|
||||
// mutating the existing one). Callers may iterate the returned slice
|
||||
// without holding any lock.
|
||||
// - Reload acquires a write lock briefly for the swap. Concurrent Get
|
||||
// calls block only for that swap window (microseconds).
|
||||
// - WatchSIGHUP runs at most one Reload at a time per holder.
|
||||
//
|
||||
// Threat model: the rationale for SIGHUP-as-reload-trigger (vs fsnotify
|
||||
// or polling) is that the existing certctl rotation playbook (server TLS
|
||||
// cert at cmd/server/tls.go::certHolder) already uses SIGHUP. Operators
|
||||
// running the standard "rotate file, kill -HUP" workflow get every
|
||||
// holder reloaded with one signal: server TLS + Intune trust anchors +
|
||||
// EST mTLS trust bundles all swap atomically.
|
||||
package trustanchor
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Holder is the SIGHUP-reloadable wrapper around a PEM-bundle trust
|
||||
// pool. Construct via New. The zero value is NOT usable.
|
||||
type Holder struct {
|
||||
mu sync.RWMutex
|
||||
certs []*x509.Certificate
|
||||
path string
|
||||
logger *slog.Logger
|
||||
// labelForLog is used only in error / info log lines so an operator
|
||||
// running multiple holders (per-profile EST mTLS, per-profile Intune,
|
||||
// server TLS) can distinguish which one fired. Defaults to "trust
|
||||
// anchor" when not set; callers SHOULD set this to a descriptive
|
||||
// string like "intune trust anchor (PathID=corp)" or "EST mTLS
|
||||
// client CA bundle (PathID=corp)".
|
||||
labelForLog string
|
||||
}
|
||||
|
||||
// New loads the trust bundle and returns a holder. Returns the same
|
||||
// fail-loud error LoadBundle does on initial load — the startup gate at
|
||||
// cmd/server/main.go is supposed to refuse boot when this fails.
|
||||
// Subsequent Reload errors are non-fatal (logged + old pool retained).
|
||||
//
|
||||
// The logger is required (never nil); the caller passes a per-profile
|
||||
// scoped logger so SIGHUP-reload events show the PathID for triage.
|
||||
func New(path string, logger *slog.Logger) (*Holder, error) {
|
||||
if logger == nil {
|
||||
return nil, errors.New("trustanchor: New requires a non-nil logger")
|
||||
}
|
||||
certs, err := LoadBundle(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Holder{certs: certs, path: path, logger: logger, labelForLog: "trust anchor"}, nil
|
||||
}
|
||||
|
||||
// SetLabelForLog records a descriptive label that future reload log
|
||||
// lines use to distinguish this holder from others (e.g. "intune trust
|
||||
// anchor (PathID=corp)"). Idempotent + safe for concurrent callers
|
||||
// (the field is read only by the SIGHUP watcher goroutine after
|
||||
// WatchSIGHUP starts).
|
||||
func (h *Holder) SetLabelForLog(label string) {
|
||||
if label == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.labelForLog = label
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the current trust anchor pool. Safe for concurrent
|
||||
// callers; the slice header is returned by value and the underlying
|
||||
// slice is immutable per-snapshot.
|
||||
func (h *Holder) Get() []*x509.Certificate {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.certs
|
||||
}
|
||||
|
||||
// Path returns the on-disk path the holder reloads from.
|
||||
func (h *Holder) Path() string {
|
||||
return h.path
|
||||
}
|
||||
|
||||
// Pool returns a fresh *x509.CertPool populated with the holder's
|
||||
// current certs. Helper for callers that need a pool instead of a
|
||||
// slice (the EST mTLS handler verifies client cert chains via
|
||||
// cert.Verify(VerifyOptions{Roots: pool}); the Intune dispatcher uses
|
||||
// the slice directly for signature-walk).
|
||||
func (h *Holder) Pool() *x509.CertPool {
|
||||
pool := x509.NewCertPool()
|
||||
for _, c := range h.Get() {
|
||||
pool.AddCert(c)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// Reload re-reads the trust anchor file at h.path and atomically swaps
|
||||
// the pool. Returns the parse error if the new file is invalid; the
|
||||
// OLD pool stays in place so a bad reload doesn't take dependent
|
||||
// dispatch paths down. Same fail-safe pattern as cmd/server/tls.go::
|
||||
// (*certHolder).Reload — a rotation that writes a half-file would
|
||||
// otherwise crash the service mid-rotation.
|
||||
func (h *Holder) Reload() error {
|
||||
certs, err := LoadBundle(h.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.certs = certs
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchSIGHUP installs a signal handler that calls Reload on each
|
||||
// SIGHUP. The returned stop function closes the internal done channel
|
||||
// and stops signal delivery so the goroutine can exit cleanly during
|
||||
// shutdown.
|
||||
//
|
||||
// Errors from Reload are logged but do not terminate the watcher — the
|
||||
// operator can fix the files and send another SIGHUP. Mirrors the
|
||||
// (*certHolder).watchSIGHUP contract from cmd/server/tls.go exactly.
|
||||
//
|
||||
// Multiple holders coexist: each registers its own goroutine on the
|
||||
// same SIGHUP signal. signal.Notify multicasts to every registered
|
||||
// channel, so a single SIGHUP reloads every per-profile trust anchor
|
||||
// + the server TLS cert in one operator action.
|
||||
func (h *Holder) WatchSIGHUP() (stop func()) {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGHUP)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
if err := h.Reload(); err != nil {
|
||||
h.logger.Error(h.labelForLog+" reload failed; continuing with previous pool",
|
||||
"error", err,
|
||||
"path", h.path)
|
||||
continue
|
||||
}
|
||||
h.logger.Info(h.labelForLog+" reloaded via SIGHUP",
|
||||
"path", h.path,
|
||||
"certs_loaded", len(h.Get()))
|
||||
case <-done:
|
||||
signal.Stop(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// LoadBundle reads a PEM bundle from disk + returns the parsed cert
|
||||
// slice. Refuses empty bundles (zero CERTIFICATE blocks); refuses any
|
||||
// bundle containing a cert past NotAfter (fail loud at boot rather than
|
||||
// silently rejecting every request at runtime).
|
||||
//
|
||||
// Non-CERTIFICATE PEM blocks are skipped (so an operator can paste a
|
||||
// chain that includes a private key by mistake without breaking the
|
||||
// load — the priv key is just ignored). Operators rotating signing
|
||||
// certs typically want this tolerance.
|
||||
func LoadBundle(path string) ([]*x509.Certificate, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("trustanchor: bundle path is empty")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trustanchor: read bundle %q: %w", path, err)
|
||||
}
|
||||
return parseBundlePEM(body, path, time.Now())
|
||||
}
|
||||
|
||||
// parseBundlePEM is the file-IO-free core of LoadBundle. Split out so
|
||||
// unit tests can hand it byte slices without writing temp files. `now`
|
||||
// is taken as a parameter so expiry tests can pin a deterministic clock.
|
||||
func parseBundlePEM(body []byte, sourceLabel string, now time.Time) ([]*x509.Certificate, error) {
|
||||
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 {
|
||||
return nil, fmt.Errorf("trustanchor: parse cert in %q: %w", sourceLabel, err)
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
return nil, fmt.Errorf("trustanchor: cert in %q expired at %s (subject=%q) — operator must rotate the trust bundle before restart",
|
||||
sourceLabel, cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName)
|
||||
}
|
||||
out = append(out, cert)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("trustanchor: %q contains no CERTIFICATE PEM blocks", sourceLabel)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package trustanchor
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 2.1: this test file holds the
|
||||
// white-box tests for the trust-anchor primitives (parseBundlePEM + LoadBundle
|
||||
// + Holder) that used to live in internal/scep/intune/{trust_anchor_test.go,
|
||||
// trust_anchor_holder_test.go}. The intune package retains a thin
|
||||
// public-surface test of LoadTrustAnchor (the back-compat shim) — the
|
||||
// detailed tests live here so the EST mTLS sibling route + any future
|
||||
// trustanchor.Holder caller share the same contract pinning.
|
||||
|
||||
// silentLogger drops everything; the SIGHUP watcher emits Info logs we don't
|
||||
// want fouling test output.
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||
}
|
||||
|
||||
// pemEncodeCert is a small DRY helper for the PEM bundle fixtures.
|
||||
func pemEncodeCert(t *testing.T, der []byte) []byte {
|
||||
t.Helper()
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// freshConnectorCertDER returns a freshly-minted EC P-256 cert as raw DER
|
||||
// + the matching key. Lifetime is parameterised so the same factory drives
|
||||
// both the happy-path and expired-cert cases.
|
||||
func freshConnectorCertDER(t *testing.T, notAfter time.Time) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "trustanchor-test"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
// writeTestBundle writes a PEM bundle of the given certs at path with mode 0600.
|
||||
func writeTestBundle(t *testing.T, path string, certs []*x509.Certificate) {
|
||||
t.Helper()
|
||||
body := []byte{}
|
||||
for _, c := range certs {
|
||||
body = append(body, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})...)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// freshHolderCert is a small factory for a self-signed EC cert with a
|
||||
// caller-controlled CN + lifetime. Used by Reload tests that swap the
|
||||
// on-disk pool between calls.
|
||||
func freshHolderCert(t *testing.T, cn string, notAfter time.Time) *x509.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.ParseCertificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
// ----- parseBundlePEM (white-box) -----
|
||||
|
||||
func TestParseBundlePEM_HappyPath_SingleCert(t *testing.T) {
|
||||
der, _ := freshConnectorCertDER(t, time.Now().Add(365*24*time.Hour))
|
||||
body := pemEncodeCert(t, der)
|
||||
|
||||
certs, err := parseBundlePEM(body, "test", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parseBundlePEM: %v", err)
|
||||
}
|
||||
if len(certs) != 1 {
|
||||
t.Fatalf("len(certs) = %d, want 1", len(certs))
|
||||
}
|
||||
if certs[0].Subject.CommonName != "trustanchor-test" {
|
||||
t.Errorf("Subject.CommonName = %q", certs[0].Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_HappyPath_MultiCert(t *testing.T) {
|
||||
d1, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
|
||||
d2, _ := freshConnectorCertDER(t, time.Now().Add(60*24*time.Hour))
|
||||
body := append(pemEncodeCert(t, d1), pemEncodeCert(t, d2)...)
|
||||
|
||||
certs, err := parseBundlePEM(body, "test", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parseBundlePEM: %v", err)
|
||||
}
|
||||
if len(certs) != 2 {
|
||||
t.Fatalf("len(certs) = %d, want 2", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_SkipsNonCertBlocks(t *testing.T) {
|
||||
der, key := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
body := append(keyPEM, pemEncodeCert(t, der)...) // priv key first, cert second
|
||||
|
||||
certs, err := parseBundlePEM(body, "test", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("parseBundlePEM should ignore non-CERTIFICATE blocks: %v", err)
|
||||
}
|
||||
if len(certs) != 1 {
|
||||
t.Fatalf("len(certs) = %d, want 1 (priv key block must be skipped)", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_EmptyBundleRejected(t *testing.T) {
|
||||
_, err := parseBundlePEM([]byte("nothing here"), "test", time.Now())
|
||||
if err == nil || !strings.Contains(err.Error(), "no CERTIFICATE PEM blocks") {
|
||||
t.Fatalf("expected 'no CERTIFICATE PEM blocks' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_OnlyKeyBlocksRejected(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
_, err := parseBundlePEM(body, "test", time.Now())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for bundle with no certs, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_ExpiredCertRejected(t *testing.T) {
|
||||
der, _ := freshConnectorCertDER(t, time.Now().Add(-1*time.Hour)) // already expired
|
||||
body := pemEncodeCert(t, der)
|
||||
|
||||
_, err := parseBundlePEM(body, "expired-bundle", time.Now())
|
||||
if err == nil || !strings.Contains(err.Error(), "expired") {
|
||||
t.Fatalf("expected expiry error, got %v", err)
|
||||
}
|
||||
// Operator-actionable message must include the subject so the audit
|
||||
// log says exactly which cert to rotate.
|
||||
if !strings.Contains(err.Error(), "trustanchor-test") {
|
||||
t.Errorf("error must include subject CN for operator action: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBundlePEM_MalformedCertRejected(t *testing.T) {
|
||||
bad := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not-a-real-asn1-cert")})
|
||||
|
||||
_, err := parseBundlePEM(bad, "test", time.Now())
|
||||
if err == nil {
|
||||
t.Fatalf("expected x509 parse error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ----- LoadBundle (filesystem-side) -----
|
||||
|
||||
func TestLoadBundle_FromDisk(t *testing.T) {
|
||||
der, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
|
||||
body := pemEncodeCert(t, der)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
certs, err := LoadBundle(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBundle: %v", err)
|
||||
}
|
||||
if len(certs) != 1 {
|
||||
t.Fatalf("len(certs) = %d, want 1", len(certs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBundle_EmptyPath(t *testing.T) {
|
||||
_, err := LoadBundle("")
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("expected empty-path error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBundle_MissingFile(t *testing.T) {
|
||||
_, err := LoadBundle("/tmp/does-not-exist-trustanchor.pem")
|
||||
if err == nil {
|
||||
t.Fatalf("expected file-not-found error, got nil")
|
||||
}
|
||||
if errors.Is(err, nil) {
|
||||
t.Fatalf("error must be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Holder -----
|
||||
|
||||
func TestHolder_NewLoadsBundle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
cert := freshHolderCert(t, "initial", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{cert})
|
||||
|
||||
holder, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
got := holder.Get()
|
||||
if len(got) != 1 || got[0].Subject.CommonName != "initial" {
|
||||
t.Fatalf("Get returned %#v, want one cert with CN=initial", got)
|
||||
}
|
||||
if holder.Path() != path {
|
||||
t.Errorf("Path = %q, want %q", holder.Path(), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_NewRequiresLogger(t *testing.T) {
|
||||
if _, err := New("/nonexistent", nil); err == nil {
|
||||
t.Fatal("nil logger must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_NewSurfacesLoadError(t *testing.T) {
|
||||
if _, err := New("/path/that/does/not/exist.pem", silentLogger()); err == nil {
|
||||
t.Fatal("missing file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_PoolReturnsAllCerts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
c1 := freshHolderCert(t, "ca-1", time.Now().Add(30*24*time.Hour))
|
||||
c2 := freshHolderCert(t, "ca-2", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{c1, c2})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := h.Pool()
|
||||
if pool == nil {
|
||||
t.Fatal("Pool returned nil")
|
||||
}
|
||||
// pool.Subjects() is deprecated for caller-owned pools that may include
|
||||
// the system roots. We've built this pool ourselves with exactly the
|
||||
// two certs from h.Get(), so it's a safe use — but the linter doesn't
|
||||
// know that. Rather than disable the lint, we cross-check via Equal()
|
||||
// over the underlying cert slice we used to build the pool.
|
||||
got := h.Get()
|
||||
if len(got) != 2 {
|
||||
t.Errorf("Get() len = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_SetLabelForLogIgnoresEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
writeTestBundle(t, path, []*x509.Certificate{
|
||||
freshHolderCert(t, "label-test", time.Now().Add(30*24*time.Hour)),
|
||||
})
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.SetLabelForLog("") // no-op; default "trust anchor" preserved
|
||||
h.SetLabelForLog("est mTLS client CA bundle")
|
||||
// No public getter for label; just exercise without crashing — race
|
||||
// detector covers the locking contract under -race.
|
||||
}
|
||||
|
||||
func TestHolder_ReloadHappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
c1 := freshHolderCert(t, "rev-1", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{c1})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Rotate on disk and call Reload.
|
||||
c2 := freshHolderCert(t, "rev-2", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{c2})
|
||||
if err := h.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
got := h.Get()
|
||||
if len(got) != 1 || got[0].Subject.CommonName != "rev-2" {
|
||||
t.Errorf("after Reload Get = %#v, want one cert CN=rev-2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_ReloadKeepsOldOnFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
good := freshHolderCert(t, "stable", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{good})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Overwrite with content that LoadBundle will reject (no PEM blocks).
|
||||
if err := os.WriteFile(path, []byte("garbage"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Reload(); err == nil {
|
||||
t.Fatal("Reload from garbage file must error")
|
||||
}
|
||||
|
||||
// Old pool still served.
|
||||
got := h.Get()
|
||||
if len(got) != 1 || got[0].Subject.CommonName != "stable" {
|
||||
t.Errorf("after failed Reload Get should still be the pre-Reload pool; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_ReloadKeepsOldOnExpired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
good := freshHolderCert(t, "still-valid", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{good})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expired := freshHolderCert(t, "expired-conn", time.Now().Add(-1*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{expired})
|
||||
|
||||
if err := h.Reload(); err == nil {
|
||||
t.Fatal("Reload with expired cert must error")
|
||||
}
|
||||
if !strings.Contains(h.Get()[0].Subject.CommonName, "still-valid") {
|
||||
t.Errorf("after expired-cert Reload, holder should retain old pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_WatchSIGHUPReloadsPool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
c1 := freshHolderCert(t, "rev-pre-sighup", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{c1})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := h.WatchSIGHUP()
|
||||
defer stop()
|
||||
|
||||
c2 := freshHolderCert(t, "rev-post-sighup", time.Now().Add(30*24*time.Hour))
|
||||
writeTestBundle(t, path, []*x509.Certificate{c2})
|
||||
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
|
||||
t.Fatalf("send SIGHUP: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
got := h.Get()
|
||||
if len(got) == 1 && got[0].Subject.CommonName == "rev-post-sighup" {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("post-SIGHUP pool not swapped in 2s; current CN=%q", got[0].Subject.CommonName)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolder_WatchSIGHUPStopIsClean(t *testing.T) {
|
||||
// We do NOT fire a SIGHUP after stop(): once signal.Stop has removed our
|
||||
// handler the kernel's default action on SIGHUP is to terminate the
|
||||
// process — it would kill the test runner. Pin "stop() is synchronous
|
||||
// and safe" by closing the watcher and verifying the holder still
|
||||
// serves the original cert without panic.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust.pem")
|
||||
writeTestBundle(t, path, []*x509.Certificate{
|
||||
freshHolderCert(t, "stop-test", time.Now().Add(30*24*time.Hour)),
|
||||
})
|
||||
|
||||
h, err := New(path, silentLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := h.WatchSIGHUP()
|
||||
stop()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if cn := h.Get()[0].Subject.CommonName; cn != "stop-test" {
|
||||
t.Errorf("after stop CN = %q, want unchanged stop-test", cn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user