ci(release): require trusted Windows signing
This commit is contained in:
parent
5205dcdfa9
commit
3e9e04f68c
5 changed files with 279 additions and 4 deletions
221
scripts/ci/verify-windows-release-artifact.ps1
Normal file
221
scripts/ci/verify-windows-release-artifact.ps1
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#Requires -Version 5.1
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$')]
|
||||
[string]$ExpectedVersion,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$ExpectedSignerSubject,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ReleaseDirectory
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Fail-Verification {
|
||||
param([Parameter(Mandatory = $true)][string]$Reason)
|
||||
throw "windows_release_verification_failed:$Reason"
|
||||
}
|
||||
|
||||
function Get-RequiredYamlValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Text,
|
||||
[Parameter(Mandatory = $true)][string]$Pattern,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
$matches = [regex]::Matches($Text, $Pattern)
|
||||
if ($matches.Count -ne 1) {
|
||||
Fail-Verification "latest_yml_${Name}_count_$($matches.Count)"
|
||||
}
|
||||
|
||||
return $matches[0].Groups[1].Value.Trim().Trim('"').Trim("'")
|
||||
}
|
||||
|
||||
function Get-Sha512Base64 {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
try {
|
||||
return [Convert]::ToBase64String($sha512.ComputeHash($stream))
|
||||
}
|
||||
finally {
|
||||
$sha512.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PeVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
[Parameter(Mandatory = $true)][version]$Expected
|
||||
)
|
||||
|
||||
$versionInfo = (Get-Item -LiteralPath $Path).VersionInfo
|
||||
foreach ($entry in @(
|
||||
@{ Name = 'file_version'; Value = $versionInfo.FileVersion },
|
||||
@{ Name = 'product_version'; Value = $versionInfo.ProductVersion }
|
||||
)) {
|
||||
$value = [string]$entry.Value
|
||||
if ($value -notmatch '^\d+\.\d+\.\d+(?:\.0)?$') {
|
||||
Fail-Verification "${Label}_$($entry.Name)_invalid"
|
||||
}
|
||||
|
||||
$actual = [version]$value
|
||||
if ($actual.Major -ne $Expected.Major -or
|
||||
$actual.Minor -ne $Expected.Minor -or
|
||||
$actual.Build -ne $Expected.Build -or
|
||||
($actual.Revision -notin @(-1, 0))) {
|
||||
Fail-Verification "${Label}_$($entry.Name)_mismatch"
|
||||
}
|
||||
}
|
||||
|
||||
if ($versionInfo.ProductName -cne 'D3RO Voice') {
|
||||
Fail-Verification "${Label}_product_name_mismatch"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ProductionSignature {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSubject
|
||||
)
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
||||
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
|
||||
Fail-Verification "${Label}_authenticode_$($signature.Status)"
|
||||
}
|
||||
|
||||
$certificate = $signature.SignerCertificate
|
||||
if ($null -eq $certificate) {
|
||||
Fail-Verification "${Label}_signer_certificate_missing"
|
||||
}
|
||||
|
||||
$actualSubject = $certificate.Subject.Trim()
|
||||
if (-not [string]::Equals($actualSubject, $ExpectedSubject, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Fail-Verification "${Label}_signer_subject_mismatch"
|
||||
}
|
||||
|
||||
if ([string]::Equals($certificate.Subject, $certificate.Issuer, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
Fail-Verification "${Label}_self_signed_certificate"
|
||||
}
|
||||
|
||||
$now = [DateTime]::UtcNow
|
||||
if ($certificate.NotBefore.ToUniversalTime() -gt $now -or $certificate.NotAfter.ToUniversalTime() -le $now) {
|
||||
Fail-Verification "${Label}_signer_certificate_not_current"
|
||||
}
|
||||
|
||||
$codeSigningOid = '1.3.6.1.5.5.7.3.3'
|
||||
$ekuOids = @()
|
||||
foreach ($extension in $certificate.Extensions) {
|
||||
if ($extension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) {
|
||||
foreach ($usage in $extension.EnhancedKeyUsages) {
|
||||
$ekuOids += $usage.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($ekuOids -notcontains $codeSigningOid) {
|
||||
Fail-Verification "${Label}_code_signing_eku_missing"
|
||||
}
|
||||
|
||||
return $certificate
|
||||
}
|
||||
|
||||
$expectedSubject = $ExpectedSignerSubject.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($expectedSubject)) {
|
||||
Fail-Verification 'expected_signer_subject_empty'
|
||||
}
|
||||
if ($expectedSubject -match '(?i)Everything2EverythingDev') {
|
||||
Fail-Verification 'development_signer_subject_forbidden'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ReleaseDirectory)) {
|
||||
$ReleaseDirectory = Join-Path $PSScriptRoot "../../apps/desktop/release/$ExpectedVersion"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ReleaseDirectory -PathType Container)) {
|
||||
Fail-Verification 'release_directory_missing'
|
||||
}
|
||||
$releasePath = (Resolve-Path -LiteralPath $ReleaseDirectory).Path
|
||||
if ((Split-Path -Leaf $releasePath) -cne $ExpectedVersion) {
|
||||
Fail-Verification 'release_directory_version_mismatch'
|
||||
}
|
||||
|
||||
$installerName = "D3RO-Voice-Setup-$ExpectedVersion-x64.exe"
|
||||
$installerPath = Join-Path $releasePath $installerName
|
||||
$latestPath = Join-Path $releasePath 'latest.yml'
|
||||
$appPath = Join-Path $releasePath 'win-unpacked/D3RO Voice.exe'
|
||||
|
||||
foreach ($requiredFile in @($installerPath, $latestPath, $appPath)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) {
|
||||
Fail-Verification "required_file_missing_$(Split-Path -Leaf $requiredFile)"
|
||||
}
|
||||
}
|
||||
|
||||
$installers = @(Get-ChildItem -LiteralPath $releasePath -File -Filter 'D3RO-Voice-Setup-*-x64.exe')
|
||||
if ($installers.Count -ne 1 -or $installers[0].Name -cne $installerName) {
|
||||
Fail-Verification 'installer_set_not_exact'
|
||||
}
|
||||
|
||||
$latestText = [System.IO.File]::ReadAllText($latestPath)
|
||||
$metadataVersion = Get-RequiredYamlValue $latestText '(?m)^version:\s*([^\r\n#]+?)\s*$' 'version'
|
||||
$metadataUrl = Get-RequiredYamlValue $latestText '(?m)^\s{2}-\s+url:\s*([^\r\n#]+?)\s*$' 'file_url'
|
||||
$metadataFileSha512 = Get-RequiredYamlValue $latestText '(?m)^\s{4}sha512:\s*([^\r\n#]+?)\s*$' 'file_sha512'
|
||||
$metadataSizeText = Get-RequiredYamlValue $latestText '(?m)^\s{4}size:\s*([^\r\n#]+?)\s*$' 'file_size'
|
||||
$metadataPath = Get-RequiredYamlValue $latestText '(?m)^path:\s*([^\r\n#]+?)\s*$' 'path'
|
||||
$metadataSha512 = Get-RequiredYamlValue $latestText '(?m)^sha512:\s*([^\r\n#]+?)\s*$' 'sha512'
|
||||
|
||||
if ($metadataVersion -cne $ExpectedVersion) {
|
||||
Fail-Verification 'latest_yml_version_mismatch'
|
||||
}
|
||||
if ($metadataUrl -cne $installerName -or $metadataPath -cne $installerName) {
|
||||
Fail-Verification 'latest_yml_installer_reference_mismatch'
|
||||
}
|
||||
|
||||
[long]$metadataSize = 0
|
||||
if (-not [long]::TryParse($metadataSizeText, [ref]$metadataSize) -or $metadataSize -le 0) {
|
||||
Fail-Verification 'latest_yml_size_invalid'
|
||||
}
|
||||
$installerSize = (Get-Item -LiteralPath $installerPath).Length
|
||||
if ($metadataSize -ne $installerSize) {
|
||||
Fail-Verification 'latest_yml_size_mismatch'
|
||||
}
|
||||
|
||||
$installerSha512 = Get-Sha512Base64 $installerPath
|
||||
if ($metadataFileSha512 -cne $installerSha512 -or $metadataSha512 -cne $installerSha512) {
|
||||
Fail-Verification 'latest_yml_sha512_mismatch'
|
||||
}
|
||||
|
||||
$expectedPeVersion = [version]"$ExpectedVersion.0"
|
||||
Assert-PeVersion $installerPath 'installer' $expectedPeVersion
|
||||
Assert-PeVersion $appPath 'unpacked_app' $expectedPeVersion
|
||||
|
||||
$installerCertificate = Assert-ProductionSignature $installerPath 'installer' $expectedSubject
|
||||
$appCertificate = Assert-ProductionSignature $appPath 'unpacked_app' $expectedSubject
|
||||
if ($installerCertificate.Thumbprint -cne $appCertificate.Thumbprint) {
|
||||
Fail-Verification 'signer_thumbprint_mismatch'
|
||||
}
|
||||
|
||||
$evidence = [ordered]@{
|
||||
schemaVersion = 1
|
||||
version = $ExpectedVersion
|
||||
installer = $installerName
|
||||
installerSize = $installerSize
|
||||
installerSha512 = $installerSha512
|
||||
unpackedApp = 'win-unpacked/D3RO Voice.exe'
|
||||
authenticodeStatus = 'Valid'
|
||||
signerSubject = $installerCertificate.Subject
|
||||
signerIssuer = $installerCertificate.Issuer
|
||||
signerThumbprint = $installerCertificate.Thumbprint
|
||||
verifiedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
}
|
||||
|
||||
$evidence | ConvertTo-Json -Depth 3
|
||||
Loading…
Add table
Add a link
Reference in a new issue