Watch
1
0
Fork
You've already forked RedFlag
0

gate: route desktop updates through the helper

Desktop self-updates ran in the agent process with their own replay
file. They now go through the privileged helper like agent and helper
self-updates, so the agent performs no binary mutation and keeps no
replay state of its own. The desktop app is a status surface only.
This commit is contained in:
Fimeg 2026-06-14 14:23:09 -04:00
commit 7d47b0769d
4 changed files with 141 additions and 291 deletions

View file

@ -2,7 +2,6 @@ package supplychain
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
@ -13,10 +12,8 @@ import (
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const (
@ -34,22 +31,13 @@ const (
// Both must be updated together if this path changes.
helperSelfStagingPath = "/var/lib/redflag/agent/pending-helper.bin"
maxSelfUpdateBinarySize = 500 * 1024 * 1024
)
// desktopSelfStagingPath is a cross-language contract: the agent stages the
// new desktop binary here and the Rust helper reads it on desktop-self
// install. The Rust counterpart is DEFAULT_DESKTOP_SELF_SOURCE in
// helper/src/main.rs. Both must be updated together if this path changes.
desktopSelfStagingPath = "/var/lib/redflag/agent/pending-desktop.bin"
// Exit codes mirror helper/src/main.rs so capability receipts have one taxonomy
// across helper-executed and agent-local self-update paths.
const (
policyExitOK = 0
policyExitBadToken = 10
policyExitTimeWindow = 12
policyExitKeyNotFound = 14
policyExitSignature = 15
policyExitArtifact = 16
policyExitReplay = 17
policyExitUnsupportedOp = 18
policyExitExecFailed = 19
policyExitInternal = 20
maxSelfUpdateBinarySize = 500 * 1024 * 1024
)
func (c *Consumer) processAgentSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
@ -98,74 +86,44 @@ func (c *Consumer) processHelperSelfToken(ctx context.Context, token *capability
return c.executor.Execute(ctx, token)
}
// processDesktopSelfToken stages the new desktop binary where the helper reads
// it, then hands the token to the privileged executor — the same path agent-self
// and helper-self take. The helper verifies the signature and hash, consumes the
// replay slot (record-before-install), and atomically installs the binary. The
// agent no longer mutates the binary or keeps its own replay file (GATE-004): the
// desktop app stays a pure status surface, and the one hardened replay guard
// lives in the helper. The token, closure, and signed message are unchanged.
func (c *Consumer) processDesktopSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if err := validateDirectSelfToken(token); err != nil {
return selfPolicyResult(token, "denied", "desktop_self_token_invalid", policyExitSignature, 0, err), nil
}
if token.Operation != selfUpdateOperation {
return selfPolicyResult(
token,
"denied",
"operation_not_allowed",
policyExitUnsupportedOp,
0,
fmt.Errorf("operation=%s (desktop-self requires upgrade)", token.Operation),
), nil
return nil, fmt.Errorf("desktop-self operation %q not supported", token.Operation)
}
// Bound the in-process staging + install to match the executor timeout.
// The agent-self and helper-self paths go through executor.Execute which
// carries its own context timeout; desktop-self runs in-process and needs
// an equivalent guard against hung downloads or stalled I/O.
ctx, cancel := context.WithTimeout(ctx, executorTimeout)
defer cancel()
entry, err := findClosureEntry(token, "redflag-desktop")
if err != nil {
return selfPolicyResult(token, "denied", "desktop_self_no_entry", policyExitBadToken, 0, err), nil
return nil, err
}
// Check context before starting potentially long staging operation.
if err := ctx.Err(); err != nil {
return selfPolicyResult(token, "failed", "desktop_self_cancelled", policyExitExecFailed, 0, err), nil
}
stagingPath := filepath.Join(constants.GetAgentStateDir(), "desktop-self-upgrade.bin")
staged, err := c.stageClosureArtifact(entry, stagingPath)
staged, err := c.stageClosureArtifact(entry, desktopSelfStagingPath)
if err != nil {
return selfPolicyResult(token, "failed", "desktop_self_stage_failed", policyExitArtifact, 0, err), nil
return nil, fmt.Errorf("stage desktop-self artifact: %w", err)
}
defer os.Remove(staged)
// Check replay before any side effects; record consumed only after a
// successful install. The token is Ed25519-signed, hash-pinned, and
// version-pinned, so re-executing the identical install after a transient
// failure (disk full, backup fail) is idempotent — burning the token before
// install would permanently prevent retry (ETHOS #3, #4).
if err := replayCheckAgent(token.TokenID); err != nil {
return selfPolicyResult(token, "denied", "token_already_consumed", policyExitReplay, 1, err), nil
}
target, err := installDesktopBinary(staged)
result, err := c.executor.Execute(ctx, token)
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] desktop_self_install_failed token_id=%s error=%v", token.TokenID, err)
return selfPolicyResult(token, "failed", "desktop_self_install_failed", policyExitExecFailed, 1, err), nil
return result, err
}
// Install succeeded — mark token consumed so it cannot be replayed.
if err := recordAgentTokenConsumed(token.TokenID); err != nil {
// Log but do not abort: the binary is already in place. A duplicate
// replay would be caught by hash + version checks, and the helper
// would refuse an identical re-install anyway.
log.Printf("[WARNING] [agent] [supplychain] desktop_self_replay_record_failed token_id=%s error=%v", token.TokenID, err)
// Best-effort: nudge the running desktop app to relaunch on the new binary.
// This is not a privileged or trust-sensitive step — just a signal — so it
// stays agent-side. A failure here does not undo a successful install.
if result != nil && result.Executed {
if target, perr := desktopBinaryPath(); perr == nil {
if sigErr := signalDesktopRestart(target); sigErr != nil {
log.Printf("[WARNING] [agent] [supplychain] desktop_restart_signal_failed token_id=%s error=%v", token.TokenID, sigErr)
}
}
}
if err := signalDesktopRestart(target); err != nil {
log.Printf("[WARNING] [agent] [supplychain] desktop_restart_signal_failed token_id=%s error=%v", token.TokenID, err)
}
log.Printf("[SECURITY] [agent] [supplychain] desktop_self_upgraded token_id=%s path=%s", token.TokenID, target)
return selfPolicyResult(token, "executed", "desktop_self_upgraded", policyExitOK, 1, nil), nil
return result, nil
}
func findClosureEntry(token *capability.Token, name string) (*capability.ClosureEntry, error) {
@ -266,125 +224,6 @@ func isDownloadRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "/api/")
}
func validateDirectSelfToken(token *capability.Token) error {
if token.Version != capability.Version {
return fmt.Errorf("unsupported token version=%d supported=%d", token.Version, capability.Version)
}
now := time.Now().UTC().Unix()
if now < token.NotBefore {
return fmt.Errorf("token not yet valid: now=%d not_before=%d", now, token.NotBefore)
}
if now > token.ExpiresAt {
return fmt.Errorf("token expired: now=%d expires_at=%d", now, token.ExpiresAt)
}
pub, err := loadCapabilityPublicKey()
if err != nil {
return err
}
keyID := capability.KeyIDFor(ed25519.PublicKey(pub))
if token.KeyID != keyID {
return fmt.Errorf("key_id mismatch: token=%s local=%s", token.KeyID, keyID)
}
if err := token.Verify(ed25519.PublicKey(pub)); err != nil {
return err
}
return nil
}
func loadCapabilityPublicKey() ([]byte, error) {
keyPath := constants.GetServerPublicKeyPath()
data, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("public key not found at %s: %w", keyPath, err)
}
if len(data) == ed25519.PublicKeySize {
return data, nil
}
trimmed := strings.TrimSpace(string(data))
decoded, decErr := hex.DecodeString(trimmed)
if decErr == nil && len(decoded) == ed25519.PublicKeySize {
return decoded, nil
}
return nil, fmt.Errorf("invalid public key size at %s: raw=%d decoded=%d", keyPath, len(data), len(decoded))
}
// replayCheckAgent returns an error if tokenID has already been consumed.
// It does NOT record the token; call recordAgentTokenConsumed after a
// successful install to close the replay window.
func replayCheckAgent(tokenID string) error {
if _, err := safeTokenFilename(tokenID); err != nil {
return err
}
if strings.ContainsAny(tokenID, "\r\n") {
return fmt.Errorf("unsafe token_id: contains newline")
}
statePath := filepath.Join(constants.GetAgentStateDir(), "consumed-self-tokens")
contents, err := os.ReadFile(statePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read replay state: %w", err)
}
if err == nil {
for _, line := range strings.Split(string(contents), "\n") {
if strings.TrimSpace(line) == tokenID {
return fmt.Errorf("token_id=%s", tokenID)
}
}
}
return nil
}
// recordAgentTokenConsumed appends tokenID to the consumed-self-tokens file.
// Call only after a successful install to avoid permanently burning the token
// on a transient failure.
func recordAgentTokenConsumed(tokenID string) error {
statePath := filepath.Join(constants.GetAgentStateDir(), "consumed-self-tokens")
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
return fmt.Errorf("create replay state dir: %w", err)
}
f, err := os.OpenFile(statePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open replay state: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintln(f, tokenID); err != nil {
return fmt.Errorf("write replay state: %w", err)
}
return nil
}
func installDesktopBinary(staged string) (string, error) {
target, err := desktopBinaryPath()
if err != nil {
return "", err
}
targetDir := filepath.Dir(target)
if info, err := os.Stat(targetDir); err != nil {
return "", fmt.Errorf("desktop install dir unavailable: %w", err)
} else if !info.IsDir() {
return "", fmt.Errorf("desktop install parent is not a directory: %s", targetDir)
}
if info, err := os.Stat(target); err == nil {
if info.IsDir() {
return "", fmt.Errorf("desktop target is a directory: %s", target)
}
// One generation back: overwrite stale .bak with current before replacing.
os.Remove(target + ".bak")
if err := copyRegularFile(target, target+".bak"); err != nil {
return "", fmt.Errorf("backup desktop binary: %w", err)
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("stat desktop binary: %w", err)
}
if err := atomicReplaceFile(staged, target, 0o755); err != nil {
return "", err
}
return target, nil
}
func desktopBinaryPath() (string, error) {
if p := os.Getenv("REDFLAG_DESKTOP_BINARY"); p != "" {
return p, nil
@ -418,39 +257,6 @@ func signalDesktopRestart(target string) error {
return fmt.Errorf("pkill -x %s failed: %w output=%s", name, err, strings.TrimSpace(string(out)))
}
func atomicReplaceFile(staged, target string, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(target), filepath.Base(target)+".new.")
if err != nil {
return fmt.Errorf("create install temp: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close install temp: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.Remove(tmpPath)
}
}()
if err := copyRegularFile(staged, tmpPath); err != nil {
return err
}
if err := os.Chmod(tmpPath, mode); err != nil {
return fmt.Errorf("chmod install temp: %w", err)
}
if runtime.GOOS == "windows" {
_ = os.Remove(target)
}
if err := os.Rename(tmpPath, target); err != nil {
return fmt.Errorf("replace binary: %w", err)
}
cleanup = false
return nil
}
func copyRegularFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
@ -490,22 +296,3 @@ func computeFileSHA256Hex(path string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil
}
func selfPolicyResult(token *capability.Token, decision, reason string, exitCode, verifiedArtifacts int, resultErr error) *PolicyResult {
var errText string
if resultErr != nil {
errText = resultErr.Error()
}
return &PolicyResult{
TokenID: token.TokenID,
AgentID: token.AgentID,
PackageType: token.PackageType,
Operation: token.Operation,
Decision: decision,
Reason: reason,
Executed: decision == "executed",
VerifiedArtifacts: verifiedArtifacts,
ExitCode: exitCode,
Error: errText,
Timestamp: time.Now().UTC().Unix(),
}
}

View file

@ -237,12 +237,13 @@ type Consumer struct {
reporter Reporter // optional
downloader ArtifactDownloader // optional, inferred from reporter when available
// mu serializes ProcessToken. The replay-state guards (helper-side, and the
// agent-side desktop-self file until GATE-004 B lands) are check-then-act and
// not safe under concurrent processing of the same token. The instance lock is
// process-level only; today's single caller (loop.go) never overlaps, but a
// future second caller — local-API trigger, retry worker — would race without
// this. One token at a time is the design invariant; this enforces it.
// mu serializes ProcessToken. The helper's replay-state guard is check-then-act
// (read-scan-rewrite) and not safe under concurrent processing of the same token;
// GATE-004 B removed the agent-side desktop-self replay file, so the helper guard
// is now the only one. The instance lock is process-level only; today's single
// caller (loop.go) never overlaps, but a future second caller — local-API trigger,
// retry worker — would race without this. One token at a time is the design
// invariant; this enforces it.
mu sync.Mutex
}

View file

@ -136,48 +136,6 @@ func TestStageClosureArtifactFromLocalFile(t *testing.T) {
}
}
func TestInstallDesktopBinaryBacksUpAndReplaces(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "redflag-desktop")
staged := filepath.Join(dir, "redflag-desktop.new")
if err := os.WriteFile(target, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("REDFLAG_DESKTOP_BINARY", target)
gotTarget, err := installDesktopBinary(staged)
if err != nil {
t.Fatalf("installDesktopBinary returned error: %v", err)
}
if gotTarget != target {
t.Fatalf("target = %q, want %q", gotTarget, target)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "new" {
t.Fatalf("installed body = %q, want new", string(got))
}
backup, err := os.ReadFile(target + ".bak")
if err != nil {
t.Fatal(err)
}
if string(backup) != "old" {
t.Fatalf("backup body = %q, want old", string(backup))
}
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o755 {
t.Fatalf("target mode = %o, want 755", info.Mode().Perm())
}
}
func TestConsumerProcessTokenRejectsWrongAgentID(t *testing.T) {
agentID := uuid.Must(uuid.NewV4())
executor := NewExecutor("/nonexistent/helper")

View file

@ -72,6 +72,19 @@ const HELPER_SELF_PACKAGE_TYPE: &str = "helper-self";
// The Go counterpart is helperSelfStagingPath in agent/internal/supplychain/binary_update.go.
// Both must be updated together if this path changes.
const DEFAULT_HELPER_SELF_SOURCE: &str = "/var/lib/redflag/agent/pending-helper.bin";
// Desktop self-upgrade (package_type "desktop-self"). The desktop binary is a
// system binary that lives beside the agent binary, so its install is a
// privileged swap like the agent's — it now runs through the helper instead of
// in the agent process (GATE-004), reusing the same hash-verify + replay guard.
const DESKTOP_SELF_PACKAGE_TYPE: &str = "desktop-self";
// DEFAULT_DESKTOP_SELF_SOURCE is a cross-language contract: the Go agent stages
// the new desktop binary here before invoking the helper with a desktop-self
// token. The Go counterpart is desktopSelfStagingPath in
// agent/internal/supplychain/binary_update.go. Both must change together.
const DEFAULT_DESKTOP_SELF_SOURCE: &str = "/var/lib/redflag/agent/pending-desktop.bin";
// DEFAULT_DESKTOP_BINARY is the install target — beside the agent binary, never
// taken from the token. Overridable via REDFLAG_DESKTOP_BINARY.
const DEFAULT_DESKTOP_BINARY: &str = "/usr/local/bin/redflag-desktop";
#[derive(Debug, Deserialize, Serialize, Clone)]
struct ClosureEntry {
@ -807,6 +820,27 @@ fn install_helper_binary(staged: &str) -> Result<(), Denial> {
Ok(())
}
// install_desktop_binary backs up the live desktop binary and installs the
// verified staged copy beside the agent binary. The install path is a helper
// constant, never taken from the token. Runs only after the replay slot is
// consumed (the side effect is irreversible). The desktop app is a one-shot the
// agent re-launches after a successful install, so no setcap/service restart is
// performed here.
fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
let install = env_or("REDFLAG_DESKTOP_BINARY", DEFAULT_DESKTOP_BINARY);
let backup = format!("{}.bak", install);
if Path::new(&install).exists() {
fs::copy(&install, &backup).map_err(|e| {
Denial::new(EXIT_EXEC_FAILED, "desktop_self_backup", format!("{} -> {}: {}", install, backup, e))
})?;
}
atomic_replace_binary(staged, &install, "desktop_self_install")?;
let _ = fs::remove_file(staged);
log_security(&format!("desktop_self_updated path={}", install));
Ok(())
}
fn run(token_file: Option<&str>, helper_file: Option<&str>) -> Result<PolicyResult, Box<(Option<CapabilityToken>, Denial)>> {
let token = match token_file {
Some(path) => read_token_from_file(path).map_err(|d| Box::new((None, d))),
@ -1022,6 +1056,76 @@ fn run(token_file: Option<&str>, helper_file: Option<&str>) -> Result<PolicyResu
});
}
// Desktop self-upgrade (GATE-004): the agent stages the new desktop binary and
// invokes the helper with a desktop-self token instead of installing it in the
// agent process. Same shape as helper-self: verify the hash against the closure
// entry, consume the replay slot (record-before-install), then atomically swap
// the desktop binary. The token, closure, and signed message are identical to
// the old in-process path — only the executor moved.
if token.package_type == DESKTOP_SELF_PACKAGE_TYPE {
if token.operation != "upgrade" {
return Err(Box::new((
Some(token),
Denial::new(
EXIT_UNSUPPORTED_OP,
"operation_not_allowed",
"desktop-self requires operation=upgrade",
),
)));
}
let sha256 = {
let entry = token.closure.iter().find(|e| e.name == "redflag-desktop");
match entry {
Some(e) => e.sha256.clone(),
None => {
return Err(Box::new((
Some(token),
Denial::new(EXIT_BAD_TOKEN, "desktop_self_no_entry", "closure missing redflag-desktop entry"),
)))
}
}
};
let source = env_or("REDFLAG_DESKTOP_SELF_SOURCE", DEFAULT_DESKTOP_SELF_SOURCE);
let staging = env_or("REDFLAG_HELPER_STAGING", DEFAULT_HELPER_STAGING) + ".desktop";
let staged = match stage_and_verify_binary(&source, &staging, &sha256) {
Ok(p) => p,
Err(d) => return Err(Box::new((Some(token), d))),
};
let state_path = PathBuf::from(env_or("REDFLAG_HELPER_STATE", DEFAULT_STATE_FILE));
if let Err(d) = replay_check_and_record(&token.token_id, &state_path) {
let _ = fs::remove_file(&staged);
return Err(Box::new((Some(token), d)));
}
log_security(&format!(
"authorized desktop_self_upgrade token_id={} agent_id={}",
token.token_id, token.agent_id
));
if let Err(d) = install_desktop_binary(&staged) {
let _ = fs::remove_file(&staged);
return Err(Box::new((Some(token), d)));
}
return Ok(PolicyResult {
token_id: token.token_id.clone(),
agent_id: token.agent_id.clone(),
package_type: token.package_type.clone(),
operation: token.operation.clone(),
decision: "executed".to_string(),
reason: "desktop_self_upgraded".to_string(),
executed: true,
verified_artifacts: 1,
exit_code: EXIT_OK,
error: None,
timestamp: now_unix(),
});
}
// Verify artifact hashes the executor can reach.
let verified = match verify_artifacts(&token) {
Ok(v) => v,