publish: carry the Windows product into the projection

The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross.

Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
This commit is contained in:
Fimeg 2026-09-09 12:47:34 -04:00
commit 765ec4188f
35 changed files with 1425 additions and 379 deletions

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"text/template"
@ -77,6 +78,96 @@ func TestInstallTemplateRenders(t *testing.T) {
}
}
func TestWindowsInstallerVerifiesBeforeMutation(t *testing.T) {
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/windows.ps1.tmpl")
if err != nil {
t.Fatal(err)
}
source := string(raw)
// Comments in the template quote the very patterns asserted below, so
// occurrence counts are taken against the code alone.
var codeOnly strings.Builder
for _, line := range strings.Split(source, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
codeOnly.WriteString(line)
codeOnly.WriteString("\n")
}
}
code := codeOnly.String()
// Anchors ignore leading indentation: the block below the staging try is
// indented, and control-flow order is what this test is about, not columns.
at := func(pattern string) int {
loc := regexp.MustCompile(`(?m)^[ \t]*` + pattern).FindStringIndex(source)
if loc == nil {
return -1
}
return loc[0]
}
ordered := []struct {
name string
pattern string
}{
{"verifier preflight", `Assert-Ed25519Verifier\r?$`},
{"manifest signature", `\$ok = Test-Ed25519Signature`},
{"binary signature", `\$verified = Test-Ed25519Signature`},
{"config ACL repair", `Repair-AgentConfigAccessIfNeeded -ConfigFilePath`},
{"service stop", `Stop-Service -Name`},
{"binary replacement", `Move-Item -Path \$TmpBinary`},
}
prev := -1
for _, step := range ordered {
got := at(step.pattern)
if got < 0 {
t.Fatalf("missing installer step %q (pattern %s)", step.name, step.pattern)
}
if got <= prev {
t.Fatalf("installer step %q runs too early: verification must precede config, service, and binary changes", step.name)
}
prev = got
}
// Fail-open verifier paths. The .NET Ed25519 probe used to return $null
// when no verifier existed and the installer carried on with a warning.
for _, forbidden := range []string{"return $null", "signature not checked", "best-effort (BouncyCastle)", "$null -eq $verified", "$null -eq $ok"} {
if strings.Contains(source, forbidden) {
t.Errorf("Windows installer contains fail-open verifier path: %s", forbidden)
}
}
// $ErrorActionPreference is Stop, so a bare Write-Error is terminating: it
// drops every diagnostic line after the first, skips the exit below it, and
// inside the desktop try/catch it was caught and downgraded to a skip
// warning that let the installer exit 0 on a manifest hash mismatch.
// Every refusal goes through Stop-Install instead.
if n := strings.Count(code, "Write-Error"); n != 1 {
t.Errorf("expected exactly one Write-Error (inside Stop-Install), found %d; refusals must use Stop-Install", n)
}
if !strings.Contains(code, "Write-Error $Line -ErrorAction Continue") {
t.Error("Stop-Install must report non-terminatingly so every diagnostic line reaches the operator")
}
if !regexp.MustCompile(`(?m)^\s*exit 1\s*$`).MatchString(source[strings.Index(source, "function Stop-Install"):]) {
t.Error("Stop-Install must exit deterministically")
}
// Cleanup must not replace the failure that brought us here.
if strings.Contains(code, "Remove-Item -LiteralPath $StagingDir") ||
strings.Contains(code, "Remove-Item -LiteralPath $Scratch") {
t.Error("staging cleanup must go through Remove-InstallerScratchDirectory so it cannot mask the primary failure")
}
if n := strings.Count(code, "Remove-InstallerScratchDirectory -Path"); n != 3 {
t.Errorf("expected every scratch removal to use the non-masking helper, found %d", n)
}
// Downloads live inside the staging directory the finally already removes;
// a redundant Remove-Item there can throw and mask a tampering refusal.
for _, dead := range []string{"Remove-Item $TmpBinary", "Remove-Item $TmpDesktop"} {
if strings.Contains(code, dead) {
t.Errorf("redundant cleanup %q can throw and mask the refusal it follows", dead)
}
}
}
// TestFreshInstallConfigKeys verifies the install template's default JSON
// includes the config keys the agent expects at minimum. This catches drift
// where a new config struct field gets added but the template never writes it

