d3ro-voice/scripts/ci/verify-windows-release-artifact.ps1

221 lines
7.5 KiB
PowerShell

#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