Watch
1
0
Fork
You've already forked RedFlag
0

feat: helper trust-file self-validation (SEC-021)

The privileged executor no longer trusts its keyring, agent_id, or
replay-guard state by content alone. Before any read, each trust input
and its immediate parent must be root-owned, not group/other-writable,
and not a symlink — otherwise deny with EXIT_TRUST_PATH (26), fail
closed. This makes the gate self-defending: a packaging or installer
mistake that leaves a trust path writable can no longer be parlayed
into key injection, token rebinding, or replay-record clearing.

An agent_id file that exists but fails validation is a denial, never a
fall-through to the next location. The replay dir is validated after
ensure-exists so a pre-planted attacker-owned dir is refused, not
adopted.
This commit is contained in:
Fimeg 2026-06-11 07:55:34 -04:00
commit e4afe1f605

View file

@ -13,7 +13,7 @@
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
@ -43,6 +43,7 @@ const EXIT_MINT_GATE: i32 = 22; // gate verdict refuses (vuln/unverified without
const EXIT_MINT_STALE: i32 = 23; // gate evidence outside the freshness window
const EXIT_MINT_KEY: i32 = 24; // mint key missing/unsafe permissions
const EXIT_MINT_DUPLICATE: i32 = 25; // request_id already minted
const EXIT_TRUST_PATH: i32 = 26; // trusted file/dir fails owner/perm/symlink validation (SEC-021)
// Default on-host locations. All overridable by env so packaging/tests can relocate.
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
@ -178,7 +179,15 @@ fn local_agent_id() -> Result<String, Denial> {
}
}
for path in AGENT_ID_FILES {
if let Ok(contents) = fs::read_to_string(path) {
let p = Path::new(path);
if fs::symlink_metadata(p).is_err() {
continue; // not provisioned at this location
}
// SEC-021: an agent_id file that exists but fails trust validation is a
// denial, never a fall-through — a writable agent_id lets a compromised
// agent rebind tokens minted for another host.
validate_trusted_path(p)?;
if let Ok(contents) = fs::read_to_string(p) {
let v = contents.trim().to_string();
if !v.is_empty() {
return Ok(v);
@ -197,8 +206,55 @@ fn key_id_for(pubkey: &[u8]) -> String {
hex::encode(&digest[..16])
}
// SEC-021: the helper defends its own trust inputs instead of relying on the
// installer having set permissions correctly. A trusted file or directory must
// be owned by the required uid, must not be writable by group or other, and
// must not be a symlink. Fail-closed: any violation is a denial, not a skip.
fn validate_trusted_path_as(path: &Path, required_uid: u32) -> Result<(), Denial> {
let meta = fs::symlink_metadata(path).map_err(|e| {
Denial::new(EXIT_TRUST_PATH, "trusted_path_unreadable", format!("{}: {}", path.display(), e))
})?;
if meta.file_type().is_symlink() {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_symlink",
format!("{} — symlinked trust inputs are refused", path.display()),
));
}
if meta.uid() != required_uid {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_wrong_owner",
format!("{} uid={} required={}", path.display(), meta.uid(), required_uid),
));
}
if meta.mode() & 0o022 != 0 {
return Err(Denial::new(
EXIT_TRUST_PATH,
"trusted_path_writable",
format!("{} mode={:o} — group/other write on a trust input", path.display(), meta.mode() & 0o7777),
));
}
Ok(())
}
// Production entry: trust inputs must be root-owned. Checks the path itself
// and its immediate parent so neither a swapped file nor a swapped containing
// directory passes.
fn validate_trusted_path(path: &Path) -> Result<(), Denial> {
validate_trusted_path_as(path, 0)?;
if let Some(parent) = path.parent() {
validate_trusted_path_as(parent, 0)?;
}
Ok(())
}
// Load *.pub hex files from the keyring dir, indexed by computed key_id.
fn load_keyring(dir: &Path) -> Result<Vec<(String, VerifyingKey)>, Denial> {
// SEC-021: refuse a keyring whose directory or files an unprivileged user
// could have swapped or appended to — a writable keyring lets a compromised
// agent inject its own key and mint self-signed tokens.
validate_trusted_path(dir)?;
let entries = fs::read_dir(dir).map_err(|e| {
Denial::new(EXIT_KEY_NOT_FOUND, "keyring_unreadable", format!("{}: {}", dir.display(), e))
})?;
@ -208,6 +264,7 @@ fn load_keyring(dir: &Path) -> Result<Vec<(String, VerifyingKey)>, Denial> {
if path.extension().and_then(|e| e.to_str()) != Some("pub") {
continue;
}
validate_trusted_path_as(&path, 0)?;
let raw = match fs::read_to_string(&path) {
Ok(r) => r,
Err(e) => {
@ -359,15 +416,22 @@ fn verify_artifacts(token: &CapabilityToken) -> Result<usize, Denial> {
// Replay guard. token_id is recorded BEFORE execution so a token can never run
// twice even across a crash. A record-write failure is fail-closed (deny).
fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Denial> {
if let Ok(contents) = fs::read_to_string(state_path) {
if contents.lines().any(|l| l.trim() == token_id) {
return Err(Denial::new(EXIT_REPLAY, "token_already_consumed", format!("token_id={}", token_id)));
}
}
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(EXIT_INTERNAL, "state_dir_create_failed", format!("{}: {}", parent.display(), e))
})?;
// SEC-021: a writable replay-guard dir lets a compromised agent clear
// consumed-token records and replay. Validate after ensure-exists so a
// pre-planted attacker-owned dir is refused, not adopted.
validate_trusted_path_as(parent, 0)?;
}
if fs::symlink_metadata(state_path).is_ok() {
validate_trusted_path_as(state_path, 0)?;
}
if let Ok(contents) = fs::read_to_string(state_path) {
if contents.lines().any(|l| l.trim() == token_id) {
return Err(Denial::new(EXIT_REPLAY, "token_already_consumed", format!("token_id={}", token_id)));
}
}
let mut existing = fs::read_to_string(state_path).unwrap_or_default();
existing.push_str(token_id);
@ -1508,6 +1572,93 @@ mod tests {
}
}
// ---- SEC-021 trust-path validation ----
// Tests run unprivileged, so they exercise validate_trusted_path_as with
// the test user's own uid (obtained from a file the test just created);
// production calls pin required_uid to 0.
fn trust_fixture(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("redflag-trust-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
dir
}
fn own_uid(dir: &Path) -> u32 {
fs::symlink_metadata(dir).unwrap().uid()
}
#[test]
fn trusted_path_accepts_owned_unwritable() {
let dir = trust_fixture("ok");
let file = dir.join("agent_id");
fs::write(&file, "abc").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let uid = own_uid(&dir);
assert!(validate_trusted_path_as(&file, uid).is_ok());
assert!(validate_trusted_path_as(&dir, uid).is_ok());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn trusted_path_rejects_group_or_other_writable() {
let dir = trust_fixture("writable");
let file = dir.join("key.pub");
fs::write(&file, "aa").unwrap();
let uid = own_uid(&dir);
for mode in [0o664u32, 0o646, 0o666] {
fs::set_permissions(&file, fs::Permissions::from_mode(mode)).unwrap();
let denial = validate_trusted_path_as(&file, uid).unwrap_err();
assert_eq!(denial.code, EXIT_TRUST_PATH, "mode {:o} must be refused", mode);
assert_eq!(denial.reason, "trusted_path_writable");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn trusted_path_rejects_wrong_owner() {
let dir = trust_fixture("owner");
let file = dir.join("agent_id");
fs::write(&file, "abc").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let uid = own_uid(&dir);
// Demand a uid this unprivileged test cannot own (root unless running as root).
let other = if uid == 0 { 12345 } else { 0 };
let denial = validate_trusted_path_as(&file, other).unwrap_err();
assert_eq!(denial.code, EXIT_TRUST_PATH);
assert_eq!(denial.reason, "trusted_path_wrong_owner");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn trusted_path_rejects_symlink() {
let dir = trust_fixture("symlink");
let target = dir.join("real");
let link = dir.join("link.pub");
fs::write(&target, "aa").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let uid = own_uid(&dir);
let denial = validate_trusted_path_as(&link, uid).unwrap_err();
assert_eq!(denial.code, EXIT_TRUST_PATH);
assert_eq!(denial.reason, "trusted_path_symlink");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn trusted_path_rejects_missing() {
let dir = trust_fixture("missing");
let uid = own_uid(&dir);
let denial = validate_trusted_path_as(&dir.join("nope"), uid).unwrap_err();
assert_eq!(denial.code, EXIT_TRUST_PATH);
assert_eq!(denial.reason, "trusted_path_unreadable");
let _ = fs::remove_dir_all(&dir);
}
// Cross-language contract vector. These exact strings are produced by the Go
// capability package for the same input; if either side changes, this breaks.
#[test]