source: advance the checked projection

Source-Sha: 194a59adfd6c901635da7a9c18253bb2210e2426
Policy-Sha: b9b0be0ee74e55f561a803b8aef6be3c2134f5a83b46816a069a372f2eb04f4d
Tree-Digest: 4697f2e281c1c6ecafff17cf6eeb391a0a1ac0fa6e5fc429f5b473f4579c252c
This commit is contained in:
Source publisher 2026-09-21 14:39:08 +00:00
commit ffdccba10f
42 changed files with 2516 additions and 554 deletions

View file

@ -10,18 +10,6 @@ on:
description: Stage a synthetic candidate for non-publishing internal admission
type: boolean
default: false
publish_source:
description: Explicitly publish the current internal public projection
type: boolean
default: false
public_projection_sha:
description: Exact 40-character internal public SHA authorized for publication
type: string
default: ""
mirror_downstreams:
description: Mirror the published Forge projection to optional downstreams
type: boolean
default: false
jobs:
release-contract:
@ -34,6 +22,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Verify candidate policy and custody rejection
run: python3 scripts/test-release-contract.py -v
- name: Test publication construction and admission
run: python3 -m unittest discover -s .publication -p 'test_*.py'
- name: Stage synthetic custody admission fixture
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.event.inputs.custody_admission == 'true'
env:
@ -366,181 +356,110 @@ jobs:
- name: Gate the public surface
run: |
set -euo pipefail
python3 -m unittest discover -s .publication -p 'test_surface_gate.py'
python3 -m unittest discover -s .publication -p 'test_surface*.py'
git -c credential.helper= -c http.extraheader= fetch --no-tags \
https://forge.caseytunturi.com/Fimeg/RedFlag.git refs/heads/public
previous=$(git rev-parse FETCH_HEAD)
python3 .publication/surface_gate.py \
--repo . --sha "$GITHUB_SHA" \
--manifest .publication/surface.json \
--previous "$previous" --require-pass \
--out /tmp/public-surface.md
cat /tmp/public-surface.md
grep -q '^\*\*Verdict: \(PASS\|REVIEW\)\*\*' /tmp/public-surface.md
grep -q '^\*\*Verdict: PASS\*\*' /tmp/public-surface.md
# An internal public push proves that a projection is safe to disclose; it
# does not disclose it. Publication requires a manual dispatch on the public
# branch with both the boolean authority and the exact tested SHA. The
# internal forge remains the only writer, and the result is read back
# anonymously before any optional downstream moves.
publish-forge:
# Publication writes run only in the fixed-command trusted service.
# No repository secret or Actions branch grants public ref authority.
publication-candidate:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [release-contract, go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins]
# Product development can remain green while disclosure is held for review.
# The retained receipt, not the overall badge, carries publication readiness.
runs-on: redflag-linux-build
needs: [go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins, public-history, public-surface]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true'
env:
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
path: control
persist-credentials: false
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.sha }}
path: source
fetch-depth: 0
- name: Publish and verify exact SHA
persist-credentials: false
- name: Record source job outcomes and prepare candidate
env:
AUTHORIZED_SHA: ${{ github.event.inputs.public_projection_sha }}
SOURCE_JOB_RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
: "${PUBLIC_FORGE_TOKEN:?PUBLIC_FORGE_TOKEN is required}"
expected="${GITHUB_SHA}"
[[ "$AUTHORIZED_SHA" =~ ^[0-9a-f]{40}$ ]]
if [ "$AUTHORIZED_SHA" != "$expected" ]; then
echo "::error::authorized projection $AUTHORIZED_SHA does not equal tested workflow SHA $expected"
exit 1
fi
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
# Ordinary publication is fast-forward only. .publication/EPOCH may
# authorise exactly one replacement, and only of the SHA it names, so
# the authorisation is spent the moment it is used.
epoch_replaces() {
[ -f .publication/EPOCH ] || return 1
awk '$1 == "replaces" { print $2 }' .publication/EPOCH
}
check_publishable() {
url="$1"
remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ -z "$remote_sha" ]; then
return 0
fi
if git cat-file -e "${remote_sha}^{commit}" 2>/dev/null &&
git merge-base --is-ancestor "$remote_sha" "$expected"; then
return 0
fi
authorised="$(epoch_replaces || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[publish] epoch authorised to replace $remote_sha"
EPOCH_LEASE="$remote_sha"
return 0
fi
echo "[publish] refusing non-fast-forward public history: $url" >&2
echo "[publish] remote is $remote_sha; .publication/EPOCH authorises ${authorised:-nothing}" >&2
exit 1
}
EPOCH_LEASE=""
check_publishable "$forge_url"
forge_auth="$(printf 'publisher-redflag:%s' "$PUBLIC_FORGE_TOKEN" | base64 -w0)"
if [ -n "$EPOCH_LEASE" ]; then
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push --force-with-lease="refs/heads/public:$EPOCH_LEASE" "$forge_url" public:public
else
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push "$forge_url" public:public
fi
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
# Fetch the anonymous Forgejo ref back into the checkout. Downstream
# receives this ref, not the internal checkout ref that happened to
# produce it.
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
echo "[publish] Forgejo anonymously serves exact tested SHA: $forge_sha"
# Downstreams reproduce the anonymously fetched Forgejo ref. They are
# deliberately best-effort: a missing credential or divergent history is a
# visible degraded mirror, never a failure of the canonical publication.
mirror-downstreams:
runs-on: redflag-linux-build
needs: [publish-forge]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true' && github.event.inputs.mirror_downstreams == 'true'
env:
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
mkdir evidence
source_sha=$(git -C source rev-parse HEAD)
python3 - <<'PY'
import json, os
from pathlib import Path
jobs = json.loads(os.environ["SOURCE_JOB_RESULTS"])
required = {"release-contract", "go-vet", "go-test", "rust-test", "cross-compile",
"web-build", "desktop-check", "installer-integrity", "dep-scan",
"commit-voice", "action-pins"}
assert set(jobs) == required
assert all(jobs[name]["result"] == "success" for name in required)
evidence = dict(source_commit=os.environ["GITHUB_SHA"],
repository=os.environ["GITHUB_REPOSITORY"],
run_id=os.environ["GITHUB_RUN_ID"],
dependency_results=jobs, source_ci_verified=False,
publication_authorized=False)
Path("evidence/source-ci.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n")
PY
git -C source -c credential.helper= -c http.extraheader= fetch --no-tags \
https://forge.caseytunturi.com/Fimeg/RedFlag.git refs/heads/public
previous=$(git -C source rev-parse FETCH_HEAD)
python3 control/.publication/vendor/prepare-publication.py \
--repo source --source "$source_sha" --policy-commit "$source_sha" \
--previous "$previous" --gate control/.publication/surface_gate.py \
--output evidence/candidate --report-only
- name: Scan candidate history with pinned gitleaks
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/gitleaks.tar.gz" \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
printf '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb %s\n' "$RUNNER_TEMP/gitleaks.tar.gz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks
git init --bare scan.git
git -C scan.git index-pack --stdin < evidence/candidate/candidate.pack
candidate=$(python3 -c 'import json; print(json.load(open("evidence/candidate/receipt.json"))["release_source_commit"])')
scan_status=0
# The reviewed allowlist rides with the control checkout, not the
# candidate, and the trusted publisher repeats this with its own pinned copy.
"$RUNNER_TEMP/gitleaks" git scan.git --redact --no-banner \
--config control/.publication/gitleaks.toml \
--log-opts="$candidate" || scan_status=$?
export PUBLICATION_SCAN_STATUS="$scan_status"
python3 - <<'PY'
import json, os
from pathlib import Path
path = Path("evidence/candidate/receipt.json")
receipt = json.loads(path.read_text())
source = json.loads(Path("evidence/source-ci.json").read_text())
assert source["source_commit"] == receipt["development_commit"]
scanned = os.environ["PUBLICATION_SCAN_STATUS"] == "0"
passed = receipt["state"] == "gates-passed" and scanned
receipt.update(source_ci_dependencies_passed=True, source_ci=source, secret_scanner_verified=scanned,
secret_scanner="gitleaks/8.30.1",
state="ready-for-trusted-verification" if passed else "blocked")
path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
PY
- name: Stage candidate in internal package custody
env:
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
run: |
python3 control/.publication/vendor/stage-publication.py \
--candidate evidence/candidate --source "$GITHUB_SHA" --run-id "$GITHUB_RUN_ID" \
--package-base "$GITHUB_SERVER_URL/api/packages/artifacts/generic/redflag-source"
- name: Retain internal receipt and candidate
if: always()
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
with:
fetch-depth: 0
- name: Mirror optional downstreams from Forgejo
run: |
set -euo pipefail
expected="${GITHUB_SHA}"
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
mirror_downstream() {
label="$1"
url="$2"
host="$3"
user="$4"
token="$5"
if [ -z "$token" ]; then
echo "::warning::[mirror] $label credential absent; Forgejo is published, $label is degraded"
return 0
fi
if ! remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"; then
echo "::warning::[mirror] cannot read $label public ref; Forgejo remains authoritative"
return 0
fi
lease=""
if [ -n "$remote_sha" ]; then
if ! git fetch --force --no-tags "$url" \
"refs/heads/public:refs/remotes/mirror-check/$label"; then
echo "::warning::[mirror] cannot fetch $label public ref; leaving it unchanged"
return 0
fi
if ! git merge-base --is-ancestor "$remote_sha" "$forge_sha"; then
# A mirror may follow the same epoch the forge just accepted,
# and only from the SHA that epoch names. Anything else is the
# divergence this branch has always refused.
authorised="$(awk '$1 == "replaces" { print $2 }' .publication/EPOCH 2>/dev/null || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[mirror] $label follows the authorised epoch from $remote_sha"
lease="$remote_sha"
else
echo "::warning::[mirror] refusing non-fast-forward $label history; recovery ref and explicit alignment required"
return 0
fi
fi
fi
auth="$(printf '%s:%s' "$user" "$token" | base64 -w0)"
if [ -n "$lease" ]; then
if ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push --force-with-lease="refs/heads/public:$lease" \
"$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label epoch push failed; Forgejo remains authoritative"
return 0
fi
elif ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push "$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label push failed; Forgejo remains authoritative"
return 0
fi
mirrored_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ "$mirrored_sha" != "$forge_sha" ]; then
echo "::warning::[mirror] $label SHA mismatch after push; Forgejo remains authoritative"
return 0
fi
echo "[mirror] $label agrees with Forgejo: $forge_sha"
}
mirror_downstream codeberg 'https://codeberg.org/Fimeg/RedFlag.git' codeberg.org Fimeg "$CODEBERG_TOKEN"
mirror_downstream github 'https://github.com/Fimeg/RedFlag.git' github.com Fimeg "$MIRROR_GITHUB_TOKEN"
name: publication-candidate-${{ github.run_id }}
path: evidence/
if-no-files-found: warn

View file

