// 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)