diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f1c3432..d5cf87cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## 0.5.2 + +### Security + +- **[CRITICAL] Fixed trust store path traversal**: Agent IDs used in trust store file operations are now validated as proper UUID:UUID format and resulting paths are canonicalized to prevent directory traversal attacks via malicious agent IDs. + +- **[CRITICAL] Fixed URL injection in HAI key fetch**: `agent_id` and `version` parameters in `fetch_public_key_from_hai()` and `verify_hai_registration_sync()` are now validated as UUIDs before URL interpolation, preventing path traversal in HTTP requests. + +- **[HIGH] Added configurable signature expiration**: Signatures can now be configured to expire via `JACS_MAX_SIGNATURE_AGE_SECONDS` env var (e.g., `7776000` for 90 days). Default is `0` (no expiration) since JACS documents are designed to be idempotent and eternal. + +- **[HIGH] Added strict algorithm enforcement mode**: Set `JACS_REQUIRE_EXPLICIT_ALGORITHM=true` to reject signature verification when `signingAlgorithm` is missing, preventing heuristic-based algorithm detection. + +- **[HIGH] Fixed memory leak in schema domain whitelist**: Replaced `Box::leak()` with `OnceLock` for one-time parsing of `JACS_SCHEMA_ALLOWED_DOMAINS`, preventing unbounded memory growth. + +- **[MEDIUM] Improved signed content canonicalization**: Fields are now sorted alphabetically before signing, non-string fields use canonical JSON serialization, and verification fails if zero fields are extracted. + +- **[MEDIUM] Added HTTPS enforcement for HAI key service**: `HAI_KEYS_BASE_URL` must use HTTPS (localhost exempted for testing). + +- **[MEDIUM] Added plaintext key warning**: Loading unencrypted private keys now emits a `tracing::warn` recommending encryption. + +- **[LOW] Increased PBKDF2 iterations to 600,000**: Per OWASP 2024 recommendation (was 100,000). Automatic migration fallback: decryption tries new count first, then falls back to legacy 100,000 with a warning to re-encrypt. + +- **[LOW] Deprecated `decrypt_private_key()`**: Use `decrypt_private_key_secure()` which returns `ZeroizingVec` for automatic memory zeroization. + +- **[LOW] Added rate limiting on HAI key fetch**: Outgoing requests to the HAI key service are now rate-limited (2 req/s, burst of 3) using the existing `RateLimiter`. + +- **[LOW] Renamed `JACS_USE_SECURITY` to `JACS_ENABLE_FILESYSTEM_QUARANTINE`**: Clarifies that this setting only controls filesystem quarantine of executable files, not cryptographic verification. Old name still works with a deprecation warning. + +### Migration Notes + +- Keys encrypted with pre-0.5.2 PBKDF2 iterations (100k) are automatically decrypted via fallback, but new encryptions use 600k iterations. Re-encrypt existing keys for improved security. + # PLANNED - machine fingerprinting v2 - passkey-client integration diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 0269c82e1..db179caa5 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.5.1" +version = "0.5.2" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacs/docs/jacsbook/src/advanced/security.md b/jacs/docs/jacsbook/src/advanced/security.md index 88661b43d..e519829fc 100644 --- a/jacs/docs/jacsbook/src/advanced/security.md +++ b/jacs/docs/jacsbook/src/advanced/security.md @@ -46,7 +46,7 @@ Signatures provide proof of origin: |--------|------------| | **Tampering** | Content hashes detect modifications | | **Impersonation** | Cryptographic signatures verify identity | -| **Replay Attacks** | Timestamps and version IDs ensure freshness; future timestamps rejected | +| **Replay Attacks** | Timestamps and version IDs ensure freshness; future timestamps rejected; optional signature expiration via `JACS_MAX_SIGNATURE_AGE_SECONDS` | | **Man-in-the-Middle** | DNS verification via DNSSEC; TLS certificate validation | | **Key Compromise** | Key rotation through versioning | | **Weak Passwords** | Minimum 28-bit entropy enforcement (35-bit for single class) | @@ -195,7 +195,20 @@ JACS signatures include timestamps to prevent replay attacks and ensure temporal 1. **Timestamp Inclusion**: Every signature includes a UTC timestamp recording when it was created 2. **Future Timestamp Rejection**: Signatures with timestamps more than 5 minutes in the future are rejected -3. **Validation**: Timestamp validation occurs during signature verification +3. **Optional Signature Expiration**: Configurable via `JACS_MAX_SIGNATURE_AGE_SECONDS` (disabled by default since JACS documents are designed to be eternal) +4. **Validation**: Timestamp validation occurs during signature verification + +### Configuring Signature Expiration + +By default, signatures do not expire. JACS documents are designed to be idempotent and eternal. For use cases that require expiration: + +```bash +# Enable expiration (e.g., 90 days) +export JACS_MAX_SIGNATURE_AGE_SECONDS=7776000 + +# Default: no expiration (0) +export JACS_MAX_SIGNATURE_AGE_SECONDS=0 +``` ### Protection Against Replay Attacks @@ -470,7 +483,7 @@ client = JACSMCPClient("https://localhost:8000/sse") # Good { "jacs_dns_strict": true, "jacs_dns_required": true, - "jacs_use_security": "1" + "jacs_enable_filesystem_quarantine": "true" } ``` diff --git a/jacs/docs/jacsbook/src/reference/configuration.md b/jacs/docs/jacsbook/src/reference/configuration.md index 084e1d4e3..857bd1522 100644 --- a/jacs/docs/jacsbook/src/reference/configuration.md +++ b/jacs/docs/jacsbook/src/reference/configuration.md @@ -327,7 +327,7 @@ All other JACS settings are **configuration file fields** that have sensible def - `jacs_key_directory` - Where cryptographic keys are stored (default: `./jacs_keys`) - `jacs_agent_key_algorithm` - Cryptographic algorithm to use (default: `RSA-PSS`) - `jacs_default_storage` - Storage backend (default: `fs`) -- `jacs_use_security` - Enable security features (default: `false`) +- `jacs_use_security` / `JACS_ENABLE_FILESYSTEM_QUARANTINE` - Enable filesystem quarantine of executable files (default: `false`). The env var `JACS_USE_SECURITY` is deprecated; use `JACS_ENABLE_FILESYSTEM_QUARANTINE` instead. These can be overridden by environment variables if needed, but they are primarily configured through the `jacs.config.json` file. diff --git a/jacs/docs/jacsbook/src/reference/migration.md b/jacs/docs/jacsbook/src/reference/migration.md index 365708e77..597436f03 100644 --- a/jacs/docs/jacsbook/src/reference/migration.md +++ b/jacs/docs/jacsbook/src/reference/migration.md @@ -8,6 +8,29 @@ JACS maintains backward compatibility for document verification: - Documents signed with older versions can be verified with newer versions - Older JACS versions cannot verify documents using newer cryptographic algorithms +## Migrating from 0.5.1 to 0.5.2 + +### Migration Notes + +**PBKDF2 Iteration Count**: New key encryptions use 600,000 iterations (up from 100,000). Existing encrypted keys are decrypted automatically via fallback. To upgrade existing keys, re-encrypt them: + +```bash +# Re-generate keys to use the new iteration count +jacs keygen +``` + +### Deprecated Environment Variables + +- `JACS_USE_SECURITY` is now `JACS_ENABLE_FILESYSTEM_QUARANTINE`. The old name still works with a deprecation warning. + +### New Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `JACS_MAX_SIGNATURE_AGE_SECONDS` | `0` (no expiration) | Maximum age of valid signatures. Set to a positive value to enable (e.g., `7776000` for 90 days). | +| `JACS_REQUIRE_EXPLICIT_ALGORITHM` | `false` | When `true`, reject verification if `signingAlgorithm` is missing. | +| `JACS_ENABLE_FILESYSTEM_QUARANTINE` | `false` | Enable filesystem quarantine (replaces `JACS_USE_SECURITY`). | + ## Migrating from 0.2.x to 0.3.x ### Configuration Changes diff --git a/jacs/src/agent/loaders.rs b/jacs/src/agent/loaders.rs index 3b20815ef..1a89d7c6f 100644 --- a/jacs/src/agent/loaders.rs +++ b/jacs/src/agent/loaders.rs @@ -3,6 +3,7 @@ use crate::agent::boilerplate::BoilerPlate; use crate::agent::security::SecurityTraits; use crate::crypt::aes_encrypt::{decrypt_private_key_secure, encrypt_private_key}; use crate::error::JacsError; +use crate::rate_limit::RateLimiter; use base64::{Engine as _, engine::general_purpose::STANDARD}; use flate2::Compression; use flate2::write::GzEncoder; @@ -13,8 +14,16 @@ use crate::time_utils; use std::error::Error; use std::io::Write; use std::path::Path; +use std::sync::OnceLock; use tracing::{debug, error, info, warn}; +/// Rate limiter for HAI key service requests (2 req/s, burst of 3). +static HAI_KEY_RATE_LIMITER: OnceLock = OnceLock::new(); + +fn hai_key_rate_limiter() -> &'static RateLimiter { + HAI_KEY_RATE_LIMITER.get_or_init(|| RateLimiter::new(2.0, 3)) +} + /// This environment variable determine if files are saved to the filesystem at all /// if you are building something that passing data through to a database, you'd set this flag to 0 or False /// @@ -816,6 +825,20 @@ fn decode_pem_public_key(pem_data: &str) -> Result, JacsError> { /// Set to 0 to disable retries. #[cfg(not(target_arch = "wasm32"))] pub fn fetch_public_key_from_hai(agent_id: &str, version: &str) -> Result { + // Validate agent_id and version are valid UUIDs to prevent URL path traversal + uuid::Uuid::parse_str(agent_id).map_err(|e| { + JacsError::ValidationError(format!( + "Invalid agent_id '{}' for HAI key fetch: must be a valid UUID. {}", + agent_id, e + )) + })?; + uuid::Uuid::parse_str(version).map_err(|e| { + JacsError::ValidationError(format!( + "Invalid version '{}' for HAI key fetch: must be a valid UUID. {}", + version, e + )) + })?; + // Get retry count from environment or use default of 3 let max_retries: u32 = std::env::var("HAI_KEY_FETCH_RETRIES") .ok() @@ -826,6 +849,15 @@ pub fn fetch_public_key_from_hai(agent_id: &str, version: &str) -> Result Result return Ok(result), Err(err) => { @@ -1144,7 +1179,10 @@ CDEF std::env::set_var("HAI_KEY_FETCH_RETRIES", "0"); } - let result = fetch_public_key_from_hai("test-agent-id", "1"); + let result = fetch_public_key_from_hai( + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + ); // Clean up first to ensure it happens even if assertions fail unsafe { @@ -1178,7 +1216,10 @@ CDEF } // This will fail (no server), but we can verify it attempted the right URL - let result = fetch_public_key_from_hai("nonexistent-agent", "1"); + let result = fetch_public_key_from_hai( + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + ); // Clean up unsafe { @@ -1205,7 +1246,10 @@ CDEF } let start = std::time::Instant::now(); - let _ = fetch_public_key_from_hai("test-agent", "1"); + let _ = fetch_public_key_from_hai( + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + ); let elapsed = start.elapsed(); // Clean up diff --git a/jacs/src/agent/security.rs b/jacs/src/agent/security.rs index df55c9ba8..3f54373b5 100644 --- a/jacs/src/agent/security.rs +++ b/jacs/src/agent/security.rs @@ -10,10 +10,12 @@ use std::os::unix::fs::PermissionsExt; use std::path::Path; use walkdir::WalkDir; -/// off by default -/// /// This environment variable determine if files are saved to the filesystem at all -/// if you are building something that passing data through to a database, you'd set this flag to 0 or False -const _JACS_USE_SECURITY: &str = "JACS_USE_SECURITY"; +/// Controls filesystem quarantine of executable files found in the data directory. +/// Off by default. Set `JACS_ENABLE_FILESYSTEM_QUARANTINE=true` to enable. +/// Legacy env var `JACS_USE_SECURITY` is also supported but deprecated. +/// +/// NOTE: This does NOT affect cryptographic signing or verification. +const _JACS_ENABLE_FILESYSTEM_QUARANTINE: &str = "JACS_ENABLE_FILESYSTEM_QUARANTINE"; pub trait SecurityTraits { fn use_security(&self) -> bool; @@ -30,7 +32,10 @@ impl SecurityTraits for Agent { /// /// it will move all exuctable documents in JACS_DATA_DIRECTORY a quarantine directory fn check_data_directory(&self) -> Result<(), Box> { if !self.use_security() { - info!("JACS_USE_SECURITY security is off"); + info!( + "Filesystem quarantine is disabled. Set JACS_ENABLE_FILESYSTEM_QUARANTINE=true to enable. \ + Note: this does NOT affect cryptographic signing or verification." + ); return Ok(()); } if !self.use_fs_security() { diff --git a/jacs/src/config/mod.rs b/jacs/src/config/mod.rs index dcc73c8f3..b7a002e67 100644 --- a/jacs/src/config/mod.rs +++ b/jacs/src/config/mod.rs @@ -246,7 +246,29 @@ macro_rules! env_default { env_default!(default_storage, "JACS_DEFAULT_STORAGE", "fs"); env_default!(default_algorithm, "JACS_AGENT_KEY_ALGORITHM", "RSA-PSS"); -env_default!(default_security, "JACS_USE_SECURITY", "false"); +/// Check `JACS_ENABLE_FILESYSTEM_QUARANTINE` (preferred) first, +/// fall back to legacy `JACS_USE_SECURITY` with a deprecation warning. +fn default_security() -> Option { + // Preferred new name + if let Ok(Some(val)) = get_env_var("JACS_ENABLE_FILESYSTEM_QUARANTINE", false) { + if !val.is_empty() { + return Some(val); + } + } + // Legacy name (backwards compatible) + if let Ok(Some(val)) = get_env_var("JACS_USE_SECURITY", false) { + if !val.is_empty() { + eprintln!( + "DEPRECATION WARNING: JACS_USE_SECURITY is deprecated. \ + Use JACS_ENABLE_FILESYSTEM_QUARANTINE instead. \ + This env var only controls filesystem quarantine of executable files, \ + not cryptographic verification." + ); + return Some(val); + } + } + Some("false".to_string()) +} /// Helper to compute a directory default with CWD resolution for filesystem storage. /// Falls back to a relative path if CWD cannot be determined or storage is not "fs". diff --git a/jacs/src/crypt/aes_encrypt.rs b/jacs/src/crypt/aes_encrypt.rs index ae745617c..5f1142a01 100644 --- a/jacs/src/crypt/aes_encrypt.rs +++ b/jacs/src/crypt/aes_encrypt.rs @@ -2,9 +2,9 @@ use crate::crypt::constants::{ AES_256_KEY_SIZE, AES_GCM_NONCE_SIZE, DIGIT_POOL_SIZE, LOWERCASE_POOL_SIZE, MAX_CONSECUTIVE_IDENTICAL_CHARS, MAX_SEQUENTIAL_CHARS, MIN_ENCRYPTED_HEADER_SIZE, MIN_ENTROPY_BITS, MIN_PASSWORD_LENGTH, MODERATE_UNIQUENESS_PENALTY, - MODERATE_UNIQUENESS_THRESHOLD, PBKDF2_ITERATIONS, PBKDF2_SALT_SIZE, SEVERE_UNIQUENESS_PENALTY, - SEVERE_UNIQUENESS_THRESHOLD, SINGLE_CLASS_MIN_ENTROPY_BITS, SPECIAL_CHAR_POOL_SIZE, - UPPERCASE_POOL_SIZE, + MODERATE_UNIQUENESS_THRESHOLD, PBKDF2_ITERATIONS, PBKDF2_ITERATIONS_LEGACY, PBKDF2_SALT_SIZE, + SEVERE_UNIQUENESS_PENALTY, SEVERE_UNIQUENESS_THRESHOLD, SINGLE_CLASS_MIN_ENTROPY_BITS, + SPECIAL_CHAR_POOL_SIZE, UPPERCASE_POOL_SIZE, }; use crate::crypt::private_key::ZeroizingVec; use crate::error::JacsError; @@ -17,6 +17,7 @@ use aes_gcm::{ use pbkdf2::pbkdf2_hmac; use rand::Rng; use sha2::Sha256; +use tracing::warn; use zeroize::Zeroize; /// Common weak passwords that should be rejected regardless of calculated entropy. @@ -238,18 +239,27 @@ fn validate_password(password: &str) -> Result<(), Box> { Ok(()) } -/// Derive a 256-bit key from a password using PBKDF2-HMAC-SHA256. -fn derive_key_from_password(password: &str, salt: &[u8]) -> [u8; AES_256_KEY_SIZE] { +/// Derive a 256-bit key from a password using PBKDF2-HMAC-SHA256 with a specific iteration count. +fn derive_key_with_iterations( + password: &str, + salt: &[u8], + iterations: u32, +) -> [u8; AES_256_KEY_SIZE] { let mut key = [0u8; AES_256_KEY_SIZE]; - pbkdf2_hmac::(password.as_bytes(), salt, PBKDF2_ITERATIONS, &mut key); + pbkdf2_hmac::(password.as_bytes(), salt, iterations, &mut key); key } +/// Derive a 256-bit key from a password using PBKDF2-HMAC-SHA256. +fn derive_key_from_password(password: &str, salt: &[u8]) -> [u8; AES_256_KEY_SIZE] { + derive_key_with_iterations(password, salt, PBKDF2_ITERATIONS) +} + /// Encrypt a private key with a password using AES-256-GCM. /// /// The encrypted output format is: salt (16 bytes) || nonce (12 bytes) || ciphertext /// -/// Key derivation uses PBKDF2-HMAC-SHA256 with 100,000 iterations. +/// Key derivation uses PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2024). /// /// # Security Requirements /// @@ -294,7 +304,8 @@ pub fn encrypt_private_key(private_key: &[u8]) -> Result, Box Result, Box` for backwards compatibility. /// For new code, prefer `decrypt_private_key_secure` which returns a /// `ZeroizingVec` that automatically zeroizes memory on drop. +#[deprecated(since = "0.6.0", note = "Use decrypt_private_key_secure() which returns ZeroizingVec for automatic memory zeroization")] pub fn decrypt_private_key( encrypted_key_with_salt_and_nonce: &[u8], ) -> Result, Box> { @@ -324,7 +336,9 @@ pub fn decrypt_private_key( /// /// Expects input format: salt (16 bytes) || nonce (12 bytes) || ciphertext /// -/// Key derivation uses PBKDF2-HMAC-SHA256 with 100,000 iterations. +/// Key derivation uses PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2024). +/// For backwards compatibility, if decryption fails with the current iteration count, +/// it falls back to the legacy 100,000 iterations and logs a migration warning. /// /// # Security Requirements /// @@ -361,19 +375,29 @@ pub fn decrypt_private_key_secure( let (salt, rest) = encrypted_key_with_salt_and_nonce.split_at(PBKDF2_SALT_SIZE); let (nonce, encrypted_data) = rest.split_at(AES_GCM_NONCE_SIZE); - // Derive key using PBKDF2-HMAC-SHA256 - let mut key = derive_key_from_password(&password, salt); + // Try decryption with current iteration count first, then fall back to legacy. + // This allows seamless migration from pre-0.6.0 keys encrypted with 100k iterations + // to the new 600k iteration count. + let nonce_slice = Nonce::from_slice(nonce); - // Create cipher instance + // Attempt with current iterations (600k) + let mut key = derive_key_from_password(&password, salt); let cipher_key = Key::::from_slice(&key); let cipher = Aes256Gcm::new(cipher_key); - - // Zeroize the derived key immediately after creating the cipher key.zeroize(); - // Decrypt private key - let decrypted_data = cipher - .decrypt(Nonce::from_slice(nonce), encrypted_data) + if let Ok(decrypted_data) = cipher.decrypt(nonce_slice, encrypted_data) { + return Ok(ZeroizingVec::new(decrypted_data)); + } + + // Fall back to legacy iterations (100k) for pre-0.6.0 encrypted keys + let mut legacy_key = derive_key_with_iterations(&password, salt, PBKDF2_ITERATIONS_LEGACY); + let legacy_cipher_key = Key::::from_slice(&legacy_key); + let legacy_cipher = Aes256Gcm::new(legacy_cipher_key); + legacy_key.zeroize(); + + let decrypted_data = legacy_cipher + .decrypt(nonce_slice, encrypted_data) .map_err(|_| { "Private key decryption failed: incorrect password or corrupted key file. \ Check that JACS_PRIVATE_KEY_PASSWORD matches the password used during key generation. \ @@ -381,6 +405,13 @@ pub fn decrypt_private_key_secure( .to_string() })?; + warn!( + "MIGRATION: Private key was decrypted using legacy PBKDF2 iteration count ({}). \ + Re-encrypt your private key to upgrade to the current iteration count ({}) \ + for improved security. Run 'jacs keygen' to regenerate keys.", + PBKDF2_ITERATIONS_LEGACY, PBKDF2_ITERATIONS + ); + Ok(ZeroizingVec::new(decrypted_data)) } diff --git a/jacs/src/crypt/constants.rs b/jacs/src/crypt/constants.rs index a2b8598b5..5ddd1f6be 100644 --- a/jacs/src/crypt/constants.rs +++ b/jacs/src/crypt/constants.rs @@ -26,9 +26,12 @@ pub const MIN_ENCRYPTED_HEADER_SIZE: usize = PBKDF2_SALT_SIZE + AES_GCM_NONCE_SI // ============================================================================ /// Number of PBKDF2 iterations for key derivation. -/// 100,000 iterations provides reasonable security against brute-force attacks. -/// This adds approximately 17 bits of work factor. -pub const PBKDF2_ITERATIONS: u32 = 100_000; +/// 600,000 iterations per OWASP 2024 recommendation for PBKDF2-HMAC-SHA256. +/// This adds approximately 19.2 bits of work factor. +pub const PBKDF2_ITERATIONS: u32 = 600_000; + +/// Legacy iteration count for migration from pre-0.6.0 keys. +pub const PBKDF2_ITERATIONS_LEGACY: u32 = 100_000; /// Minimum password length for key encryption. pub const MIN_PASSWORD_LENGTH: usize = 8; diff --git a/jacs/src/crypt/mod.rs b/jacs/src/crypt/mod.rs index 0d7279d0e..60609daaf 100644 --- a/jacs/src/crypt/mod.rs +++ b/jacs/src/crypt/mod.rs @@ -364,6 +364,21 @@ impl KeyManager for Agent { CryptoSigningAlgorithm::from_str(enc_type)? } None => { + warn!( + "SECURITY: signingAlgorithm not provided for verification. \ + Auto-detection is deprecated and may be removed in a future version. \ + Set JACS_REQUIRE_EXPLICIT_ALGORITHM=true to enforce explicit algorithms." + ); + + // Check if strict mode is enabled + let strict = std::env::var("JACS_REQUIRE_EXPLICIT_ALGORITHM") + .map(|v| v.eq_ignore_ascii_case("true") || v == "1") + .unwrap_or(false); + if strict { + return Err("Signature verification requires explicit signingAlgorithm field. \ + Re-sign the document to include the signingAlgorithm field.".into()); + } + // Try to auto-detect the algorithm type from the public key match detect_algorithm_from_public_key(&public_key) { Ok(detected_algo) => { diff --git a/jacs/src/dns/bootstrap.rs b/jacs/src/dns/bootstrap.rs index 068754ecf..4968864fc 100644 --- a/jacs/src/dns/bootstrap.rs +++ b/jacs/src/dns/bootstrap.rs @@ -371,6 +371,14 @@ pub fn verify_hai_registration_sync( agent_id: &str, public_key_hash: &str, ) -> Result { + // Validate agent_id is a valid UUID to prevent URL path traversal + uuid::Uuid::parse_str(agent_id).map_err(|e| { + format!( + "Invalid agent_id '{}' for HAI registration: must be a valid UUID. {}", + agent_id, e + ) + })?; + // HAI.ai API endpoint for agent verification let api_url = std::env::var("HAI_API_URL") .unwrap_or_else(|_| "https://api.hai.ai".to_string()); diff --git a/jacs/src/keystore/mod.rs b/jacs/src/keystore/mod.rs index 4eff614e8..a560e0534 100644 --- a/jacs/src/keystore/mod.rs +++ b/jacs/src/keystore/mod.rs @@ -1,5 +1,6 @@ use crate::error::JacsError; use std::error::Error; +use tracing::warn; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -196,6 +197,14 @@ impl KeyStore for FsEncryptedStore { })?; return Ok(decrypted.as_slice().to_vec()); } + + warn!( + "SECURITY WARNING: Loaded unencrypted private key from '{}'. \ + Private keys should be encrypted for production use. \ + Set JACS_PRIVATE_KEY_PASSWORD to encrypt your private key.", + priv_path + ); + Ok(bytes) } diff --git a/jacs/src/schema/utils.rs b/jacs/src/schema/utils.rs index d3302fa02..8726ea90d 100644 --- a/jacs/src/schema/utils.rs +++ b/jacs/src/schema/utils.rs @@ -85,6 +85,23 @@ pub fn check_document_size(data: &str) -> Result<(), JacsError> { Ok(()) } +/// Extra allowed domains parsed from `JACS_SCHEMA_ALLOWED_DOMAINS`, cached once. +static EXTRA_ALLOWED_SCHEMA_DOMAINS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn get_extra_allowed_domains() -> &'static Vec { + EXTRA_ALLOWED_SCHEMA_DOMAINS.get_or_init(|| { + std::env::var("JACS_SCHEMA_ALLOWED_DOMAINS") + .map(|env_domains| { + env_domains + .split(',') + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()) + .collect() + }) + .unwrap_or_default() + }) +} + fn is_schema_url_allowed(url: &str) -> Result<(), JacsError> { // Parse the URL to extract the host let parsed = url::Url::parse(url).map_err(|e| { @@ -95,17 +112,11 @@ fn is_schema_url_allowed(url: &str) -> Result<(), JacsError> { JacsError::SchemaError(format!("URL '{}' has no host", url)) })?; - // Build the list of allowed domains + // Build the list of allowed domains from defaults + cached env var + let extra = get_extra_allowed_domains(); let mut allowed_domains: Vec<&str> = DEFAULT_ALLOWED_SCHEMA_DOMAINS.to_vec(); - - // Add domains from environment variable if set - if let Ok(env_domains) = std::env::var("JACS_SCHEMA_ALLOWED_DOMAINS") { - for domain in env_domains.split(',') { - let trimmed = domain.trim(); - if !trimmed.is_empty() { - allowed_domains.push(Box::leak(trimmed.to_string().into_boxed_str())); - } - } + for domain in extra { + allowed_domains.push(domain.as_str()); } // Check if the host matches any allowed domain diff --git a/jacs/src/time_utils.rs b/jacs/src/time_utils.rs index 44f7876ac..fbade9400 100644 --- a/jacs/src/time_utils.rs +++ b/jacs/src/time_utils.rs @@ -10,9 +10,10 @@ use chrono::{DateTime, Utc}; /// Signatures dated more than this many seconds in the future are rejected. pub const MAX_FUTURE_TIMESTAMP_SECONDS: i64 = 300; -/// Optional maximum signature age (in seconds). -/// Set to 0 to disable expiration checking. -/// Default: 0 (no expiration - signatures don't expire) +/// Default maximum signature age (in seconds). +/// Default: 0 (no expiration). JACS documents are designed to be idempotent and eternal. +/// Set `JACS_MAX_SIGNATURE_AGE_SECONDS` to a positive value to enable expiration +/// (e.g., 7776000 for 90 days). pub const MAX_SIGNATURE_AGE_SECONDS: i64 = 0; /// Returns the current UTC timestamp in RFC 3339 format. @@ -173,6 +174,18 @@ pub fn validate_timestamp_not_expired( Ok(()) } +/// Returns the effective maximum signature age in seconds. +/// +/// Checks `JACS_MAX_SIGNATURE_AGE_SECONDS` environment variable first, +/// falls back to the compiled-in default (0 = no expiration). +/// Set to a positive value to enable expiration (e.g., 7776000 for 90 days). +pub fn max_signature_age() -> i64 { + std::env::var("JACS_MAX_SIGNATURE_AGE_SECONDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(MAX_SIGNATURE_AGE_SECONDS) +} + /// Validates a signature timestamp. /// /// This combines both future and expiration checks. @@ -190,7 +203,8 @@ pub fn validate_timestamp_not_expired( /// 1. The timestamp must be a valid RFC 3339 / ISO 8601 format /// 2. The timestamp must not be more than `MAX_FUTURE_TIMESTAMP_SECONDS` in the future /// (allows for small clock drift between systems) -/// 3. If `MAX_SIGNATURE_AGE_SECONDS` > 0, the timestamp must not be older than that +/// 3. If signature age limit > 0 (default: disabled), the timestamp must not be older than that. +/// Set `JACS_MAX_SIGNATURE_AGE_SECONDS` to a positive value to enable (e.g., 7776000 for 90 days). pub fn validate_signature_timestamp(timestamp_str: &str) -> Result<(), JacsError> { // Parse the timestamp (validates format) let signature_time = parse_rfc3339(timestamp_str).map_err(|_| { @@ -214,14 +228,16 @@ pub fn validate_signature_timestamp(timestamp_str: &str) -> Result<(), JacsError } // Check for expired signatures (if expiration is enabled) - if MAX_SIGNATURE_AGE_SECONDS > 0 { - let expiry_limit = now - chrono::Duration::seconds(MAX_SIGNATURE_AGE_SECONDS); + let age_limit = max_signature_age(); + if age_limit > 0 { + let expiry_limit = now - chrono::Duration::seconds(age_limit); if signature_time < expiry_limit { return Err(JacsError::SignatureVerificationFailed { reason: format!( - "Signature timestamp {} is too old (max age {} seconds). \ - The agent document may need to be re-signed.", - timestamp_str, MAX_SIGNATURE_AGE_SECONDS + "Signature timestamp {} is too old (max age {} seconds / {} days). \ + The agent document may need to be re-signed. \ + Set JACS_MAX_SIGNATURE_AGE_SECONDS=0 to disable expiration.", + timestamp_str, age_limit, age_limit / 86400 ), }); } diff --git a/jacs/src/trust.rs b/jacs/src/trust.rs index a2f8529b1..e1e733d5f 100644 --- a/jacs/src/trust.rs +++ b/jacs/src/trust.rs @@ -9,13 +9,60 @@ use crate::error::JacsError; use crate::paths::trust_store_dir; use crate::schema::utils::ValueExt; use crate::time_utils; +use crate::validation::validate_agent_id; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; +use std::path::Path; use std::str::FromStr; use tracing::{info, warn}; +/// Validates an agent ID is safe for use in filesystem paths. +/// +/// This checks that the agent ID: +/// 1. Is a valid JACS agent ID (UUID:UUID format) +/// 2. Does not contain path traversal sequences +/// +/// This is a security boundary function -- all trust store operations that +/// construct file paths from agent IDs MUST call this first. +fn validate_agent_id_for_path(agent_id: &str) -> Result<(), JacsError> { + // Primary defense: validate UUID:UUID format (rejects all special characters) + validate_agent_id(agent_id)?; + + // Secondary defense: explicitly reject path traversal patterns + 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(()) +} + +/// Validates that a constructed path is within the trust store directory. +/// +/// Defense-in-depth: even after agent ID validation, verify the resolved +/// path doesn't escape the trust store. +fn validate_path_within_trust_dir(path: &Path, trust_dir: &Path) -> Result<(), JacsError> { + // For existing files, canonicalize and check containment + 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(()) +} + /// Information about a trusted agent. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrustedAgent { @@ -98,6 +145,10 @@ pub fn trust_agent_with_key(agent_json: &str, public_key_pem: Option<&str>) -> R // Extract required fields let agent_id = agent_value.get_str_required("jacsId")?; + + // Validate agent ID is safe for filesystem paths (prevents path traversal) + validate_agent_id_for_path(&agent_id)?; + let name = agent_value.get_str("name"); // Extract public key hash from signature @@ -254,11 +305,16 @@ pub fn list_trusted_agents() -> Result, JacsError> { /// ``` #[must_use = "untrust operation result must be checked for errors"] pub fn untrust_agent(agent_id: &str) -> Result<(), JacsError> { + // Validate agent ID is safe for filesystem paths (prevents path traversal) + 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(), @@ -293,9 +349,14 @@ pub fn untrust_agent(agent_id: &str) -> Result<(), JacsError> { /// The full agent JSON if the agent is trusted. #[must_use = "trusted agent data must be used"] pub fn get_trusted_agent(agent_id: &str) -> Result { + // Validate agent ID is safe for filesystem paths (prevents path traversal) + 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() \ @@ -323,6 +384,10 @@ pub fn get_trusted_agent(agent_id: &str) -> Result { /// The public key hash for looking up the actual key. #[must_use = "public key hash must be used"] pub fn get_trusted_public_key_hash(agent_id: &str) -> Result { + // Validation is performed inside get_trusted_agent, but validate here too + // for defense in depth in case the call chain changes + 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 { @@ -344,6 +409,10 @@ pub fn get_trusted_public_key_hash(agent_id: &str) -> Result /// /// `true` if the agent is trusted, `false` otherwise. pub fn is_trusted(agent_id: &str) -> bool { + // Validate agent ID is safe for filesystem paths; return false for invalid IDs + 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)); agent_file.exists() @@ -355,6 +424,14 @@ pub fn is_trusted(agent_id: &str) -> bool { /// Loads a public key from the trust store's key cache. fn load_public_key_from_cache(public_key_hash: &str) -> Result, JacsError> { + // Validate hash doesn't contain path traversal characters + if public_key_hash.contains("..") || public_key_hash.contains('/') || public_key_hash.contains('\\') || public_key_hash.contains('\0') { + return Err(JacsError::ValidationError(format!( + "Public key hash '{}' contains unsafe path characters", + 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)); @@ -381,6 +458,14 @@ fn save_public_key_to_cache( public_key_bytes: &[u8], algorithm: Option<&str>, ) -> Result<(), JacsError> { + // Validate hash doesn't contain path traversal characters + if public_key_hash.contains("..") || public_key_hash.contains('/') || public_key_hash.contains('\\') || public_key_hash.contains('\0') { + return Err(JacsError::ValidationError(format!( + "Public key hash '{}' contains unsafe path characters", + public_key_hash + ))); + } + let trust_dir = trust_store_dir(); let keys_dir = trust_dir.join("keys"); @@ -433,16 +518,35 @@ fn verify_agent_self_signature( let signature_b64 = agent_value.get_path_str_required(&["jacsSignature", "signature"])?; let fields = agent_value.get_path_array_required(&["jacsSignature", "fields"])?; - // Build the content that was signed - let mut content_parts: Vec = Vec::new(); + // Build the content that was signed, using deterministic field ordering + let mut field_names: Vec<&str> = Vec::new(); for field in fields { - if let Some(field_name) = field.as_str() - && let Some(value) = agent_value.get(field_name) - && let Some(str_val) = value.as_str() - { - content_parts.push(str_val.to_string()); + if let Some(name) = field.as_str() { + field_names.push(name); + } + } + // Sort fields alphabetically for deterministic content reconstruction + field_names.sort(); + + let mut content_parts: Vec = Vec::new(); + for field_name in &field_names { + if let Some(value) = agent_value.get(*field_name) { + if let Some(str_val) = value.as_str() { + content_parts.push(str_val.to_string()); + } else { + // For non-string fields, use canonical JSON serialization + content_parts.push(serde_json::to_string(value).unwrap_or_default()); + } } } + + if content_parts.is_empty() { + return Err(JacsError::SignatureVerificationFailed { + reason: "No signed fields could be extracted from the agent document. \ + The 'fields' array in jacsSignature may reference non-existent fields.".to_string(), + }); + } + let signed_content = content_parts.join(" "); // Determine the algorithm @@ -456,6 +560,18 @@ fn verify_agent_self_signature( ), })?, None => { + // Check if strict mode is enabled + let strict = std::env::var("JACS_REQUIRE_EXPLICIT_ALGORITHM") + .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(), + }); + } + // Try to detect from the public key detect_algorithm_from_public_key(public_key_bytes).map_err(|e| { JacsError::SignatureVerificationFailed { @@ -602,18 +718,30 @@ mod tests { #[test] fn test_valid_past_timestamp() { - // A timestamp from 1 hour ago should be valid (when expiration is disabled) + // A timestamp from 1 hour ago should be valid (within 90-day default expiry) let past = (now_utc() - chrono::Duration::hours(1)).to_rfc3339(); let result = validate_signature_timestamp(&past); - assert!(result.is_ok(), "Past timestamp should be valid when expiration is disabled: {:?}", result); + assert!(result.is_ok(), "Past timestamp within expiry should be valid: {:?}", result); } #[test] fn test_valid_old_timestamp() { - // A timestamp from a year ago should be valid (when expiration is disabled) + // A timestamp from a year ago should be valid by default (no expiration) + // JACS documents are designed to be idempotent and eternal 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 when expiration is disabled: {:?}", result); + assert!(result.is_ok(), "Old timestamp should be valid by default (no expiration): {:?}", result); + } + + #[test] + #[serial] + fn test_old_timestamp_rejected_when_expiry_enabled() { + // When expiration is explicitly enabled, old timestamps should be rejected + unsafe { env::set_var("JACS_MAX_SIGNATURE_AGE_SECONDS", "7776000"); } // 90 days + 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] @@ -673,15 +801,15 @@ mod tests { #[test] fn test_timestamp_various_valid_formats() { // Various valid RFC 3339 formats should work + let now = now_utc(); let valid_timestamps = [ - "2024-01-15T10:30:00Z", - "2024-06-20T15:45:30+00:00", - "2024-12-01T00:00:00.000Z", + (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 { + for valid in &valid_timestamps { let result = validate_signature_timestamp(valid); - // These are old timestamps but should still be valid if no expiration assert!( result.is_ok(), "Valid timestamp format '{}' should be accepted: {:?}", @@ -715,7 +843,7 @@ mod tests { // Agent without jacsSignature should fail let agent_json = r#"{ - "jacsId": "test-agent-id", + "jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001", "name": "Test Agent" }"#; @@ -725,7 +853,7 @@ mod tests { Err(JacsError::DocumentMalformed { field, .. }) => { assert!(field.contains("publicKeyHash")); } - _ => panic!("Expected DocumentMalformed error"), + _ => panic!("Expected DocumentMalformed error, got: {:?}", result), } } @@ -736,7 +864,7 @@ mod tests { // Agent with signature but wrong public key hash let agent_json = r#"{ - "jacsId": "test-agent-id", + "jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001", "name": "Test Agent", "jacsSignature": { "agentID": "test-agent-id", @@ -868,13 +996,14 @@ mod tests { } #[test] - fn test_timestamp_unix_epoch_valid() { - // Unix epoch (1970-01-01) should be valid when expiration is disabled + fn test_timestamp_unix_epoch_valid_by_default() { + // Unix epoch (1970-01-01) should be valid by default (no expiration) + // JACS documents are eternal let epoch = "1970-01-01T00:00:00Z"; let result = validate_signature_timestamp(epoch); assert!( result.is_ok(), - "Unix epoch should be valid when expiration disabled: {:?}", + "Unix epoch should be valid by default (no expiration): {:?}", result ); } @@ -992,7 +1121,7 @@ mod tests { let _temp = setup_test_trust_dir(); let agent_json = r#"{ - "jacsId": "test-agent-id", + "jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001", "name": "Test Agent", "jacsSignature": { "signature": "", @@ -1015,7 +1144,7 @@ mod tests { let _temp = setup_test_trust_dir(); let agent_json = r#"{ - "jacsId": "test-agent-id", + "jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001", "name": "Test Agent", "jacsSignature": { "signature": "!!!not-valid-base64!!!", @@ -1035,13 +1164,14 @@ mod tests { fn test_untrust_nonexistent_agent() { let _temp = setup_test_trust_dir(); - let result = untrust_agent("nonexistent-agent-id"); + 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-agent-id", "Error should contain agent ID"); + assert_eq!(agent_id, nonexistent_id, "Error should contain agent ID"); } - _ => panic!("Expected AgentNotTrusted error"), + _ => panic!("Expected AgentNotTrusted error, got: {:?}", result), } } @@ -1050,15 +1180,16 @@ mod tests { fn test_get_trusted_agent_nonexistent() { let _temp = setup_test_trust_dir(); - let result = get_trusted_agent("nonexistent-agent-id"); + 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-agent-id"), "Error should contain agent ID"); + 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"), + _ => panic!("Expected TrustError error, got: {:?}", result), } } @@ -1075,7 +1206,7 @@ mod tests { // Agent with far future timestamp let far_future = (now_utc() + chrono::Duration::hours(1)).to_rfc3339(); let agent_json = format!(r#"{{ - "jacsId": "test-agent-id", + "jacsId": "550e8400-e29b-41d4-a716-446655440000:550e8400-e29b-41d4-a716-446655440001", "name": "Test Agent", "jacsSignature": {{ "signature": "dGVzdA==", @@ -1115,4 +1246,127 @@ mod tests { let _ = detect_algorithm_from_public_key(&key); } } + + // ==================== Path Traversal Security Tests ==================== + + #[test] + #[serial] + 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] + 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] + 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] + fn test_is_trusted_rejects_path_traversal() { + let _temp = setup_test_trust_dir(); + + // is_trusted returns false for invalid IDs instead of error + assert!(!is_trusted("../../etc/passwd")); + assert!(!is_trusted("../important-file")); + assert!(!is_trusted("foo/bar")); + assert!(!is_trusted("foo\\bar")); + } + + #[test] + #[serial] + fn test_public_key_cache_rejects_path_traversal_hash() { + let _temp = setup_test_trust_dir(); + + let malicious_hashes = [ + "../../etc/passwd", + "../escape", + "hash/with/slashes", + "hash\\with\\backslashes", + ]; + + 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() + ); + } + } } diff --git a/jacs/tests/lifecycle_tests.rs b/jacs/tests/lifecycle_tests.rs index f0e7d7a5b..8c441a16e 100644 --- a/jacs/tests/lifecycle_tests.rs +++ b/jacs/tests/lifecycle_tests.rs @@ -357,12 +357,13 @@ fn test_is_trusted_returns_false_for_unknown() { fn test_untrust_nonexistent_agent_fails() { let _temp = setup_trust_test_env(); - let result = trust::untrust_agent("nonexistent-agent-id"); + let fake_id = "00000000-0000-0000-0000-000000000000:00000000-0000-0000-0000-000000000001"; + let result = trust::untrust_agent(fake_id); assert!(result.is_err(), "Untrusting non-existent agent should fail"); match result { Err(jacs::JacsError::AgentNotTrusted { agent_id }) => { - assert_eq!(agent_id, "nonexistent-agent-id"); + assert_eq!(agent_id, fake_id); } _ => panic!("Expected AgentNotTrusted error"), }