mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
328 lines
10 KiB
Go
328 lines
10 KiB
Go
// 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)
|