mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 22:58:52 +00:00
feat: add Vault PKI and DigiCert CertCentral issuer connectors (M32 + M37)
Vault PKI: synchronous issuance via /v1/{mount}/sign/{role}, token auth,
revocation, CA cert retrieval, 14 tests. DigiCert CertCentral: async order
model (submit → poll → download), X-DC-DEVKEY auth, OV/EV support, PEM
bundle parsing, 16 tests. Both conditionally registered based on env vars.
Includes OpenAPI enum updates, seed data, connector docs, architecture docs,
README badges, and testing guide sign-off (Parts 38 + 39, 12 automated
smoke test assertions all passing).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
package vault_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"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||
)
|
||||
|
||||
func TestVaultConnector(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 == "/v1/sys/health" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"initialized":true,"sealed":false,"standby":false}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token-12345",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
TTL: "8760h",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingAddr", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Token: "s.test-token",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing addr")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "addr is required") {
|
||||
t.Errorf("Expected addr required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingToken", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token is required") {
|
||||
t.Errorf("Expected token required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_MissingRole", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing role")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "role is required") {
|
||||
t.Errorf("Expected role required error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidateConfig_UnreachableVault", func(t *testing.T) {
|
||||
config := vault.Config{
|
||||
Addr: "http://localhost:19999",
|
||||
Token: "s.test-token",
|
||||
Role: "web-certs",
|
||||
}
|
||||
|
||||
connector := vault.New(nil, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unreachable Vault")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"initialized":true,"sealed":false}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
// Verify auth header
|
||||
if r.Header.Get("X-Vault-Token") != "s.test-token" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"data": {
|
||||
"certificate": %q,
|
||||
"issuing_ca": %q,
|
||||
"ca_chain": [%q],
|
||||
"serial_number": "aa:bb:cc:dd:ee:ff",
|
||||
"expiration": 1893456000
|
||||
}
|
||||
}`, testCertPEM, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
TTL: "8760h",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "app.example.com",
|
||||
SANs: []string{"app.example.com", "www.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 is empty")
|
||||
}
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
if result.OrderID == "" {
|
||||
t.Error("OrderID is empty")
|
||||
}
|
||||
if !strings.HasPrefix(result.OrderID, "vault-") {
|
||||
t.Errorf("Expected OrderID to start with 'vault-', got '%s'", result.OrderID)
|
||||
}
|
||||
// Verify serial normalization (colons replaced with dashes)
|
||||
if strings.Contains(result.Serial, ":") {
|
||||
t.Errorf("Serial should not contain colons, got '%s'", result.Serial)
|
||||
}
|
||||
t.Logf("Vault issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":["invalid CSR"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid CSR") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssueCertificate_Forbidden", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.bad-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||
req := issuer.IssuanceRequest{
|
||||
CommonName: "test.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
_, err := connector.IssueCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for forbidden response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Logf("Got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
||||
testCertPEM, _ := generateTestCert(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := fmt.Sprintf(`{
|
||||
"data": {
|
||||
"certificate": %q,
|
||||
"issuing_ca": %q,
|
||||
"serial_number": "11:22:33:44:55:66",
|
||||
"expiration": 1893456000
|
||||
}
|
||||
}`, testCertPEM, testCertPEM)
|
||||
w.Write([]byte(resp))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||
renewReq := issuer.RenewalRequest{
|
||||
CommonName: "renew.example.com",
|
||||
CSRPEM: csrPEM,
|
||||
}
|
||||
|
||||
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Serial == "" {
|
||||
t.Error("Serial is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/v1/pki/revoke":
|
||||
// Verify token
|
||||
if r.Header.Get("X-Vault-Token") == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":{"revocation_time":1234567890}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "aa-bb-cc-dd-ee-ff",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_ServerError", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/sys/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.URL.Path == "/v1/pki/revoke":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"errors":["serial not found"]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "00-00-00-00",
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for server error response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||
expectedPEM := "-----BEGIN CERTIFICATE-----\nTESTCA\n-----END CERTIFICATE-----\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v1/pki/ca/pem":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(expectedPEM))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
config := &vault.Config{
|
||||
Addr: srv.URL,
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
caPEM, err := connector.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||
}
|
||||
|
||||
if caPEM != expectedPEM {
|
||||
t.Errorf("Expected CA PEM %q, got %q", expectedPEM, caPEM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetOrderStatus_Synchronous", func(t *testing.T) {
|
||||
config := &vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
status, err := connector.GetOrderStatus(ctx, "vault-aa-bb-cc")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "completed" {
|
||||
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||
}
|
||||
if status.OrderID != "vault-aa-bb-cc" {
|
||||
t.Errorf("Expected OrderID 'vault-aa-bb-cc', got '%s'", status.OrderID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||
config := &vault.Config{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "s.test-token",
|
||||
Mount: "pki",
|
||||
Role: "web-certs",
|
||||
}
|
||||
connector := vault.New(config, logger)
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("GetRenewalInfo should return nil for Vault")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Test Certificate",
|
||||
},
|
||||
DNSNames: []string{"test.example.com"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
// generateTestCSR creates a test CSR for the given common name.
|
||||
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
csrTemplate := x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
},
|
||||
DNSNames: []string{commonName},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
}
|
||||
|
||||
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create CSR: %v", err)
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrBytes,
|
||||
}))
|
||||
|
||||
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse CSR: %v", err)
|
||||
}
|
||||
|
||||
return csr, csrPEM
|
||||
}
|
||||
Reference in New Issue
Block a user