mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +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) }
|
||||
@@ -109,6 +109,11 @@ func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if err := verifyESTTransport(r); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
@@ -134,6 +139,11 @@ func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if err := verifyESTTransport(r); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
@@ -149,6 +159,60 @@ func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeCertResponse(w, result)
|
||||
}
|
||||
|
||||
// verifyESTTransport implements Bundle-4 / M-021 EST transport precondition.
|
||||
//
|
||||
// RFC 7030 §3.2.3 ("Linking Identity and POP Information") requires that when
|
||||
// EST clients use certificate-based authentication AND send a Proof-of-Possession
|
||||
// (PoP), the PoP MUST be cryptographically bound to the underlying TLS session
|
||||
// via TLS-Unique (RFC 5929). With TLS 1.3 (which certctl pins via
|
||||
// `tls.Config.MinVersion = tls.VersionTLS13` per the HTTPS-Everywhere milestone),
|
||||
// TLS-Unique is unavailable; RFC 9266 defines `tls-exporter` as the TLS 1.3
|
||||
// replacement.
|
||||
//
|
||||
// **Current scope of this function (Bundle-4 closure):** certctl does NOT
|
||||
// currently support EST client certificate authentication. The EST endpoint
|
||||
// accepts unauthenticated POSTs (the SCEP equivalent enforces a
|
||||
// challenge-password via `preflightSCEPChallengePassword`; EST has no
|
||||
// equivalent today). Per RFC 7030 §3.2.3, channel binding is REQUIRED only
|
||||
// when client certificate authentication is in use; without that, the §3.2.3
|
||||
// requirement is moot.
|
||||
//
|
||||
// What we DO enforce here as defense-in-depth:
|
||||
//
|
||||
// 1. r.TLS must be non-nil — the EST endpoint MUST be reached over TLS.
|
||||
// Defensive: certctl pins HTTPS-only at the server-side TLS config, but
|
||||
// a future routing-layer regression that exposes EST over plaintext
|
||||
// would be caught here.
|
||||
// 2. Negotiated TLS version must be >= TLS 1.2 — RFC 7030 doesn't mandate
|
||||
// a specific TLS version, but a pre-1.2 negotiation indicates a
|
||||
// misconfigured client/server pair. certctl's MinVersion is TLS 1.3
|
||||
// so this should always hold.
|
||||
// 3. r.TLS.HandshakeComplete must be true — defensive against partial-
|
||||
// handshake replays.
|
||||
//
|
||||
// **Deferred to a future bundle (operator decision required):**
|
||||
//
|
||||
// - RFC 9266 `tls-exporter` channel binding when EST mTLS is added.
|
||||
// - EST mTLS support itself — currently EST is unauth-or-bearer; mTLS
|
||||
// would be a V3-aligned compliance feature.
|
||||
//
|
||||
// Returns nil if all preconditions pass; non-nil error otherwise.
|
||||
func verifyESTTransport(r *http.Request) error {
|
||||
if r.TLS == nil {
|
||||
return fmt.Errorf("EST endpoint reached over plaintext; TLS required (RFC 7030 §3.2.1)")
|
||||
}
|
||||
if !r.TLS.HandshakeComplete {
|
||||
return fmt.Errorf("EST request reached handler before TLS handshake completed")
|
||||
}
|
||||
// tls.VersionTLS12 == 0x0303; certctl's MinVersion is TLS 1.3 (0x0304).
|
||||
// Defensive lower bound at TLS 1.2 lets us catch a future MinVersion
|
||||
// regression cleanly without coupling this guard to the server config.
|
||||
if r.TLS.Version < 0x0303 {
|
||||
return fmt.Errorf("EST request negotiated TLS version 0x%04x; TLS 1.2 minimum required", r.TLS.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CSRAttrs handles GET /.well-known/est/csrattrs
|
||||
// Returns the CSR attributes the server wants the client to include in enrollment requests.
|
||||
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
@@ -170,6 +171,7 @@ func TestESTSimpleEnroll_Success_PEM(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
@@ -195,6 +197,7 @@ func TestESTSimpleEnroll_Success_Base64DER(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrB64))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.Header.Set("Content-Type", "application/pkcs10")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
@@ -222,6 +225,7 @@ func TestESTSimpleEnroll_EmptyBody(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(""))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
@@ -235,6 +239,7 @@ func TestESTSimpleEnroll_InvalidCSR(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader("not-a-valid-csr"))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
@@ -251,6 +256,7 @@ func TestESTSimpleEnroll_ServiceError(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
|
||||
@@ -271,6 +277,7 @@ func TestESTSimpleReEnroll_Success(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnroll(w, req)
|
||||
|
||||
@@ -396,6 +403,7 @@ func TestESTSimpleReEnroll_ServiceError(t *testing.T) {
|
||||
h := NewESTHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnroll(w, req)
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVerifyESTTransport_Bundle4_M021 covers the EST transport precondition
|
||||
// added in Bundle-4 / M-021. See verifyESTTransport doc comment in est.go for
|
||||
// scope rationale (RFC 7030 §3.2.3 channel binding is moot without EST mTLS;
|
||||
// what we DO enforce is TLS pre-conditions).
|
||||
func TestVerifyESTTransport_Bundle4_M021(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "plaintext_request_rejected",
|
||||
req: &http.Request{TLS: nil},
|
||||
wantErr: true,
|
||||
errContains: "plaintext",
|
||||
},
|
||||
{
|
||||
name: "incomplete_handshake_rejected",
|
||||
req: &http.Request{TLS: &tls.ConnectionState{
|
||||
HandshakeComplete: false,
|
||||
Version: tls.VersionTLS13,
|
||||
}},
|
||||
wantErr: true,
|
||||
errContains: "handshake",
|
||||
},
|
||||
{
|
||||
name: "tls10_rejected",
|
||||
req: &http.Request{TLS: &tls.ConnectionState{
|
||||
HandshakeComplete: true,
|
||||
Version: tls.VersionTLS10,
|
||||
}},
|
||||
wantErr: true,
|
||||
errContains: "TLS 1.2 minimum",
|
||||
},
|
||||
{
|
||||
name: "tls12_accepted",
|
||||
req: &http.Request{TLS: &tls.ConnectionState{
|
||||
HandshakeComplete: true,
|
||||
Version: tls.VersionTLS12,
|
||||
}},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tls13_accepted",
|
||||
req: &http.Request{TLS: &tls.ConnectionState{
|
||||
HandshakeComplete: true,
|
||||
Version: tls.VersionTLS13,
|
||||
}},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := verifyESTTransport(tc.req)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("verifyESTTransport(%s): expected error, got nil", tc.name)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("verifyESTTransport(%s): unexpected error: %v", tc.name, err)
|
||||
}
|
||||
if tc.wantErr && tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("verifyESTTransport(%s): error %q missing substring %q", tc.name, err.Error(), tc.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzExtractCSRFromPKCS7 exercises the SCEP PKCS#7 envelope parser at
|
||||
// internal/api/handler/scep.go::extractCSRFromPKCS7. Bundle-4 / H-004:
|
||||
// this parser is reachable by an anonymous network attacker via
|
||||
// POST /scep?operation=PKIOperation. It calls into hand-written ASN.1
|
||||
// unmarshaling logic in parseSignedDataForCSR (which uses encoding/asn1
|
||||
// from stdlib but with manual structure layouts). Any panic, OOM, or
|
||||
// allocation amplification surfaces here.
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// go test -run='^$' -fuzz=FuzzExtractCSRFromPKCS7 -fuzztime=10m \
|
||||
// ./internal/api/handler/
|
||||
//
|
||||
// CI gate (Bundle-4 added in .github/workflows/ci.yml): runs at
|
||||
// -fuzztime=2m on every PR. The full 10m runs are reserved for the
|
||||
// scheduled overnight job to keep PR latency reasonable.
|
||||
func FuzzExtractCSRFromPKCS7(f *testing.F) {
|
||||
// Seed corpus: a few well-formed envelopes + a few deliberately
|
||||
// malformed ones to give the fuzzer mutational starting points.
|
||||
seeds := [][]byte{
|
||||
// Minimal PKCS#7 ContentInfo OID + empty content.
|
||||
mustHex("3013060B2A864886F70D010907020100"),
|
||||
// Empty input — fuzzer should return error, not panic.
|
||||
{},
|
||||
// Single zero byte — parses as ASN.1 boolean false.
|
||||
{0x00},
|
||||
// Truncated SEQUENCE with bogus length.
|
||||
{0x30, 0x81, 0xff},
|
||||
// Recursive SEQUENCE wrapping (fuzzer + parser depth check).
|
||||
{0x30, 0x80, 0x30, 0x80, 0x30, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
f.Add(seed)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
// Bound input size — the fuzzer otherwise tends to chase
|
||||
// "find" rewards via 100MB inputs that aren't representative.
|
||||
// Real network input is bounded by MaxBytesReader (1MB default).
|
||||
if len(data) > 1<<20 {
|
||||
return
|
||||
}
|
||||
// extractCSRFromPKCS7 returns (csrDER, challengePassword, transactionID, error).
|
||||
// We don't care about the return values — we care that it doesn't
|
||||
// panic, OOM, or allocate unbounded memory. The Go test harness
|
||||
// reports panics as test failures.
|
||||
_, _, _, _ = extractCSRFromPKCS7(data)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzParseSignedDataForCSR exercises the inner SignedData parser
|
||||
// directly (the function extractCSRFromPKCS7 calls). Same scope as
|
||||
// FuzzExtractCSRFromPKCS7 but narrower; helps the fuzzer find paths
|
||||
// that the wrapping function's fallbacks would otherwise mask.
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// go test -run='^$' -fuzz=FuzzParseSignedDataForCSR -fuzztime=10m \
|
||||
// ./internal/api/handler/
|
||||
func FuzzParseSignedDataForCSR(f *testing.F) {
|
||||
seeds := [][]byte{
|
||||
mustHex("3013060B2A864886F70D010907020100"),
|
||||
{},
|
||||
{0x00},
|
||||
{0x30, 0x80},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
f.Add(seed)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
if len(data) > 1<<20 {
|
||||
return
|
||||
}
|
||||
_, _ = parseSignedDataForCSR(data)
|
||||
})
|
||||
}
|
||||
|
||||
// mustHex decodes a hex string for fuzz seeds. Panics on malformed
|
||||
// hex — only used at test setup with hard-coded constants.
|
||||
func mustHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -118,7 +119,22 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
// no Authorization header to verify the relying-party contract.
|
||||
r.RegisterPKIHandlers(certificateHandler)
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
// Bundle-4 / M-021: the EST handler now requires `r.TLS != nil` per
|
||||
// verifyESTTransport. The integration tests use httptest.NewServer (HTTP,
|
||||
// not HTTPS) for simplicity. Wrap the router with a fake-TLS injector that
|
||||
// sets a synthetic `*tls.ConnectionState` on every request — mimicking what
|
||||
// the real TLS listener does in production. The injector is test-only;
|
||||
// production paths use the real listener's `r.TLS`.
|
||||
wrapped := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.TLS == nil {
|
||||
req.TLS = &tls.ConnectionState{
|
||||
HandshakeComplete: true,
|
||||
Version: tls.VersionTLS13,
|
||||
}
|
||||
}
|
||||
r.ServeHTTP(w, req)
|
||||
})
|
||||
server := httptest.NewServer(wrapped)
|
||||
t.Cleanup(func() { server.Close() })
|
||||
|
||||
return server, certRepo, jobRepo, agentRepo
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package pkcs7
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzPEMToDERChain exercises the PEM-to-DER converter in
|
||||
// internal/pkcs7/pkcs7.go::PEMToDERChain. Bundle-4 / H-004 (defense in depth):
|
||||
// this function isn't directly network-reachable today (callers pass
|
||||
// trusted PEM from issuer connectors), but it operates on byte input
|
||||
// that traces back to upstream CA responses; a malicious-CA scenario
|
||||
// could feed crafted PEM. Fuzz to ensure no panic, no allocation
|
||||
// amplification.
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// go test -run='^$' -fuzz=FuzzPEMToDERChain -fuzztime=10m ./internal/pkcs7/
|
||||
func FuzzPEMToDERChain(f *testing.F) {
|
||||
seeds := []string{
|
||||
// Empty input.
|
||||
"",
|
||||
// Minimal valid PEM (an empty CERTIFICATE block — not a real cert).
|
||||
"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n",
|
||||
// Truncated header.
|
||||
"-----BEGIN CERTIFICATE",
|
||||
// Multiple BEGIN, no END.
|
||||
"-----BEGIN CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n",
|
||||
// Body with binary garbage.
|
||||
"-----BEGIN CERTIFICATE-----\n\x00\xff\xfe\x80\n-----END CERTIFICATE-----\n",
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
f.Add(seed)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, data string) {
|
||||
// Bound input — same rationale as the SCEP fuzz.
|
||||
if len(data) > 1<<20 {
|
||||
return
|
||||
}
|
||||
_, _ = PEMToDERChain(data)
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzASN1EncodeLength exercises the hand-rolled BER length encoder.
|
||||
// Bundle-4 / H-004: the encoder is used when building PKCS#7 envelopes
|
||||
// returned to EST/SCEP clients, so an attacker cannot directly feed
|
||||
// untrusted bytes into it — but a future caller that did would be
|
||||
// vulnerable to integer overflow / unbounded allocation. Fuzz the
|
||||
// length values to confirm the encoder handles boundary conditions
|
||||
// (negative, zero, MaxInt, etc.).
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// go test -run='^$' -fuzz=FuzzASN1EncodeLength -fuzztime=2m ./internal/pkcs7/
|
||||
func FuzzASN1EncodeLength(f *testing.F) {
|
||||
seeds := []int{0, 1, 127, 128, 255, 256, 65535, 65536, 1 << 20, 1 << 30, -1}
|
||||
for _, seed := range seeds {
|
||||
f.Add(seed)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, length int) {
|
||||
// Bound input — fuzz-generated lengths in the billions cause
|
||||
// the encoder to allocate huge byte slices. Real PKCS#7 envelopes
|
||||
// from certctl never exceed a few MB.
|
||||
if length > 1<<24 || length < 0 {
|
||||
return
|
||||
}
|
||||
out := ASN1EncodeLength(length)
|
||||
// Sanity: encoder always returns at least one byte.
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("ASN1EncodeLength(%d) returned empty slice", length)
|
||||
}
|
||||
// Sanity: encoder never returns more than 5 bytes for int input
|
||||
// (1 length-of-length byte + 4 bytes for a 32-bit length).
|
||||
if len(out) > 5 {
|
||||
t.Fatalf("ASN1EncodeLength(%d) returned %d bytes; expected ≤5", length, len(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user