mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:51:30 +00:00
1c099071d1
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).
429 lines
12 KiB
Go
429 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
|
)
|
|
|
|
// mockESTService implements ESTService for testing.
|
|
type mockESTService struct {
|
|
CACertPEM string
|
|
CACertErr error
|
|
EnrollResult *domain.ESTEnrollResult
|
|
EnrollErr error
|
|
CSRAttrs []byte
|
|
CSRAttrsErr error
|
|
}
|
|
|
|
func (m *mockESTService) GetCACerts(ctx context.Context) (string, error) {
|
|
return m.CACertPEM, m.CACertErr
|
|
}
|
|
|
|
func (m *mockESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
return m.EnrollResult, m.EnrollErr
|
|
}
|
|
|
|
func (m *mockESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
return m.EnrollResult, m.EnrollErr
|
|
}
|
|
|
|
func (m *mockESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
|
return m.CSRAttrs, m.CSRAttrsErr
|
|
}
|
|
|
|
// generateTestCSRPEM creates a valid ECDSA P-256 CSR for testing.
|
|
func generateTestCSRPEM(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
template := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
|
DNSNames: []string{"test.example.com", "www.example.com"},
|
|
}
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to create CSR: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
|
}
|
|
|
|
// generateTestCSRBase64DER creates a valid base64-encoded DER CSR for EST wire format.
|
|
func generateTestCSRBase64DER(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
template := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
|
DNSNames: []string{"test.example.com"},
|
|
}
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to create CSR: %v", err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(csrDER)
|
|
}
|
|
|
|
// generateTestCertPEM creates a real self-signed certificate PEM for testing.
|
|
func generateTestCertPEM(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "Test CA"},
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
IsCA: true,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to create certificate: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
|
}
|
|
|
|
func TestESTCACerts_Success(t *testing.T) {
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockESTService{
|
|
CACertPEM: certPEM,
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
|
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
|
|
}
|
|
cte := w.Header().Get("Content-Transfer-Encoding")
|
|
if cte != "base64" {
|
|
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
|
|
}
|
|
}
|
|
|
|
func TestESTCACerts_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTCACerts_ServiceError(t *testing.T) {
|
|
svc := &mockESTService{
|
|
CACertErr: errors.New("issuer unavailable"),
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_Success_PEM(t *testing.T) {
|
|
csrPEM := generateTestCSRPEM(t)
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockESTService{
|
|
EnrollResult: &domain.ESTEnrollResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: certPEM,
|
|
},
|
|
}
|
|
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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if !strings.Contains(ct, "application/pkcs7-mime") {
|
|
t.Errorf("expected application/pkcs7-mime, got %s", ct)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_Success_Base64DER(t *testing.T) {
|
|
csrB64 := generateTestCSRBase64DER(t)
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockESTService{
|
|
EnrollResult: &domain.ESTEnrollResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: "",
|
|
},
|
|
}
|
|
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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simpleenroll", nil)
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_EmptyBody(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
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)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_InvalidCSR(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
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)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleEnroll_ServiceError(t *testing.T) {
|
|
csrPEM := generateTestCSRPEM(t)
|
|
svc := &mockESTService{
|
|
EnrollErr: errors.New("issuance failed"),
|
|
}
|
|
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)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleReEnroll_Success(t *testing.T) {
|
|
csrPEM := generateTestCSRPEM(t)
|
|
certPEM := generateTestCertPEM(t)
|
|
svc := &mockESTService{
|
|
EnrollResult: &domain.ESTEnrollResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: certPEM,
|
|
},
|
|
}
|
|
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)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleReEnroll_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simplereenroll", nil)
|
|
w := httptest.NewRecorder()
|
|
h.SimpleReEnroll(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTCSRAttrs_NoContent(t *testing.T) {
|
|
svc := &mockESTService{
|
|
CSRAttrs: nil,
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CSRAttrs(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected 204, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTCSRAttrs_WithData(t *testing.T) {
|
|
svc := &mockESTService{
|
|
CSRAttrs: []byte{0x30, 0x00}, // empty SEQUENCE
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CSRAttrs(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
ct := w.Header().Get("Content-Type")
|
|
if ct != "application/csrattrs" {
|
|
t.Errorf("expected application/csrattrs, got %s", ct)
|
|
}
|
|
}
|
|
|
|
func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockESTService{}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/csrattrs", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CSRAttrs(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestBuildCertsOnlyPKCS7_ViaSharedPackage(t *testing.T) {
|
|
// Test with a dummy DER certificate via shared pkcs7 package
|
|
dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE
|
|
result, err := pkcs7.BuildCertsOnlyPKCS7([][]byte{dummyCert})
|
|
if err != nil {
|
|
t.Fatalf("BuildCertsOnlyPKCS7 failed: %v", err)
|
|
}
|
|
if len(result) == 0 {
|
|
t.Error("expected non-empty PKCS#7 output")
|
|
}
|
|
// Verify it starts with SEQUENCE tag
|
|
if result[0] != 0x30 {
|
|
t.Errorf("expected PKCS#7 to start with SEQUENCE tag (0x30), got 0x%02x", result[0])
|
|
}
|
|
}
|
|
|
|
func TestPemToDERChain_ViaSharedPackage(t *testing.T) {
|
|
pemData := generateTestCertPEM(t)
|
|
certs, err := pkcs7.PEMToDERChain(pemData)
|
|
if err != nil {
|
|
t.Fatalf("PEMToDERChain failed: %v", err)
|
|
}
|
|
if len(certs) != 1 {
|
|
t.Errorf("expected 1 cert, got %d", len(certs))
|
|
}
|
|
}
|
|
|
|
func TestPemToDERChain_NoCerts_ViaSharedPackage(t *testing.T) {
|
|
_, err := pkcs7.PEMToDERChain("not a PEM")
|
|
if err == nil {
|
|
t.Error("expected error for invalid PEM")
|
|
}
|
|
}
|
|
|
|
func TestESTCSRAttrs_ServiceError(t *testing.T) {
|
|
svc := &mockESTService{
|
|
CSRAttrsErr: errors.New("service error"),
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CSRAttrs(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTSimpleReEnroll_ServiceError(t *testing.T) {
|
|
csrPEM := generateTestCSRPEM(t)
|
|
svc := &mockESTService{
|
|
EnrollErr: errors.New("renewal failed"),
|
|
}
|
|
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)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestESTCACerts_UnableToGetCerts(t *testing.T) {
|
|
svc := &mockESTService{
|
|
CACertErr: errors.New("CA unavailable"),
|
|
}
|
|
h := NewESTHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|