diff --git a/cmd/server/main.go b/cmd/server/main.go index 0133ce9..c31c157 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/cmd/server/preflight_test.go b/cmd/server/preflight_test.go new file mode 100644 index 0000000..8f32ccd --- /dev/null +++ b/cmd/server/preflight_test.go @@ -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) } diff --git a/internal/api/handler/est.go b/internal/api/handler/est.go index 36d9e61..9ea80fc 100644 --- a/internal/api/handler/est.go +++ b/internal/api/handler/est.go @@ -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) { diff --git a/internal/api/handler/est_handler_test.go b/internal/api/handler/est_handler_test.go index 11a33b9..e4433de 100644 --- a/internal/api/handler/est_handler_test.go +++ b/internal/api/handler/est_handler_test.go @@ -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) diff --git a/internal/api/handler/est_transport_test.go b/internal/api/handler/est_transport_test.go new file mode 100644 index 0000000..9c002dc --- /dev/null +++ b/internal/api/handler/est_transport_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/handler/scep_fuzz_test.go b/internal/api/handler/scep_fuzz_test.go new file mode 100644 index 0000000..7060029 --- /dev/null +++ b/internal/api/handler/scep_fuzz_test.go @@ -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 +} diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index e30ce4f..e914628 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -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 diff --git a/internal/pkcs7/pkcs7_fuzz_test.go b/internal/pkcs7/pkcs7_fuzz_test.go new file mode 100644 index 0000000..caa01dc --- /dev/null +++ b/internal/pkcs7/pkcs7_fuzz_test.go @@ -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)) + } + }) +}