use crate::agent::{
AGENT_SIGNATURE_FIELDNAME, SignatureContentMode, build_signature_content,
extract_signature_fields,
};
use crate::crypt::hash::hash_public_key;
use crate::crypt::{
CryptoSigningAlgorithm, detect_algorithm_from_public_key, normalize_public_key_pem,
};
use crate::error::JacsError;
use crate::paths::trust_store_dir;
use crate::schema::utils::ValueExt;
use crate::time_utils;
use crate::validation::{require_relative_path_safe, validate_agent_id};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use tracing::{info, warn};
fn default_trust_verified() -> bool {
true
}
fn validate_agent_id_for_path(agent_id: &str) -> Result<(), JacsError> {
validate_agent_id(agent_id)?;
if agent_id.contains("..")
|| agent_id.contains('/')
|| agent_id.contains('\\')
|| agent_id.contains('\0')
{
return Err(JacsError::ValidationError(format!(
"Agent ID '{}' contains unsafe path characters",
agent_id
)));
}
Ok(())
}
fn validate_path_within_trust_dir(path: &Path, trust_dir: &Path) -> Result<(), JacsError> {
if path.exists() {
let canonical_path = path.canonicalize().map_err(|e| JacsError::Internal {
message: format!("Failed to canonicalize path: {}", e),
})?;
let canonical_trust = trust_dir.canonicalize().map_err(|e| JacsError::Internal {
message: format!("Failed to canonicalize trust dir: {}", e),
})?;
if !canonical_path.starts_with(&canonical_trust) {
return Err(JacsError::ValidationError(
"Path traversal detected: resolved path is outside trust store".to_string(),
));
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustedAgent {
pub agent_id: String,
pub name: Option<String>,
pub public_key_pem: String,
pub public_key_hash: String,
pub trusted_at: String,
#[serde(default = "default_trust_verified")]
pub verified: bool,
}
#[must_use = "trust operation result must be checked for errors"]
pub fn trust_agent(agent_json: &str) -> Result<String, JacsError> {
trust_agent_with_key(agent_json, None)
}
#[must_use = "trust operation result must be checked for errors"]
pub fn trust_agent_with_key(
agent_json: &str,
public_key_pem: Option<&str>,
) -> Result<String, JacsError> {
let agent_value: Value =
serde_json::from_str(agent_json).map_err(|e| JacsError::DocumentMalformed {
field: "agent_json".to_string(),
reason: e.to_string(),
})?;
let raw_agent_id = agent_value.get_str_required("jacsId")?;
let agent_id = if raw_agent_id.contains(':') {
raw_agent_id
} else {
let version = agent_value.get_str_required("jacsVersion")?;
format!("{}:{}", raw_agent_id, version)
};
validate_agent_id_for_path(&agent_id)?;
let name = agent_value.get_str("name");
let public_key_hash = agent_value.get_path_str_required(&["jacsSignature", "publicKeyHash"])?;
let signing_algorithm = agent_value.get_path_str(&["jacsSignature", "signingAlgorithm"]);
if signing_algorithm.is_none() {
warn!(
agent_id = %agent_id,
"SECURITY WARNING: Agent signature missing signingAlgorithm field. \
Falling back to algorithm detection. This is insecure and deprecated. \
Re-sign this agent document to include the signingAlgorithm field."
);
}
let public_key_bytes: Vec<u8> = match public_key_pem {
Some(pem) => {
let text_bytes = pem.as_bytes().to_vec();
if hash_public_key(&text_bytes) == public_key_hash {
text_bytes
} else if pem.trim().starts_with("-----BEGIN") {
let body: String = pem
.trim()
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&body)
.unwrap_or(text_bytes)
} else {
text_bytes
}
}
None => {
load_public_key_from_cache(&public_key_hash)?
}
};
let computed_hash = hash_public_key(&public_key_bytes);
if computed_hash != public_key_hash {
return Err(JacsError::SignatureVerificationFailed {
reason: format!(
"Public key hash mismatch for agent '{}': the provided public key (hash: '{}') \
does not match the key hash in the agent's signature (expected: '{}'). \
This could mean: (1) the wrong public key was provided, \
(2) the agent document was modified after signing, or \
(3) the agent's keys have been rotated. \
Verify you have the correct public key for this agent.",
agent_id, computed_hash, public_key_hash
),
});
}
verify_agent_self_signature(
&agent_value,
&public_key_bytes,
signing_algorithm.as_deref(),
)?;
let trust_dir = trust_store_dir();
fs::create_dir_all(&trust_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: trust_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let agent_file = trust_dir.join(format!("{}.json", agent_id));
fs::write(&agent_file, agent_json).map_err(|e| JacsError::FileWriteFailed {
path: agent_file.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
save_public_key_to_cache(
&public_key_hash,
&public_key_bytes,
signing_algorithm.as_deref(),
)?;
let trusted_agent = TrustedAgent {
agent_id: agent_id.clone(),
name,
public_key_pem: normalize_public_key_pem(&public_key_bytes),
public_key_hash,
trusted_at: time_utils::now_rfc3339(),
verified: true,
};
let metadata_file = trust_dir.join(format!("{}.meta.json", agent_id));
let metadata_json =
serde_json::to_string_pretty(&trusted_agent).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize metadata: {}", e),
})?;
fs::write(&metadata_file, metadata_json).map_err(|e| JacsError::Internal {
message: format!("Failed to write metadata file: {}", e),
})?;
info!("Trusted agent {} added to trust store", agent_id);
Ok(agent_id)
}
#[must_use = "list of trusted agents must be used"]
pub fn list_trusted_agents() -> Result<Vec<String>, JacsError> {
let trust_dir = trust_store_dir();
if !trust_dir.exists() {
return Ok(Vec::new());
}
let mut agents = Vec::new();
let entries = fs::read_dir(&trust_dir).map_err(|e| JacsError::Internal {
message: format!("Failed to read trust store directory: {}", e),
})?;
for entry in entries {
let entry = entry.map_err(|e| JacsError::Internal {
message: format!("Failed to read directory entry: {}", e),
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".meta.json"))
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
if is_trusted(stem) {
agents.push(stem.to_string());
}
}
}
Ok(agents)
}
#[must_use = "untrust operation result must be checked for errors"]
pub fn untrust_agent(agent_id: &str) -> Result<(), JacsError> {
validate_agent_id_for_path(agent_id)?;
let trust_dir = trust_store_dir();
let agent_file = trust_dir.join(format!("{}.json", agent_id));
let metadata_file = trust_dir.join(format!("{}.meta.json", agent_id));
validate_path_within_trust_dir(&agent_file, &trust_dir)?;
if !agent_file.exists() {
return Err(JacsError::AgentNotTrusted {
agent_id: agent_id.to_string(),
});
}
if agent_file.exists() {
fs::remove_file(&agent_file).map_err(|e| JacsError::Internal {
message: format!("Failed to remove agent file: {}", e),
})?;
}
if metadata_file.exists() {
fs::remove_file(&metadata_file).map_err(|e| JacsError::Internal {
message: format!("Failed to remove metadata file: {}", e),
})?;
}
info!("Agent {} removed from trust store", agent_id);
Ok(())
}
#[must_use = "trusted agent data must be used"]
pub fn get_trusted_agent(agent_id: &str) -> Result<String, JacsError> {
validate_agent_id_for_path(agent_id)?;
let trust_dir = trust_store_dir();
let agent_file = trust_dir.join(format!("{}.json", agent_id));
validate_path_within_trust_dir(&agent_file, &trust_dir)?;
if !agent_file.exists() {
return Err(JacsError::TrustError(format!(
"Agent '{}' is not in the trust store. Use trust_agent() or trust_agent_with_key() \
to add the agent first. Use list_trusted_agents() to see currently trusted agents. \
Expected file at: {}",
agent_id,
agent_file.to_string_lossy()
)));
}
match read_trusted_agent_metadata(agent_id)? {
Some(metadata) if metadata.verified => {}
Some(_) => {
return Err(JacsError::TrustError(format!(
"Agent '{}' is stored only as an unverified A2A bookmark, not a trusted agent. \
Use a cryptographically verified trust flow before treating it as trusted.",
agent_id
)));
}
None => {
return Err(JacsError::TrustError(format!(
"Agent '{}' is missing trust metadata and cannot be treated as trusted.",
agent_id
)));
}
}
fs::read_to_string(&agent_file).map_err(|e| JacsError::FileReadFailed {
path: agent_file.to_string_lossy().to_string(),
reason: e.to_string(),
})
}
#[must_use = "public key hash must be used"]
pub fn get_trusted_public_key_hash(agent_id: &str) -> Result<String, JacsError> {
validate_agent_id_for_path(agent_id)?;
let agent_json = get_trusted_agent(agent_id)?;
let agent_value: Value =
serde_json::from_str(&agent_json).map_err(|e| JacsError::DocumentMalformed {
field: "agent_json".to_string(),
reason: e.to_string(),
})?;
agent_value.get_path_str_required(&["jacsSignature", "publicKeyHash"])
}
pub fn trust_a2a_card(agent_id: &str, card_json: &str) -> Result<String, JacsError> {
validate_agent_id_for_path(agent_id)?;
warn!(
agent_id = %agent_id,
"SECURITY: Trusting A2A card WITHOUT cryptographic verification. \
This card has not been signature-checked. Do not treat this entry \
as identity-verified. Use trust_agent_with_key() for verified trust."
);
let trust_dir = trust_store_dir();
fs::create_dir_all(&trust_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: trust_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let agent_file = trust_dir.join(format!("{}.json", agent_id));
fs::write(&agent_file, card_json).map_err(|e| JacsError::FileWriteFailed {
path: agent_file.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let trusted_agent = TrustedAgent {
agent_id: agent_id.to_string(),
name: None,
public_key_pem: String::new(),
public_key_hash: String::new(),
trusted_at: time_utils::now_rfc3339(),
verified: false,
};
let metadata_file = trust_dir.join(format!("{}.meta.json", agent_id));
let metadata_json =
serde_json::to_string_pretty(&trusted_agent).map_err(|e| JacsError::Internal {
message: format!("Failed to serialize metadata: {}", e),
})?;
fs::write(&metadata_file, metadata_json).map_err(|e| JacsError::Internal {
message: format!("Failed to write metadata file: {}", e),
})?;
info!("Trusted A2A agent {} added to trust store", agent_id);
Ok(agent_id.to_string())
}
pub fn is_trusted(agent_id: &str) -> bool {
if validate_agent_id_for_path(agent_id).is_err() {
return false;
}
let trust_dir = trust_store_dir();
let agent_file = trust_dir.join(format!("{}.json", agent_id));
if !agent_file.exists() {
return false;
}
read_trusted_agent_metadata(agent_id)
.ok()
.flatten()
.map(|metadata| metadata.verified)
.unwrap_or(false)
}
pub fn is_verified_trusted(agent_id: &str) -> bool {
is_trusted(agent_id)
}
fn read_trusted_agent_metadata(agent_id: &str) -> Result<Option<TrustedAgent>, JacsError> {
let trust_dir = trust_store_dir();
let metadata_file = trust_dir.join(format!("{}.meta.json", agent_id));
validate_path_within_trust_dir(&metadata_file, &trust_dir)?;
if !metadata_file.exists() {
return Ok(None);
}
let metadata_json =
fs::read_to_string(&metadata_file).map_err(|e| JacsError::FileReadFailed {
path: metadata_file.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let metadata =
serde_json::from_str::<TrustedAgent>(&metadata_json).map_err(|e| JacsError::Internal {
message: format!(
"Failed to parse trust metadata '{}': {}",
metadata_file.display(),
e
),
})?;
Ok(Some(metadata))
}
fn load_public_key_from_cache(public_key_hash: &str) -> Result<Vec<u8>, JacsError> {
require_relative_path_safe(public_key_hash)?;
let trust_dir = trust_store_dir();
let keys_dir = trust_dir.join("keys");
let key_file = keys_dir.join(format!("{}.pem", public_key_hash));
if !key_file.exists() {
return Err(JacsError::TrustError(format!(
"Public key not found in trust store cache for hash '{}'. \
To trust this agent, call trust_agent_with_key() and provide the agent's public key PEM. \
Expected key at: {}",
public_key_hash,
key_file.to_string_lossy()
)));
}
fs::read(&key_file).map_err(|e| JacsError::FileReadFailed {
path: key_file.to_string_lossy().to_string(),
reason: e.to_string(),
})
}
fn save_public_key_to_cache(
public_key_hash: &str,
public_key_bytes: &[u8],
algorithm: Option<&str>,
) -> Result<(), JacsError> {
require_relative_path_safe(public_key_hash)?;
let trust_dir = trust_store_dir();
let keys_dir = trust_dir.join("keys");
fs::create_dir_all(&keys_dir).map_err(|e| JacsError::DirectoryCreateFailed {
path: keys_dir.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
let key_file = keys_dir.join(format!("{}.pem", public_key_hash));
fs::write(&key_file, public_key_bytes).map_err(|e| JacsError::FileWriteFailed {
path: key_file.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
if let Some(algo) = algorithm {
let algo_file = keys_dir.join(format!("{}.algo", public_key_hash));
fs::write(&algo_file, algo).map_err(|e| JacsError::FileWriteFailed {
path: algo_file.to_string_lossy().to_string(),
reason: e.to_string(),
})?;
}
Ok(())
}
fn validate_signature_timestamp(timestamp_str: &str) -> Result<(), JacsError> {
time_utils::validate_signature_timestamp(timestamp_str)
}
fn verify_agent_self_signature(
agent_value: &Value,
public_key_bytes: &[u8],
algorithm: Option<&str>,
) -> Result<(), JacsError> {
let signature_date = agent_value.get_path_str_required(&["jacsSignature", "date"])?;
validate_signature_timestamp(&signature_date)?;
let iat = agent_value
.get_path(&["jacsSignature", "iat"])
.and_then(Value::as_i64)
.ok_or_else(|| JacsError::SignatureVerificationFailed {
reason: "Missing or invalid jacsSignature.iat in agent signature.".to_string(),
})?;
let jti = agent_value
.get_path_str(&["jacsSignature", "jti"])
.ok_or_else(|| JacsError::SignatureVerificationFailed {
reason: "Missing jacsSignature.jti in agent signature.".to_string(),
})?;
if jti.trim().is_empty() {
return Err(JacsError::SignatureVerificationFailed {
reason: "Invalid jacsSignature.jti: nonce cannot be empty.".to_string(),
});
}
time_utils::validate_signature_iat(iat)?;
let signature_b64 = agent_value.get_path_str_required(&["jacsSignature", "signature"])?;
let _fields = agent_value.get_path_array_required(&["jacsSignature", "fields"])?;
let signature_fields = extract_signature_fields(agent_value, AGENT_SIGNATURE_FIELDNAME);
let algo = match algorithm {
Some(a) => CryptoSigningAlgorithm::from_str(a).map_err(|_| {
JacsError::SignatureVerificationFailed {
reason: format!(
"Unknown signing algorithm '{}'. Supported algorithms are: \
'ring-Ed25519', 'RSA-PSS', 'pq2025'. \
The agent document may have been signed with an unsupported algorithm version.",
a
),
}
})?,
None => {
let strict =
crate::storage::jenv::get_env_var("JACS_REQUIRE_EXPLICIT_ALGORITHM", false)
.ok()
.flatten()
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if strict {
return Err(JacsError::SignatureVerificationFailed {
reason: "Signature missing signingAlgorithm field. \
Strict algorithm enforcement is enabled (JACS_REQUIRE_EXPLICIT_ALGORITHM=true). \
Re-sign the agent document to include the signingAlgorithm field.".to_string(),
});
}
detect_algorithm_from_public_key(public_key_bytes).map_err(|e| {
JacsError::SignatureVerificationFailed {
reason: format!(
"Could not detect signing algorithm from public key: {}. \
The agent document is missing the 'signingAlgorithm' field and \
automatic detection failed. Re-sign the agent document to include \
the signingAlgorithm field, or verify the public key format is correct.",
e
),
}
})?
}
};
let verify_with_payload = |payload: &str| -> Result<(), JacsError> {
match algo {
CryptoSigningAlgorithm::RsaPss => crate::crypt::rsawrapper::verify_string(
public_key_bytes.to_vec(),
payload,
&signature_b64,
),
CryptoSigningAlgorithm::RingEd25519 => crate::crypt::ringwrapper::verify_string(
public_key_bytes.to_vec(),
payload,
&signature_b64,
),
CryptoSigningAlgorithm::Pq2025 => crate::crypt::pq2025::verify_string(
public_key_bytes.to_vec(),
payload,
&signature_b64,
),
}
};
let (canonical_payload, _) = build_signature_content(
agent_value,
signature_fields.clone(),
AGENT_SIGNATURE_FIELDNAME,
SignatureContentMode::CanonicalV2,
)?;
verify_with_payload(&canonical_payload).map_err(|e| {
JacsError::SignatureVerificationFailed {
reason: format!(
"Cryptographic signature verification failed using {} algorithm: {}. \
This typically means: (1) the agent document was modified after signing, \
(2) the wrong public key is being used, or (3) the signature is corrupted. \
Verify the agent document integrity and ensure the correct public key is provided.",
algo, e
),
}
})?;
info!("Agent self-signature verified successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time_utils::{now_rfc3339, now_utc};
use serial_test::serial;
use std::env;
use tempfile::TempDir;
struct TrustTestGuard {
_temp_dir: TempDir,
original_home: Option<String>,
}
impl TrustTestGuard {
fn new() -> Self {
let original_home = env::var("HOME").ok();
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
unsafe {
env::set_var("HOME", temp_dir.path().to_str().unwrap());
}
Self {
_temp_dir: temp_dir,
original_home,
}
}
}
impl Drop for TrustTestGuard {
fn drop(&mut self) {
unsafe {
match &self.original_home {
Some(home) => env::set_var("HOME", home),
None => env::remove_var("HOME"),
}
}
}
}
fn setup_test_trust_dir() -> TrustTestGuard {
TrustTestGuard::new()
}
#[test]
fn test_valid_recent_timestamp() {
let now = now_rfc3339();
let result = validate_signature_timestamp(&now);
assert!(
result.is_ok(),
"Recent timestamp should be valid: {:?}",
result
);
}
#[test]
fn test_valid_past_timestamp() {
let past = (now_utc() - chrono::Duration::hours(1)).to_rfc3339();
let result = validate_signature_timestamp(&past);
assert!(
result.is_ok(),
"Past timestamp within expiry should be valid: {:?}",
result
);
}
#[test]
#[serial(home_env)]
fn test_valid_old_timestamp() {
let old = (now_utc() - chrono::Duration::days(365)).to_rfc3339();
let result = validate_signature_timestamp(&old);
assert!(
result.is_ok(),
"Old timestamp should be valid by default (no expiration): {:?}",
result
);
}
#[test]
#[serial(home_env)]
fn test_old_timestamp_rejected_when_expiry_enabled() {
unsafe {
env::set_var("JACS_MAX_SIGNATURE_AGE_SECONDS", "7776000");
} let old = (now_utc() - chrono::Duration::days(365)).to_rfc3339();
let result = validate_signature_timestamp(&old);
unsafe {
env::remove_var("JACS_MAX_SIGNATURE_AGE_SECONDS");
}
assert!(
result.is_err(),
"Year-old timestamp should be rejected when 90-day expiry is enabled"
);
}
#[test]
fn test_timestamp_slight_future_allowed() {
let slight_future = (now_utc() + chrono::Duration::seconds(30)).to_rfc3339();
let result = validate_signature_timestamp(&slight_future);
assert!(
result.is_ok(),
"Slight future timestamp should be allowed for clock drift: {:?}",
result
);
}
#[test]
fn test_timestamp_far_future_rejected() {
let far_future = (now_utc() + chrono::Duration::minutes(10)).to_rfc3339();
let result = validate_signature_timestamp(&far_future);
assert!(result.is_err(), "Far future timestamp should be rejected");
if let Err(JacsError::SignatureVerificationFailed { reason }) = result {
assert!(
reason.contains("future"),
"Error should mention future timestamp: {}",
reason
);
} else {
panic!("Expected SignatureVerificationFailed error");
}
}
#[test]
fn test_timestamp_invalid_format_rejected() {
let invalid_timestamps = [
"not-a-timestamp",
"2024-13-01T00:00:00Z", "2024-01-32T00:00:00Z", "01/01/2024", "", ];
for invalid in invalid_timestamps {
let result = validate_signature_timestamp(invalid);
assert!(
result.is_err(),
"Invalid timestamp '{}' should be rejected",
invalid
);
if let Err(JacsError::SignatureVerificationFailed { reason }) = result {
assert!(
reason.contains("Invalid") || reason.contains("format"),
"Error should mention invalid format for '{}': {}",
invalid,
reason
);
}
}
}
#[test]
fn test_timestamp_various_valid_formats() {
let now = now_utc();
let valid_timestamps = [
(now - chrono::Duration::hours(1)).to_rfc3339(),
(now - chrono::Duration::days(1)).to_rfc3339(),
(now - chrono::Duration::days(30)).to_rfc3339(),
];
for valid in &valid_timestamps {
let result = validate_signature_timestamp(valid);
assert!(
result.is_ok(),
"Valid timestamp format '{}' should be accepted: {:?}",
valid,
result
);
}
}
#[test]
#[serial(home_env)]
fn test_list_empty_trust_store() {
let _temp = setup_test_trust_dir();
let agents = list_trusted_agents().unwrap();
assert!(agents.is_empty());
}
#[test]
#[serial(home_env)]
fn test_is_trusted_unknown() {
let _temp = setup_test_trust_dir();
assert!(!is_trusted("unknown-agent-id"));
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_missing_signature() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
"name": "Test Agent"
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
match result {
Err(JacsError::DocumentMalformed { field, .. }) => {
assert!(field.contains("publicKeyHash"));
}
_ => panic!("Expected DocumentMalformed error, got: {:?}", result),
}
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_invalid_public_key_hash() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
"name": "Test Agent",
"jacsSignature": {
"agentID": "test-agent-id",
"agentVersion": "v1",
"date": "2024-01-01T00:00:00Z",
"signature": "dGVzdHNpZw==",
"publicKeyHash": "wrong-hash",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
}
#[test]
#[serial(home_env)]
fn test_trust_agent_accepts_split_jacs_id_and_version() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": "550e8400-e29b-41d4-a716-446655440000",
"jacsVersion": "550e8400-e29b-41d4-a716-446655440001",
"name": "Split ID Agent",
"jacsSignature": {
"agentID": "test-agent-id",
"agentVersion": "v1",
"date": "2024-01-01T00:00:00Z",
"signature": "dGVzdHNpZw==",
"publicKeyHash": "missing-hash",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
if let Err(JacsError::ValidationError(msg)) = &result {
assert!(
!msg.contains("UUID:VERSION_UUID"),
"split jacsId+jacsVersion should not fail ID format validation: {}",
msg
);
}
}
#[test]
#[serial(home_env)]
fn test_save_and_load_public_key_cache() {
let _temp = setup_test_trust_dir();
let test_key = b"test-public-key-content";
let hash = "test-hash-123";
let save_result = save_public_key_to_cache(hash, test_key, Some("ring-Ed25519"));
assert!(save_result.is_ok());
let load_result = load_public_key_from_cache(hash);
assert!(load_result.is_ok());
assert_eq!(load_result.unwrap(), test_key);
}
#[test]
#[serial(home_env)]
fn test_load_missing_public_key_cache() {
let _temp = setup_test_trust_dir();
let result = load_public_key_from_cache("nonexistent-hash");
assert!(result.is_err());
match result {
Err(JacsError::TrustError(msg)) => {
assert!(
msg.contains("nonexistent-hash"),
"Error should contain the hash"
);
assert!(
msg.contains("trust_agent_with_key"),
"Error should suggest trust_agent_with_key"
);
}
_ => panic!("Expected TrustError error"),
}
}
#[test]
#[serial(home_env)]
fn test_load_public_key_from_cache_rejects_path_traversal_hash() {
let _temp = setup_test_trust_dir();
let result = load_public_key_from_cache("public_keys/../etc/passwd");
assert!(
result.is_err(),
"path traversal in publicKeyHash should be rejected"
);
assert!(
matches!(result, Err(JacsError::ValidationError(_))),
"expected ValidationError, got {:?}",
result
);
}
#[test]
fn test_timestamp_empty_string_rejected() {
let result = validate_signature_timestamp("");
assert!(result.is_err(), "Empty timestamp should be rejected");
if let Err(JacsError::SignatureVerificationFailed { reason }) = result {
assert!(
reason.contains("Invalid") || reason.contains("format"),
"Error should mention invalid format: {}",
reason
);
}
}
#[test]
fn test_timestamp_whitespace_only_rejected() {
let whitespace_timestamps = [" ", "\t\t", "\n\n", " \t\n "];
for ts in whitespace_timestamps {
let result = validate_signature_timestamp(ts);
assert!(
result.is_err(),
"Whitespace-only timestamp '{}' should be rejected",
ts.escape_debug()
);
}
}
#[test]
fn test_timestamp_extremely_far_future_rejected() {
let far_future = "3000-01-01T00:00:00Z";
let result = validate_signature_timestamp(far_future);
assert!(
result.is_err(),
"Extremely far future timestamp should be rejected"
);
}
#[test]
fn test_timestamp_truly_invalid_formats_rejected() {
let invalid_timestamps = [
"2024/01/01T00:00:00Z", "Jan 01, 2024", "1704067200", "2024-W01", ];
for ts in invalid_timestamps {
let result = validate_signature_timestamp(ts);
assert!(
result.is_err(),
"Invalid timestamp format '{}' should be rejected",
ts
);
}
}
#[test]
fn test_timestamp_with_injection_attempt() {
let injection_attempts = [
"2024-01-01T00:00:00Z; DROP TABLE users;",
"2024-01-01T00:00:00Z<script>",
"2024-01-01T00:00:00Z\x00null",
"2024-01-01T00:00:00Z' OR '1'='1",
];
for ts in injection_attempts {
let result = validate_signature_timestamp(ts);
assert!(
result.is_err(),
"Timestamp with injection attempt '{}' should be rejected",
ts.escape_debug()
);
}
}
#[test]
#[serial(home_env)]
fn test_timestamp_unix_epoch_valid_by_default() {
let epoch = "1970-01-01T00:00:00Z";
let result = validate_signature_timestamp(epoch);
assert!(
result.is_ok(),
"Unix epoch should be valid by default (no expiration): {:?}",
result
);
}
#[test]
fn test_timestamp_y2k38_boundary() {
let y2k38 = "2038-01-19T03:14:07Z";
let result = validate_signature_timestamp(y2k38);
let _ = result;
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_invalid_json() {
let _temp = setup_test_trust_dir();
let invalid_json_cases = [
"", "not json at all", "{", "[}", "{'invalid': 'single quotes'}", "{\"incomplete\":", ];
for invalid_json in invalid_json_cases {
let result = trust_agent(invalid_json);
assert!(
result.is_err(),
"Invalid JSON '{}' should be rejected",
invalid_json.escape_debug()
);
}
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_missing_jacs_id() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"name": "Test Agent",
"jacsSignature": {
"signature": "dGVzdA==",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
match result {
Err(JacsError::DocumentMalformed { field, .. }) => {
assert!(
field.contains("jacsId"),
"Error should mention jacsId: {}",
field
);
}
_ => panic!("Expected DocumentMalformed error for missing jacsId"),
}
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_null_fields() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": null,
"name": "Test Agent",
"jacsSignature": {
"signature": "dGVzdA==",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err(), "Null jacsId should be rejected");
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_wrong_type_fields() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": 12345,
"name": "Test Agent",
"jacsSignature": {
"signature": "dGVzdA==",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err(), "Non-string jacsId should be rejected");
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_empty_signature() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
"name": "Test Agent",
"jacsSignature": {
"signature": "",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_malformed_base64_signature() {
let _temp = setup_test_trust_dir();
let agent_json = r#"{
"jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
"name": "Test Agent",
"jacsSignature": {
"signature": "!!!not-valid-base64!!!",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}
}"#;
let result = trust_agent(agent_json);
assert!(result.is_err());
}
#[test]
#[serial(home_env)]
fn test_untrust_nonexistent_agent() {
let _temp = setup_test_trust_dir();
let nonexistent_id =
"550e8400-e29b-41d4-a716-446655440099:550e8400-e29b-41d4-a716-446655440098";
let result = untrust_agent(nonexistent_id);
assert!(result.is_err());
match result {
Err(JacsError::AgentNotTrusted { agent_id }) => {
assert_eq!(agent_id, nonexistent_id, "Error should contain agent ID");
}
_ => panic!("Expected AgentNotTrusted error, got: {:?}", result),
}
}
#[test]
#[serial(home_env)]
fn test_get_trusted_agent_nonexistent() {
let _temp = setup_test_trust_dir();
let nonexistent_id =
"550e8400-e29b-41d4-a716-446655440099:550e8400-e29b-41d4-a716-446655440098";
let result = get_trusted_agent(nonexistent_id);
assert!(result.is_err());
match result {
Err(JacsError::TrustError(msg)) => {
assert!(
msg.contains(nonexistent_id),
"Error should contain agent ID"
);
assert!(
msg.contains("not in the trust store"),
"Error should explain the issue"
);
assert!(
msg.contains("trust_agent"),
"Error should suggest using trust_agent"
);
}
_ => panic!("Expected TrustError error, got: {:?}", result),
}
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_future_signature_timestamp() {
let _temp = setup_test_trust_dir();
let test_key = b"test-public-key";
let hash = "test-future-hash";
save_public_key_to_cache(hash, test_key, Some("ring-Ed25519")).unwrap();
let far_future = (now_utc() + chrono::Duration::hours(1)).to_rfc3339();
let agent_json = format!(
r#"{{
"jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001",
"name": "Test Agent",
"jacsSignature": {{
"signature": "dGVzdA==",
"publicKeyHash": "{}",
"date": "{}",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}}
}}"#,
hash, far_future
);
let result = trust_agent(&agent_json);
assert!(result.is_err());
}
#[test]
fn test_algorithm_detection_with_empty_key() {
use crate::crypt::detect_algorithm_from_public_key;
let result = detect_algorithm_from_public_key(&[]);
assert!(result.is_err(), "Empty public key should fail detection");
}
#[test]
fn test_algorithm_detection_with_very_short_key() {
use crate::crypt::detect_algorithm_from_public_key;
let short_keys = [vec![0u8; 1], vec![0u8; 10], vec![0u8; 20]];
for key in short_keys {
let _ = detect_algorithm_from_public_key(&key);
}
}
#[test]
#[serial(home_env)]
fn test_trust_agent_rejects_path_traversal_agent_id() {
let _temp = setup_test_trust_dir();
let path_traversal_ids = [
"../../etc/passwd",
"../../../etc/shadow",
"valid-uuid:../../escape",
"foo/bar",
"foo\\bar",
"foo\0bar:baz",
];
for malicious_id in path_traversal_ids {
let agent_json = format!(
r#"{{
"jacsId": "{}",
"name": "Malicious Agent",
"jacsSignature": {{
"signature": "dGVzdA==",
"publicKeyHash": "abc123",
"date": "2024-01-01T00:00:00Z",
"signingAlgorithm": "ring-Ed25519",
"fields": ["name"]
}}
}}"#,
malicious_id
);
let result = trust_agent(&agent_json);
assert!(
result.is_err(),
"Path traversal agent ID '{}' should be rejected",
malicious_id.escape_debug()
);
}
}
#[test]
#[serial(home_env)]
fn test_untrust_rejects_path_traversal() {
let _temp = setup_test_trust_dir();
let path_traversal_ids = [
"../../etc/passwd",
"../important-file",
"foo/bar",
"foo\\bar",
];
for malicious_id in path_traversal_ids {
let result = untrust_agent(malicious_id);
assert!(
result.is_err(),
"Path traversal agent ID '{}' should be rejected by untrust_agent",
malicious_id.escape_debug()
);
}
}
#[test]
#[serial(home_env)]
fn test_get_trusted_agent_rejects_path_traversal() {
let _temp = setup_test_trust_dir();
let path_traversal_ids = [
"../../etc/passwd",
"../important-file",
"foo/bar",
"foo\\bar",
];
for malicious_id in path_traversal_ids {
let result = get_trusted_agent(malicious_id);
assert!(
result.is_err(),
"Path traversal agent ID '{}' should be rejected by get_trusted_agent",
malicious_id.escape_debug()
);
}
}
#[test]
#[serial(home_env)]
fn test_is_trusted_rejects_path_traversal() {
let _temp = setup_test_trust_dir();
assert!(!is_trusted("../../etc/passwd"));
assert!(!is_trusted("../important-file"));
assert!(!is_trusted("foo/bar"));
assert!(!is_trusted("foo\\bar"));
}
#[test]
#[serial(home_env)]
fn test_public_key_cache_rejects_path_traversal_hash() {
let _temp = setup_test_trust_dir();
let malicious_hashes = [
"../../etc/passwd",
"../escape",
"public_keys/../etc/passwd",
"..",
];
for malicious_hash in malicious_hashes {
let save_result =
save_public_key_to_cache(malicious_hash, b"key-data", Some("ring-Ed25519"));
assert!(
save_result.is_err(),
"Path traversal hash '{}' should be rejected by save_public_key_to_cache",
malicious_hash.escape_debug()
);
let load_result = load_public_key_from_cache(malicious_hash);
assert!(
load_result.is_err(),
"Path traversal hash '{}' should be rejected by load_public_key_from_cache",
malicious_hash.escape_debug()
);
}
}
}