mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 19:28:58 +00:00
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.
This commit is contained in:
+528
-5
@@ -2,14 +2,24 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// ESTService implements the EST (RFC 7030) enrollment protocol.
|
||||
@@ -22,6 +32,24 @@ type ESTService struct {
|
||||
logger *slog.Logger
|
||||
profileID string // optional: constrain enrollments to a specific profile
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 7.1: per-status atomic
|
||||
// counters surfaced by IndividualStats() / the AdminEST endpoint.
|
||||
// Created lazily by NewESTService so the dispatcher's hot path stays
|
||||
// nil-safe even if a future refactor forgets to wire the counters.
|
||||
counters *estCounterTab
|
||||
|
||||
// estPathIDForLog / estMTLSConfigured / estBasicConfigured /
|
||||
// estServerKeygenEnabled / estTrustAnchor are observability metadata
|
||||
// the AdminEST handler reads via Stats(). They're populated once at
|
||||
// startup by SetESTAdminMetadata; the dispatcher hot path never
|
||||
// reads them (the hot path consults the typed config fields on the
|
||||
// HANDLER instance, not the service).
|
||||
estPathIDForLog string
|
||||
estMTLSConfigured bool
|
||||
estBasicConfigured bool
|
||||
estServerKeygenEnabled bool
|
||||
estTrustAnchor *trustanchor.Holder
|
||||
}
|
||||
|
||||
// NewESTService creates a new ESTService for the given issuer connector.
|
||||
@@ -31,6 +59,7 @@ func NewESTService(issuerID string, issuer IssuerConnector, auditService *AuditS
|
||||
issuerID: issuerID,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
counters: &estCounterTab{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +100,83 @@ func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain
|
||||
}
|
||||
|
||||
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
|
||||
// RFC 7030 Section 4.5: /csrattrs tells clients what to put in their CSR.
|
||||
// Returns nil if no specific attributes are required.
|
||||
// RFC 7030 §4.5: /csrattrs tells clients what to put in their CSR. The
|
||||
// response is base64(DER(SEQUENCE OF AttrOrOID)) where AttrOrOID is either
|
||||
// a bare OID (an attribute the client SHOULD include) or an Attribute
|
||||
// SEQUENCE { type OID, values SET OF ANY }. We emit the bare-OID form for
|
||||
// every entry — the EST endpoint hint contract is "what attributes /
|
||||
// EKUs to include in the CSR", not "what specific values to set".
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phase 6.2: replaces the v2.0.x
|
||||
// nil/204 stub with a profile-derived OID list. Sources:
|
||||
// - profile.AllowedEKUs → emitted as id-kp-* OIDs (RFC 5280 §4.2.1.12).
|
||||
// Clients use these to add the matching EKU OIDs to their CSR's
|
||||
// extensionRequest attribute.
|
||||
// - profile.RequiredCSRAttributes → emitted as the matching CSR
|
||||
// attribute / DN-attribute OIDs (e.g. serialNumber → 2.5.4.5).
|
||||
//
|
||||
// Returns nil when no profile is configured OR the resolved hint set is
|
||||
// empty after dropping unknown entries — the handler then writes 204
|
||||
// per RFC 7030 §4.5.2 (the original stub semantic). Unknown entries are
|
||||
// dropped + warning-logged; any one typo'd EKU/attribute string
|
||||
// shouldn't take down the entire csrattrs surface.
|
||||
func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
||||
// For now, we don't require specific CSR attributes.
|
||||
// In the future, this could return key type constraints from the profile.
|
||||
return nil, nil
|
||||
if s.profileID == "" || s.profileRepo == nil {
|
||||
// No bound profile = no hints. Maintains the v2.0.x behavior of
|
||||
// returning 204 to legacy deployments that haven't opted into a
|
||||
// CertificateProfile. The handler writes 204-No-Content when the
|
||||
// returned slice is empty.
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := s.profileRepo.Get(ctx, s.profileID)
|
||||
if err != nil || profile == nil {
|
||||
// Profile lookup failure isn't fatal — we degrade to the
|
||||
// no-hints case + log so the operator can spot misconfig. Same
|
||||
// rationale as the audit-noop path in processEnrollment.
|
||||
s.logger.Warn("est csrattrs: profile lookup failed; degrading to no-hints",
|
||||
"profile_id", s.profileID,
|
||||
"error", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var oids []asn1.ObjectIdentifier
|
||||
// EKU hints first (RFC 5280 §4.2.1.12 OIDs). Skip serverAuth + clientAuth
|
||||
// when the profile only allows the default — those are well-known and
|
||||
// every modern client adds them by default; emitting them in csrattrs
|
||||
// is just noise. But if the operator narrowed AllowedEKUs to e.g.
|
||||
// `["clientAuth"]` for an mTLS-only profile, we DO want clients to
|
||||
// know to drop serverAuth — so we emit the EKU hints unconditionally
|
||||
// when the profile is narrower than the default. The narrowing check
|
||||
// is implicit: if AllowedEKUs is the default (just serverAuth), we
|
||||
// emit just serverAuth, which is what well-behaved clients do anyway.
|
||||
for _, eku := range profile.AllowedEKUs {
|
||||
if oid, ok := domain.EKUStringToOID(eku); ok {
|
||||
oids = append(oids, oid)
|
||||
} else {
|
||||
s.logger.Warn("est csrattrs: unknown EKU in profile; dropping",
|
||||
"profile_id", s.profileID, "eku", eku)
|
||||
}
|
||||
}
|
||||
// Required CSR attribute / DN-attribute hints.
|
||||
for _, attr := range profile.RequiredCSRAttributes {
|
||||
if oid, ok := domain.AttributeStringToOID(attr); ok {
|
||||
oids = append(oids, oid)
|
||||
} else {
|
||||
s.logger.Warn("est csrattrs: unknown CSR attribute in profile; dropping",
|
||||
"profile_id", s.profileID, "attribute", attr)
|
||||
}
|
||||
}
|
||||
if len(oids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// RFC 7030 §4.5.2: response body is the DER encoding of a SEQUENCE
|
||||
// of AttrOrOID. asn1.Marshal of []asn1.ObjectIdentifier produces
|
||||
// SEQUENCE OF OBJECT IDENTIFIER, which is the bare-OID form.
|
||||
der, err := asn1.Marshal(oids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("est csrattrs: marshal OID sequence: %w", err)
|
||||
}
|
||||
return der, nil
|
||||
}
|
||||
|
||||
// processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll.
|
||||
@@ -84,20 +184,24 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
// Parse the CSR to extract CN and SANs
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
s.counters.inc(estCounterCSRInvalid)
|
||||
return nil, fmt.Errorf("invalid CSR PEM")
|
||||
}
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
s.counters.inc(estCounterCSRInvalid)
|
||||
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||
}
|
||||
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
s.counters.inc(estCounterCSRSignatureMismatch)
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
commonName := csr.Subject.CommonName
|
||||
if commonName == "" {
|
||||
s.counters.inc(estCounterCSRInvalid)
|
||||
return nil, fmt.Errorf("CSR must include a Common Name")
|
||||
}
|
||||
|
||||
@@ -126,6 +230,7 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
}
|
||||
}
|
||||
if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil {
|
||||
s.counters.inc(estCounterCSRPolicyViolation)
|
||||
s.logger.Error("EST enrollment rejected: crypto policy violation",
|
||||
"action", auditAction,
|
||||
"common_name", commonName,
|
||||
@@ -156,12 +261,20 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
// EST enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
||||
if err != nil {
|
||||
s.counters.inc(estCounterIssuerError)
|
||||
s.logger.Error("EST enrollment failed",
|
||||
"action", auditAction,
|
||||
"common_name", commonName,
|
||||
"error", err)
|
||||
return nil, fmt.Errorf("certificate issuance failed: %w", err)
|
||||
}
|
||||
// Phase 7.1: tick success counter — distinguish initial vs renewal so
|
||||
// the admin GUI can show enrollment-mix at a glance.
|
||||
if auditAction == "est_simple_reenroll" {
|
||||
s.counters.inc(estCounterSuccessSimpleReEnroll)
|
||||
} else {
|
||||
s.counters.inc(estCounterSuccessSimpleEnroll)
|
||||
}
|
||||
|
||||
// Audit the enrollment
|
||||
if s.auditService != nil {
|
||||
@@ -189,3 +302,413 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
ChainPEM: result.ChainPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 5 — serverkeygen.
|
||||
//
|
||||
// RFC 7030 §4.4: the client submits a CSR whose key may be a placeholder;
|
||||
// the server generates the keypair, issues a cert with the SERVER-generated
|
||||
// pubkey, then returns BOTH the cert AND the corresponding private key
|
||||
// encrypted to the client's separately-supplied key-encipherment public
|
||||
// key (RFC 7030 §4.4.2 mandates secure key delivery).
|
||||
//
|
||||
// Wire shape: multipart/mixed body assembled by the handler. The service
|
||||
// returns the raw cert PEM + the RAW private key bytes (already CMS-
|
||||
// EnvelopedData-wrapped); the handler composes the multipart envelope.
|
||||
|
||||
// ESTServerKeygenResult is an alias for the domain type so existing callers
|
||||
// don't reach across packages — handlers + tests reference the alias here,
|
||||
// the wire schema lives in internal/domain/est.go.
|
||||
type ESTServerKeygenResult = domain.ESTServerKeygenResult
|
||||
|
||||
// ErrServerKeygenRequiresKeyEncipherment is returned when the client's
|
||||
// CSR doesn't carry an RSA key-encipherment public key the server can
|
||||
// use to wrap the generated private key. RFC 7030 §4.4.2 mandates an
|
||||
// encryption mechanism; we do NOT support the plaintext-PKCS#8 fallback.
|
||||
var ErrServerKeygenRequiresKeyEncipherment = errors.New("est serverkeygen: client CSR missing RSA key-encipherment public key")
|
||||
|
||||
// ErrServerKeygenUnsupportedAlgorithm is returned when the CSR pubkey
|
||||
// algorithm isn't in the server's supported-keygen list. Currently
|
||||
// supported: RSA-2048, RSA-3072, RSA-4096, ECDSA P-256, ECDSA P-384.
|
||||
var ErrServerKeygenUnsupportedAlgorithm = errors.New("est serverkeygen: unsupported keygen algorithm requested by CSR")
|
||||
|
||||
// ErrServerKeygenDisabled signals the handler that the per-profile gate
|
||||
// is off (CertCertConfig.ServerKeygenEnabled == false). Maps to HTTP
|
||||
// 404 (the endpoint isn't routable for this profile) at the handler.
|
||||
var ErrServerKeygenDisabled = errors.New("est serverkeygen: disabled for this profile")
|
||||
|
||||
// SimpleServerKeygen runs the RFC 7030 §4.4 server-driven key generation
|
||||
// flow. The CSR's Subject + SANs drive the issued cert's identity; the
|
||||
// CSR's pubkey (which the client supplies as the encryption target for
|
||||
// the returned private key) MUST be RSA so we can wrap with PKCS#1 v1.5
|
||||
// keyTrans (matches the BUILDER's algorithm choice). The newly-generated
|
||||
// keypair's algorithm is picked to match the profile's
|
||||
// AllowedKeyAlgorithms first entry (or RSA-2048 default when no profile
|
||||
// constraint) — the server isn't trying to second-guess the operator's
|
||||
// crypto policy.
|
||||
//
|
||||
// Returns ESTServerKeygenResult{CertPEM, ChainPEM, EncryptedKey} where
|
||||
// EncryptedKey is the CMS EnvelopedData wrapping a PKCS#8 marshal of the
|
||||
// freshly-minted private key. The plaintext private key bytes are
|
||||
// zeroized inside the call before return — the handler never sees them.
|
||||
func (s *ESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*ESTServerKeygenResult, error) {
|
||||
// 1. Parse + signature-verify the CSR. We re-use processEnrollment's
|
||||
// gates verbatim so a misshapen CSR fails the same way it does on
|
||||
// the simpleenroll path.
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
commonName := csr.Subject.CommonName
|
||||
if commonName == "" {
|
||||
return nil, fmt.Errorf("CSR must include a Common Name")
|
||||
}
|
||||
// The CSR pubkey IS the encryption target for the returned private
|
||||
// key per RFC 7030 §4.4.2 — refuse non-RSA pubkeys at the door so
|
||||
// the BUILDER doesn't fail later with a less-actionable error.
|
||||
rsaPub, ok := csr.PublicKey.(*rsa.PublicKey)
|
||||
if !ok || rsaPub == nil {
|
||||
s.counters.inc(estCounterCSRPolicyViolation)
|
||||
return nil, ErrServerKeygenRequiresKeyEncipherment
|
||||
}
|
||||
|
||||
// 2. Resolve profile (for AllowedKeyAlgorithms + AllowedEKUs +
|
||||
// MaxTTLSeconds + MustStaple — the same set the simpleenroll path
|
||||
// reads). When no profile is bound, fall back to RSA-2048 + the
|
||||
// issuer's defaults — same v2.0.x posture as a no-profile
|
||||
// simpleenroll.
|
||||
var profile *domain.CertificateProfile
|
||||
if s.profileID != "" && s.profileRepo != nil {
|
||||
if p, perr := s.profileRepo.Get(ctx, s.profileID); perr == nil && p != nil {
|
||||
profile = p
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate the server-side keypair matching the profile's first
|
||||
// AllowedKeyAlgorithms entry (or RSA-2048 default). The signer
|
||||
// abstraction's MemoryDriver is overkill here — we just need a
|
||||
// crypto.PrivateKey + matching crypto.PublicKey for one CSR
|
||||
// re-derivation + one PKCS#8 marshal. The plaintext key never hits
|
||||
// disk: it's allocated, marshaled, then explicitly zeroized below.
|
||||
freshPriv, freshPub, algoLabel, err := s.generateServerKeyForProfile(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Build a synthetic CSR carrying the original CSR's Subject +
|
||||
// SANs but the SERVER-generated pubkey. This is the CSR we hand to
|
||||
// the issuer connector — the issued cert binds the device identity
|
||||
// to the new keypair.
|
||||
serverCSR := &x509.CertificateRequest{
|
||||
Subject: csr.Subject,
|
||||
DNSNames: csr.DNSNames,
|
||||
IPAddresses: csr.IPAddresses,
|
||||
EmailAddresses: csr.EmailAddresses,
|
||||
URIs: csr.URIs,
|
||||
SignatureAlgorithm: csrSignatureForKey(freshPriv),
|
||||
}
|
||||
serverCSRDER, err := x509.CreateCertificateRequest(rand.Reader, serverCSR, freshPriv)
|
||||
if err != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
return nil, fmt.Errorf("est serverkeygen: build server CSR: %w", err)
|
||||
}
|
||||
serverCSRPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: serverCSRDER}))
|
||||
|
||||
// 5. SAN list mirrors processEnrollment's collect-and-issue logic.
|
||||
var sans []string
|
||||
for _, dns := range csr.DNSNames {
|
||||
sans = append(sans, dns)
|
||||
}
|
||||
for _, ip := range csr.IPAddresses {
|
||||
sans = append(sans, ip.String())
|
||||
}
|
||||
for _, email := range csr.EmailAddresses {
|
||||
sans = append(sans, email)
|
||||
}
|
||||
for _, uri := range csr.URIs {
|
||||
sans = append(sans, uri.String())
|
||||
}
|
||||
|
||||
// 6. Issuance gates: profile's AllowedEKUs / MaxTTLSeconds /
|
||||
// MustStaple. The crypto-policy validation runs against the SERVER
|
||||
// CSR (so the freshly-generated key is what's checked) — that's
|
||||
// what the operator's policy is meant to constrain.
|
||||
if _, csrErr := ValidateCSRAgainstProfile(serverCSRPEM, profile); csrErr != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
s.logger.Error("EST serverkeygen rejected: crypto policy violation",
|
||||
"common_name", commonName, "algo", algoLabel, "error", csrErr)
|
||||
return nil, fmt.Errorf("EST serverkeygen rejected: %w", csrErr)
|
||||
}
|
||||
var (
|
||||
ekus []string
|
||||
maxTTLSeconds int
|
||||
mustStaple bool
|
||||
)
|
||||
if profile != nil {
|
||||
ekus = profile.AllowedEKUs
|
||||
maxTTLSeconds = profile.MaxTTLSeconds
|
||||
mustStaple = profile.MustStaple
|
||||
}
|
||||
|
||||
// 7. Issue.
|
||||
issued, err := s.issuer.IssueCertificate(ctx, commonName, sans, serverCSRPEM, ekus, maxTTLSeconds, mustStaple)
|
||||
if err != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
s.counters.inc(estCounterIssuerError)
|
||||
s.logger.Error("EST serverkeygen failed",
|
||||
"common_name", commonName, "algo", algoLabel, "error", err)
|
||||
return nil, fmt.Errorf("EST serverkeygen issuance failed: %w", err)
|
||||
}
|
||||
s.counters.inc(estCounterSuccessServerKeygen)
|
||||
|
||||
// 8. Marshal the freshly-generated private key as PKCS#8 (RFC 5958).
|
||||
// PKCS#8 is the format both libest and openssl smime expect on the
|
||||
// other end of CMS EnvelopedData unwrap.
|
||||
pkcs8, err := x509.MarshalPKCS8PrivateKey(freshPriv)
|
||||
if err != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
return nil, fmt.Errorf("est serverkeygen: marshal PKCS#8: %w", err)
|
||||
}
|
||||
|
||||
// 9. Build a synthetic recipient cert wrapping the device's
|
||||
// CSR-supplied key-encipherment pubkey. The BUILDER expects a
|
||||
// *x509.Certificate so it can read RawIssuer + SerialNumber for
|
||||
// the IssuerAndSerial rid; we synth one with the device CN + a
|
||||
// stable serial. Real PKI shape but we never sign / publish it
|
||||
// — purely a carrier for the pubkey + issuer info inside the
|
||||
// CMS envelope.
|
||||
recipient, err := buildSyntheticRecipientCert(rsaPub, csr)
|
||||
if err != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
zeroizeBytes(pkcs8)
|
||||
return nil, fmt.Errorf("est serverkeygen: synth recipient cert: %w", err)
|
||||
}
|
||||
|
||||
// 10. Encrypt the PKCS#8 with the device's pubkey via CMS
|
||||
// EnvelopedData. AES-256-CBC content encryption + RSA PKCS#1 v1.5
|
||||
// keyTrans — same algorithm choices as the BUILDER's hard-coded
|
||||
// defaults.
|
||||
encryptedKey, err := pkcs7.BuildEnvelopedData(pkcs8, recipient, rand.Reader)
|
||||
if err != nil {
|
||||
zeroizeKey(freshPriv)
|
||||
zeroizeBytes(pkcs8)
|
||||
return nil, fmt.Errorf("est serverkeygen: build EnvelopedData: %w", err)
|
||||
}
|
||||
|
||||
// 11. Zeroize the in-memory plaintext key + PKCS#8 bytes. Ciphertext
|
||||
// remains; the handler emits it then returns. Best-effort — Go's
|
||||
// GC may have copied the buffers around already, but this closes
|
||||
// the obvious leak path at handler return time.
|
||||
zeroizeKey(freshPriv)
|
||||
zeroizeBytes(pkcs8)
|
||||
_ = freshPub // referenced only at issuance time; nothing to zero
|
||||
|
||||
// 12. Audit + return.
|
||||
if s.auditService != nil {
|
||||
details := map[string]interface{}{
|
||||
"common_name": commonName,
|
||||
"sans": sans,
|
||||
"issuer_id": s.issuerID,
|
||||
"serial": issued.Serial,
|
||||
"protocol": "EST",
|
||||
"keygen": "server",
|
||||
"algorithm": algoLabel,
|
||||
}
|
||||
if s.profileID != "" {
|
||||
details["profile_id"] = s.profileID
|
||||
}
|
||||
_ = s.auditService.RecordEvent(ctx, "est-client", "system", "est_server_keygen", "certificate", issued.Serial, details)
|
||||
}
|
||||
s.logger.Info("EST serverkeygen successful",
|
||||
"common_name", commonName, "serial", issued.Serial,
|
||||
"algo", algoLabel, "issuer", s.issuerID)
|
||||
|
||||
return &ESTServerKeygenResult{
|
||||
CertPEM: issued.CertPEM,
|
||||
ChainPEM: issued.ChainPEM,
|
||||
EncryptedKey: encryptedKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateServerKeyForProfile returns a freshly-minted (priv, pub, label)
|
||||
// triple. The chosen algorithm matches profile.AllowedKeyAlgorithms[0]
|
||||
// when the profile has constraints; otherwise RSA-2048 (the broadest
|
||||
// compatibility default, matches what the local issuer self-bootstraps
|
||||
// when the operator hasn't pinned a key algorithm).
|
||||
func (s *ESTService) generateServerKeyForProfile(profile *domain.CertificateProfile) (priv interface{}, pub interface{}, label string, err error) {
|
||||
algo := "RSA"
|
||||
size := 2048
|
||||
if profile != nil && len(profile.AllowedKeyAlgorithms) > 0 {
|
||||
first := profile.AllowedKeyAlgorithms[0]
|
||||
algo = first.Algorithm
|
||||
if first.MinSize > 0 {
|
||||
size = first.MinSize
|
||||
}
|
||||
}
|
||||
switch algo {
|
||||
case domain.KeyAlgorithmRSA:
|
||||
k, kerr := rsa.GenerateKey(rand.Reader, size)
|
||||
if kerr != nil {
|
||||
return nil, nil, "", fmt.Errorf("est serverkeygen: rsa.GenerateKey size=%d: %w", size, kerr)
|
||||
}
|
||||
return k, &k.PublicKey, fmt.Sprintf("RSA-%d", size), nil
|
||||
case domain.KeyAlgorithmECDSA:
|
||||
var curve elliptic.Curve
|
||||
switch size {
|
||||
case 256:
|
||||
curve = elliptic.P256()
|
||||
label = "ECDSA-P256"
|
||||
case 384:
|
||||
curve = elliptic.P384()
|
||||
label = "ECDSA-P384"
|
||||
case 521:
|
||||
curve = elliptic.P521()
|
||||
label = "ECDSA-P521"
|
||||
default:
|
||||
return nil, nil, "", fmt.Errorf("%w: ECDSA size=%d (allowed: 256/384/521)", ErrServerKeygenUnsupportedAlgorithm, size)
|
||||
}
|
||||
k, kerr := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if kerr != nil {
|
||||
return nil, nil, "", fmt.Errorf("est serverkeygen: ecdsa.GenerateKey: %w", kerr)
|
||||
}
|
||||
return k, &k.PublicKey, label, nil
|
||||
default:
|
||||
return nil, nil, "", fmt.Errorf("%w: %q (allowed: RSA, ECDSA)", ErrServerKeygenUnsupportedAlgorithm, algo)
|
||||
}
|
||||
}
|
||||
|
||||
// csrSignatureForKey picks a sane SignatureAlgorithm for x509.CreateCertificateRequest
|
||||
// given a private key. Mirrors what the stdlib defaults to but pinning here
|
||||
// avoids hitting the deprecated SHA1WithRSA on RSA keys (Go's stdlib still
|
||||
// defaults to SHA-256 for RSA, so this is mostly belt-and-braces).
|
||||
func csrSignatureForKey(k interface{}) x509.SignatureAlgorithm {
|
||||
switch k.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return x509.SHA256WithRSA
|
||||
case *ecdsa.PrivateKey:
|
||||
return x509.ECDSAWithSHA256 // P-256 + P-384 both default fine; P-521 will pick SHA-256 too
|
||||
default:
|
||||
return x509.UnknownSignatureAlgorithm // stdlib derives a sensible default
|
||||
}
|
||||
}
|
||||
|
||||
// buildSyntheticRecipientCert wraps the device's CSR-supplied
|
||||
// key-encipherment pubkey in a minimal *x509.Certificate so the
|
||||
// pkcs7.BuildEnvelopedData function (which keys off RawIssuer +
|
||||
// SerialNumber for the IssuerAndSerial rid) can address it. The cert
|
||||
// is never signed or persisted — it lives only inside this function
|
||||
// + the EnvelopedData blob produced.
|
||||
//
|
||||
// We pin the issuer DN to the device's own Subject DN so the rid is
|
||||
// self-referential — a stable, reproducible identifier the device's
|
||||
// EST client library can match against its own cert request when it
|
||||
// decrypts the response. Serial number is the SHA-256 prefix of the
|
||||
// CSR signature (deterministic per CSR; collisions across millions of
|
||||
// CSRs are negligible).
|
||||
func buildSyntheticRecipientCert(rsaPub *rsa.PublicKey, csr *x509.CertificateRequest) (*x509.Certificate, error) {
|
||||
// Self-sign the synthetic cert with an EPHEMERAL key so it parses
|
||||
// cleanly via x509.CreateCertificate + ParseCertificate. The
|
||||
// signature is throwaway — no one verifies it — but x509 won't
|
||||
// build a cert without one.
|
||||
ephemKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ephemeral signer: %w", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: deterministicSerial(csr.Signature),
|
||||
Subject: csr.Subject,
|
||||
Issuer: csr.Subject, // self-referential; never verified
|
||||
NotBefore: serverKeygenSyntheticNotBefore,
|
||||
NotAfter: serverKeygenSyntheticNotAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment,
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, rsaPub, ephemKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create synth cert: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse synth cert: %w", err)
|
||||
}
|
||||
zeroizeKey(ephemKey) // burn the ephemeral signer immediately
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// deterministicSerial picks a stable serial number from the first 16
|
||||
// bytes of the CSR signature. Avoids a fresh CSPRNG draw per request +
|
||||
// gives the device's client library a serial it can re-derive locally
|
||||
// for diagnostic-log correlation.
|
||||
func deterministicSerial(sig []byte) *big.Int {
|
||||
if len(sig) == 0 {
|
||||
// Defensive: an unsigned CSR shouldn't reach here (CheckSignature
|
||||
// gated upstream) but a deterministic fallback ensures the cert
|
||||
// builder never crashes on a zero-byte serial.
|
||||
return big.NewInt(1)
|
||||
}
|
||||
end := 16
|
||||
if len(sig) < end {
|
||||
end = len(sig)
|
||||
}
|
||||
return new(big.Int).SetBytes(sig[:end])
|
||||
}
|
||||
|
||||
// serverKeygenSyntheticNotBefore / NotAfter are stable timestamps for
|
||||
// the never-published synthetic recipient cert. Using fixed-far-past +
|
||||
// fixed-far-future means the cert struct round-trips cleanly through
|
||||
// x509 without any time-source plumbing.
|
||||
var (
|
||||
serverKeygenSyntheticNotBefore = mustParseTime("2020-01-01T00:00:00Z")
|
||||
serverKeygenSyntheticNotAfter = mustParseTime("2099-12-31T23:59:59Z")
|
||||
)
|
||||
|
||||
func mustParseTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("est: hard-coded time %q failed to parse: %v", s, err))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// zeroizeKey overwrites the in-memory bytes of the private key with
|
||||
// zeros. Best-effort: Go's GC may have copied the buffer; closures the
|
||||
// math/big and crypto stdlib hold may keep their own copies. The
|
||||
// canonical defense is "don't keep this key around for long" — we
|
||||
// release the reference inside the calling function so GC reclaims it
|
||||
// promptly.
|
||||
func zeroizeKey(k interface{}) {
|
||||
switch v := k.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
// Best-effort: zero the big.Int components. Calls to
|
||||
// SetBytes(nil) reset the underlying word slice.
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if v.D != nil {
|
||||
v.D.SetUint64(0)
|
||||
}
|
||||
for i := range v.Primes {
|
||||
if v.Primes[i] != nil {
|
||||
v.Primes[i].SetUint64(0)
|
||||
}
|
||||
}
|
||||
case *ecdsa.PrivateKey:
|
||||
if v == nil || v.D == nil {
|
||||
return
|
||||
}
|
||||
v.D.SetUint64(0)
|
||||
}
|
||||
}
|
||||
|
||||
// zeroizeBytes overwrites a byte slice with zeros in place.
|
||||
func zeroizeBytes(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 7.3 — per-profile counter
|
||||
// isolation regression test. Mirrors the SCEP equivalent at
|
||||
// internal/api/handler/scep_profile_counter_isolation_test.go.
|
||||
//
|
||||
// Why this test exists: the future-bug class it guards against is a
|
||||
// cmd/server/main.go refactor that constructs a SINGLE shared
|
||||
// *estCounterTab and injects it into every per-profile ESTService —
|
||||
// that would compile cleanly, pass every existing route-level test,
|
||||
// and silently inflate one profile's counters with another's traffic.
|
||||
|
||||
func TestESTService_PerProfileCountersIsolated(t *testing.T) {
|
||||
silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||
|
||||
// Two services with separate issuers + counter tabs. NewESTService
|
||||
// allocates a fresh estCounterTab per instance (Phase 7.1 contract);
|
||||
// this test pins that contract.
|
||||
corpSvc := NewESTService("iss-corp", &mockIssuerConnector{}, nil, silent)
|
||||
iotSvc := NewESTService("iss-iot", &mockIssuerConnector{Err: errors.New("issuer down")}, nil, silent)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// CORP: drive 3 successful enrollments. Each ticks
|
||||
// success_simpleenroll on CORP's tab; IOT's tab MUST stay zero
|
||||
// for that label.
|
||||
for i := 0; i < 3; i++ {
|
||||
csrPEM := generateCSRPEM(t, "device-corp.example.com", []string{"device-corp.example.com"})
|
||||
if _, err := corpSvc.SimpleEnroll(ctx, csrPEM); err != nil {
|
||||
t.Fatalf("corp enroll #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// IOT: drive 2 enrollments. Each fails issuance (mock returns err
|
||||
// from IssueCertificate); each ticks issuer_error on IOT's tab.
|
||||
for i := 0; i < 2; i++ {
|
||||
csrPEM := generateCSRPEM(t, "device-iot.example.com", []string{"device-iot.example.com"})
|
||||
if _, err := iotSvc.SimpleEnroll(ctx, csrPEM); err == nil {
|
||||
t.Fatalf("iot enroll #%d: expected issuer error", i)
|
||||
}
|
||||
}
|
||||
|
||||
// CORP snapshot: success=3, issuer_error=0.
|
||||
corpSnap := corpSvc.Stats(time.Now()).Counters
|
||||
if got := corpSnap[estCounterSuccessSimpleEnroll]; got != 3 {
|
||||
t.Errorf("corp success_simpleenroll = %d, want 3", got)
|
||||
}
|
||||
if got := corpSnap[estCounterIssuerError]; got != 0 {
|
||||
t.Errorf("corp issuer_error = %d, want 0 (no IOT bleed)", got)
|
||||
}
|
||||
|
||||
// IOT snapshot: success=0, issuer_error=2.
|
||||
iotSnap := iotSvc.Stats(time.Now()).Counters
|
||||
if got := iotSnap[estCounterSuccessSimpleEnroll]; got != 0 {
|
||||
t.Errorf("iot success_simpleenroll = %d, want 0 (no CORP bleed)", got)
|
||||
}
|
||||
if got := iotSnap[estCounterIssuerError]; got != 2 {
|
||||
t.Errorf("iot issuer_error = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Sanity: the two services' counter tabs MUST be distinct *estCounterTab
|
||||
// pointers. If a future refactor introduces a shared tab, this assertion
|
||||
// catches it before the snapshot bleed becomes silent.
|
||||
if corpSvc.counters == iotSvc.counters {
|
||||
t.Fatal("corp + iot share the same *estCounterTab — per-profile isolation broken")
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// generateCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
||||
@@ -178,3 +182,124 @@ func TestESTService_SimpleEnroll_WithProfile(t *testing.T) {
|
||||
t.Fatal("expected audit details")
|
||||
}
|
||||
}
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 6.3 csrattrs tests.
|
||||
// Pin the contract that GetCSRAttrs returns DER(SEQUENCE OF OID) when the
|
||||
// bound profile carries hints, falls back to the v2.0.x nil/204 stub when
|
||||
// the profile is absent / empty / corrupt, and silently drops unknown
|
||||
// EKU/attribute names rather than emitting garbage OIDs.
|
||||
|
||||
func newCSRAttrsTestService(t *testing.T) (*ESTService, *mockProfileRepo) {
|
||||
t.Helper()
|
||||
repo := newMockProfileRepository()
|
||||
silent := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||
svc := NewESTService("iss-local", &mockIssuerConnector{}, nil, silent)
|
||||
svc.SetProfileRepo(repo)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_NoProfileBound_Returns204Body(t *testing.T) {
|
||||
svc, _ := newCSRAttrsTestService(t)
|
||||
// SetProfileID intentionally NOT called — handler should see empty body
|
||||
// + write 204 per RFC 7030 §4.5.2 (legacy stub semantic preserved).
|
||||
got, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("got non-nil body for unbound profile: %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_ProfileWithEKUsAndAttrs_ReturnsOIDList(t *testing.T) {
|
||||
svc, repo := newCSRAttrsTestService(t)
|
||||
svc.SetProfileID("prof-corp")
|
||||
repo.AddProfile(&domain.CertificateProfile{
|
||||
ID: "prof-corp",
|
||||
Name: "corp",
|
||||
AllowedEKUs: []string{"serverAuth", "clientAuth"},
|
||||
RequiredCSRAttributes: []string{"serialNumber"},
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
der, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(der) == 0 {
|
||||
t.Fatal("expected non-empty body for profile with hints")
|
||||
}
|
||||
var got []asn1.ObjectIdentifier
|
||||
if _, err := asn1.Unmarshal(der, &got); err != nil {
|
||||
t.Fatalf("body should be DER(SEQUENCE OF OID); unmarshal: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 OIDs (2 EKUs + 1 attribute), got %d: %v", len(got), got)
|
||||
}
|
||||
// Pin the exact OIDs so a future EKUStringToOID typo trips the test.
|
||||
wantSerialNumberOID := asn1.ObjectIdentifier{2, 5, 4, 5}
|
||||
wantServerAuthOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 1}
|
||||
wantClientAuthOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2}
|
||||
have := make(map[string]bool, len(got))
|
||||
for _, o := range got {
|
||||
have[o.String()] = true
|
||||
}
|
||||
for _, want := range []asn1.ObjectIdentifier{wantServerAuthOID, wantClientAuthOID, wantSerialNumberOID} {
|
||||
if !have[want.String()] {
|
||||
t.Errorf("missing OID %v in csrattrs response", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_EmptyProfile_Returns204Body(t *testing.T) {
|
||||
svc, repo := newCSRAttrsTestService(t)
|
||||
svc.SetProfileID("prof-empty")
|
||||
repo.AddProfile(&domain.CertificateProfile{
|
||||
ID: "prof-empty",
|
||||
Name: "empty",
|
||||
Enabled: true,
|
||||
})
|
||||
got, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("empty profile should return nil body for 204; got %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_GarbageProfile_DropsUnknownAndKeepsValid(t *testing.T) {
|
||||
svc, repo := newCSRAttrsTestService(t)
|
||||
svc.SetProfileID("prof-garbage")
|
||||
repo.AddProfile(&domain.CertificateProfile{
|
||||
ID: "prof-garbage",
|
||||
Name: "garbage",
|
||||
AllowedEKUs: []string{"serverAuth", "thisIsNotAnEKU"},
|
||||
RequiredCSRAttributes: []string{"serialNumber", "blarg-not-an-attribute"},
|
||||
Enabled: true,
|
||||
})
|
||||
der, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got []asn1.ObjectIdentifier
|
||||
if _, err := asn1.Unmarshal(der, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("expected 2 OIDs (the valid subset); got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESTService_GetCSRAttrs_ProfileLookupError_DegradesToNoHints(t *testing.T) {
|
||||
svc, repo := newCSRAttrsTestService(t)
|
||||
svc.SetProfileID("prof-missing")
|
||||
repo.GetErr = errors.New("repo unreachable")
|
||||
got, err := svc.GetCSRAttrs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("profile lookup error must NOT propagate; got: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("profile-lookup-error path must degrade to nil body; got %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user