Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion PowerShellBuild/IB.tasks.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Remove-Variable -Name PSBPreference -Scope Script -Force -ErrorAction Ignore
Set-Variable -Name PSBPreference -Option ReadOnly -Scope Script -Value (. ([IO.Path]::Combine($PSScriptRoot, 'build.properties.ps1')))
$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies
$__DefaultBuildDependencies = $PSBPreference.Build.Dependencies

# Synopsis: Initialize build environment variables
task Init {
Expand Down Expand Up @@ -197,4 +197,136 @@ task Test Analyze,Pester

task . Build,Test

# Synopsis: Signs module files (*.psd1, *.psm1, *.ps1) with an Authenticode signature
task SignModule -If {
if (-not $PSBPreference.Sign.Enabled) {
Write-Warning 'Module signing is not enabled.'
return $false
}
if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) {
Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.'
return $false
}
$true
} Build, {
$certParams = @{
CertificateSource = $PSBPreference.Sign.CertificateSource
CertStoreLocation = $PSBPreference.Sign.CertStoreLocation
CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar
CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar
}
if ($PSBPreference.Sign.Thumbprint) {
$certParams.Thumbprint = $PSBPreference.Sign.Thumbprint
}
if ($PSBPreference.Sign.PfxFilePath) {
$certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath
}
if ($PSBPreference.Sign.PfxFilePassword) {
$certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword
}

$certificate = if ($PSBPreference.Sign.Certificate) {
$PSBPreference.Sign.Certificate
} else {
Get-PSBuildCertificate @certParams
}

if ($null -eq $certificate) {
throw $LocalizedData.NoCertificateFound
}

$signingParams = @{
Path = $PSBPreference.Build.ModuleOutDir
Certificate = $certificate
TimestampServer = $PSBPreference.Sign.TimestampServer
HashAlgorithm = $PSBPreference.Sign.HashAlgorithm
Include = $PSBPreference.Sign.FilesToSign
}
Invoke-PSBuildModuleSigning @signingParams
}

# Synopsis: Creates a Windows catalog (.cat) file for the built module
task BuildCatalog -If {
if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) {
Write-Warning 'Catalog generation is not enabled.'
return $false
}
if (-not (Get-Command -Name 'New-FileCatalog' -ErrorAction Ignore)) {
Write-Warning 'New-FileCatalog is not available. Catalog generation requires Windows.'
return $false
}
$true
} SignModule, {
$catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) {
$PSBPreference.Sign.Catalog.FileName
} else {
"$($PSBPreference.General.ModuleName).cat"
}
$catalogFilePath = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath $catalogFileName

$catalogParams = @{
ModulePath = $PSBPreference.Build.ModuleOutDir
CatalogFilePath = $catalogFilePath
CatalogVersion = $PSBPreference.Sign.Catalog.Version
}
New-PSBuildFileCatalog @catalogParams
}

