// 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/certctl-io/certctl/internal/connector/target" "github.com/certctl-io/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"` // ExecDeadline caps each PowerShell subprocess (local mode) to this // duration when the caller's ctx has no deadline of its own. Operators // on slow Windows links can extend; default is 60s. Caller-supplied // deadlines (via ctx) always win — the wrapper is a safety net for code // paths that forgot to attach one. Top-10 fix #4 of the 2026-05-02 // deployment-target audit re-run. ExecDeadline time.Duration `json:"exec_deadline,omitempty"` // 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. The deadline field // caps each subprocess invocation when the caller's ctx has no deadline of // its own — see Top-10 fix #4 of the 2026-05-02 deployment-target audit. type realExecutor struct { deadline time.Duration } func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) { // Attach the configured default deadline ONLY when the caller's ctx has // no deadline of its own. Caller deadlines always win — this wrapper is // a safety net for code paths that forgot to attach one. if _, ok := ctx.Deadline(); !ok && e.deadline > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, e.deadline) defer cancel() } 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{deadline: cfg.ExecDeadline}, }, 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" } // Top-10 fix #4: default the per-PowerShell-subprocess deadline so a hung // WinRM / cert-store call does not block the deploy worker indefinitely // when the caller's ctx has no deadline. Operators on slow links can // override via JSON config (`exec_deadline`). if cfg.ExecDeadline == 0 { cfg.ExecDeadline = 60 * time.Second } } // 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 } // snapshotEntry captures one cert in the target store with the SAME Subject // as the new cert — i.e. a cert that may be displaced by Import-PfxCertificate // and must be re-imported on rollback. The PfxPath is a temp file on the // remote host populated by Export-PfxCertificate during the snapshot phase. // // Bundle 7 of the 2026-05-02 deployment-target audit. type snapshotEntry struct { Thumbprint string PfxPath string } // snapshotState is the parsed output of the pre-deploy Get-ChildItem snapshot // PowerShell script. AllThumbprints is every cert in the store at deploy // time (used by the post-rollback verify phase to confirm the store is // back to pre-deploy state). Entries is the subset whose Subject matches // the new cert and was Export-PfxCertificate'd into TempDir for restore. // ExportPassword is the transient password used for both Export and // rollback Import; it is held in memory only and never logged or // persisted in metadata. // // Bundle 7 of the 2026-05-02 deployment-target audit. type snapshotState struct { Entries []snapshotEntry AllThumbprints []string TempDir string ExportPassword string } // DeployCertificate imports a certificate into the Windows Certificate Store. // // Bundle 7 of the 2026-05-02 deployment-target audit added a pre-deploy // snapshot + on-import-failure rollback wrapper around the original single // PowerShell import script: // 1. Parse the new cert's Subject DN from CertPEM (used by the snapshot to // decide which existing certs may be displaced). // 2. Run the snapshot script: Get-ChildItem the store; for every cert with // the same Subject as the new one, Export-PfxCertificate to a tempdir // using a transient export password. Captures every thumbprint for // post-rollback verification. // 3. Run the original import script (unchanged contract: PFX import + // optional FriendlyName + optional RemoveExpired). // 4. On import-script failure: run the rollback script (Remove-Item the // new cert if it landed; Import-PfxCertificate every snapshot entry; // clean up the tempdir) and a verify script (assert all original // thumbprints are back). Return wrapped error to the operator. // 5. On success: best-effort cleanup of the snapshot tempdir. 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) // Bundle 7: parse the new cert's Subject DN. The snapshot phase uses // this to decide which existing certs to Export-PfxCertificate for // the rollback path. Cert PEM parse errors fail the deploy before // any cert-store mutation. newCert, err := certutil.ParseCertificatePEM(request.CertPEM) if err != nil { return nil, fmt.Errorf("parse new cert for snapshot: %w", err) } newSubject := newCert.Subject.String() // Bundle 10 / Top-10 fix #3: SHA-1 idempotency short-circuit. If the // cert is already in the store AND not expired, skip the destructive // import cycle entirely. Conservative: any error during the probe falls // through to today's full deploy path. False negatives are safe; false // positives are dangerous. thumbprintEarly, err := certutil.ComputeThumbprint(request.CertPEM) if err != nil { return nil, fmt.Errorf("compute thumbprint: %w", err) } already, idemErr := c.isCertAlreadyInStore(ctx, thumbprintEarly) if idemErr == nil && already { c.logger.Info("WinCertStore already has this cert; skipping deploy", "thumbprint", thumbprintEarly, "store_name", c.config.StoreName) return &target.DeploymentResult{ Success: true, TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName), DeploymentID: fmt.Sprintf("wincertstore-idem-%d", time.Now().Unix()), Message: "Cert already in store and valid; idempotent skip", DeployedAt: time.Now(), Metadata: map[string]string{ "thumbprint": thumbprintEarly, "store_name": c.config.StoreName, "store_location": c.config.StoreLocation, "idempotent": "true", }, }, nil } // Bundle 7: pre-deploy snapshot. A separate transient export password // from the import PFX password — different lifecycle, different // PowerShell script. Held in memory only; never logged or persisted. exportPassword, err := certutil.GenerateRandomPassword(32) if err != nil { return nil, fmt.Errorf("generate snapshot export password: %w", err) } snapshotScript := c.buildSnapshotScript(newSubject, exportPassword) snapshotOut, err := c.executor.Execute(ctx, snapshotScript) if err != nil { // Snapshot failure is a real outage signal — bail out before any // cert-store mutation. The rollback path requires snapshot data; // we have none. return nil, fmt.Errorf("pre-deploy snapshot failed: %s: %w", snapshotOut, err) } state := parseSnapshotOutput(snapshotOut, exportPassword) c.logger.Debug("pre-deploy snapshot captured", "snapshot_entries", len(state.Entries), "total_thumbprints", len(state.AllThumbprints), "tempdir", state.TempDir) // Generate transient PFX password for the import. 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 { // Bundle 7: import failed. Roll back — Remove-Item the new cert // if it landed, Import-PfxCertificate each snapshotted PFX, clean // up the tempdir. Then verify the rollback by re-reading // Get-ChildItem. c.logger.Error("PowerShell import failed; attempting rollback", "error", err, "output", output, "snapshot_entries", len(state.Entries)) rbErr := c.rollbackImport(ctx, state, thumbprint) if rbErr != nil { // Both import AND rollback failed — operator-actionable. combined := fmt.Errorf("PowerShell import failed (%w) AND rollback also failed (%v); manual operator inspection required", err, rbErr) c.logger.Error("WinCertStore rollback also failed", "import_error", err, "rollback_error", rbErr, "new_thumbprint", thumbprint, "snapshot_entries", len(state.Entries)) return &target.DeploymentResult{ Success: false, TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName), Message: combined.Error(), DeployedAt: time.Now(), Metadata: map[string]string{ "thumbprint": thumbprint, "store_name": c.config.StoreName, "store_location": c.config.StoreLocation, "import_error": output, "rollback_error": rbErr.Error(), "rolled_back": "false", "manual_action_required": "true", }, }, combined } // Rollback succeeded. Best-effort verification — re-read // Get-ChildItem and assert every original thumbprint is back. // Skipped when the snapshot was empty (first-time deploy with // no prior thumbprints to verify against). verifyNote := "" if len(state.AllThumbprints) > 0 { if vErr := c.verifyRollback(ctx, state); vErr != nil { verifyNote = fmt.Sprintf(" (warning: %v)", vErr) c.logger.Warn("WinCertStore rollback verification disagreed", "error", vErr) } } errMsg := fmt.Sprintf("PowerShell import failed; rolled back%s: %v (output: %s)", verifyNote, err, output) return &target.DeploymentResult{ Success: false, TargetAddress: fmt.Sprintf("cert:\\%s\\%s", c.config.StoreLocation, c.config.StoreName), Message: errMsg, DeployedAt: time.Now(), Metadata: map[string]string{ "thumbprint": thumbprint, "store_name": c.config.StoreName, "store_location": c.config.StoreLocation, "import_error": output, "rolled_back": "true", }, }, fmt.Errorf("%s", errMsg) } // Success path: clean up the snapshot tempdir on a best-effort basis. // Failure here is non-fatal — operators don't need their deploy to // fail because of leftover temp files; surface as a debug log. if state.TempDir != "" { if cleanupErr := c.cleanupSnapshot(ctx, state); cleanupErr != nil { c.logger.Debug("snapshot tempdir cleanup failed (non-fatal)", "error", cleanupErr, "tempdir", state.TempDir) } } 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) // --- Bundle 7: pre-deploy snapshot + on-import-failure rollback --- // escapePowerShellSingleQuoted escapes a string for safe embedding inside a // single-quoted PowerShell literal. PowerShell single-quoted strings have no // escape sequences other than the apostrophe-doubling rule: a literal // apostrophe inside the string is written as two consecutive apostrophes. // Subject DN strings can contain apostrophes (e.g. CN=O'Reilly) so this is // load-bearing for the snapshot script's -eq Subject comparison. func escapePowerShellSingleQuoted(s string) string { return strings.ReplaceAll(s, "'", "''") } // buildSnapshotScript builds the pre-deploy Get-ChildItem snapshot PowerShell. // Output format (one line per cert plus a trailing TEMPDIR line): // // SNAPSHOT:: -- same-Subject cert, exported for restore // THUMB: -- different Subject; track for verify only // TEMPDIR: -- tempdir created for the snapshot exports // // The export password is embedded as a single-quoted literal. GenerateRandomPassword // returns alphanumeric chars only so it cannot break the literal. // // Bundle 7 of the 2026-05-02 deployment-target audit. The "# CERTCTL_SNAPSHOT" // comment tag identifies the script to test mocks deterministically. func (c *Connector) buildSnapshotScript(newSubject, exportPassword string) string { escapedSubject := escapePowerShellSingleQuoted(newSubject) var sb strings.Builder sb.WriteString("# CERTCTL_SNAPSHOT\n") fmt.Fprintf(&sb, "$store = 'Cert:\\%s\\%s'\n", c.config.StoreLocation, c.config.StoreName) sb.WriteString("$tempDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'certctl-snapshot-' + [System.Guid]::NewGuid().ToString())\n") sb.WriteString("New-Item -ItemType Directory -Path $tempDir -Force | Out-Null\n") fmt.Fprintf(&sb, "$pwd = ConvertTo-SecureString -String '%s' -Force -AsPlainText\n", exportPassword) fmt.Fprintf(&sb, "$newSubject = '%s'\n", escapedSubject) sb.WriteString("Get-ChildItem $store -ErrorAction SilentlyContinue | ForEach-Object {\n") sb.WriteString(" if ($_.Subject -eq $newSubject) {\n") sb.WriteString(" $pfx = [System.IO.Path]::Combine($tempDir, $_.Thumbprint + '.pfx')\n") sb.WriteString(" try {\n") sb.WriteString(" Export-PfxCertificate -Cert $_ -FilePath $pfx -Password $pwd -ChainOption EndEntityCertOnly | Out-Null\n") sb.WriteString(" Write-Output ('SNAPSHOT:' + $_.Thumbprint + ':' + $pfx)\n") sb.WriteString(" } catch {\n") sb.WriteString(" Write-Output ('THUMB:' + $_.Thumbprint)\n") sb.WriteString(" }\n") sb.WriteString(" } else {\n") sb.WriteString(" Write-Output ('THUMB:' + $_.Thumbprint)\n") sb.WriteString(" }\n") sb.WriteString("}\n") sb.WriteString("Write-Output ('TEMPDIR:' + $tempDir)\n") return sb.String() } // parseSnapshotOutput consumes the output of buildSnapshotScript and returns // a populated snapshotState. Lines that don't match the expected prefixes // are tolerated (logged at debug level) so transient PowerShell warnings // don't fail the parse. func parseSnapshotOutput(output, exportPassword string) *snapshotState { state := &snapshotState{ExportPassword: exportPassword} for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "SNAPSHOT:"): rest := strings.TrimPrefix(line, "SNAPSHOT:") parts := strings.SplitN(rest, ":", 2) if len(parts) != 2 { continue } state.Entries = append(state.Entries, snapshotEntry{ Thumbprint: parts[0], PfxPath: parts[1], }) state.AllThumbprints = append(state.AllThumbprints, parts[0]) case strings.HasPrefix(line, "THUMB:"): state.AllThumbprints = append(state.AllThumbprints, strings.TrimPrefix(line, "THUMB:")) case strings.HasPrefix(line, "TEMPDIR:"): state.TempDir = strings.TrimPrefix(line, "TEMPDIR:") } } return state } // rollbackImport runs the rollback PowerShell script that: // 1. Removes the new cert from the store if it landed (Test-Path guard). // 2. Re-imports each snapshot entry's PFX from the tempdir. // 3. Cleans up the tempdir. // // Returns nil on success, wrapped error on rollback-script failure. // // Bundle 7 of the 2026-05-02 deployment-target audit. The "# CERTCTL_ROLLBACK" // comment tag identifies the script to test mocks deterministically. func (c *Connector) rollbackImport(ctx context.Context, state *snapshotState, newThumbprint string) error { var sb strings.Builder sb.WriteString("# CERTCTL_ROLLBACK\n") fmt.Fprintf(&sb, "$store = 'Cert:\\%s\\%s'\n", c.config.StoreLocation, c.config.StoreName) fmt.Fprintf(&sb, "$pwd = ConvertTo-SecureString -String '%s' -Force -AsPlainText\n", state.ExportPassword) // Remove the new cert if it landed. fmt.Fprintf(&sb, "$newCertPath = '%s\\%s\\%s'\n", fmt.Sprintf("Cert:\\%s", c.config.StoreLocation), c.config.StoreName, newThumbprint) sb.WriteString("if (Test-Path $newCertPath) { Remove-Item $newCertPath -Force -ErrorAction SilentlyContinue }\n") // Re-import each snapshot entry. for _, entry := range state.Entries { fmt.Fprintf(&sb, "Import-PfxCertificate -FilePath '%s' -CertStoreLocation $store -Password $pwd -Exportable | Out-Null\n", entry.PfxPath) } // Clean up the snapshot tempdir. if state.TempDir != "" { fmt.Fprintf(&sb, "Remove-Item -Recurse -Force '%s' -ErrorAction SilentlyContinue\n", state.TempDir) } sb.WriteString("Write-Output 'ROLLBACK_OK'\n") output, err := c.executor.Execute(ctx, sb.String()) if err != nil { return fmt.Errorf("rollback script: %w (output: %s)", err, strings.TrimSpace(output)) } c.logger.Info("WinCertStore rollback completed", "snapshot_entries", len(state.Entries), "new_thumbprint", newThumbprint, "output", strings.TrimSpace(output)) return nil } // verifyRollback re-reads Get-ChildItem on the store and asserts every // pre-deploy thumbprint is back. Returns nil on full match; returns a // non-fatal warning error when one or more thumbprints are missing // (the rollback's Remove-Item / Import-PfxCertificate ran but the store // is in an unexpected state — operator inspection recommended). // // Bundle 7 of the 2026-05-02 deployment-target audit. The "# CERTCTL_VERIFY" // comment tag identifies the script to test mocks deterministically. func (c *Connector) verifyRollback(ctx context.Context, state *snapshotState) error { if len(state.AllThumbprints) == 0 { return nil } quoted := make([]string, 0, len(state.AllThumbprints)) for _, t := range state.AllThumbprints { quoted = append(quoted, "'"+t+"'") } var sb strings.Builder sb.WriteString("# CERTCTL_VERIFY\n") fmt.Fprintf(&sb, "$store = 'Cert:\\%s\\%s'\n", c.config.StoreLocation, c.config.StoreName) sb.WriteString("$found = Get-ChildItem $store -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Thumbprint\n") fmt.Fprintf(&sb, "$want = @(%s)\n", strings.Join(quoted, ",")) sb.WriteString("$missing = $want | Where-Object { $_ -notin $found }\n") sb.WriteString("if ($missing.Count -eq 0) { Write-Output 'VERIFY_OK' } else { Write-Output ('VERIFY_FAILED:' + ($missing -join ',')) }\n") output, err := c.executor.Execute(ctx, sb.String()) if err != nil { return fmt.Errorf("verify probe: %w", err) } out := strings.TrimSpace(output) if out == "VERIFY_OK" { return nil } return fmt.Errorf("rollback verification disagreed: %s", out) } // cleanupSnapshot best-effort removes the snapshot tempdir on the success // path so operators' filesystems don't accumulate `certctl-snapshot-*` // directories. Failure is non-fatal (caller logs at debug level). // // Bundle 7 of the 2026-05-02 deployment-target audit. The "# CERTCTL_CLEANUP" // comment tag identifies the script to test mocks deterministically. func (c *Connector) cleanupSnapshot(ctx context.Context, state *snapshotState) error { if state.TempDir == "" { return nil } script := fmt.Sprintf( "# CERTCTL_CLEANUP\nRemove-Item -Recurse -Force '%s' -ErrorAction SilentlyContinue\nWrite-Output 'CLEANUP_OK'\n", state.TempDir) if _, err := c.executor.Execute(ctx, script); err != nil { return fmt.Errorf("cleanup script: %w", err) } return nil } // isCertAlreadyInStore checks if the given thumbprint is already in the // configured certificate store and is still valid (NotAfter in future). // Returns (true, nil) iff the cert is in the store AND not expired. // Returns (false, nil) on any mismatch or missing cert. 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) isCertAlreadyInStore(ctx context.Context, thumbprint string) (bool, error) { script := fmt.Sprintf( "# CERTCTL_IDEM_PROBE\n"+ "$cert = Get-ChildItem 'Cert:\\%s\\%s\\%s' -ErrorAction SilentlyContinue; "+ "if ($cert -and $cert.NotAfter -gt (Get-Date)) { Write-Output 'IDEM_MATCH' } else { Write-Output 'IDEM_MISS' }", c.config.StoreLocation, c.config.StoreName, thumbprint, ) output, err := c.executor.Execute(ctx, script) if err != nil { // Executor error: return false (conservative — fall through to full deploy) c.logger.Debug("idempotency probe executor error", "error", err, "output", output) return false, nil } out := strings.TrimSpace(output) if out == "IDEM_MATCH" { c.logger.Debug("idempotency probe matched", "thumbprint", thumbprint) return true, nil } // "IDEM_MISS" or any other output c.logger.Debug("idempotency probe missed", "output", out) return false, nil }