diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a45a5161..b22d36f6 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:
@@ -70,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
@@ -82,6 +107,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 +119,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/ModVerify.slnx b/ModVerify.slnx
index 917a44fd..d3cd519c 100644
--- a/ModVerify.slnx
+++ b/ModVerify.slnx
@@ -7,7 +7,9 @@
+
+
@@ -16,6 +18,11 @@
+
+
+
+
+
diff --git a/deploy-local.ps1 b/deploy-local.ps1
index 740c5791..f06b3d7e 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"
@@ -13,28 +24,77 @@ $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"
+
+$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
-dotnet build $toolProj --configuration Release -f net481 --output "$deployRoot\bin\tool" /p:DebugType=None /p:DebugSymbols=false
+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 /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 /p:LocalDeploy=true
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,20 +116,37 @@ 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"
-# 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 --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."
+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)
+}
diff --git a/docs/cert-playbook.md b/docs/cert-playbook.md
new file mode 100644
index 00000000..2f94a592
--- /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 00000000..2a798b89
--- /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).
diff --git a/modules/ModdingToolBase b/modules/ModdingToolBase
index 3901d1a8..0e97dc47 160000
--- a/modules/ModdingToolBase
+++ b/modules/ModdingToolBase
@@ -1 +1 @@
-Subproject commit 3901d1a899b8830ef691c06684b023a85f290b84
+Subproject commit 0e97dc475c42a4ebf084e4917b526e3dbee50b47
diff --git a/src/ModVerify.CliApp/ModVerify.CliApp.csproj b/src/ModVerify.CliApp/ModVerify.CliApp.csproj
index b1c97477..0ec2beb5 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/ModVerifyAppEnvironment.cs b/src/ModVerify.CliApp/ModVerifyAppEnvironment.cs
index 192d97b1..73f937a4 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
diff --git a/src/ModVerify.CliApp/Program.cs b/src/ModVerify.CliApp/Program.cs
index e5316f4f..ba1711c1 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;
diff --git a/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs b/src/ModVerify.CliApp/Updates/ModVerifyUpdater.cs
index af7d3697..1c7d699b 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 0550b340..231b92a4 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));