# 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