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:
parent
6c3461cdf9
commit
e4afe1f605
1 changed files with 158 additions and 7 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue