396 lines
12 KiB
PowerShell
396 lines
12 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$ExePath,
|
|
[string]$Subject = 'CN=Portable Caffeine Dev Code Signing',
|
|
[string]$FriendlyName = 'Portable Caffeine Dev Code Signing (Self-Signed)',
|
|
[ValidateSet('None', 'CurrentUser', 'LocalMachine')]
|
|
[string]$TrustScope = 'CurrentUser',
|
|
[ValidateSet('CurrentUser', 'LocalMachine')]
|
|
[string]$PrivateKeyStoreScope = 'CurrentUser',
|
|
[int]$YearsValid = 5,
|
|
[switch]$ForceNewCertificate,
|
|
[string]$TimestampServer,
|
|
[string]$CerExportPath,
|
|
[string]$PfxExportPath,
|
|
[securestring]$PfxPassword
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# In Windows PowerShell 5.1, $PSScriptRoot may be empty during param default evaluation.
|
|
# Resolve script-relative defaults after the param block for compatibility.
|
|
$scriptRoot = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
|
|
$PSScriptRoot
|
|
} elseif ($MyInvocation.MyCommand.Path) {
|
|
Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
} else {
|
|
(Get-Location).Path
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($ExePath)) {
|
|
$ExePath = Join-Path $scriptRoot 'dist\PortableCaffeine.exe'
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($CerExportPath)) {
|
|
$CerExportPath = Join-Path $scriptRoot 'dist\PortableCaffeine-dev-signing.cer'
|
|
}
|
|
|
|
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
|
|
throw "This script is intended for Windows only."
|
|
}
|
|
|
|
if (-not (Get-Command New-SelfSignedCertificate -ErrorAction SilentlyContinue)) {
|
|
throw "New-SelfSignedCertificate is not available in this PowerShell session."
|
|
}
|
|
|
|
if (-not (Get-Command Set-AuthenticodeSignature -ErrorAction SilentlyContinue)) {
|
|
throw "Set-AuthenticodeSignature is not available in this PowerShell session."
|
|
}
|
|
|
|
if (-not ('PortableCaffeineBuild.SigningHelpers' -as [type])) {
|
|
$helperCode = @"
|
|
using System;
|
|
using System.Security.Principal;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
namespace PortableCaffeineBuild
|
|
{
|
|
public static class SigningHelpers
|
|
{
|
|
public static bool IsAdministrator()
|
|
{
|
|
WindowsIdentity identity = WindowsIdentity.GetCurrent();
|
|
if (identity == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
WindowsPrincipal principal = new WindowsPrincipal(identity);
|
|
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
|
}
|
|
|
|
public static bool StoreContainsThumbprint(string storeName, string storeLocation, string thumbprint)
|
|
{
|
|
if (string.IsNullOrEmpty(thumbprint))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
StoreName sn = (StoreName)Enum.Parse(typeof(StoreName), storeName, true);
|
|
StoreLocation sl = (StoreLocation)Enum.Parse(typeof(StoreLocation), storeLocation, true);
|
|
|
|
X509Store store = new X509Store(sn, sl);
|
|
try
|
|
{
|
|
store.Open(OpenFlags.ReadOnly);
|
|
string normalized = thumbprint.Replace(" ", string.Empty).ToUpperInvariant();
|
|
foreach (X509Certificate2 cert in store.Certificates)
|
|
{
|
|
if (cert == null || string.IsNullOrEmpty(cert.Thumbprint))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (cert.Thumbprint.Replace(" ", string.Empty).ToUpperInvariant() == normalized)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
store.Close();
|
|
}
|
|
}
|
|
|
|
public static void AddPublicCertificateToStore(byte[] rawData, string storeName, string storeLocation)
|
|
{
|
|
if (rawData == null || rawData.Length == 0)
|
|
{
|
|
throw new ArgumentException("rawData");
|
|
}
|
|
|
|
X509Certificate2 publicCert = new X509Certificate2(rawData);
|
|
try
|
|
{
|
|
if (StoreContainsThumbprint(storeName, storeLocation, publicCert.Thumbprint))
|
|
{
|
|
return;
|
|
}
|
|
|
|
StoreName sn = (StoreName)Enum.Parse(typeof(StoreName), storeName, true);
|
|
StoreLocation sl = (StoreLocation)Enum.Parse(typeof(StoreLocation), storeLocation, true);
|
|
X509Store store = new X509Store(sn, sl);
|
|
try
|
|
{
|
|
store.Open(OpenFlags.ReadWrite);
|
|
store.Add(publicCert);
|
|
}
|
|
finally
|
|
{
|
|
store.Close();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
publicCert.Reset();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"@
|
|
|
|
Add-Type -TypeDefinition $helperCode -ReferencedAssemblies @(
|
|
'System.dll'
|
|
)
|
|
}
|
|
|
|
function Test-IsAdministrator {
|
|
return [PortableCaffeineBuild.SigningHelpers]::IsAdministrator()
|
|
}
|
|
|
|
function Get-CodeSigningCertificate {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$SubjectName,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$StoreScope
|
|
)
|
|
|
|
$storePath = "Cert:\$StoreScope\My"
|
|
if (-not (Test-Path -LiteralPath $storePath)) {
|
|
return $null
|
|
}
|
|
|
|
$now = Get-Date
|
|
$certs = Get-ChildItem -Path $storePath |
|
|
Where-Object {
|
|
$_.Subject -eq $SubjectName -and
|
|
$_.HasPrivateKey -and
|
|
$_.NotAfter -gt $now
|
|
} |
|
|
Sort-Object -Property NotAfter -Descending
|
|
|
|
if ($certs) {
|
|
return @($certs)[0]
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function New-CodeSigningCertificate {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$SubjectName,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Friendly,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$StoreScope,
|
|
[Parameter(Mandatory = $true)]
|
|
[int]$ValidYears
|
|
)
|
|
|
|
$certStoreLocation = "Cert:\$StoreScope\My"
|
|
$notAfter = (Get-Date).AddYears($ValidYears)
|
|
|
|
Write-Host "Creating new self-signed code signing certificate..."
|
|
Write-Host "Subject: $SubjectName"
|
|
Write-Host "Store: $certStoreLocation"
|
|
|
|
$newCert = New-SelfSignedCertificate `
|
|
-Type CodeSigningCert `
|
|
-Subject $SubjectName `
|
|
-FriendlyName $Friendly `
|
|
-CertStoreLocation $certStoreLocation `
|
|
-KeyAlgorithm RSA `
|
|
-KeyLength 2048 `
|
|
-KeySpec Signature `
|
|
-HashAlgorithm SHA256 `
|
|
-KeyExportPolicy Exportable `
|
|
-NotAfter $notAfter
|
|
|
|
if (-not $newCert) {
|
|
throw "New-SelfSignedCertificate did not return a certificate."
|
|
}
|
|
|
|
return $newCert
|
|
}
|
|
|
|
function Install-CertificateTrust {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('CurrentUser', 'LocalMachine')]
|
|
[string]$Scope
|
|
)
|
|
|
|
foreach ($storeName in @('Root', 'TrustedPublisher')) {
|
|
$alreadyTrusted = [PortableCaffeineBuild.SigningHelpers]::StoreContainsThumbprint($storeName, $Scope, $Certificate.Thumbprint)
|
|
if ($alreadyTrusted) {
|
|
Write-Host "Trusted store already contains cert: $Scope\$storeName ($($Certificate.Thumbprint))"
|
|
continue
|
|
}
|
|
|
|
Write-Host "Adding cert to trusted store: $Scope\$storeName"
|
|
[PortableCaffeineBuild.SigningHelpers]::AddPublicCertificateToStore($Certificate.RawData, $storeName, $Scope)
|
|
}
|
|
}
|
|
|
|
function Export-PublicCertificateIfRequested {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
|
[string]$Path
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
|
return
|
|
}
|
|
|
|
$dir = Split-Path -Parent $Path
|
|
if ($dir) {
|
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
}
|
|
|
|
$raw = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
|
[System.IO.File]::WriteAllBytes($Path, $raw)
|
|
Write-Host "Exported public certificate: $Path"
|
|
}
|
|
|
|
function Export-PfxIfRequested {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
|
[string]$Path,
|
|
[securestring]$Password
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Path)) {
|
|
return
|
|
}
|
|
|
|
if (-not $Password) {
|
|
throw "PfxExportPath was provided but no -PfxPassword was supplied."
|
|
}
|
|
|
|
if (-not (Get-Command Export-PfxCertificate -ErrorAction SilentlyContinue)) {
|
|
throw "Export-PfxCertificate is not available in this PowerShell session."
|
|
}
|
|
|
|
$dir = Split-Path -Parent $Path
|
|
if ($dir) {
|
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
}
|
|
|
|
Export-PfxCertificate -Cert $Certificate -FilePath $Path -Password $Password -Force | Out-Null
|
|
Write-Host "Exported PFX: $Path"
|
|
}
|
|
|
|
function Sign-Executable {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path,
|
|
[Parameter(Mandatory = $true)]
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
|
[string]$TimestampUrl
|
|
)
|
|
|
|
$params = @{
|
|
FilePath = $Path
|
|
Certificate = $Certificate
|
|
HashAlgorithm = 'SHA256'
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($TimestampUrl)) {
|
|
$params['TimestampServer'] = $TimestampUrl
|
|
}
|
|
|
|
Write-Host "Signing file: $Path"
|
|
$result = Set-AuthenticodeSignature @params
|
|
|
|
if (-not $result) {
|
|
throw "Set-AuthenticodeSignature did not return a result."
|
|
}
|
|
|
|
Write-Host "Sign status: $($result.Status)"
|
|
if ($result.StatusMessage) {
|
|
Write-Host "Status msg: $($result.StatusMessage)"
|
|
}
|
|
|
|
return $result
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $ExePath)) {
|
|
throw "EXE not found: $ExePath`nBuild it first with .\build-caffeine-exe.ps1"
|
|
}
|
|
|
|
if ($YearsValid -lt 1 -or $YearsValid -gt 50) {
|
|
throw "YearsValid must be between 1 and 50."
|
|
}
|
|
|
|
$needsAdmin = $false
|
|
if ($TrustScope -eq 'LocalMachine' -or $PrivateKeyStoreScope -eq 'LocalMachine') {
|
|
$needsAdmin = $true
|
|
}
|
|
|
|
if ($needsAdmin -and -not (Test-IsAdministrator)) {
|
|
throw "This operation requires an elevated PowerShell session (Run as Administrator) because LocalMachine certificate stores are being modified/used."
|
|
}
|
|
|
|
$cert = $null
|
|
if (-not $ForceNewCertificate) {
|
|
$cert = Get-CodeSigningCertificate -SubjectName $Subject -StoreScope $PrivateKeyStoreScope
|
|
}
|
|
|
|
if (-not $cert) {
|
|
$cert = New-CodeSigningCertificate -SubjectName $Subject -Friendly $FriendlyName -StoreScope $PrivateKeyStoreScope -ValidYears $YearsValid
|
|
} else {
|
|
Write-Host "Reusing existing certificate:"
|
|
Write-Host "Subject: $($cert.Subject)"
|
|
Write-Host "Thumbprint: $($cert.Thumbprint)"
|
|
Write-Host "Expires: $($cert.NotAfter)"
|
|
Write-Host "Store: Cert:\$PrivateKeyStoreScope\My"
|
|
}
|
|
|
|
Export-PublicCertificateIfRequested -Certificate $cert -Path $CerExportPath
|
|
Export-PfxIfRequested -Certificate $cert -Path $PfxExportPath -Password $PfxPassword
|
|
|
|
if ($TrustScope -ne 'None') {
|
|
Write-Host ""
|
|
Write-Host "Installing trust for self-signed certificate..."
|
|
Install-CertificateTrust -Certificate $cert -Scope $TrustScope
|
|
} else {
|
|
Write-Host "Skipping trust-store installation (TrustScope=None)."
|
|
}
|
|
|
|
Write-Host ""
|
|
$signResult = Sign-Executable -Path $ExePath -Certificate $cert -TimestampUrl $TimestampServer
|
|
$verify = Get-AuthenticodeSignature -FilePath $ExePath
|
|
|
|
Write-Host ""
|
|
Write-Host "Verification:"
|
|
Write-Host "Status: $($verify.Status)"
|
|
if ($verify.SignerCertificate) {
|
|
Write-Host "Thumbprint: $($verify.SignerCertificate.Thumbprint)"
|
|
Write-Host "Subject: $($verify.SignerCertificate.Subject)"
|
|
}
|
|
if ($verify.TimeStamperCertificate) {
|
|
Write-Host "Timestamped: Yes"
|
|
} else {
|
|
Write-Host "Timestamped: No"
|
|
}
|
|
|
|
if ($verify.Status -ne 'Valid') {
|
|
Write-Warning "Signature was applied but verification is not 'Valid'. For self-signed certs, ensure trust was installed in Root + TrustedPublisher for the relevant scope."
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "Done."
|
|
Write-Host "EXE: $ExePath"
|
|
if ($CerExportPath) {
|
|
Write-Host "CER: $CerExportPath"
|
|
}
|