mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
fix(bundle-4): EST/SCEP Attack Surface Hardening — 3 audit findings closed
Closes 3 findings (1 High + 1 Medium + 1 Low) from
/Users/shankar/Desktop/cowork/comprehensive-audit-2026-04-25/.
Bundle 4 hardens the only attack surface reachable by an anonymous network
attacker in certctl: the unauthenticated EST + SCEP enrollment endpoints.
Findings closed:
- H-004 (High): Hand-rolled ASN.1 parser had no fuzz target.
The audit's original framing pointed at internal/pkcs7/, but recon
confirmed that package is an ASN.1 ENCODER (BuildCertsOnlyPKCS7,
ASN1Wrap*, ASN1EncodeLength) — not a parser. The actual hand-rolled
PKCS#7 PARSING reachable via anonymous network is in
internal/api/handler/scep.go::extractCSRFromPKCS7 +
parseSignedDataForCSR. Added native go fuzz targets:
* internal/api/handler/scep_fuzz_test.go::FuzzExtractCSRFromPKCS7
* internal/api/handler/scep_fuzz_test.go::FuzzParseSignedDataForCSR
* internal/pkcs7/pkcs7_fuzz_test.go::FuzzPEMToDERChain (defense-in-depth)
* internal/pkcs7/pkcs7_fuzz_test.go::FuzzASN1EncodeLength (defense-in-depth)
Local 15s fuzz session: 150k execs on FuzzExtractCSRFromPKCS7,
937k on FuzzPEMToDERChain, 925k on FuzzASN1EncodeLength — zero panics.
- M-021 (Medium): EST TLS-Unique channel binding (RFC 7030 §3.2.3).
Added internal/api/handler/est.go::verifyESTTransport — defense-in-depth
TLS pre-conditions (r.TLS != nil; HandshakeComplete; TLS ≥ 1.2).
The full §3.2.3 channel binding only applies when EST mTLS is in use;
certctl does not currently support EST mTLS, so the §3.2.3 requirement
is moot today. RFC 9266 (TLS 1.3 tls-exporter) and EST mTLS are
documented as deferred follow-ups in the verifyESTTransport doc comment.
- L-005 (Low): EST/SCEP issuer-binding fail-loud at startup.
Pre-Bundle-4 cmd/server/main.go validated that CERTCTL_EST_ISSUER_ID and
CERTCTL_SCEP_ISSUER_ID existed in the registry but did NOT validate the
issuer TYPE could emit a CA cert. An operator binding EST to an ACME
issuer (whose GetCACertPEM returns explicit error) booted successfully
and only failed at first /est/cacerts request. Post-Bundle-4: new
preflightEnrollmentIssuer helper calls GetCACertPEM(ctx) at startup
with a 10s timeout. Failure logs the connector error + the candidate
issuer types and os.Exit(1).
Tests added/modified:
- internal/api/handler/est_transport_test.go (new) — 5 verifyESTTransport
table cases covering plaintext-rejected, incomplete-handshake-rejected,
TLS 1.0 rejected, TLS 1.2/1.3 accepted
- cmd/server/preflight_test.go (new) — TestPreflightEnrollmentIssuer
covering nil-connector, error-from-issuer, empty-PEM, valid cases
- internal/api/handler/est_handler_test.go (modified) — 7 POST sites
now stamp r.TLS to satisfy the new transport pre-condition
- internal/integration/negative_test.go (modified) — setupTestServer
wraps the test handler with a fake-TLS-state injector so the EST
handler receives r.TLS != nil; production paths still rely on the
real TLS listener
Threat model reference: TB-11 (EST/SCEP client ↔ Server) per
cowork/comprehensive-audit-2026-04-25/threat-model.md.
Standards: RFC 7030 §3.2.3, RFC 8894 §3, RFC 5652, RFC 9266 (deferred).
This commit is contained in:
@@ -630,6 +630,17 @@ func main() {
|
||||
logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Bundle-4 / L-005: validate the issuer can actually serve a CA certificate
|
||||
// at startup, not at first request time. ACME / DigiCert / Sectigo etc.
|
||||
// return an error from GetCACertPEM because they don't expose a static
|
||||
// CA chain; binding EST to one of those would silently degrade enrollment.
|
||||
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := preflightEnrollmentIssuer(preflightCtx, "EST", cfg.EST.IssuerID, issuerConn); err != nil {
|
||||
preflightCancel()
|
||||
logger.Error("startup refused: EST issuer cannot serve CA certificate", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
preflightCancel()
|
||||
estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger)
|
||||
estService.SetProfileRepo(profileRepo)
|
||||
if cfg.EST.ProfileID != "" {
|
||||
@@ -668,6 +679,15 @@ func main() {
|
||||
logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Bundle-4 / L-005: validate the issuer can actually serve a CA certificate
|
||||
// at startup. Same rationale as EST above.
|
||||
preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", cfg.SCEP.IssuerID, issuerConn); err != nil {
|
||||
preflightCancel()
|
||||
logger.Error("startup refused: SCEP issuer cannot serve CA certificate", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
preflightCancel()
|
||||
scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword)
|
||||
scepService.SetProfileRepo(profileRepo)
|
||||
if cfg.SCEP.ProfileID != "" {
|
||||
@@ -981,6 +1001,43 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
||||
// can actually serve a CA certificate. This closes audit finding L-005:
|
||||
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
||||
// registry but did not verify the issuer TYPE could emit a CA cert. An
|
||||
// operator who bound CERTCTL_EST_ISSUER_ID to an ACME issuer (which does
|
||||
// not have a static CA cert — see internal/connector/issuer/acme/acme.go::
|
||||
// GetCACertPEM returning an explicit error) would boot successfully and
|
||||
// only see failures at the first /est/cacerts request, hiding the misconfig
|
||||
// for hours/days behind a degraded enrollment surface.
|
||||
//
|
||||
// Strategy: call issuerConn.GetCACertPEM(ctx) at startup with a short
|
||||
// timeout. If the issuer can serve a CA cert (local, vault, openssl,
|
||||
// stepca, awsacmpca, etc.), the call succeeds and we proceed. If not
|
||||
// (acme, digicert, sectigo, entrust, googlecas, ejbca, globalsign — most
|
||||
// vendor-CA issuers that hand back chains per-issuance), the call fails
|
||||
// loudly with the connector's own error string, and the caller os.Exit(1)s.
|
||||
//
|
||||
// Returns nil on success, non-nil error suitable for structured logging
|
||||
// + os.Exit(1) by the caller. Caller is responsible for the timeout context.
|
||||
func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, issuerConn service.IssuerConnector) error {
|
||||
if issuerConn == nil {
|
||||
return fmt.Errorf("%s issuer %q: connector is nil", protocol, issuerID)
|
||||
}
|
||||
caCertPEM, err := issuerConn.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s issuer %q: cannot serve CA certificate (%w); "+
|
||||
"choose an issuer type that exposes a static CA chain "+
|
||||
"(local / vault / openssl / stepca / awsacmpca) or disable %s",
|
||||
protocol, issuerID, err, protocol)
|
||||
}
|
||||
if caCertPEM == "" {
|
||||
return fmt.Errorf("%s issuer %q: GetCACertPEM returned empty PEM with no error; "+
|
||||
"choose an issuer type that exposes a static CA chain", protocol, issuerID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming
|
||||
// requests to either the authenticated apiHandler chain or the unauthenticated
|
||||
// noAuthHandler chain based on URL path prefix. Extracted from main() so the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
||||
type fakeIssuerConn struct {
|
||||
caCertPEM string
|
||||
caCertErr error
|
||||
}
|
||||
|
||||
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GenerateCRL(ctx context.Context, revokedCerts []service.CRLEntry) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) SignOCSPResponse(ctx context.Context, req service.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return f.caCertPEM, f.caCertErr
|
||||
}
|
||||
func (f *fakeIssuerConn) GetRenewalInfo(ctx context.Context, certPEM string) (*service.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestPreflightEnrollmentIssuer covers Bundle-4 / L-005 startup validation
|
||||
// for EST/SCEP issuer binding.
|
||||
func TestPreflightEnrollmentIssuer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issuer service.IssuerConnector
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "nil_connector_fails",
|
||||
issuer: nil,
|
||||
wantErr: true,
|
||||
errContains: "connector is nil",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_error_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertErr: errStub("ACME issuers do not provide a static CA certificate"),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "cannot serve CA certificate",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_empty_pem_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "empty PEM",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_valid_pem_succeeds",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := preflightEnrollmentIssuer(context.Background(), "EST", "iss-test", tc.issuer)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.wantErr && tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("error %q missing substring %q", err.Error(), tc.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errStub is a tiny error wrapper so test cases can use string literals
|
||||
// without importing fmt in every test struct entry.
|
||||
type errStub string
|
||||
|
||||
func (e errStub) Error() string { return string(e) }
|
||||
Reference in New Issue
Block a user