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

@ -224,6 +224,8 @@ jobs:
cache: true
- name: Install template integrity
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
- name: Package ownership guards
run: python3 -m unittest discover -s installer/linux -p 'test_*.py' -v
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
# it enforces on the fleet. Tools installed directly (no third-party actions) so
@ -296,10 +298,18 @@ jobs:
# window rather than a single commit.
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
[ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
# A run owns what its branch adds to the trunk, so measure from
# the merge base. A counted window reaches past that base and
# fails on commits the run did not introduce.
BASE="$(git merge-base origin/main HEAD 2>/dev/null || true)"
if [ -n "$BASE" ] && [ "$BASE" != "$(git rev-parse HEAD)" ]; then
RANGE="$BASE..HEAD"
else
RANGE="$(git rev-list --max-count=10 HEAD | tail -1)^..HEAD"
git rev-parse --verify "${RANGE%%..*}" >/dev/null 2>&1 || RANGE="HEAD"
fi
fi
fi
python3 .publication/commit_voice.py --range "$RANGE" \
--allowlist .publication/commit-voice-allowlist.json \
--repository Fimeg/RedFlag

View file

@ -3,6 +3,8 @@ name: msi-custody-proof
# Manual internal proof for an ordinary branch. It calls the same MSI build
# entry point as release.yml, but creates no tag, release, or public artifact.
on:
push:
branches: [task/windows-product-boundary-20260909]
workflow_dispatch:
jobs:
@ -24,6 +26,14 @@ jobs:
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
echo "Server version: $VERSION"
- name: Check native runtime lifecycle and migration payload
run: |
cd server
go test -race -count=1 ./cmd/server ./internal/database ./internal/config
- name: Check MSI version identity and license rendering
run: python3 -m unittest discover -s installer/windows -p 'test_build_inputs.py' -v
- name: Build staged Windows Server
env:
GOOS: windows
@ -42,8 +52,9 @@ jobs:
- name: Build MSI and prove staged-byte custody
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq jq msitools wixl
bash installer/windows/provision-msitools.sh "$RUNNER_TEMP/msitools-0.106"
export PATH="$RUNNER_TEMP/msitools-0.106/bin:$PATH"
export LD_LIBRARY_PATH="$RUNNER_TEMP/msitools-0.106/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
installer/windows/build-msi.sh \
dist/redflag-server-windows-amd64.exe \
"${{ steps.version.outputs.value }}" \

View file

@ -405,14 +405,11 @@ jobs:
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
# tables all populated correctly against a real cross-compiled
# server binary).
sudo apt-get update -qq
# /usr/bin/wixl is shipped by the `wixl` package on Ubuntu noble,
# not by `msitools` — msitools carries msiinfo and msibuild only.
# Installing msitools alone succeeds and then dies twenty lines
# later on "wixl: command not found", which is how v0.2.9.3 failed
# on 2026-09-04. msitools stays for the msiinfo verification the
# Product.wxs comments describe.
sudo apt-get install -y -qq jq msitools wixl
# Older distro wixl versions omit UI tables without failing the
# build. Release and custody proof use the same pinned compiler.
bash installer/windows/provision-msitools.sh "$RUNNER_TEMP/msitools-0.106"
export PATH="$RUNNER_TEMP/msitools-0.106/bin:$PATH"
export LD_LIBRARY_PATH="$RUNNER_TEMP/msitools-0.106/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
command -v wixl
command -v msiinfo
command -v msiextract

View file

@ -332,17 +332,26 @@ installer/linux/build-deb.sh
installer/linux/debian/control.in
installer/linux/debian/postinst
installer/linux/debian/postrm
installer/linux/debian/preinst
installer/linux/debian/prerm
installer/linux/desktop/redflag-desktop.desktop
installer/linux/inspect-deb.sh
installer/linux/polkit/50-redflag-agent.rules
installer/linux/sudoers/redflag-agent
installer/linux/systemd/redflag-agent.service
installer/linux/test_maintainer_scripts.py
installer/windows/Product.wxs
installer/windows/ServerUI.wxs
installer/windows/build-desktop.ps1
installer/windows/build-msi.sh
installer/windows/config/redflag.env.example
installer/windows/msi_version.py
installer/windows/provision-msitools.sh
installer/windows/render-license.py
installer/windows/test-agent-verifier.ps1
installer/windows/test_build_inputs.py
installer/windows/testdata/run3181-hollow.msi
installer/windows/verify-msi-boundary.py
installer/windows/verify-msi.sh
protocol/README.md
protocol/testdata/mutation-golden.json
@ -364,7 +373,11 @@ scripts/update-action-pins.sh
scripts/vendor/release-contract-core.py
server/.env.example
server/Dockerfile
server/cmd/server/http_lifecycle.go
server/cmd/server/http_lifecycle_test.go
server/cmd/server/main.go
server/cmd/server/service_other.go
server/cmd/server/service_windows.go
server/cmd/server/webui_test.go
server/cmd/server/wire.go
server/docker-entrypoint.sh
@ -456,6 +469,7 @@ server/internal/config/config_test.go
server/internal/crypto/aesgcm.go
server/internal/database/db.go
server/internal/database/db_test.go
server/internal/database/embedded_migrations_test.go
server/internal/database/migration_runner_test.go
server/internal/database/migrations/001_initial_schema.down.sql
server/internal/database/migrations/001_initial_schema.up.sql

View file

@ -23,3 +23,26 @@ LC_ALL=C git ls-tree -r --full-tree HEAD^{tree} | LC_ALL=C sort | sha256sum
```
The result must equal the commit's `Tree-Digest` trailer.
## Why public history begins at the epoch
Public history starts at a single constructed commit because the history
before it was not admissible. Twenty-six commits carried an internal author
identity and one body quoted an internal registry address, and no force push
removes what a mirror has already copied.
Replaying eleven hundred commit messages through a filter would have produced
a different object graph wearing the old words, and would still have required
a person to read every one of them. The honest alternative is this one: the
development history is preserved in full inside the private forge, where it is
useful, and the public repository carries what was deliberately sent out.
The reviewed history allowlist is empty for the first time. It held forty-four
commits, and every one of them was excused for exactly the thing the epoch
removes.
Everything after the epoch commit is an ordinary publication carrying the same
three trailers, so a force push stops being routine and starts meaning that
something deliberate happened. The trailers bind a projection to its source
without pretending the public commit is the private one: same lineage,
different tree, and the record says so.

View file

@ -101,6 +101,12 @@ curl -sfL -H "X-Registration-Token: your-token" "https://your-server.com/api/v1/
```
**Windows:**
Run elevated Windows PowerShell with an operator-installed OpenSSL 3 executable
on `PATH` (`Get-Command openssl`). The installer checks Ed25519 acceptance and
rejection before changing the agent. Missing verification support, unsigned or
invalid signatures, and manifest hash mismatches stop installation. Installing
PowerShell 7 alone does not supply this verifier.
```powershell
iwr -Headers @{"X-Registration-Token"="your-token"} "https://your-server.com/api/v1/install/windows" | iex
```

View file

@ -564,9 +564,33 @@ fn verify_artifacts(token: &CapabilityToken) -> Result<usize, Denial> {
Ok(verified)
}
// Replay guard. token_id is recorded BEFORE execution so a token can never run
// twice even across a crash. A record-write failure is fail-closed (deny).
// The ledger is shared by fleet and standalone callers, including separate
// helper processes. Lock and append to the existing inode; never truncate old
// claims. Sync the claim before permitting mutation.
fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Denial> {
#[cfg(unix)]
return replay_check_and_record_as(token_id, state_path, 0);
#[cfg(not(unix))]
Err(Denial::new(
EXIT_INTERNAL,
"replay_lock_unavailable",
"unsupported platform",
))
}
#[cfg(unix)]
fn replay_check_and_record_as(
token_id: &str,
state_path: &Path,
required_uid: u32,
) -> Result<(), Denial> {
if token_id.is_empty() || token_id.trim() != token_id || token_id.contains(['\n', '\r']) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"invalid_token_id",
"invalid replay ledger key",
));
}
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(
@ -578,33 +602,164 @@ fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Deni
// SEC-021: a writable replay-guard dir lets a compromised agent clear
// consumed-token records and replay. Validate after ensure-exists so a
// pre-planted attacker-owned dir is refused, not adopted.
validate_trusted_path_as(parent, 0)?;
validate_trusted_path_as(parent, required_uid)?;
}
if fs::symlink_metadata(state_path).is_ok() {
validate_trusted_path_as(state_path, 0)?;
let failure = |e: std::io::Error| {
Denial::new(
EXIT_INTERNAL,
"replay_ledger_io_failed",
format!("{}: {}", state_path.display(), e),
)
};
let mut ledger = fs::OpenOptions::new()
.read(true)
.append(true)
.create(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(state_path)
.map_err(failure)?;
validate_trusted_path_as(state_path, required_uid)?;
let metadata = ledger.metadata().map_err(failure)?;
if !metadata.is_file()
|| metadata.uid() != required_uid
|| metadata.mode() & 0o022 != 0
|| metadata.nlink() != 1
{
return Err(Denial::new(
EXIT_TRUST_PATH,
"replay_ledger_unsafe",
"ledger must be a private, singly linked regular file",
));
}
if let Ok(contents) = fs::read_to_string(state_path) {
if contents.lines().any(|l| l.trim() == token_id) {
// SAFETY: ledger owns the live descriptor; closing it releases the lock on
// every return path. No caller-side mutex is part of this trust boundary.
while unsafe { libc::flock(ledger.as_raw_fd(), libc::LOCK_EX) } != 0 {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::Interrupted {
return Err(failure(error));
}
}
let mut existing = String::new();
ledger.read_to_string(&mut existing).map_err(failure)?;
if !existing.is_empty() && !existing.ends_with('\n') {
return Err(Denial::new(
EXIT_INTERNAL,
"replay_ledger_incomplete",
"partial record requires operator recovery",
));
}
if existing.lines().any(|line| line.trim() == token_id) {
return Err(Denial::new(
EXIT_REPLAY,
"token_already_consumed",
format!("token_id={}", token_id),
));
}
ledger
.write_all(format!("{}\n", token_id).as_bytes())
.map_err(failure)?;
ledger.sync_all().map_err(failure)?;
if let Some(parent) = state_path.parent() {
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(failure)?;
}
let mut existing = fs::read_to_string(state_path).unwrap_or_default();
existing.push_str(token_id);
existing.push('\n');
fs::write(state_path, existing).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"state_write_failed",
format!("{}: {}", state_path.display(), e),
)
})?;
Ok(())
}
#[cfg(all(test, unix))]
mod replay_ledger_tests {
use super::*;
use std::sync::{Arc, Barrier};
fn fixture(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"redflag-replay-{}-{}-{}",
name,
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&path).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path
}
#[test]
fn concurrent_duplicate_has_one_winner() {
let root = fixture("duplicate");
let barrier = Arc::new(Barrier::new(16));
let handles: Vec<_> = (0..16)
.map(|_| {
let barrier = barrier.clone();
let ledger = root.join("consumed-tokens");
std::thread::spawn(move || {
barrier.wait();
replay_check_and_record_as("one-token", &ledger, unsafe { libc::geteuid() })
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
assert!(results
.iter()
.filter_map(|r| r.as_ref().err())
.all(|d| d.code == EXIT_REPLAY));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn concurrent_distinct_claims_preserve_legacy_records() {
let root = fixture("distinct");
let path = root.join("consumed-tokens");
fs::write(&path, "legacy-token\n").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
let barrier = Arc::new(Barrier::new(16));
let handles: Vec<_> = (0..16)
.map(|i| {
let barrier = barrier.clone();
let path = path.clone();
std::thread::spawn(move || {
barrier.wait();
replay_check_and_record_as(&format!("token-{i}"), &path, unsafe {
libc::geteuid()
})
.unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().collect::<BTreeSet<_>>().len(), 17);
assert_eq!(
replay_check_and_record_as("legacy-token", &path, unsafe { libc::geteuid() })
.unwrap_err()
.code,
EXIT_REPLAY
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn corrupt_or_symlinked_ledger_never_becomes_empty_state() {
let root = fixture("corrupt");
let path = root.join("consumed-tokens");
fs::write(&path, "partial-record").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
assert!(replay_check_and_record_as("next", &path, unsafe { libc::geteuid() }).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), "partial-record");
let link = root.join("link");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(replay_check_and_record_as("next", &link, unsafe { libc::geteuid() }).is_err());
fs::remove_dir_all(root).unwrap();
}
}
// Build the argv plan for the one authorized operation. Returns a list of
// (program, args) invocations — single-element for line managers, per-artifact
// for image/winget managers. Unsupported (type, operation) pairs are denied
@ -1025,11 +1180,7 @@ fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Deni
return Err(Denial::new(
EXIT_TRUST_PATH,
"stage_open_failed",
format!(
"{}: {}",
staging.display(),
std::io::Error::last_os_error()
),
format!("{}: {}", staging.display(), std::io::Error::last_os_error()),
));
}
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
@ -5022,7 +5173,10 @@ fn main() {
// The signed release manifest lists version_cmd "--version" for this
// component, so the binary has to answer it. Positional, like every other
// arm here, and ahead of any path that touches privilege or state.
if matches!(args.get(1).map(|s| s.as_str()), Some("--version") | Some("-V")) {
if matches!(
args.get(1).map(|s| s.as_str()),
Some("--version") | Some("-V")
) {
println!("RedFlag helper v{}", env!("REDFLAG_VERSION"));
return;
}

View file

@ -107,13 +107,15 @@ install -D -m 0644 "$HERE/polkit/50-redflag-agent.rules" \
# overrides rather than reimplementing any of its logic.
install -D -m 0755 "$PROVISION_SRC" \
"$STAGE/usr/lib/redflag/provision-standalone-authority.sh"
install -D -m 0644 "$REPO_ROOT/LICENSE" "$STAGE/usr/share/doc/redflag/copyright"
install -D -m 0644 "$REPO_ROOT/THIRD_PARTY_LICENSES.md" "$STAGE/usr/share/doc/redflag/THIRD_PARTY_LICENSES.md"
# ---- control ----------------------------------------------------------------
install -d -m 0755 "$STAGE/DEBIAN"
sed -e "s|@VERSION@|$VERSION|g" -e "s|@MAINTAINER@|$MAINTAINER|g" \
"$HERE/debian/control.in" > "$STAGE/DEBIAN/control"
for script in postinst prerm postrm; do
for script in preinst postinst prerm postrm; do
sed -e "s|@VERSION@|$VERSION|g" "$HERE/debian/$script" > "$STAGE/DEBIAN/$script"
chmod 0755 "$STAGE/DEBIAN/$script"
done
@ -131,7 +133,7 @@ EOF
# Syntax-check the maintainer scripts before shipping them. A broken postinst is
# discovered at install time on the operator's machine otherwise.
for script in postinst prerm postrm; do
for script in preinst postinst prerm postrm; do
sh -n "$STAGE/DEBIAN/$script" || fail "$script failed shell syntax check"
done
if command -v visudo >/dev/null 2>&1; then

View file

@ -30,6 +30,18 @@ case "$1" in
*) exit 0 ;;
esac
# Also guard reconfiguration and recovery after an interrupted installation.
for b in redflag-agent redflag-helper redflag-desktop; do
path="$RUN_BIN_DIR/$b"
if [ -L "$path" ]; then
[ "$(readlink "$path")" = "$PKG_BIN_DIR/$b" ] && continue
elif [ ! -e "$path" ]; then
continue
fi
warn "$path is not the package runtime link; explicit migration is required"
exit 1
done
# ---- user, groups -----------------------------------------------------------
if ! getent group "$LOCAL_GROUP" >/dev/null 2>&1; then
addgroup --system "$LOCAL_GROUP"
@ -69,15 +81,15 @@ chown "$AGENT_USER":"$AGENT_USER" "$AGENT_CONFIG_DIR" "$SERVER_KEY_DIR"
# ---- runtime symlinks -------------------------------------------------------
# The agent, helper and their sudoers/service contracts all reference
# /usr/local/bin (agent/internal/constants/paths.go). Debian policy keeps
# package files out of /usr/local, so ship in /usr/bin and link. A real file at
# the link path is left alone: that is a helper-performed self-update binary,
# and clobbering it would silently downgrade the running install.
# package files out of /usr/local, so ship in /usr/bin and link. The preflight
# above refuses foreign paths; the package-managed helper refuses self-update.
for b in redflag-agent redflag-helper redflag-desktop; do
if [ -L "$RUN_BIN_DIR/$b" ] || [ ! -e "$RUN_BIN_DIR/$b" ]; then
install -d -m 0755 "$RUN_BIN_DIR"
ln -sfn "$PKG_BIN_DIR/$b" "$RUN_BIN_DIR/$b"
else
warn "$RUN_BIN_DIR/$b exists and is not a symlink — leaving it (self-updated binary?); packaged build is at $PKG_BIN_DIR/$b"
warn "$RUN_BIN_DIR/$b changed during configuration — refusing runtime takeover"
exit 1
fi
done
if command -v setcap >/dev/null 2>&1; then
@ -145,9 +157,11 @@ elif [ -x "$PROVISION" ]; then
else
warn "standalone authority provisioning failed — agent installed, local approval unavailable"
warn "re-run once resolved: sudo REDFLAG_BIN_DIR=$RUN_BIN_DIR $PROVISION"
exit 1
fi
else
warn "$PROVISION missing or not executable — standalone authority not provisioned"
exit 1
fi
# ---- desktop access ---------------------------------------------------------
@ -162,10 +176,12 @@ fi
# ---- service ----------------------------------------------------------------
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
systemctl enable redflag-agent.service || true
systemctl restart redflag-agent.service || \
systemctl daemon-reload
systemctl enable redflag-agent.service
if ! systemctl restart redflag-agent.service; then
warn "redflag-agent did not start — check: journalctl -u redflag-agent"
exit 1
fi
fi
exit 0

View file

@ -10,7 +10,7 @@ case "$1" in
remove|purge)
for b in redflag-agent redflag-helper redflag-desktop; do
# Only our own symlinks; a self-updated real binary is left for the operator.
if [ -L "$RUN_BIN_DIR/$b" ]; then
if [ -L "$RUN_BIN_DIR/$b" ] && [ "$(readlink "$RUN_BIN_DIR/$b")" = "/usr/bin/$b" ]; then
rm -f "$RUN_BIN_DIR/$b"
fi
done

View file

@ -0,0 +1,14 @@
#!/bin/sh
# Refuse an implicit takeover of script-installed or operator-owned binaries.
set -e
case "$1" in install|upgrade) ;; *) exit 0 ;; esac
for b in redflag-agent redflag-helper redflag-desktop; do
path="/usr/local/bin/$b"
if [ -L "$path" ]; then
[ "$(readlink "$path")" = "/usr/bin/$b" ] && continue
elif [ ! -e "$path" ]; then
continue
fi
echo "[ERROR] [deb] [preinst] $path is not this package's runtime link; preserve the existing install and explicitly migrate it before installing redflag" >&2
exit 1
done

View file

@ -92,6 +92,8 @@ check_file /etc/polkit-1/rules.d/50-redflag-agent.rules 644
check_file /etc/xdg/autostart/redflag-desktop.desktop 644
check_file /usr/share/applications/redflag-desktop.desktop 644
check_file /usr/lib/redflag/provision-standalone-authority.sh 755
check_file /usr/share/doc/redflag/copyright 644
check_file /usr/share/doc/redflag/THIRD_PARTY_LICENSES.md 644
# Debian policy: nothing under /usr/local may be shipped in the archive.
if grep -qE ' \./usr/local/' "$TMP/contents"; then
@ -112,7 +114,7 @@ for b in redflag-agent redflag-helper redflag-desktop; do
done
# ---- maintainer scripts -----------------------------------------------------
for s in postinst prerm postrm; do
for s in preinst postinst prerm postrm; do
f="$TMP/DEBIAN/$s"
if [ ! -f "$f" ]; then bad maintscript "$s missing"; continue; fi
[ -x "$f" ] || bad maintscript "$s not executable"

View file

@ -0,0 +1,52 @@
"""Exercise package ownership guards without modifying the host installation."""
import pathlib
import subprocess
import tempfile
import unittest
HERE = pathlib.Path(__file__).parent
class PackageOwnership(unittest.TestCase):
def run_script(self, name, root, action):
# Relocate only the fixed installation root. Execute the actual shell
# script; every command and branch in its ownership guard remains real.
script = (HERE / "debian" / name).read_text().replace("/usr/local/bin", str(root))
return subprocess.run(["sh", "-s", "--", action], input=script, text=True, capture_output=True)
def test_fresh_install_and_own_links_are_accepted(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
self.assertEqual(self.run_script("preinst", root, "install").returncode, 0)
for binary in ["redflag-agent", "redflag-helper", "redflag-desktop"]:
(root / binary).symlink_to("/usr/bin/" + binary)
self.assertEqual(self.run_script("preinst", root, "upgrade").returncode, 0)
def test_unmanaged_binary_and_foreign_link_survive_refusal(self):
for kind in ["binary", "symlink"]:
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
path = root / "redflag-agent"
if kind == "binary":
path.write_bytes(b"existing runtime")
else:
path.symlink_to("/opt/existing/redflag-agent")
result = self.run_script("preinst", root, "upgrade")
self.assertNotEqual(result.returncode, 0)
self.assertIn("explicitly migrate", result.stderr)
if kind == "binary":
self.assertEqual(path.read_bytes(), b"existing runtime")
else:
self.assertEqual(str(path.readlink()), "/opt/existing/redflag-agent")
def test_reconfigure_refuses_before_provisioning(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / "redflag-helper").write_bytes(b"existing helper")
result = self.run_script("postinst", root, "configure")
self.assertNotEqual(result.returncode, 0)
self.assertIn("explicit migration", result.stderr)
if __name__ == "__main__":
unittest.main()

View file

@ -18,14 +18,14 @@
elements, explicit Directory nesting, no StandardDirectory shorthand) —
wixl's grammar, not v4's.
Scope (v1): install the already-built, already-signed server binary as a
Windows service, with a forward-only upgrade guard. It does NOT bundle
Scope (v1): install the staged server binary as a Windows service. Payload
custody is checked by build-msi.sh; this is not an Authenticode claim.
It does NOT bundle
PostgreSQL — docker-compose doesn't either (postgres:16-alpine is a
separate container); a native install is expected to point at a reachable
Postgres the same way the docker path does via config/.env. The installer's
job ends where the docker quick-start's does today: "service is running,
go to http://localhost:<port>/setup and finish configuration in the
browser" (README.md's existing docker flow, same UX, different transport).
service is left stopped until the operator supplies database credentials,
admin credentials and signing configuration in redflag.env, then starts it.
Config: the Go binary only ever reads OS environment variables
(server/internal/config/config.go). Docker gets those from compose's
@ -69,18 +69,24 @@
Forward-only, no downgrade — ETHOS doctrine elsewhere in this codebase
(refresh-token rotation, capability tokens) applied to the installer
itself. Windows Installer only compares the first three version
fields for upgrade detection — pass $(var.RedFlagVersion) as the
3-part form from CI, same 3-vs-4-part reconciliation bump-version.sh
already does for desktop/Cargo.toml vs tauri.conf.json.
fields for upgrade detection. msi_version.py encodes patch*100+revision
in the third field, so fourth-field RedFlag releases remain ordered.
Same-version package replacement is safe from fourth-field collisions
under that mapping and prevents duplicate product registrations.
-->
<MajorUpgrade DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
<MediaTemplate EmbedCab="yes" />
<Property Id="ARPNOMODIFY" Value="1" />
<UIRef Id="RedFlagServerUI" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="RedFlag">
<Component Id="ServerBinary" Guid="7a2d5e9c-3f81-4c6b-9e0a-2b7d4f8c1a63">
<!-- New component identity for the corrected 64-bit component;
the product UpgradeCode remains the existing Server identity. -->
<Component Id="ServerBinary" Guid="0d252388-94fb-468f-a050-9087d11f03dd" Win64="yes">
<File Id="RedFlagServerExe"
Source="dist/redflag-server-windows-amd64.exe"
Name="redflag-server.exe"
@ -105,7 +111,6 @@
<ServiceControl Id="RedFlagServerServiceControl"
Name="RedFlagServer"
Start="install"
Stop="both"
Remove="uninstall"
Wait="yes" />
@ -124,7 +129,7 @@
-->
<Directory Id="CommonAppDataFolder">
<Directory Id="REDFLAGDATA" Name="RedFlag">
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70">
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70" Win64="no">
<File Id="RedFlagEnvExample"
Source="config/redflag.env.example"
Name="redflag.env.example"
@ -140,11 +145,11 @@
</Feature>
<!--
Desktop tray feature slots in here later (Casey, 2026-06-30: "checkbox
Desktop feature slots in here later (Casey, 2026-06-30: "checkbox
for adding the desktop alongside it" plus future screen-capture work
from the session-broker RAF). Deliberately not stubbed as a disabled
checkbox — a checkbox that does nothing when checked is worse than no
checkbox. Wire it once the desktop/Tauri artifact has a real merge
checkbox. Wire it once the complete Desktop/Qt payload has an owned
path into this installer, per Casey's "keep it separate for now."
-->

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<UI Id="RedFlagServerUI">
<TextStyle Id="Normal" FaceName="Segoe UI" Size="9" />
<TextStyle Id="Title" FaceName="Segoe UI" Size="14" Bold="yes" />
<Property Id="DefaultUIFont" Value="Normal" />
<Dialog Id="ServerWelcome" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Install RedFlag Server" />
<Control Id="Scope" Type="Text" X="20" Y="55" Width="330" Height="50" Text="This installs the fleet authority and web dashboard as a Windows service. It does not install RedFlag Desktop, a local Agent, or PostgreSQL." />
<Control Id="Requirements" Type="Text" X="20" Y="115" Width="330" Height="70" Text="You need administrator rights and an existing PostgreSQL database. After installation, create redflag.env from the supplied example, set your credentials and signing key, then start RedFlagServer." />
<Control Id="Boundary" Type="Text" X="20" Y="190" Width="330" Height="35" Text="The service remains stopped after setup. Standalone local-machine installation is not part of this package." />
<Control Id="Next" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Next">
<Publish Event="NewDialog" Value="ServerLicense">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerLicense" Width="370" Height="270" Title="[ProductName] License">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="24" Text="{\Title}GNU AGPL version 3" />
<Control Id="License" Type="ScrollableText" X="20" Y="45" Width="330" Height="150" Sunken="yes" TabSkip="no">
<Text SourceFile="dist/License.rtf" />
</Control>
<Control Id="Notice" Type="Text" X="20" Y="202" Width="330" Height="30" Text="RedFlag is free software under the license above. Click Install to install the Server files and register its service." />
<Control Id="Back" Type="PushButton" X="160" Y="240" Width="60" Height="18" Text="Back">
<Publish Event="NewDialog" Value="ServerWelcome">1</Publish>
</Control>
<Control Id="Install" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Install">
<Publish Event="EndDialog" Value="Return">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerMaintenance" Width="370" Height="270" Title="[ProductName] Maintenance">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Maintain RedFlag Server" />
<Control Id="Scope" Type="Text" X="20" Y="65" Width="330" Height="100" Text="Repair reinstalls the Server files. Remove stops and removes its service and package files. Your redflag.env, server keys and database are preserved. Neither action changes a separately installed Agent or Desktop. Start the service manually after repair." />
<Control Id="Repair" Type="PushButton" X="90" Y="200" Width="80" Height="22" Default="yes" Text="Repair">
<Publish Property="REINSTALL" Value="ALL" Order="1">1</Publish>
<Publish Property="REINSTALLMODE" Value="omus" Order="2">1</Publish>
<Publish Event="EndDialog" Value="Return" Order="3">1</Publish>
</Control>
<Control Id="Remove" Type="PushButton" X="190" Y="200" Width="80" Height="22" Text="Remove">
<Publish Property="REMOVE" Value="ALL" Order="1">1</Publish>
<Publish Event="EndDialog" Value="Return" Order="2">1</Publish>
</Control>
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerProgress" Width="370" Height="180" Title="[ProductName] Setup" Modeless="yes">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="24" Text="{\Title}Applying package changes" />
<Control Id="Action" Type="Text" X="20" Y="70" Width="330" Height="30">
<Subscribe Event="ActionText" Attribute="Text" />
</Control>
<Control Id="Progress" Type="ProgressBar" X="20" Y="110" Width="330" Height="16" ProgressBlocks="yes">
<Subscribe Event="SetProgress" Attribute="Progress" />
</Control>
</Dialog>
<Dialog Id="ServerComplete" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Package operation complete" />
<Control Id="NextSteps" Type="Text" X="20" Y="65" Width="330" Height="135" Text="After installing or repairing: copy [CommonAppDataFolder]RedFlag\redflag.env.example to redflag.env in the same folder. Configure PostgreSQL, admin credentials, JWT secret and signing key. Start RedFlagServer in Services, then open http://localhost:8080 (or your configured port). Package completion does not verify database connectivity or application health. After removal, operator configuration and database remain." />
<Control Id="Finish" Type="PushButton" X="290" Y="240" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Return">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerFailed" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="95" Text="Setup could not complete. Review the Windows Installer log before retrying. Do not assume the Server is installed or running." />
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<Dialog Id="ServerCancelled" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="70" Text="Setup was cancelled. You can run this package again when ready." />
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
<Publish Event="EndDialog" Value="Exit">1</Publish>
</Control>
</Dialog>
<InstallUISequence>
<Show Dialog="ServerWelcome" After="CostFinalize">NOT Installed</Show>
<Show Dialog="ServerMaintenance" After="ServerWelcome">Installed AND NOT REMOVE</Show>
<Show Dialog="ServerProgress" Before="ExecuteAction">1</Show>
<Show Dialog="ServerComplete" OnExit="success">1</Show>
<Show Dialog="ServerFailed" OnExit="error">1</Show>
<Show Dialog="ServerCancelled" OnExit="cancel">1</Show>
</InstallUISequence>
</UI>
</Fragment>
</Wix>

View file

@ -15,11 +15,15 @@ PROOF_JSON="$4"
SOURCE_COMMIT="$5"
SOURCE_REF="$6"
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
MSI_VERSION=$(printf '%s\n' "$REDFLAG_VERSION" | cut -d. -f1-3)
for tool in jq msiinfo msiextract wixl; do
for tool in jq msiinfo msiextract wixl python3; do
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 2; }
done
[ "$(wixl --version)" = "0.106" ] || {
echo "wixl 0.106 is required for the Server UI; use provision-msitools.sh" >&2
exit 2
}
MSI_VERSION=$(python3 "$ROOT/installer/windows/msi_version.py" "$REDFLAG_VERSION")
[ -f "$SERVER_EXE" ] || { echo "no such Server executable: $SERVER_EXE" >&2; exit 2; }
SERVER_EXE=$(realpath "$SERVER_EXE")
@ -27,10 +31,12 @@ OUTPUT_MSI=$(realpath -m "$OUTPUT_MSI")
PROOF_JSON=$(realpath -m "$PROOF_JSON")
mkdir -p "$ROOT/installer/windows/dist" "$(dirname "$OUTPUT_MSI")" "$(dirname "$PROOF_JSON")"
cp "$SERVER_EXE" "$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe"
python3 "$ROOT/installer/windows/render-license.py" \
"$ROOT/LICENSE" "$ROOT/installer/windows/dist/License.rtf"
(
cd "$ROOT/installer/windows"
wixl Product.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
wixl -a x64 --ext ui Product.wxs ServerUI.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
)
CAB_FILE=$(find "$(dirname "$OUTPUT_MSI")" "$ROOT/installer/windows" \
@ -52,6 +58,8 @@ fi
--source-ref "$SOURCE_REF" \
--proof-record "$PROOF_JSON"
python3 "$ROOT/installer/windows/verify-msi-boundary.py" "$OUTPUT_MSI"
FIXTURE="$ROOT/installer/windows/testdata/run3181-hollow.msi"
if "$ROOT/installer/windows/verify-msi.sh" "$FIXTURE" \
"$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe" \

View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Preserve RedFlag's fourth version field in MSI's three-field identity."""
import re
import sys
def product_version(version):
if not re.fullmatch(r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(\.[0-9]{1,2})?", version):
raise ValueError("expected major.minor.patch[.revision], with revision 0..99")
parts = [int(part) for part in version.split(".")]
major, minor, patch = parts[:3]
revision = parts[3] if len(parts) == 4 else 0
build = patch * 100 + revision
if major > 255 or minor > 255 or build > 65535:
raise ValueError("version exceeds MSI limits (255.255.65535)")
return f"{major}.{minor}.{build}"
if __name__ == "__main__":
try:
print(product_version(sys.argv[1]))
except (ValueError, IndexError) as error:
sys.exit(f"Invalid RedFlag MSI version: {error}")

View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# CI-only tool bootstrap shared by release and custody proof. wixl 0.101
# silently omits the Server UI tables; 0.106 implements the required grammar.
set -euo pipefail
[ "$#" -eq 1 ] || { echo "usage: $0 <empty-install-prefix>" >&2; exit 2; }
PREFIX=$(realpath -m "$1")
[ ! -e "$PREFIX" ] || { echo "tool prefix already exists: $PREFIX" >&2; exit 2; }
WORK=$(mktemp -d)
sudo apt-get update -qq
sudo apt-get install -y -qq build-essential valac bison gettext ninja-build \
libglib2.0-dev libgsf-1-dev libgcab-dev libxml2-dev gobject-introspection \
python3-venv curl jq
curl --fail --location --retry 3 \
https://download.gnome.org/sources/msitools/0.106/msitools-0.106.tar.xz \
-o "$WORK/msitools.tar.xz"
echo "1ed34279cf8080f14f1b8f10e649474125492a089912e7ca70e59dfa2e5a659b $WORK/msitools.tar.xz" | sha256sum -c -
curl --fail --location --retry 3 \
https://files.pythonhosted.org/packages/55/a6/47b9353c331318a13eb050887eacfd61eb075746285f9baf7ef7de6ae235/meson-1.5.2-py3-none-any.whl \
-o "$WORK/meson-1.5.2-py3-none-any.whl"
echo "77706e2368a00d789c097632ccf4fc39251fba56d03e1e1b262559a3c7a08f5b $WORK/meson-1.5.2-py3-none-any.whl" | sha256sum -c -
python3 -m venv "$WORK/venv"
"$WORK/venv/bin/pip" install --no-index --no-deps "$WORK/meson-1.5.2-py3-none-any.whl"
tar -xJf "$WORK/msitools.tar.xz" -C "$WORK"
"$WORK/venv/bin/meson" setup "$WORK/build" "$WORK/msitools-0.106" \
--prefix="$PREFIX" --libdir=lib
"$WORK/venv/bin/meson" compile -C "$WORK/build"
"$WORK/venv/bin/meson" install -C "$WORK/build"
LD_LIBRARY_PATH="$PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$PREFIX/bin/wixl" --version

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Put the repository's complete license into the MSI's native RTF control."""
import pathlib
import sys
def render(text):
# The repository license is ASCII. Refuse lossy conversion if that changes.
text.encode("ascii")
escaped = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Segoe UI;}}\\f0\\fs18\n" + escaped.replace("\n", "\\par\n") + "}\n"
if __name__ == "__main__":
source, destination = map(pathlib.Path, sys.argv[1:])
destination.write_text(render(source.read_text(encoding="utf-8")), encoding="ascii")

View file

@ -0,0 +1,84 @@
# Run in elevated Windows PowerShell 5.1 and PowerShell 7 with operator-installed
# OpenSSL 3 on PATH. Loads only verifier functions; never installs an agent.
$ErrorActionPreference = "Stop"
$Template = Join-Path $PSScriptRoot "../../server/internal/services/templates/install/scripts/windows.ps1.tmpl"
$Tokens = $null
$ParseErrors = $null
$AST = [System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path $Template).Path, [ref]$Tokens, [ref]$ParseErrors)
if ($ParseErrors.Count -ne 0) { throw ($ParseErrors | Out-String) }
$Names = @("Convert-HexToBytes", "Stop-Install", "Remove-InstallerScratchDirectory",
"New-InstallerScratchDirectory", "Test-Ed25519Signature", "Assert-Ed25519Verifier")
foreach ($Name in $Names) {
$Function = $AST.Find({ param($Node)
$Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name
}, $true)
if (-not $Function) { throw "Missing function: $Name" }
. ([scriptblock]::Create($Function.Extent.Text))
}
Assert-Ed25519Verifier
$Key = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
$Sig = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
$Message = [byte[]]@(0x72)
$WrongKey = Convert-HexToBytes "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
if (Test-Ed25519Signature $Message $Sig $WrongKey) { throw "Wrong key accepted" }
$Sig[0] = $Sig[0] -bxor 1
if (Test-Ed25519Signature $Message $Sig $Key) { throw "Tampered signature accepted" }
$Rejected = $false
try { Test-Ed25519Signature $Message ([byte[]]@(1)) $Key | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Malformed signature accepted" }
$Rejected = $false
try { Test-Ed25519Signature $Message $Sig ([byte[]]@(1)) | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Malformed key accepted" }
$script:Ed25519OpenSSL = $null
$Rejected = $false
try { Test-Ed25519Signature $Message $Sig $Key | Out-Null } catch { $Rejected = $true }
if (-not $Rejected) { throw "Missing verifier accepted" }
# Cleanup must report its own trouble as a warning and leave the real failure
# standing. Removing an absent directory is the cheapest way to force that path.
$Warnings = @()
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningVariable +Warnings -WarningAction SilentlyContinue
if ($Warnings.Count -eq 0) { throw "Cleanup of a missing directory raised no warning" }
$script:Ed25519OpenSSL = $null
$Primary = $null
try {
try { throw "PRIMARY FAILURE" } finally {
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningAction SilentlyContinue
}
} catch { $Primary = $_.Exception.Message }
if ($Primary -ne "PRIMARY FAILURE") { throw "Cleanup masked the primary failure: got '$Primary'" }
# Stop-Install exits; run it in a child PowerShell so this script survives.
# The regression it guards: with $ErrorActionPreference = "Stop", a bare
# Write-Error inside the desktop try/catch threw, was caught by that catch,
# printed as a skip warning, and the installer exited 0 on a hash mismatch.
$Self = (Get-Process -Id $PID).Path
$Probe = @"
`$ErrorActionPreference = "Stop"
$($AST.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Stop-Install" }, $true).Extent.Text)
try {
Stop-Install @("REFUSAL LINE 1", "REFUSAL LINE 2", "REFUSAL LINE 3")
} catch {
Write-Host "SWALLOWED BY CATCH"
}
Write-Host "CONTINUED PAST REFUSAL"
"@
$ProbeFile = Join-Path ([System.IO.Path]::GetTempPath()) "redflag-probe-$([guid]::NewGuid().ToString('N')).ps1"
Set-Content -LiteralPath $ProbeFile -Value $Probe
try {
$Output = & $Self -NoProfile -File $ProbeFile 2>&1 | Out-String
$Code = $LASTEXITCODE
} finally {
Remove-InstallerScratchDirectory -Path $ProbeFile
}
if ($Code -ne 1) { throw "Refusal exited $Code, expected 1" }
if ($Output -match "SWALLOWED BY CATCH") { throw "An enclosing catch swallowed the refusal" }
if ($Output -match "CONTINUED PAST REFUSAL") { throw "Installer continued past a refusal" }
foreach ($n in 1..3) {
if ($Output -notmatch "REFUSAL LINE $n") { throw "Diagnostic line $n never reached the operator" }
}
Write-Host "PASS: exact installer functions accept valid signatures and reject tampering, wrong keys, malformed inputs, and missing verifier; cleanup warns without masking."

View file

@ -0,0 +1,35 @@
import importlib.util
import pathlib
import unittest
from msi_version import product_version
spec = importlib.util.spec_from_file_location("render_license", pathlib.Path(__file__).with_name("render-license.py"))
license_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(license_module)
class BuildInputs(unittest.TestCase):
def test_revision_upgrades_are_distinct_and_ordered(self):
releases = ["0.2.9.5", "0.2.9.6", "0.2.10", "0.3.0", "1.0.0"]
versions = [tuple(map(int, product_version(v).split("."))) for v in releases]
self.assertEqual(versions, sorted(set(versions)))
self.assertGreater(versions[0], (0, 2, 9)) # Previous MSI scheme.
self.assertEqual(product_version("0.2.9.6"), "0.2.906")
def test_three_part_release_equals_revision_zero(self):
self.assertEqual(product_version("0.3.1"), product_version("0.3.1.0"))
def test_out_of_range_or_nonrelease_versions_are_rejected(self):
for version in ["0.2.9.100", "256.1.0", "1.256.0", "1.0.656", "1.0.655.36", "0.2.9-rc1", "dev", "1.2", "1.2.3.4.5"]:
with self.subTest(version=version), self.assertRaises(ValueError):
product_version(version)
def test_license_escapes_rtf_syntax_without_losing_text(self):
rendered = license_module.render("{license}\\path\nsecond line")
self.assertIn(r"\{license\}\\path\par", rendered)
self.assertIn("second line", rendered)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Check Server package ownership and wizard tables after custody verification."""
import subprocess
import sys
def export(msi, table):
return subprocess.check_output(["msiinfo", "export", msi, table], text=True)
def rows(msi, table):
lines = export(msi, table).splitlines()
return [dict(zip(lines[0].split("\t"), line.split("\t"))) for line in lines[3:] if line]
def verify(msi):
properties = {row["Property"]: row["Value"] for row in rows(msi, "Property")}
assert properties["ProductName"] == "RedFlag Server", "wrong product"
assert properties["UpgradeCode"].lower().strip("{}") == "8f3c9e6a-4b1d-4a7f-9c2e-1d6a8b2f5e91", "Server upgrade identity changed"
files = {row["FileName"].split("|")[-1] for row in rows(msi, "File")}
assert files == {"redflag-server.exe", "redflag.env.example"}, f"unexpected Server payload: {files}"
assert {row["Feature"] for row in rows(msi, "Feature")} == {"ServerFeature"}, "unexpected product mode"
components = {row["Component"]: row for row in rows(msi, "Component")}
assert int(components["ServerBinary"]["Attributes"]) & 256, "Server component is not 64-bit"
services = rows(msi, "ServiceInstall")
assert len(services) == 1 and services[0]["Name"] == "RedFlagServer", "unexpected service ownership"
controls = rows(msi, "ServiceControl")
assert len(controls) == 1 and controls[0]["Name"] == "RedFlagServer", "unexpected service control"
events = int(controls[0]["Event"])
assert not events & (1 | 16), "MSI must not start an unconfigured service"
assert events & (2 | 32 | 128) == (2 | 32 | 128), "stop/remove lifecycle missing"
dialogs = {row["Dialog"] for row in rows(msi, "Dialog")}
assert {"ServerWelcome", "ServerLicense", "ServerMaintenance", "ServerProgress", "ServerComplete", "ServerFailed", "ServerCancelled"} <= dialogs, "wizard incomplete"
controls_text = export(msi, "Control")
assert "GNU AFFERO GENERAL PUBLIC LICENSE" in controls_text, "full license text missing"
assert "How to Apply These Terms to Your New Programs" in controls_text, "license text truncated"
summary = subprocess.check_output(["msiinfo", "suminfo", msi], text=True)
assert "x64;" in summary, "Server AMD64 payload needs an x64 MSI"
print("Server package boundary verified; Windows UI and installation still require native acceptance.")
if __name__ == "__main__":
try:
verify(sys.argv[1])
except (AssertionError, KeyError, ValueError, subprocess.CalledProcessError) as error:
sys.exit(f"Server package boundary failed: {error}")

View file

@ -0,0 +1,44 @@
package main
import (
"context"
"errors"
"net"
"net/http"
"time"
)
// Report service readiness only after initialization and a successful bind.
// SCM stop and console signals use the same HTTP drain and deferred cleanup.
func serveHTTP(ctx context.Context, ready func(), addr string, handler http.Handler) error {
if err := ctx.Err(); err != nil {
return nil
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
server := &http.Server{Handler: handler}
stopped := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(stopped)
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
_ = server.Close()
}
case <-finished:
}
}()
ready()
err = server.Serve(listener)
close(finished)
<-stopped
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}

View file

@ -0,0 +1,57 @@
package main
import (
"context"
"net"
"net/http"
"testing"
"time"
)
func TestNativeHTTPStopsOnServiceCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- serveHTTP(ctx, func() { close(ready) }, "127.0.0.1:0", http.NewServeMux())
}()
select {
case <-ready:
case err := <-done:
t.Fatalf("server exited before readiness: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("server never reported readiness")
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("service stop: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("HTTP server did not stop")
}
}
func TestNativeHTTPBindFailureDoesNotReportReady(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
ready := false
err = serveHTTP(context.Background(), func() { ready = true }, listener.Addr().String(), http.NewServeMux())
if err == nil || ready {
t.Fatalf("occupied address: err=%v ready=%v", err, ready)
}
}
func TestNativeHTTPCancelledStartupDoesNotReportReady(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
ready := false
if err := serveHTTP(ctx, func() { ready = true }, "127.0.0.1:0", http.NewServeMux()); err != nil || ready {
t.Fatalf("cancelled startup: err=%v ready=%v", err, ready)
}
}

View file

@ -11,11 +11,13 @@ import (
"log"
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/Fimeg/RedFlag/server/internal/api/handlers"
@ -107,7 +109,7 @@ func isSetupComplete(cfg *config.Config, signingService *services.SigningService
return true
}
func startWelcomeModeServer() {
func startWelcomeModeServer(ctx context.Context, ready func()) {
setupHandler := handlers.NewSetupHandler("/app/config")
router := gin.Default()
@ -140,7 +142,7 @@ func startWelcomeModeServer() {
log.Printf("Welcome mode server started on :8080")
log.Printf("Waiting for configuration...")
if err := router.Run(":8080"); err != nil {
if err := serveHTTP(ctx, ready, ":8080", router); err != nil {
log.Fatal("Failed to start welcome mode server:", err)
}
}
@ -169,6 +171,24 @@ func main() {
return
}
if !migrate {
handled, err := runWindowsService(func(ctx context.Context, ready func()) {
runServer(ctx, ready, false)
})
if err != nil {
log.Fatal("Windows service failed: ", err)
}
if handled {
return
}
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runServer(ctx, func() {}, migrate)
}
func runServer(ctx context.Context, ready func(), migrate bool) {
// Load configuration
cfg, err := config.Load()
if err != nil {
@ -177,7 +197,7 @@ func main() {
log.Printf("Or configure via web interface at: http://localhost:8080/setup")
// Start welcome mode server
startWelcomeModeServer()
startWelcomeModeServer(ctx, ready)
return
}
@ -197,8 +217,7 @@ func main() {
// Handle migrate-only flag
if migrate {
migrationsPath := filepath.Join("internal", "database", "migrations")
if err := db.Migrate(migrationsPath); err != nil {
if err := db.MigrateEmbedded(); err != nil {
log.Fatal("Migration failed:", err)
}
fmt.Printf("[OK] Database migrations completed\n")
@ -206,8 +225,7 @@ func main() {
}
// Run migrations — abort on failure (F-B1-11 fix)
migrationsPath := filepath.Join("internal", "database", "migrations")
if err := db.Migrate(migrationsPath); err != nil {
if err := db.MigrateEmbedded(); err != nil {
log.Fatalf("[ERROR] [server] [database] migration_failed error=%q — server cannot start with incomplete schema", err)
}
log.Printf("[INFO] [server] [database] migrations_complete")
@ -376,7 +394,7 @@ func main() {
log.Printf("Server setup incomplete - starting welcome mode")
log.Printf("Setup required: Admin credentials, signing keys, and database configuration")
log.Printf("Access setup at: http://%s:%d/setup", serverAddr, cfg.Server.Port)
startWelcomeModeServer()
startWelcomeModeServer(ctx, ready)
return
}
@ -1015,7 +1033,6 @@ func main() {
}
// Load subsystems into queue
ctx := context.Background()
if err := subsystemScheduler.LoadSubsystems(ctx); err != nil {
log.Printf("Warning: Failed to load subsystems: %v", err)
} else {
@ -1135,7 +1152,7 @@ func main() {
fmt.Printf("Admin interface: http://%s:%d/admin\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("Dashboard: http://%s:%d\n\n", cfg.Server.Host, cfg.Server.Port)
if err := router.Run(addr); err != nil {
if err := serveHTTP(ctx, ready, addr, router); err != nil {
log.Fatal("Failed to start server:", err)
}
}

View file

@ -0,0 +1,9 @@
//go:build !windows
package main
import "context"
func runWindowsService(run func(context.Context, func())) (bool, error) {
return false, nil
}

View file

@ -0,0 +1,69 @@
//go:build windows
package main
import (
"context"
"time"
"golang.org/x/sys/windows/svc"
)
const serverServiceName = "RedFlagServer"
type windowsServerService struct {
run func(context.Context, func())
}
func runWindowsService(run func(context.Context, func())) (bool, error) {
service, err := svc.IsWindowsService()
if err != nil || !service {
return service, err
}
return true, svc.Run(serverServiceName, &windowsServerService{run: run})
}
func (s *windowsServerService) Execute(_ []string, requests <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
done := make(chan struct{})
status := svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 10000}
changes <- status
go func() {
defer close(done)
s.run(ctx, func() { close(ready) })
}()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ready:
ready = nil
if ctx.Err() == nil {
status = svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
changes <- status
}
case request := <-requests:
switch request.Cmd {
case svc.Interrogate:
changes <- status
case svc.Stop, svc.Shutdown:
status = svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 10000}
changes <- status
cancel()
}
case <-ticker.C:
if status.State == svc.StartPending || status.State == svc.StopPending {
status.CheckPoint++
changes <- status
}
case <-done:
if ctx.Err() == nil {
return true, 1 // An unsolicited exit is not a successful service stop.
}
return false, 0
}
}
}

View file

@ -104,8 +104,17 @@ func TestWindowsInstallScriptUsesResolvedVersionAndRuntimeServerURL(t *testing.T
if !strings.Contains(body, "function Test-Ed25519Signature") {
t.Fatalf("installer does not include Ed25519 verifier helper")
}
if !strings.Contains(body, "binary signature not checked (manifest hash pin already enforced)") {
t.Fatalf("installer does not distinguish missing Ed25519 verifier from tampering")
// The installer no longer continues without a verifier, so the warning this
// once asserted is gone. Its point survives the change: an unavailable
// verifier must be reported as a missing prerequisite, not as tampering.
if strings.Contains(body, "signature not checked") {
t.Fatalf("installer still continues when no Ed25519 verifier is available")
}
if !strings.Contains(body, "function Assert-Ed25519Verifier") {
t.Fatalf("installer does not prove the Ed25519 verifier before installing")
}
if !strings.Contains(body, "A working operator-installed OpenSSL 3 Ed25519 verifier is required. No agent files or services were changed.") {
t.Fatalf("installer does not distinguish an unavailable Ed25519 verifier from tampering")
}
if strings.Contains(body, `Administrators:(OI)(CI)F`) {
t.Fatalf("installer uses localized/early ACL grant that can block registration")

View file

@ -309,7 +309,11 @@ func loadOrCreateKey(name string) (string, error) {
return v, nil
}
dir := getEnv("REDFLAG_DATA_DIR", "/app/data")
defaultDataDir := "/app/data"
if runtime.GOOS == "windows" {
defaultDataDir = filepath.Join(os.Getenv("ProgramData"), "RedFlag", "server")
}
dir := getEnv("REDFLAG_DATA_DIR", defaultDataDir)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create data dir %s: %w", dir, err)
}
@ -332,7 +336,6 @@ func loadOrCreateKey(name string) (string, error) {
return encoded, nil
}
// RunSetupWizard is deprecated - configuration is now handled via web interface
func RunSetupWizard() error {
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")

View file

@ -1,10 +1,11 @@
package database
import (
"embed"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@ -14,6 +15,20 @@ import (
_ "github.com/lib/pq"
)
// Native packages carry their schema in the same binary as the server.
// Never depend on the service manager's working directory for migrations.
//
//go:embed migrations/*.up.sql
var embeddedMigrations embed.FS
func (db *DB) MigrateEmbedded() error {
source, err := fs.Sub(embeddedMigrations, "migrations")
if err != nil {
return fmt.Errorf("open embedded migrations: %w", err)
}
return db.migrateFS(source)
}
// Connection-pool defaults. Sized for a single well-tuned server fronting 50200
// agents (SCALE-001 S1). The old 25/5 ceiling starved around ~30 concurrent agents
// once the scheduler workers, sweeps, syncer, reconciler, and request handlers all
@ -73,6 +88,10 @@ func envInt(key string, def int) int {
// Migrate runs database migrations with proper tracking and safety
func (db *DB) Migrate(migrationsPath string) error {
return db.migrateFS(os.DirFS(migrationsPath))
}
func (db *DB) migrateFS(source fs.FS) error {
// Create migrations table if it doesn't exist
createTableSQL := `
CREATE TABLE IF NOT EXISTS schema_migrations (
@ -90,7 +109,7 @@ func (db *DB) Migrate(migrationsPath string) error {
}
// Read migration files
files, err := os.ReadDir(migrationsPath)
files, err := fs.ReadDir(source, ".")
if err != nil {
return fmt.Errorf("failed to read migrations directory: %w", err)
}
@ -119,8 +138,7 @@ func (db *DB) Migrate(migrationsPath string) error {
}
// Read migration file
path := filepath.Join(migrationsPath, filename)
content, err := os.ReadFile(path)
content, err := fs.ReadFile(source, filename)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)
}

View file

@ -0,0 +1,37 @@
package database
import (
"bytes"
"io/fs"
"os"
"path/filepath"
"testing"
)
func TestNativeMigrationPayloadMatchesSource(t *testing.T) {
sources, err := filepath.Glob("migrations/*.up.sql")
if err != nil || len(sources) == 0 {
t.Fatalf("migration source inventory: %v (%d files)", err, len(sources))
}
embedded, err := fs.Glob(embeddedMigrations, "migrations/*.up.sql")
if err != nil || len(embedded) != len(sources) {
t.Fatalf("embedded inventory: %v (%d files, want %d)", err, len(embedded), len(sources))
}
for _, name := range sources {
want, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
got, err := embeddedMigrations.ReadFile(filepath.ToSlash(name))
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("embedded migration differs: %s: %v", name, err)
}
}
// Service startup may run from System32, not the checkout or install dir.
t.Chdir(t.TempDir())
for _, name := range embedded {
if _, err := embeddedMigrations.ReadFile(name); err != nil {
t.Fatalf("migration depends on working directory: %s: %v", name, err)
}
}
}

View file

@ -95,7 +95,7 @@ func TestServerStartsAfterMigrationFailure(t *testing.T) {
}
// Must now use log.Fatalf for migration failure
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
migrationBlock := extractBlock(src, "db.MigrateEmbedded()", `migrations_complete`)
if migrationBlock == "" {
t.Fatal("[ERROR] [server] [database] cannot find migration block in main.go")
}
@ -125,7 +125,7 @@ func TestServerMustAbortOnMigrationFailure(t *testing.T) {
}
src := string(content)
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
migrationBlock := extractBlock(src, "db.MigrateEmbedded()", `migrations_complete`)
if migrationBlock == "" {
t.Fatal("[ERROR] [server] [database] cannot find migration block")
}

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"
}
if (-not $script:Ed25519OpenSSL) {
throw "OpenSSL 3 with Ed25519 support must be installed by the operator and available on PATH"
}
$Scratch = New-InstallerScratchDirectory
try {
$null = [System.Security.Cryptography.Ed25519]
} catch {
return $null
}
# 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 {
$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
$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"
}
if (Test-Ed25519Signature -Data ([byte[]]@(0x73)) -Signature $Signature -PublicKey $Public) {
throw "Ed25519 verifier accepted tampered data"
}
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
}
}
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,18 +291,154 @@ 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"
# Verify the download before changing an installed agent.
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
$StagingDir = New-InstallerScratchDirectory
$TmpBinary = Join-Path $StagingDir "redflag-agent.exe"
try {
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# --- 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 {
$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 {}
}
$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"]
}
# Check for existing installation in new location
if (Test-Path $ConfigPath) {
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
@ -263,7 +449,7 @@ if (Test-Path $ConfigPath) {
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} elseif (Test-Path $OldConfigPath) {
} elseif (Test-Path $OldConfigPath) {
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
$MigrationNeeded = $true
try {
@ -275,12 +461,12 @@ if (Test-Path $ConfigPath) {
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} else {
} else {
Write-Host "✓ Fresh installation" -ForegroundColor Green
}
}
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Check if config version indicates migration is needed
try {
if ([int]$ConfigVersion -lt 4) {
@ -303,10 +489,10 @@ if (-not $MigrationNeeded) {
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
}
}
}
}
# Handle migration if needed
if ($MigrationNeeded) {
# 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
@ -317,194 +503,44 @@ if ($MigrationNeeded) {
# Backup old configuration if it exists
if (Test-Path $OldConfigPath) {
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
Copy-Item $OldConfigPath $BackupDir -ErrorAction SilentlyContinue
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 SilentlyContinue
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') {
# 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
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}}"
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"]
}
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
}
# 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
# 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
}
Ensure-LocalApiGroup -GroupName $LocalApiGroup
# 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
}
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
} finally {
Remove-InstallerScratchDirectory -Path $StagingDir
}
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
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"
}
} catch {
Write-Error "Binary signature verification failed - possible tampering"
Write-Error "Detail: $($_.Exception.Message)"
Remove-Item $TmpBinary -Force
exit 1
}
} 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
# 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 {