Release from Windows or Mac

- MSCO.csproj is SDK-style now (net35 via Microsoft.NETFramework.ReferenceAssemblies):
  builds with "dotnet build -c Release" on Windows and macOS; game DLL folder via
  MSC_MANAGED / -p:MscManaged (default: the game folder). Output verified identical
  in structure (references, types, methods, resources) to the old project.
- release.ps1 works with Windows PowerShell 5.1 and PowerShell 7: secrets in DPAPI files
  on Windows, in the macOS keychain on a Mac (file with chmod 600 on Linux); cross-platform
  RSA/password input; release target calls pwsh on non-Windows
- RELEASING.md: how to release and how to set up the Mac

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:22:04 +02:00
co-authored by Claude Opus 5.5
parent 578bd30b3d
commit fe08fa5213
3 changed files with 267 additions and 172 deletions
+54 -92
View File
@@ -1,61 +1,74 @@
<?xml version="1.0" encoding="utf-8"?> <Project Sdk="Microsoft.NET.Sdk">
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> <!--
MSCO (Unity 5 / Mono, .NET 3.5). Baut mit dem normalen .NET SDK - unter Windows und auch auf dem Mac (nur zum
Bauen/Veröffentlichen, das Spiel gibt es nur für Windows): dotnet build -c Release
Die Spiel-DLLs (UnityEngine, Assembly-CSharp, PlayMaker, cInput) kommen aus dem Spielordner. Woanders (z.B. Mac):
den Ordner "mysummercar_Data/Managed" dorthin kopieren und MSC_MANAGED (Umgebungsvariable) oder
-p:MscManaged=<ordner> setzen. Release-Builds laden neue Versionen automatisch hoch (release.ps1).
-->
<PropertyGroup> <PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration> <TargetFramework>net35</TargetFramework>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{CE3FFBAB-946E-9569-CCAA-9CFA2D5C11F3}</ProjectGuid>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MSCO</RootNamespace>
<AssemblyName>MSCO</AssemblyName> <AssemblyName>MSCO</AssemblyName>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion> <RootNamespace>MSCO</RootNamespace>
<FileAlignment>512</FileAlignment>
<TargetFrameworkProfile>Unity Full v3.5</TargetFrameworkProfile>
<LangVersion>13</LangVersion> <LangVersion>13</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- Versionsangaben stehen in Properties/AssemblyInfo.cs (aus MSCO.ModVersion) -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<!-- Ausgabe wie bisher: bin\Release\MSCO.dll (ohne Unterordner net35) -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<DebugType Condition=" '$(Configuration)' == 'Release' ">none</DebugType>
<Optimize Condition=" '$(Configuration)' == 'Release' ">true</Optimize>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;NU1702</NoWarn>
<!-- Spielordner und dessen Managed-Ordner (überschreibbar) -->
<GamePath Condition=" '$(GamePath)' == '' ">F:\SteamLibrary\steamapps\common\My Summer Car</GamePath>
<MscManaged Condition=" '$(MscManaged)' == '' and '$(MSC_MANAGED)' != '' ">$(MSC_MANAGED)</MscManaged>
<MscManaged Condition=" '$(MscManaged)' == '' ">$(GamePath)\mysummercar_Data\Managed</MscManaged>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols> <ItemGroup>
<DebugType>full</DebugType> <!-- .NET-3.5-Referenzen ohne installiertes Targeting Pack (auch auf dem Mac) -->
<Optimize>false</Optimize> <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<OutputPath>bin\Debug\</OutputPath> </ItemGroup>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport> <ItemGroup>
<WarningLevel>4</WarningLevel> <!-- Beispiel-Mods sind eigene Projekte -->
</PropertyGroup> <Compile Remove="examples/**" />
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <None Remove="examples/**" />
<DebugType>none</DebugType> <EmbeddedResource Remove="examples/**" />
<Optimize>true</Optimize> </ItemGroup>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" /> <Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" /> <Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" /> <Reference Include="System.Data" />
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
<Reference Include="UnityEngine"> <Reference Include="UnityEngine">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\UnityEngine.dll</HintPath> <HintPath>$(MscManaged)\UnityEngine.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="Assembly-CSharp-firstpass"> <Reference Include="Assembly-CSharp-firstpass">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath> <HintPath>$(MscManaged)\Assembly-CSharp-firstpass.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="Assembly-CSharp"> <Reference Include="Assembly-CSharp">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath> <HintPath>$(MscManaged)\Assembly-CSharp.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="cInput"> <Reference Include="cInput">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\cInput.dll</HintPath> <HintPath>$(MscManaged)\cInput.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="PlayMaker"> <Reference Include="PlayMaker">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\PlayMaker.dll</HintPath> <HintPath>$(MscManaged)\PlayMaker.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="0Harmony"> <Reference Include="0Harmony">
@@ -63,75 +76,24 @@
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="MoneySync.cs" />
<Compile Include="WheelSpin.cs" />
<Compile Include="DamageSync.cs" />
<Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.cs" />
<Compile Include="HingeSync.cs" />
<Compile Include="ControlSync.cs" />
<Compile Include="Toasts.cs" />
<Compile Include="PlayerNeeds.cs" />
<Compile Include="Favorites.cs" />
<Compile Include="Bootstrap.cs" />
<Compile Include="Commands.cs" />
<Compile Include="MscoLog.cs" />
<Compile Include="ItemSpawner.cs" />
<Compile Include="Cheats.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
<Compile Include="SceneCache.cs" />
<Compile Include="UiTheme.cs" />
<Compile Include="VehicleStateSync.cs" />
<Compile Include="Branding.cs" />
<Compile Include="DiscordPresence.cs" />
<Compile Include="DoorSync.cs" />
<Compile Include="FsmSync.cs" />
<Compile Include="JoinLinks.cs" />
<Compile Include="LobbyUI.cs" />
<Compile Include="MenuHelper.cs" />
<Compile Include="MenuInputBlocker.cs" />
<Compile Include="MiniJson.cs" />
<Compile Include="PerfStats.cs" />
<Compile Include="FsmDump.cs" />
<Compile Include="VehicleAdmin.cs" />
<Compile Include="WorldAdmin.cs" />
<Compile Include="OilCheck.cs" />
<Compile Include="PacketReader.cs" />
<Compile Include="ModApi.cs" />
<Compile Include="ModLoader.cs" />
<Compile Include="MenuBranding.cs" />
<Compile Include="MSCO.cs" />
<Compile Include="MultiplayerManager.cs" />
<Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" />
<Compile Include="VehicleFx.cs" />
<Compile Include="WinHttp.cs" />
<Compile Include="WorldSync.cs" />
<EmbeddedResource Include="menu-logo.png"> <EmbeddedResource Include="menu-logo.png">
<LogicalName>MSCO.menu-logo.png</LogicalName> <LogicalName>MSCO.menu-logo.png</LogicalName>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="logo.png"> <EmbeddedResource Include="logo.png">
<LogicalName>MSCO.logo.png</LogicalName> <LogicalName>MSCO.logo.png</LogicalName>
</EmbeddedResource> </EmbeddedResource>
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="RemotePlayer.cs" />
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- Zum Testen: MSCO.dll direkt in den Spielordner (My Summer Car\MSCO) - läuft das Spiel, ist sie gesperrt --> <!-- Zum Testen: MSCO.dll direkt in den Spielordner (My Summer Car\MSCO) - läuft das Spiel, ist sie gesperrt -->
<Target Name="CopyToGame" AfterTargets="Build"> <Target Name="CopyToGame" AfterTargets="Build" Condition="Exists('$(GamePath)\MSCO')">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="F:\SteamLibrary\steamapps\common\My Summer Car\MSCO" ContinueOnError="true" Condition="Exists('F:\SteamLibrary\steamapps\common\My Summer Car')" /> <Copy SourceFiles="$(TargetPath)" DestinationFolder="$(GamePath)\MSCO" ContinueOnError="true" />
</Target> </Target>
<!-- Release-Build: neue Version automatisch auf den Keymaster laden (nur mit Release-Token, siehe release.ps1) -->
<!-- Release-Build: neue Version automatisch auf den Keymaster laden (Token + Signier-Schlüssel, siehe release.ps1) -->
<Target Name="MscoRelease" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'"> <Target Name="MscoRelease" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\release.ps1&quot; -Product mod -File &quot;$(TargetPath)&quot;" ContinueOnError="true" /> <Exec Condition="'$(OS)' == 'Windows_NT'" Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\release.ps1&quot; -Product mod -File &quot;$(TargetPath)&quot;" ContinueOnError="true" />
<Exec Condition="'$(OS)' != 'Windows_NT'" Command="pwsh -NoProfile -File &quot;$(MSBuildProjectDirectory)/release.ps1&quot; -Product mod -File &quot;$(TargetPath)&quot;" ContinueOnError="true" />
</Target> </Target>
</Project> </Project>
+66
View File
@@ -0,0 +1,66 @@
# Updates veröffentlichen (Mod & Launcher)
Neue Versionen der Mod und des Launchers landen beim Bauen automatisch auf dem Keymaster (`release.ps1`). Jede Datei
wird dabei mit dem privaten Release-Schlüssel **signiert** – der Launcher und das Setup installieren nur signierte
Dateien. Dafür braucht der Rechner, auf dem gebaut wird, zwei Geheimnisse:
| | Windows | macOS |
|---|---|---|
| Release-Token (Keymaster `RELEASE_TOKEN`) | `%USERPROFILE%\.msco\release-token.dpapi` (DPAPI) | Schlüsselbund: *MSCO release-token* |
| Signier-Schlüssel (RSA-3072) | `%USERPROFILE%\.msco\release-signing-key.dpapi` (DPAPI) | Schlüsselbund: *MSCO release-signing-key* |
Sicherung des Schlüssels: `\\mbmnas\mrmaho\MSCOModBackupData\msco-signing-key-backup.txt` (AES-256, eigenes
Passwort – das Passwort **nicht** auf dem NAS speichern).
## Ablauf
1. Version erhöhen: Mod in `MSCO.cs` (`ModVersion`), Launcher in `MSCO.Launcher.csproj` (`<Version>`) – und den
Abschnitt `## <Version>` in der jeweiligen `CHANGELOG.md` (wird zum Changelog auf der Website/im Launcher).
2. Bauen:
- Mod: im Ordner `MSCO` → `dotnet build -c Release`
- Launcher: im Ordner `MSCO.Launcher` → `dotnet publish -c Release`
3. Die Ausgabe zeigt `MSCO release: mod 2.x.y uploaded to https://msco.mbm.wtf`. Gibt es die Version schon, passiert
nichts. `MSCO_NO_RELEASE=1` (Umgebungsvariable) baut, ohne hochzuladen.
4. Code committen und pushen.
## Einrichten auf Windows
Läuft schon (Token und Schlüssel liegen in `%USERPROFILE%\.msco`). Neuer PC:
```powershell
powershell -ExecutionPolicy Bypass -File MSCO\release.ps1 -SaveToken
powershell -ExecutionPolicy Bypass -File MSCO\release.ps1 -ImportSigningKey \\mbmnas\mrmaho\MSCOModBackupData\msco-signing-key-backup.txt
```
> In einem PowerShell-Fenster **ohne** „Als Administrator ausführen“ – sonst sind Netzlaufwerke wie `Z:` nicht da
> (der Pfad `\\mbmnas\mrmaho\…` geht in beiden).
## Einrichten auf dem Mac (nur bauen/veröffentlichen – das Spiel gibt es nur für Windows)
1. Werkzeuge: [.NET 10 SDK](https://dotnet.microsoft.com/download) und PowerShell 7:
```bash
brew install --cask dotnet-sdk powershell
```
2. Repositories klonen (`MSCO`, `MSCO-Launcher` von git.mbm.wtf).
3. NAS verbinden (Finder → *Gehe zu* → *Mit Server verbinden* → `smb://mbmnas/mrmaho`) – erscheint unter
`/Volumes/mrmaho`.
4. Spiel-DLLs zum Kompilieren der Mod (UnityEngine, Assembly-CSharp, PlayMaker, cInput) liegen unter
`MSCOModBackupData/Managed` auf dem NAS. Am besten einmal lokal kopieren und den Ordner merken:
```bash
mkdir -p ~/MSC-Managed && cp /Volumes/mrmaho/MSCOModBackupData/Managed/*.dll ~/MSC-Managed/
echo 'export MSC_MANAGED="$HOME/MSC-Managed"' >> ~/.zshrc && source ~/.zshrc
```
5. Token und Schlüssel in den Schlüsselbund:
```bash
pwsh MSCO/release.ps1 -SaveToken
pwsh MSCO/release.ps1 -ImportSigningKey /Volumes/mrmaho/MSCOModBackupData/msco-signing-key-backup.txt
```
6. Danach wie oben: `dotnet build -c Release` (Mod) bzw. `dotnet publish -c Release` (Launcher).
Nach einem Spiel-Update (neue `Assembly-CSharp.dll` usw.) die DLLs auf dem NAS bzw. in `~/MSC-Managed` erneuern.
## Neuer Schlüssel?
Nur im Notfall (Schlüssel verloren): `release.ps1 -NewSigningKey` erzeugt ein neues Paar – dann muss der neue
öffentliche Schlüssel in `MSCO.Launcher/Services/ReleaseSignature.cs` und `MSCO.Launcher/Setup/Install.cs`, und
Spieler mit einem älteren Launcher müssen ihn einmal neu installieren (ihr Launcher lehnt die neuen Signaturen ab).
+146 -79
View File
@@ -1,17 +1,19 @@
# Neue Version automatisch auf den Keymaster hochladen (msco.mbm.wtf -> /download, Launcher-Updates). # 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: # 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 # 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), lokal verschlüsselt in # Braucht den Release-Token des Keymasters (RELEASE_TOKEN in dessen .env) und den privaten Signier-Schlüssel.
# %USERPROFILE%\.msco\release-token.dpapi (Windows DPAPI, nur dein Windows-Konto kann ihn lesen - anlegen mit # Beide liegen verschlüsselt beim Entwickler:
# "release.ps1 -SaveToken"), sonst als Umgebungsvariable MSCO_RELEASE_TOKEN. Ohne Token wird nichts hochgeladen. # Windows: %USERPROFILE%\.msco\*.dpapi (Windows DPAPI - nur dein Windows-Konto kann sie lesen)
# MSCO_NO_RELEASE=1 schaltet das Hochladen ab (Test-Builds). # 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 <backup>" aus der Sicherung ("-ExportSigningKey <datei>", mit eigenem Passwort) übernehmen.
# MSCO_RELEASE_TOKEN (Umgebungsvariable) ersetzt den gespeicherten Token, MSCO_NO_RELEASE=1 schaltet Hochladen ab.
# #
# Signatur: jede Datei wird mit dem privaten Release-Schlüssel signiert (RSA-3072, SHA-256) - der Launcher und das Setup # Signatur: jede Datei wird signiert (RSA-3072, SHA-256, "MSCO-RELEASE|produkt|version|sha256") - der Launcher und
# installieren nur, was zu ihrem eingebauten öffentlichen Schlüssel passt. Selbst wer den Keymaster oder den Token # das Setup installieren nur, was zu ihrem eingebauten öffentlichen Schlüssel passt. Selbst wer den Keymaster oder
# übernimmt, kann so keine fremde Datei als MSCO-Update verteilen. Der Schlüssel liegt DPAPI-verschlüsselt in # den Token übernimmt, kann so keine fremde Datei als MSCO-Update verteilen.
# %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. # Gibt es die Version auf dem Keymaster schon, passiert nichts - also einfach die Version erhöhen und bauen.
param( param(
[ValidateSet("mod", "launcher", "installer")][string]$Product, [ValidateSet("mod", "launcher", "installer")][string]$Product,
@@ -26,68 +28,132 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path $here = Split-Path -Parent $MyInvocation.MyCommand.Path
Add-Type -AssemblyName System.Security # ---------------------------------------------------------------- Ablage der Geheimnisse (Windows / macOS / Linux)
$entropy = [Text.Encoding]::UTF8.GetBytes("MSCO release token v1")
$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser $onWindows = ($PSVersionTable.PSEdition -eq "Desktop") -or ($IsWindows -eq $true)
# Geheimnisse in %USERPROFILE%\.msco - nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App), dann sähe $onMac = ($IsMacOS -eq $true)
# ein normales Terminal die Dateien nicht. Die alte Ablage in AppData wird noch gelesen. $homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
$secretDir = Join-Path $env:USERPROFILE ".msco" # Nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App) - ein normales Terminal sähe die Dateien nicht
function Find-Secret([string]$name) { $secretDir = Join-Path $homeDir ".msco"
$new = Join-Path $secretDir $name if ($onWindows) { Add-Type -AssemblyName System.Security }
if (Test-Path $new) { return $new }
$old = Join-Path $env:APPDATA "MSCO\$name" function Get-SecretFile([string]$name) {
if (Test-Path $old) { return $old } $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 return $new
} }
$tokenDpapi = Find-Secret "release-token.dpapi"
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) # Token einmal verschlüsselt ablegen: fragt verdeckt danach (oder nimmt MSCO_RELEASE_TOKEN)
if ($SaveToken) { if ($SaveToken) {
$plain = $env:MSCO_RELEASE_TOKEN $plain = $env:MSCO_RELEASE_TOKEN
if (-not $plain) { if (-not $plain) {
$secure = Read-Host "Release token" -AsSecureString $secure = Read-Host "Release token" -AsSecureString
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) $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 } 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 Write-Secret $tokenName $tokenEntropy ([Text.Encoding]::UTF8.GetBytes($plain))
$enc = [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($plain), $entropy, $scope) Write-Host "Release token saved encrypted: $(Get-SecretLocation $tokenName)"
[IO.File]::WriteAllBytes($tokenDpapi, $enc)
Write-Host "Release token saved encrypted in $tokenDpapi"
exit 0 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) # Neues Schlüsselpaar (nur einmal - der öffentliche Schlüssel ist in Launcher und Setup eingebaut)
if ($NewSigningKey) { if ($NewSigningKey) {
if (Test-Path $keyFile) { Write-Host "There already is a signing key ($keyFile) - not replaced"; exit 1 } if ($null -ne (Read-Secret $keyName $keyEntropy)) { Write-Host "There already is a signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1 }
$rsa = New-Object System.Security.Cryptography.RSACng 3072 $rsa = New-Rsa 3072
$private = $rsa.ToXmlString($true) Write-Secret $keyName $keyEntropy ([Text.Encoding]::UTF8.GetBytes($rsa.ToXmlString($true)))
New-Item -ItemType Directory -Force (Split-Path $keyFile) | Out-Null Write-Host "Signing key saved encrypted: $(Get-SecretLocation $keyName)"
[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 "PUBLIC KEY (put into Launcher/Setup):"
Write-Host $rsa.ToXmlString($false) Write-Host $rsa.ToXmlString($false)
exit 0 exit 0
} }
# Sicherung des privaten Schlüssels mit eigenem Passwort (AES-256, PBKDF2) - z.B. für einen USB-Stick/Passwort-Manager
# Sicherung des privaten Schlüssels mit eigenem Passwort (AES-256, PBKDF2) - z.B. auf dem NAS / USB-Stick
if ($ExportSigningKey) { if ($ExportSigningKey) {
if (-not (Test-Path $keyFile)) { Write-Host "No signing key"; exit 1 } $private = Read-Secret $keyName $keyEntropy
$private = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $keyEntropy, $scope) if ($null -eq $private) { Write-Host "No signing key ($(Get-SecretLocation $keyName))"; exit 1 }
$plainPw = Get-BackupPassword "Password for the backup" $plainPw = Get-BackupPassword "Password for the backup"
if ($plainPw.Length -lt 12) { Write-Host "Use at least 12 characters"; exit 1 } if ($plainPw.Length -lt 12) { Write-Host "Use at least 12 characters"; exit 1 }
# Zweimal fragen: ein Tippfehler machte das Backup sonst unbrauchbar # Zweimal fragen: ein Tippfehler machte das Backup sonst unbrauchbar
$plainPw2 = Get-BackupPassword "Repeat the password" $plainPw2 = Get-BackupPassword "Repeat the password"
if ($plainPw2 -ne $plainPw) { Write-Host "The passwords do not match - nothing written"; exit 1 } 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) $salt = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt)
$kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($plainPw, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256) $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw $salt; $aes.GenerateIV()
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = $kdf.GetBytes(32); $aes.GenerateIV()
$enc = $aes.CreateEncryptor().TransformFinalBlock($private, 0, $private.Length) $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) $out = "MSCO-SIGNING-KEY-BACKUP v1`n" + [Convert]::ToBase64String($salt) + "`n" + [Convert]::ToBase64String($aes.IV) + "`n" + [Convert]::ToBase64String($enc)
try { try {
@@ -102,51 +168,50 @@ if ($ExportSigningKey) {
} }
# Gegenprobe: Datei lesen und mit dem Passwort wieder entschlüsseln # Gegenprobe: Datei lesen und mit dem Passwort wieder entschlüsseln
$lines = [IO.File]::ReadAllText($ExportSigningKey).Split("`n") $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 = Get-Pbkdf2Key $plainPw ([Convert]::FromBase64String($lines[1])); $aes2.IV = [Convert]::FromBase64String($lines[2])
$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) $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 } 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" Write-Host "Backup written and verified: $ExportSigningKey (AES-256, PBKDF2-SHA256 300000) - keep it and the password safe"
exit 0 exit 0
} }
# Sicherung zurückspielen (neuer PC / Windows neu): Datei + Passwort -> wieder DPAPI-verschlüsselt in %USERPROFILE%\.msco
# Sicherung übernehmen (zweiter Rechner, z.B. Mac, oder neuer PC): Datei + Passwort -> verschlüsselte Ablage dieses Rechners
if ($ImportSigningKey) { if ($ImportSigningKey) {
if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Not found: $ImportSigningKey (network drive? see -ExportSigningKey)"; exit 1 } if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Not found: $ImportSigningKey (network drive? see -ExportSigningKey)"; exit 1 }
$lines = [IO.File]::ReadAllText($ImportSigningKey).Split("`n") $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 } 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" $plainPw = Get-BackupPassword "Password of the backup"
try { try {
$kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($plainPw, [Convert]::FromBase64String($lines[1]), 300000, [Security.Cryptography.HashAlgorithmName]::SHA256) $aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw ([Convert]::FromBase64String($lines[1])); $aes.IV = [Convert]::FromBase64String($lines[2])
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = $kdf.GetBytes(32); $aes.IV = [Convert]::FromBase64String($lines[2]) $enc = [Convert]::FromBase64String($lines[3].Trim())
$enc = [Convert]::FromBase64String($lines[3])
$private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length) $private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length)
$rsa = New-Object System.Security.Cryptography.RSACng $rsa = New-Rsa
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) $rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
} catch { Write-Host "Wrong password or damaged backup"; exit 1 } } catch { Write-Host "Wrong password or damaged backup"; exit 1 }
$target = Join-Path $secretDir "release-signing-key.dpapi" $current = Read-Secret $keyName $keyEntropy
if (Test-Path -LiteralPath $target) { if ($null -ne $current) {
$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 } 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 Write-Host "There already is a different signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1
} }
New-Item -ItemType Directory -Force -Path $secretDir | Out-Null Write-Secret $keyName $keyEntropy $private
[IO.File]::WriteAllBytes($target, [Security.Cryptography.ProtectedData]::Protect($private, $keyEntropy, $scope)) Write-Host "Signing key restored: $(Get-SecretLocation $keyName)"
Write-Host "Signing key restored to $target"
exit 0 exit 0
} }
# ---------------------------------------------------------------- Hochladen
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 (-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 } if ($env:MSCO_NO_RELEASE -eq "1") { Write-Host "MSCO release: MSCO_NO_RELEASE=1 - not uploading."; exit 0 }
$token = $env:MSCO_RELEASE_TOKEN $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) { if (-not $token) {
Write-Host "MSCO release: no release token ($tokenDpapi or MSCO_RELEASE_TOKEN) - not uploading." 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 exit 0
} }
$keymaster = if ($env:MSCO_KEYMASTER) { $env:MSCO_KEYMASTER.TrimEnd("/") } else { "https://msco.mbm.wtf" } $keymaster = if ($env:MSCO_KEYMASTER) { $env:MSCO_KEYMASTER.TrimEnd("/") } else { "https://msco.mbm.wtf" }
@@ -162,6 +227,7 @@ if (-not $Version) {
} }
if (-not $Version) { $Version = ((Get-Item $File).VersionInfo.ProductVersion -split '\+')[0] } 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 "## <Version>" aus CHANGELOG.md (Text ohne Markdown, gekürzt) # Changelog: Abschnitt "## <Version>" aus CHANGELOG.md (Text ohne Markdown, gekürzt)
if (-not $Changelog) { if (-not $Changelog) {
@@ -186,19 +252,20 @@ if (-not $Changelog) {
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $File)) $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $File))
$sha = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace("-", "").ToLowerInvariant() $sha = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace("-", "").ToLowerInvariant()
$signature = "" $signature = ""
if (Test-Path $keyFile) { try {
try { $privateBytes = Read-Secret $keyName $keyEntropy
$private = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $keyEntropy, $scope)) } catch { Write-Host "MSCO release: the signing key cannot be decrypted (other user/PC?) - not uploading"; exit 0 }
$rsa = New-Object System.Security.Cryptography.RSACng if ($null -eq $privateBytes) {
$rsa.FromXmlString($private) Write-Host "MSCO release: no signing key ($(Get-SecretLocation $keyName)) - import it with 'release.ps1 -ImportSigningKey <backup>'. Not uploading."
$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 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 Add-Type -AssemblyName System.Net.Http
$client = New-Object System.Net.Http.HttpClient $client = New-Object System.Net.Http.HttpClient