source: advance the checked projection
Source-Sha: 194a59adfd6c901635da7a9c18253bb2210e2426 Policy-Sha: b9b0be0ee74e55f561a803b8aef6be3c2134f5a83b46816a069a372f2eb04f4d Tree-Digest: 4697f2e281c1c6ecafff17cf6eeb391a0a1ac0fa6e5fc429f5b473f4579c252c
This commit is contained in:
parent
0401c76c79
commit
ffdccba10f
42 changed files with 2516 additions and 554 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue