Files
certctl/internal/service/est_counters.go
T
shankar0123 8bc9f4eed8 EST RFC 7030 hardening master bundle Phases 5-7: end-to-end serverkeygen
+ profile-driven csrattrs + admin observability with per-status
counters + reload-trust endpoint.

Phase 5 — RFC 7030 §4.4 server-driven key generation:
- internal/pkcs7/envelopeddata_builder.go is the inverse of the
  existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1
  v1.5 keyTrans + per-call random IV. Round-trip pinned in test
  (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the
  original plaintext byte-for-byte).
- ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client
  CSR → require RSA pubkey for keyTrans → resolve per-profile
  algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in-
  memory keygen → re-build CSR with server pubkey → run existing
  issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a
  synthetic recipient cert wrapping the device's CSR-supplied pubkey
  → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM
  + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey-
  Encipherment / ErrServerKeygenUnsupportedAlgorithm /
  ErrServerKeygenDisabled.
- ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2
  multipart/mixed with random per-response boundary; per-profile
  SetServerKeygenEnabled gate returns 404 when off (defense in depth
  even if the route was registered).
- New routes POST /.well-known/est/[<PathID>/]serverkeygen +
  /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml +
  openapi-parity guard updated.

Phase 6 — Real csrattrs implementation:
- New CertificateProfile.RequiredCSRAttributes []string + migration
  000022_certificate_profiles_csrattrs.up.sql. The migration also
  lands the previously-unwired must_staple column (closes the 5.6
  follow-up loop where the field shipped at the domain + service
  layer but the postgres scan/insert/update never persisted it).
- domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-*
  EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985
  PKCS#10 attributes + Microsoft Intune device-serial OID.
- ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a
  profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU /
  attribute strings dropped + warning-logged so a typo doesn't take
  down the entire endpoint.

Phase 7 — Admin observability + counters + reload-trust:
- internal/service/est_counters.go: estCounterTab (sync/atomic; 12
  named labels) + ESTStatsSnapshot per-profile shape +
  ESTService.Stats(now) zero-allocation accessor + ReloadTrust()
  SIGHUP-equivalent + SetESTAdminMetadata setter.
- Counter ticks wired into processEnrollment + SimpleServerKeygen at
  every success/failure leg.
- internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim:
  Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both
  endpoints admin-gated (M-008 triplet pinned + admin_est.go added
  to AdminGatedHandlers).
- New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/
  est/reload-trust; openapi.yaml documented; openapi-parity guard
  reproduced clean.
- cmd/server/main.go grows estServices map populated by the per-
  profile EST loop + handed to AdminEST. New MTLSTrust() +
  HasMTLSTrust() accessors on ESTHandler so main.go can pull the
  trust holder for the admin-metadata wire-up.
- Per-profile counter isolation regression test
  (internal/service/est_profile_counter_isolation_test.go) proves
  a future shared-counter refactor would fail at compile-time
  pointer-identity check.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean across
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
service/pkcs7/domain/cmd/server, go test -short -count=1 green
for every non-postgres package. G-3 docs-drift guard reproduced
locally clean (Phases 5-7 added zero new env vars; Phase 1
already documented per-profile SERVER_KEYGEN_ENABLED).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation /
docs/est.md / release prep) remain — post-2.1.0 work.
2026-04-29 23:57:45 +00:00

206 lines
7.7 KiB
Go