# Synopsis: Signs the module catalog (.cat) file with an Authenticode signature
task SignCatalog -If {
if (-not ($PSBPreference.Sign.Enabled -and $PSBPreference.Sign.Catalog.Enabled)) {
Write-Warning 'Catalog signing is not enabled.'
return $false
}
if (-not (Get-Command -Name 'Set-AuthenticodeSignature' -ErrorAction Ignore)) {
Write-Warning 'Set-AuthenticodeSignature is not available. Module signing requires Windows.'
Comment thread
HeyItsGilbert marked this conversation as resolved.
Outdated
return $false
}
$true
} BuildCatalog, {
$certParams = @{
CertificateSource = $PSBPreference.Sign.CertificateSource
CertStoreLocation = $PSBPreference.Sign.CertStoreLocation
CertificateEnvVar = $PSBPreference.Sign.CertificateEnvVar
CertificatePasswordEnvVar = $PSBPreference.Sign.CertificatePasswordEnvVar
}
if ($PSBPreference.Sign.Thumbprint) {
$certParams.Thumbprint = $PSBPreference.Sign.Thumbprint
}
if ($PSBPreference.Sign.PfxFilePath) {
$certParams.PfxFilePath = $PSBPreference.Sign.PfxFilePath
}
if ($PSBPreference.Sign.PfxFilePassword) {
$certParams.PfxFilePassword = $PSBPreference.Sign.PfxFilePassword
}

$certificate = if ($PSBPreference.Sign.Certificate) {
$PSBPreference.Sign.Certificate
} else {
Get-PSBuildCertificate @certParams
}

if ($null -eq $certificate) {
throw $LocalizedData.NoCertificateFound
}

$catalogFileName = if ($PSBPreference.Sign.Catalog.FileName) {
$PSBPreference.Sign.Catalog.FileName
} else {
"$($PSBPreference.General.ModuleName).cat"
}

$signingParams = @{
Path = $PSBPreference.Build.ModuleOutDir
Certificate = $certificate
TimestampServer = $PSBPreference.Sign.TimestampServer
HashAlgorithm = $PSBPreference.Sign.HashAlgorithm
Include = @($catalogFileName)
}
Invoke-PSBuildModuleSigning @signingParams
}

# Synopsis: Signs module files and catalog (meta task)
task Sign SignCatalog
Comment thread
HeyItsGilbert marked this conversation as resolved.
Outdated

