Watch
1
0
Fork
You've already forked RedFlag
0

feat: signed release manifest + fail-closed binary distribution

No unsigned binary path: build refuses when signing is disabled, downloads
return 404 when no signed package resolves. Signed release manifest endpoint,
installer verifies manifest signature and pins binary hash, Windows token
mandatory, Rust helper verify-binary.
This commit is contained in:
Fimeg 2026-05-29 10:47:41 -04:00
commit 1c18a6b55a
10 changed files with 492 additions and 7022 deletions

File diff suppressed because it is too large Load diff

View file

@ -102,8 +102,9 @@ curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-to
**Windows (PowerShell):**
```powershell
iwr https://your-server.com/install.ps1 | iex
& ([scriptblock]::Create((iwr -useb https://your-server.com/install.ps1).Content)) -Token your-registration-token
```
The token is required — the installer will not register without it. (`iwr ... | iex` cannot pass arguments; the scriptblock form is how PowerShell hands the token to the fetched script, the equivalent of Linux's `bash -s -- token`.)
**macOS (curl):**
```bash
@ -277,6 +278,34 @@ Remove-Item "C:\ProgramData\RedFlag\config.json"
5. Agent verifies signature + nonce + timestamp before execution
6. All updates have checksum verification + rollback on failure
### Binary Integrity: Closing the Cold-Start Gap
The update pipeline runs as root and executes downloaded binaries. That makes the
binary itself a trust boundary, not just the transport. RedFlag verifies the agent
binary at every stage of its life:
- **Install time (cold start).** The installer fetches a signed *release manifest*
one Ed25519-signed document listing the expected SHA-256 of every released binary
per platform/architecture, signed with the same key the agent trusts. Before it
executes anything, the installer verifies the manifest signature and confirms the
downloaded binary's hash matches the manifest entry. Any mismatch removes the
binary and aborts. This closes the gap where a first install would run an
unverified binary.
- **Upgrade time.** Self-upgrade verifies SHA-256 + Ed25519 signature before the
atomic binary swap, with rollback to the previous binary on failure.
- **Runtime.** A small, privileged, network-less Rust executor — separate from the Go
agent and outside its trust boundary — re-verifies the agent binary's hash. A
mismatch stops command issuance and surfaces in the Security Health panel alongside
nonce and machine-binding violations.
**The two-binary payoff.** To run unsigned code on a managed host, an attacker now has
to replace *both* the Go agent and the Rust executor — atomically, while the executor
is actively watching the agent, before the next check-in reaches the server. That is a
different class of problem than swapping a single binary: it takes local root with
precise timing, or a compromise of the build-and-signing infrastructure itself — which
the capability-token gate already addresses. The defense is layered on purpose, and the
layers are independent.
---
---
@ -384,7 +413,7 @@ Remove-Item "C:\Program Files\Aggregator\*" -Recurse -ErrorAction SilentlyContin
Remove-Item "C:\ProgramData\Aggregator\*" -Recurse -ErrorAction SilentlyContinue
# Then install new agent
iwr https://your-server.com/install.ps1 | iex
& ([scriptblock]::Create((iwr -useb https://your-server.com/install.ps1).Content)) -Token your-registration-token
```
### Full Fresh Install (Clean State)

View file

@ -36,6 +36,7 @@ const EXIT_REPLAY: i32 = 17;
const EXIT_UNSUPPORTED_OP: i32 = 18;
const EXIT_EXEC_FAILED: i32 = 19;
const EXIT_INTERNAL: i32 = 20;
const EXIT_INTEGRITY: i32 = 21; // agent-binary hash mismatch (watchdog mode)
// Default on-host locations. All overridable by env so packaging/tests can relocate.
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
@ -595,7 +596,85 @@ mod tests {
}
}
#[derive(Debug, Serialize)]
struct IntegrityResult {
target: String,
expected: String,
actual: String,
matched: bool,
timestamp: i64,
}
// verify-binary — runtime watchdog half of the two-binary model.
//
// Hashes the agent binary on disk and compares it to the expected SHA-256. This
// is network-less by design (same guarantee as the token executor): it emits a
// structured verdict to stdout and signals via exit code. The agent reports the
// hash to the server on check-in and reacts to a mismatch.
//
// SKETCH — the hash/compare/verdict is complete and safe to ship. The
// kill-agent-on-mismatch + final phone-home is intentionally NOT wired here:
// phoning home would break this process's network-less guarantee, so it belongs
// agent-side. The kill itself (SIGKILL to a passed --agent-pid) is privileged
// and local and could live here behind an --enforce flag. Settle that path
// against the network-less invariant before wiring it.
//
// Usage: redflag-helper verify-binary <target_path> <expected_sha256>
fn run_verify_binary(args: &[String]) -> i32 {
let (target, expected) = match (args.first(), args.get(1)) {
(Some(t), Some(e)) => (t.clone(), e.trim().to_lowercase()),
_ => {
log_error("verify-binary usage: redflag-helper verify-binary <target_path> <expected_sha256>");
return EXIT_BAD_TOKEN;
}
};
let actual = match compute_file_sha256(Path::new(&target)) {
Ok(h) => h.to_lowercase(),
Err(e) => {
log_error(&format!("integrity_hash_failed target={} error={}", target, e));
return EXIT_INTEGRITY;
}
};
let matched = actual == expected;
let result = IntegrityResult {
target: target.clone(),
expected: expected.clone(),
actual: actual.clone(),
matched,
timestamp: now_unix(),
};
match serde_json::to_string(&result) {
Ok(s) => println!("{}", s),
Err(e) => log_error(&format!("integrity_result_serialize_failed error={}", e)),
}
if matched {
log_security(&format!("integrity_ok target={} sha256={}", target, actual));
EXIT_OK
} else {
// TODO(watchdog): on mismatch, kill the agent (SIGKILL to --agent-pid,
// privileged + local) and have the agent make one final phone-home before
// going silent. Phone-home stays agent-side to keep this executor
// network-less. Not wired yet — verdict only.
log_security(&format!(
"integrity_mismatch target={} expected={} actual={}",
target, expected, actual
));
EXIT_INTEGRITY
}
}
fn main() {
// Subcommand dispatch. No subcommand = the token executor (reads a capability
// token from stdin), preserving the existing contract. "verify-binary" is the
// runtime watchdog mode.
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(|s| s.as_str()) == Some("verify-binary") {
std::process::exit(run_verify_binary(&args[2..]));
}
match run() {
Ok(result) => {
log_security(&format!("executed token_id={} package_type={}", result.token_id, result.package_type));

View file

@ -471,6 +471,11 @@ func main() {
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
// Signed release manifest — cold-start trust root the installer verifies
// before executing a freshly-downloaded binary. Own path (not under
// /downloads/) to avoid colliding with the /downloads/:platform param route.
api.GET("/manifest", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadManifest)
// Package artifact download (for hash computation at approval time)
api.GET("/downloads/artifact", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadPackageArtifact)

View file

@ -237,12 +237,6 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
return
}
// Build filename based on platform
filename := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
filename += ".exe"
}
var agentPath string
var signedPackage *models.AgentUpdatePackage // ISSUE-002: Track for signature header
@ -271,9 +265,15 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
}
}
// Fallback to unsigned generic binary
// No signed package found — serve nothing. Signing is mandatory.
if agentPath == "" {
agentPath = filepath.Join(h.agentDir, "binaries", platform, filename)
log.Printf("[WARNING] [server] [downloads] no_signed_package version=%s platform=%s", version, platform)
c.JSON(http.StatusNotFound, gin.H{
"error": "No signed binary available",
"platform": platform,
"version": version,
})
return
}
// Check if file exists and is not empty
@ -302,7 +302,7 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
c.Header("X-Content-Length", strconv.FormatInt(info.Size(), 10))
// ISSUE-002: Include signature header for signed packages
if version != "" && signedPackage != nil && signedPackage.Signature != "" {
if signedPackage.Signature != "" {
c.Header("X-Content-Signature", signedPackage.Signature)
}
@ -315,6 +315,106 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
c.File(agentPath)
}
// DownloadManifest serves the signed release manifest — the cold-start trust
// root. It lists the expected SHA-256 of every released binary for the given
// version (defaults to latest) and is signed with the server's Ed25519 key.
// The signature is over the verbatim response body and travels in
// X-Content-Signature; the key fingerprint is in X-Key-Id. The installer
// verifies the signature, then checks the downloaded binary against the matching
// entry before executing it.
func (h *DownloadHandler) DownloadManifest(c *gin.Context) {
version := c.Query("version")
if version == "" || version == "latest" {
version = serverVersion.AgentVersion
}
if h.signingService == nil || !h.signingService.IsEnabled() {
log.Printf("[ERROR] [server] [downloads] manifest_unavailable reason=signing_disabled version=%s", version)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "release manifest unavailable: signing disabled"})
return
}
manifest := h.buildReleaseManifest(version)
if len(manifest.Artifacts) == 0 {
log.Printf("[WARN] [server] [downloads] manifest_empty version=%s", version)
c.JSON(http.StatusNotFound, gin.H{"error": "no signed binaries available for version", "version": version})
return
}
// The signed bytes are the verbatim body. json.Marshal is deterministic here
// (no map fields; Artifacts built in fixed platform order), so the installer
// verifies the signature over exactly what it received.
body, err := json.Marshal(manifest)
if err != nil {
log.Printf("[ERROR] [server] [downloads] manifest_marshal_failed version=%s error=%v", version, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build manifest"})
return
}
sig, err := h.signingService.SignBytes(body)
if err != nil {
log.Printf("[ERROR] [server] [downloads] manifest_sign_failed version=%s error=%v", version, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sign manifest"})
return
}
c.Header("X-Content-Signature", sig)
c.Header("X-Key-Id", manifest.KeyID)
if c.Request.Method == "HEAD" {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "application/json", body)
}
// buildReleaseManifest assembles the manifest from the signed-package records.
// Platforms are walked in a fixed order so the marshalled bytes are stable.
// A platform with no signed package (or no resolvable checksum) is omitted
// rather than fabricated — the manifest never lies about what it can attest.
func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseManifest {
manifest := services.ReleaseManifest{
Version: version,
GeneratedAt: time.Now().UTC().Unix(),
KeyID: h.signingService.GetCurrentKeyID(),
}
platforms := []struct{ platform, arch string }{
{"linux", "amd64"},
{"linux", "arm64"},
{"windows", "amd64"},
{"windows", "arm64"},
}
for _, p := range platforms {
pkg, err := h.packageQueries.GetSignedPackage(version, p.platform, p.arch)
if err != nil || pkg == nil {
continue
}
checksum := pkg.Checksum
if checksum == "" && pkg.BinaryPath != "" {
if cs, csErr := computeFileSHA256(pkg.BinaryPath); csErr == nil {
checksum = cs
}
}
if checksum == "" {
log.Printf("[WARN] [server] [downloads] manifest_skip_no_checksum version=%s platform=%s-%s", version, p.platform, p.arch)
continue
}
filename := "redflag-agent"
if p.platform == "windows" {
filename += ".exe"
}
manifest.Artifacts = append(manifest.Artifacts, services.ManifestArtifact{
Platform: p.platform,
Architecture: p.arch,
Filename: filename,
SHA256: checksum,
Size: pkg.FileSize,
})
}
return manifest
}
// DownloadUpdatePackage serves signed agent update packages
func (h *DownloadHandler) DownloadUpdatePackage(c *gin.Context) {
packageID := c.Param("package_id")

View file

@ -9,7 +9,6 @@ import (
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/google/uuid"
)
// BuildOrchestratorService handles building and signing agent binaries
@ -35,6 +34,10 @@ func NewBuildOrchestratorService(signingService *SigningService, packageQueries
// the existing row is returned unchanged. Previously the server would re-sign
// and insert a fresh row every boot, accumulating duplicate packages and
// confusing the dashboard's "update available" list.
//
// Signing is required. If the signing service is disabled, returns an error.
// The pre-v0.2.1 unsigned fallback path has been removed — every binary served
// must carry an Ed25519 signature.
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
@ -47,62 +50,36 @@ func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architec
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
if s.signingService.IsEnabled() {
// Compute the on-disk checksum once so we can compare against any
// existing row before paying the cost of a fresh sign.
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
if checksumErr != nil {
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
return existing, nil
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
} else {
log.Printf("Signing disabled, creating unsigned package entry")
// Create unsigned package entry for backward compatibility
unsignedPackage := &models.AgentUpdatePackage{
ID: uuid.New(),
Version: version,
Platform: platform,
Architecture: architecture,
BinaryPath: binaryPath,
Signature: "",
Checksum: "", // Would need to calculate if needed
FileSize: 0, // Would need to stat if needed
CreatedBy: "build-orchestrator",
IsActive: true,
}
// Get file info
if info, err := os.Stat(binaryPath); err == nil {
unsignedPackage.FileSize = info.Size()
}
// Store unsigned package
err := s.packageQueries.StoreSignedPackage(unsignedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store unsigned package: %w", err)
}
return unsignedPackage, nil
if !s.signingService.IsEnabled() {
return nil, fmt.Errorf("cannot build agent: signing is disabled")
}
// Compute the on-disk checksum once so we can compare against any
// existing row before paying the cost of a fresh sign.
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
if checksumErr != nil {
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
return existing, nil
}
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
}
// SignExistingBinary signs an existing binary file
@ -145,4 +122,4 @@ func (s *BuildOrchestratorService) GetSignedPackage(version, platform, architect
// ListSignedPackages lists all signed packages (with optional filters)
func (s *BuildOrchestratorService) ListSignedPackages(version, platform string, limit, offset int) ([]models.AgentUpdatePackage, error) {
return s.packageQueries.ListUpdatePackages(version, platform, limit, offset)
}
}

View file

@ -0,0 +1,35 @@
package services
// Release manifest — the cold-start trust root.
//
// At install time the agent installer has no pinned binary hash to check
// against: it pulls the binary and (until now) ran it. The manifest closes that
// gap. It is a single JSON document listing the expected SHA-256 of every
// released agent binary per platform/architecture, signed with the same Ed25519
// key the agents already trust (TOFU pubkey on first contact, pinned key on
// upgrade). The installer fetches the manifest, verifies the signature over the
// exact bytes it received, then verifies the downloaded binary's hash matches
// the manifest entry before executing anything.
//
// The signed bytes are the verbatim JSON of ReleaseManifest (no map fields, so
// Go's json.Marshal is deterministic; Artifacts is sorted before marshalling).
// Whatever the server serves as the body is exactly what was signed — the
// signature travels in the X-Content-Signature header, the key fingerprint in
// X-Key-Id, mirroring the binary download endpoint.
// ManifestArtifact is one released binary's expected identity.
type ManifestArtifact struct {
Platform string `json:"platform"`
Architecture string `json:"architecture"`
Filename string `json:"filename"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// ReleaseManifest is the signed set of expected binary hashes for one version.
type ReleaseManifest struct {
Version string `json:"version"`
GeneratedAt int64 `json:"generated_at"`
KeyID string `json:"key_id"`
Artifacts []ManifestArtifact `json:"artifacts"`
}

View file

@ -236,6 +236,17 @@ func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage,
return pkg, nil
}
// SignBytes signs arbitrary content with the server's Ed25519 key and returns a
// hex signature. Used for the release manifest (the cold-start trust root the
// installer verifies before executing a freshly-downloaded binary). The
// counterpart verifier is VerifySignature.
func (s *SigningService) SignBytes(content []byte) (string, error) {
if !s.enabled || s.privateKey == nil {
return "", fmt.Errorf("signing service is disabled")
}
return hex.EncodeToString(ed25519.Sign(s.privateKey, content)), nil
}
// VerifySignature verifies a file signature using the embedded public key
func (s *SigningService) VerifySignature(content []byte, signatureHex string) (bool, error) {
// Decode signature

View file

@ -249,6 +249,105 @@ TMP_BINARY=$(mktemp)
TMP_HEADERS=$(mktemp)
curl -fsSL -o "$TMP_BINARY" -D "$TMP_HEADERS" "${BINARY_URL}"
# --- Cold-start trust: verify the binary against the signed release manifest ---
# The manifest is one Ed25519-signed document listing the expected SHA-256 of
# every released binary per platform/arch. We verify the manifest signature with
# the embedded server public key, then confirm the downloaded binary matches its
# manifest entry BEFORE executing anything. Fail-closed: any failure removes the
# binary and exits non-zero. This is the install-time counterpart to the
# self-upgrade hash check — it closes the gap where first-install ran an
# unverified binary.
PLATFORM_TAG="linux"
SERVER_PUBKEY="{{.ServerPublicKey}}"
MANIFEST_URL="{{.ServerURL}}/api/v1/manifest?version=${VERSION}"
if [ -z "$SERVER_PUBKEY" ]; then
echo "ERROR: No server public key embedded in installer — cannot establish cold-start trust."
rm -f "$TMP_BINARY" "$TMP_HEADERS"
exit 1
fi
# Ensure an Ed25519 verifier (python3-cryptography) is available.
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
echo "Installing python3-cryptography for manifest verification..."
case "$PM" in
apt) apt-get install -y python3-cryptography 2>/dev/null || true ;;
dnf|yum) dnf install -y python3-cryptography 2>/dev/null || true ;;
pacman) pacman -S --noconfirm python-cryptography 2>/dev/null || true ;;
*) pip3 install cryptography 2>/dev/null || true ;;
esac
fi
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
echo "ERROR: Could not provide python3-cryptography — cannot verify the release manifest."
rm -f "$TMP_BINARY" "$TMP_HEADERS"
exit 1
fi
TMP_MANIFEST=$(mktemp)
TMP_MANIFEST_HDR=$(mktemp)
if ! curl -fsSL -o "$TMP_MANIFEST" -D "$TMP_MANIFEST_HDR" "$MANIFEST_URL"; then
echo "ERROR: Failed to fetch release manifest from ${MANIFEST_URL}"
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST" "$TMP_MANIFEST_HDR"
exit 1
fi
MANIFEST_SIG=$(grep -i "x-content-signature" "$TMP_MANIFEST_HDR" | awk '{print $2}' | tr -d '\r\n')
rm -f "$TMP_MANIFEST_HDR"
if [ -z "$MANIFEST_SIG" ]; then
echo "ERROR: Release manifest is unsigned (no X-Content-Signature header) — refusing to install."
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST"
exit 1
fi
# Verifier: checks the Ed25519 signature over the manifest bytes, then prints the
# expected sha256 for this platform/arch (exit 2 = bad signature, 3 = no entry).
MANIFEST_VERIFY=$(mktemp)
cat <<'MANIFEST_EOF' > "$MANIFEST_VERIFY"
#!/usr/bin/env python3
import sys, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
pubkey_path, manifest_path, sig_path, platform, arch = sys.argv[1:6]
with open(pubkey_path) as f: pub = bytes.fromhex(f.read().strip())
with open(sig_path) as f: sig = bytes.fromhex(f.read().strip())
with open(manifest_path, 'rb') as f: body = f.read()
try:
Ed25519PublicKey.from_public_bytes(pub).verify(sig, body)
except InvalidSignature:
print("manifest signature invalid", file=sys.stderr); sys.exit(2)
except Exception as e:
print("manifest verify error: %s" % e, file=sys.stderr); sys.exit(2)
m = json.loads(body)
for a in m.get("artifacts", []):
if a.get("platform") == platform and a.get("architecture") == arch:
print(a.get("sha256", "")); sys.exit(0)
print("no manifest entry for %s/%s" % (platform, arch), file=sys.stderr); sys.exit(3)
MANIFEST_EOF
echo "$SERVER_PUBKEY" > "${TMP_MANIFEST}.pub"
echo "$MANIFEST_SIG" > "${TMP_MANIFEST}.sig"
set +e
EXPECTED_HASH=$(python3 "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "$TMP_MANIFEST" "${TMP_MANIFEST}.sig" "$PLATFORM_TAG" "$ARCH_TAG")
MANIFEST_RC=$?
set -e
rm -f "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "${TMP_MANIFEST}.sig" "$TMP_MANIFEST"
if [ $MANIFEST_RC -ne 0 ] || [ -z "$EXPECTED_HASH" ]; then
echo "ERROR: Release manifest verification failed (rc=$MANIFEST_RC) — refusing to install."
rm -f "$TMP_BINARY" "$TMP_HEADERS"
exit 1
fi
ACTUAL_HASH=$(sha256sum "$TMP_BINARY" | awk '{print $1}')
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
echo "ERROR: Binary hash does not match the signed manifest — possible tampering."
echo " expected (signed manifest): $EXPECTED_HASH"
echo " actual (downloaded): $ACTUAL_HASH"
rm -f "$TMP_BINARY" "$TMP_HEADERS"
exit 1
fi
echo "✓ Cold-start trust established: binary matches signed release manifest ($ACTUAL_HASH)"
# --- end cold-start manifest verification ---
# Verify checksum if server provided one
EXPECTED_CHECKSUM=$(grep -i "x-content-sha256" "$TMP_HEADERS" | awk '{print $2}' | tr -d '\r\n')
if [ -n "$EXPECTED_CHECKSUM" ]; then

View file

@ -7,10 +7,23 @@
#Requires -RunAsAdministrator
param(
[Parameter(Mandatory=$true)]
[string]$Token,
[Parameter(Mandatory=$false)]
[string]$Server = "",
[Parameter(Mandatory=$false)]
[switch]$SkipServiceInstall = $false
)
# The registration token is required — a fresh install cannot authenticate
# without it. This is what the one-liner passes (-Token). The server URL may be
# passed (-Server) or falls back to the render-time value (the host you fetched
# the script from), matching the Linux installer.
$RegistrationToken = $Token
$ServerUrl = if ($Server -ne "") { $Server } else { "{{.ServerURL}}" }
# 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."
@ -160,6 +173,76 @@ $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}}"
try {
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing -PassThru
} catch {
Write-Error "Failed to fetch release manifest from $ManifestUrl - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
$ManifestBody = $ManifestResp.Content
$ManifestSig = $null
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
}
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]::FromHexString($ServerPubKey)
$sg = [Convert]::FromHexString($ManifestSig)
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
$ok = [System.Security.Cryptography.Ed25519]::Verify($pk, $bodyBytes, $sg)
if (-not $ok) {
Write-Error "Release manifest signature invalid - refusing to install."
Remove-Item $TmpBinary -Force
exit 1
}
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
} catch {
Write-Host "WARNING: No native Ed25519 verifier - manifest signature not checked (hash pin still enforced)." -ForegroundColor Yellow
Write-Host "For full signature verification, install BouncyCastle." -ForegroundColor Yellow
}
# 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")) {
@ -240,10 +323,10 @@ if (Test-Path $ConfigPath) {
"agent_id": "",
"token": "",
"refresh_token": "",
"registration_token": "{{.RegistrationToken}}",
"registration_token": "$RegistrationToken",
"machine_id": "",
"check_in_interval": 300,
"server_url": "{{.ServerURL}}",
"server_url": "$ServerUrl",
"network": {
"timeout": 30000000000,
"retry_count": 3,
@ -307,9 +390,9 @@ if (Test-Path $ConfigPath) {
if ($ExistingRefreshToken -ne "") {
Write-Host "[INFO] [installer] [register] Upgrade in place - existing credentials detected, skipping registration" -ForegroundColor Cyan
Write-Host "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates" -ForegroundColor Gray
} elseif ("{{.RegistrationToken}}" -ne "") {
} elseif ($RegistrationToken -ne "") {
Write-Host "[INFO] [installer] [register] Registering agent with server..." -ForegroundColor Cyan
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "{{.ServerURL}}", "--token", "{{.RegistrationToken}}", "--register" -Wait -PassThru -NoNewWindow
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "$ServerUrl", "--token", "$RegistrationToken", "--register" -Wait -PassThru -NoNewWindow
if ($RegisterProcess.ExitCode -eq 0) {
Write-Host "[SUCCESS] [installer] [register] Agent registered successfully" -ForegroundColor Green
Write-Host "[INFO] [installer] [register] Agent ID assigned, configuration updated" -ForegroundColor Gray
@ -318,7 +401,7 @@ if ($ExistingRefreshToken -ne "") {
Write-Host "[WARN] [installer] [register] Agent installed but not registered. Service will not start." -ForegroundColor Yellow
Write-Host ""
Write-Host "[INFO] [installer] [register] To retry registration manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server {{.ServerURL}} --token YOUR_TOKEN --register" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Then start service:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Start-Service -Name $ServiceName" -ForegroundColor Gray
exit 1
@ -327,7 +410,7 @@ if ($ExistingRefreshToken -ne "") {
Write-Host "[INFO] [installer] [register] No registration token provided - skipping registration" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Service will start but agent will exit until registered" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] To register manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server {{.ServerURL}} --token YOUR_TOKEN --register" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
}
# Step 6: Install Windows service (if not skipped)