View file

@ -13,6 +13,13 @@ if [ "$EUID" -ne 0 ]; then
exit 1
fi
# The installed helper uses sudo too. Check this before creating users or
# touching an existing installation; minimal root shells may not provide it.
if ! command -v sudo >/dev/null 2>&1; then
echo "ERROR: sudo is required by the RedFlag helper. Install sudo from your distribution, then rerun this installer." >&2
exit 1
fi
# Variables
AGENT_ID="{{.AgentID}}"
AGENT_USER="redflag-agent"

View file

@ -15,6 +15,7 @@ $RegistrationToken = if ($env:RF_TOKEN) { $env:RF_TOKEN } else { "{{.Registra
$ServerUrl = if ($env:RF_SERVER) { $env:RF_SERVER } else { "{{.ServerURL}}" }
$ServerUrl = $ServerUrl.TrimEnd("/")
$SkipServiceInstall = [bool]$env:RF_SKIP_SERVICE_INSTALL
$ErrorActionPreference = "Stop"
function Convert-HexToBytes {
param([Parameter(Mandatory=$true)][string]$Hex)
@ -31,6 +32,44 @@ function Convert-HexToBytes {
return ,$Bytes
}
# $ErrorActionPreference is Stop, which makes Write-Error terminating: only the
# first diagnostic line would reach the operator, the exit below it would never
# run, and inside a try/catch a refusal would be downgraded to a caught warning.
# Emit every line non-terminatingly, then exit. exit is not catchable, so a
# refusal raised here cannot be swallowed by an enclosing catch.
function Stop-Install {
param([Parameter(Mandatory=$true)][string[]]$Message)
foreach ($Line in $Message) {
Write-Error $Line -ErrorAction Continue
}
exit 1
}
# Cleanup must never replace the failure that brought us here. On Windows a
# scanner routinely holds a transient handle on a freshly downloaded executable,
# so removal can fail on exactly the paths where the real error matters most.
function Remove-InstallerScratchDirectory {
param([Parameter(Mandatory=$true)][string]$Path)
try {
Remove-Item -LiteralPath $Path -Recurse -Force
} catch {
Write-Warning "Could not remove installer staging directory ${Path}: $($_.Exception.Message)"
}
}
function New-InstallerScratchDirectory {
$Scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("redflag-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $Scratch -ErrorAction Stop | Out-Null
& icacls $Scratch /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Remove-InstallerScratchDirectory -Path $Scratch
throw "Failed to protect installer staging directory"
}
return $Scratch
}
function Test-Ed25519Signature {
param(
[Parameter(Mandatory=$true)][byte[]]$Data,
@ -45,44 +84,49 @@ function Test-Ed25519Signature {
throw "invalid Ed25519 signature length"
}
try {
$null = [System.Security.Cryptography.Ed25519]
} catch {
return $null
if (-not $script:Ed25519OpenSSL) {
throw "OpenSSL 3 with Ed25519 support must be installed by the operator and available on PATH"
}
$Scratch = New-InstallerScratchDirectory
try {
$Ok = [System.Security.Cryptography.Ed25519]::VerifyData($Data, $Signature, $PublicKey)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
# RFC 8410 SubjectPublicKeyInfo for a raw 32-byte Ed25519 public key.
[byte[]]$DER = (Convert-HexToBytes "302a300506032b6570032100") + $PublicKey
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "key.der"), $DER)
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "signature.bin"), $Signature)
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "message.bin"), $Data)
# Use the native process exit code, not localized output or a missing
# .NET type. No downloaded executable participates in its own trust.
$Start = New-Object System.Diagnostics.ProcessStartInfo
$Start.FileName = $script:Ed25519OpenSSL
$Start.WorkingDirectory = $Scratch
$Start.Arguments = "pkeyutl -verify -rawin -pubin -keyform DER -inkey key.der -in message.bin -sigfile signature.bin"
$Start.UseShellExecute = $false
$Start.CreateNoWindow = $true
$Process = [System.Diagnostics.Process]::Start($Start)
try {
$Process.WaitForExit()
return ($Process.ExitCode -eq 0)
} finally {
$Process.Dispose()
}
} finally {
Remove-InstallerScratchDirectory -Path $Scratch
}
}
try {
$Verifier = [System.Security.Cryptography.Ed25519]::Create($PublicKey)
$Ok = $Verifier.VerifyData($Data, $Signature)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
}
function Assert-Ed25519Verifier {
$Verifier = Get-Command openssl -CommandType Application -ErrorAction Stop | Select-Object -First 1
$script:Ed25519OpenSSL = $Verifier.Source
# RFC 8032 section 7.1, test 2. Prove both acceptance and rejection before
# touching an installed agent; a version string does not prove a provider.
$Public = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
$Signature = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
if (-not (Test-Ed25519Signature -Data ([byte[]]@(0x72)) -Signature $Signature -PublicKey $Public)) {
throw "OpenSSL cannot verify Ed25519 signatures"
}
try {
$Ok = [System.Security.Cryptography.Ed25519]::Verify($Signature, $Data, $PublicKey)
return [bool]$Ok
} catch [System.Management.Automation.MethodException] {
} catch [System.Management.Automation.RuntimeException] {
if ($_.Exception.Message -notmatch "method|overload|argument") {
throw
}
if (Test-Ed25519Signature -Data ([byte[]]@(0x73)) -Signature $Signature -PublicKey $Public) {
throw "Ed25519 verifier accepted tampered data"
}
return $null
}
function Set-AgentConfigPermissions {
@ -99,14 +143,12 @@ function Set-AgentConfigPermissions {
# S-1-5-32-544 BUILTIN\Administrators
& icacls $ConfigDirectoryPath /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-19:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to set ACL on $ConfigDirectoryPath"
exit 1
Stop-Install "Failed to set ACL on $ConfigDirectoryPath"
}
& icacls $ConfigFilePath /inheritance:r /grant:r "*S-1-5-18:F" "*S-1-5-19:F" "*S-1-5-32-544:F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to set ACL on $ConfigFilePath"
exit 1
Stop-Install "Failed to set ACL on $ConfigFilePath"
}
}
@ -205,9 +247,18 @@ function Ensure-LocalApiGroup {
# Runtime admin check for better error messaging
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Error "This installer must be run as Administrator."
Write-Error "Right-click PowerShell and select 'Run as Administrator', then retry."
exit 1
Stop-Install @(
"This installer must be run as Administrator.",
"Right-click PowerShell and select 'Run as Administrator', then retry."
)
}
# Prove the Ed25519 verifier accepts and rejects before anything is downloaded
# or installed. A missing or broken verifier stops the install here, untouched.
try {
Assert-Ed25519Verifier
} catch {
Stop-Install "A working operator-installed OpenSSL 3 Ed25519 verifier is required. No agent files or services were changed. Detail: $($_.Exception.Message)"
}
# Detect Windows architecture
@ -216,8 +267,7 @@ switch ($Arch) {
"AMD64" { $ArchTag = "amd64" }
"ARM64" { $ArchTag = "arm64" }
default {
Write-Error "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
exit 1
Stop-Install "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
}
}
@ -241,270 +291,256 @@ Write-Host "Platform: {{.Platform}}"
Write-Host "Installing to: $InstallDir\redflag-agent.exe"
Write-Host
# Step 0: Detect existing installation and migration requirements
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
$MigrationNeeded = $false
$CurrentVersion = "unknown"
$ConfigVersion = "0"
$ConfigPath = Join-Path $AgentConfigDir "config.json"
$OldConfigPath = Join-Path $OldConfigDir "config.json"
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Check for existing installation in new location
if (Test-Path $ConfigPath) {
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
try {
$Config = Get-Content $ConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} elseif (Test-Path $OldConfigPath) {
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
$MigrationNeeded = $true
try {
$Config = Get-Content $OldConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} else {
Write-Host "✓ Fresh installation" -ForegroundColor Green
}
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Check if config version indicates migration is needed
try {
if ([int]$ConfigVersion -lt 4) {
$MigrationNeeded = $true
Write-Host "⚠ Config version $ConfigVersion < v4 - migration required" -ForegroundColor Yellow
}
} catch {
# Config version not a valid number
}
# Check for missing security features
if (Test-Path $ConfigPath) {
$ConfigContent = Get-Content $ConfigPath -Raw
if ($ConfigContent -notmatch "nonce_validation") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: nonce_validation" -ForegroundColor Yellow
}
if ($ConfigContent -notmatch "machine_id") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
}
}
}
# Handle migration if needed
if ($MigrationNeeded) {
Write-Host
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
# Create backup directory
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
# Backup old configuration if it exists
if (Test-Path $OldConfigPath) {
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
Copy-Item $OldConfigPath $BackupDir -ErrorAction SilentlyContinue
}
# Backup current configuration if we're upgrading
if (Test-Path $ConfigPath) {
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction SilentlyContinue
}
Write-Host "Migration will run automatically when agent starts."
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
Write-Host
}
# Step 1: Stop existing service if running
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($Service -and $Service.Status -eq 'Running') {
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force
Start-Sleep -Seconds 2
}
# Step 2: Create directories
Write-Host "Creating directories..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
Ensure-LocalApiGroup -GroupName $LocalApiGroup
# Step 3: Download agent binary
# Verify the download before changing an installed agent.
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
$TmpBinary = Join-Path $env:TEMP "redflag-agent-download.exe"
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
# --- Cold-start trust: verify the binary against the signed release manifest ---
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
# platform/arch and is Ed25519-signed. The hash pin is mandatory and fail-closed;
# the manifest signature is verified where an Ed25519 verifier exists. NOTE: .NET
# ships no native Ed25519, so on Windows the signature is best-effort (BouncyCastle)
# while the hash pin is always enforced — full signature parity with Linux lands
# when the Windows farm is reachable.
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
$ServerPubKey = "{{.ServerPublicKey}}"
$StagingDir = New-InstallerScratchDirectory
$TmpBinary = Join-Path $StagingDir "redflag-agent.exe"
try {
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
} catch {
$ManifestStatus = $null
$ManifestErrorBody = $null
$Resp = $_.Exception.Response
if ($Resp) {
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
try {
$Stream = $Resp.GetResponseStream()
if ($Stream) {
$Reader = New-Object System.IO.StreamReader($Stream)
$ManifestErrorBody = $Reader.ReadToEnd()
$Reader.Close()
}
} catch {}
}
if ($ManifestStatus) {
Write-Error "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
} else {
Write-Error "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
}
if ($ManifestErrorBody) {
Write-Error "Manifest response: $ManifestErrorBody"
}
Remove-Item $TmpBinary -Force
exit 1
}
$ManifestBody = $ManifestResp.Content
$ManifestSig = $null
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
}
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
if (-not ($ManifestSig -and $ServerPubKey)) {
Write-Error "Release manifest is unsigned or no server key embedded - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
# Defense-in-depth: verify the manifest's Ed25519 signature if a verifier exists.
try {
$pk = Convert-HexToBytes $ServerPubKey
$sg = Convert-HexToBytes $ManifestSig
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
} catch {
Write-Error "Release manifest signature or public key is malformed - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
if ($null -eq $ok) {
Write-Host "WARNING: No native Ed25519 verifier - manifest signature not checked (hash pin still enforced)." -ForegroundColor Yellow
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
} elseif (-not $ok) {
Write-Error "Release manifest signature invalid - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
} else {
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
}
# Mandatory hash pin: the downloaded binary must match its manifest entry.
$Manifest = $ManifestBody | ConvertFrom-Json
$ExpectedHash = $null
foreach ($a in $Manifest.artifacts) {
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
$ExpectedHash = $a.sha256.ToLower()
break
}
}
if (-not $ExpectedHash) {
Write-Error "No manifest entry for windows/$ArchTag - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualManifestHash -ne $ExpectedHash) {
Write-Error "Binary hash does not match the signed manifest - possible tampering."
Write-Error " expected (signed manifest): $ExpectedHash"
Write-Error " actual (downloaded): $ActualManifestHash"
Remove-Item $TmpBinary -Force
exit 1
}
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
# --- end cold-start manifest verification ---
# Verify checksum if server provided one
$ExpectedChecksum = $null
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
}
if ($ExpectedChecksum) {
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedChecksum) {
Write-Error "Checksum verification failed"
Write-Error "Expected: $ExpectedChecksum"
Write-Error "Actual: $ActualHash"
Remove-Item $TmpBinary -Force
exit 1
}
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
} else {
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
}
# ISSUE-002: Verify binary signature before installation (TOFU model)
$ExpectedSignature = $null
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
}
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
# --- Cold-start trust: verify the binary against the signed release manifest ---
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
# platform/arch and is Ed25519-signed. Signature and hash checks are mandatory.
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
$ServerPubKey = "{{.ServerPublicKey}}"
try {
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
$sigBytes = Convert-HexToBytes $ExpectedSignature
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
if ($null -eq $verified) {
Write-Host "WARNING: No native Ed25519 verifier - binary signature not checked (manifest hash pin already enforced)." -ForegroundColor Yellow
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
} elseif ($verified) {
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
} else {
throw "Signature verification failed"
}
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
} catch {
Write-Error "Binary signature verification failed - possible tampering"
Write-Error "Detail: $($_.Exception.Message)"
Remove-Item $TmpBinary -Force
exit 1
$ManifestStatus = $null
$ManifestErrorBody = $null
$Resp = $_.Exception.Response
if ($Resp) {
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
try {
$Stream = $Resp.GetResponseStream()
if ($Stream) {
$Reader = New-Object System.IO.StreamReader($Stream)
$ManifestErrorBody = $Reader.ReadToEnd()
$Reader.Close()
}
} catch {}
}
$Reason = @()
if ($ManifestStatus) {
$Reason += "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
} else {
$Reason += "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
}
if ($ManifestErrorBody) {
$Reason += "Manifest response: $ManifestErrorBody"
}
Stop-Install $Reason
}
$ManifestBody = $ManifestResp.Content
$ManifestSig = $null
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
}
} else {
Write-Host "WARNING: Cannot verify signature - missing public key or signature" -ForegroundColor Yellow
Write-Host "This is a security risk. Ensure server is trusted." -ForegroundColor Yellow
}
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
if (-not ($ManifestSig -and $ServerPubKey)) {
Stop-Install "Release manifest is unsigned or no server key embedded - refusing to install."
}
# Establish the authority of the manifest before trusting any artifact hash.
try {
$pk = Convert-HexToBytes $ServerPubKey
$sg = Convert-HexToBytes $ManifestSig
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
} catch {
Stop-Install "Release manifest signature or public key is malformed - refusing to install."
}
if (-not $ok) {
Stop-Install "Release manifest signature invalid - refusing to install."
} else {
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
}
# Mandatory hash pin: the downloaded binary must match its manifest entry.
$Manifest = $ManifestBody | ConvertFrom-Json
$ExpectedHash = $null
foreach ($a in $Manifest.artifacts) {
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
$ExpectedHash = $a.sha256.ToLower()
break
}
}
if (-not $ExpectedHash) {
Stop-Install "No manifest entry for windows/$ArchTag - refusing to install."
}
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualManifestHash -ne $ExpectedHash) {
Stop-Install @(
"Binary hash does not match the signed manifest - possible tampering.",
" expected (signed manifest): $ExpectedHash",
" actual (downloaded): $ActualManifestHash"
)
}
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
# --- end cold-start manifest verification ---
# Verify checksum if server provided one
$ExpectedChecksum = $null
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
}
if ($ExpectedChecksum) {
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedChecksum) {
Stop-Install @(
"Checksum verification failed",
"Expected: $ExpectedChecksum",
"Actual: $ActualHash"
)
}
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
} else {
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
}
# ISSUE-002: Verify binary signature before installation (TOFU model)
$ExpectedSignature = $null
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
}
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
try {
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
$sigBytes = Convert-HexToBytes $ExpectedSignature
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
if ($verified) {
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
} else {
throw "Signature verification failed"
}
} catch {
Stop-Install @(
"Binary signature verification failed - possible tampering",
"Detail: $($_.Exception.Message)"
)
}
} else {
Stop-Install "Missing binary signature or server public key - refusing to install."
}
# Step 0: Detect existing installation and migration requirements
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
$MigrationNeeded = $false
$CurrentVersion = "unknown"
$ConfigVersion = "0"
$ConfigPath = Join-Path $AgentConfigDir "config.json"
$OldConfigPath = Join-Path $OldConfigDir "config.json"
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Check for existing installation in new location
if (Test-Path $ConfigPath) {
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
try {
$Config = Get-Content $ConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} elseif (Test-Path $OldConfigPath) {
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
$MigrationNeeded = $true
try {
$Config = Get-Content $OldConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} else {
Write-Host "✓ Fresh installation" -ForegroundColor Green
}
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Check if config version indicates migration is needed
try {
if ([int]$ConfigVersion -lt 4) {
$MigrationNeeded = $true
Write-Host "⚠ Config version $ConfigVersion < v4 - migration required" -ForegroundColor Yellow
}
} catch {
# Config version not a valid number
}
# Check for missing security features
if (Test-Path $ConfigPath) {
$ConfigContent = Get-Content $ConfigPath -Raw
if ($ConfigContent -notmatch "nonce_validation") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: nonce_validation" -ForegroundColor Yellow
}
if ($ConfigContent -notmatch "machine_id") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
}
}
}
# Handle migration if needed
if ($MigrationNeeded) {
Write-Host
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
# Create backup directory
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
# Backup old configuration if it exists
if (Test-Path $OldConfigPath) {
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
Copy-Item $OldConfigPath $BackupDir -ErrorAction Stop
}
# Backup current configuration if we're upgrading
if (Test-Path $ConfigPath) {
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction Stop
}
Write-Host "Migration will run automatically when agent starts."
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
Write-Host
}
# Step 1: Stop existing service if running
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($Service -and $Service.Status -eq 'Running') {
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force
Start-Sleep -Seconds 2
}
# Step 2: Create directories
Write-Host "Creating directories..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
Ensure-LocalApiGroup -GroupName $LocalApiGroup
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
} finally {
Remove-InstallerScratchDirectory -Path $StagingDir
}
# Step 4: Handle configuration
if (Test-Path $ConfigPath) {
@ -655,11 +691,11 @@ try {
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
if ($ManifestDesktop) {
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
Write-Error "Desktop binary hash does not match signed manifest — refusing to install."
Write-Error " expected: $($ManifestDesktop.sha256.ToLower())"
Write-Error " actual: $DesktopHash"
Remove-Item $TmpDesktop -Force
exit 1
Stop-Install @(
"Desktop binary hash does not match signed manifest — refusing to install.",
" expected: $($ManifestDesktop.sha256.ToLower())",
" actual: $DesktopHash"
)
}
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
} else {