Public/Invoke-sqmSignModule.ps1
|
<# .SYNOPSIS Signs all PowerShell script files in a module directory using Set-AuthenticodeSignature. .DESCRIPTION Signs .ps1, .psm1, and .psd1 files (configurable) under a module root directory recursively. Works with any code signing certificate: commercial OV cert, self-signed cert, or a SignPath-exported PFX file. Designed to be run before each GitHub release. Certificate resolution order: 1. PFX file path (-CertificatePath) 2. Thumbprint (-CertificateThumbprint) - searched in LocalMachine\My, then CurrentUser\My 3. Auto-detect - first valid, non-expired code signing cert in both stores Each file is checked for an existing signature before signing. Files with a valid signature are skipped unless -Force is specified. Files with an invalid or expired signature are always re-signed. On timestamp server failure the function automatically retries with a fallback TSA. Results are returned as a list of PSCustomObjects and copied to the clipboard. .PARAMETER ModulePath Path to the module root directory. All matching files are signed recursively. If omitted, the parent of $PSScriptRoot is used (auto-detect for module-internal calls). .PARAMETER CertificateThumbprint Thumbprint of a certificate in Cert:\LocalMachine\My or Cert:\CurrentUser\My. If omitted and -CertificatePath is also omitted, the function auto-detects a valid code signing certificate from both stores. .PARAMETER CertificatePath Path to a .pfx file. Takes precedence over -CertificateThumbprint. .PARAMETER CertificatePassword SecureString password for the PFX file specified in -CertificatePath. .PARAMETER TimestampServer URL of the timestamp authority (TSA). Default: http://timestamp.digicert.com. On failure the function retries with http://timestamp.sectigo.com as fallback. .PARAMETER IncludeExtensions File extensions to sign. Default: @('.ps1', '.psm1', '.psd1'). .PARAMETER Force Re-signs files that already carry a valid signature. Without -Force those files are skipped. .EXAMPLE # 1. Sign with a specific certificate from the store Invoke-sqmSignModule -ModulePath "C:\Dev\MyModule" ` -CertificateThumbprint "AB12CD34EF56..." .EXAMPLE # 2. Sign with a PFX file $pwd = ConvertTo-SecureString "secret" -AsPlainText -Force Invoke-sqmSignModule -ModulePath "C:\Dev\MyModule" ` -CertificatePath "C:\Certs\CodeSign.pfx" -CertificatePassword $pwd .EXAMPLE # 3. Auto-detect certificate (no parameters needed if cert is in store) Invoke-sqmSignModule -ModulePath "C:\Dev\MyModule" .EXAMPLE # 4. WhatIf dry run - show which files would be signed Invoke-sqmSignModule -ModulePath "C:\Dev\MyModule" -WhatIf .EXAMPLE # 5. Force re-sign all files, even those already signed Invoke-sqmSignModule -ModulePath "C:\Dev\MyModule" -Force .NOTES Requires: Invoke-sqmLogging Compatible with PowerShell 5.1 and later. Uses SHA-256 as the hash algorithm for the Authenticode signature. The code signing OID is 1.3.6.1.5.5.7.3.3. #> function Invoke-sqmSignModule { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false, Position = 0)] [string]$ModulePath, [Parameter(Mandatory = $false)] [string]$CertificateThumbprint, [Parameter(Mandatory = $false)] [string]$CertificatePath, [Parameter(Mandatory = $false)] [System.Security.SecureString]$CertificatePassword, [Parameter(Mandatory = $false)] [string]$TimestampServer = 'http://timestamp.digicert.com', [Parameter(Mandatory = $false)] [string[]]$IncludeExtensions = @('.ps1', '.psm1', '.psd1'), [Parameter(Mandatory = $false)] [switch]$Force ) begin { $functionName = $MyInvocation.MyCommand.Name $fallbackTsa = 'http://timestamp.sectigo.com' $codeSignOid = '1.3.6.1.5.5.7.3.3' $results = [System.Collections.Generic.List[PSCustomObject]]::new() Invoke-sqmLogging -Message "Starting $functionName" -FunctionName $functionName -Level "INFO" # ------------------------------------------------------------------ # Resolve ModulePath # ------------------------------------------------------------------ if (-not $ModulePath) { $ModulePath = Split-Path -Parent $PSScriptRoot Invoke-sqmLogging -Message "ModulePath auto-detected: $ModulePath" -FunctionName $functionName -Level "INFO" } if (-not (Test-Path -LiteralPath $ModulePath -PathType Container)) { $msg = "ModulePath not found or is not a directory: $ModulePath" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } # ------------------------------------------------------------------ # Resolve certificate # ------------------------------------------------------------------ $cert = $null if ($CertificatePath) { # Load from PFX file if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) { $msg = "CertificatePath not found: $CertificatePath" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } try { if ($CertificatePassword) { $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2( $CertificatePath, $CertificatePassword) } else { $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2( $CertificatePath) } Invoke-sqmLogging -Message "Certificate loaded from PFX: $CertificatePath" -FunctionName $functionName -Level "INFO" } catch { $msg = "Failed to load PFX certificate from '$CertificatePath': $($_.Exception.Message)" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } } elseif ($CertificateThumbprint) { # Search by thumbprint in both stores $thumbprintClean = $CertificateThumbprint -replace '\s', '' $cert = Get-ChildItem -Path 'Cert:\LocalMachine\My' -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -ieq $thumbprintClean } | Select-Object -First 1 if (-not $cert) { $cert = Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -ieq $thumbprintClean } | Select-Object -First 1 } if (-not $cert) { $msg = "Certificate with thumbprint '$CertificateThumbprint' not found in LocalMachine\My or CurrentUser\My." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } Invoke-sqmLogging -Message "Certificate found by thumbprint in store." -FunctionName $functionName -Level "INFO" } else { # Auto-detect: first valid, non-expired code signing cert $now = Get-Date $allCerts = @(Get-ChildItem -Path 'Cert:\LocalMachine\My' -ErrorAction SilentlyContinue) + @(Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue) $cert = $allCerts | Where-Object { $_.NotAfter -gt $now -and $_.HasPrivateKey -and ($_.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $codeSignOid }) } | Select-Object -First 1 if (-not $cert) { $msg = "No valid code signing certificate found in LocalMachine\My or CurrentUser\My. Provide -CertificateThumbprint or -CertificatePath." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } Invoke-sqmLogging -Message "Code signing certificate auto-detected: $($cert.Subject)" -FunctionName $functionName -Level "INFO" } # ------------------------------------------------------------------ # Validate certificate # ------------------------------------------------------------------ $hasCodeSignEku = $cert.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $codeSignOid } if (-not $hasCodeSignEku) { $msg = "Certificate '$($cert.Subject)' does not have the Code Signing EKU (OID $codeSignOid)." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } if ($cert.NotAfter -lt (Get-Date)) { $msg = "Certificate '$($cert.Subject)' expired on $($cert.NotAfter.ToString('yyyy-MM-dd'))." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } if (-not $cert.HasPrivateKey) { $msg = "Certificate '$($cert.Subject)' does not have a private key - cannot sign." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } Invoke-sqmLogging -Message "Certificate validated - Subject: $($cert.Subject) | Thumbprint: $($cert.Thumbprint) | Expiry: $($cert.NotAfter.ToString('yyyy-MM-dd'))" -FunctionName $functionName -Level "INFO" } process { # Collect files $files = Get-ChildItem -LiteralPath $ModulePath -Recurse -File -ErrorAction Stop | Where-Object { $IncludeExtensions -contains $_.Extension.ToLower() } if (-not $files) { Invoke-sqmLogging -Message "No files matching extensions ($($IncludeExtensions -join ', ')) found under $ModulePath." -FunctionName $functionName -Level "WARNING" return } Invoke-sqmLogging -Message "Found $($files.Count) file(s) to process under $ModulePath." -FunctionName $functionName -Level "INFO" foreach ($file in $files) { $filePath = $file.FullName $fileName = $file.Name $prevStatus = 'Unknown' $errorMessage = $null $usedTsa = $TimestampServer $fileStatus = 'Unknown' # Check existing signature try { $sigInfo = Get-AuthenticodeSignature -LiteralPath $filePath -ErrorAction Stop $prevStatus = $sigInfo.Status.ToString() } catch { $prevStatus = 'CheckFailed' } # Skip if already valid and -Force not set if ($prevStatus -eq 'Valid' -and -not $Force) { Invoke-sqmLogging -Message "Skipping '$fileName' - already has a valid signature. Use -Force to re-sign." -FunctionName $functionName -Level "INFO" $results.Add([PSCustomObject]@{ FilePath = $filePath FileName = $fileName Status = 'Skipped' PreviousStatus = $prevStatus Thumbprint = $cert.Thumbprint CertSubject = $cert.Subject TimestampServer = $null ErrorMessage = $null }) continue } # WhatIf if (-not $PSCmdlet.ShouldProcess($filePath, "Sign with certificate '$($cert.Subject)'")) { $results.Add([PSCustomObject]@{ FilePath = $filePath FileName = $fileName Status = 'WhatIf' PreviousStatus = $prevStatus Thumbprint = $cert.Thumbprint CertSubject = $cert.Subject TimestampServer = $TimestampServer ErrorMessage = $null }) continue } # Sign - try primary TSA, then fallback $signed = $false foreach ($tsa in @($TimestampServer, $fallbackTsa)) { try { $signResult = Set-AuthenticodeSignature ` -FilePath $filePath ` -Certificate $cert ` -TimestampServer $tsa ` -HashAlgorithm SHA256 ` -ErrorAction Stop if ($signResult.Status -eq 'Valid') { $fileStatus = 'Signed' $usedTsa = $tsa $signed = $true Invoke-sqmLogging -Message "Signed '$fileName' using TSA: $tsa" -FunctionName $functionName -Level "INFO" break } else { $errorMessage = "Set-AuthenticodeSignature returned status '$($signResult.Status)' using TSA: $tsa" Invoke-sqmLogging -Message "$fileName - $errorMessage" -FunctionName $functionName -Level "WARNING" } } catch { $errorMessage = "TSA '$tsa' failed: $($_.Exception.Message)" Invoke-sqmLogging -Message "$fileName - $errorMessage" -FunctionName $functionName -Level "WARNING" } } if (-not $signed) { $fileStatus = 'Failed' Invoke-sqmLogging -Message "FAILED to sign '$fileName'. Last error: $errorMessage" -FunctionName $functionName -Level "ERROR" } $results.Add([PSCustomObject]@{ FilePath = $filePath FileName = $fileName Status = $fileStatus PreviousStatus = $prevStatus Thumbprint = $cert.Thumbprint CertSubject = $cert.Subject TimestampServer = $usedTsa ErrorMessage = $errorMessage }) } } end { $signedCount = @($results | Where-Object { $_.Status -eq 'Signed' }).Count $skippedCount = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count $failedCount = @($results | Where-Object { $_.Status -eq 'Failed' }).Count $whatIfCount = @($results | Where-Object { $_.Status -eq 'WhatIf' }).Count $summaryMsg = "$functionName complete - Signed: $signedCount | Skipped: $skippedCount | Failed: $failedCount | WhatIf: $whatIfCount" Invoke-sqmLogging -Message $summaryMsg -FunctionName $functionName -Level "INFO" Write-Verbose $summaryMsg if ($failedCount -gt 0) { Write-Warning "${functionName}: $failedCount file(s) could not be signed. Check ErrorMessage in results." } # Copy summary to clipboard if ($results.Count -gt 0) { try { $clipText = $results | Select-Object FileName, Status, PreviousStatus, TimestampServer, ErrorMessage | Format-Table -AutoSize | Out-String Set-Clipboard -Value $clipText.Trim() Invoke-sqmLogging -Message "Results ($($results.Count) file(s)) copied to clipboard." -FunctionName $functionName -Level "INFO" } catch { Invoke-sqmLogging -Message "Could not write to clipboard: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } } return $results } } |