mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 17:08:51 +00:00
2a384c690e
Phase 2 of the #6 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 633a10a) shipped the secret.Ref opaque
credential type with PBKDF2-derived key, ChaCha20-Poly1305 envelope,
String/MarshalJSON redaction to "[redacted]", and the Use callback
that zero-fills the per-call buffer after the consumer returns.
This commit applies the type to the three connectors flagged by the
audit and adds the JSON-roundtrip glue that the production factory
path needs.
Shared (internal/secret/):
- Add UnmarshalJSON on *Ref so json.Unmarshal of a stored config
blob (issuerfactory.NewFromConfig) parses the bytes-as-string into
NewRefFromString without callers having to know the field type
changed. Null and missing keys leave the receiver nil; non-string
payloads (numbers, bools) are rejected with a typed error. Pinned
by TestRef_UnmarshalJSON: string_value, null, missing_key,
number_rejected, roundtrip_marshal_then_unmarshal (the round-trip
goes through "[redacted]" intentionally — JSON-marshal-then-
unmarshal of a Config with secrets is NOT a supported test pattern;
callers that construct a rawConfig must use a JSON literal with
the real values).
Per-connector migration:
- EJBCA (ejbca.go): Config.Token: string → *secret.Ref. ValidateConfig
empty-check uses Token.IsEmpty() (nil-safe). setAuthHeaders rewritten
to call Token.Use; the Bearer header string is built inside the
callback and the buffer is zeroed on return. mTLS path is
unaffected.
- GlobalSign (globalsign.go): Config.APIKey + Config.APISecret: string
→ *secret.Ref. Both ValidateConfig empty-checks use IsEmpty().
Extracted setAuthHeaders helper consolidates the four duplicated
triple-Set sites (ValidateConfig probe, IssueCertificate,
RevokeCertificate, pollCertificateOnce) so any future header-shape
change applies once. ValidateConfig now pulls from the local cfg
(post-Unmarshal) so the helper takes a *Config rather than the
receiver — needed because ValidateConfig writes the validated cfg
onto c.config only AFTER the probe succeeds.
- Sectigo (sectigo.go): Config.Login + Config.Password: string →
*secret.Ref. CustomerURI stays plain string (org identifier, not
a credential). setAuthHeaders rewritten to call Login.Use +
Password.Use; ValidateConfig's inline header writes use the same
pattern (the ValidateConfig probe writes to a local cfg, not
c.config, so it can't share setAuthHeaders without rewiring — the
inline form is fine, kept consistent in shape).
Test migration:
- ejbca_test.go, ejbca_failure_test.go, ejbca_stubs_test.go: bulk
Token: "X" → Token: secret.NewRefFromString("X") via sed; secret
import added.
- globalsign_test.go, globalsign_failure_test.go: same pattern for
APIKey + APISecret.
- sectigo_test.go, sectigo_failure_test.go: same pattern for Login +
Password.
Two tests (TestGlobalSign_ServerTLSConfig/PinnedCA_TrustsExpectedServer
and TestSectigoConnector/ValidateConfig_Success) used to construct
rawConfig via json.Marshal(config) → ValidateConfig(rawConfig). After
the migration, json.Marshal redacts *secret.Ref to "[redacted]" by
design, so the roundtripped rawConfig wrote "[redacted]" as the
actual header value and the mock server's auth-header check 403'd.
Both tests now build rawConfig as a JSON literal (the production-
shape input — the factory path always feeds rawConfig from the DB
or env, never from json.Marshal of an in-memory Config). The new
tests have a comment explaining the trap so the next person who
adds a similar test sees the pattern.
Out of scope (intentional):
- The `internal/config/config.SectigoConfig` / `GlobalSignConfig` /
`EJBCAConfig` env-var-loader structs are still plain strings —
those types are the env-load shape, not the steady-state runtime
shape. The seed path in service/issuer.go json-marshals them into
a map[string]interface{} which the factory then UnmarshalJSON's
into the connector Config; the new UnmarshalJSON on *Ref handles
the conversion at the boundary.
- DigiCert.APIKey + Vault.Token are still plain strings; Phase 3
will pick them up. The audit explicitly named EJBCA / GlobalSign /
Sectigo as the Phase 2 scope (RESULTS.md L633).
Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck across all four packages clean
- go test -short -count=1 across secret, ejbca, globalsign, sectigo,
issuerfactory, service, api/handler: green
Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #6 — Phase 2.
820 lines
26 KiB
Go
820 lines
26 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"
|
|
"github.com/shankar0123/certctl/internal/secret"
|
|
)
|
|
|
|
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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("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: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("gs-test-secret"),
|
|
PollMaxWaitSeconds: 1, // keep async-pending tests fast
|
|
}
|
|
|
|
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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("gs-test-secret"),
|
|
ClientCertPath: clientCert,
|
|
ClientKeyPath: clientKey,
|
|
ServerCAPath: caPath,
|
|
}
|
|
|
|
connector := globalsign.New(&config, logger)
|
|
// Build rawConfig as a JSON literal so the secrets round-trip
|
|
// intact via UnmarshalJSON (json.Marshal redacts *secret.Ref →
|
|
// "[redacted]" by design; it MUST NOT be used to construct a
|
|
// rawConfig that ValidateConfig will then header-write back).
|
|
rawConfig := []byte(fmt.Sprintf(
|
|
`{"api_url":%q,"api_key":"gs-test-key","api_secret":"gs-test-secret","client_cert_path":%q,"client_key_path":%q,"server_ca_path":%q}`,
|
|
config.APIUrl, config.ClientCertPath, config.ClientKeyPath, config.ServerCAPath,
|
|
))
|
|
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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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: secret.NewRefFromString("gs-test-key"),
|
|
APISecret: secret.NewRefFromString("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)
|
|
}
|