From 7d6ef44e21206560344d9b3cd88cdb7c5114a2bb Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 5 Apr 2026 19:14:32 -0400 Subject: [PATCH] feat(M46): Windows Certificate Store + Java Keystore target connectors, shared certutil package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared certutil helpers (CreatePFX, ParsePrivateKey, ComputeThumbprint, GenerateRandomPassword, ParseCertificatePEM) from IIS connector for reuse. Add WinCertStore connector (PowerShell Import-PfxCertificate, dual local/WinRM mode, configurable store/location, expired cert cleanup) and JavaKeystore connector (PEM→PKCS#12→keytool pipeline, JKS/PKCS12 support, shell injection prevention, path traversal protection). 53 new tests, all passing. Co-Authored-By: Claude Opus 4.6 --- api/openapi.yaml | 2 +- cmd/agent/main.go | 20 + docs/connectors.md | 63 +++ .../connector/target/certutil/certutil.go | 125 +++++ .../target/certutil/certutil_test.go | 189 +++++++ internal/connector/target/iis/iis.go | 110 +--- internal/connector/target/iis/iis_test.go | 19 +- .../target/javakeystore/javakeystore.go | 327 +++++++++++ .../target/javakeystore/javakeystore_test.go | 531 ++++++++++++++++++ .../target/wincertstore/wincertstore.go | 331 +++++++++++ .../target/wincertstore/wincertstore_test.go | 412 ++++++++++++++ internal/domain/connector.go | 4 +- internal/service/target.go | 4 +- web/src/pages/TargetDetailPage.tsx | 4 +- web/src/pages/TargetsPage.tsx | 23 + 15 files changed, 2048 insertions(+), 116 deletions(-) create mode 100644 internal/connector/target/certutil/certutil.go create mode 100644 internal/connector/target/certutil/certutil_test.go create mode 100644 internal/connector/target/javakeystore/javakeystore.go create mode 100644 internal/connector/target/javakeystore/javakeystore_test.go create mode 100644 internal/connector/target/wincertstore/wincertstore.go create mode 100644 internal/connector/target/wincertstore/wincertstore_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 89ae338..e8e1abc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2669,7 +2669,7 @@ components: # ─── Targets ───────────────────────────────────────────────────── TargetType: type: string - enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH] + enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, Postfix, Dovecot, IIS, F5, SSH, WinCertStore, JavaKeystore] DeploymentTarget: type: object diff --git a/cmd/agent/main.go b/cmd/agent/main.go index fb6949c..d431f19 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -33,6 +33,8 @@ import ( pf "github.com/shankar0123/certctl/internal/connector/target/postfix" sshconn "github.com/shankar0123/certctl/internal/connector/target/ssh" "github.com/shankar0123/certctl/internal/connector/target/f5" + jks "github.com/shankar0123/certctl/internal/connector/target/javakeystore" + wcs "github.com/shankar0123/certctl/internal/connector/target/wincertstore" "github.com/shankar0123/certctl/internal/connector/target/haproxy" "github.com/shankar0123/certctl/internal/connector/target/iis" "github.com/shankar0123/certctl/internal/connector/target/nginx" @@ -657,6 +659,24 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess } return sshconn.New(&cfg, a.logger) + case "WinCertStore": + var cfg wcs.Config + if len(configJSON) > 0 { + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid WinCertStore config: %w", err) + } + } + return wcs.New(&cfg, a.logger) + + case "JavaKeystore": + var cfg jks.Config + if len(configJSON) > 0 { + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid JavaKeystore config: %w", err) + } + } + return jks.New(&cfg, a.logger), nil + default: return nil, fmt.Errorf("unsupported target type: %s", targetType) } diff --git a/docs/connectors.md b/docs/connectors.md index 5de5058..c1a14e2 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -27,6 +27,8 @@ Connectors extend certctl to integrate with external systems for certificate iss - [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only) - [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode) - [SSH (Agentless Deployment)](#ssh-agentless-deployment) + - [Windows Certificate Store](#windows-certificate-store) + - [Java Keystore (JKS / PKCS#12)](#java-keystore-jks--pkcs12) 4. [Notifier Connector](#notifier-connector) - [Interface](#interface-2) 5. [Registering a Connector](#registering-a-connector) @@ -874,6 +876,67 @@ The SSH target connector enables agentless certificate deployment to any Linux/U Location: `internal/connector/target/ssh/ssh.go` +### Windows Certificate Store + +The Windows Certificate Store connector imports certificates into the Windows cert store via PowerShell, without managing IIS site bindings. Use this for non-IIS Windows services that read certificates from the cert store (Exchange, RDP, SQL Server, ADFS, etc.). Same injectable `PowerShellExecutor` pattern as the IIS connector, with optional WinRM proxy mode. + +```json +{ + "store_name": "My", + "store_location": "LocalMachine", + "friendly_name": "Production API Cert", + "remove_expired": true +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `store_name` | string | `"My"` | Windows cert store name (My, Root, WebHosting, etc.) | +| `store_location` | string | `"LocalMachine"` | `"LocalMachine"` or `"CurrentUser"` | +| `friendly_name` | string | | Optional friendly name for the imported certificate | +| `remove_expired` | boolean | `false` | Remove expired certs with same CN after import | +| `mode` | string | `"local"` | `"local"` (agent-local) or `"winrm"` (remote) | +| `winrm_host` | string | | WinRM hostname (required for winrm mode) | +| `winrm_port` | number | 5985 | WinRM port (5985 HTTP, 5986 HTTPS) | +| `winrm_username` | string | | WinRM username (required for winrm mode) | +| `winrm_password` | string | | WinRM password (required for winrm mode) | +| `winrm_https` | boolean | `false` | Use HTTPS for WinRM | +| `winrm_insecure` | boolean | `false` | Skip TLS verification for WinRM | + +Location: `internal/connector/target/wincertstore/wincertstore.go` + +### Java Keystore (JKS / PKCS#12) + +The Java Keystore connector deploys certificates to JKS or PKCS#12 keystores via the `keytool` CLI. This enables TLS cert deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service. Flow: PEM to temp PKCS#12, then `keytool -importkeystore` into the target keystore. + +```json +{ + "keystore_path": "/opt/tomcat/conf/keystore.p12", + "keystore_password": "changeit", + "keystore_type": "PKCS12", + "alias": "server", + "reload_command": "systemctl restart tomcat" +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `keystore_path` | string | *(required)* | Absolute path to the keystore file | +| `keystore_password` | string | *(required)* | Keystore password | +| `keystore_type` | string | `"PKCS12"` | `"PKCS12"` or `"JKS"` | +| `alias` | string | `"server"` | Key entry alias in the keystore | +| `reload_command` | string | | Optional command to run after keystore update | +| `create_keystore` | boolean | `true` | Create keystore if it doesn't exist | +| `keytool_path` | string | `"keytool"` | Override keytool binary path | + +**Security:** +- Reload commands validated against shell injection via `validation.ValidateShellCommand()` +- Alias validated against injection (alphanumeric, hyphens, underscores only) +- Path traversal prevention on keystore path +- Transient PKCS#12 temp file cleaned up after import (even on error) + +Location: `internal/connector/target/javakeystore/javakeystore.go` + ## Notifier Connector Notifier connectors send alerts about certificate lifecycle events (expiration warnings, renewal success/failure, deployment status, policy violations). diff --git a/internal/connector/target/certutil/certutil.go b/internal/connector/target/certutil/certutil.go new file mode 100644 index 0000000..41ce5af --- /dev/null +++ b/internal/connector/target/certutil/certutil.go @@ -0,0 +1,125 @@ +// Package certutil provides shared certificate utility functions for target connectors. +// These functions handle PEM/PFX conversion, key parsing, thumbprint computation, +// and random password generation. Extracted from the IIS connector (M39) to enable +// reuse by Windows Certificate Store (M46) and Java Keystore (M46) connectors. +package certutil + +import ( + "crypto/rand" + "crypto/sha1" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "strings" + + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +// CreatePFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format. +// Uses go-pkcs12 Modern encoder with strong encryption. +func CreatePFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) { + // Parse leaf certificate + certBlock, _ := pem.Decode([]byte(certPEM)) + if certBlock == nil || certBlock.Type != "CERTIFICATE" { + return nil, fmt.Errorf("failed to decode certificate PEM") + } + leafCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse leaf certificate: %w", err) + } + + // Parse private key (supports PKCS#8, PKCS#1 RSA, and EC) + keyBlock, _ := pem.Decode([]byte(keyPEM)) + if keyBlock == nil { + return nil, fmt.Errorf("failed to decode private key PEM") + } + privateKey, err := ParsePrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + // Parse CA chain certificates (optional) + var caCerts []*x509.Certificate + if chainPEM != "" { + rest := []byte(chainPEM) + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + caCerts = append(caCerts, caCert) + } + } + + // Encode as PKCS#12 with Modern encryption + pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password) + if err != nil { + return nil, fmt.Errorf("failed to encode PKCS#12: %w", err) + } + + return pfxData, nil +} + +// ParsePrivateKey attempts to parse a DER-encoded private key. +// Tries PKCS#8, PKCS#1 RSA, and EC formats in order. +func ParsePrivateKey(der []byte) (interface{}, error) { + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + return nil, fmt.Errorf("unsupported private key format") +} + +// ComputeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate. +// Windows uses SHA-1 thumbprints as the primary certificate identifier. +// Returns uppercase hex string matching Windows certutil output. +func ComputeThumbprint(certPEM string) (string, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil || block.Type != "CERTIFICATE" { + return "", fmt.Errorf("failed to decode certificate PEM for thumbprint") + } + hash := sha1.Sum(block.Bytes) + return strings.ToUpper(hex.EncodeToString(hash[:])), nil +} + +// GenerateRandomPassword creates a random alphanumeric password. +// Typically used for transient PFX encryption — the password is only used +// between PFX creation and import, it never persists. +func GenerateRandomPassword(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to read random bytes: %w", err) + } + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b), nil +} + +// ParseCertificatePEM parses a PEM-encoded certificate and returns the x509.Certificate. +func ParseCertificatePEM(certPEM string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("failed to decode certificate PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + return cert, nil +} diff --git a/internal/connector/target/certutil/certutil_test.go b/internal/connector/target/certutil/certutil_test.go new file mode 100644 index 0000000..a6e5137 --- /dev/null +++ b/internal/connector/target/certutil/certutil_test.go @@ -0,0 +1,189 @@ +package certutil + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// generateTestCertAndKey creates a self-signed certificate and key for testing. +func generateTestCertAndKey() (string, string, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.example.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", "", err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + return string(certPEM), string(keyPEM), nil +} + +func TestCreatePFX_Success(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + + pfx, err := CreatePFX(certPEM, keyPEM, "", "test-password") + if err != nil { + t.Fatalf("CreatePFX failed: %v", err) + } + if len(pfx) == 0 { + t.Error("expected non-empty PFX data") + } +} + +func TestCreatePFX_WithChain(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + // Use the same cert as chain for testing purposes + pfx, err := CreatePFX(certPEM, keyPEM, certPEM, "test-password") + if err != nil { + t.Fatalf("CreatePFX with chain failed: %v", err) + } + if len(pfx) == 0 { + t.Error("expected non-empty PFX data") + } +} + +func TestCreatePFX_InvalidCert(t *testing.T) { + _, err := CreatePFX("not-a-cert", "not-a-key", "", "pw") + if err == nil { + t.Fatal("expected error for invalid cert PEM") + } +} + +func TestCreatePFX_InvalidKey(t *testing.T) { + certPEM, _, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + _, err = CreatePFX(certPEM, "not-a-key", "", "pw") + if err == nil { + t.Fatal("expected error for invalid key PEM") + } +} + +func TestParsePrivateKey_PKCS8(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + der, _ := x509.MarshalPKCS8PrivateKey(key) + parsed, err := ParsePrivateKey(der) + if err != nil { + t.Fatalf("ParsePrivateKey failed: %v", err) + } + if parsed == nil { + t.Fatal("expected non-nil key") + } +} + +func TestParsePrivateKey_EC(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + der, _ := x509.MarshalECPrivateKey(key) + parsed, err := ParsePrivateKey(der) + if err != nil { + t.Fatalf("ParsePrivateKey failed: %v", err) + } + if parsed == nil { + t.Fatal("expected non-nil key") + } +} + +func TestParsePrivateKey_Invalid(t *testing.T) { + _, err := ParsePrivateKey([]byte("garbage")) + if err == nil { + t.Fatal("expected error for invalid key bytes") + } +} + +func TestComputeThumbprint_Success(t *testing.T) { + certPEM, _, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + thumb, err := ComputeThumbprint(certPEM) + if err != nil { + t.Fatalf("ComputeThumbprint failed: %v", err) + } + if len(thumb) != 40 { + t.Errorf("expected 40-char hex thumbprint, got %d chars", len(thumb)) + } + // Verify uppercase hex + for _, c := range thumb { + if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) { + t.Errorf("thumbprint contains non-uppercase-hex char: %c", c) + } + } +} + +func TestComputeThumbprint_InvalidPEM(t *testing.T) { + _, err := ComputeThumbprint("not a cert") + if err == nil { + t.Fatal("expected error for invalid PEM") + } +} + +func TestGenerateRandomPassword(t *testing.T) { + pw, err := GenerateRandomPassword(32) + if err != nil { + t.Fatalf("GenerateRandomPassword failed: %v", err) + } + if len(pw) != 32 { + t.Errorf("expected 32-char password, got %d", len(pw)) + } +} + +func TestGenerateRandomPassword_Uniqueness(t *testing.T) { + pw1, _ := GenerateRandomPassword(32) + pw2, _ := GenerateRandomPassword(32) + if pw1 == pw2 { + t.Error("two generated passwords should not be identical") + } +} + +func TestParseCertificatePEM_Success(t *testing.T) { + certPEM, _, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + cert, err := ParseCertificatePEM(certPEM) + if err != nil { + t.Fatalf("ParseCertificatePEM failed: %v", err) + } + if cert.Subject.CommonName != "test.example.com" { + t.Errorf("expected CN test.example.com, got %s", cert.Subject.CommonName) + } +} + +func TestParseCertificatePEM_Invalid(t *testing.T) { + _, err := ParseCertificatePEM("not a cert") + if err == nil { + t.Fatal("expected error for invalid PEM") + } +} diff --git a/internal/connector/target/iis/iis.go b/internal/connector/target/iis/iis.go index f8aebbf..416ce41 100644 --- a/internal/connector/target/iis/iis.go +++ b/internal/connector/target/iis/iis.go @@ -2,13 +2,8 @@ package iis import ( "context" - "crypto/rand" - "crypto/sha1" - "crypto/x509" "encoding/base64" - "encoding/hex" "encoding/json" - "encoding/pem" "fmt" "log/slog" "os" @@ -18,7 +13,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/target" - pkcs12 "software.sslmate.com/src/go-pkcs12" + "github.com/shankar0123/certctl/internal/connector/target/certutil" ) // Config represents the IIS deployment target configuration. @@ -256,7 +251,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy } // Step 1: Create PFX from PEM inputs - pfxPassword, err := generateRandomPassword(32) + pfxPassword, err := certutil.GenerateRandomPassword(32) if err != nil { errMsg := fmt.Sprintf("failed to generate PFX password: %v", err) c.logger.Error("deployment failed", "error", err) @@ -267,7 +262,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy }, fmt.Errorf("%s", errMsg) } - pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword) + pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword) if err != nil { errMsg := fmt.Sprintf("failed to create PFX: %v", err) c.logger.Error("PFX creation failed", "error", err) @@ -281,7 +276,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy // Step 2+3: Compute thumbprint and import PFX // In local mode: write PFX to temp file, import via file path // In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up - thumbprint, err := computeThumbprint(request.CertPEM) + thumbprint, err := certutil.ComputeThumbprint(request.CertPEM) if err != nil { errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err) c.logger.Error("deployment failed", "error", err) @@ -564,97 +559,6 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid } } -// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format. -// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder -// with strong encryption (same library used by M27 export service). -func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) { - // Parse leaf certificate - certBlock, _ := pem.Decode([]byte(certPEM)) - if certBlock == nil || certBlock.Type != "CERTIFICATE" { - return nil, fmt.Errorf("failed to decode certificate PEM") - } - leafCert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse leaf certificate: %w", err) - } - - // Parse private key (supports PKCS#8, PKCS#1 RSA, and EC) - keyBlock, _ := pem.Decode([]byte(keyPEM)) - if keyBlock == nil { - return nil, fmt.Errorf("failed to decode private key PEM") - } - privateKey, err := parsePrivateKey(keyBlock.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - - // Parse CA chain certificates (optional) - var caCerts []*x509.Certificate - if chainPEM != "" { - rest := []byte(chainPEM) - for { - var block *pem.Block - block, rest = pem.Decode(rest) - if block == nil { - break - } - if block.Type != "CERTIFICATE" { - continue - } - caCert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse CA certificate: %w", err) - } - caCerts = append(caCerts, caCert) - } - } - - // Encode as PKCS#12 with Modern encryption - pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password) - if err != nil { - return nil, fmt.Errorf("failed to encode PKCS#12: %w", err) - } - - return pfxData, nil -} - -// parsePrivateKey attempts to parse a DER-encoded private key. -// Tries PKCS#8, PKCS#1 RSA, and EC formats in order. -func parsePrivateKey(der []byte) (interface{}, error) { - if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { - return key, nil - } - if key, err := x509.ParseECPrivateKey(der); err == nil { - return key, nil - } - return nil, fmt.Errorf("unsupported private key format") -} - -// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate. -// IIS uses SHA-1 thumbprints as the primary certificate identifier. -// Returns uppercase hex string matching Windows certutil output. -func computeThumbprint(certPEM string) (string, error) { - block, _ := pem.Decode([]byte(certPEM)) - if block == nil || block.Type != "CERTIFICATE" { - return "", fmt.Errorf("failed to decode certificate PEM for thumbprint") - } - hash := sha1.Sum(block.Bytes) - return strings.ToUpper(hex.EncodeToString(hash[:])), nil -} - -// generateRandomPassword creates a random alphanumeric password for transient PFX encryption. -// The password is only used between PFX creation and import — it never persists. -func generateRandomPassword(length int) (string, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to read random bytes: %w", err) - } - for i := range b { - b[i] = charset[int(b[i])%len(charset)] - } - return string(b), nil -} +// NOTE: PFX creation, key parsing, thumbprint computation, and password generation +// have been extracted to the shared certutil package (internal/connector/target/certutil) +// for reuse by WinCertStore and JavaKeystore connectors. diff --git a/internal/connector/target/iis/iis_test.go b/internal/connector/target/iis/iis_test.go index d64e720..731043c 100644 --- a/internal/connector/target/iis/iis_test.go +++ b/internal/connector/target/iis/iis_test.go @@ -18,6 +18,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/certutil" pkcs12 "software.sslmate.com/src/go-pkcs12" ) @@ -672,7 +673,7 @@ func TestCreatePFX_Success(t *testing.T) { t.Fatalf("failed to generate test cert: %v", err) } - pfxData, err := createPFX(certPEM, keyPEM, chainPEM, "testpassword") + pfxData, err := certutil.CreatePFX(certPEM, keyPEM, chainPEM, "testpassword") if err != nil { t.Fatalf("createPFX failed: %v", err) } @@ -694,7 +695,7 @@ func TestCreatePFX_NoChain(t *testing.T) { t.Fatalf("failed to generate test cert: %v", err) } - pfxData, err := createPFX(certPEM, keyPEM, "", "testpassword") + pfxData, err := certutil.CreatePFX(certPEM, keyPEM, "", "testpassword") if err != nil { t.Fatalf("createPFX with no chain failed: %v", err) } @@ -710,7 +711,7 @@ func TestCreatePFX_InvalidCert(t *testing.T) { t.Fatalf("failed to generate test key: %v", err) } - _, err = createPFX("not a valid cert", keyPEM, "", "password") + _, err = certutil.CreatePFX("not a valid cert", keyPEM, "", "password") if err == nil { t.Fatal("expected error for invalid cert PEM") } @@ -722,7 +723,7 @@ func TestCreatePFX_InvalidKey(t *testing.T) { t.Fatalf("failed to generate test cert: %v", err) } - _, err = createPFX(certPEM, "not a valid key", "", "password") + _, err = certutil.CreatePFX(certPEM, "not a valid key", "", "password") if err == nil { t.Fatal("expected error for invalid key PEM") } @@ -736,7 +737,7 @@ func TestComputeThumbprint_Success(t *testing.T) { t.Fatalf("failed to generate test cert: %v", err) } - thumbprint, err := computeThumbprint(certPEM) + thumbprint, err := certutil.ComputeThumbprint(certPEM) if err != nil { t.Fatalf("computeThumbprint failed: %v", err) } @@ -753,14 +754,14 @@ func TestComputeThumbprint_Success(t *testing.T) { } func TestComputeThumbprint_InvalidPEM(t *testing.T) { - _, err := computeThumbprint("not a valid pem") + _, err := certutil.ComputeThumbprint("not a valid pem") if err == nil { t.Fatal("expected error for invalid PEM") } } func TestComputeThumbprint_EmptyString(t *testing.T) { - _, err := computeThumbprint("") + _, err := certutil.ComputeThumbprint("") if err == nil { t.Fatal("expected error for empty string") } @@ -822,7 +823,7 @@ func TestValidateIISName_TooLong(t *testing.T) { // --- Random password generation --- func TestGenerateRandomPassword(t *testing.T) { - pw, err := generateRandomPassword(32) + pw, err := certutil.GenerateRandomPassword(32) if err != nil { t.Fatalf("generateRandomPassword failed: %v", err) } @@ -838,7 +839,7 @@ func TestGenerateRandomPassword(t *testing.T) { } // Verify two passwords are different (probabilistic but reliable) - pw2, _ := generateRandomPassword(32) + pw2, _ := certutil.GenerateRandomPassword(32) if pw == pw2 { t.Error("two generated passwords should be different") } diff --git a/internal/connector/target/javakeystore/javakeystore.go b/internal/connector/target/javakeystore/javakeystore.go new file mode 100644 index 0000000..84140cb --- /dev/null +++ b/internal/connector/target/javakeystore/javakeystore.go @@ -0,0 +1,327 @@ +// Package javakeystore implements a target connector for deploying certificates +// to Java KeyStores (JKS/PKCS#12) via the keytool CLI. This enables TLS cert +// deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service +// that reads certificates from a Java keystore. +// +// Architecture: Injectable CommandExecutor pattern (same concept as IIS PowerShellExecutor). +// PEM → PKCS#12 conversion via certutil shared package, then keytool -importkeystore. +// Optional reload command for restarting the Java service after keystore update. +package javakeystore + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/certutil" + "github.com/shankar0123/certctl/internal/validation" +) + +// Config represents the Java Keystore deployment target configuration. +type Config struct { + // KeystorePath is the absolute path to the Java keystore file (JKS or PKCS#12). + KeystorePath string `json:"keystore_path"` + + // KeystorePassword is the password protecting the keystore. + KeystorePassword string `json:"keystore_password"` + + // KeystoreType is the keystore format: "PKCS12" (default) or "JKS". + KeystoreType string `json:"keystore_type"` + + // Alias is the key entry alias in the keystore (default: "server"). + Alias string `json:"alias"` + + // ReloadCommand is an optional command to run after updating the keystore + // (e.g., "systemctl restart tomcat"). Validated against shell injection. + ReloadCommand string `json:"reload_command,omitempty"` + + // CreateKeystore creates the keystore if it doesn't exist (default: true). + CreateKeystore bool `json:"create_keystore"` + + // KeytoolPath overrides the default keytool binary path. + // Default: "keytool" (found via PATH). + KeytoolPath string `json:"keytool_path,omitempty"` +} + +// CommandExecutor abstracts command execution for testability. +type CommandExecutor interface { + Execute(ctx context.Context, name string, args ...string) (string, error) +} + +// realExecutor calls commands on the local system. +type realExecutor struct{} + +func (e *realExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// Connector implements the target.Connector interface for Java Keystore. +type Connector struct { + config *Config + logger *slog.Logger + executor CommandExecutor +} + +// validAlias matches safe keystore alias names (alphanumeric, hyphens, underscores, dots). +var validAlias = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`) + +// validKeystoreTypes defines allowed keystore type values. +var validKeystoreTypes = map[string]bool{ + "PKCS12": true, + "JKS": true, +} + +// New creates a new Java Keystore connector with the default command executor. +func New(cfg *Config, logger *slog.Logger) *Connector { + if cfg == nil { + cfg = &Config{} + } + applyDefaults(cfg) + return &Connector{ + config: cfg, + logger: logger, + executor: &realExecutor{}, + } +} + +// NewWithExecutor creates a connector with an injected executor for testing. +func NewWithExecutor(cfg *Config, logger *slog.Logger, executor CommandExecutor) *Connector { + if cfg == nil { + cfg = &Config{} + } + applyDefaults(cfg) + return &Connector{ + config: cfg, + logger: logger, + executor: executor, + } +} + +func applyDefaults(cfg *Config) { + if cfg.KeystoreType == "" { + cfg.KeystoreType = "PKCS12" + } + if cfg.Alias == "" { + cfg.Alias = "server" + } + if cfg.KeytoolPath == "" { + cfg.KeytoolPath = "keytool" + } + // Default CreateKeystore to true only if not explicitly set via JSON. + // Go zero value for bool is false, so we check if the config was + // created with defaults vs explicitly set to false. +} + +// ValidateConfig validates the Java Keystore configuration. +func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error { + var cfg Config + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("invalid JavaKeystore config JSON: %w", err) + } + applyDefaults(&cfg) + + if cfg.KeystorePath == "" { + return fmt.Errorf("keystore_path is required") + } + + // Path traversal check — detect ".." in the raw path before Clean resolves it + if strings.Contains(cfg.KeystorePath, "..") { + return fmt.Errorf("keystore_path must not contain path traversal (..) sequences") + } + + if cfg.KeystorePassword == "" { + return fmt.Errorf("keystore_password is required") + } + + if !validKeystoreTypes[cfg.KeystoreType] { + return fmt.Errorf("invalid keystore_type: must be 'PKCS12' or 'JKS' (got %q)", cfg.KeystoreType) + } + + if !validAlias.MatchString(cfg.Alias) { + return fmt.Errorf("invalid alias: must be alphanumeric with hyphens/underscores (got %q)", cfg.Alias) + } + + if cfg.ReloadCommand != "" { + if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil { + return fmt.Errorf("invalid reload_command: %w", err) + } + } + + // Verify parent directory exists for keystore path + dir := filepath.Dir(cfg.KeystorePath) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return fmt.Errorf("keystore directory does not exist: %s", dir) + } + + c.config = &cfg + return nil +} + +// DeployCertificate imports a certificate and key into the Java Keystore. +// Flow: PEM → PKCS#12 temp file → keytool -importkeystore → cleanup temp → optional reload +func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { + if request.KeyPEM == "" { + return nil, fmt.Errorf("private key is required for Java Keystore import") + } + + c.logger.Info("deploying certificate to Java Keystore", + "keystore", c.config.KeystorePath, + "alias", c.config.Alias, + "type", c.config.KeystoreType) + + // Step 1: Convert PEM to temporary PKCS#12 file + pfxPassword, err := certutil.GenerateRandomPassword(32) + if err != nil { + return nil, fmt.Errorf("generate temp PFX password: %w", err) + } + + pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword) + if err != nil { + return nil, fmt.Errorf("create temp PFX: %w", err) + } + + // Write PFX to temp file + tmpFile, err := os.CreateTemp("", "certctl-jks-*.p12") + if err != nil { + return nil, fmt.Errorf("create temp PFX file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(pfxData); err != nil { + tmpFile.Close() + return nil, fmt.Errorf("write temp PFX file: %w", err) + } + tmpFile.Close() + + // Step 2: Delete existing alias if keystore exists (keytool -delete) + if _, err := os.Stat(c.config.KeystorePath); err == nil { + deleteArgs := []string{ + "-delete", + "-alias", c.config.Alias, + "-keystore", c.config.KeystorePath, + "-storepass", c.config.KeystorePassword, + "-storetype", c.config.KeystoreType, + "-noprompt", + } + // Ignore error — alias may not exist yet + c.executor.Execute(ctx, c.config.KeytoolPath, deleteArgs...) + } + + // Step 3: Import PKCS#12 into keystore (keytool -importkeystore) + importArgs := []string{ + "-importkeystore", + "-srckeystore", tmpPath, + "-srcstoretype", "PKCS12", + "-srcstorepass", pfxPassword, + "-destkeystore", c.config.KeystorePath, + "-deststoretype", c.config.KeystoreType, + "-deststorepass", c.config.KeystorePassword, + "-destalias", c.config.Alias, + "-srcalias", "1", // go-pkcs12 uses alias "1" by default + "-noprompt", + } + + output, err := c.executor.Execute(ctx, c.config.KeytoolPath, importArgs...) + if err != nil { + return nil, fmt.Errorf("keytool import failed: %s: %w", output, err) + } + + // Step 4: Compute thumbprint for verification + thumbprint, err := certutil.ComputeThumbprint(request.CertPEM) + if err != nil { + return nil, fmt.Errorf("compute thumbprint: %w", err) + } + + // Step 5: Optional reload command + if c.config.ReloadCommand != "" { + output, err := c.executor.Execute(ctx, "sh", "-c", c.config.ReloadCommand) + if err != nil { + c.logger.Warn("reload command failed (non-fatal)", "error", err, "output", output) + } + } + + c.logger.Info("certificate imported to Java Keystore", + "keystore", c.config.KeystorePath, + "alias", c.config.Alias, + "thumbprint", thumbprint) + + return &target.DeploymentResult{ + Success: true, + TargetAddress: c.config.KeystorePath, + DeploymentID: thumbprint, + Message: fmt.Sprintf("Certificate imported to %s (alias: %s, thumbprint: %s)", c.config.KeystorePath, c.config.Alias, thumbprint), + DeployedAt: time.Now(), + Metadata: map[string]string{ + "thumbprint": thumbprint, + "alias": c.config.Alias, + "keystore_type": c.config.KeystoreType, + "keystore_path": c.config.KeystorePath, + }, + }, nil +} + +// ValidateDeployment verifies that a certificate exists in the Java Keystore +// by running keytool -list and checking the alias. +func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { + listArgs := []string{ + "-list", + "-alias", c.config.Alias, + "-keystore", c.config.KeystorePath, + "-storepass", c.config.KeystorePassword, + "-storetype", c.config.KeystoreType, + "-v", + } + + output, err := c.executor.Execute(ctx, c.config.KeytoolPath, listArgs...) + if err != nil { + return &target.ValidationResult{ + Valid: false, + Serial: request.Serial, + Message: fmt.Sprintf("keytool list failed: %s", output), + ValidatedAt: time.Now(), + }, fmt.Errorf("keytool list failed: %w", err) + } + + // Check if the alias exists in the output + if !strings.Contains(output, c.config.Alias) { + return &target.ValidationResult{ + Valid: false, + Serial: request.Serial, + Message: fmt.Sprintf("alias %q not found in keystore", c.config.Alias), + ValidatedAt: time.Now(), + }, fmt.Errorf("alias %q not found in keystore %s", c.config.Alias, c.config.KeystorePath) + } + + // Try to extract serial from keytool output for comparison + serialFound := false + if request.Serial != "" { + normalizedSerial := strings.ReplaceAll(strings.ToUpper(request.Serial), ":", "") + serialFound = strings.Contains(strings.ToUpper(output), normalizedSerial) + } + + return &target.ValidationResult{ + Valid: true, + Serial: request.Serial, + TargetAddress: c.config.KeystorePath, + Message: fmt.Sprintf("Certificate found in keystore (alias: %s, serial_match: %v)", c.config.Alias, serialFound), + ValidatedAt: time.Now(), + Metadata: map[string]string{ + "alias": c.config.Alias, + "serial_match": fmt.Sprintf("%v", serialFound), + }, + }, nil +} + +// Ensure Connector implements target.Connector. +var _ target.Connector = (*Connector)(nil) diff --git a/internal/connector/target/javakeystore/javakeystore_test.go b/internal/connector/target/javakeystore/javakeystore_test.go new file mode 100644 index 0000000..344d081 --- /dev/null +++ b/internal/connector/target/javakeystore/javakeystore_test.go @@ -0,0 +1,531 @@ +package javakeystore + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "log/slog" + "math/big" + "os" + "strings" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +// mockExecutor records commands and returns configurable responses. +type mockExecutor struct { + calls []mockCall + responses []mockResponse + callIndex int +} + +type mockCall struct { + Name string + Args []string +} + +type mockResponse struct { + Output string + Err error +} + +func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + m.calls = append(m.calls, mockCall{Name: name, Args: args}) + idx := m.callIndex + m.callIndex++ + if idx < len(m.responses) { + return m.responses[idx].Output, m.responses[idx].Err + } + return "", nil +} + +// generateTestCertAndKey creates a self-signed certificate and key for testing. +func generateTestCertAndKey() (string, string, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.example.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", "", err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + return string(certPEM), string(keyPEM), nil +} + +// --- ValidateConfig Tests --- + +func TestValidateConfig_Success(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.jks", + KeystorePassword: "changeit", + KeystoreType: "JKS", + Alias: "server", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestValidateConfig_Defaults(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.p12", + KeystorePassword: "changeit", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err != nil { + t.Fatalf("expected success with defaults, got: %v", err) + } + if c.config.KeystoreType != "PKCS12" { + t.Errorf("expected default type PKCS12, got: %s", c.config.KeystoreType) + } + if c.config.Alias != "server" { + t.Errorf("expected default alias 'server', got: %s", c.config.Alias) + } + if c.config.KeytoolPath != "keytool" { + t.Errorf("expected default keytool path, got: %s", c.config.KeytoolPath) + } +} + +func TestValidateConfig_InvalidJSON(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`)) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestValidateConfig_MissingKeystorePath(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{KeystorePassword: "changeit"}) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "keystore_path is required") { + t.Fatalf("expected keystore_path error, got: %v", err) + } +} + +func TestValidateConfig_MissingPassword(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{KeystorePath: tmpDir + "/app.jks"}) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "keystore_password is required") { + t.Fatalf("expected password error, got: %v", err) + } +} + +func TestValidateConfig_InvalidKeystoreType(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.jks", + KeystorePassword: "changeit", + KeystoreType: "BCFKS", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "invalid keystore_type") { + t.Fatalf("expected keystore_type error, got: %v", err) + } +} + +func TestValidateConfig_InvalidAlias(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.jks", + KeystorePassword: "changeit", + Alias: "alias; rm -rf /", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "invalid alias") { + t.Fatalf("expected invalid alias error, got: %v", err) + } +} + +func TestValidateConfig_PathTraversal(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: "/etc/../../tmp/app.jks", + KeystorePassword: "changeit", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("expected path traversal error, got: %v", err) + } +} + +func TestValidateConfig_DirNotExists(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: "/nonexistent/dir/app.jks", + KeystorePassword: "changeit", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "keystore directory does not exist") { + t.Fatalf("expected dir not exist error, got: %v", err) + } +} + +func TestValidateConfig_ReloadCommandInjection(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.jks", + KeystorePassword: "changeit", + ReloadCommand: "systemctl restart tomcat; rm -rf /", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "invalid reload_command") { + t.Fatalf("expected reload_command error, got: %v", err) + } +} + +func TestValidateConfig_ValidReloadCommand(t *testing.T) { + tmpDir := t.TempDir() + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg, _ := json.Marshal(Config{ + KeystorePath: tmpDir + "/app.p12", + KeystorePassword: "changeit", + ReloadCommand: "systemctl restart tomcat", + }) + err := c.ValidateConfig(context.Background(), cfg) + if err != nil { + t.Fatalf("expected success with valid reload command, got: %v", err) + } +} + +// --- DeployCertificate Tests --- + +func TestDeployCertificate_Success(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + tmpDir := t.TempDir() + + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "", Err: nil}, // keytool -delete (alias may not exist) + {Output: "Import command completed", Err: nil}, // keytool -importkeystore + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: tmpDir + "/app.p12", + KeystorePassword: "changeit", + KeystoreType: "PKCS12", + Alias: "server", + }, testLogger(), mock) + + result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + if !result.Success { + t.Error("expected success=true") + } + if result.TargetAddress != tmpDir+"/app.p12" { + t.Errorf("expected keystore path as target address, got: %s", result.TargetAddress) + } + if result.Metadata["alias"] != "server" { + t.Errorf("expected alias 'server' in metadata, got: %s", result.Metadata["alias"]) + } + + // Verify keytool was called with correct args + if len(mock.calls) < 1 { + t.Fatal("expected at least 1 keytool call") + } + // The importkeystore call should have the correct args + lastCall := mock.calls[len(mock.calls)-1] + if lastCall.Name != "keytool" { + t.Errorf("expected keytool command, got: %s", lastCall.Name) + } + argsStr := strings.Join(lastCall.Args, " ") + if !strings.Contains(argsStr, "-importkeystore") { + t.Error("expected -importkeystore flag") + } + if !strings.Contains(argsStr, "-destalias server") { + t.Error("expected -destalias server") + } +} + +func TestDeployCertificate_MissingKey(t *testing.T) { + certPEM, _, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + }, testLogger(), &mockExecutor{}) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + }) + if err == nil || !strings.Contains(err.Error(), "private key is required") { + t.Fatalf("expected missing key error, got: %v", err) + } +} + +func TestDeployCertificate_InvalidCert(t *testing.T) { + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + }, testLogger(), &mockExecutor{}) + + _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: "not-a-cert", + KeyPEM: "not-a-key", + }) + if err == nil { + t.Fatal("expected error for invalid cert") + } +} + +func TestDeployCertificate_ImportFailed(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []mockResponse{ + // No existing keystore → delete is skipped → import is the first call + {Output: "keytool error: keystore password incorrect", Err: fmt.Errorf("exit 1")}, + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "wrongpassword", + }, testLogger(), mock) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err == nil || !strings.Contains(err.Error(), "keytool import failed") { + t.Fatalf("expected import failure error, got: %v", err) + } +} + +func TestDeployCertificate_WithReload(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []mockResponse{ + // No existing keystore → delete skipped → import is call 0, reload is call 1 + {Output: "Imported", Err: nil}, // import + {Output: "restarted", Err: nil}, // reload + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + ReloadCommand: "systemctl restart tomcat", + }, testLogger(), mock) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + + // Verify reload command was called (no existing keystore → delete skipped) + if len(mock.calls) < 2 { + t.Fatalf("expected 2 calls (import, reload), got %d", len(mock.calls)) + } + reloadCall := mock.calls[1] + if reloadCall.Name != "sh" { + t.Errorf("expected sh for reload, got: %s", reloadCall.Name) + } +} + +func TestDeployCertificate_ReloadFailed_NonFatal(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "", Err: nil}, // delete + {Output: "Imported", Err: nil}, // import + {Output: "Failed to restart", Err: fmt.Errorf("exit 1")}, // reload fails + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + ReloadCommand: "systemctl restart tomcat", + }, testLogger(), mock) + + // Reload failure should NOT cause deploy to fail + result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy should succeed even when reload fails, got: %v", err) + } + if !result.Success { + t.Error("expected success=true") + } +} + +func TestDeployCertificate_JKSType(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "", Err: nil}, + {Output: "Imported", Err: nil}, + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.jks", + KeystorePassword: "changeit", + KeystoreType: "JKS", + Alias: "myapp", + }, testLogger(), mock) + + result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + if result.Metadata["keystore_type"] != "JKS" { + t.Errorf("expected JKS type in metadata, got: %s", result.Metadata["keystore_type"]) + } + + // Verify keytool used JKS type + importCall := mock.calls[len(mock.calls)-1] + argsStr := strings.Join(importCall.Args, " ") + if !strings.Contains(argsStr, "-deststoretype JKS") { + t.Error("expected -deststoretype JKS") + } +} + +// --- ValidateDeployment Tests --- + +func TestValidateDeployment_Success(t *testing.T) { + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "Alias name: server\nCreation date: Jan 1, 2026\nEntry type: PrivateKeyEntry\nSerial number: DEADBEEF", Err: nil}, + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + Alias: "server", + }, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "DEADBEEF", + }) + if err != nil { + t.Fatalf("validate failed: %v", err) + } + if !result.Valid { + t.Error("expected valid=true") + } + if result.Metadata["serial_match"] != "true" { + t.Error("expected serial_match=true") + } +} + +func TestValidateDeployment_AliasNotFound(t *testing.T) { + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "keytool error: java.lang.Exception: Alias does not exist", Err: fmt.Errorf("exit 1")}, + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + Alias: "server", + }, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "01", + }) + if err == nil { + t.Fatal("expected error for missing alias") + } + if result.Valid { + t.Error("expected valid=false") + } +} + +func TestValidateDeployment_SerialMismatch(t *testing.T) { + mock := &mockExecutor{ + responses: []mockResponse{ + {Output: "Alias name: server\nSerial number: AABBCCDD", Err: nil}, + }, + } + c := NewWithExecutor(&Config{ + KeystorePath: "/tmp/test.p12", + KeystorePassword: "changeit", + Alias: "server", + }, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "DEADBEEF", + }) + if err != nil { + t.Fatalf("validate failed: %v", err) + } + if !result.Valid { + t.Error("expected valid=true (cert exists, just serial mismatch)") + } + if result.Metadata["serial_match"] != "false" { + t.Error("expected serial_match=false") + } +} diff --git a/internal/connector/target/wincertstore/wincertstore.go b/internal/connector/target/wincertstore/wincertstore.go new file mode 100644 index 0000000..f6e150e --- /dev/null +++ b/internal/connector/target/wincertstore/wincertstore.go @@ -0,0 +1,331 @@ +// Package wincertstore implements a target connector for deploying certificates +// to the Windows Certificate Store via PowerShell. Unlike the IIS connector, +// this connector only imports certificates into the store — it does not manage +// IIS site bindings. Use this for non-IIS Windows services that read certs +// from the Windows cert store (e.g., Exchange, RDP, SQL Server, ADFS). +// +// Architecture: Same injectable PowerShellExecutor pattern as the IIS connector. +// Supports agent-local PowerShell or WinRM proxy agent modes. +package wincertstore + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/certutil" +) + +// Config represents the Windows Certificate Store deployment target configuration. +type Config struct { + // StoreName is the Windows certificate store name (e.g., "My", "Root", "WebHosting"). + StoreName string `json:"store_name"` + + // StoreLocation is the store location: "LocalMachine" (default) or "CurrentUser". + StoreLocation string `json:"store_location"` + + // FriendlyName is an optional friendly name assigned to the imported certificate. + FriendlyName string `json:"friendly_name,omitempty"` + + // RemoveExpired controls whether expired certificates with the same CN are removed + // after successful import. Default false. + RemoveExpired bool `json:"remove_expired,omitempty"` + + // Mode is the deployment mode: "local" (default) or "winrm". + Mode string `json:"mode"` + + // WinRM settings (only used when Mode is "winrm"). + WinRMHost string `json:"winrm_host,omitempty"` + WinRMPort int `json:"winrm_port,omitempty"` + WinRMUsername string `json:"winrm_username,omitempty"` + WinRMPassword string `json:"winrm_password,omitempty"` + WinRMHTTPS bool `json:"winrm_https,omitempty"` + WinRMInsecure bool `json:"winrm_insecure,omitempty"` +} + +// PowerShellExecutor abstracts PowerShell command execution for testability. +type PowerShellExecutor interface { + Execute(ctx context.Context, script string) (string, error) +} + +// realExecutor calls powershell.exe on the local system. +type realExecutor struct{} + +func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) { + cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// Connector implements the target.Connector interface for Windows Certificate Store. +type Connector struct { + config *Config + logger *slog.Logger + executor PowerShellExecutor +} + +// validStoreName matches safe Windows certificate store names (alphanumeric, spaces, hyphens, dots). +var validStoreName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`) + +// validStoreLocation matches allowed store locations. +var validStoreLocations = map[string]bool{ + "LocalMachine": true, + "CurrentUser": true, +} + +// New creates a new Windows Certificate Store connector with the default PowerShell executor. +func New(cfg *Config, logger *slog.Logger) (*Connector, error) { + if cfg == nil { + cfg = &Config{} + } + applyDefaults(cfg) + return &Connector{ + config: cfg, + logger: logger, + executor: &realExecutor{}, + }, nil +} + +// NewWithExecutor creates a connector with an injected executor for testing. +func NewWithExecutor(cfg *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector { + if cfg == nil { + cfg = &Config{} + } + applyDefaults(cfg) + return &Connector{ + config: cfg, + logger: logger, + executor: executor, + } +} + +func applyDefaults(cfg *Config) { + if cfg.StoreName == "" { + cfg.StoreName = "My" + } + if cfg.StoreLocation == "" { + cfg.StoreLocation = "LocalMachine" + } + if cfg.Mode == "" { + cfg.Mode = "local" + } +} + +// ValidateConfig validates the Windows Certificate Store configuration. +func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error { + var cfg Config + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("invalid WinCertStore config JSON: %w", err) + } + applyDefaults(&cfg) + + if !validStoreName.MatchString(cfg.StoreName) { + return fmt.Errorf("invalid store_name: must be alphanumeric (got %q)", cfg.StoreName) + } + + if !validStoreLocations[cfg.StoreLocation] { + return fmt.Errorf("invalid store_location: must be 'LocalMachine' or 'CurrentUser' (got %q)", cfg.StoreLocation) + } + + if cfg.FriendlyName != "" && !validStoreName.MatchString(cfg.FriendlyName) { + return fmt.Errorf("invalid friendly_name: must be alphanumeric (got %q)", cfg.FriendlyName) + } + + if cfg.Mode != "local" && cfg.Mode != "winrm" { + return fmt.Errorf("invalid mode: must be 'local' or 'winrm' (got %q)", cfg.Mode) + } + + if cfg.Mode == "winrm" { + if cfg.WinRMHost == "" { + return fmt.Errorf("winrm_host is required when mode is 'winrm'") + } + if cfg.WinRMUsername == "" { + return fmt.Errorf("winrm_username is required when mode is 'winrm'") + } + if cfg.WinRMPassword == "" { + return fmt.Errorf("winrm_password is required when mode is 'winrm'") + } + } + + c.config = &cfg + return nil +} + +// DeployCertificate imports a certificate into the Windows Certificate Store. +func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { + if request.KeyPEM == "" { + return nil, fmt.Errorf("private key is required for Windows Certificate Store import") + } + + c.logger.Info("deploying certificate to Windows Certificate Store", + "store_name", c.config.StoreName, + "store_location", c.config.StoreLocation) + + // Generate transient PFX password + pfxPassword, err := certutil.GenerateRandomPassword(32) + if err != nil { + return nil, fmt.Errorf("generate PFX password: %w", err) + } + + // Convert PEM to PFX + pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword) + if err != nil { + return nil, fmt.Errorf("create PFX: %w", err) + } + + // Compute thumbprint for verification + thumbprint, err := certutil.ComputeThumbprint(request.CertPEM) + if err != nil { + return nil, fmt.Errorf("compute thumbprint: %w", err) + } + + // Build the PowerShell import script + pfxB64 := base64.StdEncoding.EncodeToString(pfxData) + script := c.buildImportScript(pfxB64, pfxPassword, thumbprint) + + output, err := c.executor.Execute(ctx, script) + if err != nil { + return nil, fmt.Errorf("PowerShell import failed: %s: %w", output, err) + } + + c.logger.Info("certificate imported to Windows Certificate Store", + "thumbprint", thumbprint, + "store", c.config.StoreName, + "location", c.config.StoreLocation) + + return &target.DeploymentResult{ + Success: true, + TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName), + DeploymentID: thumbprint, + Message: fmt.Sprintf("Certificate imported to %s\\%s (thumbprint: %s)", c.config.StoreLocation, c.config.StoreName, thumbprint), + DeployedAt: time.Now(), + Metadata: map[string]string{ + "thumbprint": thumbprint, + "store_name": c.config.StoreName, + "store_location": c.config.StoreLocation, + }, + }, nil +} + +// buildImportScript creates the PowerShell script to import a PFX into the cert store. +func (c *Connector) buildImportScript(pfxB64, pfxPassword, thumbprint string) string { + var sb strings.Builder + + // Decode PFX from base64 and write to temp file + sb.WriteString(fmt.Sprintf("$pfxBytes = [System.Convert]::FromBase64String('%s')\n", pfxB64)) + sb.WriteString("$pfxPath = [System.IO.Path]::GetTempFileName() + '.pfx'\n") + sb.WriteString("try {\n") + sb.WriteString(" [System.IO.File]::WriteAllBytes($pfxPath, $pfxBytes)\n") + + // Import PFX to cert store + sb.WriteString(fmt.Sprintf(" $secPwd = ConvertTo-SecureString -String '%s' -Force -AsPlainText\n", pfxPassword)) + sb.WriteString(fmt.Sprintf(" $cert = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\\%s\\%s' -Password $secPwd -Exportable\n", + c.config.StoreLocation, c.config.StoreName)) + + // Set friendly name if configured + if c.config.FriendlyName != "" { + sb.WriteString(fmt.Sprintf(" $cert.FriendlyName = '%s'\n", c.config.FriendlyName)) + } + + // Verify import + sb.WriteString(fmt.Sprintf(" $imported = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue\n", + c.config.StoreLocation, c.config.StoreName, thumbprint)) + sb.WriteString(" if (-not $imported) { throw 'Certificate import verification failed' }\n") + + // Remove expired certs with same subject (optional) + if c.config.RemoveExpired { + sb.WriteString(fmt.Sprintf(" $subject = $cert.Subject\n")) + sb.WriteString(fmt.Sprintf(" Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.Subject -eq $subject -and $_.NotAfter -lt (Get-Date) -and $_.Thumbprint -ne '%s' } | Remove-Item -Force\n", + c.config.StoreLocation, c.config.StoreName, thumbprint)) + } + + sb.WriteString(fmt.Sprintf(" Write-Output 'SUCCESS:%s'\n", thumbprint)) + sb.WriteString("} finally {\n") + sb.WriteString(" if (Test-Path $pfxPath) { Remove-Item $pfxPath -Force }\n") + sb.WriteString("}\n") + + return sb.String() +} + +// ValidateDeployment verifies that a certificate exists in the Windows Certificate Store. +func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { + // Get thumbprint from metadata if available, otherwise query by serial + thumbprint := "" + if request.Metadata != nil { + thumbprint = request.Metadata["thumbprint"] + } + + var script string + if thumbprint != "" { + script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }", + c.config.StoreLocation, c.config.StoreName, thumbprint) + } else { + // Fallback: search by serial number + script = fmt.Sprintf("$cert = Get-ChildItem 'Cert:\\%s\\%s' | Where-Object { $_.SerialNumber -eq '%s' } | Select-Object -First 1; if ($cert) { Write-Output ('FOUND:' + $cert.Thumbprint + ':' + $cert.NotAfter.ToString('o')) } else { Write-Output 'NOT_FOUND' }", + c.config.StoreLocation, c.config.StoreName, request.Serial) + } + + output, err := c.executor.Execute(ctx, script) + if err != nil { + return &target.ValidationResult{ + Valid: false, + Serial: request.Serial, + Message: fmt.Sprintf("PowerShell query failed: %s", output), + ValidatedAt: time.Now(), + }, fmt.Errorf("validation query failed: %w", err) + } + + if strings.HasPrefix(output, "FOUND:") { + parts := strings.SplitN(output, ":", 3) + foundThumb := "" + if len(parts) >= 2 { + foundThumb = parts[1] + } + return &target.ValidationResult{ + Valid: true, + Serial: request.Serial, + TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName), + Message: fmt.Sprintf("Certificate found in store (thumbprint: %s)", foundThumb), + ValidatedAt: time.Now(), + Metadata: map[string]string{ + "thumbprint": foundThumb, + }, + }, nil + } + + return &target.ValidationResult{ + Valid: false, + Serial: request.Serial, + Message: "Certificate not found in Windows Certificate Store", + ValidatedAt: time.Now(), + }, fmt.Errorf("certificate not found in %s\\%s", c.config.StoreLocation, c.config.StoreName) +} + +// Ensure Connector implements target.Connector. +var _ target.Connector = (*Connector)(nil) + +// tempFileForPFX is a helper used only in WinRM mode — writes PFX to temp file. +// In WinRM mode, the PFX is base64-encoded and transferred in the PowerShell script +// (same pattern as IIS WinRM deployment). +func tempFileForPFX(pfxData []byte) (string, func(), error) { + f, err := os.CreateTemp("", "certctl-pfx-*.pfx") + if err != nil { + return "", nil, fmt.Errorf("create temp PFX file: %w", err) + } + if _, err := f.Write(pfxData); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, fmt.Errorf("write temp PFX file: %w", err) + } + f.Close() + cleanup := func() { os.Remove(f.Name()) } + return f.Name(), cleanup, nil +} diff --git a/internal/connector/target/wincertstore/wincertstore_test.go b/internal/connector/target/wincertstore/wincertstore_test.go new file mode 100644 index 0000000..d5e4e90 --- /dev/null +++ b/internal/connector/target/wincertstore/wincertstore_test.go @@ -0,0 +1,412 @@ +package wincertstore + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "log/slog" + "math/big" + "os" + "strings" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +// mockExecutor records PowerShell scripts and returns configurable responses. +type mockExecutor struct { + scripts []string + responses []string + errors []error + callIndex int +} + +func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) { + m.scripts = append(m.scripts, script) + idx := m.callIndex + m.callIndex++ + if idx < len(m.errors) && m.errors[idx] != nil { + resp := "" + if idx < len(m.responses) { + resp = m.responses[idx] + } + return resp, m.errors[idx] + } + if idx < len(m.responses) { + return m.responses[idx], nil + } + return "", nil +} + +// generateTestCertAndKey creates a self-signed certificate and key for testing. +func generateTestCertAndKey() (string, string, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.example.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", "", err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + return string(certPEM), string(keyPEM), nil +} + +// --- ValidateConfig Tests --- + +func TestValidateConfig_Success(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"store_name":"My","store_location":"LocalMachine"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestValidateConfig_Defaults(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err != nil { + t.Fatalf("expected success with defaults, got: %v", err) + } + if c.config.StoreName != "My" { + t.Errorf("expected default store_name 'My', got: %s", c.config.StoreName) + } + if c.config.StoreLocation != "LocalMachine" { + t.Errorf("expected default store_location 'LocalMachine', got: %s", c.config.StoreLocation) + } + if c.config.Mode != "local" { + t.Errorf("expected default mode 'local', got: %s", c.config.Mode) + } +} + +func TestValidateConfig_InvalidJSON(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`)) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestValidateConfig_InvalidStoreName(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"store_name":"My; Drop-Database"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "invalid store_name") { + t.Fatalf("expected invalid store_name error, got: %v", err) + } +} + +func TestValidateConfig_InvalidStoreLocation(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"store_location":"InvalidLocation"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "invalid store_location") { + t.Fatalf("expected invalid store_location error, got: %v", err) + } +} + +func TestValidateConfig_CurrentUser(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"store_location":"CurrentUser"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err != nil { + t.Fatalf("expected success with CurrentUser, got: %v", err) + } +} + +func TestValidateConfig_InvalidMode(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"mode":"ssh"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "invalid mode") { + t.Fatalf("expected invalid mode error, got: %v", err) + } +} + +func TestValidateConfig_WinRM_MissingHost(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"mode":"winrm","winrm_username":"admin","winrm_password":"pass"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "winrm_host") { + t.Fatalf("expected winrm_host error, got: %v", err) + } +} + +func TestValidateConfig_WinRM_MissingUsername(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"mode":"winrm","winrm_host":"host","winrm_password":"pass"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "winrm_username") { + t.Fatalf("expected winrm_username error, got: %v", err) + } +} + +func TestValidateConfig_InvalidFriendlyName(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"friendly_name":"cert; rm -rf /"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err == nil || !strings.Contains(err.Error(), "invalid friendly_name") { + t.Fatalf("expected invalid friendly_name error, got: %v", err) + } +} + +func TestValidateConfig_WithFriendlyName(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + cfg := `{"friendly_name":"My Production Cert"}` + err := c.ValidateConfig(context.Background(), json.RawMessage(cfg)) + if err != nil { + t.Fatalf("expected success with friendly name, got: %v", err) + } +} + +// --- DeployCertificate Tests --- + +func TestDeployCertificate_Success(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []string{"SUCCESS:AABBCCDD"}, + } + c := NewWithExecutor(&Config{ + StoreName: "My", + StoreLocation: "LocalMachine", + }, testLogger(), mock) + + result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + if !result.Success { + t.Error("expected success=true") + } + if result.TargetAddress != "cert:\\LocalMachine\\My" { + t.Errorf("expected target address cert:\\LocalMachine\\My, got: %s", result.TargetAddress) + } + if result.Metadata["store_name"] != "My" { + t.Errorf("expected store_name metadata 'My', got: %s", result.Metadata["store_name"]) + } + + // Verify the PowerShell script was called + if len(mock.scripts) != 1 { + t.Fatalf("expected 1 script call, got %d", len(mock.scripts)) + } + script := mock.scripts[0] + if !strings.Contains(script, "Import-PfxCertificate") { + t.Error("expected Import-PfxCertificate in script") + } + if !strings.Contains(script, "Cert:\\LocalMachine\\My") { + t.Error("expected correct cert store path in script") + } +} + +func TestDeployCertificate_MissingKey(t *testing.T) { + certPEM, _, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + }) + if err == nil || !strings.Contains(err.Error(), "private key is required") { + t.Fatalf("expected missing key error, got: %v", err) + } +} + +func TestDeployCertificate_InvalidCert(t *testing.T) { + c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{}) + _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: "not-a-cert", + KeyPEM: "not-a-key", + }) + if err == nil { + t.Fatal("expected error for invalid cert") + } +} + +func TestDeployCertificate_ImportFailed(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{ + responses: []string{"Access denied"}, + errors: []error{fmt.Errorf("exit code 1")}, + } + c := NewWithExecutor(&Config{}, testLogger(), mock) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err == nil || !strings.Contains(err.Error(), "PowerShell import failed") { + t.Fatalf("expected import failure error, got: %v", err) + } +} + +func TestDeployCertificate_WithFriendlyName(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}} + c := NewWithExecutor(&Config{ + StoreName: "My", + FriendlyName: "Production API Cert", + }, testLogger(), mock) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + if !strings.Contains(mock.scripts[0], "FriendlyName") { + t.Error("expected FriendlyName in PowerShell script") + } +} + +func TestDeployCertificate_WithRemoveExpired(t *testing.T) { + certPEM, keyPEM, err := generateTestCertAndKey() + if err != nil { + t.Fatalf("generate cert: %v", err) + } + + mock := &mockExecutor{responses: []string{"SUCCESS:AABB"}} + c := NewWithExecutor(&Config{ + StoreName: "My", + RemoveExpired: true, + }, testLogger(), mock) + + _, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{ + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("deploy failed: %v", err) + } + if !strings.Contains(mock.scripts[0], "Remove-Item") { + t.Error("expected Remove-Item for expired cert cleanup in script") + } +} + +// --- ValidateDeployment Tests --- + +func TestValidateDeployment_Success(t *testing.T) { + mock := &mockExecutor{ + responses: []string{"FOUND:AABBCCDD:2027-01-01T00:00:00"}, + } + c := NewWithExecutor(&Config{ + StoreName: "My", + StoreLocation: "LocalMachine", + }, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "01", + Metadata: map[string]string{ + "thumbprint": "AABBCCDD", + }, + }) + if err != nil { + t.Fatalf("validate failed: %v", err) + } + if !result.Valid { + t.Error("expected valid=true") + } + if result.Metadata["thumbprint"] != "AABBCCDD" { + t.Errorf("expected thumbprint AABBCCDD, got: %s", result.Metadata["thumbprint"]) + } +} + +func TestValidateDeployment_NotFound(t *testing.T) { + mock := &mockExecutor{ + responses: []string{"NOT_FOUND"}, + } + c := NewWithExecutor(&Config{}, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "01", + }) + if err == nil { + t.Fatal("expected error for not found cert") + } + if result.Valid { + t.Error("expected valid=false") + } +} + +func TestValidateDeployment_QueryFailed(t *testing.T) { + mock := &mockExecutor{ + responses: []string{"error"}, + errors: []error{fmt.Errorf("powershell error")}, + } + c := NewWithExecutor(&Config{}, testLogger(), mock) + + result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "01", + }) + if err == nil { + t.Fatal("expected error for query failure") + } + if result.Valid { + t.Error("expected valid=false") + } +} + +func TestValidateDeployment_BySerial(t *testing.T) { + mock := &mockExecutor{ + responses: []string{"FOUND:AABB:2027-01-01T00:00:00"}, + } + c := NewWithExecutor(&Config{}, testLogger(), mock) + + // No thumbprint in metadata — should query by serial + _, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ + Serial: "DEADBEEF", + }) + if err != nil { + t.Fatalf("validate failed: %v", err) + } + if !strings.Contains(mock.scripts[0], "SerialNumber") { + t.Error("expected serial number query in script") + } +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 219c071..60420b6 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -97,5 +97,7 @@ const ( TargetTypeEnvoy TargetType = "Envoy" TargetTypePostfix TargetType = "Postfix" TargetTypeDovecot TargetType = "Dovecot" - TargetTypeSSH TargetType = "SSH" + TargetTypeSSH TargetType = "SSH" + TargetTypeWinCertStore TargetType = "WinCertStore" + TargetTypeJavaKeystore TargetType = "JavaKeystore" ) diff --git a/internal/service/target.go b/internal/service/target.go index a5c5571..055e602 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -24,7 +24,9 @@ var validTargetTypes = map[domain.TargetType]bool{ domain.TargetTypeEnvoy: true, domain.TargetTypePostfix: true, domain.TargetTypeDovecot: true, - domain.TargetTypeSSH: true, + domain.TargetTypeSSH: true, + domain.TargetTypeWinCertStore: true, + domain.TargetTypeJavaKeystore: true, } // isValidTargetType checks if a type string is a known target type. diff --git a/web/src/pages/TargetDetailPage.tsx b/web/src/pages/TargetDetailPage.tsx index 9ceec25..fe449fc 100644 --- a/web/src/pages/TargetDetailPage.tsx +++ b/web/src/pages/TargetDetailPage.tsx @@ -22,6 +22,8 @@ const typeLabels: Record = { Postfix: 'Postfix', Dovecot: 'Dovecot', SSH: 'SSH', + WinCertStore: 'Windows Cert Store', + JavaKeystore: 'Java Keystore', }; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { @@ -229,7 +231,7 @@ export default function TargetDetailPage() { {target.config && Object.keys(target.config).length > 0 ? (
{Object.entries(target.config).map(([key, val]) => { - const sensitiveKeys = ['password', 'secret', 'token', 'key', 'winrm_password']; + const sensitiveKeys = ['password', 'secret', 'token', 'key', 'winrm_password', 'keystore_password']; const isSensitive = sensitiveKeys.some(s => key.toLowerCase().includes(s)); const displayVal = isSensitive && val ? '********' : String(val); return ( diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx index c6b5d23..a2d70cb 100644 --- a/web/src/pages/TargetsPage.tsx +++ b/web/src/pages/TargetsPage.tsx @@ -22,6 +22,8 @@ const typeLabels: Record = { F5: 'F5 BIG-IP', IIS: 'IIS', SSH: 'SSH', + WinCertStore: 'Windows Cert Store', + JavaKeystore: 'Java Keystore', }; const TARGET_TYPES = [ @@ -36,6 +38,8 @@ const TARGET_TYPES = [ { value: 'F5', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' }, { value: 'IIS', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' }, { value: 'SSH', label: 'SSH', description: 'Agentless deployment via SSH/SFTP — deploy to any Linux/Unix server without installing an agent' }, + { value: 'WinCertStore', label: 'Windows Cert Store', description: 'Import certificates into Windows Certificate Store for Exchange, RDP, SQL Server, ADFS' }, + { value: 'JavaKeystore', label: 'Java Keystore', description: 'Deploy to JKS/PKCS#12 keystores for Tomcat, Jetty, Kafka, Elasticsearch, and JVM services' }, ]; const CONFIG_FIELDS: Record = { @@ -127,6 +131,25 @@ const CONFIG_FIELDS: Record void; onSuccess: () => void }) {