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.
313 lines
11 KiB
Go
313 lines
11 KiB
Go
// 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/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(" $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)
|