@ -1,5 +1,12 @@
name: desktop-windows
on:
push:
branches: [main]
paths:
- 'helper/**'
- 'agent/**'
- 'installer/windows/**'
- '.gitea/workflows/desktop-windows.yml'
workflow_dispatch:
inputs:
version:
@ -25,13 +32,41 @@ jobs:
DESKTOP_VERSION: ${{ github.event.inputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$output = Join-Path $env:RUNNER_TEMP ('redflag-desktop-' + [guid]::NewGuid().ToString())
if ([string]::IsNullOrWhiteSpace($env:DESKTOP_VERSION)) {
$cargoVersion = Select-String -Path desktop/Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
if (-not $cargoVersion) { throw 'Desktop Cargo version not found.' }
$env:DESKTOP_VERSION = $cargoVersion.Matches[0].Groups[1].Value
}
$output = Join-Path (Get-Location).Path 'desktop-windows-payload'
& ./installer/windows/build-desktop.ps1 -OutputDirectory $output -Version $env:DESKTOP_VERSION
if ($LASTEXITCODE -ne 0) { throw 'Native Desktop payload failed.' }
"DESKTOP_PAYLOAD=$output" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Check Windows Helper target
run: |
$ErrorActionPreference = 'Stop'
Push-Location helper
try {
cargo check --locked
if ($LASTEXITCODE -ne 0) { throw 'Windows Helper check failed.' }
cargo test --locked
if ($LASTEXITCODE -ne 0) { throw 'Windows Helper tests failed.' }
} finally { Pop-Location }
# The Agent's half of the fixed-task exchange is behind a windows build
# tag, so the Linux go-test job never compiles it. This is the only lane
# that can run it. Package-scoped: the rest of the Agent suite is proved
# on Linux and this worker is not a second general test host.
- name: Check Windows Agent transport
run: |
$ErrorActionPreference = 'Stop'
Push-Location agent
try {
go vet ./internal/supplychain/
if ($LASTEXITCODE -ne 0) { throw 'Windows Agent transport vet failed.' }
go test -v -count=1 ./internal/supplychain/
if ($LASTEXITCODE -ne 0) { throw 'Windows Agent transport tests failed.' }
} finally { Pop-Location }
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-windows-amd64
path: ${{ env.DESKTOP_PAYLOAD }}
path: desktop-windows-payload
if-no-files-found: error
retention-days: 30

View file

@ -19,49 +19,58 @@ jobs:
promote:
runs-on: release-trusted
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Verify selected internal release tag
- name: Fetch and verify signed source from internal Gitea
id: release
env:
PROMOTE_TAG: ${{ github.event.inputs.tag }}
SELECTED_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
TAG=$PROMOTE_TAG
TAG=$SELECTED_TAG
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+){2,3}([.-][0-9A-Za-z]+)*$ ]]
[[ "$TAG" != */* ]]
git check-ref-format "refs/tags/$TAG"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test -s "$TOKEN_FILE"
# Bootstrap from the internal authority without downloading an action.
# Keep credentials out of URLs, git config and command arguments.
ASKPASS=$(mktemp)
trap 'rm -f "$ASKPASS"' EXIT
cat >"$ASKPASS" <<'ASKPASS_EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' release-redflag ;;
*Password*) cat "$GIT_TOKEN_FILE" ;;
*) exit 1 ;;
esac
ASKPASS_EOF
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TOKEN_FILE="$TOKEN_FILE" GIT_TERMINAL_PROMPT=0
RELEASE_SOURCE_DIR=$(mktemp -d "$PWD/.release-source.XXXXXX")
git init -q "$RELEASE_SOURCE_DIR"
cd "$RELEASE_SOURCE_DIR"
git remote add origin "${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
git -c credential.helper= fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = tag
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
git checkout --detach "$SOURCE_SHA"
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
test "$(git cat-file -t "refs/tags/$TAG")" = tag
git tag -v "$TAG"
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
VERSION=${TAG#v}
echo "RELEASE_SOURCE_DIR=$RELEASE_SOURCE_DIR" >> "$GITHUB_ENV"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
- name: Fetch accepted internal Gitea release bytes
env:
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
run: |
set -euo pipefail
cd "$RELEASE_SOURCE_DIR"
[[ "$ACCEPTED_RECEIPT_SHA256" =~ ^[0-9a-f]{64}$ ]]
command -v jq >/dev/null
@ -120,7 +129,10 @@ jobs:
>/tmp/actual-release-assets.sorted
diff -u /tmp/expected-release-assets.sorted /tmp/actual-release-assets.sorted
python3 scripts/release-contract.py verify artifacts/internal "$VERSION" "$SOURCE_SHA" "$TAG"
# Use the same declared builder identity as assembly and custody.
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" \
python3 scripts/release-contract.py verify artifacts/internal "$VERSION" "$SOURCE_SHA" "$TAG"
echo "Accepted internal alpha $TAG receipt $RECEIPT_SHA256"
- name: Promote the same bytes to Forgejo
@ -131,6 +143,7 @@ jobs:
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
run: |
set -euo pipefail
cd "$RELEASE_SOURCE_DIR"
FORGE_URL='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
FORGE_API='https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag'
TOKEN_FILE=/srv/release-trusted/.secrets/forge-redflag-release-token

View file

@ -2,6 +2,16 @@ name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Existing signed tag whose staged candidate should enter custody without rebuilding
required: true
type: string
receipt_sha256:
description: SHA-256 of the existing package candidate files.sha256
required: true
type: string
permissions:
contents: read
@ -10,6 +20,7 @@ jobs:
# Gate runs before any build. A tag that doesn't agree with the tree is a
# broken release waiting to happen — fail here, not after artifacts exist.
gate:
if: github.event_name == 'push'
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -189,6 +200,7 @@ jobs:
# Build the web UI once — it's the same embed for every platform.
web:
if: github.event_name == 'push'
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -217,6 +229,7 @@ jobs:
# binary and signed into the release manifest. If this fails, `release` never
# builds (it is in `needs`).
dep-scan:
if: github.event_name == 'push'
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -648,11 +661,14 @@ jobs:
- name: Assemble deterministic release candidate
id: assemble
env:
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
run: |
set -euo pipefail
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
VERSION=${GITHUB_REF#refs/tags/v}
scripts/assemble-release-candidate.sh \
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" scripts/assemble-release-candidate.sh \
artifacts candidate "$VERSION" "$GITHUB_SHA" "v$VERSION"
RECEIPT=$(sha256sum candidate/files.sha256 | awk '{print $1}')
echo "receipt_sha256=$RECEIPT" >> "$GITHUB_OUTPUT"
@ -668,58 +684,84 @@ jobs:
# rebuilds nothing and publishes only Casey's internal Gitea alpha release.
# Public promotion is a separate manual workflow after hands-on testing.
publish_internal:
if: always() && !cancelled() && ((github.event_name == 'push' && needs.stage_package.result == 'success') || github.event_name == 'workflow_dispatch')
runs-on: release-trusted
needs: [stage_package]
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Reverify release tag
- name: Fetch and verify signed source from internal Gitea
id: release
env:
SELECTED_TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
TAG="v$VERSION"
TAG=$SELECTED_TAG
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+){2,3}([.-][0-9A-Za-z]+)*$ ]]
git check-ref-format "refs/tags/$TAG"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test -s "$TOKEN_FILE"
# Bootstrap from the internal authority without downloading an action.
# Keep credentials out of URLs, git config and command arguments.
ASKPASS=$(mktemp)
trap 'rm -f "$ASKPASS"' EXIT
cat >"$ASKPASS" <<'ASKPASS_EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) printf '%s\n' release-redflag ;;
*Password*) cat "$GIT_TOKEN_FILE" ;;
*) exit 1 ;;
esac
ASKPASS_EOF
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TOKEN_FILE="$TOKEN_FILE" GIT_TERMINAL_PROMPT=0
RELEASE_SOURCE_DIR=$(mktemp -d "$PWD/.release-source.XXXXXX")
git init -q "$RELEASE_SOURCE_DIR"
cd "$RELEASE_SOURCE_DIR"
git remote add origin "${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
git -c credential.helper= fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = tag
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
git checkout --detach "$SOURCE_SHA"
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
test "$(git cat-file -t "refs/tags/$TAG")" = tag
test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA"
git tag -v "$TAG"
# The trusted lane checks ancestry independently of ordinary CI.
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/public
if [ "$GITHUB_EVENT_NAME" = push ]; then
test "$SOURCE_SHA" = "$GITHUB_SHA"
fi
# Check public ancestry independently of the build lane.
git -c credential.helper= fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/origin/public
echo "RELEASE_SOURCE_DIR=$RELEASE_SOURCE_DIR" >> "$GITHUB_ENV"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
- name: Fetch and verify package candidate
env:
PACKAGE_RECEIPT_SHA256: ${{ needs.stage_package.outputs.receipt_sha256 }}
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
PACKAGE_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 || needs.stage_package.outputs.receipt_sha256 }}
VERSION: ${{ steps.release.outputs.version }}
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
cd "$RELEASE_SOURCE_DIR"
scripts/fetch-release-candidate.sh \
artifacts/package artifacts "$VERSION" \
"$PACKAGE_RECEIPT_SHA256" \
/srv/release-trusted/.secrets/package-read-token
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$GITHUB_SHA" "v$VERSION"
# Builder identity is fixed independently of this runner's access URL.
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" \
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$SOURCE_SHA" "v$VERSION"
- name: Create Gitea release
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
cd "$RELEASE_SOURCE_DIR"
TAG="v$VERSION"
API="${GITHUB_SERVER_URL}/api/v1"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token

3
.gitignore vendored
View file

@ -29,6 +29,9 @@ go.work
# Dependency directories (remove comment if using vendoring)
vendor/
# Go vendoring only. The publication rail's vendored tools are tracked
# source, and a new one must not be swallowed on its way into custody.
!.publication/vendor/
# Go build cache
.cache/

View file

@ -0,0 +1,46 @@
# Reviewed scanner decisions for the public source projection.
#
# The publication rail scans the candidate's whole reachable history, so a
# finding in already-published history would block every future publication,
# not only the one that introduced it. Nothing here can un-disclose bytes the
# Forge already serves; each entry records that a specific already-public
# location was read and judged not to be a credential.
#
# This file is part of the public surface on purpose: the decision should be
# auditable by the same people who audit the code. It extends the default rule
# set rather than replacing it, and every entry names one rule, one path and
# one commit. Anything new, anywhere else, still blocks.
[extend]
useDefault = true
[[allowlists]]
description = """
Documentation example response body. A truncated JWT ending in '...', shown so
a reader can see the shape of a registration response. Reviewed 2026-09-21.
"""
condition = "AND"
targetRules = ["generic-api-key"]
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
paths = ['''^RAF/flows/01-registration\.md$''']
[[allowlists]]
description = """
Install instructions. The header value is the literal placeholder 'your-token',
which the surrounding prose tells the reader to replace. Reviewed 2026-09-21.
"""
condition = "AND"
targetRules = ["curl-auth-header"]
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
paths = ['''^README\.md$''']
[[allowlists]]
description = """
Test fixture. A JWT deliberately signed 'invalidsig' by the test that proves
the auth middleware never writes a token to stdout. Its value is not a
credential anywhere; that is the point of the test. Reviewed 2026-09-21.
"""
condition = "AND"
targetRules = ["jwt"]
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
paths = ['''^server/internal/api/handlers/auth_middleware_leak_test\.go$''']

View file

@ -11,6 +11,7 @@
.publication/EPOCH
.publication/commit-voice-allowlist.json
.publication/commit_voice.py
.publication/gitleaks.toml
.publication/paths.txt
.publication/surface.json
.publication/surface_gate.py

View file

@ -24,7 +24,6 @@
],
"review_required": [
"RAF/README.md",
"RAF/components/04-helper.md",
"RAF/components/05-desktop.md",
"RAF/core/02-architecture-decisions.md",
"RAF/flows/06-update-lifecycle.md",

View file

@ -26,13 +26,10 @@ every one of its commits was scanned can still be a tree that should not be
public, because publication is a property of the tree, not of the diffs that
built it.
The severity model is deliberately harsher than the incremental scanner's.
There, a home path or a LAN address is a WARN: one line in one patch, and a
human is reading the patch anyway. Here the same finding is a DENY, because
nobody reads a whole tree, and because the finding means the path is standing
in the public product right now, not that it passed through once.
Absence of known-secret content is not authorization to publish.
Credential findings are hard boundaries. Private addresses and house hostnames
are advisory topology findings; an allowlisted source file may name them without
handing anyone a capability. Private identities, unadmitted paths, unsafe links,
and credentials still block. Absence of known-secret content is not authorization to publish.
Exceptions are narrow and auditable: path, rule, reason, review date. A rule
turned off globally is not an exception, it is a retreat, so this file has no
@ -84,12 +81,11 @@ class Rule:
self.severity = severity
# Capabilities deny because publishing one hands control to whoever reads it
# and no later cleanup takes it back. Topology denies here — see the module
# docstring — because in a tree it is a standing disclosure, not a transit.
# Capability rules cannot be waived by a path exception. Topology is advisory.
HARD_RULES = {"private-key", "bearer-token"}
CONTENT_RULES = (
Rule("private-key", "private key material",
r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY", DENY),
r"-----BEGIN (?:(?:RSA|DSA|EC|OPENSSH|PGP|ENCRYPTED) )?PRIVATE KEY", DENY),
# The value must be quoted. An unquoted run of letters after `authorization:`
# is a type name in every language that has types, and matching it made the
# gate cry wolf over `authorization: MutationAuthorization` on first run.
@ -100,7 +96,7 @@ CONTENT_RULES = (
Rule("rfc1918", "private LAN address",
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
r"|192\.168\.\d{1,3}\.\d{1,3}"
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", DENY),
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", NOTE),
# Case-sensitive on purpose. Every rule here is otherwise IGNORECASE, and
# a case-blind `/Users/` matched the phrase "sessions/users/seats" in a
# comment about loginctl. A macOS home is capitalised; a POSIX path segment
@ -108,7 +104,7 @@ CONTENT_RULES = (
Rule("home-path", "developer home directory",
r"/home/[a-z][a-z0-9_-]*|(?-i:/Users/[A-Za-z])|(?-i:C:\\\\Users\\\\)", DENY),
Rule("internal-host", "internal hostname or forge",
r"\bwiuf|\barchdev\b|\bwiufph\b", DENY),
r"\bwiuf|\barchdev\b|\bwiufph\b", NOTE),
# The internal domain only. An author's own public contact address is not a
# leak — it is on the security page on purpose, and matching it made the
# gate refuse commits for being signed by the person who wrote them.
@ -144,12 +140,7 @@ class Finding:
def redact(text, match):
"""Keep the shape, drop the value. A report is itself a publishable object."""
s, e = match.span()
line = text[max(0, s - 40):e + 40].replace("\n", " ").strip()
hit = match.group(0)
keep = 2 if len(hit) > 6 else 1
masked = hit[:keep] + "*" * max(1, len(hit) - keep * 2) + (hit[-keep:] if len(hit) > 6 else "")
return line.replace(hit, masked)[:150]
return "[matched content withheld]"
class Manifest:
@ -203,6 +194,8 @@ class Manifest:
def excepted(self, path, rule_id):
"""An exception names one path and one rule, and says why, and when."""
if rule_id in HARD_RULES:
return None
for exc in self.exceptions:
if exc.get("rule") != rule_id:
continue
@ -245,13 +238,15 @@ def top_level(repo, sha):
# ---------------------------------------------------------------- gate 1
def gate_path_authority(repo, sha, manifest, findings):
def gate_path_authority(repo, sha, manifest, findings, previous=""):
if run(repo, ["rev-parse", "--is-shallow-repository"]).strip() != "false":
findings.append(Finding(
"path-authority", DENY, "shallow-history", "-",
"complete history is required to establish path authority"))
return
entries = tree_entries(repo, sha)
prior = {e[4]: e[:3] for e in tree_entries(repo, previous)} if previous else {}
changed = {e[4] for e in entries if prior.get(e[4]) != e[:3]}
present = {entry[4] for entry in entries}
origins = {path: sha for path in present}
commits = run(repo, ["rev-list", sha]).splitlines()
@ -277,7 +272,7 @@ def gate_path_authority(repo, sha, manifest, findings):
"path-authority", DENY, "unlisted-path", path,
f"no exact manifest entry admits this path in {location}"))
review_pattern = manifest.needs_review(path)
if review_pattern and path in present:
if review_pattern and path in changed:
findings.append(Finding(
"path-authority", REVIEW, "review-path", path,
f"manifest preserves human review under {review_pattern!r}"))
@ -302,6 +297,21 @@ def gate_path_authority(repo, sha, manifest, findings):
# ---------------------------------------------------------------- gate 2
def gate_new_surface(repo, sha, previous, manifest, findings):
# Symlinks and submodules are surface changes disguised as files.
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
if mode == "120000":
target = run(repo, ["show", f"{sha}:{path}"]).strip()
escapes = target.startswith("/") or ".." in target.split("/")
findings.append(Finding(
"new-surface", DENY if escapes else NOTE, "symlink", path,
"symlink target leaves the public tree" if escapes
else "symlink stays inside the public tree"))
if otype == "commit":
findings.append(Finding(
"new-surface", REVIEW, "submodule", path,
"submodule gitlink; its URL must resolve publicly"))
if not previous:
findings.append(Finding(
"new-surface", NOTE, "no-baseline", "-",
@ -326,19 +336,6 @@ def gate_new_surface(repo, sha, previous, manifest, findings):
"new-surface", REVIEW, "new-dotfile", path,
"new dotfile or dotdirectory entering the public tree"))
# Symlinks and submodules are surface changes disguised as files.
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
if mode == "120000":
target = run(repo, ["show", f"{sha}:{path}"]).strip()
escapes = target.startswith("/") or ".." in target.split("/")
findings.append(Finding(
"new-surface", DENY if escapes else NOTE, "symlink", path,
"symlink target leaves the public tree" if escapes
else "symlink stays inside the public tree"))
if otype == "commit":
findings.append(Finding(
"new-surface", REVIEW, "submodule", path,
"submodule gitlink; its URL must resolve publicly"))
def gate_submodule_urls(repo, sha, findings):
@ -381,17 +378,14 @@ def gate_file_class(repo, sha, manifest, findings):
# ---------------------------------------------------------------- gate 4
def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
def gate_content(repo, sha, manifest, findings, limit_per_rule=40, seen=None):
counts = {}
seen = set() if seen is None else seen
for _mode, otype, _oid, _size, path in tree_entries(repo, sha):
if otype != "blob" or BINARY_HINT.search(path):
continue
try:
text = run(repo, ["show", f"{sha}:{path}"])
except RuntimeError:
continue
if "\0" in text[:8000]:
if otype != "blob" or (_oid, path) in seen:
continue
seen.add((_oid, path))
text = run(repo, ["cat-file", "blob", _oid])
for rule in CONTENT_RULES:
m = rule.pattern.search(text)
if not m:
@ -413,33 +407,25 @@ def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
# ---------------------------------------------------------------- gate 5
def gate_history(repo, sha, manifest, findings, limit_per_rule=25):
sep = "\x1e"
out = run(repo, ["log", f"--format=%H{sep}%an <%ae>{sep}%s{sep}%b\x1d", sha])
counts = {}
total = 0
for record in out.split("\x1d"):
record = record.strip("\n")
if not record.strip():
continue
parts = record.split(sep)
if len(parts) < 4:
continue
h, ident, subject, body = parts[0], parts[1], parts[2], parts[3]
total += 1
blob = f"{ident}\n{subject}\n{body}"
if run(repo, ["rev-parse", "--is-shallow-repository"]).strip() != "false":
findings.append(Finding("history", DENY, "shallow-history", "-",
"complete history is required"))
return
commits = run(repo, ["rev-list", sha]).splitlines()
seen = set()
for revision in commits:
# Include deleted blobs, binary-named blobs and committer metadata.
gate_content(repo, revision, manifest, findings, seen=seen)
if revision != sha:
gate_file_class(repo, revision, manifest, findings)
blob = run(repo, ["cat-file", "commit", revision])
for rule in MESSAGE_RULES:
m = rule.pattern.search(blob)
if not m:
continue
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
if counts[rule.rule_id] > limit_per_rule:
continue
findings.append(Finding(
"history", rule.severity, rule.rule_id, h[:12],
f"{rule.description} in commit metadata: {subject[:60]}",
excerpt=redact(blob, m)))
if rule.pattern.search(blob):
findings.append(Finding("history", rule.severity, rule.rule_id,
revision[:12], rule.description + " in commit metadata"))
findings.append(Finding("history", NOTE, "reachable", "-",
f"{total} commits reachable from {sha[:12]}"))
f"{len(commits)} commits reachable from {sha[:12]}"))
# ---------------------------------------------------------------- gate 6
@ -508,6 +494,8 @@ def main():
ap.add_argument("--out", default="")
ap.add_argument("--inventory-out", default="")
ap.add_argument("--skip-history", action="store_true")
ap.add_argument("--require-pass", action="store_true", help="fail on review findings as well as denials")
ap.add_argument("--json-out", default="")
args = ap.parse_args()
sha = run(args.repo, ["rev-parse", args.sha]).strip()
@ -525,7 +513,7 @@ def main():
manifest = Manifest(json.loads(manifest_text), manifest_rel, args.repo, sha)
findings = []
gate_path_authority(args.repo, sha, manifest, findings)
gate_path_authority(args.repo, sha, manifest, findings, previous)
gate_new_surface(args.repo, sha, previous, manifest, findings)
gate_submodule_urls(args.repo, sha, findings)
gate_file_class(args.repo, sha, manifest, findings)
@ -546,7 +534,15 @@ def main():
fh.write(inventory(args.repo, sha))
print(f"wrote {args.inventory_out}")
return 1 if any(f.severity == DENY for f in findings) else 0
if args.json_out:
with open(args.json_out, "w") as fh:
json.dump({"candidate": sha, "previous": previous,
"complete_history": not args.skip_history,
"findings": [{"severity": f.severity, "gate": f.gate,
"rule": f.rule, "path": f.path} for f in findings]},
fh, sort_keys=True, indent=2)
return 1 if any(f.severity == DENY or (args.require_pass and f.severity == REVIEW)
for f in findings) else 0
if __name__ == "__main__":

View file

@ -84,6 +84,23 @@ class SurfaceGateTests(unittest.TestCase):
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("Verdict: PASS", result.stdout)
def test_review_is_required_only_for_changed_public_bytes(self):
old = self.candidate({"product/main.go": "package main\n"})
path = self.repo / ".publication/surface.json"
policy = json.loads(path.read_text())
policy["review_required"] = ["product/main.go"]
path.write_text(json.dumps(policy, indent=2) + "\n")
subprocess.run(["git", "-C", self.repo, "add", "."], check=True)
subprocess.run(["git", "-C", self.repo, "commit", "-qm", "test: mark review"], check=True)
unchanged = self.run_gate("HEAD", "--previous", old, "--require-pass")
self.assertEqual(0, unchanged.returncode, unchanged.stdout + unchanged.stderr)
(self.repo / "product/main.go").write_text("package changed\n")
subprocess.run(["git", "-C", self.repo, "add", "."], check=True)
subprocess.run(["git", "-C", self.repo, "commit", "-qm", "test: change reviewed bytes"], check=True)
changed = self.run_gate("HEAD", "--previous", old, "--require-pass")
self.assertEqual(1, changed.returncode)
self.assertIn("review-path", changed.stdout)
def test_unlisted_file_under_product_directory_fails(self):
sha = self.candidate(
{"product/main.go": "package main\n", "product/private.txt": "not admitted\n"},

View file

@ -16,6 +16,35 @@ the tip. Removing a path from disclosure therefore requires history that does
not contain it; a deletion commit alone is insufficient. Shallow history fails
closed. Internal development history is preserved separately.
Candidate preparation now runs separately from public ref movement. Its
internal receipt binds the source commit, both policy files, previous public
head, constructor and gate hashes, exact tree inventory, omitted source paths
and retained object pack. The source check requires the exact successful run
and every required job, including all cross-compile jobs; the candidate also
requires a separate secret scan. These unsigned receipts are evidence for the
trusted publisher to verify, not permission for a builder to publish.
Preparation records disclosure holds without failing otherwise successful
product CI. The pack and receipt enter internal package custody under the
source SHA and source run ID. Custody does not make a held candidate ready.
The shared contract now defines a fixed-command trusted publisher with
READY, BLOCKED and PUBLISHED receipts. It independently reconstructs this same
projection under protected tool/policy/workflow identities, rechecks source CI
and the public parent, then accepts only an authenticated human request naming
the exact READY receipt. A one-parent fast-forward compare-and-swap and fresh
anonymous commit/tree readback are required before it reports PUBLISHED.
The implementation and negative tests are not a claim that production
publisher credentials, branch protections or human authentication are enrolled.
The source workflow no longer contains public or mirror push jobs.
Private network addresses and house hostnames are advisory findings. Private
identities, unadmitted paths, unsafe links and credential findings still block.
Credential rules cannot be waived by a path exception. The gate scans reachable
historical blobs as well as the current tree and commit metadata, and withholds
matched content from reports. Policy-marked review paths require a new decision
only when their mode or bytes differ from the previous public head.
Reproduce the tree digest from a checked-out public commit with:
```sh

View file

@ -43,7 +43,7 @@ web/src/
│ ├── polling.ts # POLL.* constants — all intervals centralized
│ ├── store.ts, queryParser.ts, vulnerabilities.ts
│ └── client-logger.ts # Client errors ship to the server log (ETHOS #1)
├── desktop/ # Tauri tray-app variant (vite.desktop.config.ts)
├── desktop/ # Retained legacy Tauri web shell; not RedFlag Desktop
└── types/ # Shared TS types mirroring server models
```
@ -65,6 +65,10 @@ web/src/
- Mobile layout usable, not optimized
- Several UI coverage gaps tracked as `UI-*` tasks (not architecture — task tier)
The native RedFlag Desktop is the Qt/QML component described in
[components/05-desktop](05-desktop.md). `web/src/desktop` is retained legacy source, not a
second supported Desktop implementation or an architectural authority.
---
*Last reviewed: 2026-06-14*
*Last reviewed: 2026-09-14*

View file

@ -20,6 +20,28 @@ On Linux the agent invokes it through `sudo systemd-run --wait --property=Protec
### Windows Invocation (SEC-030, decided 2026-07-01)
**Current source boundary:** the native Windows payload now builds the Rust Helper. It
answers its version and supports verification-only binary and `MutationEnvelope` checks.
Windows-only source now opens each trust-path component without following reparse points,
checks SYSTEM/Administrators ownership and denies write grants to other principals, rejects
hard-linked trust files, and creates write-through, one-shot authorization claims. The fixed
task dispatcher reads an envelope from its protected exchange, pins the installed authority
placement, and uses the shared verifier. The wrapper carrying that envelope is decoded
before the envelope itself, so a request the Helper could read but whose envelope it could
not is refused under its own validated request id instead of leaving the caller an unknown
outcome; a wrapper too malformed to establish an identity still writes no result, because
the Helper does not invent one. A shared Go/Rust fixture pins that wrapper on both sides.
The Agent reaches this path read-only through `redflag-agent -verify-envelope`, which mints
nothing, admits no backend, and passes no command or path to the privileged process.
Windows mint and backend execution still return typed refusals.
Native acceptance of the *installed* rail — ACL enforcement, service identity, task
definition, and exchange roots — remains open: the transport is covered by tests, not by a
witness on a provisioned host. Replay refusal is likewise open and deliberately unexercised,
because verification must not consume an authorization; the claim belongs at an execution
boundary that no admitted backend has yet. Low-privilege service migration and WUA / Winget
executors remain required before mutation authority is enabled.
Windows has no `systemd-run` equivalent for spinning up an ad-hoc transient privileged unit, so the helper is invoked through a **Scheduled Task, configured to run once as SYSTEM**. The agent's own service account has no standing right to mutate anything — it only holds a delegated "AllowedToRun" ACE on this one task definition (`schtasks /run /tn RedFlagHelper`), the direct Windows analogue of the Linux sudoers line that grants exactly one `systemd-run` invocation and nothing else.
Two elevation-model alternatives were considered and rejected:
@ -28,6 +50,54 @@ Two elevation-model alternatives were considered and rejected:
The task definition itself is provisioned **at agent-install time** by the (already-elevated) install script, the same moment Linux drops its sudoers entry — not self-provisioned by the agent on first gated operation, which would just relocate the "who grants the first elevation" problem rather than solve it.
`installer/windows/provision-helper.ps1` now prepares that installer-owned boundary. It
creates one triggerless `\RedFlag\Helper` task with a fixed Helper path and `run-request`
argument, grants the Agent service SID read/run rather than task mutation, and places the
Agent-writable request child beneath a non-Agent-writable Helper root beside SYSTEM-owned
trust, replay, and result directories. The staged
payload validates this description but does not run the provisioning script.
`run-request` accepts no command-line arguments. Its machine exchange is fixed at
`C:\ProgramData\RedFlag\helper`; the installer refuses alternate data roots for this
transport. It opens each component relative to a retained directory handle, rejects
reparse points and hard-linked files, bounds input size, and denies concurrent write/delete
sharing while reading. The RedFlag and Helper roots, authority file, and result directory
must pass owner/DACL checks. A handle lock serializes dispatcher instances.
The Agent writes `requests\pending.json`: `version`, a fresh UUID `request_id`, an action
(`verify-envelope` or `execute-envelope`), and the unchanged `envelope`. Unknown envelope
fields and duplicate struct fields are refused. Installer-owned `authority.json` contains
`version`, `target_id`, `authority_kind` (`standalone` or `fleet-server`), `authority_id`, and
`public_keys` (Ed25519 hex). This is local trust configuration. Provisioning accepts it
through `-AuthorityFile`, but refuses to replace an existing different authority; enrollment
and rotation need their own explicit transaction.
The result is a create-new `results\<request_id>.json` containing `version`, `request_id`,
`envelope_verified`, and the common joined `receipt`. Read-only verification reports
`decision=verified`, `executed=false`, and zero verified backend actions. Backend execution
currently reports `backend_not_migrated`; neither path consumes replay authorization.
Protected results are flushed and closed before the Agent can read them. Results stay as
local evidence; retention and timed-out request reconciliation remain follow-up work.
The Agent's Windows envelope executor triggers only `\RedFlag\Helper`, serializes its one
pending slot across processes, validates the full receipt join, and leaves an unresolved
slot intact after task-start failure, timeout, cancellation, or an invalid response. Task
start never counts as execution success. A later call first reads any pending request and
its protected result. A valid joined result retires the slot and is returned when the caller
retried the same action, authorization, and manifest; a different call may proceed while the
old result stays as evidence. Missing, malformed, or mismatched results keep the slot blocked
with its request ID for reconciliation. Windows standalone mint explicitly refuses until
fresh StepUp exists. New Agent service installs select the virtual
`NT SERVICE\RedFlagAgent` account and enable its service SID. Helper provisioning grants
that SID modify access to Agent state/logs, modify access to the untrusted request directory,
read access to results, and no access to authority/replay state. Existing SYSTEM services
use the explicit `-MigrateAgentService` transaction: enable the service SID, prepare ACL/task
ownership while LocalSystem is still available, stop the service, change its account, verify
SCM state, and restore its prior running state. Only an observed LocalSystem service is
migrated; another operator-owned identity is refused. If the new identity cannot start, the
script restores LocalSystem and its prior running state while retaining the prepared narrow
ACLs for a later retry. Native transport acceptance remains open.
The ACL lockdown described here is the v1 cut, not the final word — Casey's call ("that'll do for now"). Revisit if a real gap in the ACE-delegation model surfaces (see `docs/tasks/SEC-030-windows-privileged-mutation-helper.md` Open Questions for what's still unresolved: WUA's COM-driven install path, rollback ownership parity with Linux's `.bak` handling).
---

View file

@ -16,7 +16,12 @@ RedFlag's supply chain gate inverts the traditional defense model: instead of **
token. Standalone pacman approval uses a signed `MutationEnvelope`, exact closure archives,
detached package signatures, helper custody, and a joined receipt. Docker, Winget, and
Windows Update still use the signed-command path. Fleet pacman envelope delivery and kernel
enforcement against out-of-band root mutation are not wired.
enforcement against out-of-band root mutation are not wired. The native Windows Helper
can verify binaries and mutation envelopes but refuses mint and backend execution. A fixed
task transport now joins Agent requests to Helper results using handle-relative custody and
installer-owned authority selection. It has no admitted Windows mutation backend and lacks
native acceptance; service privilege migration and execution remain open. Verification does
not consume replay authority. See [Helper invocation](../components/04-helper.md).
---
@ -51,12 +56,12 @@ the unprivileged agent-side consumer. Its *role* as a runtime allow/deny RPC is
### Why this model (two tests it has to pass)
**Cross-platform.** Linux eBPF and macOS ESF can pause an exec and ask a daemon "may this
proceed?" Windows WDAC cannot — it is signature-based, with no runtime callback. A
decision-daemon model therefore has no Windows mapping. A capability token maps onto all
three identically: in every case the enforcement layer only needs to answer "is this
execution authorized," and a verified token + a privileged executor that the OS trusts is
platform-agnostic. We build for the platform with the tightest constraint.
**Cross-platform.** Linux eBPF and macOS ESF can observe or mediate execution at a kernel
boundary. Windows App Control (WDAC) instead decides which code may execute from policy; it
does not authorize one package transaction or protect every file and registry mutation that
an already-trusted process can make. The common primitive is the signed mutation
authorization consumed by a privileged executor. Platform enforcement narrows bypasses
around that executor, but each platform must state exactly what its kernel mechanism covers.
**Protects people.** Protection comes down to where the trust root lives. The signing key
lives at the server (the human-approval authority), off the host. An attacker who fully owns
@ -76,12 +81,15 @@ The two tests converge on the same answer, which is the signal it's right.
| Platform | Enforcement Mechanism | What It Blocks |
|----------|----------------------|----------------|
| **Linux** | eBPF (syscalls/sys_enter_execve) or AppArmor | apt, dnf, yum, pip, npm, bun, docker (CLI) |
| **Windows** | WDAC (Windows Defender Application Control) | winget, npm.cmd, pip.exe, choco, scoop |
| **Windows** | App Control (WDAC) for executable trust; RedFlag kernel driver for covered mutation transactions | Untrusted mutation code; covered file/registry writes when the driver exists |
| **macOS** | Endpoint Security Framework (ESF) | brew, pip, npm, bun, cargo |
**Target distinction:** kernel enforcement sits below userspace wrappers. The current eBPF
scaffold is not connected to the capability model, so RedFlag does not yet claim that an
out-of-band package-manager invocation is blocked.
**Target distinction:** App Control and transaction enforcement are different Windows
layers. WDAC constrains executable trust; it does not prove that trusted code performed an
authorized RedFlag transaction. SEC-030 owns the driver and transaction context needed to
deny covered file and registry mutations outside a verified envelope. Neither that driver
nor the current Linux eBPF scaffold is connected to the capability model, so RedFlag does
not yet claim kernel-level blocking on either platform.
### Trust Chain
@ -252,9 +260,10 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
verifies the local Arch keyring, and refuses downgrades or operation-name lies. Legacy
registry entries without local paths are not rehashed. The current unit is not
network-isolated.
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
package-manager execution except via the trusted executor. This is defense-in-depth design;
the present eBPF scaffold is not wired to the capability model.
- **Kernel layer (where applicable).** Linux eBPF and macOS ESF mediate covered execution.
Windows WDAC constrains executable trust, while a separate RedFlag driver is required for
covered transaction writes. This is defense-in-depth design; none of these transaction
enforcement paths is wired to the capability model today.
---
@ -283,9 +292,10 @@ runtime evidence support them.
rotated, replicated, or held by a federation/guild node without touching the agent↔helper
interface. This is the long-term cross-platform answer hidden in a one-line design choice.
5. **Kernel stops are defense-in-depth, not a prerequisite.** The capability model protects on
a host where eBPF/WDAC/ESF cannot be deployed (locked-down managed box, constrained
container). Kernel enforcement raises the cost of bypass; it does not gate whether the model
means anything. Partial deployment still moves a host out of the soft-target category.
a host where eBPF, a Windows transaction driver, or ESF cannot be deployed (locked-down
managed box, constrained container). Kernel enforcement raises the cost of bypass; it does
not gate whether the model means anything. Partial deployment still moves a host out of the
soft-target category.
6. **No doctrinal knobs.** Signing required and forward-only (no downgrade) are ETHOS doctrine,
not configurable. The token has no "skip verification" path.
@ -476,8 +486,9 @@ and kernel adapters remain.
## Footer: Assumptions & Connections
**Assumption:** The capability model is the floor; kernel-level primitives (eBPF, WDAC, ESF)
are defense-in-depth on top, not a prerequisite. Userspace wrappers alone are bypassable.
**Assumption:** The capability model is the floor; kernel-level transaction primitives
(eBPF, a Windows driver, ESF) are defense-in-depth on top, not a prerequisite. WDAC is a
separate executable-trust layer. Userspace wrappers alone are bypassable.
**Current:** The privileged executor has a narrow argv-only API, no shell, and a stripped
environment; the Agent that hands it capabilities is unprivileged and holds no signing key.
@ -494,6 +505,4 @@ the helper unit. Neither property applies globally until each migrated backend p
---
*Last reviewed: 2026-09-01*
*Last reviewed: 2026-08-26*
*Last reviewed: 2026-09-14*

View file

@ -18,6 +18,7 @@ type CLI struct {
Scan bool
Status bool
LocalStatus bool
VerifyEnvelope string
InitStandalone bool
ListUpdates bool
Version bool
@ -49,6 +50,7 @@ func ParseFlags() *CLI {
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
flag.StringVar(&cli.VerifyEnvelope, "verify-envelope", "", "Verify a signed mutation envelope through the installed privileged Helper and print its receipt")
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
flag.BoolVar(&cli.Version, "version", false, "Show version information")

View file

@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"log"
"os"
@ -48,6 +49,16 @@ func main() {
return
}
// Read-only proof of the privileged Helper transport (SEC-030). It runs
// before config load for the same reason -local-status does: driving the
// fixed task needs the Agent's own rights, not its protected config.
if cli.VerifyEnvelope != "" {
if err := HandleVerifyEnvelopeCommand(cli.VerifyEnvelope); err != nil {
log.Fatal("Envelope verification failed: ", err)
}
return
}
// Handle Windows service management commands
if HandleWindowsServiceCommands(cli) {
return
@ -91,8 +102,8 @@ func main() {
// Handle registration command
if cli.Register {
if cfg.IsStandalone() {
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
if cfg.IsStandalone() || cfg.PendingFleetJoin != nil {
log.Fatal("Registration refused: use RedFlag Desktop to complete the standalone fleet join")
}
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
log.Fatal("Registration failed:", err)
@ -133,6 +144,17 @@ func main() {
}
defer unlock()
// A Server may have accepted this identity before the process stopped
// during local authority replacement. Resume that exact transaction before
// evaluating mode; the helper is idempotent for the same Server key.
if cfg.PendingFleetJoin != nil {
receipt, err := handlers.CompletePendingFleetJoin(context.Background(), cfg, configPath)
if err != nil {
log.Fatalf("[FATAL] [agent] [fleet-join] recovery_failed request_id=%s error=%v", cfg.PendingFleetJoin.RequestID, err)
}
log.Printf("[INFO] [agent] [fleet-join] recovery_completed request_id=%s key_id=%s", receipt.RequestID, receipt.SigningKeyID)
}
// Check if registered
if !cfg.IsRegistered() && !cfg.IsStandalone() {
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
@ -147,7 +169,7 @@ func main() {
}
// Start agent service (console mode)
if err := agent.RunAgentLoop(cfg); err != nil {
if err := agent.RunAgentLoop(cfg, configPath); err != nil {
log.Fatal("Agent failed:", err)
}
}

View file

@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"time"
@ -50,7 +51,7 @@ func newCircuitBreaker(name string, cfg config.CircuitBreakerConfig) *circuitbre
}
// RunAgentLoop runs the main agent polling loop
func RunAgentLoop(cfg *config.Config) error {
func RunAgentLoop(cfg *config.Config, configPath string) error {
// Panic recovery for the main agent loop [TD-002]
defer recovery.Recover("agent_main_loop")
@ -65,6 +66,7 @@ func RunAgentLoop(cfg *config.Config) error {
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
Ctx: context.Background(),
EnableDesktop: true,
ConfigPath: configPath,
})
if err != nil {
return err
@ -89,6 +91,7 @@ type LoopContextOptions struct {
Ctx context.Context
StopCh <-chan struct{}
EnableDesktop bool
ConfigPath string
}
// NewLoopContext builds the canonical dependency graph for the agent polling
@ -98,6 +101,9 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
if opts.Ctx == nil {
opts.Ctx = context.Background()
}
if opts.ConfigPath == "" {
opts.ConfigPath = constants.GetAgentConfigPath()
}
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
@ -284,6 +290,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
TeeLogger: teeLogger,
EventBuffer: teeLogger.Buffer(),
StopCh: opts.StopCh,
ConfigPath: opts.ConfigPath,
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
"apt": aptCB,
"dnf": dnfCB,
@ -314,9 +321,10 @@ type LoopContext struct {
EventBuffer *event.Buffer
Ctx context.Context
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
ConfigPath string
}
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error, modeChanged <-chan struct{}) error {
recordLocalAgentStatus(ctx.Cfg, "standalone", false)
log.Printf("[INFO] [agent] [standalone] local_mode_started agent_id=%s", ctx.Cfg.AgentID)
if err := triggerScan("standalone-startup"); err != nil {
@ -335,6 +343,9 @@ func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
return nil
case <-ctx.StopCh:
return nil
case <-modeChanged:
log.Printf("[INFO] [agent] [fleet-join] standalone_loop_stopped restart_required=true")
return nil
case <-ticker.C:
if err := triggerScan("standalone-interval"); err != nil && !errors.Is(err, localapi.ErrScanInFlight) {
ctx.TeeLogger.Warning("agent", "standalone", "scan", "periodic_scan_not_started", map[string]interface{}{"error": err.Error()})
@ -351,6 +362,15 @@ func RunPollingLoop(loopCtx *LoopContext) error {
defer recovery.Recover("agent_polling_loop")
ctx := loopCtx
modeChanged := make(chan struct{})
var modeChangeOnce sync.Once
var fleetJoinInFlight atomic.Bool
var fleetJoinAccepted atomic.Bool
fleetJoinRequestID := ctx.Cfg.FleetJoinRequestID
stopStandaloneForFleet := func() {
fleetJoinAccepted.Store(true)
time.AfterFunc(750*time.Millisecond, func() { modeChangeOnce.Do(func() { close(modeChanged) }) })
}
// FEAT-002 write path: single-flight scan trigger for the local API.
// Authorization is the socket/pipe group ACL; the scan itself runs through
@ -379,6 +399,9 @@ func RunPollingLoop(loopCtx *LoopContext) error {
// typed failures into localapi sentinels for honest HTTP codes.
var approveInFlight atomic.Bool
approveUpdate := func(body []byte) (interface{}, error) {
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
return nil, fmt.Errorf("%w: fleet authority transition is in progress", localapi.ErrApprovalConflict)
}
if !approveInFlight.CompareAndSwap(false, true) {
return nil, fmt.Errorf("%w: approval already in flight", localapi.ErrApprovalConflict)
}
@ -415,6 +438,53 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
}
prepareFleetJoin := func() (interface{}, error) {
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
}
result, err := handlers.PrepareLocalFleetJoin(ctx.Cfg)
switch {
case err == nil:
return result, nil
case errors.Is(err, handlers.ErrFleetJoinMode):
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
case errors.Is(err, handlers.ErrFleetJoinUnavailable):
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
default:
return nil, err
}
}
joinFleet := func(body []byte) (interface{}, error) {
if fleetJoinAccepted.Load() || !fleetJoinInFlight.CompareAndSwap(false, true) {
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
}
defer fleetJoinInFlight.Store(false)
var req handlers.LocalFleetJoinRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("invalid fleet join request: %w", err)
}
next := *ctx.Cfg
next.FleetJoinRequestID = fleetJoinRequestID
result, err := handlers.HandleLocalFleetJoin(ctx.Ctx, &next, ctx.ConfigPath, req)
fleetJoinRequestID = next.FleetJoinRequestID
switch {
case err == nil:
stopStandaloneForFleet()
return result, nil
case errors.Is(err, handlers.ErrFleetJoinAccepted):
stopStandaloneForFleet()
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
case errors.Is(err, handlers.ErrFleetJoinMode):
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
case errors.Is(err, handlers.ErrFleetJoinUnavailable),
errors.Is(err, supplychain.ErrFleetAuthorityUnavailable):
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
default:
return nil, err
}
}
var onDesktopHealth func(version string, windowOpen bool)
if ctx.DesktopManager != nil {
onDesktopHealth = ctx.DesktopManager.RecordHealth
@ -447,14 +517,16 @@ func RunPollingLoop(loopCtx *LoopContext) error {
return response, nil
}
localAPIServer, err := localapi.Start(localapi.Options{
Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan,
ScanRunning: scanInFlight.Load,
ApproveUpdate: approveUpdate,
OnDesktopHealth: onDesktopHealth,
DockerProvider: dockerProvider,
EventsProvider: ctx.EventBuffer.ReadHistory,
Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan,
ScanRunning: scanInFlight.Load,
ApproveUpdate: approveUpdate,
PrepareFleetJoin: prepareFleetJoin,
JoinFleet: joinFleet,
OnDesktopHealth: onDesktopHealth,
DockerProvider: dockerProvider,
EventsProvider: ctx.EventBuffer.ReadHistory,
})
if err != nil {
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
@ -483,7 +555,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
if ctx.Cfg.IsStandalone() {
return runStandaloneLoop(ctx, triggerScan)
return runStandaloneLoop(ctx, triggerScan, modeChanged)
}
consecutiveFailures := 0

View file

@ -338,6 +338,38 @@ type RegisterResponse struct {
Config map[string]interface{} `json:"config"`
}
// FleetJoinRequest preserves a standalone Agent's UUID while registering that
// same machine with a Server. The seed stays on the host; only its current TOTP
// code crosses this endpoint.
type FleetJoinRequest struct {
RequestID uuid.UUID `json:"request_id"`
AgentID uuid.UUID `json:"agent_id"`
RegistrationToken string `json:"registration_token"`
TOTPCode string `json:"totp_code"`
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
Metadata map[string]string `json:"metadata"`
AvailableScanners []string `json:"available_scanners"`
DeviceType string `json:"device_type"`
DeviceModel string `json:"device_model"`
OSDistro string `json:"os_distro"`
}
type FleetJoinResponse struct {
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
SigningPublicKey string `json:"signing_public_key"`
SigningKeyID string `json:"signing_key_id"`
ServerURL string `json:"server_url"`
Config map[string]interface{} `json:"config"`
}
// Register registers the agent with the server
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
@ -388,6 +420,36 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
return &result, nil
}
// JoinFleet calls the dedicated one-way standalone transition endpoint. It
// does not mutate this Client's token because the caller must first persist the
// returned credentials and replace local helper authority.
func (c *Client) JoinFleet(req FleetJoinRequest) (*FleetJoinResponse, error) {
endpoint := fmt.Sprintf("%s/api/v1/fleet-join", c.baseURL)
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode fleet join request: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create fleet join request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("fleet join request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return nil, fmt.Errorf("fleet join failed: %s - %s", resp.Status, string(bodyBytes))
}
var result FleetJoinResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
return nil, fmt.Errorf("decode fleet join response: %w", err)
}
return &result, nil
}
// TokenRenewalRequest is the payload for token renewal using refresh token
type TokenRenewalRequest struct {
AgentID uuid.UUID `json:"agent_id"`
@ -1072,7 +1134,7 @@ type SystemInfoReport struct {
IPAddress string `json:"ip_address,omitempty"`
Processes int `json:"processes,omitempty"`
Uptime string `json:"uptime,omitempty"`
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
DeviceModel string `json:"device_model,omitempty"`
OSDistro string `json:"os_distro,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`

View file

@ -182,6 +182,13 @@ type Config struct {
// Desktop App Configuration
Desktop DesktopConfig `json:"desktop,omitempty"`
// Standalone to fleet transition. Non-nil means the Server has accepted
// this identity but local authority replacement has not fully converged.
PendingFleetJoin *PendingFleetJoin `json:"pending_fleet_join,omitempty"`
// Stable across retries so a lost Server response cannot turn one join into
// a second credential-minting request.
FleetJoinRequestID string `json:"fleet_join_request_id,omitempty"`
// Process Explorer Configuration
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
@ -199,6 +206,20 @@ type DesktopConfig struct {
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
}
// PendingFleetJoin is persisted only after the fleet server has accepted this
// standalone identity. It is the crash-recovery bridge between remote
// registration and the privileged helper replacing local authority.
type PendingFleetJoin struct {
RequestID string `json:"request_id"`
ServerURL string `json:"server_url"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
SigningPublicKey string `json:"signing_public_key"`
SigningKeyID string `json:"signing_key_id"`
CheckInInterval int `json:"check_in_interval,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
}
// Load reads configuration from multiple sources with priority order:
// 1. CLI flags
// 2. Environment variables
@ -776,21 +797,21 @@ func (c *Config) SetDegradedMode(enabled bool) error {
// IsRegistered checks if the agent is registered
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
return c.AgentID != uuid.Nil && c.Token != "" && c.RefreshToken != "" && c.PendingFleetJoin == nil
}
// IsStandalone reports whether this config has a stable local identity and no
// fleet credential. A partial fleet enrollment is not standalone: refresh or
// registration material must never silently become local mutation authority.
func (c *Config) IsStandalone() bool {
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == "" && c.PendingFleetJoin == nil
}
// InitializeStandalone gives a local-only Agent one durable UUID. It is
// idempotent, but refuses any fleet credential so provisioning cannot convert a
// fleet host into local authority by accident.
func (c *Config) InitializeStandalone() error {
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" || c.PendingFleetJoin != nil {
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
}
if c.AgentID != uuid.Nil {
@ -930,6 +951,13 @@ func mergeConfigPreservingDefaults(target, source *Config) {
if source.Desktop != (DesktopConfig{}) {
target.Desktop = source.Desktop
}
if source.PendingFleetJoin != nil {
pending := *source.PendingFleetJoin
target.PendingFleetJoin = &pending
}
if source.FleetJoinRequestID != "" {
target.FleetJoinRequestID = source.FleetJoinRequestID
}
// Version info
if source.Version != "" {

View file

@ -2,13 +2,16 @@ package crypto
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
@ -156,6 +159,60 @@ func saveCacheMetadata(meta *CacheMetadata) error {
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
}
// CacheFleetServerPublicKey installs the exact key returned by the fleet-join
// transaction. This bypasses TOFU cache reuse: a standalone host may have an
// unrelated stale cache, and that must not outrank the authority just adopted
// by the privileged helper.
func CacheFleetServerPublicKey(publicKeyHex, keyID string) error {
raw, err := hex.DecodeString(publicKeyHex)
if err != nil {
return fmt.Errorf("decode joined Server public key: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return fmt.Errorf("joined Server public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize)
}
digest := sha256.Sum256(raw)
computedID := hex.EncodeToString(digest[:16])
if keyID == "" || keyID != computedID {
return fmt.Errorf("joined Server key id mismatch: got %q want %q", keyID, computedID)
}
if err := removeCachedKeysExcept(keyID); err != nil {
return fmt.Errorf("retire pre-join Server key cache: %w", err)
}
key := ed25519.PublicKey(raw)
if err := cachePublicKey(key); err != nil {
return fmt.Errorf("cache joined Server primary key: %w", err)
}
if err := CachePublicKeyByID(keyID, key); err != nil {
return fmt.Errorf("cache joined Server key id: %w", err)
}
return saveCacheMetadata(&CacheMetadata{
KeyID: keyID, Version: 1, CachedAt: time.Now().UTC(), TTLHours: defaultCacheTTLHours,
})
}
func removeCachedKeysExcept(keyID string) error {
dir := getPublicKeyDir()
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
keep := filepath.Base(getKeyPathByID(keyID))
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, "server_public_key_") || name == keep {
continue
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
return err
}
}
return nil
}
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
// Implements Trust-On-First-Use (TOFU) with rotation awareness.

View file

@ -27,7 +27,7 @@ func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet", RefreshToken: "refresh"}, LocalApproveRequest{
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
})
if !errors.Is(err, ErrApprovalFleetMode) {

View file

@ -71,11 +71,15 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
"desktop binary not found at %s — local operations console will not appear", desktopPath)
}
// --- Autostart entry (Linux only) ---
// --- Desktop session launchers (Linux only) ---
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
check(fileExists(autostartPath),
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
compositorUnitPath := "/etc/systemd/user/redflag-desktop-compositor.service"
check(fileExists(compositorUnitPath),
"desktop compositor session launcher not found at %s — RedFlag Desktop will not start in Hyprland/Sway/River sessions", compositorUnitPath)
}
// --- Socket directory permissions ---

View file

@ -40,6 +40,10 @@ var (
ErrApprovalConflict = errors.New("localapi: approval conflict")
// ErrApprovalUnavailable → 503: no local authority on this host.
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
// ErrFleetJoinConflict maps mode, validation, and in-flight joins to 409.
ErrFleetJoinConflict = errors.New("localapi: fleet join conflict")
// ErrFleetJoinUnavailable maps a missing platform/helper path to 503.
ErrFleetJoinUnavailable = errors.New("localapi: fleet join unavailable")
)
// Options configures the local read-only API. Group, socket, and pipe defaults
@ -75,6 +79,11 @@ type Options struct {
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
// ErrApprovalUnavailable to control the HTTP status.
ApproveUpdate func(body []byte) (interface{}, error)
// PrepareFleetJoin creates the host-held proof seed. JoinFleet consumes the
// Server URL, one-seat join token, and that seed through the one-way helper
// transition. Both are disabled outside standalone mode.
PrepareFleetJoin func() (interface{}, error)
JoinFleet func(body []byte) (interface{}, error)
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
// agent can track app liveness/version — on Linux Desktop is autostart-
// launched and this is the only signal. Nil means reports are logged only.
@ -159,24 +168,26 @@ func (s *Server) Stop() {
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
approveUpdate func(body []byte) (interface{}, error)
onDesktopHealth func(version string, windowOpen bool)
systemInfo func() (*system.SystemInfo, error)
topProcesses func(limit int) ([]system.TopProcess, error)
monitorSnapshot func() (*system.ResourceSnapshot, error)
processes func() (*system.FullProcessSnapshot, error)
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
software func() (*system.SoftwareSnapshot, error)
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
connections func() (*system.ConnectionSnapshot, error)
services func() (*system.ServiceSnapshot, error)
docker func() (*DockerResponse, error)
events func() ([]*models.SystemEvent, error)
scanRunning func() bool
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
approveUpdate func(body []byte) (interface{}, error)
prepareFleetJoin func() (interface{}, error)
joinFleet func(body []byte) (interface{}, error)
onDesktopHealth func(version string, windowOpen bool)
systemInfo func() (*system.SystemInfo, error)
topProcesses func(limit int) ([]system.TopProcess, error)
monitorSnapshot func() (*system.ResourceSnapshot, error)
processes func() (*system.FullProcessSnapshot, error)
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
software func() (*system.SoftwareSnapshot, error)
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
connections func() (*system.ConnectionSnapshot, error)
services func() (*system.ServiceSnapshot, error)
docker func() (*DockerResponse, error)
events func() ([]*models.SystemEvent, error)
scanRunning func() bool
}
// DesktopStatusProvider allows the desktop manager to report its status.
@ -196,6 +207,7 @@ type IdentityResponse struct {
ConfigVersion string `json:"config_version,omitempty"`
CheckInInterval int `json:"check_in_interval"`
Registered bool `json:"registered"`
Mode string `json:"mode"`
}
type StatusResponse struct {
@ -277,24 +289,26 @@ type SecurityResponse struct {
func newHandler(opts Options) http.Handler {
h := &handler{
cfg: opts.Config,
loadCache: opts.LoadCache,
desktop: opts.DesktopProvider,
triggerScan: opts.TriggerScan,
approveUpdate: opts.ApproveUpdate,
onDesktopHealth: opts.OnDesktopHealth,
systemInfo: opts.SystemProvider,
topProcesses: opts.ProcessProvider,
monitorSnapshot: opts.MonitorProvider,
processes: opts.ProcessesProvider,
processDetail: opts.ProcessDetailProvider,
software: opts.SoftwareProvider,
packageDetail: opts.PackageDetailProvider,
connections: opts.ConnectionsProvider,
services: opts.ServicesProvider,
docker: opts.DockerProvider,
events: opts.EventsProvider,
scanRunning: opts.ScanRunning,
cfg: opts.Config,
loadCache: opts.LoadCache,
desktop: opts.DesktopProvider,
triggerScan: opts.TriggerScan,
approveUpdate: opts.ApproveUpdate,
prepareFleetJoin: opts.PrepareFleetJoin,
joinFleet: opts.JoinFleet,
onDesktopHealth: opts.OnDesktopHealth,
systemInfo: opts.SystemProvider,
topProcesses: opts.ProcessProvider,
monitorSnapshot: opts.MonitorProvider,
processes: opts.ProcessesProvider,
processDetail: opts.ProcessDetailProvider,
software: opts.SoftwareProvider,
packageDetail: opts.PackageDetailProvider,
connections: opts.ConnectionsProvider,
services: opts.ServicesProvider,
docker: opts.DockerProvider,
events: opts.EventsProvider,
scanRunning: opts.ScanRunning,
}
if h.systemInfo == nil {
h.systemInfo = func() (*system.SystemInfo, error) {
@ -345,6 +359,8 @@ func newHandler(opts Options) http.Handler {
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
mux.HandleFunc("/v1/actions/prepare-fleet-join", h.prepareFleetJoinAction)
mux.HandleFunc("/v1/actions/join-fleet", h.joinFleetAction)
return mux
}
@ -566,6 +582,14 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
}
mode := "incomplete"
if h.cfg.IsRegistered() {
mode = "fleet"
} else if h.cfg.IsStandalone() {
mode = "standalone"
} else if h.cfg.PendingFleetJoin != nil {
mode = "joining"
}
resp := IdentityResponse{
AgentID: h.cfg.AgentID.String(),
ServerURL: h.cfg.ServerURL,
@ -578,6 +602,7 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
ConfigVersion: h.cfg.Version,
CheckInInterval: h.cfg.CheckInInterval,
Registered: h.cfg.IsRegistered(),
Mode: mode,
}
writeJSON(w, resp)
}
@ -762,6 +787,69 @@ func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
}
}
func (h *handler) prepareFleetJoinAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.prepareFleetJoin == nil {
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
return
}
result, err := h.prepareFleetJoin()
switch {
case err == nil:
writeJSON(w, result)
case errors.Is(err, ErrFleetJoinConflict):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
case errors.Is(err, ErrFleetJoinUnavailable):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
default:
log.Printf("[ERROR] [agent] [localapi] fleet_join_prepare_failed error=%v", err)
http.Error(w, "fleet join preparation failed", http.StatusInternalServerError)
}
}
func (h *handler) joinFleetAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.joinFleet == nil {
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "request read failed", http.StatusBadRequest)
return
}
result, err := h.joinFleet(body)
switch {
case err == nil:
writeJSON(w, result)
case errors.Is(err, ErrFleetJoinConflict):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
case errors.Is(err, ErrFleetJoinUnavailable):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
default:
log.Printf("[ERROR] [agent] [localapi] fleet_join_failed error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
}
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {

View file

@ -13,93 +13,18 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
"github.com/gofrs/uuid/v5"
)
// RegisterAgent registers the agent with the server
func RegisterAgent(cfg *config.Config, serverURL string) error {
// Get detailed system information
sysInfo, err := system.GetSystemInfo(version.Version)
req, err := collectRegistrationRequest()
if err != nil {
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
// Fall back to basic detection
hostname, _ := os.Hostname()
osType, osVersion, osArch := client.DetectSystem()
sysInfo = &system.SystemInfo{
Hostname: hostname,
OSType: osType,
OSVersion: osVersion,
OSArchitecture: osArch,
AgentVersion: version.Version,
Metadata: make(map[string]string),
}
return err
}
// Use registration token from config if available
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
// Create metadata with system information
metadata := map[string]string{
"installation_time": time.Now().Format(time.RFC3339),
}
// Add system info to metadata
if sysInfo.CPUInfo.ModelName != "" {
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
}
if sysInfo.CPUInfo.Cores > 0 {
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
}
if sysInfo.MemoryInfo.Total > 0 {
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
}
if sysInfo.RunningProcesses > 0 {
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
}
if sysInfo.Uptime != "" {
metadata["uptime"] = sysInfo.Uptime
}
// Add disk information
for i, disk := range sysInfo.DiskInfo {
if i == 0 {
metadata["disk_mount"] = disk.Mountpoint
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
break // Only add primary disk info
}
}
// Get machine ID for binding
machineID, err := system.GetMachineID()
if err != nil {
return fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
}
// Get embedded public key fingerprint
publicKeyFingerprint := system.GetPublicKeyFingerprint()
if publicKeyFingerprint == "" {
log.Printf("Warning: No embedded public key fingerprint found")
}
// Detect available scanners for platform-specific subsystem creation
availableScanners := scanner.DetectAvailable()
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
req := client.RegisterRequest{
Hostname: sysInfo.Hostname,
OSType: sysInfo.OSType,
OSVersion: sysInfo.OSVersion,
OSArchitecture: sysInfo.OSArchitecture,
AgentVersion: sysInfo.AgentVersion,
MachineID: machineID,
PublicKeyFingerprint: publicKeyFingerprint,
Metadata: metadata,
AvailableScanners: availableScanners,
DeviceType: sysInfo.DeviceType,
DeviceModel: sysInfo.DeviceModel,
OSDistro: sysInfo.OSDistro,
}
resp, err := apiClient.Register(req)
if err != nil {
return err
@ -136,9 +61,121 @@ func RegisterAgent(cfg *config.Config, serverURL string) error {
return nil
}
func collectRegistrationRequest() (client.RegisterRequest, error) {
// Get detailed system information
sysInfo, err := system.GetSystemInfo(version.Version)
if err != nil {
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
// Fall back to basic detection
hostname, _ := os.Hostname()
osType, osVersion, osArch := client.DetectSystem()
sysInfo = &system.SystemInfo{
Hostname: hostname,
OSType: osType,
OSVersion: osVersion,
OSArchitecture: osArch,
AgentVersion: version.Version,
Metadata: make(map[string]string),
}
}
// Create metadata with system information
metadata := map[string]string{
"installation_time": time.Now().Format(time.RFC3339),
}
// Add system info to metadata
if sysInfo.CPUInfo.ModelName != "" {
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
}
if sysInfo.CPUInfo.Cores > 0 {
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
}
if sysInfo.MemoryInfo.Total > 0 {
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
}
if sysInfo.RunningProcesses > 0 {
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
}
if sysInfo.Uptime != "" {
metadata["uptime"] = sysInfo.Uptime
}
// Add disk information
for i, disk := range sysInfo.DiskInfo {
if i == 0 {
metadata["disk_mount"] = disk.Mountpoint
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
break // Only add primary disk info
}
}
// Get machine ID for binding
machineID, err := system.GetMachineID()
if err != nil {
return client.RegisterRequest{}, fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
}
// Get embedded public key fingerprint
publicKeyFingerprint := system.GetPublicKeyFingerprint()
if publicKeyFingerprint == "" {
log.Printf("Warning: No embedded public key fingerprint found")
}
// Detect available scanners for platform-specific subsystem creation
availableScanners := scanner.DetectAvailable()
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
return client.RegisterRequest{
Hostname: sysInfo.Hostname,
OSType: sysInfo.OSType,
OSVersion: sysInfo.OSVersion,
OSArchitecture: sysInfo.OSArchitecture,
AgentVersion: sysInfo.AgentVersion,
MachineID: machineID,
PublicKeyFingerprint: publicKeyFingerprint,
Metadata: metadata,
AvailableScanners: availableScanners,
DeviceType: sysInfo.DeviceType,
DeviceModel: sysInfo.DeviceModel,
OSDistro: sysInfo.OSDistro,
}, nil
}
// JoinFleet registers an existing standalone identity with the fleet Server.
// It returns credentials without persisting them; the caller must first finish
// the privileged authority replacement transaction.
func JoinFleet(cfg *config.Config, requestID, serverURL, registrationToken, seed string) (*client.FleetJoinResponse, error) {
if cfg == nil || !cfg.IsStandalone() {
return nil, fmt.Errorf("fleet join requires a standalone Agent identity")
}
requestUUID, err := uuid.FromString(requestID)
if err != nil {
return nil, fmt.Errorf("fleet join request id is invalid: %w", err)
}
base, err := collectRegistrationRequest()
if err != nil {
return nil, err
}
code, err := fleetJoinTOTPCode(seed, time.Now().UTC())
if err != nil {
return nil, err
}
req := client.FleetJoinRequest{
RequestID: requestUUID, AgentID: cfg.AgentID,
RegistrationToken: registrationToken, TOTPCode: code,
Hostname: base.Hostname, OSType: base.OSType, OSVersion: base.OSVersion,
OSArchitecture: base.OSArchitecture, AgentVersion: base.AgentVersion,
MachineID: base.MachineID, PublicKeyFingerprint: base.PublicKeyFingerprint,
Metadata: base.Metadata, AvailableScanners: base.AvailableScanners,
DeviceType: base.DeviceType, DeviceModel: base.DeviceModel, OSDistro: base.OSDistro,
}
return client.NewClient(serverURL, "").JoinFleet(req)
}
// FetchAndCachePublicKey fetches the server's Ed25519 public key and caches it locally
func FetchAndCachePublicKey(serverURL string) error {
_, err := crypto.FetchAndCacheServerPublicKey(serverURL)
return err
}

View file

@ -14,6 +14,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/agent"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/recovery"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
"golang.org/x/sys/windows/svc/eventlog"
@ -188,10 +189,12 @@ func InstallService() error {
// Create service with proper configuration
s, err = m.CreateService(serviceName, exePath, mgr.Config{
DisplayName: "RedFlag Update Agent",
Description: "RedFlag agent for automated system updates and monitoring",
StartType: mgr.StartAutomatic,
Dependencies: []string{"Tcpip", "Dnscache"},
DisplayName: "RedFlag Update Agent",
Description: "RedFlag agent for system observation and authorized mutation requests",
StartType: mgr.StartAutomatic,
Dependencies: []string{"Tcpip", "Dnscache"},
ServiceStartName: `NT SERVICE\RedFlagAgent`,
SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED,
})
if err != nil {
return fmt.Errorf("failed to create service: %w", err)

View file

@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
@ -68,6 +69,9 @@ func (e *Executor) MintEnvelope(
manifest capability.MutationManifest,
gateEvidence GateEvidence,
) (*capability.MutationEnvelope, string, error) {
if runtime.GOOS == "windows" {
return nil, "", fmt.Errorf("Windows local mint requires authenticated StepUp; mint authority is unavailable")
}
requestID, err := uuid.NewV4()
if err != nil {
return nil, "", fmt.Errorf("generate envelope request id: %w", err)
@ -144,6 +148,24 @@ func (e *Executor) MintEnvelope(
return &envelope, request.RequestID, nil
}
// VerifyEnvelope drives the installed privileged verifier over the platform's
// own transport and returns its joined receipt. Verification only: it mints
// nothing, runs no backend, and consumes no execution authorization. SEC-030
// requires read-only proof of the Windows transport before a backend can be
// admitted, and this is the Agent's one way to ask for it.
func (e *Executor) VerifyEnvelope(
ctx context.Context,
envelope *capability.MutationEnvelope,
) (*capability.MutationReceipt, error) {
if envelope == nil {
return nil, fmt.Errorf("mutation envelope is nil")
}
if runtime.GOOS != "windows" {
return nil, fmt.Errorf("envelope verification transport is implemented on Windows only")
}
return verifyWindowsEnvelope(ctx, envelope)
}
// ExecuteEnvelope hands one signed envelope to the privileged verifier and
// returns its joined receipt, including denials and failed package execution.
func (e *Executor) ExecuteEnvelope(
@ -153,6 +175,9 @@ func (e *Executor) ExecuteEnvelope(
if envelope == nil {
return nil, fmt.Errorf("mutation envelope is nil")
}
if runtime.GOOS == "windows" {
return executeWindowsEnvelope(ctx, envelope)
}
payload, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("marshal mutation envelope: %w", err)

View file

@ -49,6 +49,8 @@ ApplicationWindow {
property string pendingProcessQuery: ""
property var approval: parse(machine.approvalJson, {
})
property var fleetJoin: parse(machine.joinJson, {
})
// The capabilities that turn "a process" into "a process that can rewrite the
// machine". Tinted so a wall of chips still reads at a glance.
readonly property var sharpCapabilities: ["CAP_SYS_ADMIN", "CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_PTRACE", "CAP_SYS_BOOT", "CAP_BPF", "CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_DAC_READ_SEARCH", "CAP_SETUID", "CAP_SETGID"]
@ -1781,7 +1783,6 @@ ApplicationWindow {
font.pixelSize: Theme.fontMeta
Layout.preferredWidth: 100
}
}
}
@ -3004,6 +3005,243 @@ ApplicationWindow {
}
Rectangle {
id: fleetJoinCard
visible: machine.enrollment === "standalone" || machine.enrollment === "joining fleet"
Layout.fillWidth: true
Layout.preferredHeight: visible ? fleetJoinColumn.implicitHeight + 30 : 0
color: Theme.surface
radius: Theme.radius
border.width: 1
border.color: machine.enrollment === "joining fleet" ? Theme.warn : Theme.divider
ColumnLayout {
id: fleetJoinColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 15
spacing: 10
RowLayout {
Layout.fillWidth: true
ColumnLayout {
spacing: 2
Text {
text: "JOIN THIS MACHINE TO A FLEET"
color: Theme.text
font.pixelSize: 11
font.weight: 750
font.letterSpacing: 1
}
Text {
text: "Keep this machine's identity. Replace its local signing authority with the fleet Server key."
color: Theme.dim
font.pixelSize: 10
}
}
Item {
Layout.fillWidth: true
}
Text {
text: machine.enrollment === "joining fleet" ? "CONVERGING" : "ONE WAY"
color: machine.enrollment === "joining fleet" ? Theme.warn : Theme.red
font.pixelSize: 9
font.weight: 750
font.letterSpacing: 1
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
text: machine.joinSeed.length ? "New host seed" : "Create host seed"
enabled: !machine.joinRunning && machine.enrollment === "standalone"
onClicked: machine.prepareFleetJoin()
background: Rectangle {
color: parent.pressed ? Theme.hover : Theme.raised
border.width: 1
border.color: Theme.divider
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: parent.enabled ? Theme.text : Theme.faint
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.pixelSize: 11
font.weight: 650
}
}
TextField {
id: fleetJoinSeed
Layout.fillWidth: true
text: machine.joinSeed
readOnly: true
selectByMouse: true
placeholderText: "Create the host proof seed first"
color: Theme.cyan
placeholderTextColor: Theme.faint
font.family: Theme.mono
background: Rectangle {
color: Theme.raised
border.width: 1
border.color: fleetJoinSeed.activeFocus ? Theme.cyan : Theme.divider
radius: Theme.radiusSm
}
}
}
Text {
Layout.fillWidth: true
text: "In RedFlag Web, create a one-seat fleet join key using this host seed. Then return here with the key."
color: Theme.faint
font.pixelSize: 10
wrapMode: Text.Wrap
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 8
rowSpacing: 4
Text {
text: "SERVER URL"
color: Theme.faint
font.pixelSize: 9
font.weight: 700
}
Text {
text: "ONE-SEAT JOIN KEY"
color: Theme.faint
font.pixelSize: 9
font.weight: 700
}
TextField {
id: fleetJoinServer
Layout.fillWidth: true
placeholderText: "https://redflag.example"
color: Theme.text
placeholderTextColor: Theme.faint
background: Rectangle {
color: Theme.raised
border.width: 1
border.color: fleetJoinServer.activeFocus ? Theme.red : Theme.divider
radius: Theme.radiusSm
}
}
TextField {
id: fleetJoinToken
Layout.fillWidth: true
placeholderText: "Paste the join key"
echoMode: TextInput.Password
color: Theme.text
placeholderTextColor: Theme.faint
font.family: Theme.mono
background: Rectangle {
color: Theme.raised
border.width: 1
border.color: fleetJoinToken.activeFocus ? Theme.red : Theme.divider
radius: Theme.radiusSm
}
}
}
CheckBox {
id: fleetJoinConfirm
enabled: machine.enrollment === "standalone" && !machine.joinRunning
text: "Retire this machine's standalone signing key and local mint permission."
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.warn : Theme.dim
font.pixelSize: 11
leftPadding: parent.indicator.width + parent.spacing
}
}
RowLayout {
Layout.fillWidth: true
Text {
Layout.fillWidth: true
text: app.fleetJoin.error || (app.fleetJoin.restart_required ? "Fleet authority installed. The Agent is restarting into fleet mode." : "")
color: app.fleetJoin.error ? Theme.bad : Theme.good
font.pixelSize: 10
font.family: Theme.mono
wrapMode: Text.Wrap
}
Button {
text: machine.joinRunning ? "Joining…" : machine.enrollment === "joining fleet" ? "Restarting Agent…" : "Join fleet"
enabled: machine.enrollment === "standalone" && !machine.joinRunning && fleetJoinConfirm.checked && fleetJoinSeed.text.length > 0 && fleetJoinServer.text.trim().length > 0 && fleetJoinToken.text.trim().length > 0
onClicked: machine.joinFleet(fleetJoinServer.text.trim(), fleetJoinToken.text.trim(), fleetJoinSeed.text.trim())
background: Rectangle {
color: !parent.enabled ? Theme.raised : parent.pressed ? Qt.darker(Theme.red, 1.15) : Theme.red
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: parent.enabled ? "white" : Theme.faint
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.pixelSize: 11
font.weight: 650
}
}
}
Connections {
function onJoinFinished(success) {
if (success) {
fleetJoinToken.text = "";
fleetJoinConfirm.checked = false;
}
}
target: machine
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 100

View file

@ -69,11 +69,14 @@ pub mod ffi {
#[qproperty(bool, scan_status_loading)]
#[qproperty(QString, operation_message)]
#[qproperty(QString, approval_json)]
#[qproperty(QString, join_seed)]
#[qproperty(QString, join_json)]
#[qproperty(i32, software_count)]
#[qproperty(i32, software_explicit_count)]
#[qproperty(i32, software_dependency_count)]
#[qproperty(i32, software_foreign_count)]
#[qproperty(bool, approval_running)]
#[qproperty(bool, join_running)]
#[qproperty(bool, telemetry_loading)]
#[qproperty(bool, overview_loading)]
#[qproperty(bool, processes_loading)]
@ -106,6 +109,15 @@ pub mod ffi {
available_version: &QString,
override_reason: &QString,
);
#[qinvokable]
fn prepare_fleet_join(self: Pin<&mut Machine>);
#[qinvokable]
fn join_fleet(
self: Pin<&mut Machine>,
server_url: &QString,
registration_token: &QString,
seed: &QString,
);
#[qsignal]
fn telemetry_updated(self: Pin<&mut Machine>);
@ -125,6 +137,10 @@ pub mod ffi {
fn operation_finished(self: Pin<&mut Machine>, success: bool);
#[qsignal]
fn approval_finished(self: Pin<&mut Machine>, success: bool);
#[qsignal]
fn join_prepared(self: Pin<&mut Machine>, success: bool);
#[qsignal]
fn join_finished(self: Pin<&mut Machine>, success: bool);
}
impl cxx_qt::Threading for Machine {}
@ -181,11 +197,14 @@ pub struct MachineRust {
scan_status_loading: bool,
operation_message: QString,
approval_json: QString,
join_seed: QString,
join_json: QString,
software_count: i32,
software_explicit_count: i32,
software_dependency_count: i32,
software_foreign_count: i32,
approval_running: bool,
join_running: bool,
telemetry_loading: bool,
overview_loading: bool,
processes_loading: bool,
@ -245,11 +264,14 @@ impl Default for MachineRust {
scan_status_loading: false,
operation_message: QString::default(),
approval_json: QString::from("{}"),
join_seed: QString::default(),
join_json: QString::from("{}"),
software_count: 0,
software_explicit_count: 0,
software_dependency_count: 0,
software_foreign_count: 0,
approval_running: false,
join_running: false,
telemetry_loading: false,
overview_loading: false,
processes_loading: false,
@ -427,6 +449,74 @@ impl ffi::Machine {
self.approval_finished(success);
}
pub fn prepare_fleet_join(mut self: Pin<&mut Self>) {
if self.join_running {
return;
}
self.as_mut().set_join_running(true);
self.as_mut().set_join_json(QString::from("{}"));
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::post(
"/v1/actions/prepare-fleet-join",
&Value::Object(Default::default()),
);
let _ = thread.queue(move |machine| machine.apply_fleet_join_preparation(result));
});
}
pub fn join_fleet(
mut self: Pin<&mut Self>,
server_url: &QString,
registration_token: &QString,
seed: &QString,
) {
if self.join_running {
return;
}
let request = serde_json::json!({
"server_url": server_url.to_string(),
"registration_token": registration_token.to_string(),
"totp_seed": seed.to_string(),
});
self.as_mut().set_join_running(true);
self.as_mut().set_join_json(QString::from("{}"));
let thread = self.qt_thread();
std::thread::spawn(move || {
let result = local_api::post("/v1/actions/join-fleet", &request);
let _ = thread.queue(move |machine| machine.apply_fleet_join(result));
});
}
fn apply_fleet_join_preparation(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_join_running(false);
let success = result.is_ok();
let payload = match result {
Ok(value) => {
self.as_mut().set_join_seed(qtext(&value, "totp_seed"));
value
}
Err(error) => serde_json::json!({ "error": error }),
};
self.as_mut().set_join_json(json_string(&payload));
self.join_prepared(success);
}
fn apply_fleet_join(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_join_running(false);
let success = result.is_ok();
let payload = match result {
Ok(value) => {
self.as_mut().set_enrollment(QString::from("joining fleet"));
self.as_mut().set_join_seed(QString::default());
value
}
Err(error) => serde_json::json!({ "error": error }),
};
self.as_mut().set_join_json(json_string(&payload));
self.join_finished(success);
}
fn apply_telemetry(mut self: Pin<&mut Self>, result: Result<Value, String>) {
self.as_mut().set_telemetry_loading(false);
match result {
@ -501,6 +591,14 @@ impl ffi::Machine {
.or_else(|| text(info, "device_type"))
.unwrap_or_else(|| "Computer".into());
let registered = data.identity["registered"].as_bool().unwrap_or(false);
let enrollment = match data.identity["mode"].as_str() {
Some("fleet") => "fleet enrolled",
Some("standalone") => "standalone",
Some("joining") => "joining fleet",
Some("incomplete") => "incomplete",
_ if registered => "fleet enrolled",
_ => "standalone",
};
let updates = data.updates["updates"]
.as_array()
.cloned()
@ -536,11 +634,7 @@ impl ffi::Machine {
.set_agent_version(qtext(&data.identity, "agent_version"));
self.as_mut()
.set_agent_status(qtext(&data.status, "agent_status"));
self.as_mut().set_enrollment(QString::from(if registered {
"fleet enrolled"
} else {
"standalone"
}));
self.as_mut().set_enrollment(QString::from(enrollment));
self.as_mut().set_uptime(qtext(info, "uptime"));
self.as_mut()
.set_process_count(info["running_processes"].as_i64().unwrap_or(0) as i32);

View file

@ -8,7 +8,7 @@ publish = false
[dependencies]
ed25519-dalek = "2.1.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["raw_value"] }
sha2 = "0.10"
hex = "0.4"
subtle = "2"

View file

@ -1,5 +1,4 @@
// redflag-helper — capability-token executor (keystone).
// Windows target: stub binary (self-upgrade not yet ported — handled by installer).
#![cfg_attr(windows, allow(dead_code, unused_variables))]
//
// Reads one Ed25519-signed capability token from stdin, verifies it against a
@ -35,6 +34,10 @@ use subtle::ConstantTimeEq;
#[allow(dead_code)]
mod mutation_protocol;
#[allow(dead_code)]
mod task_dispatch;
#[cfg(windows)]
mod windows_custody;
use mutation_protocol::{
key_id_for, MutationAuthorization, MutationEnvelope, MutationManifest, MutationOutcome,
MutationReceipt, MUTATION_PROTOCOL_VERSION,
@ -42,11 +45,40 @@ use mutation_protocol::{
#[cfg(windows)]
fn main() {
eprintln!(
"redflag-helper: Windows self-upgrade not yet implemented. \
Desktop and agent updates on Windows are handled by the installer."
);
std::process::exit(0);
let args: Vec<String> = std::env::args().collect();
if matches!(
args.get(1).map(|s| s.as_str()),
Some("--version") | Some("-V")
) {
println!("RedFlag helper v{}", env!("REDFLAG_VERSION"));
return;
}
// Windows begins with the verification half of the shared RAF mutation
// protocol. These paths have no mutation authority and consume no replay
// state. Privileged execution stays fail-closed until Windows trust-path,
// replay, and fixed-task transport are implemented and accepted together.
if args.get(1).map(|s| s.as_str()) == Some("verify-binary") {
std::process::exit(run_verify_binary(&args[2..]));
}
if args.get(1).map(|s| s.as_str()) == Some("verify-envelope") {
std::process::exit(run_verify_envelope_cli(&args[2..]));
}
if args.get(1).map(|s| s.as_str()) == Some("run-request") {
if args.len() != 2 {
log_security("denied reason=task_arguments_forbidden");
std::process::exit(EXIT_BAD_TOKEN);
}
std::process::exit(task_dispatch::run());
}
let command = args.get(1).map(|s| s.as_str()).unwrap_or("<none>");
log_security(&format!(
"denied reason=windows_execution_unavailable command={} exit={}",
command, EXIT_UNSUPPORTED_OP
));
std::process::exit(EXIT_UNSUPPORTED_OP);
}
const SUPPORTED_TOKEN_VERSION: u32 = 1;
@ -87,6 +119,12 @@ const DEFAULT_AGENT_MUTATION_ENVELOPE_DIR: &str = "/var/lib/redflag/agent/mutati
#[cfg(unix)]
const DEFAULT_AGENT_MUTATION_RECEIPT_DIR: &str = "/var/lib/redflag/agent/mutation-receipts";
#[cfg(unix)]
const DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR: &str = "/var/lib/redflag/agent/fleet-join-requests";
#[cfg(unix)]
const DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR: &str = "/var/lib/redflag/agent/fleet-join-receipts";
#[cfg(unix)]
const DEFAULT_STANDALONE_SUDOERS_FILE: &str = "/etc/sudoers.d/redflag-agent-mint";
#[cfg(unix)]
const DEFAULT_AGENT_MINT_REQUEST_DIR: &str = "/var/lib/redflag/agent/mint";
#[cfg(unix)]
const DEFAULT_AGENT_TOKEN_DIR: &str = "/var/lib/redflag/agent/tokens";
@ -1186,6 +1224,17 @@ fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Deni
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
}
// Symlink-proof staging needs openat and O_NOFOLLOW. Until Windows has its own
// trust-path proof, staging there refuses rather than creating by path.
#[cfg(not(unix))]
fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Denial> {
Err(Denial::new(
EXIT_TRUST_PATH,
"stage_unavailable",
format!("{}: unsupported platform", staging.display()),
))
}
// stage_and_verify_binary copies a source binary into a root-only staging file
// and verifies its SHA-256 against the expected hash. Returns the staging path.
// Run BEFORE the replay slot is consumed so a tampered binary does not burn the
@ -1355,6 +1404,16 @@ fn install_staged_agent_binary(staged: &str) -> Result<(), Denial> {
// comes up with the capability grant in place.
reconcile_agent_unit_dropin();
// Agent updates are the normal fleet delivery path. Reconcile both tray
// launch mechanisms here as well as on desktop-self updates, so an
// existing host gains compositor-session support with its next agent
// update instead of waiting for a separate desktop package update.
let desktop_binary = env_or("REDFLAG_DESKTOP_BINARY", DEFAULT_DESKTOP_BINARY);
#[cfg(unix)]
reconcile_desktop_autostart(&desktop_binary);
#[cfg(unix)]
reconcile_compositor_session_launcher(&desktop_binary);
// Enqueue the restart with --no-block and return. The agent process is the
// consumer blocked on this helper's stdout pipe; a synchronous restart would
// SIGTERM it before we emit our result (broken pipe). --no-block lets this
@ -1499,7 +1558,10 @@ fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
// launches — heal the session-start provisioning the token install can't
// carry (UPDATE-002 gap 2), same reconciliation posture as the agent unit
// drop-in.
#[cfg(unix)]
reconcile_desktop_autostart(&install);
#[cfg(unix)]
reconcile_compositor_session_launcher(&install);
Ok(())
}
@ -1513,6 +1575,7 @@ fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
// user to bind, and the tray's own socket-permission diagnostic tells the user
// the exact usermod to run. Non-fatal on failure — the binary install already
// succeeded and is journaled.
#[cfg(unix)]
fn reconcile_desktop_autostart(binary_path: &str) {
const AUTOSTART_DIR: &str = "/etc/xdg/autostart";
const AUTOSTART_PATH: &str = "/etc/xdg/autostart/redflag-desktop.desktop";
@ -1560,6 +1623,140 @@ fn reconcile_desktop_autostart(binary_path: &str) {
));
}
// reconcile_compositor_session_launcher installs a user-systemd fallback for
// compositors such as Hyprland, Sway, and River. Those sessions commonly do
// not consume /etc/xdg/autostart entries, leaving the otherwise-installed tray
// binary inert. Desktop environments continue using the XDG entry above: the
// launcher exits without starting a second copy unless it detects a supported
// compositor in the user manager's imported session environment.
//
// The helper is root-owned, so it places the unit globally rather than writing
// into a particular user's dotfiles. This preserves user configuration and
// supports the first graphical login after an agent update.
#[cfg(unix)]
fn reconcile_compositor_session_launcher(binary_path: &str) {
const UNIT_DIR: &str = "/etc/systemd/user";
const UNIT_PATH: &str = "/etc/systemd/user/redflag-desktop-compositor.service";
const WANTS_DIR: &str = "/etc/systemd/user/default.target.wants";
const WANTS_PATH: &str =
"/etc/systemd/user/default.target.wants/redflag-desktop-compositor.service";
const LAUNCHER_DIR: &str = "/usr/local/libexec";
const LAUNCHER_PATH: &str = "/usr/local/libexec/redflag-desktop-compositor";
let launcher = format!(
"#!/bin/sh\n\
# Wait for the compositor to import its session variables into the user manager.\n\
# Hyprland configurations commonly do this shortly after login.\n\
attempts=0\n\
while [ \"$attempts\" -lt 30 ]; do\n\
session=\"$(systemctl --user show-environment 2>/dev/null || true)\"\n\
desktop=\"$(printf '%s\\n' \"$session\" | sed -n 's/^XDG_CURRENT_DESKTOP=//p' | head -n 1)\"\n\
wayland=\"$(printf '%s\\n' \"$session\" | sed -n 's/^WAYLAND_DISPLAY=//p' | head -n 1)\"\n\
runtime=\"$(printf '%s\\n' \"$session\" | sed -n 's/^XDG_RUNTIME_DIR=//p' | head -n 1)\"\n\
runtime=\"${{runtime:-${{XDG_RUNTIME_DIR:-}}}}\"\n\
case \"$desktop\" in\n\
*Hyprland*|*sway*|*Sway*|*river*)\n\
if [ -n \"$wayland\" ] && [ -n \"$runtime\" ] && [ -S \"$runtime/$wayland\" ]; then\n\
export XDG_CURRENT_DESKTOP=\"$desktop\" WAYLAND_DISPLAY=\"$wayland\" XDG_RUNTIME_DIR=\"$runtime\"\n\
exec {}\n\
fi\n\
;;\n\
?*) exit 0 ;;\n\
esac\n\
attempts=$((attempts + 1))\n\
sleep 2\n\
done\n\
exit 0\n",
binary_path
);
let unit = format!(
"[Unit]\n\
Description=RedFlag desktop tray for compositor-only sessions\n\
After=default.target\n\
\n\
[Service]\n\
Type=simple\n\
Environment=XDG_RUNTIME_DIR=%t\n\
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus\n\
ExecStart={}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
LAUNCHER_PATH
);
if let Err(e) = fs::create_dir_all(LAUNCHER_DIR) {
log_security(&format!(
"desktop_compositor_launcher_dir_failed path={} err={}",
LAUNCHER_DIR, e
));
return;
}
if let Err(e) = fs::write(LAUNCHER_PATH, launcher) {
log_security(&format!(
"desktop_compositor_launcher_write_failed path={} err={}",
LAUNCHER_PATH, e
));
return;
}
if let Err(e) = fs::set_permissions(LAUNCHER_PATH, fs::Permissions::from_mode(0o755)) {
log_security(&format!(
"desktop_compositor_launcher_chmod_failed path={} err={}",
LAUNCHER_PATH, e
));
return;
}
if let Err(e) = fs::create_dir_all(UNIT_DIR) {
log_security(&format!(
"desktop_compositor_unit_dir_failed path={} err={}",
UNIT_DIR, e
));
return;
}
if let Err(e) = fs::write(UNIT_PATH, unit) {
log_security(&format!(
"desktop_compositor_unit_write_failed path={} err={}",
UNIT_PATH, e
));
return;
}
if let Err(e) = fs::set_permissions(UNIT_PATH, fs::Permissions::from_mode(0o644)) {
log_security(&format!(
"desktop_compositor_unit_chmod_failed path={} err={}",
UNIT_PATH, e
));
return;
}
if let Err(e) = fs::create_dir_all(WANTS_DIR) {
log_security(&format!(
"desktop_compositor_wants_dir_failed path={} err={}",
WANTS_DIR, e
));
return;
}
if !Path::new(WANTS_PATH).exists() {
if let Err(e) =
std::os::unix::fs::symlink("../redflag-desktop-compositor.service", WANTS_PATH)
{
log_security(&format!(
"desktop_compositor_enable_failed path={} err={}",
WANTS_PATH, e
));
return;
}
}
log_security(&format!(
"desktop_compositor_launcher_reconciled unit={}",
UNIT_PATH
));
}
fn run(
token_file: Option<&str>,
helper_file: Option<&str>,
@ -1948,6 +2145,8 @@ const MINT_CLOCK_SKEW_SECS: i64 = 60;
const MINT_TOKEN_TTL_SECS: i64 = 600; // matches the 10-minute command validity window
const DEFAULT_MINT_KEY: &str = "/etc/redflag/authority_local.key";
const DEFAULT_MINT_JOURNAL: &str = "/var/lib/redflag/journal/mint.log";
#[cfg(unix)]
const FLEET_AUTHORITY_VERSION: u32 = 1;
// The standalone local API exposes legacy capability approval only for APT and
// DNF. Keep the privileged minter equally narrow; pacman has its envelope path.
const MINTABLE_PACKAGE_TYPES: &[&str] = &["apt", "dnf"];
@ -1978,6 +2177,31 @@ struct MintRequest {
gate_evidence: GateEvidence,
}
#[cfg(unix)]
#[derive(Debug, Deserialize)]
struct FleetAuthorityRequest {
version: u32,
request_id: String,
agent_id: String,
server_url: String,
signing_public_key: String,
signing_key_id: String,
}
#[cfg(unix)]
#[derive(Debug, Serialize)]
struct FleetAuthorityReceipt {
version: u32,
request_id: String,
agent_id: String,
server_url: String,
signing_key_id: String,
server_authority_installed: bool,
local_authority_retired: bool,
already_applied: bool,
applied_at: i64,
}
#[cfg(unix)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
@ -2770,13 +2994,14 @@ fn run_mint_retire_key(paths: &MintPaths) -> Result<(), Denial> {
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
let pub_path = keyring_dir.join("authority_local.pub");
if pub_path.exists() {
if let Err(e) = fs::remove_file(&pub_path) {
log_error(&format!(
"keyring_pub_remove_failed path={} error={}",
pub_path.display(),
e
));
}
validate_trusted_path(&pub_path)?;
fs::remove_file(&pub_path).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"keyring_pub_remove_failed",
format!("{}: {}", pub_path.display(), error),
)
})?;
}
mint_journal_append(
@ -2787,6 +3012,475 @@ fn run_mint_retire_key(paths: &MintPaths) -> Result<(), Denial> {
Ok(())
}
#[cfg(unix)]
fn validate_fleet_server_url(value: &str) -> Result<(), Denial> {
let value = value.trim();
let rest = value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
.ok_or_else(|| {
Denial::new(
EXIT_BAD_TOKEN,
"fleet_authority_server_url_invalid",
"server_url must use http or https",
)
})?;
if rest.is_empty()
|| value.len() > 2048
|| value.ends_with('/')
|| value
.bytes()
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
|| value.contains('@')
|| value.contains('?')
|| value.contains('#')
{
return Err(Denial::new(
EXIT_BAD_TOKEN,
"fleet_authority_server_url_invalid",
value,
));
}
Ok(())
}
#[cfg(unix)]
fn validate_fleet_authority_request(req: &FleetAuthorityRequest) -> Result<Vec<u8>, Denial> {
if req.version != FLEET_AUTHORITY_VERSION {
return Err(Denial::new(
EXIT_VERSION,
"fleet_authority_version_unsupported",
format!("version={}", req.version),
));
}
if !is_valid_uuid_v4(&req.request_id) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"fleet_authority_request_id_invalid",
&req.request_id,
));
}
if !is_valid_uuid_v4(&req.agent_id) {
return Err(Denial::new(
EXIT_AGENT_MISMATCH,
"fleet_authority_agent_id_invalid",
&req.agent_id,
));
}
let local_id = local_agent_id()?;
if req.agent_id != local_id {
return Err(Denial::new(
EXIT_AGENT_MISMATCH,
"fleet_authority_agent_mismatch",
format!("request={} local={}", req.agent_id, local_id),
));
}
validate_fleet_server_url(&req.server_url)?;
let public_key = hex::decode(req.signing_public_key.trim()).map_err(|error| {
Denial::new(
EXIT_SIGNATURE,
"fleet_authority_public_key_invalid",
error.to_string(),
)
})?;
if public_key.len() != 32 {
return Err(Denial::new(
EXIT_SIGNATURE,
"fleet_authority_public_key_invalid",
format!("bytes={} expected=32", public_key.len()),
));
}
let key_bytes: [u8; 32] = public_key.as_slice().try_into().map_err(|_| {
Denial::new(
EXIT_SIGNATURE,
"fleet_authority_public_key_invalid",
"expected 32 bytes",
)
})?;
let computed_id = key_id_for(&key_bytes);
if req.signing_key_id != computed_id {
return Err(Denial::new(
EXIT_SIGNATURE,
"fleet_authority_key_id_mismatch",
format!("request={} computed={}", req.signing_key_id, computed_id),
));
}
Ok(public_key)
}
#[cfg(unix)]
fn require_fleet_exchange_name(
path: &str,
expected_dir: &str,
request_id: &str,
) -> Result<(), Denial> {
validate_agent_exchange_path(path, expected_dir)?;
let expected = format!("{}.json", request_id);
let actual = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if actual != expected {
return Err(Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_exchange_join_mismatch",
format!("actual={} expected={}", actual, expected),
));
}
Ok(())
}
#[cfg(unix)]
fn read_trusted_text(path: &Path) -> Result<String, Denial> {
validate_trusted_path(path)?;
let mut options = fs::OpenOptions::new();
options
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
let mut file = options.open(path).map_err(|error| {
Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_key_open_failed",
format!("{}: {}", path.display(), error),
)
})?;
let mut value = String::new();
file.read_to_string(&mut value).map_err(|error| {
Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_key_read_failed",
format!("{}: {}", path.display(), error),
)
})?;
Ok(value)
}
#[cfg(unix)]
fn install_fleet_public_key(
keyring_dir: &Path,
request_id: &str,
public_key: &[u8],
) -> Result<bool, Denial> {
if !keyring_dir.exists() {
fs::create_dir_all(keyring_dir).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_keyring_create_failed",
format!("{}: {}", keyring_dir.display(), error),
)
})?;
fs::set_permissions(keyring_dir, fs::Permissions::from_mode(0o755)).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_keyring_mode_failed",
format!("{}: {}", keyring_dir.display(), error),
)
})?;
}
validate_trusted_path(keyring_dir)?;
let expected = hex::encode(public_key);
let destination = keyring_dir.join("server.pub");
if destination.exists() {
let current = read_trusted_text(&destination)?;
if current.trim() != expected {
return Err(Denial::new(
EXIT_SIGNATURE,
"fleet_authority_server_key_conflict",
destination.display().to_string(),
));
}
return Ok(false);
}
let temporary = keyring_dir.join(format!(".server.{}.tmp", request_id));
if temporary.exists() {
let staged = read_trusted_text(&temporary)?;
if staged.trim() != expected {
return Err(Denial::new(
EXIT_SIGNATURE,
"fleet_authority_staged_key_conflict",
temporary.display().to_string(),
));
}
} else {
let mut options = fs::OpenOptions::new();
options
.write(true)
.create_new(true)
.mode(0o644)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
let mut file = options.open(&temporary).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_key_stage_failed",
format!("{}: {}", temporary.display(), error),
)
})?;
std::io::Write::write_all(&mut file, expected.as_bytes()).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_key_stage_write_failed",
error.to_string(),
)
})?;
file.sync_all().map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_key_stage_sync_failed",
error.to_string(),
)
})?;
}
fs::rename(&temporary, &destination).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_key_install_failed",
format!("{}: {}", destination.display(), error),
)
})?;
let directory = open_directory_without_symlinks(keyring_dir)?;
directory.sync_all().map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_keyring_sync_failed",
error.to_string(),
)
})?;
validate_trusted_path(&destination)?;
Ok(true)
}
#[cfg(unix)]
fn remove_standalone_sudoers(path: &Path) -> Result<(), Denial> {
if !path.exists() {
return Ok(());
}
validate_trusted_path(path)?;
fs::remove_file(path).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_sudoers_remove_failed",
format!("{}: {}", path.display(), error),
)
})
}
#[cfg(unix)]
fn retain_only_fleet_server_key(keyring_dir: &Path) -> Result<(), Denial> {
validate_trusted_path(keyring_dir)?;
let entries = fs::read_dir(keyring_dir).map_err(|error| {
Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_keyring_read_failed",
format!("{}: {}", keyring_dir.display(), error),
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_keyring_entry_failed",
error.to_string(),
)
})?;
let name = entry.file_name();
if name == "server.pub" {
continue;
}
let path = entry.path();
let stale_trust = path.extension().and_then(|value| value.to_str()) == Some("pub")
|| name.to_string_lossy().starts_with(".server.");
if !stale_trust {
continue;
}
validate_trusted_path(&path)?;
if !entry
.file_type()
.map_err(|error| {
Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_key_type_failed",
format!("{}: {}", path.display(), error),
)
})?
.is_file()
{
return Err(Denial::new(
EXIT_TRUST_PATH,
"fleet_authority_key_not_regular",
path.display().to_string(),
));
}
fs::remove_file(&path).map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_stale_key_remove_failed",
format!("{}: {}", path.display(), error),
)
})?;
}
open_directory_without_symlinks(keyring_dir)?
.sync_all()
.map_err(|error| {
Denial::new(
EXIT_INTERNAL,
"fleet_authority_keyring_sync_failed",
error.to_string(),
)
})
}
#[cfg(unix)]
fn adopt_fleet_authority(req: &FleetAuthorityRequest) -> Result<FleetAuthorityReceipt, Denial> {
let public_key = validate_fleet_authority_request(req)?;
let paths = MintPaths::from_env();
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
let local_public = keyring_dir.join("authority_local.pub");
let server_public = keyring_dir.join("server.pub");
let local_private_exists = paths.key_path.exists();
if !local_private_exists {
if !server_public.exists() {
return Err(Denial::new(
EXIT_MINT_KEY,
"fleet_authority_partial_state",
"local private key is absent without an installed Server authority",
));
}
let existing = read_trusted_text(&server_public)?;
if existing.trim() != hex::encode(&public_key) {
return Err(Denial::new(
EXIT_SIGNATURE,
"fleet_authority_server_key_conflict",
server_public.display().to_string(),
));
}
retain_only_fleet_server_key(&keyring_dir)?;
remove_standalone_sudoers(Path::new(DEFAULT_STANDALONE_SUDOERS_FILE))?;
mint_journal_append(
&paths.journal_path,
&serde_json::json!({
"ts": now_unix(),
"event": "fleet_authority_reconciled",
"request_id": req.request_id,
"agent_id": req.agent_id,
"server_url": req.server_url,
"key_id": req.signing_key_id,
}),
)?;
return Ok(FleetAuthorityReceipt {
version: FLEET_AUTHORITY_VERSION,
request_id: req.request_id.clone(),
agent_id: req.agent_id.clone(),
server_url: req.server_url.clone(),
signing_key_id: req.signing_key_id.clone(),
server_authority_installed: true,
local_authority_retired: true,
already_applied: true,
applied_at: now_unix(),
});
}
validate_trusted_path(&paths.key_path)?;
if local_public.exists() {
validate_trusted_path(&local_public)?;
}
install_fleet_public_key(&keyring_dir, &req.request_id, &public_key)?;
run_mint_retire_key(&paths)?;
retain_only_fleet_server_key(&keyring_dir)?;
remove_standalone_sudoers(Path::new(DEFAULT_STANDALONE_SUDOERS_FILE))?;
mint_journal_append(
&paths.journal_path,
&serde_json::json!({
"ts": now_unix(),
"event": "fleet_authority_adopted",
"request_id": req.request_id,
"agent_id": req.agent_id,
"server_url": req.server_url,
"key_id": req.signing_key_id,
}),
)?;
Ok(FleetAuthorityReceipt {
version: FLEET_AUTHORITY_VERSION,
request_id: req.request_id.clone(),
agent_id: req.agent_id.clone(),
server_url: req.server_url.clone(),
signing_key_id: req.signing_key_id.clone(),
server_authority_installed: true,
local_authority_retired: true,
already_applied: false,
applied_at: now_unix(),
})
}
#[cfg(unix)]
fn run_adopt_fleet_cli(args: &[String]) -> i32 {
if args.len() != 4 || args[0] != "--request-file" || args[2] != "--receipt-file" {
log_error("adopt-fleet usage: redflag-helper adopt-fleet --request-file <path> --receipt-file <path>");
return EXIT_BAD_TOKEN;
}
let request_file = &args[1];
let receipt_file = &args[3];
let result = read_agent_exchange_file(request_file, DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR)
.and_then(|raw| {
serde_json::from_str::<FleetAuthorityRequest>(&raw).map_err(|error| {
Denial::new(
EXIT_BAD_TOKEN,
"fleet_authority_request_parse_failed",
error.to_string(),
)
})
})
.and_then(|request| {
require_fleet_exchange_name(
request_file,
DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR,
&request.request_id,
)?;
require_fleet_exchange_name(
receipt_file,
DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR,
&request.request_id,
)?;
adopt_fleet_authority(&request)
});
match result {
Ok(receipt) => match write_agent_exchange_json(
&receipt,
receipt_file,
DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR,
) {
Ok(()) => {
log_security(&format!(
"fleet_authority_adopted request_id={} agent_id={} key_id={} already_applied={}",
receipt.request_id,
receipt.agent_id,
receipt.signing_key_id,
receipt.already_applied
));
EXIT_OK
}
Err(denial) => {
log_error(&format!(
"fleet_authority_receipt_failed reason={} detail={}",
denial.reason, denial.detail
));
denial.code
}
},
Err(denial) => {
log_security(&format!(
"denied reason={} detail={} exit={}",
denial.reason, denial.detail, denial.code
));
denial.code
}
}
}
// CLI: redflag-helper mint --request-file <p> --token-out <p>
// redflag-helper mint --init-key
// redflag-helper mint --retire-key
@ -2937,14 +3631,17 @@ mod tests {
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
#[cfg(unix)]
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
dir
}
#[cfg(unix)]
fn own_uid(dir: &Path) -> u32 {
fs::symlink_metadata(dir).unwrap().uid()
}
#[cfg(unix)]
#[test]
fn trusted_path_accepts_owned_unwritable() {
let dir = trust_fixture("ok");
@ -2958,6 +3655,7 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn trusted_path_rejects_group_or_other_writable() {
let dir = trust_fixture("writable");
@ -2978,6 +3676,7 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn trusted_path_rejects_wrong_owner() {
let dir = trust_fixture("owner");
@ -2994,6 +3693,7 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn trusted_path_rejects_symlink() {
let dir = trust_fixture("symlink");
@ -3009,6 +3709,7 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn trusted_path_rejects_missing() {
let dir = trust_fixture("missing");
@ -3074,6 +3775,7 @@ mod tests {
}
}
#[cfg(unix)]
fn mint_fixture(name: &str) -> MintFixture {
let dir =
std::env::temp_dir().join(format!("redflag-mint-test-{}-{}", name, std::process::id()));
@ -3144,6 +3846,7 @@ mod tests {
// The full loop: mint a token, then verify it with the exact same functions
// the execute path uses. If this passes, a minted token is executable.
#[cfg(unix)]
#[test]
fn mint_round_trips_through_execute_verification() {
let fx = mint_fixture("roundtrip");
@ -3181,6 +3884,7 @@ mod tests {
assert!(journal.contains(&token.token_id));
}
#[cfg(unix)]
#[test]
fn mint_rejects_stale_evidence() {
let fx = mint_fixture("stale");
@ -3191,6 +3895,7 @@ mod tests {
assert_eq!(d.code, EXIT_MINT_STALE);
}
#[cfg(unix)]
#[test]
fn mint_rejects_future_dated_evidence() {
let fx = mint_fixture("future");
@ -3201,6 +3906,7 @@ mod tests {
assert_eq!(d.code, EXIT_MINT_STALE);
}
#[cfg(unix)]
#[test]
fn mint_vulnerable_requires_override_reason() {
let fx = mint_fixture("vuln");
@ -3218,6 +3924,7 @@ mod tests {
assert!(journal.contains(&token.token_id));
}
#[cfg(unix)]
#[test]
fn mint_duplicate_request_id_denied() {
let fx = mint_fixture("dup");
@ -3235,6 +3942,7 @@ mod tests {
.is_file());
}
#[cfg(unix)]
#[test]
fn mint_loose_key_permissions_denied() {
let fx = mint_fixture("perms");
@ -3244,6 +3952,7 @@ mod tests {
assert_eq!(d.code, EXIT_MINT_KEY);
}
#[cfg(unix)]
#[test]
fn mint_rejects_agent_self_and_bad_hashes() {
let fx = mint_fixture("shape");
@ -3271,6 +3980,7 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn mint_rejects_package_types_outside_local_api() {
let fx = mint_fixture("package-types");
@ -4631,7 +5341,22 @@ fn verify_envelope(
now: i64,
) -> Result<MutationEnvelope, Box<(Option<MutationEnvelope>, Denial)>> {
let envelope = read_envelope_from_file(envelope_file).map_err(|d| Box::new((None, d)))?;
verify_envelope_with_context(envelope, now, local_agent_id, || {
load_keyring(&PathBuf::from(env_or(
"REDFLAG_HELPER_KEYRING",
DEFAULT_KEYRING_DIR,
)))
})
}
// Platform transports supply independently protected identity and keys. The
// signed-byte verification and denial order remain one implementation.
fn verify_envelope_with_context(
envelope: MutationEnvelope,
now: i64,
read_identity: impl FnOnce() -> Result<String, Denial>,
read_keys: impl FnOnce() -> Result<Vec<(String, VerifyingKey)>, Denial>,
) -> Result<MutationEnvelope, Box<(Option<MutationEnvelope>, Denial)>> {
// Evaluate the denial before moving the envelope into the return — every
// detail string reads from it.
macro_rules! deny {
@ -4671,7 +5396,7 @@ fn verify_envelope(
// target_id is the RedFlag agent identity. Both copies are signed and the
// verifier requires them equal; both are compared anyway, because the cost
// is a string compare and the failure would be silent.
let local = match local_agent_id() {
let local = match read_identity() {
Ok(v) => v,
Err(d) => deny!(d),
};
@ -4744,8 +5469,7 @@ fn verify_envelope(
));
}
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
let keyring = match load_keyring(&keyring_dir) {
let keyring = match read_keys() {
Ok(k) => k,
Err(d) => deny!(d),
};
@ -5192,6 +5916,11 @@ fn main() {
std::process::exit(run_mint_cli(&args[2..]));
}
// One-way standalone-to-fleet authority replacement.
if args.get(1).map(|s| s.as_str()) == Some("adopt-fleet") {
std::process::exit(run_adopt_fleet_cli(&args[2..]));
}
// Standalone mutation authority: signs a fully resolved pacman manifest
// only after the helper has verified the package identities and signatures.
if args.get(1).map(|s| s.as_str()) == Some("mint-envelope") {

View file

@ -21,6 +21,7 @@ const AUTHORIZATION_DOMAIN: &str = "redflag.mutation-authorization";
const RECEIPT_DOMAIN: &str = "redflag.mutation-receipt";
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ResolvedAction {
pub kind: String,
pub identity: String,
@ -28,6 +29,7 @@ pub struct ResolvedAction {
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Evidence {
pub kind: String,
pub digest: String,
@ -36,6 +38,7 @@ pub struct Evidence {
/// `target_id` MUST be the locally provisioned RedFlag agent identity. The
/// generic name is deliberate: a later protocol may define another namespace.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MutationManifest {
pub protocol_version: u32,
pub operation_id: String,
@ -47,6 +50,7 @@ pub struct MutationManifest {
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MutationAuthorization {
pub protocol_version: u32,
pub authorization_id: String,
@ -63,6 +67,7 @@ pub struct MutationAuthorization {
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MutationEnvelope {
pub manifest: MutationManifest,
pub authorization: MutationAuthorization,

View file

@ -34,6 +34,14 @@ try {
& go build -trimpath -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$Version" -o (Join-Path $output 'redflag-agent.exe') ./cmd/agent/
if ($LASTEXITCODE -ne 0) { throw 'Agent build failed.' }
} finally { Pop-Location }
Push-Location (Join-Path $root 'helper')
$previousVersion = $env:REDFLAG_RELEASE_VERSION
try {
$env:REDFLAG_RELEASE_VERSION = $Version
& cargo build --release --locked
if ($LASTEXITCODE -ne 0) { throw 'Helper build failed.' }
Copy-Item 'target/release/redflag-helper.exe' $output
} finally { $env:REDFLAG_RELEASE_VERSION = $previousVersion; Pop-Location }
$desktop = Join-Path $output 'redflag-desktop.exe'
& windeployqt --release --qmldir (Join-Path $root 'desktop/qml') --dir $output $desktop
if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed.' }
@ -42,6 +50,7 @@ foreach ($relative in @('Qt6Core.dll', 'Qt6Gui.dll', 'Qt6Qml.dll', 'Qt6Quick.dll
}
Copy-Item (Join-Path $root 'LICENSE') (Join-Path $output 'LICENSE.txt')
Copy-Item (Join-Path $root 'THIRD_PARTY_LICENSES.md') $output
Copy-Item (Join-Path $root 'installer/windows/provision-helper.ps1') $output
# Remove the build toolchain from DLL lookup for the version check. A clean
# Windows VM must additionally launch QML and exercise the Agent pipe.
$buildPath = $env:PATH
@ -51,6 +60,33 @@ try {
if ($LASTEXITCODE -ne 0 -or $reportedVersion -ne "RedFlag v$expectedVersion") {
throw 'Staged Desktop failed its version check without the Qt build PATH.'
}
$helper = Join-Path $output 'redflag-helper.exe'
$helperVersion = & $helper --version
if ($LASTEXITCODE -ne 0 -or $helperVersion -ne "RedFlag helper v$expectedVersion") {
throw 'Staged Helper failed its version check.'
}
& $helper execute-envelope
if ($LASTEXITCODE -ne 18) {
throw 'Windows Helper did not fail closed for unavailable execution.'
}
# The fixed task entry point takes no runtime arguments, and its exchange
# root is not negotiable. Both checks are read-only: this worker carries no
# RedFlag installation, so run-request can only fail to open the fixed root.
# Neither touches service identity, ACLs, or the task scheduler.
& $helper run-request extra-argument
if ($LASTEXITCODE -ne 10) {
throw 'Windows Helper accepted arguments on its fixed task entry point.'
}
& $helper run-request
if ($LASTEXITCODE -ne 26) {
throw 'Windows Helper did not refuse an unprovisioned exchange root.'
}
$helperRail = & (Join-Path $output 'provision-helper.ps1') -Describe | ConvertFrom-Json
if ($helperRail.principal -ne 'SYSTEM' -or
$helperRail.runnable_by -ne 'NT SERVICE\RedFlagAgent' -or
$helperRail.arguments -ne 'run-request') {
throw 'Staged Helper task description does not preserve the fixed authority boundary.'
}
} finally { $env:PATH = $buildPath }
$files = @(Get-ChildItem $output -Recurse -File | Sort-Object FullName | ForEach-Object {
[ordered]@{
@ -59,6 +95,6 @@ $files = @(Get-ChildItem $output -Recurse -File | Sort-Object FullName | ForEach
size = $_.Length
}
})
[ordered]@{ desktop_version = $reportedVersion; files = $files } |
[ordered]@{ desktop_version = $reportedVersion; helper_version = $helperVersion; files = $files } |
ConvertTo-Json -Depth 4 | Set-Content (Join-Path $output 'payload.json') -Encoding UTF8
Write-Output "Desktop payload staged at $output; this is not yet an installer or an installation test."

View file

@ -10,6 +10,21 @@ spec.loader.exec_module(license_module)
class BuildInputs(unittest.TestCase):
def test_helper_task_is_fixed_and_separates_agent_input(self):
script = pathlib.Path(__file__).with_name("provision-helper.ps1").read_text()
self.assertIn("$serviceAccount = 'NT SERVICE\\RedFlagAgent'", script)
self.assertIn("$requestPath = Join-Path $helperState 'requests'", script)
self.assertIn("-Execute $helperPath -Argument 'run-request'", script)
self.assertIn("$agentService.StartName -ne $serviceAccount", script)
self.assertIn("[switch]$MigrateAgentService", script)
self.assertIn("Refusing to replace operator-owned Agent service identity", script)
self.assertIn("'sidtype', 'RedFlagAgent', 'unrestricted'", script)
self.assertIn("'config', 'RedFlagAgent', 'obj=', $serviceAccount", script)
self.assertIn("'config', 'RedFlagAgent', 'obj=', 'LocalSystem'", script)
self.assertIn("Set-RedFlagDirectoryAcl -Path $requestPath -AgentAccess Modify", script)
self.assertIn("Set-RedFlagDirectoryAcl -Path $resultPath -AgentAccess Read", script)
self.assertNotIn("$(Arg", script)
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]

View file

@ -14,9 +14,8 @@ if [[ "$(git rev-parse --is-shallow-repository)" != "false" ]]; then
fi
git rev-parse --verify "${ref}^{commit}" >/dev/null
# RedFlag legitimately manages private networks. This gate is deliberately
# Casey-specific: it catches known home infrastructure and author metadata,
# not every RFC1918 address a fleet-management tool needs in examples/tests.
# RedFlag legitimately manages private networks. House topology is advisory in
# the surface gate; this legacy check retains private home/identity checks.
#
# It excludes itself, because it has to write down what it is looking for. That
# was invisible while the file arrived in an old commit already on the
@ -26,12 +25,12 @@ git log "$ref" -p --no-ext-diff --no-color --format='@@COMMIT@@%H' \
-- . ':(exclude)scripts/check-public-history.sh' |
awk '
/^@@COMMIT@@/ { sha=substr($0,11); next }
/10\.10\.20\.[0-9]{1,3}|172\.16\.42\.[0-9]{1,3}|wiuf-docker|gitea\.wiuf\.net|\/home\/casey/ { print sha }
/\/home\/casey/ { print sha }
' >"$tmpdir/content"
git log "$ref" --format='%H%x09%an%x09%ae%x09%cn%x09%ce' |
awk -F '\t' '
tolower($0) ~ /@wiuf\.net|@wifu\.net|10\.10\.20\.|172\.16\.42\.|\/home\/casey/ { print $1 }
tolower($0) ~ /@wiuf\.net|@wifu\.net|\/home\/casey/ { print $1 }
' >"$tmpdir/metadata"
sed -E '/^[[:space:]]*(#|$)/d' "$allowlist" | sort -u >"$tmpdir/allowed"

View file

@ -29,6 +29,8 @@ TOKENS_DIR="/var/lib/redflag/agent/tokens"
MUTATION_REQUEST_DIR="/var/lib/redflag/agent/mutation-requests"
MUTATION_ENVELOPE_DIR="/var/lib/redflag/agent/mutation-envelopes"
MUTATION_RECEIPT_DIR="/var/lib/redflag/agent/mutation-receipts"
FLEET_JOIN_REQUEST_DIR="/var/lib/redflag/agent/fleet-join-requests"
FLEET_JOIN_RECEIPT_DIR="/var/lib/redflag/agent/fleet-join-receipts"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent-mint"
log() { echo "[INFO] [provision] [standalone-authority] $*"; }
@ -66,8 +68,9 @@ install -d -m 0750 -o "$AGENT_USER" -g "$LOCAL_GROUP" "$MINT_REQUEST_DIR"
log "mint request dir ready: $MINT_REQUEST_DIR"
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" \
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR"
log "mutation exchange dirs ready"
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR" \
"$FLEET_JOIN_REQUEST_DIR" "$FLEET_JOIN_RECEIPT_DIR"
log "mutation and fleet-join exchange dirs ready"
# Tokens dir should already exist from the base install; ensure it does.
[ -d "$TOKENS_DIR" ] || install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$TOKENS_DIR"
@ -91,9 +94,10 @@ cat > "$SUDOERS_FILE" <<EOF
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint --request-file $MINT_REQUEST_DIR/* --token-out $TOKENS_DIR/*
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint-envelope --request-file $MUTATION_REQUEST_DIR/* --envelope-out $MUTATION_ENVELOPE_DIR/*
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN execute-envelope --envelope-file $MUTATION_ENVELOPE_DIR/* --receipt-file $MUTATION_RECEIPT_DIR/*
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN adopt-fleet --request-file $FLEET_JOIN_REQUEST_DIR/* --receipt-file $FLEET_JOIN_RECEIPT_DIR/*
EOF
chmod 0440 "$SUDOERS_FILE"
visudo -c -f "$SUDOERS_FILE" >/dev/null || fail "sudoers validation failed for $SUDOERS_FILE"
log "sudoers installed: $SUDOERS_FILE"
log "standalone authority provisioned — fleet join is not implemented; do not add fleet credentials beside this key"
log "standalone authority provisioned — Desktop may retire it through the one-way fleet join"

View file

@ -463,7 +463,14 @@ func runServer(ctx context.Context, ready func(), migrate bool) {
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
var fleetJoinHandler *handlers.FleetJoinHandler
if signingService != nil && signingService.IsEnabled() {
fleetJoinHandler = handlers.NewFleetJoinHandler(db.DB, registrationTokenQueries, agentQueries, signingService.GetPublicKey(), cfg)
fleetJoinHandler = handlers.NewFleetJoinHandler(
db.DB,
registrationTokenQueries,
agentQueries,
signingService.GetPublicKey(),
signingService.GetCurrentKeyID(),
cfg,
)
}
maintenanceWindowHandler := handlers.NewMaintenanceWindowHandler(maintenanceWindowQueries)

View file

@ -7,10 +7,14 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"github.com/Fimeg/RedFlag/server/internal/api/middleware"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
@ -20,22 +24,26 @@ import (
"github.com/jmoiron/sqlx"
)
const fleetJoinRequestMetadataKey = "redflag_fleet_join_request_id"
// FleetJoinHandler handles standalone-to-fleet migration (SEC-025).
type FleetJoinHandler struct {
db *sqlx.DB
tokenQueries *queries.RegistrationTokenQueries
agentQueries *queries.AgentQueries
signingPublicKey string
signingPublicKey string
signingKeyID string
config *config.Config
}
// NewFleetJoinHandler creates a FleetJoinHandler.
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey string, cfg *config.Config) *FleetJoinHandler {
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey, signingKeyID string, cfg *config.Config) *FleetJoinHandler {
return &FleetJoinHandler{
db: db,
tokenQueries: tokenQueries,
agentQueries: agentQueries,
signingPublicKey: signingPublicKey,
signingKeyID: signingKeyID,
config: cfg,
}
}
@ -44,27 +52,35 @@ func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQue
// seed never crosses this channel — the server holds it encrypted from token
// creation and only the 6-digit code travels here.
type FleetJoinRequest struct {
RegistrationToken string `json:"registration_token" binding:"required"`
TOTPCode string `json:"totp_code" binding:"required"`
RequestID uuid.UUID `json:"request_id" binding:"required"`
AgentID uuid.UUID `json:"agent_id" binding:"required"`
RegistrationToken string `json:"registration_token" binding:"required"`
TOTPCode string `json:"totp_code" binding:"required"`
// Standard agent registration fields (same as RegisterAgent).
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
AvailableScanners []string `json:"available_scanners"`
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
AvailableScanners []string `json:"available_scanners"`
Metadata map[string]interface{} `json:"metadata"`
DeviceType string `json:"device_type"`
DeviceModel string `json:"device_model"`
OSDistro string `json:"os_distro"`
}
// FleetJoinResponse is the server's response on successful fleet join.
type FleetJoinResponse struct {
AgentID uuid.UUID `json:"agent_id"`
RefreshToken string `json:"refresh_token"`
SigningPublicKey string `json:"signing_public_key"`
ServerURL string `json:"server_url"`
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
SigningPublicKey string `json:"signing_public_key"`
SigningKeyID string `json:"signing_key_id"`
ServerURL string `json:"server_url"`
Config map[string]interface{} `json:"config"`
}
// JoinFleet handles POST /api/v1/fleet-join. Validates the registration token
@ -75,9 +91,13 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.RequestID == uuid.Nil || req.AgentID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "request_id and agent_id must be UUIDs"})
return
}
// Step 1: Validate registration token.
tokenInfo, err := h.tokenQueries.ValidateRegistrationToken(req.RegistrationToken)
tokenInfo, err := h.tokenQueries.GetFleetJoinToken(req.RegistrationToken)
if err != nil || tokenInfo == nil {
log.Printf("[SECURITY] [server] [fleet-join] invalid_token error=%v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
@ -90,6 +110,12 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "this token does not require 2FA — use the standard registration endpoint"})
return
}
var tokenMetadata map[string]interface{}
if err := json.Unmarshal(tokenInfo.Metadata, &tokenMetadata); err != nil || tokenMetadata["fleet_join"] != true {
log.Printf("[SECURITY] [server] [fleet-join] wrong_token_class token_id=%s", tokenInfo.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "this token is not a fleet join key"})
return
}
// Step 3: Validate the TOTP code against the server-held seed. The seed
// was stored encrypted at token creation and never crosses this channel;
@ -105,11 +131,49 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid TOTP code"})
return
}
recovering, err := h.tokenQueries.FleetJoinTokenUsedBy(tokenInfo.ID, req.AgentID)
if err != nil {
log.Printf("[ERROR] [server] [fleet-join] token_usage_lookup_failed request_id=%s error=%q", req.RequestID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
seatAvailable := tokenInfo.Status == "active" && tokenInfo.SeatsUsed < tokenInfo.MaxSeats
if !seatAvailable && !recovering {
log.Printf("[SECURITY] [server] [fleet-join] spent_token_wrong_agent request_id=%s agent_id=%s", req.RequestID, req.AgentID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "fleet join token belongs to another Agent"})
return
}
existingByID, idErr := h.agentQueries.GetAgentByID(req.AgentID)
if recovering {
if idErr != nil || existingByID == nil {
log.Printf("[ERROR] [server] [fleet-join] recovery_agent_missing request_id=%s agent_id=%s error=%v", req.RequestID, req.AgentID, idErr)
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery cannot find the registered Agent"})
return
}
if req.MachineID != "" && (existingByID.MachineID == nil || *existingByID.MachineID != req.MachineID) {
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery machine ID mismatch"})
return
}
storedRequestID, _ := existingByID.Metadata[fleetJoinRequestMetadataKey].(string)
if storedRequestID == "" || storedRequestID != req.RequestID.String() {
log.Printf("[SECURITY] [server] [fleet-join] recovery_request_mismatch request_id=%s agent_id=%s", req.RequestID, req.AgentID)
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery request does not match the accepted transaction"})
return
}
} else if idErr == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Agent ID is already registered"})
return
} else if !errors.Is(idErr, sql.ErrNoRows) {
log.Printf("[ERROR] [server] [fleet-join] agent_lookup_failed request_id=%s error=%q", req.RequestID, idErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
// Step 5: Check machine ID isn't already registered (same as RegisterAgent).
if req.MachineID != "" {
existing, err := h.agentQueries.GetAgentByMachineID(req.MachineID)
if err == nil && existing != nil && existing.ID.String() != "" {
if err == nil && existing != nil && existing.ID != req.AgentID {
c.JSON(http.StatusConflict, gin.H{
"error": "machine ID already registered to another agent",
"existing_agent_id": existing.ID.String(),
@ -139,9 +203,21 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
if req.PublicKeyFingerprint != "" {
pubKeyFP = &req.PublicKeyFingerprint
}
deviceType := req.DeviceType
if !models.ValidDeviceType(deviceType) {
deviceType = "server"
}
var deviceModel *string
if req.DeviceModel != "" {
deviceModel = &req.DeviceModel
}
var osDistro *string
if req.OSDistro != "" {
osDistro = &req.OSDistro
}
agent := &models.Agent{
ID: uuid.Must(uuid.NewV4()),
ID: req.AgentID,
Hostname: req.Hostname,
OSType: req.OSType,
OSVersion: req.OSVersion,
@ -150,6 +226,9 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
CurrentVersion: req.AgentVersion,
MachineID: machineID,
PublicKeyFingerprint: pubKeyFP,
DeviceType: deviceType,
DeviceModel: deviceModel,
OSDistro: osDistro,
LastSeen: time.Now().UTC(),
Status: "online",
Metadata: models.JSONB{},
@ -160,30 +239,75 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
agent.Metadata[k] = v
}
}
createQuery := `
INSERT INTO agents (
id, hostname, os_type, os_version, os_architecture,
agent_version, current_version, machine_id, public_key_fingerprint,
last_seen, status, metadata
) VALUES (
:id, :hostname, :os_type, :os_version, :os_architecture,
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
:last_seen, :status, :metadata
)`
if _, err := tx.NamedExec(createQuery, agent); err != nil {
log.Printf("[ERROR] [server] [fleet-join] create_agent_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
return
if recovering {
for k, v := range existingByID.Metadata {
if _, exists := agent.Metadata[k]; !exists {
agent.Metadata[k] = v
}
}
if agent.MachineID == nil {
agent.MachineID = existingByID.MachineID
}
if agent.PublicKeyFingerprint == nil {
agent.PublicKeyFingerprint = existingByID.PublicKeyFingerprint
}
if req.DeviceType == "" {
agent.DeviceType = existingByID.DeviceType
}
if agent.DeviceModel == nil {
agent.DeviceModel = existingByID.DeviceModel
}
if agent.OSDistro == nil {
agent.OSDistro = existingByID.OSDistro
}
}
agent.Metadata[fleetJoinRequestMetadataKey] = req.RequestID.String()
// Mark token as used.
var tokenSuccess bool
tokenHash := queries.HashRegistrationToken(req.RegistrationToken)
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
log.Printf("[ERROR] [server] [fleet-join] mark_token_failed error=%v success=%v", err, tokenSuccess)
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
return
if recovering {
updateQuery := `
UPDATE agents SET
hostname = :hostname, os_type = :os_type, os_version = :os_version,
os_architecture = :os_architecture, agent_version = :agent_version,
current_version = :current_version, machine_id = :machine_id,
public_key_fingerprint = :public_key_fingerprint,
device_type = :device_type, device_model = :device_model,
os_distro = :os_distro, last_seen = :last_seen,
status = :status, metadata = :metadata
WHERE id = :id`
if _, err := tx.NamedExec(updateQuery, agent); err != nil {
log.Printf("[ERROR] [server] [fleet-join] recover_agent_failed request_id=%s error=%q", req.RequestID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to recover fleet join"})
return
}
if _, err := tx.Exec("UPDATE refresh_tokens SET revoked = TRUE WHERE agent_id = $1 AND NOT revoked", agent.ID); err != nil {
log.Printf("[ERROR] [server] [fleet-join] recover_revoke_tokens_failed request_id=%s error=%q", req.RequestID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to recover fleet join"})
return
}
} else {
createQuery := `
INSERT INTO agents (
id, hostname, os_type, os_version, os_architecture,
agent_version, current_version, machine_id, public_key_fingerprint,
device_type, device_model, os_distro, last_seen, status, metadata
) VALUES (
:id, :hostname, :os_type, :os_version, :os_architecture,
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
:device_type, :device_model, :os_distro, :last_seen, :status, :metadata
)`
if _, err := tx.NamedExec(createQuery, agent); err != nil {
log.Printf("[ERROR] [server] [fleet-join] create_agent_failed request_id=%s error=%q", req.RequestID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
return
}
var tokenSuccess bool
tokenHash := queries.HashRegistrationToken(req.RegistrationToken)
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
log.Printf("[ERROR] [server] [fleet-join] mark_token_failed request_id=%s error=%v success=%v", req.RequestID, err, tokenSuccess)
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
return
}
}
// Generate refresh token.
@ -202,36 +326,53 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
return
}
// Create subsystems for available scanners.
if len(req.AvailableScanners) > 0 {
for _, scanner := range req.AvailableScanners {
// Initial join creates the same observed scanner rows as registration.
if !recovering {
scanners := append([]string{}, req.AvailableScanners...)
scanners = append(scanners, "storage", "system")
for _, scanner := range scanners {
interval := 60
if scanner == "storage" || scanner == "system" {
interval = 5
}
sub := models.AgentSubsystem{
AgentID: agent.ID,
Subsystem: scanner,
Enabled: true,
AutoRun: true,
IntervalMinutes: 60,
IntervalMinutes: interval,
}
subQuery := `INSERT INTO agent_subsystems (agent_id, subsystem, enabled, auto_run, interval_minutes) VALUES (:agent_id, :subsystem, :enabled, :auto_run, :interval_minutes)`
subQuery := `INSERT INTO agent_subsystems (agent_id, subsystem, enabled, auto_run, interval_minutes) VALUES (:agent_id, :subsystem, :enabled, :auto_run, :interval_minutes) ON CONFLICT (agent_id, subsystem) DO NOTHING`
if _, err := tx.NamedExec(subQuery, sub); err != nil {
log.Printf("[WARNING] [server] [fleet-join] create_subsystem_failed scanner=%s error=%q", scanner, err)
}
}
}
accessToken, err := middleware.GenerateAgentToken(agent.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
return
}
if err := tx.Commit(); err != nil {
log.Printf("[ERROR] [server] [fleet-join] commit_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
log.Printf("[INFO] [server] [fleet-join] agent_registered agent_id=%s hostname=%s machine_id=%s",
agent.ID, agent.Hostname, req.MachineID)
log.Printf("[INFO] [server] [fleet-join] completed request_id=%s agent_id=%s hostname=%s recovered=%v",
req.RequestID, agent.ID, agent.Hostname, recovering)
c.JSON(http.StatusCreated, FleetJoinResponse{
AgentID: agent.ID,
Token: accessToken,
RefreshToken: refreshToken,
SigningPublicKey: h.signingPublicKey,
SigningKeyID: h.signingKeyID,
ServerURL: resolveServerURL(c, h.config, "fleet-join"),
Config: map[string]interface{}{
"check_in_interval": h.config.CheckInInterval,
},
})
}

View file

@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/config"
@ -44,10 +45,10 @@ func NewRegistrationTokenHandler(tokenQueries *queries.RegistrationTokenQueries,
// GenerateRegistrationToken creates a new registration token
func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
var request struct {
Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
Metadata map[string]interface{} `json:"metadata"`
Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
Metadata map[string]interface{} `json:"metadata"`
// FleetJoin + TOTPSeed: SEC-025 fleet-join 2FA. When the operator
// creates a fleet-join token, they enter the host's TOTP seed
// (displayed on the standalone host). This is the one admin-
@ -110,18 +111,23 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
}
metadata["server_url"] = resolveServerURL(c, h.config, "registration-tokens")
metadata["expires_in"] = expiresIn
metadata["fleet_join"] = request.FleetJoin
// Default max_seats to 1 if not provided or invalid
maxSeats := request.MaxSeats
if maxSeats < 1 {
maxSeats = 1
}
if request.FleetJoin {
maxSeats = 1
}
// Store token in database. For fleet-join tokens (SEC-025), the operator
// provides the TOTP seed from the standalone host; it must be valid
// base32 or neither an authenticator nor join validation can use it.
var totpSeed string
if request.FleetJoin {
request.TOTPSeed = strings.ToUpper(strings.TrimSpace(request.TOTPSeed))
if request.TOTPSeed == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "fleet-join tokens require a TOTP seed"})
return
@ -145,6 +151,9 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
// SEC-002: token travels in a header, never the URL — query strings land in
// shell history, process lists, and access logs.
installCommand := fmt.Sprintf("curl -sfL -H \"X-Registration-Token: %s\" \"%s/api/v1/install/linux\" | sudo bash", token, serverURL)
if request.FleetJoin {
installCommand = ""
}
response := gin.H{
"token": token,
@ -152,6 +161,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
"expires_at": expiresAt,
"install_command": installCommand,
"metadata": metadata,
"fleet_join": request.FleetJoin,
}
c.JSON(http.StatusCreated, response)

View file

@ -29,35 +29,35 @@ type RegistrationTokenQueries struct {
}
type RegistrationToken struct {
ID uuid.UUID `json:"id" db:"id"`
TokenHash string `json:"-" db:"token_hash"`
ID uuid.UUID `json:"id" db:"id"`
TokenHash string `json:"-" db:"token_hash"`
// TokenEncrypted is the AES-256-GCM ciphertext (nonce||ct) of the plaintext
// token. Never serialised; decrypted into Token for live tokens only.
TokenEncrypted []byte `json:"-" db:"token_encrypted"`
TokenEncrypted []byte `json:"-" db:"token_encrypted"`
// Token is the decrypted plaintext, populated only for live tokens so the
// operator UI can rebuild the install one-liner. Empty for spent/expired/
// revoked tokens and for legacy one-way-hashed tokens.
Token string `json:"token,omitempty" db:"-"`
Label *string `json:"label" db:"label"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UsedAt *time.Time `json:"used_at" db:"used_at"`
UsedByAgentID *uuid.UUID `json:"used_by_agent_id" db:"used_by_agent_id"`
Revoked bool `json:"revoked" db:"revoked"`
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
Status string `json:"status" db:"status"`
CreatedBy string `json:"created_by" db:"created_by"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
MaxSeats int `json:"max_seats" db:"max_seats"`
SeatsUsed int `json:"seats_used" db:"seats_used"`
Token string `json:"token,omitempty" db:"-"`
Label *string `json:"label" db:"label"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UsedAt *time.Time `json:"used_at" db:"used_at"`
UsedByAgentID *uuid.UUID `json:"used_by_agent_id" db:"used_by_agent_id"`
Revoked bool `json:"revoked" db:"revoked"`
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
Status string `json:"status" db:"status"`
CreatedBy string `json:"created_by" db:"created_by"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
MaxSeats int `json:"max_seats" db:"max_seats"`
SeatsUsed int `json:"seats_used" db:"seats_used"`
// TOTPSeedEncrypted is the TOTP seed for fleet-join 2FA (SEC-025), stored
// as AES-256-GCM ciphertext (nonce||ct) of the base32 seed. Nil for
// standard registration tokens; set only when the operator creates a
// fleet-join token that requires the standalone host to prove possession
// of the seed via a TOTP code. Never serialised; decrypted only at join
// validation. The join request carries the code, never the seed.
TOTPSeedEncrypted []byte `json:"-" db:"totp_seed_encrypted"`
TOTPSeedEncrypted []byte `json:"-" db:"totp_seed_encrypted"`
}
// isLive reports whether a token is still usable for enrollment — the only state
@ -81,17 +81,17 @@ func (q *RegistrationTokenQueries) revealPlaintext(t *RegistrationToken) {
}
type TokenRequest struct {
Label string `json:"label"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
Metadata map[string]interface{} `json:"metadata"`
Label string `json:"label"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
Metadata map[string]interface{} `json:"metadata"`
}
type TokenResponse struct {
Token string `json:"token"`
Label string `json:"label"`
ExpiresAt time.Time `json:"expires_at"`
InstallCommand string `json:"install_command"`
Token string `json:"token"`
Label string `json:"label"`
ExpiresAt time.Time `json:"expires_at"`
InstallCommand string `json:"install_command"`
}
// NewRegistrationTokenQueries builds the query handle. encKeyB64 is the base64
@ -205,6 +205,45 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
return &regToken, nil
}
// GetFleetJoinToken resolves the raw join secret even after its one seat was
// consumed. The fleet-join handler uses that spent state only to recover the
// same Agent ID after a response was lost; standard enrollment never calls it.
func (q *RegistrationTokenQueries) GetFleetJoinToken(token string) (*RegistrationToken, error) {
var regToken RegistrationToken
query := `
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used, totp_seed_encrypted
FROM registration_tokens
WHERE token_hash = $1
AND status IN ('active', 'used')
AND NOT revoked
AND expires_at > NOW()
AND totp_seed_encrypted IS NOT NULL
`
if err := q.db.Get(&regToken, query, HashRegistrationToken(token)); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("invalid, expired, or revoked fleet join token")
}
return nil, fmt.Errorf("failed to resolve fleet join token: %w", err)
}
return &regToken, nil
}
func (q *RegistrationTokenQueries) FleetJoinTokenUsedBy(tokenID, agentID uuid.UUID) (bool, error) {
var used bool
err := q.db.Get(&used, `
SELECT EXISTS (
SELECT 1 FROM registration_token_usage
WHERE token_id = $1 AND agent_id = $2
)
`, tokenID, agentID)
if err != nil {
return false, fmt.Errorf("failed to resolve fleet join token use: %w", err)
}
return used, nil
}
// MarkTokenUsed marks a token as used by an agent.
// The caller passes the plaintext token; this function hashes it before calling
// the stored procedure (which now matches on token_hash).
@ -448,4 +487,4 @@ func (q *RegistrationTokenQueries) GetTokenUsageStats() (map[string]int, error)
}
return stats, nil
}
}

View file

@ -26,12 +26,22 @@ func TestGo_RunsAllSubmittedTasks(t *testing.T) {
if got != n {
t.Fatalf("ran %d tasks, want %d", got, n)
}
s := r.Snapshot()
if s.Submitted != n {
if s := r.Snapshot(); s.Submitted != n {
t.Errorf("submitted=%d, want %d", s.Submitted, n)
}
if s.Completed != n {
t.Errorf("completed=%d, want %d", s.Completed, n)
// Completed is incremented after the task body returns, so the WaitGroup
// above cannot cover it: the last worker may still be accounting when Wait
// releases. Wait for the counter actually asserted, as the panic test does.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if r.Snapshot().Completed >= n {
break
}
time.Sleep(2 * time.Millisecond)
}
if c := r.Snapshot().Completed; c != n {
t.Errorf("completed=%d, want %d", c, n)
}
}

View file

@ -204,13 +204,15 @@ const AgentsEnrollment: React.FC = () => {
const [installPlatform, setInstallPlatform] = useState<string>('linux');
const [installTokenId, setInstallTokenId] = useState<string>('');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const [createdToken, setCreatedToken] = useState<{ token: string; label: string } | null>(null);
const [createdToken, setCreatedToken] = useState<{ token: string; label: string; fleetJoin: boolean } | null>(null);
// Create-key form
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
label: '',
expires_in: '168h',
max_seats: 1,
fleet_join: false,
totp_seed: '',
});
// Signing keys (preserved from the old Agent Management page — now its own
@ -249,7 +251,7 @@ const AgentsEnrollment: React.FC = () => {
const availableTokens = React.useMemo(
() =>
allTokens.filter(
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats,
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats && !t.metadata?.fleet_join,
),
[allTokens],
);
@ -293,11 +295,17 @@ const AgentsEnrollment: React.FC = () => {
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
const request: CreateRegistrationTokenRequest = {
...formData,
max_seats: formData.fleet_join ? 1 : formData.max_seats,
totp_seed: formData.fleet_join ? formData.totp_seed?.trim().toUpperCase() : undefined,
};
createToken.mutate(request, {
onSuccess: (data: any) => {
const label = formData.label || data.label;
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setCreatedToken({ token: data.token, label });
const fleetJoin = Boolean(formData.fleet_join);
setFormData({ label: '', expires_in: '168h', max_seats: 1, fleet_join: false, totp_seed: '' });
setCreatedToken({ token: data.token, label, fleetJoin });
refetch();
},
});
@ -435,8 +443,10 @@ const AgentsEnrollment: React.FC = () => {
<div className="space-y-4">
<div className="alert alert-success flex items-start justify-between gap-3">
<p className="text-sm text-success-800">
<span className="font-medium">Key "{createdToken.label}" created.</span> Copy the
token now — it cannot be retrieved again, only a hash is stored.
<span className="font-medium">Key "{createdToken.label}" created.</span>{' '}
{createdToken.fleetJoin
? 'Return to the standalone Desktop and enter this token.'
: 'Copy the token now, then choose the target platform and copy its install command.'}
</p>
<button
onClick={() => setCreatedToken(null)}
@ -460,13 +470,21 @@ const AgentsEnrollment: React.FC = () => {
</button>
</div>
</div>
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
<InstallCommandBox
command={installCommand}
platform={installPlatform}
copied={copiedCommand === 'install'}
onCopy={() => copyToClipboard(installCommand, 'install')}
/>
{createdToken.fleetJoin ? (
<div className="rounded-lg border border-primary-200 bg-primary-50 p-4 text-sm text-primary-900">
This one-seat key can join only the standalone Agent whose host seed you entered. It is not an install key.
</div>
) : (
<>
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
<InstallCommandBox
command={installCommand}
platform={installPlatform}
copied={copiedCommand === 'install'}
onCopy={() => copyToClipboard(installCommand, 'install')}
/>
</>
)}
</div>
) : (
<div className="space-y-4">
@ -551,6 +569,18 @@ const AgentsEnrollment: React.FC = () => {
)
) : (
<form onSubmit={handleCreateToken} className="space-y-4">
<label className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4 cursor-pointer">
<input
type="checkbox"
checked={Boolean(formData.fleet_join)}
onChange={(e) => setFormData({ ...formData, fleet_join: e.target.checked, max_seats: e.target.checked ? 1 : formData.max_seats })}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium text-gray-900">Join an existing standalone machine</span>
<span className="block text-xs text-gray-500 mt-1">Creates one key for one existing Agent ID. Generate the host seed in RedFlag Desktop first.</span>
</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
@ -584,13 +614,29 @@ const AgentsEnrollment: React.FC = () => {
type="number"
min="1"
max="100"
value={formData.max_seats || 1}
value={formData.fleet_join ? 1 : formData.max_seats || 1}
disabled={Boolean(formData.fleet_join)}
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
className="form-input"
/>
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
<p className="mt-1 text-xs text-gray-500">{formData.fleet_join ? 'Fleet join keys always have one seat' : 'Number of agents that can enroll with this key'}</p>
</div>
</div>
{formData.fleet_join && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Standalone host seed *</label>
<input
type="text"
required
autoComplete="off"
spellCheck={false}
value={formData.totp_seed || ''}
onChange={(e) => setFormData({ ...formData, totp_seed: e.target.value.toUpperCase().replace(/\s/g, '') })}
placeholder="Paste the seed shown by RedFlag Desktop"
className="form-input font-mono"
/>
</div>
)}
<button type="submit" disabled={createToken.isPending} className="btn-primary">
{createToken.isPending ? 'Creating...' : 'Create key'}
</button>
@ -690,6 +736,7 @@ const AgentsEnrollment: React.FC = () => {
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500">
<span className={cn('inline-block w-2 h-2 rounded-full', STATUS_DOT[t.status] || 'bg-gray-400')} />
<span>{getStatusText(t)}</span>
{t.metadata?.fleet_join && <span className="text-primary-700 font-medium">Fleet join</span>}
<span>·</span>
<span>
{t.seats_used}/{t.max_seats} seats
@ -728,10 +775,13 @@ const AgentsEnrollment: React.FC = () => {
<span className={cn('badge badge-lg', tokenStatusColor(selectedToken.status))}>
{getStatusText(selectedToken)}
</span>
{selectedToken.metadata?.fleet_join && (
<span className="badge badge-lg ml-2 bg-primary-100 text-primary-800">Fleet join</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{selectedToken.status === 'active' && selectedToken.seats_used < selectedToken.max_seats && (
{selectedToken.status === 'active' && selectedToken.seats_used < selectedToken.max_seats && !selectedToken.metadata?.fleet_join && (
<button
onClick={() => installWithKey(selectedToken.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"

View file

@ -525,6 +525,8 @@ export interface CreateRegistrationTokenRequest {
expires_in?: string;
max_seats?: number;
metadata?: Record<string, any>;
fleet_join?: boolean;
totp_seed?: string;
}
export interface RegistrationTokenStats {