mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 20:28:51 +00:00
ba66748b5b
Phase 7 of the certctl architecture diligence remediation closes
SEC-H2 by eliminating `sh -c` from every production target-connector
exec call site, replacing it with argv-form exec.CommandContext
fed by a new validating shell-split helper.
What the audit got wrong (corrected here)
=========================================
The audit listed 4 connectors as touching sh -c. Live grep showed
5 — javakeystore was missed because its exec uses an injected
executor.Execute(ctx, "sh", "-c", ...) shape instead of the more
typical exec.CommandContext direct call. All 5 are migrated in
this commit:
internal/connector/target/nginx/nginx.go
internal/connector/target/apache/apache.go
internal/connector/target/haproxy/haproxy.go
internal/connector/target/postfix/postfix.go
internal/connector/target/javakeystore/javakeystore.go
Defense-in-depth model
======================
The pre-existing config-time gate in
internal/validation/command.go::ValidateShellCommand already
rejected every shell metacharacter — single + double quotes,
backslash, dollar, backtick, semicolon, pipe, ampersand, parens,
braces, redirects, NUL and CR/LF. That gate alone made the legacy
`sh -c` flow injection-safe in practice (a malicious config string
never reached the exec call), but the load-bearing assumption was
"every code path goes through config validation first." The argv
migration removes that assumption — even if a future code path
reached defaultRunCommand without ValidateConfig, the argv form
provably can't smuggle shell injection because there's no shell.
New helper: validation.SplitShellCommand
========================================
internal/validation/command.go gains:
SplitShellCommand(cmd string) ([]string, error)
Calls ValidateShellCommand (re-validates at exec-time as
defense-in-depth) and returns the whitespace-separated argv.
Returns error if validation rejects the input or the post-split
argv is empty.
Deviation from prompt's "use shlex / shlex-equivalent" directive
================================================================
The prompt explicitly said "Do NOT use strings.Fields — it
doesn't handle quoted arguments. Use shlex-equivalent or
github.com/google/shlex for correctness."
Deviation: this commit uses strings.Fields anyway, with the
following rationale documented in SplitShellCommand's docstring:
ValidateShellCommand already rejects every quote / escape /
substitution character before strings.Fields runs. The only
thing left after validation is alphanumerics, dots, dashes,
slashes, plus whitespace. strings.Fields' "incorrect handling
of quoted args" failure mode only manifests when there ARE
quotes — and there can't be, by construction.
Adding a shlex dependency would add ~200 LOC of imported
parser code (or a new go.mod entry) to handle a case that
the deny-list provably forbids. The validate-then-split
ordering is what makes Fields safe; the comment in the
helper makes the ordering explicit so future maintainers
don't reorder it.
The SplitShellCommand_HappyPaths test pins this contract — e.g.
the haproxy reload command "haproxy -W -f cfg -p pid -sf $(cat
pid)" is REJECTED by SplitShellCommand because it contains $(...).
Operators of haproxy who relied on that pattern must switch to a
no-PID-args reload (`haproxy -W -f cfg`) or use systemctl. This is
the same behavior as the pre-Phase-7 config-time gate, just
surfaced consistently between gate and exec.
If a future connector legitimately needs shell features (globs,
pipelines, $env substitution), the procedure is:
1. Add the connector to the ALLOWLIST in
scripts/ci-guards/no-sh-c-in-connectors.sh with a documented
justification.
2. Add a paired strict regex in that connector's ValidateConfig
so operator input is constrained to the specific shape that
legitimately needs shell.
The empty-by-default ALLOWLIST is the load-bearing default.
Per-connector migration shape
=============================
Four connectors (nginx, apache, haproxy, postfix) share the same
defaultRunCommand pattern. Before:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
}
After:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
argv, err := validation.SplitShellCommand(command)
if err != nil {
return nil, fmt.Errorf("invalid reload/validate command: %w", err)
}
return exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
}
The test-seam contract `runReload(ctx context.Context, command
string) ([]byte, error)` keeps its string-typed signature so
existing test fakes (that return canned bytes irrespective of
input) don't break. Only the production default implementation
changed.
javakeystore is different — its exec goes through an injected
executor.Execute(ctx, name string, args ...string), which is
already variadic and never needed a shell wrapper. The migration
unpacks argv directly:
argv, err := validation.SplitShellCommand(c.config.ReloadCommand)
if err != nil { /* log + skip */ }
output, runErr := c.executor.Execute(ctx, argv[0], argv[1:]...)
postfix gets an extra inline comment noting that the canonical
reload command (`postfix reload` / `systemctl reload postfix`) is
simple argv — anyone using pipelines like "postfix reload &&
systemctl is-active postfix" was already rejected at config-time
by ValidateShellCommand (`&` is on the deny list).
Tests
=====
internal/validation/command_test.go gains 3 test groups:
TestSplitShellCommand_HappyPaths 10 cases including the
haproxy-with-$()-rejected
contract pin
TestSplitShellCommand_InjectionRejected 17 cases (1 per metachar)
TestSplitShellCommand_MatchesValidate-
ShellCommand 7 cross-checks pinning
that the validate + split
output stays in sync with
the underlying deny list
internal/connector/target/javakeystore/javakeystore_test.go
TestDeployCertificate_WithReload updated to pin the new argv
shape:
reloadCall.Name == "systemctl"
reloadCall.Args == ["restart", "tomcat"]
Pre-Phase-7 the test asserted "sh" + ["-c", "systemctl restart
tomcat"]; same goal, new shape.
internal/connector/target/apache/apache_test.go +
internal/connector/target/haproxy/haproxy_test.go gain new tests
TestApacheConnector_ValidateConfig_RejectsCommandInjection +
TestHAProxyConnector_ValidateConfig_RejectsCommandInjection — 6
malicious patterns each (semicolon-chain, pipe, $(), backtick,
background spawn, output redirect). Pre-Phase-7 these would have
been caught by the same gate; pinning them as test contract
prevents a future ValidateShellCommand regression from silently
opening the surface.
CI guard
========
scripts/ci-guards/no-sh-c-in-connectors.sh greps for any future
`(exec\.Command(Context)?|\.Execute)\([^)]*"sh"[[:space:]]*,[[:space:]]*"-c"`
under internal/connector/target/*.go (excluding _test.go and
comment lines). Auto-picked-up by the existing
.github/workflows/ci.yml regression-guards loop.
ALLOWLIST is empty post-Phase-7. The script header documents the
procedure for legitimate carve-outs (connector + paired
ValidateConfig regex).
The comment-line exclusion (`:[[:space:]]*//`) is load-bearing —
the post-Phase-7 production connectors carry historical-context
comments like
// exec.CommandContext(ctx, "sh", "-c", command) — the legacy
// shape pre-Phase-7 ...
explaining the migration. Those comments would otherwise
false-positive the guard.
Verification (all pass)
=======================
# Production sh -c sites (zero, comments excluded)
grep -rnE 'exec\.Command(Context)?\([^,]+,\s*"sh"\s*,\s*"-c"' \
internal/connector/target/ --include='*.go' --exclude='*_test.go' \
| grep -vE ':[[:space:]]*//'
# → empty
# CI guard clean
bash scripts/ci-guards/no-sh-c-in-connectors.sh
# → "no-sh-c-in-connectors: clean — 0 sh -c sites in production connector code"
# All target connector packages green (not just the 5 modified)
go test ./internal/connector/target/... -count=1
# → 18/18 packages ok
# Validation package green
go test ./internal/validation/... -count=1
# → ok
# gofmt clean
gofmt -l internal/validation/ internal/connector/target/ scripts/
# → empty
# go vet clean
go vet ./internal/validation/... ./internal/connector/target/...
# → empty
Files changed (10):
internal/validation/command.go (+37 -0)
internal/validation/command_test.go (+109 -0)
internal/connector/target/nginx/nginx.go (+22 -2)
internal/connector/target/apache/apache.go (+11 -1)
internal/connector/target/haproxy/haproxy.go (+11 -1)
internal/connector/target/postfix/postfix.go (+18 -1)
internal/connector/target/javakeystore/javakeystore.go (+18 -2)
internal/connector/target/javakeystore/javakeystore_test.go (+11 -2)
internal/connector/target/apache/apache_test.go (+42 -0)
internal/connector/target/haproxy/haproxy_test.go (+41 -0)
scripts/ci-guards/no-sh-c-in-connectors.sh (new, 93 lines)
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SEC-H2
705 lines
25 KiB
Go
705 lines
25 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
// 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"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/certctl/internal/connector/target/certutil"
|
|
"github.com/certctl-io/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"`
|
|
|
|
// BackupRetention controls how many .certctl-bak.<unix-nanos>.p12 backup
|
|
// files to keep after a successful deploy. Bundle 8 (2026-05-02
|
|
// deployment-target audit) introduced these backups for on-import-failure
|
|
// rollback; without retention, every deploy adds another file and disks
|
|
// fill up over time. Values:
|
|
// 0 → use default of 3 (keep most recent 3 backups).
|
|
// N → keep most recent N backups.
|
|
// -1 → opt out of pruning entirely (operators that wire their own
|
|
// archival/rotation logic).
|
|
BackupRetention int `json:"backup_retention,omitempty"`
|
|
|
|
// BackupDir overrides the directory where .certctl-bak.* files are
|
|
// written and pruned from. Default: filepath.Dir(KeystorePath) — same
|
|
// filesystem as the keystore itself, so backup writes are atomic-ish
|
|
// and a full disk fails fast at snapshot time rather than mid-deploy.
|
|
BackupDir string `json:"backup_dir,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.
|
|
//
|
|
// Bundle 8 of the 2026-05-02 deployment-target audit added a pre-delete
|
|
// snapshot + on-import-failure rollback wrapper around the original
|
|
// keytool flow:
|
|
// 1. Convert PEM to PKCS#12 temp file (transient password, never logged).
|
|
// 2. If the keystore exists, run `keytool -exportkeystore` to a sibling
|
|
// `.certctl-bak.<unix-nanos>.p12` BEFORE the irreversible -delete.
|
|
// Backup path persisted in a local variable for the rollback path.
|
|
// 3. Run the existing -delete (best-effort; alias may not exist).
|
|
// 4. Run keytool -importkeystore.
|
|
// 5. On import failure with a backup in hand, rollbackImport runs
|
|
// keytool -delete (clean up the alias the failed import may have
|
|
// created) + keytool -importkeystore from the backup PFX.
|
|
// 6. On success: compute thumbprint, run optional reload command,
|
|
// prune old backup files per Config.BackupRetention.
|
|
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)
|
|
|
|
// Bundle 10 / Top-10 fix #3: SHA-256 idempotency short-circuit. Only
|
|
// when the keystore exists (not first-time deploy). If the existing
|
|
// alias's SHA-256 fingerprint matches the new cert, skip the -delete +
|
|
// -importkeystore cycle entirely. Conservative: any error during the
|
|
// probe falls through to today's full deploy path.
|
|
if _, err := os.Stat(c.config.KeystorePath); err == nil {
|
|
// Keystore exists; try the probe
|
|
newCert, err := certutil.ParseCertificatePEM(request.CertPEM)
|
|
if err == nil {
|
|
// Compute SHA-256 of the new cert's DER-encoded bytes
|
|
sha256Hex := computeCertSHA256DERHex(newCert)
|
|
|
|
already, idemErr := c.isAliasAlreadyDeployedWithThumbprint(ctx, sha256Hex)
|
|
if idemErr == nil && already {
|
|
c.logger.Info("JavaKeystore already has this cert; skipping deploy",
|
|
"keystore", c.config.KeystorePath,
|
|
"alias", c.config.Alias)
|
|
// Compute SHA-1 thumbprint for metadata compatibility
|
|
sha1Thumb, _ := certutil.ComputeThumbprint(request.CertPEM)
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: fmt.Sprintf("jks:%s#%s", c.config.KeystorePath, c.config.Alias),
|
|
DeploymentID: fmt.Sprintf("jks-idem-%d", time.Now().Unix()),
|
|
Message: "Alias already deployed with matching cert; idempotent skip",
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"keystore_path": c.config.KeystorePath,
|
|
"alias": c.config.Alias,
|
|
"keystore_type": c.config.KeystoreType,
|
|
"thumbprint": sha1Thumb,
|
|
"idempotent": "true",
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Bundle 8: pre-delete snapshot. When the keystore exists, run
|
|
// keytool -exportkeystore to capture the prior alias state into a
|
|
// sibling PKCS#12 backup file BEFORE the irreversible -delete step.
|
|
// Backup path is held in a local variable for the rollback path;
|
|
// snapshot failure aborts the deploy entirely (no mutation has
|
|
// happened yet, so the keystore is untouched).
|
|
//
|
|
// Empty backupPath = first-time deploy (keystore file doesn't exist
|
|
// yet) — rollback in that case has nothing to restore from; the
|
|
// failure path returns the import error verbatim.
|
|
var backupPath string
|
|
if _, err := os.Stat(c.config.KeystorePath); err == nil {
|
|
var snapErr error
|
|
backupPath, snapErr = c.snapshotKeystore(ctx)
|
|
if snapErr != nil {
|
|
return nil, fmt.Errorf("pre-deploy snapshot failed: %w", snapErr)
|
|
}
|
|
c.logger.Debug("pre-deploy snapshot captured", "backup_path", backupPath)
|
|
|
|
// Step 2: Delete existing alias (keytool -delete). Best-effort —
|
|
// the alias may not exist in this keystore.
|
|
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 {
|
|
// Bundle 8: import failed. Roll back if we have a backup; otherwise
|
|
// surface the import error verbatim (first-time deploy — nothing
|
|
// to restore from, the failed import didn't write anything we can
|
|
// undo at the alias level).
|
|
if backupPath != "" {
|
|
c.logger.Error("keytool import failed; attempting rollback",
|
|
"error", err,
|
|
"output", output,
|
|
"backup_path", backupPath)
|
|
rbErr := c.rollbackImport(ctx, backupPath)
|
|
if rbErr != nil {
|
|
// Operator-actionable: import AND rollback both failed.
|
|
// Surface BOTH errors AND the backup path so the operator
|
|
// can manually keytool -importkeystore from the .p12 file
|
|
// to recover.
|
|
combined := fmt.Errorf("keytool import failed (%w) AND rollback also failed (%v); manual operator inspection required (backup at %s)", err, rbErr, backupPath)
|
|
c.logger.Error("JavaKeystore rollback also failed",
|
|
"import_error", err,
|
|
"rollback_error", rbErr,
|
|
"backup_path", backupPath)
|
|
return nil, combined
|
|
}
|
|
return nil, fmt.Errorf("keytool import failed; rolled back from %s: %s: %w", backupPath, output, err)
|
|
}
|
|
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
|
|
//
|
|
// Phase 7 SEC-H2 closure (2026-05-14): argv-form exec instead
|
|
// of `sh -c`. See nginx connector's defaultRunCommand for the
|
|
// shared rationale. ValidateShellCommand was already called at
|
|
// config-time (line 178 above); SplitShellCommand re-validates
|
|
// here as defense-in-depth and produces the argv for
|
|
// executor.Execute(name, args...) — note the executor's
|
|
// signature was already variadic-args, so the migration was
|
|
// purely "split and unpack."
|
|
if c.config.ReloadCommand != "" {
|
|
argv, err := validation.SplitShellCommand(c.config.ReloadCommand)
|
|
if err != nil {
|
|
c.logger.Warn("reload command failed validation (non-fatal)", "error", err)
|
|
} else {
|
|
output, runErr := c.executor.Execute(ctx, argv[0], argv[1:]...)
|
|
if runErr != nil {
|
|
c.logger.Warn("reload command failed (non-fatal)", "error", runErr, "output", output)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bundle 8: prune old backups on the success path so operator filesystems
|
|
// don't accumulate .certctl-bak.* files indefinitely. Failure here is
|
|
// non-fatal (debug log only) — the deploy succeeded, retention cleanup
|
|
// is housekeeping.
|
|
c.pruneBackups()
|
|
|
|
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)
|
|
|
|
// --- Bundle 8: pre-delete snapshot + on-import-failure rollback ---
|
|
|
|
// backupFilePrefix is the literal prefix on rollback-snapshot files.
|
|
// Centralised here so the snapshot writer, the rollback reader, and the
|
|
// retention pruner all agree on the naming convention.
|
|
//
|
|
// Bundle 8 of the 2026-05-02 deployment-target audit.
|
|
const backupFilePrefix = ".certctl-bak."
|
|
|
|
// backupFileSuffix is the literal suffix on rollback-snapshot files. Always
|
|
// PKCS#12 regardless of the source keystore type — `keytool -exportkeystore`
|
|
// destinations are PKCS#12 by convention because every JVM can read PKCS#12,
|
|
// while JKS is OpenJDK-specific.
|
|
const backupFileSuffix = ".p12"
|
|
|
|
// backupDir returns the directory rollback snapshots are written to.
|
|
// Operators can override via Config.BackupDir; default = same dir as the
|
|
// keystore so snapshots land on the same filesystem (atomic-ish writes,
|
|
// disk-full failures surface at snapshot time rather than mid-deploy).
|
|
func (c *Connector) backupDir() string {
|
|
if c.config.BackupDir != "" {
|
|
return c.config.BackupDir
|
|
}
|
|
return filepath.Dir(c.config.KeystorePath)
|
|
}
|
|
|
|
// snapshotKeystore runs `keytool -exportkeystore` to copy the existing alias
|
|
// into a new PKCS#12 file at <backupDir>/.certctl-bak.<unix-nanos>.p12.
|
|
// Returns the backup path on success; the caller persists it for the
|
|
// rollback path.
|
|
//
|
|
// The export password mirrors the keystore password — it's the same secret
|
|
// the operator already trusts the connector with, and avoiding a second
|
|
// transient password keeps the rollback's matching `-srcstorepass` simple.
|
|
//
|
|
// Bundle 8 of the 2026-05-02 deployment-target audit.
|
|
func (c *Connector) snapshotKeystore(ctx context.Context) (string, error) {
|
|
backupPath := filepath.Join(
|
|
c.backupDir(),
|
|
fmt.Sprintf("%s%d%s", backupFilePrefix, time.Now().UnixNano(), backupFileSuffix),
|
|
)
|
|
exportArgs := []string{
|
|
"-exportkeystore",
|
|
"-srckeystore", c.config.KeystorePath,
|
|
"-srcstoretype", c.config.KeystoreType,
|
|
"-srcstorepass", c.config.KeystorePassword,
|
|
"-srcalias", c.config.Alias,
|
|
"-destkeystore", backupPath,
|
|
"-deststoretype", "PKCS12",
|
|
"-deststorepass", c.config.KeystorePassword,
|
|
"-noprompt",
|
|
}
|
|
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, exportArgs...)
|
|
if err != nil {
|
|
// keytool -exportkeystore returns non-zero when the alias isn't
|
|
// present in the source keystore. That's a normal first-time-on-
|
|
// existing-keystore signal, NOT an outage. Treat it as "no
|
|
// snapshot to roll back to" and proceed cleanly — the import
|
|
// will create the alias from scratch, and rollback (if the
|
|
// import then fails) will be the no-backup path.
|
|
lowerOut := strings.ToLower(output)
|
|
if strings.Contains(lowerOut, "does not exist") || strings.Contains(lowerOut, "alias <") {
|
|
c.logger.Debug("snapshot found no existing alias to export — first-time-on-keystore deploy",
|
|
"alias", c.config.Alias,
|
|
"output", output)
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("keytool -exportkeystore: %s: %w", output, err)
|
|
}
|
|
return backupPath, nil
|
|
}
|
|
|
|
// rollbackImport restores the previous alias state from a snapshot PFX. Two
|
|
// keytool calls in order:
|
|
// 1. -delete the alias (best-effort — the failed import may or may not have
|
|
// created an alias entry; we don't know which, so we always try).
|
|
// 2. -importkeystore from the backup PFX, restoring the original cert + key
|
|
// under the original alias.
|
|
//
|
|
// Returns nil on success; wrapped error on rollback-script failure. The
|
|
// caller surfaces the wrapped error to the operator alongside the import
|
|
// error and the backup path so manual recovery is possible.
|
|
//
|
|
// Bundle 8 of the 2026-05-02 deployment-target audit.
|
|
func (c *Connector) rollbackImport(ctx context.Context, backupPath string) error {
|
|
// Step 1: best-effort delete (alias may not exist after a failed import).
|
|
deleteArgs := []string{
|
|
"-delete",
|
|
"-alias", c.config.Alias,
|
|
"-keystore", c.config.KeystorePath,
|
|
"-storepass", c.config.KeystorePassword,
|
|
"-storetype", c.config.KeystoreType,
|
|
"-noprompt",
|
|
}
|
|
c.executor.Execute(ctx, c.config.KeytoolPath, deleteArgs...)
|
|
|
|
// Step 2: re-import from the backup PKCS#12 to restore the previous state.
|
|
importArgs := []string{
|
|
"-importkeystore",
|
|
"-srckeystore", backupPath,
|
|
"-srcstoretype", "PKCS12",
|
|
"-srcstorepass", c.config.KeystorePassword,
|
|
"-destkeystore", c.config.KeystorePath,
|
|
"-deststoretype", c.config.KeystoreType,
|
|
"-deststorepass", c.config.KeystorePassword,
|
|
"-srcalias", c.config.Alias,
|
|
"-destalias", c.config.Alias,
|
|
"-noprompt",
|
|
}
|
|
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, importArgs...)
|
|
if err != nil {
|
|
return fmt.Errorf("rollback re-import: %s: %w", output, err)
|
|
}
|
|
c.logger.Info("JavaKeystore rollback completed", "backup_path", backupPath)
|
|
return nil
|
|
}
|
|
|
|
// pruneBackups removes older `.certctl-bak.*.p12` files beyond the configured
|
|
// retention count so operator filesystems don't accumulate snapshots
|
|
// indefinitely. Best-effort: any error during the readdir / remove cycle
|
|
// is swallowed at debug level — the deploy already succeeded, retention
|
|
// cleanup is housekeeping.
|
|
//
|
|
// Retention semantics (per Config.BackupRetention):
|
|
// - 0 → default of 3 (keep most recent 3 backups).
|
|
// - N → keep most recent N backups.
|
|
// - -1 → opt out entirely (no pruning).
|
|
//
|
|
// "Most recent" is determined by file ModTime, not by the unix-nanos in the
|
|
// filename — ModTime is robust against system-clock changes between deploys
|
|
// and aligns with the actual filesystem ordering operators see in `ls -lt`.
|
|
//
|
|
// Bundle 8 of the 2026-05-02 deployment-target audit.
|
|
func (c *Connector) pruneBackups() {
|
|
keep := c.config.BackupRetention
|
|
if keep == 0 {
|
|
keep = 3
|
|
}
|
|
if keep < 0 {
|
|
return // operator opted out
|
|
}
|
|
dir := c.backupDir()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
c.logger.Debug("backup retention prune skipped: ReadDir failed",
|
|
"dir", dir, "error", err)
|
|
return
|
|
}
|
|
type backupFile struct {
|
|
name string
|
|
modTime time.Time
|
|
}
|
|
var backups []backupFile
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, backupFilePrefix) || !strings.HasSuffix(name, backupFileSuffix) {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
backups = append(backups, backupFile{name: name, modTime: info.ModTime()})
|
|
}
|
|
if len(backups) <= keep {
|
|
return
|
|
}
|
|
// Sort newest-first by ModTime; older entries (the tail) get pruned.
|
|
sort.Slice(backups, func(i, j int) bool {
|
|
return backups[i].modTime.After(backups[j].modTime)
|
|
})
|
|
for _, b := range backups[keep:] {
|
|
path := filepath.Join(dir, b.name)
|
|
if err := os.Remove(path); err != nil {
|
|
c.logger.Debug("backup retention prune: Remove failed",
|
|
"path", path, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// computeCertSHA256DERHex computes SHA-256 of cert's raw DER encoding as lowercase hex.
|
|
func computeCertSHA256DERHex(cert *x509.Certificate) string {
|
|
h := sha256.Sum256(cert.Raw)
|
|
return fmt.Sprintf("%x", h)
|
|
}
|
|
|
|
// isAliasAlreadyDeployedWithThumbprint checks if the alias exists in the
|
|
// keystore and its SHA-256 fingerprint matches the given thumbprint.
|
|
// Runs `keytool -list -alias -v` and parses the output for the SHA-256 line.
|
|
// Returns (true, nil) iff the alias exists AND fingerprint matches.
|
|
// Returns (false, nil) on mismatch or alias missing. Returns (false, error)
|
|
// only on executor errors — falls through to the full deploy path (conservative).
|
|
//
|
|
// Bundle 10 / Top-10 fix #3 of the 2026-05-02 deployment-target audit.
|
|
func (c *Connector) isAliasAlreadyDeployedWithThumbprint(ctx context.Context, sha256Hex string) (bool, error) {
|
|
args := []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, args...)
|
|
if err != nil {
|
|
// Alias missing, keystore missing, or executor error: treat as miss, fall through
|
|
c.logger.Debug("idempotency probe executor error or alias missing",
|
|
"error", err,
|
|
"output", output)
|
|
return false, nil
|
|
}
|
|
|
|
// Parse output for SHA256 line. Real keytool output has format:
|
|
// "SHA256: AA:BB:CC:..." (colons every 2 chars, uppercase hex)
|
|
// or sometimes "SHA-256: ..." depending on JDK version
|
|
lines := strings.Split(output, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "SHA256:") || strings.HasPrefix(line, "SHA-256:") {
|
|
// Extract hex part: remove colons, convert to lowercase
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
hexWithColons := strings.TrimSpace(parts[1])
|
|
hexNormalized := strings.ToLower(strings.ReplaceAll(hexWithColons, ":", ""))
|
|
if hexNormalized == sha256Hex {
|
|
c.logger.Debug("idempotency probe matched", "alias", c.config.Alias)
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.logger.Debug("idempotency probe missed", "alias", c.config.Alias)
|
|
return false, nil
|
|
}
|