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:
parent
7c6ab1503e
commit
765ec4188f
35 changed files with 1425 additions and 379 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue