From de51bfde67c8cecc7f01ebfb2ecd0a17ec5d60fa Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 21:19:44 +0100 Subject: [PATCH] Releases wie bei MSCO: build.ps1 laedt die Version gleich auf die Website release.ps1 wie bei MSCO: Token und Signier-Schluessel DPAPI unter %USERPROFILE%\.bso (-SaveToken, -ExportSigningKey, -ImportSigningKey), BSO_NO_RELEASE=1, Fehler brechen den Build nicht ab. Statt Gitea. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- build.ps1 | 7 +- release.ps1 | 222 +++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 165 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afab5b9..4db5c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog – BSO Mod Neue Version: hier einen Abschnitt `## ` anlegen, `` in `BSO.csproj` und `Bootstrap.ModVersion` -erhöhen, dann `.\build.ps1 -Release`. +erhöhen, dann `.\build.ps1` (laedt die Version gleich hoch). ## 0.5.1 - F8 entfernt: verbunden wird nur automatisch beim Start über die Lobby diff --git a/build.ps1 b/build.ps1 index 990aa07..635db9e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,9 +2,10 @@ # dist\dinput8.dll -> in den Spielordner (Bin_High_Win64) # dist\BSO\BSO.dll + *.json -> nach Bin_High_Win64\BSO # dist\BSO-Mod.zip -> Release-Paket fuer den Launcher (release.ps1 -Product mod) -# -Release: danach signieren und auf die Website laden (Version aus BSO.csproj, Changelog aus CHANGELOG.md) +# Danach automatisch signieren und auf die Website laden (wie MSCO; Version aus BSO.csproj, Changelog aus CHANGELOG.md). +# Gibt es die Version schon, passiert nichts. -NoRelease oder BSO_NO_RELEASE=1: nur bauen. # Voraussetzungen: Visual Studio mit C++, .NET 10 SDK. -param([switch]$Release) +param([switch]$NoRelease) $ErrorActionPreference = "Stop" $root = $PSScriptRoot $dist = Join-Path $root "dist" @@ -48,7 +49,7 @@ finally { $archive.Dispose() } Write-Host "Fertig: $dist" -if ($Release) { +if (-not $NoRelease) { $version = ([xml](Get-Content (Join-Path $root "BSO.csproj"))).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 & (Join-Path $root "release.ps1") -Product mod -Version $version -File $zip } diff --git a/release.ps1 b/release.ps1 index 9e53edb..2629eb1 100644 --- a/release.ps1 +++ b/release.ps1 @@ -1,41 +1,143 @@ -# Signiert ein Release und veroeffentlicht es als Gitea-Release auf git.mbm.wtf (Launcher und Website holen es von dort). -# .\release.ps1 -Product mod|launcher -Version 0.5.0 -File dist\BSO-Mod.zip [-Changelog "..."] -# Laeuft auch automatisch bei "dotnet publish -p:BsoRelease=true" (Launcher) bzw. "build.ps1 -Release" (Mod). -# - Repo: mod -> MrMaho/BSO-Mod, launcher -> MrMaho/BSO-Launcher; Tag v (wird auf dem Standard-Branch angelegt). -# - Ohne -Changelog: der Abschnitt "## " aus CHANGELOG.md neben diesem Skript. -# - Dateien im Release: die Datei (Launcher als BSO-Launcher-Setup.exe) und bso-release.json mit Version, SHA-256 und -# Signatur. Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten -# oeffentlichen Schluessel, egal woher die Datei kommt. -# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1) und BSO_GITEA_TOKEN (Gitea: Einstellungen -> Anwendungen -# -> Token mit "repository: Lesen und Schreiben"), auch als Windows-Benutzervariable. Optional BSO_GITEA_URL. +# Neue Version automatisch auf die BSO-Website hochladen (wie release.ps1 bei MSCO -> Keymaster). +# Laeuft nach dem Release-Build von selbst (Launcher: dotnet publish -c Release, Mod: build.ps1) - oder von Hand: +# powershell -ExecutionPolicy Bypass -File release.ps1 -Product mod|launcher -File [-Version 0.5.1] [-Changelog "..."] +# +# Braucht den Release-Token der Website (RELEASE_TOKEN in deren .env) und den privaten Signier-Schluessel. Beide liegen +# verschluesselt beim Entwickler unter %USERPROFILE%\.bso\*.dpapi (Windows DPAPI - nur dein Windows-Konto kann sie lesen). +# Einrichten: "-SaveToken" (Token), Schluessel auf einem weiteren Rechner mit "-ImportSigningKey " aus der +# Sicherung ("-ExportSigningKey ", mit eigenem Passwort) uebernehmen. Neues Paar: BSO-Launcher\new-release-key.ps1. +# BSO_RELEASE_TOKEN (Umgebungs- oder Benutzervariable) ersetzt den gespeicherten Token, BSO_MASTER_URL die Website +# (Standard https://bso.mbm.wtf), BSO_NO_RELEASE=1 schaltet Hochladen ab. +# +# Signatur: jede Datei wird signiert (RSA-3072, SHA-256, "BSO-RELEASE|produkt|version|sha256") - der Launcher +# installiert nur, was zu seinem eingebauten oeffentlichen Schluessel passt. +# Gibt es die Version auf der Website schon, passiert nichts - also einfach die Version erhoehen und bauen. +# Fehler brechen den Build nicht ab (Meldung, Exit 0). param( - [Parameter(Mandatory)][ValidateSet("mod", "launcher")][string]$Product, - [Parameter(Mandatory)][string]$Version, - [Parameter(Mandatory)][string]$File, - [string]$Changelog = "" + [ValidateSet("mod", "launcher")][string]$Product, + [string]$File, + [string]$Version = "", + [string]$Changelog = "", + [switch]$SaveToken, + [string]$ExportSigningKey = "", + [string]$ImportSigningKey = "" ) $ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path Add-Type -AssemblyName System.Security Add-Type -AssemblyName System.Net.Http -# Aus der Umgebung - sonst die Windows-Benutzervariable (z.B. wenn Visual Studio vor dem Setzen gestartet wurde) -function Setting([string]$name) { - $v = [Environment]::GetEnvironmentVariable($name) - if (-not $v) { $v = [Environment]::GetEnvironmentVariable($name, "User") } - return $v -} -$gitea = ((Setting "BSO_GITEA_URL"), "https://git.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/') -$token = Setting "BSO_GITEA_TOKEN" -if (-not $token) { Write-Host "BSO_GITEA_TOKEN nicht gesetzt - kein Release angelegt"; return } -$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi" -if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" } -$Version = $Version.Split('+')[0] -$repo = @{ mod = "BSO-Mod"; launcher = "BSO-Launcher" }[$Product] -$api = "$gitea/api/v1/repos/MrMaho/$repo" +# ---------------------------------------------------------------- Ablage der Geheimnisse +# Nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App) - ein normales Terminal saehe die Dateien nicht +$secretDir = Join-Path $env:USERPROFILE ".bso" +$tokenFile = Join-Path $secretDir "release-token.dpapi" +$keyFile = Join-Path $secretDir "release-signing-key.dpapi" +$oldKeyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi" # fruehere Ablage (new-release-key.ps1) -# Changelog aus CHANGELOG.md: alles unter "## " bis zur naechsten "## "-Zeile +function Read-Secret([string]$file) { + if (-not (Test-Path -LiteralPath $file)) { return $null } + return [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file), $null, [Security.Cryptography.DataProtectionScope]::CurrentUser) +} + +function Write-Secret([string]$file, [byte[]]$data) { + New-Item -ItemType Directory -Force -Path $secretDir | Out-Null + [IO.File]::WriteAllBytes($file, [Security.Cryptography.ProtectedData]::Protect($data, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)) +} + +function Read-SigningKey { + $k = Read-Secret $keyFile + if ($null -eq $k) { $k = Read-Secret $oldKeyFile } + return $k +} + +function Get-Pbkdf2Key([string]$password, [byte[]]$salt) { + $kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($password, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256) + return $kdf.GetBytes(32) +} + +function Get-BackupPassword([string]$prompt) { + if ($env:BSO_BACKUP_PASSWORD) { return $env:BSO_BACKUP_PASSWORD } + $secure = Read-Host $prompt -AsSecureString + return (New-Object System.Net.NetworkCredential('', $secure)).Password +} + +# ---------------------------------------------------------------- Einrichten / Sichern + +if ($SaveToken) { + $plain = $env:BSO_RELEASE_TOKEN + if (-not $plain) { + $secure = Read-Host "Release-Token" -AsSecureString + $plain = (New-Object System.Net.NetworkCredential('', $secure)).Password + } + if (-not $plain -or $plain.Length -lt 24) { Write-Host "Token zu kurz - nichts gespeichert"; exit 1 } + Write-Secret $tokenFile ([Text.Encoding]::UTF8.GetBytes($plain)) + Write-Host "Release-Token verschluesselt gespeichert: $tokenFile" + exit 0 +} + +# Sicherung des privaten Schluessels mit eigenem Passwort (AES-256, PBKDF2) - z.B. auf dem NAS / USB-Stick +if ($ExportSigningKey) { + $private = Read-SigningKey + if ($null -eq $private) { Write-Host "Kein Signier-Schluessel ($keyFile)"; exit 1 } + $pw = Get-BackupPassword "Passwort fuer die Sicherung" + if ($pw.Length -lt 12) { Write-Host "Mindestens 12 Zeichen"; exit 1 } + if ((Get-BackupPassword "Passwort wiederholen") -ne $pw) { Write-Host "Die Passwoerter sind nicht gleich - nichts geschrieben"; exit 1 } + $salt = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt) + $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $pw $salt; $aes.GenerateIV() + $enc = $aes.CreateEncryptor().TransformFinalBlock($private, 0, $private.Length) + $out = "BSO-SIGNING-KEY-BACKUP v1`n" + [Convert]::ToBase64String($salt) + "`n" + [Convert]::ToBase64String($aes.IV) + "`n" + [Convert]::ToBase64String($enc) + [IO.File]::WriteAllText($ExportSigningKey, $out) + # Gegenprobe: Datei lesen und mit dem Passwort wieder entschluesseln + $lines = [IO.File]::ReadAllText($ExportSigningKey).Split("`n") + $aes2 = [Security.Cryptography.Aes]::Create(); $aes2.Key = Get-Pbkdf2Key $pw ([Convert]::FromBase64String($lines[1])); $aes2.IV = [Convert]::FromBase64String($lines[2]) + $check = [Convert]::FromBase64String($lines[3]); $check = $aes2.CreateDecryptor().TransformFinalBlock($check, 0, $check.Length) + if ([Convert]::ToBase64String($check) -ne [Convert]::ToBase64String($private)) { Write-Host "Gegenprobe FEHLGESCHLAGEN - die Sicherung ist unbrauchbar"; exit 1 } + Write-Host "Sicherung geschrieben und geprueft: $ExportSigningKey - Datei und Passwort gut aufheben" + exit 0 +} + +if ($ImportSigningKey) { + if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Nicht gefunden: $ImportSigningKey"; exit 1 } + $lines = [IO.File]::ReadAllText($ImportSigningKey).Split("`n") + if ($lines.Count -lt 4 -or $lines[0].Trim() -ne "BSO-SIGNING-KEY-BACKUP v1") { Write-Host "Das ist keine BSO-Schluessel-Sicherung"; exit 1 } + $pw = Get-BackupPassword "Passwort der Sicherung" + try { + $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $pw ([Convert]::FromBase64String($lines[1])); $aes.IV = [Convert]::FromBase64String($lines[2]) + $enc = [Convert]::FromBase64String($lines[3].Trim()) + $private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length) + [Security.Cryptography.RSA]::Create().FromXmlString([Text.Encoding]::UTF8.GetString($private)) + } catch { Write-Host "Falsches Passwort oder beschaedigte Sicherung"; exit 1 } + if ($null -ne (Read-Secret $keyFile)) { Write-Host "Es gibt schon einen Signier-Schluessel ($keyFile) - nicht ersetzt"; exit 1 } + Write-Secret $keyFile $private + Write-Host "Signier-Schluessel uebernommen: $keyFile" + exit 0 +} + +# ---------------------------------------------------------------- Hochladen + +if (-not $Product -or -not $File) { Write-Host "Aufruf: release.ps1 -Product mod|launcher -File | -SaveToken | -ExportSigningKey | -ImportSigningKey "; exit 1 } +if ($env:BSO_NO_RELEASE -eq "1") { Write-Host "BSO release: BSO_NO_RELEASE=1 - nicht hochgeladen."; exit 0 } + +$token = $env:BSO_RELEASE_TOKEN +if (-not $token) { $token = [Environment]::GetEnvironmentVariable("BSO_RELEASE_TOKEN", "User") } # z.B. Visual Studio vor dem Setzen gestartet +if (-not $token) { + try { $stored = Read-Secret $tokenFile; if ($null -ne $stored) { $token = [Text.Encoding]::UTF8.GetString($stored) } } + catch { Write-Host "BSO release: gespeicherter Token nicht lesbar (anderer Benutzer/PC?) - nicht hochgeladen."; exit 0 } +} +if (-not $token) { Write-Host "BSO release: kein Release-Token ($tokenFile oder BSO_RELEASE_TOKEN) - einrichten mit 'release.ps1 -SaveToken'. Nicht hochgeladen."; exit 0 } +$master = $env:BSO_MASTER_URL +if (-not $master) { $master = [Environment]::GetEnvironmentVariable("BSO_MASTER_URL", "User") } +if (-not $master) { $master = "https://bso.mbm.wtf" } +$master = $master.TrimEnd('/') + +if (-not (Test-Path $File)) { Write-Host "BSO release: $File nicht gefunden"; exit 1 } +if (-not $Version) { $Version = ((Get-Item $File).VersionInfo.ProductVersion -split '\+')[0] } +$Version = $Version.Split('+')[0] +if (-not $Version) { Write-Host "BSO release: Version unbekannt - -Version angeben"; exit 1 } + +# Changelog: Abschnitt "## " aus CHANGELOG.md neben diesem Skript if (-not $Changelog) { - $md = Join-Path $PSScriptRoot "CHANGELOG.md" + $md = Join-Path $here "CHANGELOG.md" if (Test-Path $md) { $lines = [IO.File]::ReadAllLines($md) $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l -match "^##\s+v?$([regex]::Escape($Version))(\s|$)" }) @@ -45,41 +147,37 @@ if (-not $Changelog) { $Changelog = ($section -join "`n").Trim() } } - if ($Changelog) { Write-Host "Changelog aus CHANGELOG.md ($Version)" } else { Write-Host "Kein Changelog fuer $Version in CHANGELOG.md" } } -$path = (Resolve-Path $File).Path -$fileName = if ($Product -eq "launcher") { "BSO-Launcher-Setup.exe" } else { Split-Path $path -Leaf } -$data = [IO.File]::ReadAllBytes($path) -$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join "" -$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null, - [System.Security.Cryptography.DataProtectionScope]::CurrentUser) -$rsa = [System.Security.Cryptography.RSA]::Create() -$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) -$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha") -$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)) -$manifest = [ordered]@{ product = $Product; version = $Version; sha256 = $sha; size = $data.Length; file = $fileName; signature = $signature } | - ConvertTo-Json -Compress +# Signieren: passt nur zu genau dieser Datei, diesem Produkt und dieser Version +$data = [IO.File]::ReadAllBytes((Resolve-Path $File)) +$sha = ([Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join "" +try { $private = Read-SigningKey } +catch { Write-Host "BSO release: Signier-Schluessel nicht lesbar (anderer Benutzer/PC?) - nicht hochgeladen"; exit 0 } +if ($null -eq $private) { Write-Host "BSO release: kein Signier-Schluessel ($keyFile) - mit 'release.ps1 -ImportSigningKey ' uebernehmen. Nicht hochgeladen."; exit 0 } +try { + $rsa = [Security.Cryptography.RSA]::Create() + $rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) + $message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha") + $signature = [Convert]::ToBase64String($rsa.SignData($message, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)) + $private = $null +} catch { Write-Host "BSO release: Signieren fehlgeschlagen ($($_.Exception.Message)) - nicht hochgeladen"; exit 0 } $http = New-Object System.Net.Http.HttpClient $http.Timeout = [TimeSpan]::FromMinutes(10) -$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("token", $token) - -# Release anlegen (Tag v) -$json = @{ tag_name = "v$Version"; name = "$Product $Version"; body = $Changelog; draft = $false; prerelease = $false } | ConvertTo-Json -Compress -$res = $http.PostAsync("$api/releases", (New-Object System.Net.Http.StringContent($json, [Text.Encoding]::UTF8, "application/json"))).Result -$body = $res.Content.ReadAsStringAsync().Result -if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$repo v$Version gibt es schon - Version erhoehen"; return } -if (-not $res.IsSuccessStatusCode) { throw "Release anlegen fehlgeschlagen ($([int]$res.StatusCode)): $body" } -$id = ($body | ConvertFrom-Json).id - -function Upload([string]$name, [byte[]]$bytes) { - $form = New-Object System.Net.Http.MultipartFormDataContent - $form.Add((New-Object System.Net.Http.ByteArrayContent(, $bytes)), "`"attachment`"", "`"$name`"") - $r = $http.PostAsync("$api/releases/$id/assets?name=$([Uri]::EscapeDataString($name))", $form).Result - if (-not $r.IsSuccessStatusCode) { throw "Hochladen von $name fehlgeschlagen ($([int]$r.StatusCode)): $($r.Content.ReadAsStringAsync().Result)" } +$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token) +$form = New-Object System.Net.Http.MultipartFormDataContent +foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) { + $form.Add((New-Object System.Net.Http.StringContent($pair[1], [Text.Encoding]::UTF8)), "`"$($pair[0])`"") } -Upload $fileName $data -Upload "bso-release.json" ([Text.Encoding]::UTF8.GetBytes($manifest)) -Write-Host "$Product $Version veroeffentlicht: $gitea/MrMaho/$repo/releases/tag/v$Version (sha256 $sha)" +$form.Add((New-Object System.Net.Http.ByteArrayContent(, $data)), "`"file`"", "`"$(Split-Path $File -Leaf)`"") +try { + $res = $http.PostAsync("$master/api/releases/upload", $form).GetAwaiter().GetResult() + $body = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult() +} catch { Write-Host "BSO release: $master nicht erreichbar ($($_.Exception.Message)) - nicht hochgeladen"; exit 0 } +switch ([int]$res.StatusCode) { + 200 { Write-Host "BSO release: $Product $Version hochgeladen nach $master" } + 409 { Write-Host "BSO release: $Product $Version gibt es auf $master schon - zum Veroeffentlichen die Version erhoehen" } + default { Write-Host "BSO release: Hochladen fehlgeschlagen (HTTP $([int]$res.StatusCode)): $body" } +} +exit 0