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

@ -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,