PSAzureSignTool.psm1

#region public functions

function Select-FilesToSign()
{
    <#
    .SYNOPSIS
    Produces a file that containing a list of files to be code signed.

    .DESCRIPTION
    Produces a file that containing a list of files to be code signed. Searches
    the provided root directory, and selects files based on regex pattern
    parameters, which do not already have a valid authenticode signature.

    .PARAMETER Path
    System.String; Required - The root path to begin searching for files to
    code sign.

    .PARAMETER Includes
    System.String; Optional - Default value is '.*' (matching everything).
    Each file which has a full path matching this regular expression pattern
    will be signed, if:
      1. The file extension matches an extension Extensions
      2. The file full name does not match the optional 'Excludes' pattern

    .PARAMETER Excludes
    System.String; Optional - Files with a full name matching this optional
    pattern will be excluded from code signing.

    .PARAMETER Extensions
    System.String; Optional - Comma-separated list of file extensions to
    include in search for files to sign.

    .PARAMETER OutputFileList
    System.String; Required - Full path for text file which will contain list
    of files that require an authenticode signature.

    .EXAMPLE
    # Searches C:\src\bin\Release, using default parameter values;
    # C:\files-to-sign.txt will be created. Any unsigned file with an extension
    # of .exe, .dll, .ocx, or .cab.
    Select-FilesToSign -Path C:\src\bin\Release -OutputFileList C:\files-to-sign.txt

    .EXAMPLE
    # Searches C:\src\bin\Release, using default parameter values;
    # C:\files-to-sign.txt will be created. Any unsigned file with an extension
    # of .exe, .dll, .ocx, or .cab that contains 'Fabrikam' or 'FabrikamFiber'
    # in the full file name
    Select-FilesToSign -Path C:\src\bin\Release -Includes "Fabrikam(Fiber)?"
      -OutputFileList C:\files-to-sign.txt

    .EXAMPLE
    # Searches C:\src\bin\Release, using default parameter values;
    # C:\files-to-sign.txt will be created. Any unsigned file with an extension
    # of .exe, .dll, .ocx, or .cab that contains 'Fabrikam' or 'FabrikamFiber'
    # that does not include 'Test' in the full file name
    Select-FilesToSign -Path C:\src\bin\Release -Includes "Fabrikam(Fiber)?"
      -Excludes "Test" -OutputFileList C:\files-to-sign.txt
    #>


    [CmdletBinding()]
    Param(
        [Alias("pa")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({Test-Path $_})]
        [string]$Path,

        [Alias("inf")]
        [ValidateNotNullOrEmpty()]
        [string]$Includes = ".*",

        [Alias("exf")]
        [ValidateNotNullOrEmpty()]
        [string]$Excludes = "^$",

        [Alias("ext")]
        [ValidateNotNullOrEmpty()]
        [string]$Extensions = ".exe,.dll,.ocx,.cab",

        [Alias("ofl")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputFileList
    )

    [string[]]$ArrExtensions = $Extensions -split ","

    New-Item -Path (Split-Path -Path $OutputFileList -Parent) -ItemType Directory -Force | Out-Null
    
    [psobject[]]$ToSign = Get-ChildItem -Path $Path -Recurse `
        | Where-Object {($_.FullName -match $Includes) `
          -and ($_.FullName -notmatch $Excludes) `
          -and ($_.Extension -in $ArrExtensions)}        
    
    if($ToSign.Count -gt 0)
    {
        $FilesToSign = $ToSign | Test-AuthenticodeSignature | Where-Object {($_.Valid -eq $false)}     
        if($FilesToSign.Count -gt 0)
        {       
            $FilesToSign.Path | Out-File -FilePath $OutputFileList -Encoding utf8 -Force
            Write-Host "`nList of files to sign saved to $OutputFileList `n"
        }
        else
        {
            throw "All files are already signed." 
        }
    }
    else
    {
        throw "No files found to sign."      
    }
}

function Start-CodeSign()
{
    <#
    .SYNOPSIS
    Invokes AzureSignTool to sign and timestamp a list of files. For detailed
    parameter descriptions, please refer to the AzureSignTool documentaiton:
    https://github.com/vcsjones/AzureSignTool

    .PARAMETER AzureKeyVaultUrl
    System.String; Required - Corresponds to the AzureKeyVault
    '--azure-key-vault-url' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER AzureKeyVaultClientId
    System.String; Required - Corresponds to the AzureKeyVault
    '--azure-key-vault-client-id' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

     .PARAMETER AzureKeyVaultTenantId
    System.String; Required - Corresponds to the AzureKeyVault
    '--azure-key-vault-tenant-id' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER AzureKeyVaultClientSecret
    System.String; Required - Corresponds to the AzureKeyVault
    '--azure-key-vault-client-secret' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER AzureKeyVaultCertificate
    System.String; Required - Corresponds to the AzureKeyVault
    '--azure-key-vault-certificate' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER InputFileList
    System.String; Required - Corresponds to the AzureKeyVault
    '--input-file-list' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER TimestampUrl
    System.String; Optional - Corresponds to the AzureKeyVault
    '--azure-key-vault-client-id' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER Description
    System.String; Optional - Corresponds to the AzureKeyVault
    '--description' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER MaxParallelism
    System.String; Optional - Corresponds to the AzureKeyVault
    '--max-degree-of-parallelism' parameter. For details see
    https://github.com/vcsjones/AzureSignTool#parameters

    .PARAMETER MaxRetries
    System.Int16; Optional - Maximum number of retries.
    Minimum value is 0, maxiumum is 25. Defaults to 3.

    .PARAMETER BackoffRateSeconds
    System.Int16; Optional - Backoff rate, in seconds.
    Multiplied by the attempt index. Minimum value is 10,
    maximum 600. Defaults to 10.
    #>


    [CmdletBinding()]
    Param(       
        [Alias("kvu")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$AzureKeyVaultUrl,

        [Alias("kvi")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$AzureKeyVaultClientId,

        [Alias("kvt")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$AzureKeyVaultTenantId,

        [Alias("kvs")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$AzureKeyVaultClientSecret,

        [Alias("kvc")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$AzureKeyVaultCertificate,

        [Alias("ifl")]
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$InputFileList,

        [Alias("tr")]
        [string]$TimestampUrl,

        [Alias("d")]
        [string]$Description,

        [Alias("mdop")]
        [string]$MaxParallelism,

        [Alias("maxr")]
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$false)]
        [ValidateRange(0,25)]
        [int16]$MaxRetries = 3,

        [Alias("brs")]
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$false)]
        [ValidateRange(10,600)]
        [int16]$BackoffRateSeconds = 10
    )

    [string[]]$SignArguments = @(
        'sign',
        '-coe',
        '-v',
        '-fd',"sha256",
        '-kvu',$AzureKeyVaultUrl,
        '-kvi',$AzureKeyVaultClientId,
        '-kvt',$AzureKeyVaultTenantId, 
        '-kvs',$AzureKeyVaultClientSecret,
        '-kvc',$AzureKeyVaultCertificate        
    )
    
    if($TimestampUrl)
    {
        $SignArguments += @('-tr',$TimestampUrl)
    }
    else
    {
        Write-host "Warning: TimestampUrl is not provided, only digital signature will be applied to assemblies."
    }

    if($MaxParallelism)
    {
        $SignArguments += @('-mdop',$MaxParallelism)
    }

    if($Description)
    {
        $SignArguments += @('-d',$Description)
    }

    Start-AzureSignTool -InputFileList $InputFileList `
                        -SignArguments $SignArguments
    Verify-Signatures -InputFileList $InputFileList `
                      -SignArguments $SignArguments `
                      -MaxRetries $MaxRetries `
                      -BackoffRateSeconds $BackoffRateSeconds
}

#endregion public functions

#region private functions

function Install-AzureSignTool()
{
    [string]$ToolVersion = if($env:AZURE_SIGN_TOOL_VERSION){$env:AZURE_SIGN_TOOL_VERSION}else{"3.0.0"}

    [string]$ToolPath = "{0}\tools\azuresigntool\{1}" -f $env:USERPROFILE,$ToolVersion
    Write-Host ("Checking {0} for existing AzureSignTool installation..." -f $ToolPath)

    if(-not (Test-Path -Path $ToolPath))
    {
        New-Item -Path $ToolPath -ItemType Directory -Force | Out-Null
    }

    if((Get-ChildItem -Path $ToolPath -File -Recurse).Count -eq "0")
    {
        Write-Host ("AzureSignTool version {0} is not available, installing..." -f $ToolVersion)
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        
        $output = dotnet tool install --tool-path $ToolPath AzureSignTool --version $ToolVersion
        Write-Host $output
    }
    else
    {
        Write-Host ("AzureSignTool version {0} is installed." -f $ToolVersion)
        Write-Debug ((Get-ChildItem -Path $ToolPath).FullName -join ", ")
    }
    
    Return $ToolPath
}

function Test-AuthenticodeSignature()
{
    <#
    .SYNOPSIS
    Validates the authenticode signature and timestamps of a list of file paths

    .PARAMETER FullName
    System.String; Required - full path to a file being verified
    #>


    [CmdletBinding()]
    Param(
        [parameter(ValueFromPipelineByPropertyName,ValueFromPipeline)]
        [ValidateScript({Test-Path $_})]
        [string]$FullName
    )

    Process {
        [psobject[]]$SigErrors = $()
        $Signature = $FullName | Get-AuthenticodeSignature
        
        $File = @{}
        $File.Path = $FullName
        
        if ($Signature.Status.ToString() -ne 'Valid')
        {            
            $File.Issue = "Signature invalid"
            $File.Valid = $false
            $SigErrors += $File
        }
        elseIf (!$Signature.TimeStamperCertificate)
        {
                
            $File.Issue = "Timestamp certificate missing"
            $File.Valid = $false
            $SigErrors += $File
        }
        else
        {
            $File.Valid = $true
            Write-Host ("Signature/timestamp valid: {0}" -f $FullName)
        }
        
        Return $SigErrors
    }
}

function Start-AzureSignTool()
{
    <#
    .SYNOPSIS
    Call AzureSignTool module to code sign assemblies

    .PARAMETER SignArguments
    System.String array; Required - array of parameters required to start code signing
    #>


    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$InputFileList,

        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$true)]
        [string[]]$SignArguments
    )

    $SignArguments += @('-ifl',$InputFileList)

    [string]$ToolPath = Install-AzureSignTool
    
    $pscore = $false
    try{
        if ((pwsh -version)){
            $pscore = $true
        }
    }
    catch{}    
    
    if($pscore){
        pwsh -Command "& $ToolPath\AzureSignTool $SignArguments"  
    }
    else{
        powershell -Command "& $ToolPath\AzureSignTool $SignArguments"
    }
}

function Verify-Signatures()
{
    <#
    .SYNOPSIS
    Validate signatures and retry code signing if needed

    .PARAMETER InputFileList
    System.String; Required - Text file containing newline-separated list of
    full paths of files. Each file must have a valid authenticode signature
    and timestamp or the function will throw an exception.

    .PARAMETER SignArguments
    System.String array; Required - array of parameters required to start code signing

    .PARAMETER RetryIdx
    System.Int16; Optional - amount of retries which already took place
    #>


    [CmdletBinding()]
    Param(
        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$true)]
        [string]$InputFileList,

        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$true)]
        [string[]]$SignArguments,

        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$false)]
        [ValidateRange(0,25)]
        [int16]$MaxRetries = 3,

        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$false)]
        [ValidateRange(10,600)]
        [int16]$BackoffRateSeconds = 10,

        [ValidateNotNullOrEmpty()]
        [Parameter(Mandatory=$false)]
        [int16]$RetryIdx = 0
    )
    
    [psobject[]]$ToSign = Get-ChildItem -Path (Get-Content $InputFileList) `
        | Test-AuthenticodeSignature `
        | Where-Object {($_.Valid -eq $false)}
    
    if($ToSign.Count -gt 0)
    {
        if($RetryIdx -lt $MaxRetries)
        {
            $RetryIdx ++
            Write-Host "`nNot all files are code signed ($($ToSign.Count)), retrying... (attempt $($RetryIdx) of $($MaxRetries))"

            $RetryOutputFileList = (Split-Path -Path $InputFileList) + "\FilesToSign_Retry_$($RetryIdx).txt"            
            $ToSign.Path | Out-File -FilePath $RetryOutputFileList -Encoding utf8 -Force
            
            $SecondsWait = $BackoffRateSeconds * $RetryIdx
            Write-Host "Retry $($RetryIdx), in $($SecondsWait) seconds"
            Start-Sleep -s $SecondsWait          
            
            Start-AzureSignTool -InputFileList $RetryOutputFileList `
                                -SignArguments $SignArguments
            Verify-Signatures -InputFileList $RetryOutputFileList `
                              -SignArguments $SignArguments `
                              -MaxRetries $MaxRetries `
                              -BackoffRateSeconds $BackoffRateSeconds `
                              -RetryIdx $RetryIdx
        }
        else
        {
            throw "`nAmount of retries ($($RetryIdx)) has been exhausted, Code Signing failed.`n"
        }
    }
}

#endregion private functions

Export-ModuleMember -Function Select-FilesToSign, `
                              Start-CodeSign