feat(M39): IIS target connector + README overhaul

Implement full IIS target connector with PEM-to-PFX conversion via
go-pkcs12, PowerShell-based deployment (Import-PfxCertificate, IIS
binding management), SHA-1 thumbprint computation, and SNI support.
Injectable PowerShellExecutor interface enables cross-platform testing.
Regex-validated config fields prevent PowerShell injection. 28 tests.

Restructure README from 563 to 313 lines: outcome-focused feature
descriptions, "Who Is This For" persona section, examples promoted
above the fold, configuration/API/security reference moved to docs.
All numbers verified against repo (25 GUI pages, 97 OpenAPI ops,
CI thresholds service 55%/handler 60%/domain 40%/middleware 30%).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-04-02 20:27:27 -04:00
parent adfb682754
commit 8b52da6aef
6 changed files with 1594 additions and 434 deletions
+504 -86
View File
@@ -2,13 +2,22 @@ package iis
import (
"context"
"crypto/rand"
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"runtime"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// Config represents the IIS deployment target configuration.
@@ -18,85 +27,178 @@ type Config struct {
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
Port int `json:"port"` // HTTPS port (default 443)
SNI bool `json:"sni"` // Enable Server Name Indication
IPAddress string `json:"ip_address"` // Bind to specific IP (default "*")
}
// PowerShellExecutor abstracts PowerShell command execution for testability.
// On real Windows deployments, the realExecutor calls powershell.exe directly.
// Tests inject a mock executor to verify command construction without Windows.
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)
output, err := cmd.CombinedOutput()
return string(output), err
}
// Connector implements the target.Connector interface for IIS (Internet Information Services).
// This connector runs on Windows agents and manages certificate deployment via IIS.
// This connector runs on Windows agents and manages certificate deployment via PowerShell.
//
// IIS certificate management requires:
// - Windows Server with IIS installed
// - PowerShell execution available
// - Administrative privileges
// - Windows Server with IIS installed
// - PowerShell execution available
// - Administrative privileges
//
// TODO: Implement actual PowerShell command execution for:
// - Certificate import: Import-PfxCertificate
// - IIS binding update: New-WebBinding, Set-WebBinding
// - Validation: Get-WebBinding
// Deployment flow:
// 1. Convert PEM cert+key to PFX (PKCS#12) format via go-pkcs12
// 2. Import PFX to Windows certificate store via Import-PfxCertificate
// 3. Compute SHA-1 thumbprint (IIS certificate identifier)
// 4. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
// 5. Verify binding is active via Get-WebBinding
type Connector struct {
config *Config
logger *slog.Logger
config *Config
logger *slog.Logger
executor PowerShellExecutor
}
// New creates a new IIS target connector with the given configuration and logger.
// Uses the real PowerShell executor for production deployments.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
config: config,
logger: logger,
executor: &realExecutor{},
}
}
// NewWithExecutor creates a new IIS target connector with an injected executor.
// Used in tests to mock PowerShell execution on non-Windows platforms.
func NewWithExecutor(config *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector {
return &Connector{
config: config,
logger: logger,
executor: executor,
}
}
// validIISName matches safe IIS site names and cert store names.
// Allows alphanumeric, spaces, underscores, hyphens, and dots.
var validIISName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`)
// validateIISName checks that an IIS name field contains only safe characters.
// This prevents PowerShell injection via malicious site or store names.
func validateIISName(name, field string) error {
if name == "" {
return fmt.Errorf("%s is required", field)
}
if len(name) > 256 {
return fmt.Errorf("%s exceeds maximum length (256 characters)", field)
}
if !validIISName.MatchString(name) {
return fmt.Errorf("%s contains invalid characters (allowed: alphanumeric, space, underscore, hyphen, dot)", field)
}
return nil
}
// validIPOrWildcard matches valid IP addresses or the wildcard "*".
var validIPOrWildcard = regexp.MustCompile(`^(\*|(\d{1,3}\.){3}\d{1,3})$`)
// ValidateConfig checks that the IIS configuration is valid and accessible.
// It verifies that we're on Windows and that the IIS site exists.
//
// TODO: Implement actual PowerShell checks.
// It verifies field values, PowerShell availability, and optionally checks that
// the IIS site exists and the cert store is accessible.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid IIS config: %w", err)
}
if cfg.SiteName == "" || cfg.CertStore == "" {
return fmt.Errorf("IIS site_name and cert_store are required")
// Validate required fields
if err := validateIISName(cfg.SiteName, "site_name"); err != nil {
return err
}
if err := validateIISName(cfg.CertStore, "cert_store"); err != nil {
return err
}
// Verify we're on Windows
if runtime.GOOS != "windows" {
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
// Apply defaults
if cfg.Port == 0 {
cfg.Port = 443
}
if cfg.IPAddress == "" {
cfg.IPAddress = "*"
}
// Validate port range
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535, got %d", cfg.Port)
}
// Validate IP address format
if !validIPOrWildcard.MatchString(cfg.IPAddress) {
return fmt.Errorf("ip_address must be a valid IPv4 address or '*', got %q", cfg.IPAddress)
}
// Validate binding_info if provided (safe characters only)
if cfg.BindingInfo != "" {
if len(cfg.BindingInfo) > 512 {
return fmt.Errorf("binding_info exceeds maximum length (512 characters)")
}
// Allow typical binding chars: alphanumeric, *, :, ., -
validBinding := regexp.MustCompile(`^[a-zA-Z0-9\*\:\.\-]+$`)
if !validBinding.MatchString(cfg.BindingInfo) {
return fmt.Errorf("binding_info contains invalid characters")
}
}
c.logger.Info("validating IIS configuration",
"site_name", cfg.SiteName,
"cert_store", cfg.CertStore,
"hostname", cfg.Hostname)
"hostname", cfg.Hostname,
"port", cfg.Port)
// TODO: Implement PowerShell check
// In production:
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
// 2. Verify site exists and is running
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
// Verify PowerShell is available
if _, err := exec.LookPath("powershell.exe"); err != nil {
return fmt.Errorf("powershell.exe not found in PATH: %w", err)
}
c.logger.Warn("IIS validation not yet fully implemented",
"site_name", cfg.SiteName)
// Verify IIS site exists
siteCheckScript := fmt.Sprintf(`Get-Website -Name '%s' | Select-Object -ExpandProperty Name`, cfg.SiteName)
output, err := c.executor.Execute(ctx, siteCheckScript)
if err != nil {
return fmt.Errorf("IIS site %q not found or inaccessible: %s (error: %w)", cfg.SiteName, strings.TrimSpace(output), err)
}
// Verify cert store is accessible
storeCheckScript := fmt.Sprintf(`Test-Path 'Cert:\LocalMachine\%s'`, cfg.CertStore)
output, err = c.executor.Execute(ctx, storeCheckScript)
if err != nil || !strings.Contains(strings.TrimSpace(output), "True") {
return fmt.Errorf("certificate store %q is not accessible: %s", cfg.CertStore, strings.TrimSpace(output))
}
c.config = &cfg
c.logger.Info("IIS configuration validated",
"site_name", cfg.SiteName,
"cert_store", cfg.CertStore)
return nil
}
// DeployCertificate imports a certificate to the Windows certificate store and updates
// the IIS binding to use the new certificate.
//
// The IIS deployment process (via PowerShell):
// 1. Create a temporary PFX file from the certificate and existing private key
// (Note: The private key is managed by the agent, not provided by the control plane)
// 2. Import the PFX to the Windows certificate store (My store by default)
// 3. Get the certificate thumbprint
// 4. Update the IIS binding to use the new certificate by thumbprint
// 5. Verify the binding is active
//
// TODO: Implement actual PowerShell commands:
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
// Deployment flow:
// 1. Convert PEM cert+key+chain to PFX format (go-pkcs12 with random password)
// 2. Write PFX to temp file (cleaned up on exit, even on error)
// 3. Compute SHA-1 thumbprint from DER cert (matches Windows certutil output)
// 4. Import PFX to Windows cert store via Import-PfxCertificate
// 5. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
// 6. Return result with thumbprint in metadata
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to IIS",
"site_name", c.config.SiteName,
@@ -104,44 +206,182 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
startTime := time.Now()
// TODO: Implement IIS certificate deployment
// In production:
// 1. Create temporary PFX from CertPEM and ChainPEM
// (Private key should already exist on the agent)
// 2. Import certificate:
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
// 3. Get certificate thumbprint:
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
// 4. Update IIS binding:
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
// 5. Remove temporary PFX file
// Validate we have a private key (required for PFX creation)
if request.KeyPEM == "" {
errMsg := "private key (KeyPEM) is required for IIS deployment"
c.logger.Error("deployment failed", "error", errMsg)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Step 1: Create PFX from PEM inputs
pfxPassword, err := generateRandomPassword(32)
if err != nil {
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
c.logger.Error("deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
if err != nil {
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
c.logger.Error("PFX creation failed", "error", err)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Step 2: Write PFX to temp file
tmpFile, err := os.CreateTemp("", "certctl-*.pfx")
if err != nil {
errMsg := fmt.Sprintf("failed to create temp PFX file: %v", err)
c.logger.Error("deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
pfxPath := tmpFile.Name()
defer os.Remove(pfxPath) // Always clean up temp PFX
if _, err := tmpFile.Write(pfxData); err != nil {
tmpFile.Close()
errMsg := fmt.Sprintf("failed to write temp PFX file: %v", err)
c.logger.Error("deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
tmpFile.Close()
// Step 3: Compute thumbprint (SHA-1 of DER-encoded cert — matches Windows certutil)
thumbprint, err := computeThumbprint(request.CertPEM)
if err != nil {
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
c.logger.Error("deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
c.logger.Debug("certificate thumbprint computed", "thumbprint", thumbprint)
// Step 4: Import PFX to Windows certificate store
importScript := fmt.Sprintf(
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
`Import-PfxCertificate -FilePath '%s' -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password`,
pfxPassword, pfxPath, c.config.CertStore,
)
output, err := c.executor.Execute(ctx, importScript)
if err != nil {
errMsg := fmt.Sprintf("PFX import failed: %v (output: %s)", err, strings.TrimSpace(output))
c.logger.Error("PFX import failed",
"error", err,
"output", strings.TrimSpace(output),
"cert_store", c.config.CertStore)
return &target.DeploymentResult{
Success: false,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
c.logger.Info("PFX imported to certificate store",
"cert_store", c.config.CertStore,
"thumbprint", thumbprint)
// Step 5: Update IIS HTTPS binding
port := c.config.Port
if port == 0 {
port = 443
}
ipAddress := c.config.IPAddress
if ipAddress == "" {
ipAddress = "*"
}
hostHeader := c.config.BindingInfo
sniFlag := 0
if c.config.SNI {
sniFlag = 1
}
bindingScript := fmt.Sprintf(
// Remove existing HTTPS binding on this port (if any), then create new one
`$existing = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
`if ($existing) { $existing | Remove-WebBinding }; `+
`New-WebBinding -Name '%s' -Protocol 'https' -Port %d -IPAddress '%s' -HostHeader '%s' -SslFlags %d; `+
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d; `+
`$binding.AddSslCertificate('%s', '%s')`,
c.config.SiteName, port,
c.config.SiteName, port, ipAddress, hostHeader, sniFlag,
c.config.SiteName, port,
thumbprint, c.config.CertStore,
)
output, err = c.executor.Execute(ctx, bindingScript)
if err != nil {
errMsg := fmt.Sprintf("IIS binding update failed: %v (output: %s)", err, strings.TrimSpace(output))
c.logger.Error("IIS binding update failed",
"error", err,
"output", strings.TrimSpace(output),
"site_name", c.config.SiteName)
// Cert is imported but binding failed — partial success
return &target.DeploymentResult{
Success: false,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
DeployedAt: time.Now(),
Metadata: map[string]string{
"thumbprint": thumbprint,
"cert_store": c.config.CertStore,
"import_success": "true",
"binding_error": strings.TrimSpace(output),
},
}, fmt.Errorf("%s", errMsg)
}
deploymentDuration := time.Since(startTime)
c.logger.Warn("IIS deployment not yet implemented",
"site_name", c.config.SiteName)
c.logger.Info("certificate deployed to IIS successfully",
"duration", deploymentDuration.String(),
"site_name", c.config.SiteName,
"thumbprint", thumbprint)
return &target.DeploymentResult{
Success: true,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
Message: "Certificate deployment to IIS initiated (stub)",
DeploymentID: fmt.Sprintf("iis-%s-%d", thumbprint[:8], time.Now().Unix()),
Message: "Certificate imported and IIS binding updated successfully",
DeployedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"cert_store": c.config.CertStore,
"thumbprint": thumbprint,
"port": fmt.Sprintf("%d", port),
"sni": fmt.Sprintf("%t", c.config.SNI),
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
//
// TODO: Implement actual PowerShell validation.
// PowerShell command:
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
// It checks the IIS binding to ensure it's active with the correct certificate thumbprint.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating IIS deployment",
"certificate_id", request.CertificateID,
@@ -150,33 +390,211 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
startTime := time.Now()
// TODO: Implement IIS deployment validation
// In production:
// 1. Query IIS binding status:
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
// 2. Verify binding exists and is active
// 3. Extract certificate thumbprint from binding
// 4. Query certificate store to verify thumbprint matches expected certificate
// 5. Check certificate validity dates and key match
port := c.config.Port
if port == 0 {
port = 443
}
// Query IIS binding for HTTPS on the configured port
bindingScript := fmt.Sprintf(
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
`if ($binding) { $binding.certificateHash } else { 'NO_BINDING' }`,
c.config.SiteName, port,
)
output, err := c.executor.Execute(ctx, bindingScript)
if err != nil {
errMsg := fmt.Sprintf("failed to query IIS binding: %v (output: %s)", err, strings.TrimSpace(output))
c.logger.Error("validation failed", "error", err, "output", strings.TrimSpace(output))
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
bindingHash := strings.TrimSpace(output)
if bindingHash == "NO_BINDING" || bindingHash == "" {
errMsg := fmt.Sprintf("no HTTPS binding found on IIS site %q port %d", c.config.SiteName, port)
c.logger.Error("validation failed", "error", errMsg)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Verify the certificate exists in the store
certCheckScript := fmt.Sprintf(
`$cert = Get-ChildItem -Path 'Cert:\LocalMachine\%s\%s' -ErrorAction SilentlyContinue; `+
`if ($cert -and $cert.NotAfter -gt (Get-Date)) { 'VALID' } `+
`elseif ($cert) { 'EXPIRED' } `+
`else { 'NOT_FOUND' }`,
c.config.CertStore, bindingHash,
)
output, err = c.executor.Execute(ctx, certCheckScript)
if err != nil {
errMsg := fmt.Sprintf("failed to verify certificate in store: %v", err)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
certStatus := strings.TrimSpace(output)
validationDuration := time.Since(startTime)
c.logger.Warn("IIS validation not yet implemented",
"site_name", c.config.SiteName)
switch certStatus {
case "VALID":
c.logger.Info("IIS deployment validated successfully",
"duration", validationDuration.String(),
"thumbprint", bindingHash)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: "Certificate is bound to IIS site and valid",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"thumbprint": bindingHash,
"site_name": c.config.SiteName,
"cert_store": c.config.CertStore,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: "Certificate deployment validation initiated (stub)",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
case "EXPIRED":
errMsg := fmt.Sprintf("certificate %s is expired in store %q", bindingHash, c.config.CertStore)
c.logger.Error("validation failed: certificate expired", "thumbprint", bindingHash)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
ValidatedAt: time.Now(),
Metadata: map[string]string{
"thumbprint": bindingHash,
"status": "expired",
},
}, fmt.Errorf("%s", errMsg)
default: // NOT_FOUND or unexpected
errMsg := fmt.Sprintf("certificate %s not found in store %q", bindingHash, c.config.CertStore)
c.logger.Error("validation failed: certificate not in store", "thumbprint", bindingHash)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: errMsg,
ValidatedAt: time.Now(),
Metadata: map[string]string{
"thumbprint": bindingHash,
"status": "not_found",
},
}, fmt.Errorf("%s", errMsg)
}
}
// executePowerShellCommand will be implemented in V3 when IIS target connector ships.
// Pattern: exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder
// with strong encryption (same library used by M27 export service).
func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
// Parse leaf certificate
certBlock, _ := pem.Decode([]byte(certPEM))
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
return nil, fmt.Errorf("failed to decode certificate PEM")
}
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
}
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
keyBlock, _ := pem.Decode([]byte(keyPEM))
if keyBlock == nil {
return nil, fmt.Errorf("failed to decode private key PEM")
}
privateKey, err := parsePrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Parse CA chain certificates (optional)
var caCerts []*x509.Certificate
if chainPEM != "" {
rest := []byte(chainPEM)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
caCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
}
caCerts = append(caCerts, caCert)
}
}
// Encode as PKCS#12 with Modern encryption
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
if err != nil {
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
}
return pfxData, nil
}
// parsePrivateKey attempts to parse a DER-encoded private key.
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
func parsePrivateKey(der []byte) (interface{}, error) {
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParseECPrivateKey(der); err == nil {
return key, nil
}
return nil, fmt.Errorf("unsupported private key format")
}
// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
// IIS uses SHA-1 thumbprints as the primary certificate identifier.
// Returns uppercase hex string matching Windows certutil output.
func computeThumbprint(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil || block.Type != "CERTIFICATE" {
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
}
hash := sha1.Sum(block.Bytes)
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
}
// generateRandomPassword creates a random alphanumeric password for transient PFX encryption.
// The password is only used between PFX creation and import — it never persists.
func generateRandomPassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b), nil
}
+845
View File
@@ -0,0 +1,845 @@
package iis
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// mockExecutor records PowerShell commands and returns configurable responses.
type mockExecutor struct {
// commands records all scripts passed to Execute in order
commands []string
// responses maps script substrings to (output, error) pairs.
// First matching substring wins.
responses map[string]mockResponse
// defaultOutput is returned when no response matches
defaultOutput string
// defaultErr is returned when no response matches
defaultErr error
}
type mockResponse struct {
output string
err error
}
func newMockExecutor() *mockExecutor {
return &mockExecutor{
responses: make(map[string]mockResponse),
}
}
func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) {
m.commands = append(m.commands, script)
for substr, resp := range m.responses {
if strings.Contains(script, substr) {
return resp.output, resp.err
}
}
return m.defaultOutput, m.defaultErr
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
// --- ValidateConfig tests ---
func TestIISConnector_ValidateConfig_Success(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-Website"] = mockResponse{output: "Default Web Site\n", err: nil}
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
cfg := Config{
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}
// We need powershell.exe in PATH for LookPath — skip on non-Windows
connector := NewWithExecutor(&cfg, testLogger(), executor)
rawConfig, _ := json.Marshal(cfg)
// On non-Windows, LookPath("powershell.exe") will fail.
// We test the validation logic up to that point by checking the error message.
err := connector.ValidateConfig(context.Background(), rawConfig)
if err != nil {
// If it's just a "powershell not found" error, that's expected on Linux
if strings.Contains(err.Error(), "powershell.exe not found") {
t.Skip("Skipping: powershell.exe not available (non-Windows)")
}
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestIISConnector_ValidateConfig_InvalidJSON(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
err := connector.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "invalid IIS config") {
t.Errorf("expected 'invalid IIS config' in error, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_MissingSiteName(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{CertStore: "My"}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for missing site_name")
}
if !strings.Contains(err.Error(), "site_name") {
t.Errorf("expected error about site_name, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_MissingCertStore(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{SiteName: "Default Web Site"}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for missing cert_store")
}
if !strings.Contains(err.Error(), "cert_store") {
t.Errorf("expected error about cert_store, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_InvalidSiteName_Injection(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{
SiteName: "Default'; Drop-Database",
CertStore: "My",
}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for injection characters in site_name")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Errorf("expected 'invalid characters' in error, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_InvalidCertStore_Injection(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{
SiteName: "Default Web Site",
CertStore: "My$(whoami)",
}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for injection characters in cert_store")
}
}
func TestIISConnector_ValidateConfig_InvalidPort(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{
SiteName: "Default Web Site",
CertStore: "My",
Port: 99999,
}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for invalid port")
}
if !strings.Contains(err.Error(), "port") {
t.Errorf("expected error about port, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_InvalidIPAddress(t *testing.T) {
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
cfg := Config{
SiteName: "Default Web Site",
CertStore: "My",
IPAddress: "not_an_ip",
}
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err == nil {
t.Fatal("expected error for invalid IP address")
}
if !strings.Contains(err.Error(), "ip_address") {
t.Errorf("expected error about ip_address, got: %v", err)
}
}
func TestIISConnector_ValidateConfig_DefaultValues(t *testing.T) {
// Test that defaults are applied (port 443, IP *)
executor := newMockExecutor()
executor.responses["Get-Website"] = mockResponse{output: "TestSite\n", err: nil}
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
cfg := Config{
SiteName: "TestSite",
CertStore: "WebHosting",
// Port and IPAddress intentionally left empty
}
connector := NewWithExecutor(&cfg, testLogger(), executor)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(context.Background(), rawConfig)
if err != nil {
if strings.Contains(err.Error(), "powershell.exe not found") {
t.Skip("Skipping: powershell.exe not available (non-Windows)")
}
t.Fatalf("ValidateConfig failed: %v", err)
}
// Verify defaults were applied
if connector.config.Port != 443 {
t.Errorf("expected default port 443, got %d", connector.config.Port)
}
if connector.config.IPAddress != "*" {
t.Errorf("expected default IP '*', got %s", connector.config.IPAddress)
}
}
// --- DeployCertificate tests ---
// generateTestCertAndKey creates a self-signed ECDSA P-256 cert+key for testing.
func generateTestCertAndKey() (certPEM, keyPEM, chainPEM string, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", "", err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "test.example.com",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
DNSNames: []string{"test.example.com"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return "", "", "", err
}
certPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", "", "", err
}
keyPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
// Use the self-signed cert as its own "chain" for testing
chainPEMStr := certPEMStr
return certPEMStr, keyPEMStr, chainPEMStr, nil
}
func TestIISConnector_DeployCertificate_Success(t *testing.T) {
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
executor := newMockExecutor()
executor.defaultOutput = "OK"
cfg := &Config{
Hostname: "web01.example.com",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
IPAddress: "*",
}
connector := NewWithExecutor(cfg, testLogger(), executor)
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
})
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// Verify thumbprint is in metadata
if result.Metadata["thumbprint"] == "" {
t.Error("expected thumbprint in metadata")
}
// SHA-1 thumbprint = 40 hex chars uppercase
if len(result.Metadata["thumbprint"]) != 40 {
t.Errorf("expected 40-char thumbprint, got %d", len(result.Metadata["thumbprint"]))
}
// Verify both import and binding scripts were executed
if len(executor.commands) != 2 {
t.Errorf("expected 2 PowerShell commands, got %d", len(executor.commands))
}
// First command should be PFX import
if len(executor.commands) > 0 && !strings.Contains(executor.commands[0], "Import-PfxCertificate") {
t.Errorf("expected Import-PfxCertificate in first command, got: %s", executor.commands[0])
}
// Second command should be binding update
if len(executor.commands) > 1 && !strings.Contains(executor.commands[1], "New-WebBinding") {
t.Errorf("expected New-WebBinding in second command, got: %s", executor.commands[1])
}
// Verify metadata
if result.Metadata["site_name"] != "Default Web Site" {
t.Errorf("expected site_name in metadata")
}
if result.Metadata["cert_store"] != "My" {
t.Errorf("expected cert_store in metadata")
}
if _, ok := result.Metadata["duration_ms"]; !ok {
t.Error("expected duration_ms in metadata")
}
}
func TestIISConnector_DeployCertificate_MissingKeyPEM(t *testing.T) {
certPEM, _, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
connector := NewWithExecutor(&Config{
SiteName: "Default Web Site",
CertStore: "My",
}, testLogger(), newMockExecutor())
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: "", // Missing key
ChainPEM: chainPEM,
})
if err == nil {
t.Fatal("expected error for missing KeyPEM")
}
if result.Success {
t.Fatal("expected failure result")
}
if !strings.Contains(err.Error(), "private key") {
t.Errorf("expected error about private key, got: %v", err)
}
}
func TestIISConnector_DeployCertificate_InvalidCertPEM(t *testing.T) {
_, keyPEM, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test key: %v", err)
}
connector := NewWithExecutor(&Config{
SiteName: "Default Web Site",
CertStore: "My",
}, testLogger(), newMockExecutor())
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: "not a valid cert",
KeyPEM: keyPEM,
ChainPEM: "",
})
if err == nil {
t.Fatal("expected error for invalid cert PEM")
}
if result.Success {
t.Fatal("expected failure result")
}
}
func TestIISConnector_DeployCertificate_InvalidKeyPEM(t *testing.T) {
certPEM, _, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
connector := NewWithExecutor(&Config{
SiteName: "Default Web Site",
CertStore: "My",
}, testLogger(), newMockExecutor())
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: "not a valid key",
ChainPEM: "",
})
if err == nil {
t.Fatal("expected error for invalid key PEM")
}
if result.Success {
t.Fatal("expected failure result")
}
}
func TestIISConnector_DeployCertificate_ImportFails(t *testing.T) {
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
executor := newMockExecutor()
executor.responses["Import-PfxCertificate"] = mockResponse{
output: "Access denied",
err: fmt.Errorf("exit status 1"),
}
connector := NewWithExecutor(&Config{
SiteName: "Default Web Site",
CertStore: "My",
}, testLogger(), executor)
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
})
if err == nil {
t.Fatal("expected error when PFX import fails")
}
if result.Success {
t.Fatal("expected failure result")
}
if !strings.Contains(err.Error(), "PFX import failed") {
t.Errorf("expected 'PFX import failed' in error, got: %v", err)
}
}
func TestIISConnector_DeployCertificate_BindingFails(t *testing.T) {
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
executor := newMockExecutor()
// Import succeeds
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
// Binding fails
executor.responses["New-WebBinding"] = mockResponse{
output: "The website 'Default Web Site' already has a binding",
err: fmt.Errorf("exit status 1"),
}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
})
if err == nil {
t.Fatal("expected error when binding update fails")
}
if result.Success {
t.Fatal("expected failure result")
}
// Partial success: cert was imported but binding failed
if result.Metadata["import_success"] != "true" {
t.Error("expected import_success=true in metadata (cert imported but binding failed)")
}
if result.Metadata["thumbprint"] == "" {
t.Error("expected thumbprint in metadata even on binding failure")
}
}
func TestIISConnector_DeployCertificate_SNIEnabled(t *testing.T) {
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
executor := newMockExecutor()
executor.defaultOutput = "OK"
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
SNI: true,
BindingInfo: "test.example.com",
}, testLogger(), executor)
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
})
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// Verify SNI flag was passed in the binding script
if len(executor.commands) < 2 {
t.Fatal("expected at least 2 commands")
}
bindingCmd := executor.commands[1]
if !strings.Contains(bindingCmd, "-SslFlags 1") {
t.Errorf("expected -SslFlags 1 for SNI, got: %s", bindingCmd)
}
if result.Metadata["sni"] != "true" {
t.Error("expected sni=true in metadata")
}
}
// --- ValidateDeployment tests ---
func TestIISConnector_ValidateDeployment_Success(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-WebBinding"] = mockResponse{output: "ABC123DEF456\n", err: nil}
executor.responses["Get-ChildItem"] = mockResponse{output: "VALID\n", err: nil}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
})
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatalf("expected valid deployment, got: %s", result.Message)
}
if result.Metadata["thumbprint"] != "ABC123DEF456" {
t.Errorf("expected thumbprint in metadata, got: %s", result.Metadata["thumbprint"])
}
if _, ok := result.Metadata["duration_ms"]; !ok {
t.Error("expected duration_ms in metadata")
}
}
func TestIISConnector_ValidateDeployment_NoBinding(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-WebBinding"] = mockResponse{output: "NO_BINDING\n", err: nil}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "TestSite",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
})
if err == nil {
t.Fatal("expected error when no binding found")
}
if result.Valid {
t.Fatal("expected invalid result")
}
if !strings.Contains(err.Error(), "no HTTPS binding found") {
t.Errorf("expected 'no HTTPS binding found' in error, got: %v", err)
}
}
func TestIISConnector_ValidateDeployment_CertNotInStore(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
executor.responses["Get-ChildItem"] = mockResponse{output: "NOT_FOUND\n", err: nil}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
})
if err == nil {
t.Fatal("expected error when cert not in store")
}
if result.Valid {
t.Fatal("expected invalid result")
}
if result.Metadata["status"] != "not_found" {
t.Errorf("expected status=not_found in metadata, got: %s", result.Metadata["status"])
}
}
func TestIISConnector_ValidateDeployment_CertExpired(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
executor.responses["Get-ChildItem"] = mockResponse{output: "EXPIRED\n", err: nil}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
})
if err == nil {
t.Fatal("expected error when cert is expired")
}
if result.Valid {
t.Fatal("expected invalid result")
}
if result.Metadata["status"] != "expired" {
t.Errorf("expected status=expired in metadata, got: %s", result.Metadata["status"])
}
}
func TestIISConnector_ValidateDeployment_QueryFails(t *testing.T) {
executor := newMockExecutor()
executor.responses["Get-WebBinding"] = mockResponse{
output: "Permission denied",
err: fmt.Errorf("exit status 1"),
}
connector := NewWithExecutor(&Config{
Hostname: "web01",
SiteName: "Default Web Site",
CertStore: "My",
Port: 443,
}, testLogger(), executor)
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
})
if err == nil {
t.Fatal("expected error when query fails")
}
if result.Valid {
t.Fatal("expected invalid result")
}
}
// --- PFX conversion tests (pure Go crypto, runs on any OS) ---
func TestCreatePFX_Success(t *testing.T) {
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
pfxData, err := createPFX(certPEM, keyPEM, chainPEM, "testpassword")
if err != nil {
t.Fatalf("createPFX failed: %v", err)
}
if len(pfxData) == 0 {
t.Fatal("expected non-empty PFX data")
}
// Verify PFX is parseable
_, _, _, err = pkcs12.DecodeChain(pfxData, "testpassword")
if err != nil {
t.Fatalf("PFX data is not valid PKCS#12: %v", err)
}
}
func TestCreatePFX_NoChain(t *testing.T) {
certPEM, keyPEM, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
pfxData, err := createPFX(certPEM, keyPEM, "", "testpassword")
if err != nil {
t.Fatalf("createPFX with no chain failed: %v", err)
}
if len(pfxData) == 0 {
t.Fatal("expected non-empty PFX data")
}
}
func TestCreatePFX_InvalidCert(t *testing.T) {
_, keyPEM, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test key: %v", err)
}
_, err = createPFX("not a valid cert", keyPEM, "", "password")
if err == nil {
t.Fatal("expected error for invalid cert PEM")
}
}
func TestCreatePFX_InvalidKey(t *testing.T) {
certPEM, _, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
_, err = createPFX(certPEM, "not a valid key", "", "password")
if err == nil {
t.Fatal("expected error for invalid key PEM")
}
}
// --- Thumbprint tests ---
func TestComputeThumbprint_Success(t *testing.T) {
certPEM, _, _, err := generateTestCertAndKey()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
thumbprint, err := computeThumbprint(certPEM)
if err != nil {
t.Fatalf("computeThumbprint failed: %v", err)
}
// SHA-1 = 20 bytes = 40 hex chars
if len(thumbprint) != 40 {
t.Errorf("expected 40-char thumbprint, got %d chars: %s", len(thumbprint), thumbprint)
}
// Should be uppercase hex
if thumbprint != strings.ToUpper(thumbprint) {
t.Errorf("thumbprint should be uppercase, got: %s", thumbprint)
}
}
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
_, err := computeThumbprint("not a valid pem")
if err == nil {
t.Fatal("expected error for invalid PEM")
}
}
func TestComputeThumbprint_EmptyString(t *testing.T) {
_, err := computeThumbprint("")
if err == nil {
t.Fatal("expected error for empty string")
}
}
// --- Validation helper tests ---
func TestValidateIISName_Valid(t *testing.T) {
tests := []string{
"Default Web Site",
"My",
"WebHosting",
"site-01",
"my_site.prod",
"Test 123",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
if err := validateIISName(name, "test_field"); err != nil {
t.Errorf("expected valid name %q, got error: %v", name, err)
}
})
}
}
func TestValidateIISName_Invalid(t *testing.T) {
tests := []struct {
name string
input string
}{
{"empty", ""},
{"semicolon", "My;Store"},
{"dollar", "My$Store"},
{"backtick", "My`Store"},
{"pipe", "My|Store"},
{"ampersand", "My&Store"},
{"parentheses", "My(Store)"},
{"quotes", `My"Store"`},
{"angle_brackets", "My<Store>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateIISName(tt.input, "test_field"); err == nil {
t.Errorf("expected error for name %q", tt.input)
}
})
}
}
func TestValidateIISName_TooLong(t *testing.T) {
longName := strings.Repeat("a", 257)
if err := validateIISName(longName, "test_field"); err == nil {
t.Fatal("expected error for name exceeding 256 chars")
}
}
// --- Random password generation ---
func TestGenerateRandomPassword(t *testing.T) {
pw, err := generateRandomPassword(32)
if err != nil {
t.Fatalf("generateRandomPassword failed: %v", err)
}
if len(pw) != 32 {
t.Errorf("expected 32-char password, got %d", len(pw))
}
// Verify it only contains allowed characters
for _, c := range pw {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
t.Errorf("unexpected character in password: %c", c)
}
}
// Verify two passwords are different (probabilistic but reliable)
pw2, _ := generateRandomPassword(32)
if pw == pw2 {
t.Error("two generated passwords should be different")
}
}