235 lines
15 KiB
PowerShell
235 lines
15 KiB
PowerShell
# 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:
|
|
# powershell -ExecutionPolicy Bypass -File release.ps1 -Product mod -File bin\Release\MSCO.dll
|
|
#
|
|
# Braucht den Release-Token des Keymasters (RELEASE_TOKEN in dessen .env), lokal verschlüsselt in
|
|
# %USERPROFILE%\.msco\release-token.dpapi (Windows DPAPI, nur dein Windows-Konto kann ihn lesen - anlegen mit
|
|
# "release.ps1 -SaveToken"), sonst als Umgebungsvariable MSCO_RELEASE_TOKEN. Ohne Token wird nichts hochgeladen.
|
|
# MSCO_NO_RELEASE=1 schaltet das Hochladen ab (Test-Builds).
|
|
#
|
|
# Signatur: jede Datei wird mit dem privaten Release-Schlüssel signiert (RSA-3072, SHA-256) - 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. Der Schlüssel liegt DPAPI-verschlüsselt in
|
|
# %USERPROFILE%\.msco\release-signing-key.dpapi (einmal anlegen: "release.ps1 -NewSigningKey"; SICHERN: "-ExportSigningKey <datei>",
|
|
# zurückspielen auf einem neuen PC: "-ImportSigningKey <datei>").
|
|
# 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
|
|
|
|
Add-Type -AssemblyName System.Security
|
|
$entropy = [Text.Encoding]::UTF8.GetBytes("MSCO release token v1")
|
|
$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
# Geheimnisse in %USERPROFILE%\.msco - nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App), dann sähe
|
|
# ein normales Terminal die Dateien nicht. Die alte Ablage in AppData wird noch gelesen.
|
|
$secretDir = Join-Path $env:USERPROFILE ".msco"
|
|
function Find-Secret([string]$name) {
|
|
$new = Join-Path $secretDir $name
|
|
if (Test-Path $new) { return $new }
|
|
$old = Join-Path $env:APPDATA "MSCO\$name"
|
|
if (Test-Path $old) { return $old }
|
|
return $new
|
|
}
|
|
$tokenDpapi = Find-Secret "release-token.dpapi"
|
|
|
|
# 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 = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
|
}
|
|
if (-not $plain -or $plain.Length -lt 24) { Write-Host "Token too short - nothing saved"; exit 1 }
|
|
New-Item -ItemType Directory -Force (Split-Path $tokenDpapi) | Out-Null
|
|
$enc = [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($plain), $entropy, $scope)
|
|
[IO.File]::WriteAllBytes($tokenDpapi, $enc)
|
|
Write-Host "Release token saved encrypted in $tokenDpapi"
|
|
exit 0
|
|
}
|
|
$keyEntropy = [Text.Encoding]::UTF8.GetBytes("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 [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
|
}
|
|
$keyFile = Find-Secret "release-signing-key.dpapi"
|
|
|
|
# Neues Schlüsselpaar (nur einmal - der öffentliche Schlüssel ist in Launcher und Setup eingebaut)
|
|
if ($NewSigningKey) {
|
|
if (Test-Path $keyFile) { Write-Host "There already is a signing key ($keyFile) - not replaced"; exit 1 }
|
|
$rsa = New-Object System.Security.Cryptography.RSACng 3072
|
|
$private = $rsa.ToXmlString($true)
|
|
New-Item -ItemType Directory -Force (Split-Path $keyFile) | Out-Null
|
|
[IO.File]::WriteAllBytes($keyFile, [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($private), $keyEntropy, $scope))
|
|
Write-Host "Signing key saved encrypted in $keyFile"
|
|
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. für einen USB-Stick/Passwort-Manager
|
|
if ($ExportSigningKey) {
|
|
if (-not (Test-Path $keyFile)) { Write-Host "No signing key"; exit 1 }
|
|
$private = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $keyEntropy, $scope)
|
|
$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; (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($salt)
|
|
$kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($plainPw, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256)
|
|
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = $kdf.GetBytes(32); $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")
|
|
$kdf2 = New-Object Security.Cryptography.Rfc2898DeriveBytes($plainPw, [Convert]::FromBase64String($lines[1]), 300000, [Security.Cryptography.HashAlgorithmName]::SHA256)
|
|
$aes2 = [Security.Cryptography.Aes]::Create(); $aes2.Key = $kdf2.GetBytes(32); $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 zurückspielen (neuer PC / Windows neu): Datei + Passwort -> wieder DPAPI-verschlüsselt in %USERPROFILE%\.msco
|
|
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 {
|
|
$kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($plainPw, [Convert]::FromBase64String($lines[1]), 300000, [Security.Cryptography.HashAlgorithmName]::SHA256)
|
|
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = $kdf.GetBytes(32); $aes.IV = [Convert]::FromBase64String($lines[2])
|
|
$enc = [Convert]::FromBase64String($lines[3])
|
|
$private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length)
|
|
$rsa = New-Object System.Security.Cryptography.RSACng
|
|
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
|
|
} catch { Write-Host "Wrong password or damaged backup"; exit 1 }
|
|
$target = Join-Path $secretDir "release-signing-key.dpapi"
|
|
if (Test-Path -LiteralPath $target) {
|
|
$current = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($target), $keyEntropy, $scope)
|
|
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 in $target - not replaced"; exit 1
|
|
}
|
|
New-Item -ItemType Directory -Force -Path $secretDir | Out-Null
|
|
[IO.File]::WriteAllBytes($target, [Security.Cryptography.ProtectedData]::Protect($private, $keyEntropy, $scope))
|
|
Write-Host "Signing key restored to $target"
|
|
exit 0
|
|
}
|
|
|
|
if (-not $Product -or -not $File) { Write-Host "Usage: release.ps1 -Product mod|launcher|installer -File <file> | -SaveToken | -NewSigningKey | -ExportSigningKey <file> | -ImportSigningKey <file>"; 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 -and (Test-Path $tokenDpapi)) {
|
|
try { $token = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($tokenDpapi), $entropy, $scope)) }
|
|
catch { Write-Host "MSCO release: $tokenDpapi cannot be decrypted (other Windows user/PC?) - not uploading."; exit 0 }
|
|
}
|
|
$tokenFile = Join-Path $env:APPDATA "MSCO\release-token.txt"
|
|
if (-not $token -and (Test-Path $tokenFile)) { $token = (Get-Content $tokenFile -Raw).Trim() }
|
|
if (-not $token) {
|
|
Write-Host "MSCO release: no release token ($tokenDpapi or MSCO_RELEASE_TOKEN) - 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] }
|
|
}
|
|
|
|
# Changelog: Abschnitt "## <Version>" 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|<produkt>|<version>|<sha256>" - 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 = ""
|
|
if (Test-Path $keyFile) {
|
|
try {
|
|
$private = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $keyEntropy, $scope))
|
|
$rsa = New-Object System.Security.Cryptography.RSACng
|
|
$rsa.FromXmlString($private)
|
|
$message = [Text.Encoding]::UTF8.GetBytes("MSCO-RELEASE|$Product|$Version|$sha")
|
|
$signature = [Convert]::ToBase64String($rsa.SignData($message, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1))
|
|
$private = $null
|
|
} catch { Write-Host "MSCO release: signing failed ($($_.Exception.Message)) - not uploading"; exit 0 }
|
|
} else {
|
|
Write-Host "MSCO release: no signing key ($keyFile) - the launcher will refuse this release. 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
|