use std::collections::BTreeMap;
use std::fmt;
#[cfg(unix)]
use std::fs::File;
use std::io::Read as _;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use serde::Deserialize;
const KEYRING_FILENAME: &str = "task-receipt-issuers.json";
const KEYRING_SCHEMA_VERSION: u16 = 1;
const KEYRING_READ_CAP: u64 = 256 * 1024;
#[derive(Debug)]
pub(crate) enum TrustedReceiptKeyringError {
StateDirectoryUnavailable,
UnsafePermissions,
InvalidFile,
ReadFailed,
Malformed,
InvalidKey,
#[cfg(not(unix))]
UnsupportedPlatform,
}
impl fmt::Display for TrustedReceiptKeyringError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::StateDirectoryUnavailable => {
"trusted receipt issuer state directory is unavailable"
}
Self::UnsafePermissions => {
"trusted receipt issuer keyring has unsafe ownership or permissions"
}
Self::InvalidFile => "trusted receipt issuer keyring is not a bounded regular file",
Self::ReadFailed => "trusted receipt issuer keyring could not be read safely",
Self::Malformed => "trusted receipt issuer keyring is malformed",
Self::InvalidKey => "trusted receipt issuer keyring contains an invalid issuer key",
#[cfg(not(unix))]
Self::UnsupportedPlatform => {
"trusted receipt issuer keyring is unsupported on this platform"
}
})
}
}
impl std::error::Error for TrustedReceiptKeyringError {}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct KeyringWire {
schema_version: u16,
issuers: BTreeMap<String, String>,
}
pub(crate) struct TrustedReceiptIssuerKeyring {
keys: BTreeMap<String, [u8; 32]>,
}
impl TrustedReceiptIssuerKeyring {
pub(crate) fn load_default() -> Result<Self, TrustedReceiptKeyringError> {
let state = tirith_core::policy::state_dir()
.ok_or(TrustedReceiptKeyringError::StateDirectoryUnavailable)?;
Self::load_path(&state.join(KEYRING_FILENAME))
}
pub(crate) fn keys(&self) -> &BTreeMap<String, [u8; 32]> {
&self.keys
}
pub(crate) fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub(crate) fn issuer_key_ids(&self) -> Vec<String> {
self.keys.keys().cloned().collect()
}
fn load_path(path: &Path) -> Result<Self, TrustedReceiptKeyringError> {
load_owner_only_keyring(path)
}
#[cfg(test)]
pub(crate) fn load_for_test(path: PathBuf) -> Result<Self, TrustedReceiptKeyringError> {
Self::load_path(&path)
}
}
#[cfg(unix)]
fn load_owner_only_keyring(
path: &Path,
) -> Result<TrustedReceiptIssuerKeyring, TrustedReceiptKeyringError> {
use std::ffi::CString;
use std::os::fd::{AsRawFd as _, FromRawFd as _};
use std::os::unix::ffi::OsStrExt as _;
use std::os::unix::fs::MetadataExt as _;
use std::path::Component;
let expected_uid = unsafe { libc::geteuid() };
if !path.is_absolute() {
return Err(TrustedReceiptKeyringError::UnsafePermissions);
}
let parent = path
.parent()
.ok_or(TrustedReceiptKeyringError::InvalidFile)?;
let file_name = path
.file_name()
.ok_or(TrustedReceiptKeyringError::InvalidFile)?;
let root_name = CString::new("/").expect("root path contains no NUL");
let root_fd = unsafe {
libc::open(
root_name.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if root_fd < 0 {
return Err(TrustedReceiptKeyringError::ReadFailed);
}
let mut directory = unsafe { File::from_raw_fd(root_fd) };
validate_keyring_directory(&directory, expected_uid, parent == Path::new("/"))?;
let normal_components = parent.components().filter_map(|component| match component {
Component::RootDir => None,
Component::Normal(component) => Some(Ok(component)),
Component::CurDir | Component::ParentDir | Component::Prefix(_) => {
Some(Err(TrustedReceiptKeyringError::UnsafePermissions))
}
});
let components = normal_components.collect::<Result<Vec<_>, _>>()?;
for (index, component) in components.iter().enumerate() {
let component = CString::new(component.as_bytes())
.map_err(|_| TrustedReceiptKeyringError::InvalidFile)?;
let next_fd = unsafe {
libc::openat(
directory.as_raw_fd(),
component.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if next_fd < 0 {
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::NotFound {
return Ok(TrustedReceiptIssuerKeyring {
keys: BTreeMap::new(),
});
}
return Err(TrustedReceiptKeyringError::UnsafePermissions);
}
let next = unsafe { File::from_raw_fd(next_fd) };
validate_keyring_directory(&next, expected_uid, index + 1 == components.len())?;
directory = next;
}
let file_name =
CString::new(file_name.as_bytes()).map_err(|_| TrustedReceiptKeyringError::InvalidFile)?;
let file_fd = unsafe {
libc::openat(
directory.as_raw_fd(),
file_name.as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if file_fd < 0 {
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::NotFound {
return Ok(TrustedReceiptIssuerKeyring {
keys: BTreeMap::new(),
});
}
return Err(TrustedReceiptKeyringError::InvalidFile);
}
let mut file = unsafe { File::from_raw_fd(file_fd) };
let metadata = file
.metadata()
.map_err(|_| TrustedReceiptKeyringError::ReadFailed)?;
if !metadata.is_file() || metadata.len() > KEYRING_READ_CAP || metadata.nlink() != 1 {
return Err(TrustedReceiptKeyringError::InvalidFile);
}
if metadata.uid() != expected_uid || metadata.mode() & 0o077 != 0 {
return Err(TrustedReceiptKeyringError::UnsafePermissions);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.by_ref()
.take(KEYRING_READ_CAP + 1)
.read_to_end(&mut bytes)
.map_err(|_| TrustedReceiptKeyringError::ReadFailed)?;
if bytes.len() as u64 > KEYRING_READ_CAP {
return Err(TrustedReceiptKeyringError::InvalidFile);
}
parse_keyring(&bytes)
}
#[cfg(unix)]
fn validate_keyring_directory(
directory: &File,
expected_uid: u32,
is_keyring_parent: bool,
) -> Result<(), TrustedReceiptKeyringError> {
use std::os::unix::fs::MetadataExt as _;
let metadata = directory
.metadata()
.map_err(|_| TrustedReceiptKeyringError::ReadFailed)?;
if !metadata.is_dir() {
return Err(TrustedReceiptKeyringError::InvalidFile);
}
let owner_is_trusted = metadata.uid() == expected_uid || metadata.uid() == 0;
let root_sticky_directory =
metadata.uid() == 0 && metadata.mode() & 0o1000 != 0 && metadata.mode() & 0o022 != 0;
if !owner_is_trusted || (metadata.mode() & 0o022 != 0 && !root_sticky_directory) {
return Err(TrustedReceiptKeyringError::UnsafePermissions);
}
if is_keyring_parent && (metadata.uid() != expected_uid || metadata.mode() & 0o077 != 0) {
return Err(TrustedReceiptKeyringError::UnsafePermissions);
}
Ok(())
}
#[cfg(not(unix))]
fn load_owner_only_keyring(
_path: &Path,
) -> Result<TrustedReceiptIssuerKeyring, TrustedReceiptKeyringError> {
Err(TrustedReceiptKeyringError::UnsupportedPlatform)
}
fn parse_keyring(bytes: &[u8]) -> Result<TrustedReceiptIssuerKeyring, TrustedReceiptKeyringError> {
let text = std::str::from_utf8(bytes).map_err(|_| TrustedReceiptKeyringError::Malformed)?;
let value = tirith_core::mcp_lock::parse_json_no_duplicates(text)
.map_err(|_| TrustedReceiptKeyringError::Malformed)?;
let wire: KeyringWire =
serde_json::from_value(value).map_err(|_| TrustedReceiptKeyringError::Malformed)?;
if wire.schema_version != KEYRING_SCHEMA_VERSION || wire.issuers.len() > 256 {
return Err(TrustedReceiptKeyringError::Malformed);
}
let mut keys = BTreeMap::new();
for (declared_id, encoded) in wire.issuers {
let decoded = tirith_core::command_card::hex_decode(&encoded)
.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
.ok_or(TrustedReceiptKeyringError::InvalidKey)?;
if ed25519_dalek::VerifyingKey::from_bytes(&decoded).is_err()
|| tirith_core::command_card::key_id_for_pubkey(&decoded) != declared_id
{
return Err(TrustedReceiptKeyringError::InvalidKey);
}
keys.insert(declared_id, decoded);
}
Ok(TrustedReceiptIssuerKeyring { keys })
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[cfg(unix)]
fn write_keyring(path: &Path, value: &serde_json::Value) {
use std::os::unix::fs::PermissionsExt as _;
let mut file = File::create(path).unwrap();
file.write_all(serde_json::to_string(value).unwrap().as_bytes())
.unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(unix)]
fn make_owner_private(path: &Path) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
#[cfg(unix)]
fn loads_only_owner_private_self_identifying_keys() {
let dir = tempfile::tempdir().unwrap();
let path = std::fs::canonicalize(dir.path())
.unwrap()
.join(KEYRING_FILENAME);
make_owner_private(path.parent().unwrap());
let public = ed25519_dalek::SigningKey::from_bytes(&[17_u8; 32])
.verifying_key()
.to_bytes();
let key_id = tirith_core::command_card::key_id_for_pubkey(&public);
write_keyring(
&path,
&serde_json::json!({
"schema_version": 1,
"issuers": { (key_id.clone()): tirith_core::command_card::hex_encode(&public) }
}),
);
let loaded = TrustedReceiptIssuerKeyring::load_for_test(path).unwrap();
assert_eq!(loaded.keys().get(&key_id), Some(&public));
}
#[test]
#[cfg(unix)]
fn rejects_symlink_and_group_readable_keyrings() {
use std::os::unix::fs::{symlink, PermissionsExt as _};
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
make_owner_private(&root);
let target = root.join("target.json");
write_keyring(
&target,
&serde_json::json!({"schema_version": 1, "issuers": {}}),
);
let link = root.join(KEYRING_FILENAME);
symlink(&target, &link).unwrap();
assert!(matches!(
TrustedReceiptIssuerKeyring::load_for_test(link),
Err(TrustedReceiptKeyringError::InvalidFile)
));
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap();
assert!(matches!(
TrustedReceiptIssuerKeyring::load_for_test(target),
Err(TrustedReceiptKeyringError::UnsafePermissions)
));
}
#[test]
#[cfg(unix)]
fn rejects_symlinked_or_non_private_keyring_parent() {
use std::os::unix::fs::{symlink, PermissionsExt as _};
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let real_parent = root.join("repo-controlled-state");
std::fs::create_dir(&real_parent).unwrap();
std::fs::set_permissions(&real_parent, std::fs::Permissions::from_mode(0o700)).unwrap();
write_keyring(
&real_parent.join(KEYRING_FILENAME),
&serde_json::json!({"schema_version": 1, "issuers": {}}),
);
let linked_parent = root.join("tirith");
symlink(&real_parent, &linked_parent).unwrap();
assert!(matches!(
TrustedReceiptIssuerKeyring::load_for_test(linked_parent.join(KEYRING_FILENAME)),
Err(TrustedReceiptKeyringError::UnsafePermissions)
));
let loose_parent = root.join("loose-state");
std::fs::create_dir(&loose_parent).unwrap();
std::fs::set_permissions(&loose_parent, std::fs::Permissions::from_mode(0o755)).unwrap();
write_keyring(
&loose_parent.join(KEYRING_FILENAME),
&serde_json::json!({"schema_version": 1, "issuers": {}}),
);
assert!(matches!(
TrustedReceiptIssuerKeyring::load_for_test(loose_parent.join(KEYRING_FILENAME)),
Err(TrustedReceiptKeyringError::UnsafePermissions)
));
}
#[test]
#[cfg(unix)]
fn absent_keyring_is_an_explicit_empty_trust_set() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
make_owner_private(&root);
let loaded =
TrustedReceiptIssuerKeyring::load_for_test(root.join(KEYRING_FILENAME)).unwrap();
assert!(loaded.is_empty());
assert!(loaded.issuer_key_ids().is_empty());
}
}