<# .SYNOPSIS Sign (or re-sign) manifests/apps.json and produce apps.json.sig. .DESCRIPTION Reads the private key from either: -KeyPath (a PEM file path, default .\keys\manifest-private.pem) or the env var MANIFEST_SIGNING_KEY (base64-encoded DER private key, used in CI) Produces manifests/apps.json.sig — a file containing the base64-encoded ed25519 signature of the raw apps.json bytes. This detached-signature approach means the JSON is never modified and there are no canonicalisation issues. .EXAMPLE # Local development .\scripts\sign-manifest.ps1 # CI (key stored as base64 DER in an env var) $env:MANIFEST_SIGNING_KEY = "" .\scripts\sign-manifest.ps1 #> param( [string]$KeyPath = ".\keys\manifest-private.pem", [string]$ManifestPath = ".\manifests\apps.json", [string]$SigPath = ".\manifests\apps.json.sig" ) $ErrorActionPreference = "Stop" # ── Locate OpenSSL (probes common Windows install paths if not on PATH) ─────── . "$PSScriptRoot\_openssl.ps1" # ── Resolve all paths to absolute using PowerShell's CWD ───────────────────── # [IO.File] uses .NET's Environment.CurrentDirectory, which differs from # PowerShell's $PWD when the session was started from another location. # GetUnresolvedProviderPathFromPSPath works even for paths that don't exist yet. $ManifestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ManifestPath) $SigPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SigPath) $KeyPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($KeyPath) # ── Resolve private key ────────────────────────────────────────────────────── $tempKeyFile = $null if ($env:MANIFEST_SIGNING_KEY) { Write-Host "Using MANIFEST_SIGNING_KEY from environment" -ForegroundColor Cyan # CI provides the PEM content as an env var $tempKeyFile = Join-Path $env:TEMP "psg-sign-key-$([System.Guid]::NewGuid()).pem" [IO.File]::WriteAllText($tempKeyFile, $env:MANIFEST_SIGNING_KEY) $resolvedKey = $tempKeyFile } elseif (Test-Path $KeyPath) { Write-Host "Using key file: $KeyPath" -ForegroundColor Cyan $resolvedKey = Resolve-Path $KeyPath } else { Write-Error "No signing key found.`nRun scripts/keygen.ps1 first, or set the MANIFEST_SIGNING_KEY env var." exit 1 } try { # ── Sign ───────────────────────────────────────────────────────────────── $tempSig = Join-Path $env:TEMP "psg-manifest-sig-$([System.Guid]::NewGuid()).bin" Write-Host "Signing $ManifestPath..." -ForegroundColor Yellow & openssl pkeyutl ` -sign ` -inkey $resolvedKey ` -rawin ` -in (Resolve-Path $ManifestPath) ` -out $tempSig if ($LASTEXITCODE -ne 0) { Write-Error "OpenSSL signing failed (exit $LASTEXITCODE)" exit 1 } # Base64-encode the raw signature bytes $sigBytes = [IO.File]::ReadAllBytes($tempSig) $sigB64 = [Convert]::ToBase64String($sigBytes) [IO.File]::WriteAllText($SigPath, $sigB64) Remove-Item $tempSig -Force Write-Host "Signature written to $SigPath" -ForegroundColor Green Write-Host "Sig (base64): $sigB64" -ForegroundColor Gray } finally { # Clean up temp key file if we created one if ($tempKeyFile -and (Test-Path $tempKeyFile)) { Remove-Item $tempKeyFile -Force } } # ── Verify round-trip ──────────────────────────────────────────────────────── Write-Host "`nVerifying round-trip..." -ForegroundColor Yellow $tempVerifySig = Join-Path $env:TEMP "psg-verify-sig-$([System.Guid]::NewGuid()).bin" $sigBytesBack = [Convert]::FromBase64String($sigB64) [IO.File]::WriteAllBytes($tempVerifySig, $sigBytesBack) # Use the public key for verification $pubKeyPath = $KeyPath -replace '-private\.pem$', '-public.pem' if (-not (Test-Path $pubKeyPath)) { Write-Warning "Public key not found at $pubKeyPath — skipping verify step" } else { & openssl pkeyutl ` -verify ` -inkey $pubKeyPath ` -pubin ` -rawin ` -in (Resolve-Path $ManifestPath) ` -sigfile $tempVerifySig if ($LASTEXITCODE -eq 0) { Write-Host "Round-trip verification passed ✓" -ForegroundColor Green } else { Write-Error "Round-trip verification FAILED — signature is corrupt!" } } Remove-Item $tempVerifySig -Force -ErrorAction SilentlyContinue