use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use ed25519_dalek::VerifyingKey;
use super::credential::{CredentialError, FederationCredential, SignedCredential};
pub const TRUST_BUNDLE_DIR_ENV: &str = "AI_MEMORY_FED_TRUST_BUNDLE_DIR";
pub const TRUST_DOMAIN_ENV: &str = "AI_MEMORY_FED_TRUST_DOMAIN";
const ISSUER_PUB_SUFFIX: &str = ".pub";
const VERIFYING_KEY_LEN: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
#[derive(Debug, Clone, Default)]
pub struct TrustBundle {
issuers: BTreeMap<String, VerifyingKey>,
trust_domain: Option<String>,
}
impl TrustBundle {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_trust_domain(mut self, trust_domain: impl Into<String>) -> Self {
let domain = trust_domain.into();
self.trust_domain = if domain.is_empty() {
None
} else {
Some(domain)
};
self
}
#[must_use]
pub fn with_issuer(mut self, issuer_id: impl Into<String>, key: VerifyingKey) -> Self {
self.issuers.insert(issuer_id.into(), key);
self
}
pub fn insert(&mut self, issuer_id: impl Into<String>, key: VerifyingKey) {
self.issuers.insert(issuer_id.into(), key);
}
#[must_use]
pub fn len(&self) -> usize {
self.issuers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.issuers.is_empty()
}
#[must_use]
pub fn trust_domain(&self) -> Option<&str> {
self.trust_domain.as_deref()
}
pub fn verify(
&self,
signed: &SignedCredential,
now_unix: i64,
) -> Result<FederationCredential, CredentialError> {
let cred = signed.credential();
if let Some(domain) = &self.trust_domain
&& &cred.trust_domain != domain
{
return Err(CredentialError::WrongTrustDomain);
}
let issuer_key = self
.issuers
.get(&cred.issuer_id)
.ok_or(CredentialError::UnknownIssuer)?;
signed.verify_against(issuer_key, now_unix)?;
Ok(cred.clone())
}
pub fn from_dir(dir: &Path) -> std::io::Result<Self> {
let mut bundle = Self::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(bundle),
Err(err) => return Err(err),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let Some(issuer_id) = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(ISSUER_PUB_SUFFIX))
else {
continue;
};
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(err) => {
tracing::warn!(issuer = issuer_id, error = %err, "skipping unreadable issuer key");
continue;
}
};
match verifying_key_from_bytes(&bytes) {
Some(key) => {
bundle.insert(issuer_id.to_string(), key);
}
None => {
tracing::warn!(
issuer = issuer_id,
len = bytes.len(),
"skipping malformed issuer key (not a 32-byte Ed25519 point)"
);
}
}
}
Ok(bundle)
}
pub fn load_from_env() -> std::io::Result<Self> {
let mut bundle = match std::env::var(TRUST_BUNDLE_DIR_ENV) {
Ok(dir) if !dir.is_empty() => Self::from_dir(Path::new(&dir))?,
_ => Self::new(),
};
if let Ok(domain) = std::env::var(TRUST_DOMAIN_ENV)
&& !domain.is_empty()
{
bundle.trust_domain = Some(domain);
}
Ok(bundle)
}
}
fn verifying_key_from_bytes(bytes: &[u8]) -> Option<VerifyingKey> {
let arr: [u8; VERIFYING_KEY_LEN] = bytes.try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn signing_key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn sample_signed(
ca: &SigningKey,
issuer_id: &str,
trust_domain: &str,
now: i64,
) -> SignedCredential {
let subject = signing_key(200);
FederationCredential {
subject_agent_id: "region/nyc/node-1".to_string(),
subject_pubkey: subject.verifying_key().to_bytes(),
issuer_id: issuer_id.to_string(),
trust_domain: trust_domain.to_string(),
not_before: now - 10,
not_after: now + 3600,
cred_version: super::super::credential::CRED_VERSION,
}
.sign(ca)
.expect("sign")
}
#[test]
fn trusted_issuer_verifies() {
let ca = signing_key(1);
let now = 1_900_000_000;
let bundle = TrustBundle::new().with_issuer("root", ca.verifying_key());
let signed = sample_signed(&ca, "root", "fleet.example", now);
let cred = bundle.verify(&signed, now).expect("verifies");
assert_eq!(cred.issuer_id, "root");
}
#[test]
fn unknown_issuer_is_rejected() {
let ca = signing_key(2);
let now = 1_900_000_000;
let bundle = TrustBundle::new().with_issuer("root", ca.verifying_key());
let signed = sample_signed(&ca, "other-ca", "fleet.example", now);
assert_eq!(
bundle.verify(&signed, now).unwrap_err(),
CredentialError::UnknownIssuer
);
}
#[test]
fn wrong_issuer_key_for_known_id_is_bad_signature() {
let real = signing_key(3);
let attacker = signing_key(4);
let now = 1_900_000_000;
let bundle = TrustBundle::new().with_issuer("root", attacker.verifying_key());
let signed = sample_signed(&real, "root", "fleet.example", now);
assert_eq!(
bundle.verify(&signed, now).unwrap_err(),
CredentialError::BadSignature
);
}
#[test]
fn domain_scoped_bundle_rejects_other_domain() {
let ca = signing_key(5);
let now = 1_900_000_000;
let bundle = TrustBundle::new()
.with_issuer("root", ca.verifying_key())
.with_trust_domain("fleet.example");
let signed = sample_signed(&ca, "root", "other.tenant", now);
assert_eq!(
bundle.verify(&signed, now).unwrap_err(),
CredentialError::WrongTrustDomain
);
}
#[test]
fn domain_scoped_bundle_accepts_matching_domain() {
let ca = signing_key(6);
let now = 1_900_000_000;
let bundle = TrustBundle::new()
.with_issuer("root", ca.verifying_key())
.with_trust_domain("fleet.example");
let signed = sample_signed(&ca, "root", "fleet.example", now);
bundle
.verify(&signed, now)
.expect("matching domain verifies");
}
#[test]
fn expired_credential_propagates_window_error() {
let ca = signing_key(7);
let now = 1_900_000_000;
let bundle = TrustBundle::new().with_issuer("root", ca.verifying_key());
let signed = sample_signed(&ca, "root", "fleet.example", now);
assert_eq!(
bundle.verify(&signed, now + 100_000).unwrap_err(),
CredentialError::Expired
);
}
#[test]
fn empty_bundle_is_empty() {
let bundle = TrustBundle::new();
assert!(bundle.is_empty());
assert_eq!(bundle.len(), 0);
assert!(bundle.trust_domain().is_none());
}
#[test]
fn from_dir_missing_is_empty_not_error() {
let dir = std::path::Path::new("/nonexistent/ai-memory-trust-bundle-xyz");
let bundle = TrustBundle::from_dir(dir).expect("missing dir is not an error");
assert!(bundle.is_empty());
}
#[test]
fn from_dir_loads_pub_files_and_skips_malformed() {
let tmp = tempfile::tempdir().expect("tempdir");
let ca = signing_key(9);
fs::write(tmp.path().join("root.pub"), ca.verifying_key().to_bytes()).expect("write valid");
fs::write(tmp.path().join("broken.pub"), [0u8; 8]).expect("write broken");
fs::write(tmp.path().join("notes.txt"), b"ignore me").expect("write txt");
let bundle = TrustBundle::from_dir(tmp.path()).expect("read dir");
assert_eq!(bundle.len(), 1);
let now = 1_900_000_000;
let signed = sample_signed(&ca, "root", "fleet.example", now);
bundle.verify(&signed, now).expect("loaded key verifies");
}
}