# Neue Version automatisch auf den Keymaster hochladen (msco.mbm.wtf -> /download, Launcher-Updates). # Läuft nach dem Release-Build von selbst (MSCO.csproj bzw. MSCO.Launcher.csproj) - oder von Hand: # Windows: powershell -ExecutionPolicy Bypass -File release.ps1 -Product mod -File bin\Release\MSCO.dll # Mac: pwsh release.ps1 -Product mod -File bin/Release/MSCO.dll (PowerShell 7: brew install powershell) # # Braucht den Release-Token des Keymasters (RELEASE_TOKEN in dessen .env) und den privaten Signier-Schlüssel. # Beide liegen verschlüsselt beim Entwickler: # Windows: %USERPROFILE%\.msco\*.dpapi (Windows DPAPI - nur dein Windows-Konto kann sie lesen) # macOS: im Schlüsselbund (Keychain, Einträge "MSCO release-token" / "MSCO release-signing-key") # Einrichten: "-SaveToken" (Token), Schlüssel einmal mit "-NewSigningKey" erzeugen bzw. auf einem weiteren Rechner mit # "-ImportSigningKey " aus der Sicherung ("-ExportSigningKey ", mit eigenem Passwort) übernehmen. # MSCO_RELEASE_TOKEN (Umgebungsvariable) ersetzt den gespeicherten Token, MSCO_NO_RELEASE=1 schaltet Hochladen ab. # # Signatur: jede Datei wird signiert (RSA-3072, SHA-256, "MSCO-RELEASE|produkt|version|sha256") - der Launcher und # das Setup installieren nur, was zu ihrem eingebauten öffentlichen Schlüssel passt. Selbst wer den Keymaster oder # den Token übernimmt, kann so keine fremde Datei als MSCO-Update verteilen. # Gibt es die Version auf dem Keymaster schon, passiert nichts - also einfach die Version erhöhen und bauen. param( [ValidateSet("mod", "launcher", "installer")][string]$Product, [string]$File, [switch]$SaveToken, [switch]$NewSigningKey, [string]$ExportSigningKey = "", [string]$ImportSigningKey = "", [string]$Version = "", [string]$Changelog = "" ) $ErrorActionPreference = "Stop" $here = Split-Path -Parent $MyInvocation.MyCommand.Path # ---------------------------------------------------------------- Ablage der Geheimnisse (Windows / macOS / Linux) $onWindows = ($PSVersionTable.PSEdition -eq "Desktop") -or ($IsWindows -eq $true) $onMac = ($IsMacOS -eq $true) $homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } # Nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App) - ein normales Terminal sähe die Dateien nicht $secretDir = Join-Path $homeDir ".msco" if ($onWindows) { Add-Type -AssemblyName System.Security } function Get-SecretFile([string]$name) { $new = Join-Path $secretDir "$name.dpapi" if (Test-Path -LiteralPath $new) { return $new } if ($env:APPDATA) { $old = Join-Path $env:APPDATA "MSCO\$name.dpapi" # frühere Ablage if (Test-Path -LiteralPath $old) { return $old } } return $new } function Get-SecretLocation([string]$name) { if ($onWindows) { return Get-SecretFile $name } if ($onMac) { return "macOS Keychain (MSCO $name)" } return (Join-Path $secretDir $name) } # Gespeichertes Geheimnis lesen (Bytes) - $null, wenn es keins gibt function Read-Secret([string]$name, [string]$entropyText) { if ($onWindows) { $file = Get-SecretFile $name if (-not (Test-Path -LiteralPath $file)) { return $null } $entropy = [Text.Encoding]::UTF8.GetBytes($entropyText) return [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file), $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser) } if ($onMac) { $value = & security find-generic-password -a $env:USER -s "MSCO $name" -w 2>$null if ($LASTEXITCODE -ne 0 -or -not $value) { return $null } return [Convert]::FromBase64String(($value | Out-String).Trim()) } $file = Join-Path $secretDir $name if (-not (Test-Path -LiteralPath $file)) { return $null } return [Convert]::FromBase64String([IO.File]::ReadAllText($file).Trim()) } function Write-Secret([string]$name, [string]$entropyText, [byte[]]$data) { if ($onWindows) { New-Item -ItemType Directory -Force -Path $secretDir | Out-Null $entropy = [Text.Encoding]::UTF8.GetBytes($entropyText) $file = Join-Path $secretDir "$name.dpapi" [IO.File]::WriteAllBytes($file, [Security.Cryptography.ProtectedData]::Protect($data, $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser)) return } if ($onMac) { # -U: vorhandenen Eintrag ersetzen; der Wert liegt danach verschlüsselt im Anmelde-Schlüsselbund & security add-generic-password -U -a $env:USER -s "MSCO $name" -l "MSCO $name" -w ([Convert]::ToBase64String($data)) if ($LASTEXITCODE -ne 0) { throw "Could not store MSCO $name in the keychain" } return } # Linux: Datei nur für den eigenen Benutzer lesbar (kein Schlüsselbund vorausgesetzt) New-Item -ItemType Directory -Force -Path $secretDir | Out-Null $file = Join-Path $secretDir $name [IO.File]::WriteAllText($file, [Convert]::ToBase64String($data)) & chmod 600 $file } # RSA: Windows PowerShell 5.1 braucht RSACng (SHA-256), PowerShell 7 (auch macOS) nimmt RSA.Create() function New-Rsa([int]$bits = 0) { if ($PSVersionTable.PSEdition -eq "Desktop") { if ($bits -gt 0) { return New-Object System.Security.Cryptography.RSACng $bits } return New-Object System.Security.Cryptography.RSACng } if ($bits -gt 0) { return [System.Security.Cryptography.RSA]::Create($bits) } return [System.Security.Cryptography.RSA]::Create() } $tokenName = "release-token"; $tokenEntropy = "MSCO release token v1" $keyName = "release-signing-key"; $keyEntropy = "MSCO release signing key v1" # Passwort für Backups: verdeckt fragen - oder aus MSCO_BACKUP_PASSWORD (für automatische Backups) function Get-BackupPassword([string]$prompt) { if ($env:MSCO_BACKUP_PASSWORD) { return $env:MSCO_BACKUP_PASSWORD } $secure = Read-Host $prompt -AsSecureString return (New-Object System.Net.NetworkCredential('', $secure)).Password } function Get-Pbkdf2Key([string]$password, [byte[]]$salt) { $kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($password, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256) return $kdf.GetBytes(32) } # ---------------------------------------------------------------- Einrichten / Sichern # Token einmal verschlüsselt ablegen: fragt verdeckt danach (oder nimmt MSCO_RELEASE_TOKEN) if ($SaveToken) { $plain = $env:MSCO_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 too short - nothing saved"; exit 1 } Write-Secret $tokenName $tokenEntropy ([Text.Encoding]::UTF8.GetBytes($plain)) Write-Host "Release token saved encrypted: $(Get-SecretLocation $tokenName)" exit 0 } # Neues Schlüsselpaar (nur einmal - der öffentliche Schlüssel ist in Launcher und Setup eingebaut) if ($NewSigningKey) { if ($null -ne (Read-Secret $keyName $keyEntropy)) { Write-Host "There already is a signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1 } $rsa = New-Rsa 3072 Write-Secret $keyName $keyEntropy ([Text.Encoding]::UTF8.GetBytes($rsa.ToXmlString($true))) Write-Host "Signing key saved encrypted: $(Get-SecretLocation $keyName)" Write-Host "PUBLIC KEY (put into Launcher/Setup):" Write-Host $rsa.ToXmlString($false) exit 0 } # Sicherung des privaten Schlüssels mit eigenem Passwort (AES-256, PBKDF2) - z.B. auf dem NAS / USB-Stick if ($ExportSigningKey) { $private = Read-Secret $keyName $keyEntropy if ($null -eq $private) { Write-Host "No signing key ($(Get-SecretLocation $keyName))"; exit 1 } $plainPw = Get-BackupPassword "Password for the backup" if ($plainPw.Length -lt 12) { Write-Host "Use at least 12 characters"; exit 1 } # Zweimal fragen: ein Tippfehler machte das Backup sonst unbrauchbar $plainPw2 = Get-BackupPassword "Repeat the password" if ($plainPw2 -ne $plainPw) { Write-Host "The passwords do not match - nothing written"; exit 1 } $salt = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt) $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw $salt; $aes.GenerateIV() $enc = $aes.CreateEncryptor().TransformFinalBlock($private, 0, $private.Length) $out = "MSCO-SIGNING-KEY-BACKUP v1`n" + [Convert]::ToBase64String($salt) + "`n" + [Convert]::ToBase64String($aes.IV) + "`n" + [Convert]::ToBase64String($enc) try { $dir = Split-Path -Parent $ExportSigningKey if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } [IO.File]::WriteAllText($ExportSigningKey, $out) } catch { Write-Host "Could not write $ExportSigningKey : $($_.Exception.Message)" Write-Host "Network drive? Drive letters like Z: are not visible in a PowerShell window that runs as administrator." Write-Host "Use a normal (not elevated) PowerShell window, or the network path, e.g. \\server\share\folder\msco-signing-key-backup.txt" exit 1 } # Gegenprobe: Datei lesen und mit dem Passwort wieder entschlüsseln $lines = [IO.File]::ReadAllText($ExportSigningKey).Split("`n") $aes2 = [Security.Cryptography.Aes]::Create(); $aes2.Key = Get-Pbkdf2Key $plainPw ([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 "Verification FAILED - the backup is not usable"; exit 1 } Write-Host "Backup written and verified: $ExportSigningKey (AES-256, PBKDF2-SHA256 300000) - keep it and the password safe" exit 0 } # Sicherung übernehmen (zweiter Rechner, z.B. Mac, oder neuer PC): Datei + Passwort -> verschlüsselte Ablage dieses Rechners if ($ImportSigningKey) { if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Not found: $ImportSigningKey (network drive? see -ExportSigningKey)"; exit 1 } $lines = [IO.File]::ReadAllText($ImportSigningKey).Split("`n") if ($lines.Count -lt 4 -or $lines[0].Trim() -ne "MSCO-SIGNING-KEY-BACKUP v1") { Write-Host "This is not an MSCO signing key backup"; exit 1 } $plainPw = Get-BackupPassword "Password of the backup" try { $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw ([Convert]::FromBase64String($lines[1])); $aes.IV = [Convert]::FromBase64String($lines[2]) $enc = [Convert]::FromBase64String($lines[3].Trim()) $private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length) $rsa = New-Rsa $rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) } catch { Write-Host "Wrong password or damaged backup"; exit 1 } $current = Read-Secret $keyName $keyEntropy if ($null -ne $current) { if ([Convert]::ToBase64String($current) -eq [Convert]::ToBase64String($private)) { Write-Host "This key is already installed"; exit 0 } Write-Host "There already is a different signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1 } Write-Secret $keyName $keyEntropy $private Write-Host "Signing key restored: $(Get-SecretLocation $keyName)" exit 0 } # ---------------------------------------------------------------- Hochladen if (-not $Product -or -not $File) { Write-Host "Usage: release.ps1 -Product mod|launcher|installer -File | -SaveToken | -NewSigningKey | -ExportSigningKey | -ImportSigningKey "; exit 1 } if ($env:MSCO_NO_RELEASE -eq "1") { Write-Host "MSCO release: MSCO_NO_RELEASE=1 - not uploading."; exit 0 } $token = $env:MSCO_RELEASE_TOKEN if (-not $token) { try { $stored = Read-Secret $tokenName $tokenEntropy if ($null -ne $stored) { $token = [Text.Encoding]::UTF8.GetString($stored) } } catch { Write-Host "MSCO release: the stored release token cannot be decrypted (other user/PC?) - not uploading."; exit 0 } } if (-not $token) { Write-Host "MSCO release: no release token ($(Get-SecretLocation $tokenName) or MSCO_RELEASE_TOKEN) - set it with 'release.ps1 -SaveToken'. Not uploading." exit 0 } $keymaster = if ($env:MSCO_KEYMASTER) { $env:MSCO_KEYMASTER.TrimEnd("/") } else { "https://msco.mbm.wtf" } if (-not (Test-Path $File)) { Write-Host "MSCO release: $File not found"; exit 1 } # Version: aus MSCO.cs (Mod) bzw. aus der Datei (Produktversion) if (-not $Version) { $source = Join-Path $here "MSCO.cs" if ($Product -eq "mod" -and (Test-Path $source)) { $m = Select-String -Path $source -Pattern 'ModVersion\s*=\s*"([^"]+)"' | Select-Object -First 1 if ($m) { $Version = $m.Matches[0].Groups[1].Value } } if (-not $Version) { $Version = ((Get-Item $File).VersionInfo.ProductVersion -split '\+')[0] } } if (-not $Version) { Write-Host "MSCO release: version unknown - pass -Version"; exit 1 } # Changelog: Abschnitt "## " aus CHANGELOG.md (Text ohne Markdown, gekürzt) if (-not $Changelog) { $log = Join-Path $here "CHANGELOG.md" if (Test-Path $log) { $lines = Get-Content $log -Encoding UTF8 $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l -match "^##\s+$([regex]::Escape($Version))\b" }) if ($start -ge 0) { $text = @() for ($i = $start + 1; $i -lt $lines.Count -and $lines[$i] -notmatch '^##\s'; $i++) { $l = $lines[$i].Trim() -replace '\*\*', '' -replace '`', '' if ($l) { $text += $l } } $Changelog = ($text -join " ") if ($Changelog.Length -gt 3500) { $Changelog = $Changelog.Substring(0, 3500) + " …" } } } } # Signieren: "MSCO-RELEASE|||" - so passt die Signatur nur zu genau dieser Datei, diesem # Produkt und dieser Version (keine alte Version als neue, keine Mod-DLL als Launcher) $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $File)) $sha = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace("-", "").ToLowerInvariant() $signature = "" try { $privateBytes = Read-Secret $keyName $keyEntropy } catch { Write-Host "MSCO release: the signing key cannot be decrypted (other user/PC?) - not uploading"; exit 0 } if ($null -eq $privateBytes) { Write-Host "MSCO release: no signing key ($(Get-SecretLocation $keyName)) - import it with 'release.ps1 -ImportSigningKey '. Not uploading." exit 0 } try { $rsa = New-Rsa $rsa.FromXmlString([Text.Encoding]::UTF8.GetString($privateBytes)) $message = [Text.Encoding]::UTF8.GetBytes("MSCO-RELEASE|$Product|$Version|$sha") $signature = [Convert]::ToBase64String($rsa.SignData($message, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)) $privateBytes = $null } catch { Write-Host "MSCO release: signing failed ($($_.Exception.Message)) - not uploading"; exit 0 } Add-Type -AssemblyName System.Net.Http $client = New-Object System.Net.Http.HttpClient $client.Timeout = [TimeSpan]::FromMinutes(10) $client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token) # Multipart von Hand: Windows PowerShell (.NET Framework) schreibt name=... ohne Anführungszeichen und die Boundary in # Anführungszeichen - das lehnt der Parser des Keymasters (Node/undici) ab. $boundary = "----MSCO" + [Guid]::NewGuid().ToString("N") $utf8 = New-Object System.Text.UTF8Encoding($false) $body = New-Object System.IO.MemoryStream function Write-Text([string]$text) { $b = $utf8.GetBytes($text); $body.Write($b, 0, $b.Length) } foreach ($field in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) { Write-Text "--$boundary`r`nContent-Disposition: form-data; name=`"$($field[0])`"`r`nContent-Type: text/plain; charset=utf-8`r`n`r`n$($field[1])`r`n" } Write-Text "--$boundary`r`nContent-Disposition: form-data; name=`"file`"; filename=`"$([System.IO.Path]::GetFileName($File))`"`r`nContent-Type: application/octet-stream`r`n`r`n" $body.Write($bytes, 0, $bytes.Length) Write-Text "`r`n--$boundary--`r`n" $form = New-Object System.Net.Http.ByteArrayContent(,$body.ToArray()) $form.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=$boundary") | Out-Null try { $res = $client.PostAsync("$keymaster/api/releases/upload", $form).GetAwaiter().GetResult() $body = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { Write-Host "MSCO release: keymaster not reachable ($($_.Exception.Message)) - not uploaded" exit 0 } switch ([int]$res.StatusCode) { 200 { Write-Host "MSCO release: $Product $Version uploaded to $keymaster" } 409 { Write-Host "MSCO release: $Product $Version is already on the keymaster - raise the version to publish a new one" } default { Write-Host "MSCO release: upload failed (HTTP $([int]$res.StatusCode)): $body" } } exit 0