mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 19:49:33 +00:00
7d6ef44e21
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 <noreply@anthropic.com>
532 lines
15 KiB
Go
532 lines
15 KiB
Go
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 <server> 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")
|
|
}
|
|
}
|