# 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( [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 # ---------------------------------------------------------------- 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) 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 $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|$)" }) if ($start -ge 0) { $section = @() for ($i = $start + 1; $i -lt $lines.Length -and $lines[$i] -notmatch '^##\s'; $i++) { $section += $lines[$i] } $Changelog = ($section -join "`n").Trim() } } } # 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("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])`"") } $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