package service
import (
"sync/atomic"
"time"
"github.com/shankar0123/certctl/internal/trustanchor"
)
// EST RFC 7030 hardening master bundle Phase 7.1.
//
// estCounterTab is the in-memory equivalent of a Prometheus
// `certctl_est_enrollments_total{status="..."}` metric. We don't take a
// Prometheus dependency here (the project doesn't expose /metrics today;
// that's a separate decision). The admin GUI's "EST Profiles" tab calls
// the GET /api/v1/admin/est/profiles endpoint, which calls
// ESTService.Stats() to render the counter snapshot.
//
// Concurrency: every field is read/written via sync/atomic so the
// service hot path stays lock-free.
// Counter labels — keep in sync with snapshot() + the admin GUI's
// counter-grid renderer. New labels MUST be added in three places:
// constants below, snapshot()'s map, and inc()'s switch.
const (
estCounterSuccessSimpleEnroll = "success_simpleenroll"
estCounterSuccessSimpleReEnroll = "success_simplereenroll"
estCounterSuccessServerKeygen = "success_serverkeygen"
estCounterAuthFailedBasic = "auth_failed_basic"
estCounterAuthFailedMTLS = "auth_failed_mtls"
estCounterAuthFailedChannelBind = "auth_failed_channel_binding"
estCounterCSRInvalid = "csr_invalid"
estCounterCSRPolicyViolation = "csr_policy_violation"
estCounterCSRSignatureMismatch = "csr_signature_mismatch"
estCounterRateLimited = "rate_limited"
estCounterIssuerError = "issuer_error"
estCounterInternalError = "internal_error"
)
type estCounterTab struct {
successSimpleEnroll atomic.Uint64
successSimpleReEnroll atomic.Uint64
successServerKeygen atomic.Uint64
authFailedBasic atomic.Uint64
authFailedMTLS atomic.Uint64
authFailedChannelBind atomic.Uint64
csrInvalid atomic.Uint64
csrPolicyViolation atomic.Uint64
csrSignatureMismatch atomic.Uint64
rateLimited atomic.Uint64
issuerError atomic.Uint64
internalError atomic.Uint64
}
// snapshot returns a zero-allocation copy of the current counter values
// keyed by the same label strings inc() accepts.
func (c *estCounterTab) snapshot() map[string]uint64 {
if c == nil {
return map[string]uint64{}
}
return map[string]uint64{
estCounterSuccessSimpleEnroll: c.successSimpleEnroll.Load(),
estCounterSuccessSimpleReEnroll: c.successSimpleReEnroll.Load(),
estCounterSuccessServerKeygen: c.successServerKeygen.Load(),
estCounterAuthFailedBasic: c.authFailedBasic.Load(),
estCounterAuthFailedMTLS: c.authFailedMTLS.Load(),
estCounterAuthFailedChannelBind: c.authFailedChannelBind.Load(),
estCounterCSRInvalid: c.csrInvalid.Load(),
estCounterCSRPolicyViolation: c.csrPolicyViolation.Load(),
estCounterCSRSignatureMismatch: c.csrSignatureMismatch.Load(),
estCounterRateLimited: c.rateLimited.Load(),
estCounterIssuerError: c.issuerError.Load(),
estCounterInternalError: c.internalError.Load(),
}
}
// inc advances the counter matching the given label. Unknown labels
// fall through to internal_error so an enum drift doesn't silently
// lose counts.
func (c *estCounterTab) inc(label string) {
if c == nil {
return
}
switch label {
case estCounterSuccessSimpleEnroll:
c.successSimpleEnroll.Add(1)
case estCounterSuccessSimpleReEnroll:
c.successSimpleReEnroll.Add(1)
case estCounterSuccessServerKeygen:
c.successServerKeygen.Add(1)
case estCounterAuthFailedBasic:
c.authFailedBasic.Add(1)
case estCounterAuthFailedMTLS:
c.authFailedMTLS.Add(1)
case estCounterAuthFailedChannelBind:
c.authFailedChannelBind.Add(1)
case estCounterCSRInvalid:
c.csrInvalid.Add(1)
case estCounterCSRPolicyViolation:
c.csrPolicyViolation.Add(1)
case estCounterCSRSignatureMismatch:
c.csrSignatureMismatch.Add(1)
case estCounterRateLimited:
c.rateLimited.Add(1)
case estCounterIssuerError:
c.issuerError.Add(1)
default:
c.internalError.Add(1)
}
}
// ESTStatsSnapshot is the per-profile observability view the admin
// GET endpoint renders. Mirrors IntuneStatsSnapshot's shape so the GUI
// can re-use the same counter-grid component.
//
// EST RFC 7030 hardening master bundle Phase 7.1.
type ESTStatsSnapshot struct {
PathID string `json:"path_id"`
IssuerID string `json:"issuer_id"`
ProfileID string `json:"profile_id,omitempty"`
Counters map[string]uint64 `json:"counters"`
MTLSEnabled bool `json:"mtls_enabled"`
BasicConfigured bool `json:"basic_auth_configured"`
ServerKeygen bool `json:"server_keygen_enabled"`
TrustAnchors []ESTTrustAnchorInfo `json:"trust_anchors,omitempty"`
TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
Now time.Time `json:"now"`
}
// ESTTrustAnchorInfo is the per-cert public summary of one trust anchor
// in the holder's pool. Same shape as IntuneTrustAnchorInfo.
type ESTTrustAnchorInfo struct {
Subject string `json:"subject"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysToExpiry int `json:"days_to_expiry"`
Expired bool `json:"expired"`
}
// Stats returns the per-profile observability snapshot. Safe for
// concurrent callers — every counter access is atomic + the trust-
// anchor walk is a per-snapshot copy.
func (s *ESTService) Stats(now time.Time) ESTStatsSnapshot {
out := ESTStatsSnapshot{
PathID: s.estPathIDForLog,
IssuerID: s.issuerID,
ProfileID: s.profileID,
Counters: s.counters.snapshot(),
MTLSEnabled: s.estMTLSConfigured,
BasicConfigured: s.estBasicConfigured,
ServerKeygen: s.estServerKeygenEnabled,
Now: now,
}
if s.estTrustAnchor != nil {
out.TrustAnchorPath = s.estTrustAnchor.Path()
for _, c := range s.estTrustAnchor.Get() {
daysToExpiry := int(c.NotAfter.Sub(now).Hours() / 24)
out.TrustAnchors = append(out.TrustAnchors, ESTTrustAnchorInfo{
Subject: c.Subject.CommonName,
NotBefore: c.NotBefore,
NotAfter: c.NotAfter,
DaysToExpiry: daysToExpiry,
Expired: now.After(c.NotAfter),
})
}
}
return out
}
// ReloadTrust forces a SIGHUP-equivalent reload of the per-profile
// EST mTLS trust anchor pool. Returns nil on success; the configured
// holder error otherwise (typically a parse error from a half-rotated
// bundle file). Mirror of SCEPService.ReloadIntuneTrust.
//
// Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS
// trust anchor configured (admin handler maps to HTTP 409).
func (s *ESTService) ReloadTrust() error {
if s.estTrustAnchor == nil {
return ErrESTMTLSDisabled
}
return s.estTrustAnchor.Reload()
}
// ErrESTMTLSDisabled signals the admin handler that an EST profile
// doesn't have mTLS configured. Maps to HTTP 409 Conflict.
var ErrESTMTLSDisabled = newESTAdminError("EST profile mTLS not enabled — no trust anchor to reload")
func newESTAdminError(msg string) error { return &estAdminError{msg: msg} }
type estAdminError struct{ msg string }
func (e *estAdminError) Error() string { return e.msg }
// SetESTAdminMetadata records the per-profile observability hints the
// AdminEST handler needs to render the Profiles tab. cmd/server/main.go
// invokes this once at startup with the data already in scope from the
// per-profile loop. Idempotent. Consolidated into one setter so the
// public surface stays narrow + every metadata field moves together.
func (s *ESTService) SetESTAdminMetadata(pathID string, mtlsEnabled, basicConfigured, serverKeygenEnabled bool, trustAnchor *trustanchor.Holder) {
s.estPathIDForLog = pathID
s.estMTLSConfigured = mtlsEnabled
s.estBasicConfigured = basicConfigured
s.estServerKeygenEnabled = serverKeygenEnabled
s.estTrustAnchor = trustAnchor
}