From 83b47e49cd343361d709e4cd5b2d86e7188f622e Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Fri, 15 May 2026 14:34:29 +0200 Subject: [PATCH 01/10] initial manifest augmention with signatures --- ModVerify.slnx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ModVerify.slnx b/ModVerify.slnx index 917a44f..08cb98d 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -8,6 +8,7 @@ + @@ -16,6 +17,11 @@ + + + + + From 2fb46accf66cd5fe5deca008ba9dfc88719408d0 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Fri, 15 May 2026 15:07:21 +0200 Subject: [PATCH 02/10] move to new projects --- ModVerify.slnx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ModVerify.slnx b/ModVerify.slnx index 08cb98d..d3cd519 100644 --- a/ModVerify.slnx +++ b/ModVerify.slnx @@ -7,8 +7,9 @@ + - + From 20df183b5f368b64111d0a9e2436b4e92023cd43 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Fri, 15 May 2026 15:56:42 +0200 Subject: [PATCH 03/10] wire deployment process --- .github/workflows/release.yml | 19 +++++++++++++++++-- deploy-local.ps1 | 32 ++++++++++++++++++++++++++++++++ modules/ModdingToolBase | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a45a516..09f9832 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,12 @@ on: env: TOOL_PROJ_PATH: ./src/ModVerify.CliApp/ModVerify.CliApp.csproj CREATOR_PROJ_PATH: ./modules/ModdingToolBase/src/AnakinApps/ApplicationManifestCreator/ApplicationManifestCreator.csproj + SIGNER_PROJ_PATH: ./modules/ModdingToolBase/src/AnakinApps/ApplicationManifestSigner/ApplicationManifestSigner.csproj UPLOADER_PROJ_PATH: ./modules/ModdingToolBase/src/AnakinApps/FtpUploader/FtpUploader.csproj TOOL_EXE: ModVerify.exe UPDATER_EXE: AnakinRaW.ExternalUpdater.exe MANIFEST_CREATOR: AnakinRaW.ApplicationManifestCreator.dll + MANIFEST_SIGNER: AnakinRaW.ApplicationManifestSigner.dll SFTP_UPLOADER: AnakinRaW.FtpUploader.dll ORIGIN_BASE: https://republicatwar.com/downloads/ModVerify ORIGIN_BASE_PART: downloads/ModVerify/ @@ -59,9 +61,12 @@ jobs: deploy: name: Deploy - # Deploy on push to main or manual trigger + # Stable deploys are gated to 'main'. Non-stable channels (beta, canary, etc.) can be + # workflow_dispatched from any branch. if: | - (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch' + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.branch != 'stable' || github.ref == 'refs/heads/main')) needs: [pack] runs-on: ubuntu-latest steps: @@ -82,6 +87,8 @@ jobs: dotnet-version: 10.0.x - name: Build Creator run: dotnet build ${{env.CREATOR_PROJ_PATH}} --configuration Release --output ./dev + - name: Build Signer + run: dotnet build ${{env.SIGNER_PROJ_PATH}} --configuration Release --output ./dev - name: Build Uploader run: dotnet build ${{env.UPLOADER_PROJ_PATH}} --configuration Release --output ./dev - name: Create binaries directory @@ -92,6 +99,14 @@ jobs: cp ./releases/net481/${{env.UPDATER_EXE}} ./deploy/ - name: Create Manifest run: dotnet ./dev/${{env.MANIFEST_CREATOR}} -a deploy/${{env.TOOL_EXE}} --appDataFiles deploy/${{env.UPDATER_EXE}} --origin ${{env.ORIGIN_BASE}} -o ./deploy -b ${{env.BRANCH_NAME}} + - name: Decode signing pfx + shell: bash + run: echo "${{ secrets.UPDATER_SIGNING_PFX_B64 }}" | base64 -d > $RUNNER_TEMP/signing.pfx + - name: Sign Manifest + run: dotnet ./dev/${{env.MANIFEST_SIGNER}} --manifest ./deploy/manifest.json --pfx $RUNNER_TEMP/signing.pfx --password "${{ secrets.UPDATER_SIGNING_PFX_PASSWORD }}" + - name: Wipe pfx + if: always() + run: rm -f $RUNNER_TEMP/signing.pfx - name: Upload Build run: dotnet ./dev/${{env.SFTP_UPLOADER}} ftp --host $host --port $port -u ${{secrets.SFTP_USER}} -p ${{secrets.SFTP_PASSWORD}} --base $base_path -s $source env: diff --git a/deploy-local.ps1 b/deploy-local.ps1 index 740c579..648a18d 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -13,13 +13,19 @@ $installDir = Join-Path $deployRoot "install" $toolProj = Join-Path $root "src\ModVerify.CliApp\ModVerify.CliApp.csproj" $creatorProj = Join-Path $root "modules\ModdingToolBase\src\AnakinApps\ApplicationManifestCreator\ApplicationManifestCreator.csproj" +$signerProj = Join-Path $root "modules\ModdingToolBase\src\AnakinApps\ApplicationManifestSigner\ApplicationManifestSigner.csproj" $uploaderProj = Join-Path $root "modules\ModdingToolBase\src\AnakinApps\FtpUploader\FtpUploader.csproj" $toolExe = "ModVerify.exe" $updaterExe = "AnakinRaW.ExternalUpdater.exe" $manifestCreatorDll = "AnakinRaW.ApplicationManifestCreator.dll" +$manifestSignerDll = "AnakinRaW.ApplicationManifestSigner.dll" $uploaderDll = "AnakinRaW.FtpUploader.dll" +$devPfx = Join-Path $deployRoot "dev-signing.pfx" +$devCer = Join-Path $deployRoot "dev-trust.cer" +$devPwd = "devpass" + # 1. Clean and Create directories if (Test-Path $deployRoot) { Remove-Item -Recurse -Force $deployRoot } New-Item -ItemType Directory -Path $stagingDir | Out-Null @@ -32,9 +38,29 @@ dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\b Write-Host "--- Building Manifest Creator ---" -ForegroundColor Cyan dotnet build $creatorProj --configuration Release --output "$deployRoot\bin\creator" +Write-Host "--- Building Manifest Signer ---" -ForegroundColor Cyan +dotnet build $signerProj --configuration Release --output "$deployRoot\bin\signer" + Write-Host "--- Building Local Uploader ---" -ForegroundColor Cyan dotnet build $uploaderProj --configuration Release --output "$deployRoot\bin\uploader" +Write-Host "--- Generating dev signing cert ---" -ForegroundColor Cyan +$curve = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName("nistP256") +$ecdsa = [System.Security.Cryptography.ECDsa]::Create($curve) +$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=ModVerify Dev Signing", + $ecdsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256) +$cert = $req.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddDays(-1), + [DateTimeOffset]::UtcNow.AddYears(10)) +[IO.File]::WriteAllBytes($devPfx, $cert.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $devPwd)) +[IO.File]::WriteAllBytes($devCer, $cert.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) +$cert.Dispose() +$ecdsa.Dispose() + # 2. Prepare staging Write-Host "--- Preparing Staging ---" -ForegroundColor Cyan Copy-Item "$deployRoot\bin\tool\$toolExe" $stagingDir @@ -56,6 +82,12 @@ dotnet "$deployRoot\bin\creator\$manifestCreatorDll" ` -o "$stagingDir" ` -b "beta" +Write-Host "--- Signing Manifest ---" -ForegroundColor Cyan +dotnet "$deployRoot\bin\signer\$manifestSignerDll" ` + --manifest "$stagingDir\manifest.json" ` + --pfx $devPfx ` + --password $devPwd + # 4. "Deploy" to server using the local uploader Write-Host "--- Deploying to Local Server ---" -ForegroundColor Cyan dotnet "$deployRoot\bin\uploader\$uploaderDll" local --base "$serverDir" --source "$stagingDir" diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index 3901d1a..1670bda 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit 3901d1a899b8830ef691c06684b023a85f290b84 +Subproject commit 1670bda362bbd42ef8dbf1e198ab85a695d6884c From fcf96e1f9589b6b717bbafe7b1ba8fa0be350b5e Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Fri, 15 May 2026 16:34:01 +0200 Subject: [PATCH 04/10] support local deploy server --- deploy-local.ps1 | 5 ++-- modules/ModdingToolBase | 2 +- .../Updates/ModVerifyUpdater.cs | 4 ++- .../ModVerifyOptionsParserTest.cs | 29 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/deploy-local.ps1 b/deploy-local.ps1 index 648a18d..12e78f8 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -103,5 +103,6 @@ Write-Host "`nTo test the update:" Write-Host "1. (Optional) Modify the version in version.json and run this script again to 'push' a new version to the local server." Write-Host "2. Run ModVerify from the install directory with the following command:" Write-Host " cd '$installDir'" -Write-Host " .\ModVerify.exe updateApplication --updateManifestUrl '$serverUri'" -Write-Host "`n Note: You can also specify a different branch using --updateBranch if needed." +Write-Host " .\ModVerify.exe updateApplication --updateBranch beta --updateServerUrl '$serverUri'" +Write-Host "`n Note: --updateServerUrl takes a server base URL and resolves to //manifest.json." +Write-Host " Use --updateManifestUrl instead if you want to point directly at a full manifest URL." diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index 1670bda..cc14954 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit 1670bda362bbd42ef8dbf1e198ab85a695d6884c +Subproject commit cc149543406c2909578d3c3b0ecacd8b09a4aeaa diff --git a/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs b/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs index af7d369..1c7d699 100644 --- a/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs +++ b/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs @@ -51,7 +51,9 @@ private async Task UpdateApplication(ApplicationUpdateOptions updateOptions, Mod var updater = new ModVerifyApplicationUpdater(updatableEnvironment, _serviceProvider); var actualBranchName = updater.GetBranchNameFromRegistry(updateOptions.BranchName, false); - var branch = updater.CreateBranch(actualBranchName, updateOptions.ManifestUrl); + var branch = !string.IsNullOrEmpty(updateOptions.ServerUrl) + ? updater.CreateBranchFromServerUrl(updateOptions.ServerUrl!, actualBranchName) + : updater.CreateBranch(actualBranchName, updateOptions.ManifestUrl); using (ConsoleUtilities.CreateHorizontalFrame(length: 40, startWithNewLine: true, newLineAtEnd: true)) { diff --git a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs index 0550b34..231b92a 100644 --- a/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs +++ b/test/ModVerify.CliApp.Test/ModVerifyOptionsParserTest.cs @@ -34,6 +34,34 @@ public void Parse_UpdateAppArg() Assert.NotNull(settings.UpdateOptions); Assert.Equal("test", settings.UpdateOptions.BranchName); Assert.Equal("https://examlple.com", settings.UpdateOptions.ManifestUrl); + Assert.Null(settings.UpdateOptions.ServerUrl); + } + + [Fact] + public void Parse_UpdateAppArg_ServerUrl() + { + const string argString = "updateApplication --updateBranch test --updateServerUrl https://example.com/updates"; + + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.True(settings.HasOptions); + Assert.Null(settings.ModVerifyOptions); + Assert.NotNull(settings.UpdateOptions); + Assert.Equal("test", settings.UpdateOptions.BranchName); + Assert.Equal("https://example.com/updates", settings.UpdateOptions.ServerUrl); + Assert.Null(settings.UpdateOptions.ManifestUrl); + } + + [Fact] + public void Parse_UpdateAppArg_ManifestAndServerUrl_AreMutuallyExclusive() + { + const string argString = "updateApplication --updateManifestUrl https://example.com/manifest.json --updateServerUrl https://example.com"; + + var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + Assert.False(settings.HasOptions); + Assert.Null(settings.ModVerifyOptions); + Assert.Null(settings.UpdateOptions); } [Fact] @@ -65,6 +93,7 @@ protected override ApplicationEnvironment CreateEnvironment() [InlineData("createBaseline --junkOption")] [InlineData("updateApplication")] [InlineData("updateApplication --updateBranch test --updateManifestUrl https://examlple.com")] + [InlineData("updateApplication --updateBranch test --updateServerUrl https://example.com")] public void Parse_InvalidArgs_NotUpdateable(string argString) { var settings = Parser.Parse(argString.Split(' ', StringSplitOptions.RemoveEmptyEntries)); From b214101b3200fc3a2c9afc44f413f3c1b6290be2 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Fri, 15 May 2026 16:46:56 +0200 Subject: [PATCH 05/10] deploy a newer version, so updating can be tested --- deploy-local.ps1 | 60 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/deploy-local.ps1 b/deploy-local.ps1 index 12e78f8..c618fad 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -1,5 +1,16 @@ # Local deployment script for ModVerify to test the update feature. -# This script builds the application, creates an update manifest, and "deploys" it to a local directory. +# This script builds the application twice at different versions, creates an update manifest +# for the newer one, and stages an "installed" copy of the older one — so triggering the +# update flow against the local server actually finds an update. + +param( + # Version baked into the "already installed" copy. Must be lower than $ServerVersion + # so the updater treats the server build as newer. + [string]$InstalledVersion = "0.0.1-local", + + # Version baked into the build that ends up on the local "server" / in the manifest. + [string]$ServerVersion = "0.0.2-local" +) $ErrorActionPreference = "Stop" @@ -26,13 +37,36 @@ $devPfx = Join-Path $deployRoot "dev-signing.pfx" $devCer = Join-Path $deployRoot "dev-trust.cer" $devPwd = "devpass" +$versionJsonPath = Join-Path $root "version.json" +$versionJsonBackup = [IO.File]::ReadAllText($versionJsonPath) + +function Set-NbgvVersion { + param([string]$Version) + $json = $versionJsonBackup | ConvertFrom-Json + $json.version = $Version + # publicReleaseRefSpec defaults the build to non-public; clearing it gives us a clean + # "X.Y.Z" InformationalVersion locally without the +gitHash height suffix making + # comparisons noisier than they need to be. + if ($json.PSObject.Properties.Name -contains 'publicReleaseRefSpec') { + $json.publicReleaseRefSpec = @() + } + ($json | ConvertTo-Json -Depth 32) | Set-Content -Path $versionJsonPath -Encoding UTF8 +} + +try { + # 1. Clean and Create directories if (Test-Path $deployRoot) { Remove-Item -Recurse -Force $deployRoot } New-Item -ItemType Directory -Path $stagingDir | Out-Null New-Item -ItemType Directory -Path $serverDir | Out-Null New-Item -ItemType Directory -Path $installDir | Out-Null -Write-Host "--- Building ModVerify (net481) ---" -ForegroundColor Cyan +Write-Host "--- Building ModVerify (net481) @ installed v$InstalledVersion ---" -ForegroundColor Cyan +Set-NbgvVersion -Version $InstalledVersion +dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\install" /p:DebugType=None /p:DebugSymbols=false + +Write-Host "--- Building ModVerify (net481) @ server v$ServerVersion ---" -ForegroundColor Cyan +Set-NbgvVersion -Version $ServerVersion dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\tool" /p:DebugType=None /p:DebugSymbols=false Write-Host "--- Building Manifest Creator ---" -ForegroundColor Cyan @@ -92,17 +126,27 @@ dotnet "$deployRoot\bin\signer\$manifestSignerDll" ` Write-Host "--- Deploying to Local Server ---" -ForegroundColor Cyan dotnet "$deployRoot\bin\uploader\$uploaderDll" local --base "$serverDir" --source "$stagingDir" -# 5. Setup a "test" installation -Write-Host "--- Setting up Test Installation ---" -ForegroundColor Cyan -Copy-Item "$deployRoot\bin\tool\*" $installDir -Recurse +# 5. Setup a "test" installation — uses the older-version build so the updater sees the +# staged server build as an upgrade. +Write-Host "--- Setting up Test Installation (v$InstalledVersion) ---" -ForegroundColor Cyan +Copy-Item "$deployRoot\bin\install\*" $installDir -Recurse Write-Host "`nLocal deployment complete!" -ForegroundColor Green -Write-Host "Server directory: $serverDir" +Write-Host "Installed version: $InstalledVersion" +Write-Host "Server version: $ServerVersion" +Write-Host "Server directory: $serverDir" Write-Host "Install directory: $installDir" Write-Host "`nTo test the update:" -Write-Host "1. (Optional) Modify the version in version.json and run this script again to 'push' a new version to the local server." -Write-Host "2. Run ModVerify from the install directory with the following command:" +Write-Host "1. Run ModVerify from the install directory with the following command:" Write-Host " cd '$installDir'" Write-Host " .\ModVerify.exe updateApplication --updateBranch beta --updateServerUrl '$serverUri'" Write-Host "`n Note: --updateServerUrl takes a server base URL and resolves to //manifest.json." Write-Host " Use --updateManifestUrl instead if you want to point directly at a full manifest URL." +Write-Host "`n2. To re-test, just rerun this script — every run produces v$InstalledVersion installed against v$ServerVersion on the server." +Write-Host " Override with -InstalledVersion / -ServerVersion to exercise other version transitions." + +} +finally { + # Always restore version.json verbatim (bytes-in == bytes-out), even if a build step above failed. + [IO.File]::WriteAllText($versionJsonPath, $versionJsonBackup) +} From b15bf6c71e6cbe89c08743f36fbad138256ea07c Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 16 May 2026 14:15:57 +0200 Subject: [PATCH 06/10] Update ModdingToolBase and manifest signing config Updated ModdingToolBase submodule. Refactored ManifestDownloadConfiguration to use ManifestDownloadConfiguration type and simplified its properties. Added ManifestSigningConfiguration with required ES256 signature policy. Added necessary using directive for security features. --- modules/ModdingToolBase | 2 +- src/ModVerify.CliApp/ModVerifyAppEnvironment.cs | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index cc14954..63aab0a 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit cc149543406c2909578d3c3b0ecacd8b09a4aeaa +Subproject commit 63aab0ac072fceed58792ad3e627c7dec4635da8 diff --git a/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs b/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs index 192d97b..73f937a 100644 --- a/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs +++ b/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Reflection; using AnakinRaW.ApplicationBase.Environment; +using AnakinRaW.AppUpdaterFramework.Security; #if !NET using System; using System.IO; @@ -64,11 +65,9 @@ protected override UpdateConfiguration CreateUpdateConfiguration() DownloadRetryDelay = 500, ValidationPolicy = ValidationPolicy.Required }, - ManifestDownloadConfiguration = new DownloadManagerConfiguration + ManifestDownloadConfiguration = new ManifestDownloadConfiguration { - AllowEmptyFileDownload = false, - DownloadRetryDelay = 500, - ValidationPolicy = ValidationPolicy.Optional + DownloadRetryDelay = 500 }, BranchDownloadConfiguration = new DownloadManagerConfiguration { @@ -82,7 +81,12 @@ protected override UpdateConfiguration CreateUpdateConfiguration() SupportsRestart = true, PassCurrentArgumentsForRestart = true }, - ValidateInstallation = true + ValidateInstallation = true, + ManifestSigningConfiguration = new SigningConfiguration + { + Policy = SignaturePolicy.Required, + SignatureAlgorithm = SignatureAlgorithm.ES256 + } }; } #endif From f09bc6cd04f6e9f5799a0719811eea8e879eeb61 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 16 May 2026 18:23:58 +0200 Subject: [PATCH 07/10] Add trust cert verification to release workflow Add a PowerShell step in release.yml to verify that the embedded modverify-trust.cer exists, is a valid public X.509 certificate, and does not contain a private key. Fail the workflow with a clear error if any check fails. Also update the ModdingToolBase submodule to the latest commit. --- .github/workflows/release.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09f9832..b22d36f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,26 @@ jobs: with: fetch-depth: 0 submodules: recursive + - name: Verify embedded trust cert + shell: pwsh + run: | + $certPath = "src/ModVerify.CliApp/Resources/Certs/modverify-trust.cer" + if (-not (Test-Path $certPath)) { + Write-Error "$certPath is missing. Generate the production trust cert per docs/update-signing-setup.md and commit it before releasing." + exit 1 + } + try { + $bytes = [IO.File]::ReadAllBytes($certPath) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) + } catch { + Write-Error "Failed to load $certPath as an X.509 certificate: $_" + exit 1 + } + if ($cert.HasPrivateKey) { + Write-Error "$certPath contains a private key. Only the public DER (.cer) is allowed; embedding a PFX would ship the signing key to every consumer." + exit 1 + } + Write-Host "OK: $certPath is public-only ($($cert.Subject))." - uses: actions/download-artifact@v8 with: name: Binary Releases From 0de459537c1325fa8d8f82703a396fc3de8503ec Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 16 May 2026 18:24:45 +0200 Subject: [PATCH 08/10] Update cert handling and add LOCAL_DEPLOY support - Update ModdingToolBase submodule to latest commit - Add LOCAL_DEPLOY constant for local deployment builds - Embed modverify-trust.cer as a resource if present - Register trusted certs at runtime, supporting dev certs in DEBUG/LOCAL_DEPLOY - Refactor Program.cs to use embedded cert resource name constant --- modules/ModdingToolBase | 2 +- src/ModVerify.CliApp/ModVerify.CliApp.csproj | 9 +++++++++ src/ModVerify.CliApp/Program.cs | 13 +++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index 63aab0a..6a44eb7 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit 63aab0ac072fceed58792ad3e627c7dec4635da8 +Subproject commit 6a44eb7c61e5e0ba1bc8a1448217de34a1fd6afe diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj index b1c9747..0ec2beb 100644 --- a/src/ModVerify.CliApp/ModVerify.CliApp.csproj +++ b/src/ModVerify.CliApp/ModVerify.CliApp.csproj @@ -25,8 +25,17 @@ true + + + $(DefineConstants);LOCAL_DEPLOY + + + diff --git a/src/ModVerify.CliApp/Program.cs b/src/ModVerify.CliApp/Program.cs index e5316f4..ba1711c 100644 --- a/src/ModVerify.CliApp/Program.cs +++ b/src/ModVerify.CliApp/Program.cs @@ -185,8 +185,21 @@ protected override IRegistry CreateRegistry() : new WindowsRegistry(); } + private const string EmbeddedTrustCertResource = "AET.ModVerify.App.Resources.Certs.modverify-trust.cer"; + protected override async Task RunAppAsync(string[] args, IServiceProvider appServiceProvider) { + if (IsUpdateableApplication) + { + string? devCertPath = null; +#if DEBUG || LOCAL_DEPLOY + devCertPath = System.IO.Path.GetFullPath( + System.IO.Path.Combine(AppContext.BaseDirectory, "..", "dev-trust.cer")); +#endif + appServiceProvider.GetRequiredService() + .RegisterTrustedCertificates(typeof(Program).Assembly, EmbeddedTrustCertResource, devCertPath); + } + var result = await HandleUpdate(appServiceProvider); if (result != 0 || _modVerifyAppSettings is null) return result; From f842aced63204a2a49e394afa595dab5a94eab6a Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Sat, 16 May 2026 18:57:10 +0200 Subject: [PATCH 09/10] Add LocalDeploy flag and update ModdingToolBase submodule Added /p:LocalDeploy=true to dotnet build commands in deploy-local.ps1 for local deployment logic. Updated ModdingToolBase submodule to commit 0e97dc475c42a4ebf084e4917b526e3dbee50b47. --- deploy-local.ps1 | 4 ++-- modules/ModdingToolBase | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy-local.ps1 b/deploy-local.ps1 index c618fad..f06b3d7 100644 --- a/deploy-local.ps1 +++ b/deploy-local.ps1 @@ -63,11 +63,11 @@ New-Item -ItemType Directory -Path $installDir | Out-Null Write-Host "--- Building ModVerify (net481) @ installed v$InstalledVersion ---" -ForegroundColor Cyan Set-NbgvVersion -Version $InstalledVersion -dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\install" /p:DebugType=None /p:DebugSymbols=false +dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\install" /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true Write-Host "--- Building ModVerify (net481) @ server v$ServerVersion ---" -ForegroundColor Cyan Set-NbgvVersion -Version $ServerVersion -dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\tool" /p:DebugType=None /p:DebugSymbols=false +dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\tool" /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true Write-Host "--- Building Manifest Creator ---" -ForegroundColor Cyan dotnet build $creatorProj --configuration Release --output "$deployRoot\bin\creator" diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase index 6a44eb7..0e97dc4 160000 --- a/modules/ModdingToolBase +++ b/modules/ModdingToolBase @@ -1 +1 @@ -Subproject commit 6a44eb7c61e5e0ba1bc8a1448217de34a1fd6afe +Subproject commit 0e97dc475c42a4ebf084e4917b526e3dbee50b47 From 0a581ed3cf2be279a327742d9c571c38ef7648d6 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Mon, 18 May 2026 20:28:05 +0200 Subject: [PATCH 10/10] add plans and design for signed updates --- docs/cert-playbook.md | 363 +++++++++++++++++++++++++++ docs/update-signing.md | 544 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 907 insertions(+) create mode 100644 docs/cert-playbook.md create mode 100644 docs/update-signing.md diff --git a/docs/cert-playbook.md b/docs/cert-playbook.md new file mode 100644 index 0000000..2f94a59 --- /dev/null +++ b/docs/cert-playbook.md @@ -0,0 +1,363 @@ +# Cert / Key Operations Playbook + +Step-by-step procedures for every cert operation in the ModVerify update-signing chain. +Background and rationale: `update-signing.md`. + +**Trust hierarchy at a glance:** + +``` +Root CA — 20-year self-signed, private key kept offline by the maintainer + │ signs + ▼ +Intermediate — 1-year, private key in GitHub Secrets, used by CI + │ signs + ▼ +Release manifest — one per release +``` + +Clients embed only the **root** public cert. The verifier chains every manifest's signing +cert back to that root via `X509Chain` with `CustomRootTrust` (the Windows cert store is +never consulted). + +--- + +## Non-negotiable rules + +- **Never touch the Windows certificate store.** No `Cert:\…`, no + `New-SelfSignedCertificate`, no `-CertStoreLocation` / `-TrustRoot` flags. +- **Never persist trust state to writable disk** on the user's machine. +- **Generate all certs in memory** via `CertificateRequest.CreateSelfSigned` / + `CertificateRequest.Create(issuerCert, …)`. Export straight to `.pfx` / `.cer` files. +- **The root private key never leaves the offline machine.** No CI, no cloud sync, no + screenshot. +- **Intermediate private key is allowed in GitHub Secrets.** It has a short lifetime by + design. +- **All scripts assume PowerShell 7.5+ / .NET 9+** (`X509CertificateLoader.LoadPkcs12`). + +--- + +## 1. Initial root cert generation (one-time) + +**When:** Before shipping the first signed release. Once, ever (except for catastrophic +root rotation — section 5). + +**Where:** Air-gapped or at least offline machine. + +**Produces:** +- `modverify-root.pfx` — root private key + cert, password-protected. **Never networked.** +- `modverify-trust.cer` — root public cert. Commits to `src/ModVerify.CliApp/Resources/Certs/`. + +### Script + +```powershell +$pwd = Read-Host "Root PFX password (write this down — losing it = losing the root)" -AsSecureString + +$ecdsa = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256) +try { + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=ModVerify Root CA", + $ecdsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256) + + # Basic Constraints: CA=true, end-entity intermediates not sub-CAs + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $true, $true, 0, $true)) + + # Key Usage: signs other certs + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign, + $true)) + + # Subject Key Identifier — helps X509Chain link intermediates to this root + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new( + $req.PublicKey, $false)) + + $notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5) + $notAfter = [DateTimeOffset]::UtcNow.AddYears(20) + $cert = $req.CreateSelfSigned($notBefore, $notAfter) + try { + [IO.File]::WriteAllBytes(".\modverify-root.pfx", $cert.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $pwd)) + [IO.File]::WriteAllBytes(".\modverify-trust.cer", $cert.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + Write-Host "Root generated. Thumbprint: $($cert.Thumbprint)" + } finally { + $cert.Dispose() + } +} finally { + $ecdsa.Dispose() +} +``` + +### After + +1. Strong passphrase (Diceware, ≥60 bits entropy). Memorize *and* record it. +2. **Back up the `.pfx` in at least two independent failure modes**: + - Password manager (1Password / Bitwarden / KeePass), encrypted entry. + - YubiKey PIV slot, or offline USB stored physically secured (safe, deposit box). + - Optional third: printed base64 + passphrase, sealed envelope, separate location. +3. Commit `modverify-trust.cer` to `src/ModVerify.CliApp/Resources/Certs/`. +4. **Delete the working-copy `.pfx`** once backups are confirmed. +5. Schedule the annual test ceremony (section 4) on a fixed date. + +--- + +## 2. Issuing an intermediate (recurring) + +**When:** +- ~2 months before current intermediate expires (so there's overlap). +- Immediately on suspected CI compromise. + +**Where:** Offline (or at minimum, disconnected) machine. + +**Produces:** +- `modverify-int-YYYYMM.pfx` — intermediate keypair, password-protected, goes to GitHub + Secrets. + +### Script + +```powershell +$rootPfxPath = ".\modverify-root.pfx" +$rootPwd = Read-Host "Root PFX password" -AsSecureString +$intPwd = Read-Host "Intermediate PFX password (will go to GitHub Secrets)" -AsSecureString + +$rootPwdPlain = [System.Net.NetworkCredential]::new("", $rootPwd).Password + +$rootBytes = [IO.File]::ReadAllBytes($rootPfxPath) +$rootCert = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadPkcs12( + $rootBytes, + $rootPwdPlain, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet) +$rootPwdPlain = $null + +$intEcdsa = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256) +try { + $intReq = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=ModVerify Signing $((Get-Date -Format yyyy-MM))", + $intEcdsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256) + + # Basic Constraints: end-entity, not a sub-CA + $intReq.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $false, $false, 0, $true)) + + # Key Usage: signs data (manifests), not certs + $intReq.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true)) + + # Subject Key Identifier + $intReq.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new( + $intReq.PublicKey, $false)) + + # Sign with root + $notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5) + $notAfter = [DateTimeOffset]::UtcNow.AddYears(1) + $serial = [System.BitConverter]::GetBytes([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + $intCert = $intReq.Create($rootCert, $notBefore, $notAfter, $serial) + + $intWithKey = $intCert.CopyWithPrivateKey($intEcdsa) + try { + $outPath = ".\modverify-int-$((Get-Date -Format yyyyMM)).pfx" + [IO.File]::WriteAllBytes($outPath, $intWithKey.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $intPwd)) + Write-Host "Intermediate written to $outPath" + Write-Host "Subject: $($intCert.Subject)" + Write-Host "Thumbprint: $($intCert.Thumbprint)" + Write-Host "Valid until: $($intCert.NotAfter.ToString('u'))" + } finally { + $intWithKey.Dispose() + $intCert.Dispose() + } +} finally { + $intEcdsa.Dispose() + $rootCert.Dispose() +} +``` + +### After + +1. Base64 the intermediate PFX: + ```powershell + [Convert]::ToBase64String([IO.File]::ReadAllBytes(".\modverify-int-YYYYMM.pfx")) ` + | Set-Clipboard + ``` +2. In GitHub: Settings → Secrets and variables → Actions: + - Update `UPDATER_SIGNING_PFX_B64` with the clipboard contents. + - Update `UPDATER_SIGNING_PFX_PASSWORD` with the passphrase. +3. Wipe the local intermediate PFX: + ```powershell + Remove-Item ".\modverify-int-YYYYMM.pfx" -Force + ``` +4. Lock the root PFX away again. +5. Trigger a release (or wait for the next one). Confirm CI signs successfully and a + freshly-installed client verifies the new manifest. + +--- + +## 3. Local dev cert generation (for `deploy-local.ps1`) + +Dev certs are generated fresh per `deploy-local.ps1` run — no persistence, no +backups needed. + +The pattern is already implemented in `deploy-local.ps1` and follows the same +in-memory `CertificateRequest.CreateSelfSigned` shape. If `deploy-local.ps1` is rewritten +for any reason, ensure it generates a *root* + *intermediate* pair (mirroring prod), so +the local-deploy flow exercises the same chain-validation code path the prod verifier uses. + +--- + +## 4. Annual root test ceremony + +**When:** Once per year on a fixed date (e.g. every January 15). Set a calendar +reminder. + +**Why:** Confirm the root key + passphrase are still accessible *before* a real +incident makes you discover otherwise. A lost root takes 6 months — 5 years to discover +during normal operation. + +### Script + +```powershell +$rootPwd = Read-Host "Root PFX password (annual test)" -AsSecureString +$rootPwdPlain = [System.Net.NetworkCredential]::new("", $rootPwd).Password + +try { + $rootBytes = [IO.File]::ReadAllBytes(".\modverify-root.pfx") + $rootCert = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadPkcs12( + $rootBytes, + $rootPwdPlain, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet) + $rootPwdPlain = $null + + Write-Host "Root loaded:" + Write-Host " Subject: $($rootCert.Subject)" + Write-Host " Thumbprint: $($rootCert.Thumbprint)" + Write-Host " Valid until: $($rootCert.NotAfter.ToString('u'))" + + # Sign a throwaway test cert — confirms the private key actually works + $testEcdsa = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256) + try { + $testReq = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=Annual Test - DELETE ME", + $testEcdsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256) + $serial = [System.BitConverter]::GetBytes([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + $testCert = $testReq.Create($rootCert, + [DateTimeOffset]::UtcNow, + [DateTimeOffset]::UtcNow.AddMinutes(5), + $serial) + Write-Host "Test cert signed successfully — root private key is intact." + $testCert.Dispose() + } finally { + $testEcdsa.Dispose() + } + + # Compare loaded cert against committed trust cert + $embeddedCer = ".\src\ModVerify.CliApp\Resources\Certs\modverify-trust.cer" + if (Test-Path $embeddedCer) { + $embedded = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadCertificate( + [IO.File]::ReadAllBytes($embeddedCer)) + if ($embedded.Thumbprint -eq $rootCert.Thumbprint) { + Write-Host "Embedded trust cert MATCHES the loaded root. OK." + } else { + Write-Warning "Embedded trust cert thumbprint DOES NOT MATCH the loaded root." + } + $embedded.Dispose() + } + + $rootCert.Dispose() + Write-Host "Annual test PASSED." +} catch { + Write-Error "ANNUAL TEST FAILED. Recovery may be required (see section 5)." + throw +} +``` + +If this fails (wrong passphrase, corrupted PFX, missing backups all gone) → go to +section 5 immediately. **Do not** wait for the current intermediate to expire. + +--- + +## 5. Catastrophic root rotation (recovery only) + +**When:** +- Root key lost (annual test failed across all backups). +- Root key compromised (someone else has it). +- Root cert within final year of its 20-year validity (unlikely concern for a long time). + +**Effect:** Auto-update is **dead** for every deployed client until they manually +reinstall. There is no graceful path. This is the failure mode the design accepts in +exchange for not needing constant trust-store ceremonies. + +### Procedure + +1. **Generate a new root** offline (section 1, using a different filename + `modverify-root-v2.pfx`). +2. **Issue a first intermediate under the new root** (section 2, signed by the new root). +3. **Update `src/ModVerify.CliApp/Resources/Certs/modverify-trust.cer`** to the new root's + public cert. (Drop the old root entirely — the new root is the only trust anchor.) +4. **Update GitHub Secrets** to the new intermediate's PFX/password. +5. **Cut a release.** Auto-update is broken for all existing clients (they don't trust + the new root). They will sit on their last installed version. +6. **Announce widely** — release notes, README, every forum the userbase reads. Make + clear that users must manually download from GitHub Releases to recover. +7. **If the recovery is due to compromise (Scenario B)**: the malicious clients out there + stay malicious until reinstalled. This is the irreducible blast radius. + +### After + +1. Resume normal operations under the new root. +2. Treat the new root with the same custody discipline as the old one (section 1 "After"). +3. The old root is dead — destroy any remaining copies (overwrite, shred the printed + backup, etc.). + +--- + +## 6. CI intermediate compromise response + +**Signs:** Unauthorized release in GitHub Actions history; signing-key leak alert; +unexplained signing activity. + +### Procedure + +1. **Immediately rotate the intermediate** via section 2. Use a new passphrase. +2. **Trigger a release** with a bumped version high enough to overtake any malicious + release the attacker might publish (the rollback-rejection mechanism in + `update-signing.md` → *Deferred update resumption* refuses anything older). +3. **Audit recent releases** since the suspected compromise window. Any unexpected + release should be flagged and version-bumped over. +4. **The compromised intermediate's manifests stay verifiable until its `notAfter`** — + you cannot revoke it mid-lifetime without infrastructure we don't have. Mitigation is + the version-bump-and-overtake. The compromised intermediate will expire naturally on + its original schedule. + +### Why we don't bother with mid-lifetime revocation + +A CRL or revocation list embedded in manifests is on the table per `update-signing.md`'s +"Revocation" notes, but for ModVerify scale, the simpler model is: **short intermediate +lifetimes mean compromise is bounded automatically**. Issue intermediates with a +defensible lifetime (1 year typical) and accept that lifetime as the worst-case +compromise window. + +--- + +## Quick reference + +| Operation | Section | Frequency | Where | +|---|---|---|---| +| Generate root | 1 | Once | Offline machine | +| Issue intermediate | 2 | Every ~9-10 months | Offline machine | +| Dev cert | 3 | Per `deploy-local.ps1` run | Local | +| Annual root test | 4 | Once per year | Offline machine | +| Root rotation | 5 | Never if everything works | Offline machine | +| CI compromise response | 6 | Hopefully never | Section 2 + bump + ship | diff --git a/docs/update-signing.md b/docs/update-signing.md new file mode 100644 index 0000000..2a798b8 --- /dev/null +++ b/docs/update-signing.md @@ -0,0 +1,544 @@ +# Update Signing & Updater Hardening + +Single source of truth for how ModVerify's updater authenticates manifests, files, and the +external-updater binary; how trust is bootstrapped and rotated; and what's still TODO. + +--- + +## Goal + +Every update artifact a ModVerify client acts on is authenticated against a trust anchor pinned +in the host at build time. Compromise of the CDN, MITM on the download channel, accidental +corruption of staged artifacts, or local tampering of files between download and install must +abort the update, not let it proceed. + +## Threat model — what we want to prevent + +In order of decreasing attacker capability: + +1. **Compromised CDN serving signed manifests pointing at malicious bytes.** Out of scope — the + bytes still have to match the signed manifest's hash, and we don't sign attacker bytes. +2. **Local attacker with write access to the install directory.** Can swap + `AnakinRaW.ExternalUpdater.exe` for arbitrary code, which the main app then launches with + the user's privileges (or elevated). Closed by *External updater hardening* — + manifest-hash verification of the updater bytes before launch. +3. **Local attacker with write access to `%TEMP%` and the download repository.** Can modify + the update-info file between writer and reader (TOCTOU), or swap a downloaded-and-verified + source blob between download and the updater's file-move. Closed by *Deferred update + resumption* — `--updatePayload` on the command line replaces the tempfile, and the updater + re-hashes every source before moving. +4. **Network-active attacker without our signing key.** Can replay older signed manifests + (rollback) to pin users on a known-vulnerable version. Closed by *Deferred update + resumption* — rollback rejection on both CDN-fetched and pending-on-disk manifests. + +What's already done covers (1). (2)-(4) is the work remaining. + +### Threats out of scope + +- Attacker who can read process memory. +- Attacker who can write to `%ProgramFiles%` directly (DLL planting next to our exe, loader + hijack — OS-level integrity problem, not something file signing fixes). +- Compromise of the **offline root** key. Catastrophic — requires shipping a new release + with a new embedded root and having every user manually reinstall (`docs/cert-playbook.md` + §5). The root is kept offline specifically to make this hard. **CI intermediate + compromise is in-scope** and handled by routine rotation (`docs/cert-playbook.md` §6): + issue a new intermediate, ship the next release, accept that manifests signed by the + compromised intermediate stay verifiable until its `notAfter` (worst-case window + bounded by intermediate lifetime, typically 1 year). + +--- + +## What's implemented today + +- **Manifest signing.** Every manifest the release pipeline publishes is signed with a self-signed + ECDSA P-256 cert held in GitHub Actions secrets. Signature is a `signature` block embedded in + the JSON: `{ alg, value, cert }`. One signature per manifest, made with the current active key. +- **Verification on the client.** Default policy is `SignaturePolicy.Required`. The fetch path + is owned by the framework's internal `ManifestFetcher`; hosts can't bypass it. The verifier + chain is `ManifestLoaderBase.LoadAndVerifyManifest` → `ISignatureVerifier.Verify` → + `ICertificateStore`. All five contracts (`ICertificateStore`, `ISignatureVerifier`, + `ManifestLoaderBase`, `IManifestLoaderProvider`, `IManifestFetcher`) are unfakeable from + outside the framework assembly. +- **Mirror failover.** Signature failure on one mirror tries the next; only after all mirrors + fail does `ManifestDownloadException` surface. +- **Config guards.** `ManifestFetcher` refuses to construct when `SignaturePolicy.Required` but + `ComponentDownloadConfiguration.ValidationPolicy != Required` (the signed manifest would be + moot if components weren't hash-checked). `ManifestLoaderBase` refuses a verified manifest in + which any `InstallableComponent` lacks integrity info. +- **`CertificateManager` (in `ApplicationBase`).** Loads trust anchors from embedded resources + and from a local-deploy dev cert path. Refuses any cert that carries a private key (no PFX + leakage into the consumer build). +- **CI guards.** `release.yml`'s `deploy` job verifies the embedded trust cert: must be a valid + X.509, must be public-only. Missing or PFX-shaped cert fails the deploy. +- **Local-deploy support.** `deploy-local.ps1` generates a throwaway dev cert per run (via + in-memory `CertificateRequest`, never touches the Windows cert store), signs the local + manifest with it, stages the public half next to the install dir. The `LOCAL_DEPLOY` MSBuild + symbol switches on the dev-cert lookup in `Program.RegisterTrustedCertificates`. +- **Production prod cert is not yet generated.** When generated, dropping + `modverify-trust.cer` into `src/ModVerify.CliApp/Resources/Certs/` activates the release + pipeline end-to-end. Setup steps below. + +## Manifest format + +```json +{ + "name": "ModVerify", + "version": "...", + "branch": "stable", + "components": [ + { + "id": "...", + "originInfo": { "url": "...", "size": ..., "sha256": "..." }, + ... + } + ], + "signature": { + "alg": "ES256", + "value": "", + "cert": "" + } +} +``` + +The signature covers the canonical bytes produced by serializing the manifest with +`signature = null` and the framework's `ManifestJsonOptions.Default`. Both signer and verifier +use the same canonicalizer (`CanonicalManifestSerializer.SerializeForDigest`) so the digest is +byte-stable. + +`signature.cert` is the **intermediate** that signed this manifest — not a self-contained +trust anchor. The verifier builds a chain from this cert to a root in the build-embedded +trust set (see *Trust bootstrap and rotation*) and rejects any manifest whose intermediate +isn't currently within its validity window. + +The verifier reads the algorithm from the manifest and dispatches per `SignatureAlgorithm` +(JWS-style: `ES256`, `ES384`, `ES512`). Future algorithms slot in non-breakingly. + +## Three-layer integrity + +- **Chain layer.** The cert in `signature.cert` (an intermediate) must chain to a root in + the build-embedded trust set and be temporally valid. Verified via `X509Chain` with + `CustomTrustStore = { root }` and `TrustMode = X509ChainTrustMode.CustomRootTrust` — the + OS cert store is bypassed entirely. +- **Manifest layer.** Signature verified with the intermediate's public key before anything + in the manifest is trusted. +- **Component layer.** The verified manifest declares SHA-256 per component. The download + manager validates each component's hash against the manifest's declaration at download + time (`HashDownloadValidator`). The three layers compose: chain authenticates the + intermediate, intermediate authenticates the manifest, manifest authenticates the + components. + +## Trust bootstrap and rotation + +### Build-time pinning + +The host app embeds the public **root cert** at +`src/ModVerify.CliApp/Resources/Certs/modverify-trust.cer`. The root is long-lived (20-year +validity); its private key is held offline by the maintainer and never signs a manifest +directly — it only signs intermediates. CI signs manifests with a short-lived intermediate +(typical 1-year lifetime) whose public cert travels inside each manifest's `signature.cert` +field. + +The csproj has a conditional `EmbeddedResource` entry, so the build works whether or not +the file is present (today: not present, until the prod root cert is generated — see +`docs/cert-playbook.md` section 1). At app startup, `CertificateManager` reads the resource +and adds the root to the in-memory `ICertificateStore`. + +The verifier uses that in-memory store as the `CustomTrustStore` for `X509Chain`, with +`TrustMode = X509ChainTrustMode.CustomRootTrust`, so the OS cert store is never consulted — +manifest verification is determined entirely by what's embedded. + +The dev path (`LOCAL_DEPLOY` builds only) additionally reads `../dev-trust.cer` relative to +the running exe — that's the dev root cert `deploy-local.ps1` generates fresh on each run. + +**Chain validation is NOT YET implemented.** Today's `SignatureVerifier` does a direct +fingerprint check against `ICertificateStore`, which assumes the cert in `signature.cert` +is itself trusted. Extending it to `X509Chain` validation against a root-only trust set +is ~30-50 LoC of verifier change plus tests — listed as required work for the migration +release. + +--- + +## External updater hardening (NOT YET IMPLEMENTED) + +**Scope: one principle.** Before launching `AnakinRaW.ExternalUpdater.exe`, the main app +must verify the on-disk updater bytes match the SHA-256 the signature-verified installed +manifest declares for it. That is the whole section. Everything else about the launch — +what arguments are passed, what manifest authorizes the update, what staged blobs feed into +it, how the deferred path resumes — is owned by *Deferred update resumption*. + +This single check is sufficient because we already have a signed-manifest trust chain +anchored at the embedded root. The manifest declares the updater's hash as a component; +bytes that match that declaration are, by definition, the bytes the root's chain signed off +on. An Authenticode signature on the updater with `WinVerifyTrust` at launch would close +the same threat at the cost of a second trust mechanism and a parallel signing +infrastructure — strictly redundant under the current design. + +The main app itself doesn't need a cert-based check either. Its integrity comes from the +same hash-chain — the updater installs main-app bytes whose SHA-256 matches the signed +manifest's declaration and then re-launches the freshly-installed binary from a file +handle it already holds. The updater is constrained by design to "apply file moves from +`--updatePayload`" and "restart the supplied `--appToStart`" — no arbitrary-target launch +capability — so subverting it into running other code is out of scope for this section: +its threat surface is bounded by those two operations, both hash- or signature-anchored. + +### Current state + +No hash check happens before the updater is launched. An attacker with write access to the +install directory can swap `AnakinRaW.ExternalUpdater.exe` between install time and the +next launch, and the main app executes it with the user's privileges. + +### Fix + +Before `Process.Start` on the updater: + +1. Open the on-disk updater `FileShare.Read`-only. +2. SHA-256 the file from the open handle; compare to the hash the signature-verified + installed manifest declares for the updater component. Mismatch → abort with a clear + error. +3. `CreateProcess` from the verified handle so the OS resolves the executable from the + same handle, closing the TOCTOU between hash and launch. + +The release pipeline needs no code-signing step for the updater binary. The updater's +integrity is fully described by the manifest's declared hash, which the existing CI +manifest-signing step already covers via the chain. + +### Migration sequencing (single release via Costura + extract) + +Everything resolves in **one release**. The cross-generation handoff for the updater +binary happens automatically via the framework's existing Costura embedding + +startup-extraction pattern, not via the manifest's install flow. + +How the pattern works (verified in ModdingToolBase): + +- `ModVerify.CliApp.csproj`'s net481 build references `ExternalUpdater.App.csproj`. + Costura.Fody packs the resulting `AnakinRaW.ExternalUpdater.exe` into `ModVerify.exe` + as an embedded resource. +- At every startup, `CosturaApplicationProductService.CreateExternalUpdaterComponent()` + compares the embedded updater's version against the on-disk updater's version and writes + the embedded copy to disk **iff** it's newer (the `streamVersion > installedVersion` + comparator in `CosturaApplicationProductService`). + +Migration release install flow: + +> Old deployed ModVerify reads R1 manifest → downloads R1 files → tries to replace +> `ModVerify.exe` → in use → delegates to the OLD updater (current deployed binary, OLD +> CLI). The OLD updater performs its file moves and exits. NEW ModVerify launches → its +> startup-extraction sees the embedded updater (NEW) is newer than the on-disk one (OLD) +> → writes the new updater to disk. From this point on every update runs through the NEW +> updater with the NEW CLI. + +The NEW updater is **never invoked by an OLD main app**, because the OLD main app is +replaced in the very same install that places the NEW updater inside the new +`ModVerify.exe`. The NEW updater therefore does not need to accept the OLD CLI. Clean +break, with no compat shim and no second release. + +The manifest's `updater` component is advisory under this scheme — what's load-bearing is +what's embedded in `ModVerify.exe`. Whether the OLD updater succeeds or fails at the +manifest-listed updater-component install step is immaterial; the Costura + extract path +produces the correct end-state either way. + +The pattern applies to any ModdingToolBase consumer that uses Costura embedding, not just +ModVerify. + +The update execution pipeline is the **same** for the immediate path and the deferred path — +the only difference is *when* it runs. Both reconstruct the install plan entirely from +authenticated on-disk state, and never trust mutable registry contents to describe what to +install. The deferred path is therefore not a separate code path — it's the same pipeline +invoked at next launch instead of immediately. "Resume" applies to both. + +### Why the current registry handoff is broken + +Today's `HKCU` entries (`UpdaterPath`, `UpdateCommandArgs`, `RequiresUpdate`) describe the +deferred update as "run this exe with these args" — all three fields are user-writable. +An attacker with `HKCU` write controls what the app executes next launch, no file tampering +needed. Authenticated on-disk state has to do that work instead. + +### On-disk state is the source of truth + +- **Pending manifest** — the signed manifest at e.g. + `%LocalAppData%/ModVerify/pending-update/manifest.json`. Self-authenticating via its + `signature` block, so the user-writable location is fine. +- **Staged download repository** — per-component blobs the download manager already + hash-verified at fetch time; re-hashed when the pipeline runs. +- **Installed manifest** — signature-verified at install time; used to derive the updater + binary path the pipeline launches (subject to Authenticode verification per *External + updater hardening*). +- **`highest-installed-version`** — written next to the installed manifest on every + successful install; consulted by step 2 for rollback rejection. + +Registry carries only what's needed to *find* the on-disk state: + +- `RequiresUpdate` — bool, the resume gate. +- `PendingManifestPath` — full path to the pending manifest on disk. +- `Branch` — branch name (e.g. `stable`), so the framework can resolve the matching staged + download repository and re-download via the right mirror if step 3 finds gaps. + +Principle: registry tells the framework what to *find*, never what to *execute*. An attacker +rewriting `PendingManifestPath` or `Branch` either points at nothing (→ fall through to a +normal launch, same as today's behavior on missing/invalid pending state) or at some other +signed manifest (→ still has to verify, and rollback rejection in step 2 catches +older-but-signed substitution). No `UpdaterPath`, no command-line args. + +### Execution pipeline + +Runs immediately after a fresh download completes, OR on startup with `RequiresUpdate=1`. +Steps are identical: + +1. **Resolve and verify the pending manifest.** In the deferred case: read + `PendingManifestPath` from registry. If the file or the staged download location for + `Branch` is missing, treat exactly like today's behavior for an inconsistent pending + state — clear the registry keys, continue with a normal launch. In the immediate case: + the manifest path is in-process. Either way, verify via the same + `ManifestLoaderBase.LoadAndVerifyManifest` chain used for CDN manifests; signature + failure aborts. +2. **Reject rollback.** Compare the pending manifest's `version` to + `highest-installed-version`. Refuse anything strictly older. Closes intentional + downgrades, stale-mirror replay, and substitution of the pending manifest with an + older-but-still-signed one. The "user reinstalls from GitHub" recovery path is preserved + because the reinstall resets this state. +3. **Re-hash staged blobs** against the verified manifest. Missing or mismatched blobs are + treated as "not staged" — gaps to fill in step 4, not errors. +4. **Compute install diff.** Per component: already installed at the right hash → skip; + staged-and-verified → include in payload; missing/corrupted → re-download via the normal + download manager against the manifest's `originInfo`. +5. **Build the updater payload.** Always `--updatePayload` (base64 JSON on the command + line), never `--updateFile` — the command line travels at the same trust level as the + launch itself; no separate tempfile to TOCTOU. Payload carries per-source-file + `{ file, destination, sha256 }`. The updater re-hashes each source before moving it; any + mismatch aborts the entire batch with backup restore (no partial application). The + updater also self-checks: refuses to run if its working directory isn't a recognized + install path. +6. **Hand off to the external updater.** Updater binary path is derived from the *installed* + signature-verified manifest (`ExternalUpdaterService.GetExternalUpdater`) — never from + registry. Updater bytes are hash-verified against the manifest's declared SHA-256 (via a + file handle) and `CreateProcess` runs from that handle. See *External updater hardening*. +7. **On success**, write the new `highest-installed-version`, delete the pending manifest, + clear registry keys, optionally prune staged blobs. On updater failure, leave state for + next-launch retry. + +Failures in 1-3 wipe pending state and fall through to a normal launch — user pays a +re-download, never an unsafe install. The only step skipped by an immediate-path invocation +relative to a deferred resume is the disk-read of the manifest (already in memory from the +fresh fetch); verification still runs. + +### Cert-rotation interaction + +If the trust store rotated (A → B) between defer and resume and the pending manifest was +signed by A: verification still succeeds while A remains trusted (during the transition +window described under *Cert rotation runbook*). Once A is dropped from the embedded set, +step 1 fails cleanly and resume falls through — same outcome as the "too old to auto-update" +path under *Trust bootstrap and rotation*. + +--- + +## Migration from unsigned to signed updates (NOT YET IMPLEMENTED) + +Deployed clients don't verify manifests and don't carry a trust cert. We can't retroactively +secure them. Goal: guarantee the next update any old client performs lands it on a known-good +signed build, protected from then on. + +### The release being developed *is* the migration release + +There is no separate "v1" path today — deployed clients fetch from a single existing path: + +``` +https://republicatwar.com/downloads/ModVerify//manifest.json +``` + +The release we're currently developing — the one that introduces signing — publishes to +**that existing path**, exactly like every prior release. It is the *last* release +published there. The path stays frozen from then on. + +What this release brings together: + +1. Embedded **root trust cert** (`Resources/Certs/modverify-trust.cer`). +2. Manifest signing wired through `release.yml` (CI signs with the first intermediate). +3. Chain-validation `SignatureVerifier` change in the framework. +4. **Hash-check on updater launch** — main-app side; verifies the on-disk + `AnakinRaW.ExternalUpdater.exe` against the signed manifest's declared SHA-256 before + `Process.Start`, then `CreateProcess` from the verified handle. +5. **New external updater binary** with hardened CLI (`--updatePayload`, per-source-file + hashes, working-directory self-check). Travels embedded inside `ModVerify.exe` via + Costura; gets written to disk on first launch by the framework's existing + extract-and-replace path. See *External updater hardening* → *Migration sequencing + (single release via Costura + extract)*. +6. Compile-time mirror URL pointing at a **new** path, e.g. + `downloads/ModVerify/v2//manifest.json`. + +The migration release's manifest must remain parseable by the currently-deployed (unsigned) +deserializer. `System.Text.Json` ignores unknown properties by default, so the new +`signature` block is expected to be tolerated by old clients — verify against the actually- +deployed framework version before relying on this. If the deployed parser is strict, strip +the signature from the manifest copy uploaded to the existing path. + +### Cross-generation handoff via Costura + extract + +The migration release ships the **new** `AnakinRaW.ExternalUpdater.exe` embedded inside +`ModVerify.exe` (Costura.Fody). It does *not* rely on the OLD updater installing itself. + +The deployed (old) ModVerify is what runs the install of the migration release: + +> Old ModVerify writes new files to staging → tries to replace `ModVerify.exe` → fails +> (process in use) → delegates to `AnakinRaW.ExternalUpdater.exe` using the **old** CLI. +> The OLD updater replaces `ModVerify.exe`, exits. + +That's the OLD updater's whole job. It does not need to install the NEW updater binary — +the new updater arrives embedded inside the new `ModVerify.exe`. After the OLD updater +exits, the new ModVerify launches; `CosturaApplicationProductService` extracts the +embedded NEW updater on startup and writes it over the on-disk OLD updater (which is no +longer running). From the next update onward, the NEW updater is on disk and the NEW CLI +is what's used. + +This collapses what would otherwise be a multi-release migration into a single release. +The build pipeline produces it the same way it produces any release — the csproj +references the new `ExternalUpdater.App` project, Costura packs it, +`ApplicationManifestCreator` writes a manifest from the staged outputs. **No JSON +hand-crafting.** + +The manifest's `updater` component listing is advisory; what's load-bearing is the +embedded updater inside `ModVerify.exe`. Whether the OLD updater succeeds or fails at the +manifest-listed updater-component install step is immaterial — Costura + extract produces +the same end-state regardless. + +This applies to any ModdingToolBase-based app that uses the Costura+extract pattern, not +just ModVerify. + +### After the migration release ships + +- **Old clients** check the existing path, see the migration release as their next update, + install it. Bytes hash-verified against the manifest (no signature verification client- + side — same trust model they've always had). Once installed, the new build's mirror URL + is /v2/; every subsequent check happens there, signature-verified end to end. +- **Migration-release clients** (and later builds) check /v2/. Until the *next* release + publishes there, the check legitimately returns "no update available." Not an error. +- The existing path is **never overwritten** after the migration release. Static blob; costs + nothing to host indefinitely; remains the upgrade ramp for any old client that comes + online months or years later. + +### No dual upload + +The migration release goes to the existing path. The first post-migration release goes to +/v2/. There is no moment when the same manifest is published to both paths. + +### Release pipeline change (`release.yml`) + +For the migration release itself: no upload-path change required — same target as today, +plus the new sign-manifest step. For the **first** post-migration release: change +`ORIGIN_BASE` and the SFTP `base_path` to point at the new /v2/ subpath. The existing upload +target is decommissioned at that point. + +### Risks and edge cases + +- **Pre-migration clients with the migration release pending as a deferred update at + cutover.** They resume into the migration release, install it, and on their first /v2/ + check land on the signed channel. No special handling. +- **Transition window threat surface** is unchanged from today: pre-migration clients on + the unsigned path remain unsigned until they update. Population shrinks as users migrate. +- **Future protocol breaks** reuse the same pattern: a future migration release introduces + /v3/, freezes /v2/ at that point, etc. Each frozen path is a static JSON file. + +--- + +## Operations + +### Cert / key recipes → `docs/cert-playbook.md` + +All cert generation, intermediate issuance, annual test, and root rotation procedures live +in `docs/cert-playbook.md`. The playbook is the step-by-step source; this doc owns the +design rationale. Cross-references: + +| Operation | Playbook section | +|---|---| +| Generate the root cert (one-time, when ready to ship the first signed release) | §1 | +| Issue a new intermediate (every ~9-10 months, or on CI compromise) | §2 | +| Local-deploy dev certs | §3 | +| Annual root test ceremony | §4 | +| Catastrophic root rotation | §5 | +| CI intermediate compromise response | §6 | + +GitHub Secrets the CI pipeline reads: + +| Secret | Value | +|---|---| +| `UPDATER_SIGNING_PFX_B64` | Base64 of the current intermediate PFX (rotated per §2) | +| `UPDATER_SIGNING_PFX_PASSWORD` | Passphrase for the current intermediate PFX | +| `SFTP_USER` / `SFTP_PASSWORD` | (Until migration release; see migration section) | + +The root PFX is **never** put into a GitHub Secret. It lives offline only. + +### Cert rotation runbook + +Trust hierarchy: embedded **root** (offline private key, 20-year cert) → **intermediate** +(in-CI private key, 1-year cert) → manifest signature. Rotation is split into two +fundamentally different operations: + +#### Routine intermediate rotation (every ~9-10 months, or on CI compromise) + +Done entirely offline. **No client coordination needed.** Procedure detailed in +`docs/cert-playbook.md` section 2; the summary: + +1. Offline ceremony: load root, generate a new intermediate keypair, sign with root, + export to PFX. +2. Push the new intermediate PFX (base64) and passphrase to GitHub Secrets. +3. Lock the root away. CI signs the next release with the new intermediate. + +Every client — including those dormant for years — verifies the next release cleanly, +because the embedded root is unchanged and the new intermediate chains to it. The +previous intermediate is now retired; manifests it signed remain verifiable until its +`notAfter` passes (auto-expiry — see *Three-layer integrity*). + +Pick the intermediate lifetime comfortably greater than your worst-case release gap. The +latest manifest on the server must still be signed by an unexpired intermediate when the +next dormant user shows up to check. 1 year is the default; lengthen if release cadence is +slower. + +#### Root rotation (catastrophic recovery only) + +Used only when the root key is lost, compromised, or within the final year of its +20-year validity. Auto-update is **dead** for every deployed client until they manually +reinstall — this is the price of not maintaining a writable on-disk trust store. + +Procedure detailed in `docs/cert-playbook.md` section 5. The trade-off is intentional: +routine rotations cost nothing (offline ceremony + push to Secrets); the rare +catastrophic event costs everyone a manual reinstall. + +#### Annual test ceremony + +Once per year, dry-run the offline root: load it, sign a throwaway cert, confirm it +chains to the embedded trust cert, discard. See `docs/cert-playbook.md` section 4. +Confirms the root key custody is intact *before* a real incident forces the discovery. + +### Local-deploy notes + +`deploy-local.ps1` is independent of the prod cert. It: + +- Generates `dev-signing.pfx` + `dev-trust.cer` fresh in `.local_deploy/` per run (in-memory, + never touches the Windows cert store). +- Builds the app twice (installed and server versions) with `/p:LocalDeploy=true` so the + `LOCAL_DEPLOY` MSBuild symbol is defined; that compiles in the dev-cert lookup path. +- Signs the local manifest with the dev pfx. +- Stages everything under `.local_deploy/` (gitignored). + +The dev cert never collides with the prod cert. The `LOCAL_DEPLOY` symbol is off by default; +Release builds shipped to users carry no dev-cert lookup code. + +### CI gates + +- `release.yml` `deploy` job's first step verifies `src/ModVerify.CliApp/Resources/Certs/modverify-trust.cer`: + must exist (else: fail with "generate the prod cert per docs/update-signing.md"), must parse + as X.509, must not carry a private key. Catches the PFX-instead-of-CER mistake before any + artifact reaches a user. +- The `pack` job and PRs are not gated, so development continues even before the prod cert is + in place. + +--- + +## Out of scope + +- Replay/downgrade beyond the rollback-rejection step in updater hardening. +- RFC 3161 timestamps / counter-signatures. +- CRL/OCSP. Recovery from a compromised key is a planned rotation (above), not online + revocation. +- A real CA-issued code-signing cert. Possibly future, for SmartScreen reputation. Until then, + self-signed + manifest-anchored trust chain is the design. +- Hardening against attackers with write access to `%ProgramFiles%` itself (OS-level integrity).