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));