mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 21:58:53 +00:00
6315ef102a
The GlobalSign Atlas HVCA connector previously used InsecureSkipVerify:true on its mTLS TLS config, disabling server certificate validation and defeating the purpose of the client-side mTLS handshake. This was a CWE-295 Improper Certificate Validation vulnerability silently degrading trust on every production call to GlobalSign's signing API. Remediation (per H-5 audit finding, Lens 4.4): - Remove InsecureSkipVerify from all three http.Client construction sites (ValidateConfig, getHTTPClient, and legacy initialisation path). - Introduce buildServerTLSConfig() helper that constructs tls.Config with MinVersion: tls.VersionTLS12 (addresses adjacent L-1 recommendation). - New optional config field `server_ca_path` (env: CERTCTL_GLOBALSIGN_SERVER_CA_PATH). When unset the connector trusts the system root CA bundle (correct default for GlobalSign's publicly-trusted HVCA endpoints). When set the bundle is loaded via x509.NewCertPool() + AppendCertsFromPEM, and only those roots are trusted (supports private HVCA deployments and defence-in-depth root pinning). - Error wrapping chain: "failed to read server CA bundle at %s" and "no valid PEM certificates found in server CA bundle at %s" surface config problems at ValidateConfig time instead of silently failing at request time. Docs, config, service env-seed, and GUI issuer type definition updated to expose the new field. Tests: 9 dead `InsecureSkipVerify: true` client TLSClientConfig blocks (no-ops against httptest.NewServer plain-HTTP) replaced with bare http.Client; new TestGlobalSign_ServerTLSConfig covers pinned-CA trust, untrusted-server rejection, missing-file and invalid-PEM error paths. Verification: - go build ./... clean - go vet ./... clean - go test -race ./internal/connector/issuer/globalsign/... ./internal/config/... ./internal/service/... ok - go test ./... (excluding testcontainers-gated repo layer) ok - golangci-lint run ./... 0 issues - govulncheck ./... 0 reachable vulns - Per-layer coverage: service 68.7% (≥55), handler 83.6% (≥60), domain 82.0% (≥40), middleware 63.8% (≥30) - globalsign package coverage: 75.9% - Invariant sweep: 0 InsecureSkipVerify references remain in globalsign package (only a test-file comment documenting the removal).
811 lines
24 KiB
Go
811 lines
24 KiB
Go
package globalsign_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
|
"github.com/shankar0123/certctl/internal/connector/issuer/globalsign"
|
|
)
|
|
|
|
func TestGlobalSignConnector(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
|
|
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodGet {
|
|
if r.Header.Get("ApiKey") == "gs-test-key" && r.Header.Get("ApiSecret") == "gs-test-secret" {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"certificates":[]}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"error":"invalid credentials"}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
config := globalsign.Config{
|
|
APIUrl: srv.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: "unused_for_httptest",
|
|
ClientKeyPath: "unused_for_httptest",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
|
|
// This test will fail at mTLS validation since httptest.NewServer doesn't do TLS.
|
|
// We're mainly checking JSON parsing and header validation.
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil || !strings.Contains(err.Error(), "certificate") {
|
|
t.Logf("ValidateConfig correctly failed on cert loading: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
|
|
config := globalsign.Config{
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: "/tmp/cert.pem",
|
|
ClientKeyPath: "/tmp/key.pem",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing api_url")
|
|
}
|
|
if !strings.Contains(err.Error(), "api_url") {
|
|
t.Errorf("Expected api_url error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
|
|
config := globalsign.Config{
|
|
APIUrl: "https://api.example.com",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: "/tmp/cert.pem",
|
|
ClientKeyPath: "/tmp/key.pem",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing api_key")
|
|
}
|
|
if !strings.Contains(err.Error(), "api_key") {
|
|
t.Errorf("Expected api_key error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ValidateConfig_MissingAPISecret", func(t *testing.T) {
|
|
config := globalsign.Config{
|
|
APIUrl: "https://api.example.com",
|
|
APIKey: "gs-test-key",
|
|
ClientCertPath: "/tmp/cert.pem",
|
|
ClientKeyPath: "/tmp/key.pem",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing api_secret")
|
|
}
|
|
if !strings.Contains(err.Error(), "api_secret") {
|
|
t.Errorf("Expected api_secret error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ValidateConfig_MissingClientCertPath", func(t *testing.T) {
|
|
config := globalsign.Config{
|
|
APIUrl: "https://api.example.com",
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientKeyPath: "/tmp/key.pem",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing client_cert_path")
|
|
}
|
|
if !strings.Contains(err.Error(), "client_cert_path") {
|
|
t.Errorf("Expected client_cert_path error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ValidateConfig_MissingClientKeyPath", func(t *testing.T) {
|
|
config := globalsign.Config{
|
|
APIUrl: "https://api.example.com",
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: "/tmp/cert.pem",
|
|
}
|
|
|
|
connector := globalsign.New(nil, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("Expected error for missing client_key_path")
|
|
}
|
|
if !strings.Contains(err.Error(), "client_key_path") {
|
|
t.Errorf("Expected client_key_path error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("IssueCertificate_Immediate", func(t *testing.T) {
|
|
testCertPEM, _ := generateTestCert(t)
|
|
testChainPEM, _ := generateTestCert(t)
|
|
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
|
|
// Verify auth headers are present
|
|
if r.Header.Get("ApiKey") != "gs-test-key" {
|
|
t.Error("ApiKey header missing or incorrect")
|
|
}
|
|
if r.Header.Get("ApiSecret") != "gs-test-secret" {
|
|
t.Error("ApiSecret header missing or incorrect")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(fmt.Sprintf(`{
|
|
"serial_number": "12345678901234567890",
|
|
"status": "issued",
|
|
"certificate": %s,
|
|
"chain": %s
|
|
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
_, csrPEM := generateTestCSR(t, "app.example.com")
|
|
req := issuer.IssuanceRequest{
|
|
CommonName: "app.example.com",
|
|
SANs: []string{"app.example.com"},
|
|
CSRPEM: csrPEM,
|
|
}
|
|
|
|
result, err := connector.IssueCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate failed: %v", err)
|
|
}
|
|
|
|
if result.CertPEM == "" {
|
|
t.Error("CertPEM should not be empty for immediate issuance")
|
|
}
|
|
if result.Serial == "" {
|
|
t.Error("Serial should not be empty for immediate issuance")
|
|
}
|
|
if result.OrderID != "12345678901234567890" {
|
|
t.Errorf("Expected OrderID '12345678901234567890', got '%s'", result.OrderID)
|
|
}
|
|
t.Logf("GlobalSign issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
|
})
|
|
|
|
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(`{
|
|
"serial_number": "98765432109876543210",
|
|
"status": "pending"
|
|
}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
|
req := issuer.IssuanceRequest{
|
|
CommonName: "secure.example.com",
|
|
CSRPEM: csrPEM,
|
|
}
|
|
|
|
result, err := connector.IssueCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate failed: %v", err)
|
|
}
|
|
|
|
if result.CertPEM != "" {
|
|
t.Error("CertPEM should be empty for pending issuance")
|
|
}
|
|
if result.OrderID != "98765432109876543210" {
|
|
t.Errorf("Expected OrderID '98765432109876543210', got '%s'", result.OrderID)
|
|
}
|
|
t.Logf("GlobalSign order pending: orderID=%s", result.OrderID)
|
|
})
|
|
|
|
t.Run("IssueCertificate_Error", func(t *testing.T) {
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"error": "invalid CSR format"}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
_, csrPEM := generateTestCSR(t, "bad.example.com")
|
|
req := issuer.IssuanceRequest{
|
|
CommonName: "bad.example.com",
|
|
CSRPEM: csrPEM,
|
|
}
|
|
|
|
_, err := connector.IssueCertificate(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("Expected error for bad request")
|
|
}
|
|
t.Logf("Expected error received: %v", err)
|
|
})
|
|
|
|
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
|
testCertPEM, _ := generateTestCert(t)
|
|
testChainPEM, _ := generateTestCert(t)
|
|
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/v2/certificates/12345") && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(fmt.Sprintf(`{
|
|
"serial_number": "12345",
|
|
"status": "issued",
|
|
"certificate": %s,
|
|
"chain": %s
|
|
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
status, err := connector.GetOrderStatus(ctx, "12345")
|
|
if err != nil {
|
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
|
}
|
|
|
|
if status.Status != "completed" {
|
|
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
|
}
|
|
if status.CertPEM == nil || *status.CertPEM == "" {
|
|
t.Error("CertPEM should not be empty")
|
|
}
|
|
t.Logf("Order status: %s", status.Status)
|
|
})
|
|
|
|
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/v2/certificates/98765") && r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{
|
|
"serial_number": "98765",
|
|
"status": "pending"
|
|
}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
status, err := connector.GetOrderStatus(ctx, "98765")
|
|
if err != nil {
|
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
|
}
|
|
|
|
if status.Status != "pending" {
|
|
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
|
}
|
|
if status.Message == nil {
|
|
t.Error("Message should not be nil for pending status")
|
|
}
|
|
t.Logf("Order status: %s, message: %s", status.Status, *status.Message)
|
|
})
|
|
|
|
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
|
testCertPEM, _ := generateTestCert(t)
|
|
testChainPEM, _ := generateTestCert(t)
|
|
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(fmt.Sprintf(`{
|
|
"serial_number": "renewal123",
|
|
"status": "issued",
|
|
"certificate": %s,
|
|
"chain": %s
|
|
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
|
req := issuer.RenewalRequest{
|
|
CommonName: "renew.example.com",
|
|
CSRPEM: csrPEM,
|
|
}
|
|
|
|
result, err := connector.RenewCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("RenewCertificate failed: %v", err)
|
|
}
|
|
|
|
if result.Serial == "" {
|
|
t.Error("Serial should not be empty")
|
|
}
|
|
t.Logf("Certificate renewed: serial=%s", result.Serial)
|
|
})
|
|
|
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/v2/certificates/") && strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
|
|
// Verify auth headers
|
|
if r.Header.Get("ApiKey") != "gs-test-key" {
|
|
t.Error("ApiKey header missing")
|
|
}
|
|
if r.Header.Get("ApiSecret") != "gs-test-secret" {
|
|
t.Error("ApiSecret header missing")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
req := issuer.RevocationRequest{
|
|
Serial: "12345678901234567890",
|
|
}
|
|
|
|
err := connector.RevokeCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Certificate revoked: serial=%s", req.Serial)
|
|
})
|
|
|
|
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/v2/certificates/") && strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"error": "certificate not found"}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
req := issuer.RevocationRequest{
|
|
Serial: "nonexistent",
|
|
}
|
|
|
|
err := connector.RevokeCertificate(ctx, req)
|
|
if err == nil {
|
|
t.Fatal("Expected error for nonexistent certificate")
|
|
}
|
|
t.Logf("Expected error received: %v", err)
|
|
})
|
|
|
|
t.Run("AuthHeaders_OnAllRequests", func(t *testing.T) {
|
|
testCertPEM, _ := generateTestCert(t)
|
|
testChainPEM, _ := generateTestCert(t)
|
|
authHeadersChecked := 0
|
|
|
|
httpClient := &http.Client{}
|
|
|
|
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Check for auth headers on every request
|
|
if r.Header.Get("ApiKey") == "gs-test-key" && r.Header.Get("ApiSecret") == "gs-test-secret" {
|
|
authHeadersChecked++
|
|
}
|
|
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(fmt.Sprintf(`{
|
|
"serial_number": "auth123",
|
|
"status": "issued",
|
|
"certificate": %s,
|
|
"chain": %s
|
|
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer mockServer.Close()
|
|
|
|
config := &globalsign.Config{
|
|
APIUrl: mockServer.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
}
|
|
|
|
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
|
|
|
|
_, csrPEM := generateTestCSR(t, "auth.example.com")
|
|
req := issuer.IssuanceRequest{
|
|
CommonName: "auth.example.com",
|
|
CSRPEM: csrPEM,
|
|
}
|
|
|
|
_, err := connector.IssueCertificate(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("IssueCertificate failed: %v", err)
|
|
}
|
|
|
|
if authHeadersChecked < 1 {
|
|
t.Errorf("Auth headers not found on request")
|
|
}
|
|
t.Logf("Auth headers verified on %d request(s)", authHeadersChecked)
|
|
})
|
|
}
|
|
|
|
// TestGlobalSign_ServerTLSConfig exercises the server-side TLS verification
|
|
// policy added by H-5. The connector must always verify the GlobalSign Atlas
|
|
// HVCA API server certificate: by default against the host's system trust
|
|
// store, and when ServerCAPath is set, against the pinned PEM bundle at that
|
|
// path. InsecureSkipVerify is no longer reachable from any production code path.
|
|
func TestGlobalSign_ServerTLSConfig(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
ctx := context.Background()
|
|
|
|
// writeClientMTLS generates a throwaway client cert+key pair and writes them
|
|
// to disk. ValidateConfig requires valid ClientCertPath / ClientKeyPath files
|
|
// before it reaches the server-CA validation path under test.
|
|
writeClientMTLS := func(t *testing.T) (certPath, keyPath string) {
|
|
t.Helper()
|
|
certPEM, keyPEM := generateTestCert(t)
|
|
dir := t.TempDir()
|
|
certPath = dir + "/client-cert.pem"
|
|
keyPath = dir + "/client-key.pem"
|
|
if err := os.WriteFile(certPath, []byte(certPEM), 0600); err != nil {
|
|
t.Fatalf("failed to write client cert: %v", err)
|
|
}
|
|
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
|
|
t.Fatalf("failed to write client key: %v", err)
|
|
}
|
|
return certPath, keyPath
|
|
}
|
|
|
|
// certToPEM re-encodes a parsed certificate as a PEM block for trust-store
|
|
// pinning. httptest.NewTLSServer.Certificate() returns the server's self-
|
|
// signed cert; pinning that cert trusts exactly that one server.
|
|
certToPEM := func(t *testing.T, cert *x509.Certificate) string {
|
|
t.Helper()
|
|
return string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Raw,
|
|
}))
|
|
}
|
|
|
|
t.Run("PinnedCA_TrustsExpectedServer", func(t *testing.T) {
|
|
// Mock Atlas API served over HTTPS with a self-signed cert. We pin
|
|
// that cert's PEM as the client's trust anchor; the validation probe
|
|
// should succeed because the pinned pool contains the server's issuer.
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodGet {
|
|
if r.Header.Get("ApiKey") == "gs-test-key" && r.Header.Get("ApiSecret") == "gs-test-secret" {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"certificates":[]}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
caPEM := certToPEM(t, srv.Certificate())
|
|
caPath := t.TempDir() + "/atlas-ca.pem"
|
|
if err := os.WriteFile(caPath, []byte(caPEM), 0600); err != nil {
|
|
t.Fatalf("failed to write pinned CA: %v", err)
|
|
}
|
|
|
|
clientCert, clientKey := writeClientMTLS(t)
|
|
config := globalsign.Config{
|
|
APIUrl: srv.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: clientCert,
|
|
ClientKeyPath: clientKey,
|
|
ServerCAPath: caPath,
|
|
}
|
|
|
|
connector := globalsign.New(&config, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
|
t.Fatalf("ValidateConfig with pinned CA should succeed, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("PinnedCA_RejectsUntrustedServer", func(t *testing.T) {
|
|
// Mock server presents its own self-signed cert; we pin an UNRELATED
|
|
// cert as the trust anchor. The TLS handshake must fail before any
|
|
// request is sent — this is exactly what H-5 remediates.
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
unrelatedPEM, _ := generateTestCert(t)
|
|
caPath := t.TempDir() + "/unrelated-ca.pem"
|
|
if err := os.WriteFile(caPath, []byte(unrelatedPEM), 0600); err != nil {
|
|
t.Fatalf("failed to write unrelated CA: %v", err)
|
|
}
|
|
|
|
clientCert, clientKey := writeClientMTLS(t)
|
|
config := globalsign.Config{
|
|
APIUrl: srv.URL,
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: clientCert,
|
|
ClientKeyPath: clientKey,
|
|
ServerCAPath: caPath,
|
|
}
|
|
|
|
connector := globalsign.New(&config, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("ValidateConfig must fail when the server cert is not signed by the pinned CA")
|
|
}
|
|
// The failure must originate from TLS verification, not from any other path.
|
|
if !strings.Contains(err.Error(), "x509") &&
|
|
!strings.Contains(err.Error(), "certificate") &&
|
|
!strings.Contains(err.Error(), "unknown authority") {
|
|
t.Errorf("expected TLS verification error, got: %v", err)
|
|
}
|
|
t.Logf("Untrusted server cert correctly rejected: %v", err)
|
|
})
|
|
|
|
t.Run("ServerCAPath_MissingFile", func(t *testing.T) {
|
|
clientCert, clientKey := writeClientMTLS(t)
|
|
config := globalsign.Config{
|
|
APIUrl: "https://example.invalid",
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: clientCert,
|
|
ClientKeyPath: clientKey,
|
|
ServerCAPath: "/nonexistent/path/to/ca.pem",
|
|
}
|
|
|
|
connector := globalsign.New(&config, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("ValidateConfig must fail when ServerCAPath points to a missing file")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to read server CA bundle") {
|
|
t.Errorf("expected 'failed to read server CA bundle' error, got: %v", err)
|
|
}
|
|
t.Logf("Missing server CA file correctly rejected: %v", err)
|
|
})
|
|
|
|
t.Run("ServerCAPath_InvalidPEM", func(t *testing.T) {
|
|
clientCert, clientKey := writeClientMTLS(t)
|
|
badCAPath := t.TempDir() + "/garbage.pem"
|
|
if err := os.WriteFile(badCAPath, []byte("this is not a PEM certificate at all"), 0600); err != nil {
|
|
t.Fatalf("failed to write garbage file: %v", err)
|
|
}
|
|
|
|
config := globalsign.Config{
|
|
APIUrl: "https://example.invalid",
|
|
APIKey: "gs-test-key",
|
|
APISecret: "gs-test-secret",
|
|
ClientCertPath: clientCert,
|
|
ClientKeyPath: clientKey,
|
|
ServerCAPath: badCAPath,
|
|
}
|
|
|
|
connector := globalsign.New(&config, logger)
|
|
rawConfig, _ := json.Marshal(config)
|
|
err := connector.ValidateConfig(ctx, rawConfig)
|
|
if err == nil {
|
|
t.Fatal("ValidateConfig must fail when ServerCAPath contains no valid PEM certificates")
|
|
}
|
|
if !strings.Contains(err.Error(), "no valid PEM certificates") {
|
|
t.Errorf("expected 'no valid PEM certificates' error, got: %v", err)
|
|
}
|
|
t.Logf("Invalid PEM correctly rejected: %v", err)
|
|
})
|
|
}
|
|
|
|
// generateTestCert generates a self-signed test certificate and returns PEM strings.
|
|
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate RSA key: %v", err)
|
|
}
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
|
Subject: pkix.Name{
|
|
CommonName: "test.example.com",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{
|
|
x509.ExtKeyUsageServerAuth,
|
|
},
|
|
DNSNames: []string{"test.example.com"},
|
|
}
|
|
|
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create certificate: %v", err)
|
|
}
|
|
|
|
certBlock := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certBytes,
|
|
})
|
|
|
|
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal private key: %v", err)
|
|
}
|
|
|
|
keyBlock := pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privKeyBytes,
|
|
})
|
|
|
|
return string(certBlock), string(keyBlock)
|
|
}
|
|
|
|
// generateTestCSR generates a test certificate signing request.
|
|
func generateTestCSR(t *testing.T, commonName string) (csrPEM string, keyPEM string) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate RSA key: %v", err)
|
|
}
|
|
|
|
template := &x509.CertificateRequest{
|
|
Subject: pkix.Name{
|
|
CommonName: commonName,
|
|
},
|
|
DNSNames: []string{commonName},
|
|
}
|
|
|
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, template, priv)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create CSR: %v", err)
|
|
}
|
|
|
|
csrBlock := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrBytes,
|
|
})
|
|
|
|
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal private key: %v", err)
|
|
}
|
|
|
|
keyBlock := pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privKeyBytes,
|
|
})
|
|
|
|
return string(csrBlock), string(keyBlock)
|
|
}
|
|
|
|
// mustMarshalJSON marshals a value to JSON string, panicking on error.
|
|
// Used to safely embed PEM data in JSON responses.
|
|
func mustMarshalJSON(v interface{}) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to marshal JSON: %v", err))
|
|
}
|
|
return string(b)
|
|
}
|