#endregion Summary Tasks
3 changes: 3 additions & 0 deletions PowerShellBuild/PowerShellBuild.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
'Build-PSBuildModule'
'Build-PSBuildUpdatableHelp'
'Clear-PSBuildOutputFolder'
'Get-PSBuildCertificate'
'Initialize-PSBuild'
'Invoke-PSBuildModuleSigning'
'New-PSBuildFileCatalog'
'Publish-PSBuildModule'
'Test-PSBuildPester'
'Test-PSBuildScriptAnalysis'
Expand Down
143 changes: 143 additions & 0 deletions PowerShellBuild/Public/Get-PSBuildCertificate.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
function Get-PSBuildCertificate {
<#
.SYNOPSIS
Resolves a code-signing X509Certificate2 from one of several common sources.
.DESCRIPTION
Resolves a code-signing certificate suitable for use with Set-AuthenticodeSignature.
Supports five certificate sources to accommodate local development, CI/CD pipelines,
and custom signing infrastructure:

Auto - Checks the CertificateEnvVar environment variable first. If it is
populated, uses EnvVar mode; otherwise falls back to Store mode.
This is the recommended default for projects that run both locally
and in automated pipelines.

Store - Selects the first valid, unexpired code-signing certificate that has
a private key from the Windows certificate store at CertStoreLocation.
Suitable for developer workstations where a certificate is installed.

Thumbprint - Like Store, but matches a specific certificate by its thumbprint.
Recommended when multiple code-signing certificates are installed and
Comment thread
HeyItsGilbert marked this conversation as resolved.
you need a deterministic selection.

EnvVar - Decodes a Base64-encoded PFX from an environment variable and
optionally decrypts it with a password from a second variable.
The most common approach for GitHub Actions, Azure DevOps Pipelines,
and GitLab CI where secrets are stored as masked variables.

PfxFile - Loads a PFX/P12 file from disk with an optional SecureString password.
Useful for local scripts, containers, and environments where a
certificate file is mounted or distributed via a secrets manager.

Note: Authenticode signing is a Windows-only capability. This function will fail
on non-Windows platforms when using Store or Thumbprint sources.
.PARAMETER CertificateSource
The source from which to resolve the code-signing certificate.
Valid values: Auto, Store, Thumbprint, EnvVar, PfxFile. Default: Auto.
.PARAMETER CertStoreLocation
Windows certificate store path to search when CertificateSource is Store or Thumbprint.
Default: Cert:\CurrentUser\My.
.PARAMETER Thumbprint
The exact certificate thumbprint to look up. Required when CertificateSource is Thumbprint.
.PARAMETER CertificateEnvVar
Name of the environment variable holding the Base64-encoded PFX certificate.
Used by the EnvVar source and by Auto as the presence-detection key.
Default: SIGNCERTIFICATE.
.PARAMETER CertificatePasswordEnvVar
Name of the environment variable holding the PFX password. Used by EnvVar source.
Default: CERTIFICATEPASSWORD.
.PARAMETER PfxFilePath
File system path to a PFX/P12 certificate file. Required when CertificateSource is PfxFile.
.PARAMETER PfxFilePassword
Password for the PFX file as a SecureString. Used by PfxFile source.
.OUTPUTS
System.Security.Cryptography.X509Certificates.X509Certificate2
Returns the resolved certificate, or $null if none was found (Store/Thumbprint sources).
.EXAMPLE
PS> $cert = Get-PSBuildCertificate

Resolve automatically: use the SIGNCERTIFICATE env var when present, otherwise search
the current user's certificate store.
.EXAMPLE
PS> $cert = Get-PSBuildCertificate -CertificateSource Store

Explicitly load the first valid code-signing certificate from the current user's store.
.EXAMPLE
PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD34EF56...'

Load a specific certificate from the certificate store by its thumbprint.
.EXAMPLE
PS> $cert = Get-PSBuildCertificate -CertificateSource EnvVar `
-CertificateEnvVar 'MY_PFX' -CertificatePasswordEnvVar 'MY_PFX_PASS'

Decode a PFX certificate stored in a CI/CD secret environment variable.
.EXAMPLE
PS> $pass = Read-Host -Prompt 'Certificate password' -AsSecureString
PS> $cert = Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath './codesign.pfx' -PfxFilePassword $pass

Load a code-signing certificate from a PFX file on disk.
#>
[CmdletBinding()]
[OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
param(
[ValidateSet('Auto', 'Store', 'Thumbprint', 'EnvVar', 'PfxFile')]
[string]$CertificateSource = 'Auto',

[string]$CertStoreLocation = 'Cert:\CurrentUser\My',

[string]$Thumbprint,

[string]$CertificateEnvVar = 'SIGNCERTIFICATE',

[string]$CertificatePasswordEnvVar = 'CERTIFICATEPASSWORD',

[string]$PfxFilePath,

[securestring]$PfxFilePassword
)

# Resolve 'Auto' to the actual source based on environment variable presence
$resolvedSource = $CertificateSource
if ($resolvedSource -eq 'Auto') {
$resolvedSource = if (-not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($CertificateEnvVar))) {
'EnvVar'
} else {
'Store'
}
Write-Verbose "CertificateSource is 'Auto'. Resolved to '$resolvedSource'."
Comment thread
HeyItsGilbert marked this conversation as resolved.
Outdated
}

$cert = $null

switch ($resolvedSource) {
'Store' {
$cert = Get-ChildItem -Path $CertStoreLocation -CodeSigningCert |
Where-Object { $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } |
Select-Object -First 1
if ($cert) {
Write-Verbose ($LocalizedData.CertificateResolvedFromStore -f $CertStoreLocation, $cert.Subject)
}
}
'Thumbprint' {
$cert = Get-ChildItem -Path $CertStoreLocation |
Where-Object { $_.Thumbprint -eq $Thumbprint -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } |
Comment thread
HeyItsGilbert marked this conversation as resolved.
Outdated
Select-Object -First 1
if ($cert) {
Write-Verbose ($LocalizedData.CertificateResolvedFromThumbprint -f $Thumbprint, $cert.Subject)
}
}
'EnvVar' {
$b64Value = [System.Environment]::GetEnvironmentVariable($CertificateEnvVar)
$buffer = [System.Convert]::FromBase64String($b64Value)
Comment thread
HeyItsGilbert marked this conversation as resolved.
Outdated
$password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar)
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password)
Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar)
}
'PfxFile' {
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxFilePath, $PfxFilePassword)
Write-Verbose ($LocalizedData.CertificateResolvedFromPfxFile -f $PfxFilePath)
}
Comment thread
HeyItsGilbert marked this conversation as resolved.
}

$cert
}
88 changes: 88 additions & 0 deletions PowerShellBuild/Public/Invoke-PSBuildModuleSigning.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
function Invoke-PSBuildModuleSigning {
<#
.SYNOPSIS
Signs PowerShell module files with an Authenticode signature.
.DESCRIPTION
Signs all files matching the Include patterns found under Path using
Set-AuthenticodeSignature. Typically called after the module is staged to the output
directory and before the catalog file is created, so that all signed source files are
captured in the catalog hash.

Authenticode signing is Windows-only. This function will fail on Linux or macOS.

Use Get-PSBuildCertificate to resolve the certificate from any of the supported sources
(certificate store, PFX file, Base64 environment variable, thumbprint, etc.) before
calling this function.
.PARAMETER Path
The directory to search recursively for files to sign. Typically the module output
directory (PSBPreference.Build.ModuleOutDir).
.PARAMETER Certificate
The X509Certificate2 code-signing certificate to sign files with. Must have a private
key and an Extended Key Usage (EKU) of Code Signing (1.3.6.1.5.5.7.3.3).
.PARAMETER TimestampServer
RFC 3161 timestamp server URI to embed in the Authenticode signature, allowing the
signature to remain valid after the certificate expires. Default: http://timestamp.digicert.com.

Other common timestamp servers:
http://timestamp.sectigo.com
http://timestamp.comodoca.com
http://tsa.starfieldtech.com
http://timestamp.globalsign.com/scripts/timstamp.dll
Comment thread
HeyItsGilbert marked this conversation as resolved.
.PARAMETER HashAlgorithm
Hash algorithm for the Authenticode signature.
Valid values: SHA256 (default), SHA384, SHA512, SHA1.
SHA1 is deprecated; prefer SHA256 or higher.
.PARAMETER Include
Glob patterns of file names to sign. Searched recursively under Path.
Default: *.psd1, *.psm1, *.ps1.
.OUTPUTS
System.Management.Automation.Signature
Returns the Signature objects from Set-AuthenticodeSignature for each signed file.
.EXAMPLE
PS> $cert = Get-PSBuildCertificate
PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert

Sign all .psd1, .psm1, and .ps1 files in the module output directory using a
certificate resolved automatically from the environment or certificate store.
.EXAMPLE
PS> $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'AB12CD...'
PS> Invoke-PSBuildModuleSigning -Path .\Output\MyModule\1.0.0 -Certificate $cert `
-TimestampServer 'http://timestamp.sectigo.com' -Include '*.psd1','*.psm1'

Sign only the manifest and root module using a specific certificate and a custom
timestamp server.
#>
[CmdletBinding()]
[OutputType([System.Management.Automation.Signature])]
param(
[parameter(Mandatory)]
[ValidateScript({
if (-not (Test-Path -Path $_ -PathType Container)) {
throw ($LocalizedData.PathArgumentMustBeAFolder)
}
$true
})]
[string]$Path,

[parameter(Mandatory)]
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

[string]$TimestampServer = 'http://timestamp.digicert.com',

[ValidateSet('SHA256', 'SHA384', 'SHA512', 'SHA1')]
[string]$HashAlgorithm = 'SHA256',
Comment thread
HeyItsGilbert marked this conversation as resolved.
Comment thread
HeyItsGilbert marked this conversation as resolved.

[string[]]$Include = @('*.psd1', '*.psm1', '*.ps1')
)

$files = Get-ChildItem -Path $Path -Recurse -Include $Include
Comment thread
HeyItsGilbert marked this conversation as resolved.
Write-Verbose ($LocalizedData.SigningModuleFiles -f $files.Count, ($Include -join ', '), $Path)

$sigParams = @{
Certificate = $Certificate
TimestampServer = $TimestampServer
HashAlgorithm = $HashAlgorithm
}

$files | Set-AuthenticodeSignature @sigParams
}
Loading