Files
aza/AzA march 2026/sign_release_artifacts.ps1
2026-06-10 22:55:03 +02:00

448 lines
16 KiB
PowerShell

# sign_release_artifacts.ps1
# DigiCert KeyLocker Code Signing — AzA Release-Artefakte
# Minimalversion: ExeOnly | InstallerOnly | DryRun
# Keine Integration in release.ps1 in dieser Version.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("ExeOnly", "InstallerOnly")]
[string]$Phase,
[string]$DistRoot = "",
[string]$InstallerPath = "",
[string]$ReportRoot = "",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
# --- Exit-Codes ---
$EXIT_OK = 0
$EXIT_ENV = 10
$EXIT_KEYPAIR = 11
$EXIT_MISSING = 12
$EXIT_UNEXPECTED = 13
$EXIT_ALREADY_SIGNED = 14
$EXIT_SIGN_FAIL = 20
$EXIT_VERIFY_FAIL = 21
$EXIT_SHA = 22
$EXIT_AUTH = 30
# --- Feste Allowlists ---
$Script:ExeAllowlist = @(
"aza_desktop.exe",
"aza_start_panel.exe",
"aza_updater.exe",
"AZA_EmpfangShell.exe",
"AZA_KontaktPanel.exe"
)
$Script:InstallerAllowlist = @(
"aza_desktop_setup.exe",
"aza_desktop_setup_TEST.exe"
)
$Script:PublisherExpected = "Praxis Lindengut AG"
function Get-ProjectRoot {
return $PSScriptRoot
}
function Find-SignTool {
$kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin"
if (-not (Test-Path $kitsRoot)) { return $null }
$candidates = Get-ChildItem -Path $kitsRoot -Recurse -Filter "signtool.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object { [version]($_.Directory.Parent.Name) } -Descending
if ($candidates) { return $candidates[0].FullName }
return $null
}
function Find-Smctl {
$candidates = @(
"${env:ProgramFiles}\DigiCert\DigiCert One Signing Manager Tools\smctl.exe",
"${env:ProgramFiles(x86)}\DigiCert\DigiCert One Signing Manager Tools\smctl.exe"
)
foreach ($p in $candidates) {
if (Test-Path $p) { return $p }
}
$found = Get-ChildItem "${env:ProgramFiles}\DigiCert" -Recurse -Filter "smctl.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($found) { return $found.FullName }
return $null
}
function Get-Sha256Hex {
param([string]$Path)
return (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToUpperInvariant()
}
function Redact-Text {
param([string]$Text)
if (-not $Text) { return "" }
$t = $Text
$t = $t -replace '[A-Fa-f0-9]{40,64}', '[HEX-REDACTED]'
$t = $t -replace '\bkey_\d+\b', '[ALIAS-REDACTED]'
$t = $t -replace '(?i)(api[_ -]?key|password|token|secret|fingerprint|certificate_id)[^\r\n]*', '[CRED-REDACTED]'
if ($env:SM_CLIENT_CERT_FILE) {
$t = $t -replace [regex]::Escape($env:SM_CLIENT_CERT_FILE), '[CERT-PATH-REDACTED]'
}
return $t
}
function Get-SignatureSummary {
param([string]$Path)
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction SilentlyContinue
$status = if ($sig) { [string]$sig.Status } else { "Unknown" }
$subject = if ($sig -and $sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { $null }
$hasTs = [bool]($sig -and $sig.TimeStamperCertificate)
$notAfter = if ($sig -and $sig.SignerCertificate) { $sig.SignerCertificate.NotAfter.ToString("o") } else { $null }
return [ordered]@{
status = $status
publisher_contains_expected = [bool]($subject -and ($subject -match [regex]::Escape($Script:PublisherExpected)))
timestamp_present = $hasTs
not_after = $notAfter
}
}
function Test-Environment {
param(
[string]$SmctlPath,
[bool]$RequireKeypair,
[ref]$KeypairAliasOut
)
if (-not $SmctlPath -or -not (Test-Path $SmctlPath)) {
throw [System.InvalidOperationException]::new("smctl nicht gefunden")
}
if (-not $env:SM_HOST) {
throw [System.InvalidOperationException]::new("SM_HOST nicht gesetzt")
}
if (-not $env:SM_CLIENT_CERT_FILE) {
throw [System.InvalidOperationException]::new("SM_CLIENT_CERT_FILE nicht gesetzt")
}
if (-not (Test-Path -LiteralPath $env:SM_CLIENT_CERT_FILE)) {
throw [System.InvalidOperationException]::new("SM_CLIENT_CERT_FILE nicht erreichbar")
}
$healthRaw = & $SmctlPath healthcheck 2>&1 | Out-String
if ($healthRaw -notmatch 'Status:\s*Connected') {
throw [System.InvalidOperationException]::new("smctl healthcheck: nicht Connected")
}
if ($healthRaw -notmatch 'Can sign:\s*Yes') {
throw [System.InvalidOperationException]::new("smctl healthcheck: Can sign nicht Yes")
}
if ($RequireKeypair) {
$kpRaw = & $SmctlPath keypair ls 2>&1 | Out-String
$onlineLines = ($kpRaw -split "`n") | Where-Object {
$_ -match 'ONLINE' -and $_ -match 'PRODUCTION'
}
if (($onlineLines | Measure-Object).Count -ne 1) {
throw [System.InvalidOperationException]::new("Keypair nicht eindeutig (ONLINE/PRODUCTION)")
}
$m = [regex]::Match(($onlineLines | Select-Object -First 1), '\bkey_\d+\b')
if (-not $m.Success) {
throw [System.InvalidOperationException]::new("Keypair-Alias konnte nicht lokal ermittelt werden")
}
$KeypairAliasOut.Value = $m.Value
}
}
function Get-TargetFiles {
param(
[string]$PhaseName,
[string]$DistRootPath,
[string]$InstallerPathFull
)
$targets = @()
if ($PhaseName -eq "ExeOnly") {
if (-not (Test-Path -LiteralPath $DistRootPath)) {
throw [System.IO.FileNotFoundException]::new("DistRoot fehlt: $DistRootPath")
}
$rootExes = Get-ChildItem -LiteralPath $DistRootPath -File -Filter "*.exe" -ErrorAction SilentlyContinue
$rootNames = @($rootExes | ForEach-Object { $_.Name })
$allowedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$Script:ExeAllowlist, [StringComparer]::OrdinalIgnoreCase)
foreach ($name in $rootNames) {
if (-not $allowedSet.Contains($name)) {
throw [System.InvalidOperationException]::new("Unerwartete EXE in DistRoot: $name")
}
}
foreach ($name in $Script:ExeAllowlist) {
$full = Join-Path $DistRootPath $name
if (-not (Test-Path -LiteralPath $full)) {
throw [System.IO.FileNotFoundException]::new("Allowlist-Datei fehlt: $name")
}
$targets += Get-Item -LiteralPath $full
}
}
else {
if (-not (Test-Path -LiteralPath $InstallerPathFull)) {
throw [System.IO.FileNotFoundException]::new("Installer fehlt: $InstallerPathFull")
}
$instName = [IO.Path]::GetFileName($InstallerPathFull)
$allowedInstaller = $false
foreach ($allowedName in $Script:InstallerAllowlist) {
if ($instName -eq $allowedName) { $allowedInstaller = $true; break }
}
if (-not $allowedInstaller) {
throw [System.InvalidOperationException]::new("Installer-Dateiname nicht erlaubt: $instName")
}
$targets += Get-Item -LiteralPath $InstallerPathFull
}
return ,$targets
}
function Assert-NotSigned {
param([System.IO.FileInfo]$File)
$sum = Get-SignatureSummary -Path $File.FullName
if ($sum.status -eq "Valid") {
throw [System.InvalidOperationException]::new("Bereits signiert: $($File.Name)")
}
if ($sum.status -ne "NotSigned") {
throw [System.InvalidOperationException]::new("Unerwarteter Signaturstatus '$($sum.status)' fuer $($File.Name)")
}
}
function Invoke-TripleVerify {
param(
[string]$Path,
[string]$SmctlPath,
[string]$SignToolPath,
[string]$PreSignSha
)
$postSignSha = Get-Sha256Hex -Path $Path
if ($postSignSha -eq $PreSignSha) {
throw [System.InvalidOperationException]::new("SHA-Invariante verletzt: Post-Sign identisch mit Pre-Sign")
}
$auth = Get-AuthenticodeSignature -FilePath $Path
if ($auth.Status -ne "Valid") {
throw [System.InvalidOperationException]::new("Get-AuthenticodeSignature nicht Valid")
}
if (-not $auth.SignerCertificate -or ($auth.SignerCertificate.Subject -notmatch [regex]::Escape($Script:PublisherExpected))) {
throw [System.InvalidOperationException]::new("Publisher nicht wie erwartet")
}
if (-not $auth.TimeStamperCertificate) {
throw [System.InvalidOperationException]::new("Kein RFC3161-Zeitstempel")
}
$stOut = & $SignToolPath verify /pa /v $Path 2>&1 | Out-String
if ($LASTEXITCODE -ne 0 -or $stOut -notmatch 'Successfully verified') {
throw [System.InvalidOperationException]::new("SignTool verify fehlgeschlagen")
}
$oldPath = $env:Path
try {
$signToolDir = Split-Path $SignToolPath -Parent
if ($signToolDir -and ($env:Path -notlike "*$signToolDir*")) {
$env:Path = "$signToolDir;$env:Path"
}
$smOut = & $SmctlPath sign verify --input $Path 2>&1 | Out-String
if ($LASTEXITCODE -ne 0 -or $smOut -notmatch 'SUCCESSFUL') {
throw [System.InvalidOperationException]::new("SMCTL sign verify fehlgeschlagen")
}
}
finally {
$env:Path = $oldPath
}
$afterVerifySha = Get-Sha256Hex -Path $Path
if ($afterVerifySha -ne $postSignSha) {
throw [System.InvalidOperationException]::new("SHA nach Verify geaendert")
}
return @{
sha_pre = $PreSignSha
sha_post_sign = $postSignSha
sha_after_verify = $afterVerifySha
}
}
function Write-Reports {
param(
[hashtable]$RunMeta,
[array]$Entries,
[string]$ReportDir
)
if (-not (Test-Path $ReportDir)) {
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
}
$jsonPath = Join-Path $ReportDir "signing_report.json"
$txtPath = Join-Path $ReportDir "signing_report.txt"
$shaPath = Join-Path $ReportDir "SHA256SUMS.txt"
$payload = [ordered]@{
generated_at = (Get-Date).ToString("o")
phase = $RunMeta.phase
dry_run = [bool]$RunMeta.dry_run
exit_code = $RunMeta.exit_code
user = $RunMeta.user
targets_count = $Entries.Count
entries = $Entries
integration_todo = @(
"release.ps1 noch nicht integriert",
"build_installer.ps1 Precheck noch nicht integriert",
"Versionierte Installer-Kopie nach Signierung aus signiertem Hauptinstaller erzeugen",
"Testkanal publish_test_update.ps1 noch nicht gebaut"
)
}
($payload | ConvertTo-Json -Depth 8) | Set-Content -Path $jsonPath -Encoding UTF8
$txt = @()
$txt += "AzA Code Signing Report"
$txt += "Generated: $($payload.generated_at)"
$txt += "Phase: $($RunMeta.phase)"
$txt += "DryRun: $($RunMeta.dry_run)"
$txt += "Exit: $($RunMeta.exit_code)"
$txt += ""
foreach ($e in $Entries) {
$txt += "File: $($e.relative_path)"
$txt += " Size: $($e.size_bytes)"
$txt += " LastWrite: $($e.last_write_time)"
$txt += " SHA256: $($e.sha256_pre)"
$txt += " SigBefore: $($e.signature_status_before)"
$txt += " Planned: $($e.planned_action)"
$txt += " Result: $($e.result)"
$txt += ""
}
($txt -join "`r`n") | Set-Content -Path $txtPath -Encoding UTF8
$shaLines = @()
foreach ($e in $Entries) {
$shaLines += "$($e.sha256_pre) $($e.relative_path)"
if ($e.sha256_post) {
$shaLines += "$($e.sha256_post) $($e.relative_path) (post-sign)"
}
if ($e.sha256_after_verify) {
$shaLines += "$($e.sha256_after_verify) $($e.relative_path) (after-verify)"
}
}
($shaLines -join "`r`n") + "`r`n" | Set-Content -Path $shaPath -Encoding UTF8
return @{
json = $jsonPath
txt = $txtPath
sha = $shaPath
dir = $ReportDir
}
}
# --- Main ---
$projectRoot = Get-ProjectRoot
if (-not $DistRoot) { $DistRoot = Join-Path $projectRoot "dist\aza_desktop" }
if (-not $InstallerPath) { $InstallerPath = Join-Path $projectRoot "dist\installer\aza_desktop_setup.exe" }
if (-not $ReportRoot) { $ReportRoot = Join-Path $projectRoot "dist\signing_reports" }
$runStamp = Get-Date -Format "yyyyMMdd_HHmmss"
$reportDir = Join-Path $ReportRoot $runStamp
$smctlPath = Find-Smctl
$signToolPath = Find-SignTool
$keypairAlias = $null
$entries = @()
$exitCode = $EXIT_OK
try {
Write-Host "sign_release_artifacts.ps1 Phase=$Phase DryRun=$($DryRun.IsPresent)"
Test-Environment -SmctlPath $smctlPath -RequireKeypair:(-not $DryRun) -KeypairAliasOut ([ref]$keypairAlias) | Out-Null
$targets = Get-TargetFiles -PhaseName $Phase -DistRootPath $DistRoot -InstallerPathFull $InstallerPath
foreach ($file in $targets) {
$rel = if ($Phase -eq "ExeOnly") {
"dist/aza_desktop/$($file.Name)"
} else {
"dist/installer/$($file.Name)"
}
$shaPre = Get-Sha256Hex -Path $file.FullName
$sigBefore = Get-SignatureSummary -Path $file.FullName
Assert-NotSigned -File $file
$entry = [ordered]@{
relative_path = $rel
size_bytes = $file.Length
last_write_time = $file.LastWriteTime.ToString("o")
sha256_pre = $shaPre
signature_status_before = $sigBefore.status
planned_action = if ($DryRun) { "dry-run-check-only" } else { "smctl-sign-simple-dynamic-auth" }
dry_run = [bool]$DryRun
result = "pending"
}
if ($DryRun) {
$entry.result = "dry-run-ok"
$entry.sha256_post = $null
$entry.sha256_after_verify = $null
$entries += $entry
Write-Host " [DryRun OK] $rel SHA256=$shaPre Status=$($sigBefore.status)"
continue
}
# Live-Pfad (heute nicht ausfuehren via -DryRun)
$signOut = & $smctlPath sign --keypair-alias $keypairAlias --input $file.FullName --simple --dynamic-auth 2>&1 | Out-String
if ($LASTEXITCODE -ne 0 -or $signOut -match 'failed|access_denied|403|error') {
$entry.result = "sign-failed"
$entry.error = Redact-Text -Text $signOut
$entries += $entry
throw [System.InvalidOperationException]::new("Signierung fehlgeschlagen")
}
try {
$shaInfo = Invoke-TripleVerify -Path $file.FullName -SmctlPath $smctlPath -SignToolPath $signToolPath -PreSignSha $shaPre
$entry.sha256_post = $shaInfo.sha_post_sign
$entry.sha256_after_verify = $shaInfo.sha_after_verify
$entry.result = "signed-and-verified"
$entries += $entry
Write-Host " [OK] $rel"
}
catch {
$entry.result = "verify-failed"
$entry.error = Redact-Text -Text $_.Exception.Message
$entries += $entry
throw
}
}
}
catch {
$msg = $_.Exception.Message
Write-Host "FEHLER: $(Redact-Text -Text $msg)" -ForegroundColor Red
if ($msg -match 'smctl nicht gefunden|SM_HOST|SM_CLIENT_CERT_FILE|healthcheck|Can sign') { $exitCode = $EXIT_ENV }
elseif ($msg -match 'Keypair nicht eindeutig|Keypair-Alias') { $exitCode = $EXIT_KEYPAIR }
elseif ($msg -match 'Allowlist-Datei fehlt|fehlt:') { $exitCode = $EXIT_MISSING }
elseif ($msg -match 'Unerwartete EXE') { $exitCode = $EXIT_UNEXPECTED }
elseif ($msg -match 'Bereits signiert') { $exitCode = $EXIT_ALREADY_SIGNED }
elseif ($msg -match 'Signierung fehlgeschlagen|access_denied|403') { $exitCode = $EXIT_SIGN_FAIL }
elseif ($msg -match 'verify fehlgeschlagen|Publisher|Zeitstempel|Get-AuthenticodeSignature') { $exitCode = $EXIT_VERIFY_FAIL }
elseif ($msg -match 'SHA-Invariante|SHA nach Verify') { $exitCode = $EXIT_SHA }
elseif ($msg -match 'Authentifizierung|dynamic-auth|auth') { $exitCode = $EXIT_AUTH }
else { $exitCode = 20 }
}
$runMeta = @{
phase = $Phase
dry_run = [bool]$DryRun
exit_code = $exitCode
user = [Environment]::UserName
}
$reportPaths = Write-Reports -RunMeta $runMeta -Entries $entries -ReportDir $reportDir
Write-Host "Report: $($reportPaths.dir)"
exit $exitCode