diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d4bc1e..2d127edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.12 + +(unreleased) + ## 0.9.11 (unreleased) diff --git a/Makefile b/Makefile index 629a6979..c9d6fe76 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,7 @@ test-jacs-features: test-jacs-pq test-jacs-cli: cargo build -p jacs-cli cd jacs && RUST_BACKTRACE=1 cargo test --test cli_tests --test cli_flags -- --nocapture + RUST_BACKTRACE=1 cargo test -p jacs-cli --test cli_convert_tests -- --nocapture test-jacs-cross-language: cd jacs && RUST_BACKTRACE=1 cargo test --features "agreements a2a attestation" --test cross_language_tests --test a2a_cross_language_tests --test attestation_cross_lang_tests -- --nocapture diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index 23a23801..fb3983ec 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-binding-core" -version = "0.9.11" +version = "0.9.12" edition = "2024" rust-version = "1.93" resolver = "3" @@ -19,11 +19,14 @@ attestation = ["jacs/attestation"] pq-tests = [] [dependencies] -jacs = { version = "0.9.11", path = "../jacs" } +jacs = { version = "0.9.12", path = "../jacs" } serde_json = "1.0" base64 = "0.22.1" serde = { version = "1.0", features = ["derive"] } tracing = "0.1" +rand = "0.9" +rsa = { version = "0.9.8", features = ["pem"] } +reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } [dev-dependencies] tempfile = "3" diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index b7df0ad9..c48746b5 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -6,7 +6,7 @@ //! by any language binding. Each binding implements the `BindingError` trait //! to convert errors to their native format. -use base64::Engine as _; +use base64::{Engine as _, engine::general_purpose}; use jacs::agent::agreement::Agreement; use jacs::agent::boilerplate::BoilerPlate; use jacs::agent::document::{DocumentTraits, JACSDocument}; @@ -18,9 +18,14 @@ use jacs::agent::{ use jacs::config::Config; use jacs::crypt::KeyManager; use jacs::crypt::hash::hash_string as jacs_hash_string; +use reqwest::blocking::Client as BlockingClient; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use reqwest::{StatusCode, Url}; use serde_json::{Value, json}; use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; pub mod conversion; @@ -196,6 +201,359 @@ fn normalize_path(path: &Path) -> PathBuf { normalized } +fn resolve_path_from_cwd(path: &str) -> BindingResult { + let requested = Path::new(path); + if requested.is_absolute() { + return Ok(normalize_path(requested)); + } + + Ok(normalize_path( + &std::env::current_dir() + .map_err(|e| { + BindingCoreError::agent_load(format!( + "Failed to determine current working directory: {}", + e + )) + })? + .join(requested), + )) +} + +fn resolve_relative_to_config(config_path: &Path, candidate: &str) -> PathBuf { + let candidate_path = Path::new(candidate); + if candidate_path.is_absolute() { + return normalize_path(candidate_path); + } + + let base_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + normalize_path(&base_dir.join(candidate_path)) +} + +fn read_password_file(path: &Path) -> BindingResult> { + if !path.exists() { + return Ok(None); + } + + let contents = fs::read_to_string(path).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to read password file {}: {}", + path.display(), + e + )) + })?; + let password = contents.trim_end_matches(|c| c == '\n' || c == '\r').trim(); + if password.is_empty() { + return Ok(None); + } + Ok(Some(password.to_string())) +} + +fn missing_password_message(error: &str) -> bool { + error.contains("No private key password available") +} + +fn truthy_env_var(name: &str) -> bool { + std::env::var(name) + .ok() + .map(|value| { + let value = value.trim(); + value.eq_ignore_ascii_case("true") || value == "1" + }) + .unwrap_or(false) +} + +const DEFAULT_NETWORK_TIMEOUT_MS: u64 = 10_000; +const DEFAULT_KEYS_BASE_URL: &str = "https://hai.ai"; + +fn build_blocking_json_client(timeout_ms: u64) -> BindingResult { + BlockingClient::builder() + .timeout(std::time::Duration::from_millis(timeout_ms.max(1))) + .build() + .map_err(|e| { + BindingCoreError::network_failed(format!("Failed to build HTTP client: {}", e)) + }) +} + +fn is_loopback_host(host: &str) -> bool { + matches!( + host.trim().trim_matches(['[', ']']), + "localhost" | "127.0.0.1" | "::1" + ) +} + +fn validate_network_url(url: &Url, description: &str) -> BindingResult<()> { + match url.scheme() { + "https" => Ok(()), + "http" if url.host_str().is_some_and(is_loopback_host) => Ok(()), + "http" => Err(BindingCoreError::network_failed(format!( + "{} must use HTTPS (got '{}'). Only localhost URLs are allowed over HTTP for testing.", + description, url + ))), + other => Err(BindingCoreError::invalid_argument(format!( + "{} must use http or https (got scheme '{}')", + description, other + ))), + } +} + +fn content_type_header(response: &reqwest::blocking::Response) -> String { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string() +} + +fn parse_json_object_body( + body: &str, + invalid_json_message: String, + non_object_message: String, +) -> BindingResult { + let value: Value = serde_json::from_str(body) + .map_err(|e| BindingCoreError::validation(format!("{}: {}", invalid_json_message, e)))?; + if !value.is_object() { + return Err(BindingCoreError::validation(non_object_message)); + } + serde_json::to_string(&value).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize JSON response: {}", e)) + }) +} + +fn resolve_keys_base_url(override_base_url: Option<&str>) -> String { + if let Some(value) = override_base_url { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.trim_end_matches('/').to_string(); + } + } + + if let Ok(value) = std::env::var("JACS_KEYS_BASE_URL") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.trim_end_matches('/').to_string(); + } + } + + if let Ok(value) = std::env::var("HAI_KEYS_BASE_URL") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.trim_end_matches('/').to_string(); + } + } + DEFAULT_KEYS_BASE_URL.to_string() +} + +fn normalize_public_key_hash(public_key_hash: &str) -> BindingResult { + let trimmed = public_key_hash.trim(); + if trimmed.is_empty() { + return Err(BindingCoreError::invalid_argument( + "public_key_hash cannot be empty", + )); + } + if trimmed.starts_with("sha256:") { + Ok(trimmed.to_string()) + } else { + Ok(format!("sha256:{}", trimmed)) + } +} + +fn decode_public_key_base64(public_key_b64: &str) -> BindingResult> { + for engine in [ + &general_purpose::STANDARD, + &general_purpose::STANDARD_NO_PAD, + &general_purpose::URL_SAFE, + &general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(decoded) = engine.decode(public_key_b64) { + return Ok(decoded); + } + } + + Err(BindingCoreError::invalid_argument( + "Public key must be valid base64 or base64url text.", + )) +} + +fn build_jwk_set_from_public_key_bytes( + public_key: &[u8], + key_algorithm: &str, + key_id: &str, +) -> BindingResult { + let normalized_algorithm = key_algorithm.trim().to_ascii_lowercase(); + + if normalized_algorithm.contains("ed25519") + || (normalized_algorithm.is_empty() && public_key.len() == 32) + { + if public_key.len() != 32 { + return Err(BindingCoreError::invalid_argument(format!( + "Ed25519 public key must be 32 bytes, got {} bytes.", + public_key.len() + ))); + } + + return Ok(json!({ + "keys": [{ + "kty": "OKP", + "crv": "Ed25519", + "x": general_purpose::URL_SAFE_NO_PAD.encode(public_key), + "kid": key_id, + "use": "sig", + "alg": "EdDSA", + }] + })); + } + + if normalized_algorithm.contains("rsa") || normalized_algorithm.is_empty() { + use rsa::traits::PublicKeyParts; + use rsa::{RsaPublicKey, pkcs1::DecodeRsaPublicKey, pkcs8::DecodePublicKey}; + + let rsa_key = if let Ok(key) = RsaPublicKey::from_pkcs1_der(public_key) { + key + } else if let Ok(key) = RsaPublicKey::from_public_key_der(public_key) { + key + } else if let Ok(pem) = std::str::from_utf8(public_key) { + match RsaPublicKey::from_public_key_pem(pem) { + Ok(key) => key, + Err(e) if normalized_algorithm.contains("rsa") => { + return Err(BindingCoreError::invalid_argument(format!( + "Failed to parse RSA public key for JWK export: {}", + e + ))); + } + Err(_) => return Ok(json!({ "keys": [] })), + } + } else if normalized_algorithm.contains("rsa") { + return Err(BindingCoreError::invalid_argument( + "Failed to parse RSA public key for JWK export.", + )); + } else { + return Ok(json!({ "keys": [] })); + }; + + return Ok(json!({ + "keys": [{ + "kty": "RSA", + "kid": key_id, + "alg": "RS256", + "use": "sig", + "n": general_purpose::URL_SAFE_NO_PAD.encode(rsa_key.n().to_bytes_be()), + "e": general_purpose::URL_SAFE_NO_PAD.encode(rsa_key.e().to_bytes_be()), + }] + })); + } + + Ok(json!({ "keys": [] })) +} + +fn resolve_password_context( + config_path: Option<&str>, + key_directory: Option<&str>, +) -> BindingResult<(PathBuf, Option)> { + let mut agent_id = None; + + if let Some(config_path) = config_path { + let resolved_config_path = resolve_path_from_cwd(config_path)?; + if resolved_config_path.exists() { + let config = Config::from_file(resolved_config_path.to_string_lossy().as_ref()) + .map_err(|e| { + BindingCoreError::agent_load(format!( + "Failed to load config from {}: {}", + resolved_config_path.display(), + e + )) + })?; + let configured_key_dir = config + .jacs_key_directory() + .as_deref() + .unwrap_or("./jacs_keys"); + let resolved_key_dir = + resolve_relative_to_config(&resolved_config_path, configured_key_dir); + agent_id = config.jacs_agent_id_and_version().clone(); + + if let Some(key_directory) = key_directory { + return Ok((resolve_path_from_cwd(key_directory)?, agent_id)); + } + return Ok((resolved_key_dir, agent_id)); + } + + if let Some(key_directory) = key_directory { + return Ok((resolve_path_from_cwd(key_directory)?, None)); + } + + return Ok(( + resolve_relative_to_config(&resolved_config_path, "./jacs_keys"), + None, + )); + } + + if let Some(key_directory) = key_directory { + return Ok((resolve_path_from_cwd(key_directory)?, None)); + } + + Ok((resolve_path_from_cwd("./jacs_keys")?, agent_id)) +} + +fn generate_private_key_password_value() -> String { + use rand::Rng; + + const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; + const DIGITS: &[u8] = b"0123456789"; + const SPECIAL: &[u8] = b"!@#$%^&*()-_=+"; + const ALL: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+"; + + let mut rng = rand::rng(); + let mut password = String::with_capacity(32); + + password.push(UPPER[rng.random_range(0..UPPER.len())] as char); + password.push(LOWER[rng.random_range(0..LOWER.len())] as char); + password.push(DIGITS[rng.random_range(0..DIGITS.len())] as char); + password.push(SPECIAL[rng.random_range(0..SPECIAL.len())] as char); + + for _ in 4..32 { + password.push(ALL[rng.random_range(0..ALL.len())] as char); + } + + password +} + +fn persist_password_file(key_directory: &Path, password: &str) -> BindingResult<()> { + fs::create_dir_all(key_directory).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to create key directory {}: {}", + key_directory.display(), + e + )) + })?; + + let password_path = key_directory.join(".jacs_password"); + fs::write(&password_path, password).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to write password file {}: {}", + password_path.display(), + e + )) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let permissions = std::fs::Permissions::from_mode(0o600); + fs::set_permissions(&password_path, permissions).map_err(|e| { + BindingCoreError::generic(format!( + "Failed to set password file permissions on {}: {}", + password_path.display(), + e + )) + })?; + } + + Ok(()) +} + fn is_editable_level(level: &str) -> bool { matches!(level, "artifact" | "config") } @@ -2335,6 +2693,286 @@ pub fn hash_string(data: &str) -> String { jacs_hash_string(&data.to_string()) } +/// Hash a base64-encoded public key using Rust-owned public-key hashing rules. +pub fn hash_public_key_base64(public_key_b64: &str) -> BindingResult { + let public_key = decode_public_key_base64(public_key_b64)?; + Ok(jacs::crypt::hash::hash_public_key(&public_key)) +} + +/// Build a JWK set from a base64-encoded public key using Rust-owned parsing rules. +pub fn build_jwk_set_from_public_key( + public_key_b64: &str, + key_algorithm: &str, + key_id: &str, +) -> BindingResult { + let public_key = decode_public_key_base64(public_key_b64)?; + let jwk_set = build_jwk_set_from_public_key_bytes(&public_key, key_algorithm, key_id)?; + serde_json::to_string(&jwk_set).map_err(|e| { + BindingCoreError::serialization_failed(format!("Failed to serialize JWK set: {}", e)) + }) +} + +/// Enforce the Rust-owned network access policy for a named capability. +pub fn ensure_network_access(capability: &str) -> BindingResult<()> { + let capability = jacs::config::NetworkCapability::from_str(capability) + .map_err(BindingCoreError::invalid_argument)?; + jacs::config::ensure_network_access(capability) + .map_err(|e| BindingCoreError::network_failed(e.to_string())) +} + +/// Fetch an A2A Agent Card JSON object using Rust-owned network policy and HTTP behavior. +pub fn fetch_agent_card(base_url: &str, timeout_ms: Option) -> BindingResult { + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return Err(BindingCoreError::invalid_argument( + "Agent base URL cannot be empty", + )); + } + + let card_url = format!( + "{}/.well-known/agent-card.json", + trimmed.trim_end_matches('/') + ); + let parsed_url = Url::parse(&card_url).map_err(|e| { + BindingCoreError::invalid_argument(format!("Invalid agent URL '{}': {}", base_url, e)) + })?; + validate_network_url(&parsed_url, "Agent Card URL")?; + jacs::config::ensure_network_access(jacs::config::NetworkCapability::AgentCardFetch) + .map_err(|e| BindingCoreError::network_failed(e.to_string()))?; + + let client = build_blocking_json_client(timeout_ms.unwrap_or(DEFAULT_NETWORK_TIMEOUT_MS))?; + let response = client + .get(parsed_url.clone()) + .header(ACCEPT, "application/json") + .send() + .map_err(|e| { + if e.is_timeout() { + BindingCoreError::network_failed(format!( + "Agent discovery timed out: {}", + parsed_url + )) + } else { + BindingCoreError::network_failed(format!( + "Agent unreachable: {} ({})", + parsed_url, e + )) + } + })?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(BindingCoreError::network_failed(format!( + "Agent card not found (404): {}", + parsed_url + ))); + } + + if !response.status().is_success() { + return Err(BindingCoreError::network_failed(format!( + "Agent card request failed (HTTP {}): {}", + response.status(), + parsed_url + ))); + } + + let content_type = content_type_header(&response); + if !content_type.is_empty() && !content_type.to_ascii_lowercase().contains("json") { + return Err(BindingCoreError::validation(format!( + "Agent card response is not JSON (content-type: {}): {}", + content_type, parsed_url + ))); + } + + let body = response.text().map_err(|e| { + BindingCoreError::network_failed(format!( + "Failed to read Agent Card response from {}: {}", + parsed_url, e + )) + })?; + + parse_json_object_body( + &body, + format!("Agent card is not valid JSON: {}", parsed_url), + format!("Agent card at {} is not a JSON object", parsed_url), + ) +} + +/// Fetch a remote key lookup JSON object using Rust-owned network policy and HTTP behavior. +pub fn fetch_remote_key_lookup( + base_url: Option<&str>, + jacs_id: Option<&str>, + version: Option<&str>, + public_key_hash: Option<&str>, + timeout_ms: Option, +) -> BindingResult { + let resolved_base_url = resolve_keys_base_url(base_url); + let mut parsed_url = Url::parse(&resolved_base_url).map_err(|e| { + BindingCoreError::invalid_argument(format!( + "Invalid JACS key base URL '{}': {}", + resolved_base_url, e + )) + })?; + validate_network_url(&parsed_url, "JACS key lookup base URL")?; + + { + let mut segments = parsed_url.path_segments_mut().map_err(|_| { + BindingCoreError::invalid_argument(format!( + "Invalid JACS key base URL '{}': cannot append path segments", + resolved_base_url + )) + })?; + + if let Some(hash) = public_key_hash + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let normalized_hash = normalize_public_key_hash(hash)?; + segments.extend(["jacs", "v1", "keys", "by-hash"]); + segments.push(&normalized_hash); + } else { + let agent_id = jacs_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + BindingCoreError::invalid_argument( + "fetch_remote_key_lookup requires jacs_id or public_key_hash", + ) + })?; + let resolved_version = version + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("latest"); + segments.extend(["jacs", "v1", "agents"]); + segments.push(agent_id); + segments.push("keys"); + segments.push(resolved_version); + } + } + + jacs::config::ensure_network_access(jacs::config::NetworkCapability::RemoteKeyFetch) + .map_err(|e| BindingCoreError::network_failed(e.to_string()))?; + + let client = build_blocking_json_client(timeout_ms.unwrap_or(DEFAULT_NETWORK_TIMEOUT_MS))?; + let response = client + .get(parsed_url.clone()) + .header(ACCEPT, "application/json") + .send() + .map_err(|e| { + if e.is_timeout() { + BindingCoreError::network_failed(format!( + "Remote key lookup timed out: {}", + parsed_url + )) + } else { + BindingCoreError::network_failed(format!( + "Failed to reach key lookup endpoint {}: {}", + parsed_url, e + )) + } + })?; + + let status = response.status(); + let content_type = content_type_header(&response); + let body = response.text().map_err(|e| { + BindingCoreError::network_failed(format!( + "Failed to read key lookup response from {}: {}", + parsed_url, e + )) + })?; + + if !status.is_success() { + let detail = if body.trim().is_empty() { + status.canonical_reason().unwrap_or("unknown error") + } else { + body.trim() + }; + return Err(BindingCoreError::network_failed(format!( + "HTTP {} from key lookup endpoint: {}", + status.as_u16(), + detail + ))); + } + + if !content_type.is_empty() && !content_type.to_ascii_lowercase().contains("json") { + return Err(BindingCoreError::validation(format!( + "Key lookup endpoint returned non-JSON response: {}", + parsed_url + ))); + } + + parse_json_object_body( + &body, + format!( + "Key lookup endpoint returned non-JSON response: {}", + parsed_url + ), + format!( + "Key lookup endpoint returned a non-object response: {}", + parsed_url + ), + ) +} + +/// Resolve a private-key password using Rust-owned env, keychain, and filesystem rules. +/// +/// Returns an empty string when no password source is available. +pub fn resolve_private_key_password( + config_path: Option<&str>, + key_directory: Option<&str>, + explicit_password: Option<&str>, +) -> BindingResult { + if let Some(password) = explicit_password { + if password.trim().is_empty() { + return Err(BindingCoreError::invalid_argument( + "Explicit password provided but empty or whitespace-only.", + )); + } + return Ok(password.to_string()); + } + + if let Ok(password) = std::env::var("JACS_PRIVATE_KEY_PASSWORD") { + if password.trim().is_empty() { + return Err(BindingCoreError::invalid_argument( + "JACS_PRIVATE_KEY_PASSWORD is set but empty or whitespace-only.", + )); + } + return Ok(password); + } + + let (resolved_key_directory, agent_id) = resolve_password_context(config_path, key_directory)?; + + match jacs::crypt::aes_encrypt::resolve_private_key_password(None, agent_id.as_deref()) { + Ok(password) => Ok(password), + Err(e) if missing_password_message(&e.to_string()) => Ok(read_password_file( + &resolved_key_directory.join(".jacs_password"), + )? + .unwrap_or_default()), + Err(e) => Err(BindingCoreError::generic(format!( + "Failed to resolve private key password: {}", + e + ))), + } +} + +/// Resolve an existing password for quickstart, or generate and optionally persist one in Rust. +pub fn quickstart_private_key_password( + config_path: Option<&str>, + key_directory: Option<&str>, +) -> BindingResult { + let existing = resolve_private_key_password(config_path, key_directory, None)?; + if !existing.is_empty() { + return Ok(existing); + } + + let password = generate_private_key_password_value(); + if truthy_env_var("JACS_SAVE_PASSWORD_FILE") { + let (resolved_key_directory, _agent_id) = + resolve_password_context(config_path, key_directory)?; + persist_password_file(&resolved_key_directory, &password)?; + } + + Ok(password) +} + /// Create a JACS configuration JSON string. pub fn create_config( jacs_use_security: Option, diff --git a/binding-core/src/simple_wrapper.rs b/binding-core/src/simple_wrapper.rs index bf20af2f..3dec18e9 100644 --- a/binding-core/src/simple_wrapper.rs +++ b/binding-core/src/simple_wrapper.rs @@ -302,6 +302,50 @@ impl SimpleAgentWrapper { .map_err(|e| BindingCoreError::signing_failed(format!("Sign file failed: {}", e)))?; Ok(signed.raw) } + + // ========================================================================= + // Format Conversion (stateless -- no agent lock needed) + // ========================================================================= + + /// Convert a JSON string to YAML. + pub fn to_yaml(&self, json_str: &str) -> BindingResult { + jacs::convert::jacs_to_yaml(json_str).map_err(|e| { + BindingCoreError::new( + crate::ErrorKind::SerializationFailed, + format!("to_yaml failed: {}", e), + ) + }) + } + + /// Convert a YAML string to pretty-printed JSON. + pub fn from_yaml(&self, yaml_str: &str) -> BindingResult { + jacs::convert::yaml_to_jacs(yaml_str).map_err(|e| { + BindingCoreError::new( + crate::ErrorKind::SerializationFailed, + format!("from_yaml failed: {}", e), + ) + }) + } + + /// Convert a JSON string to a self-contained HTML document. + pub fn to_html(&self, json_str: &str) -> BindingResult { + jacs::convert::jacs_to_html(json_str).map_err(|e| { + BindingCoreError::new( + crate::ErrorKind::SerializationFailed, + format!("to_html failed: {}", e), + ) + }) + } + + /// Extract JSON from an HTML document produced by `to_html`. + pub fn from_html(&self, html_str: &str) -> BindingResult { + jacs::convert::html_to_jacs(html_str).map_err(|e| { + BindingCoreError::new( + crate::ErrorKind::SerializationFailed, + format!("from_html failed: {}", e), + ) + }) + } } // ============================================================================= @@ -317,3 +361,127 @@ pub fn sign_message_json(wrapper: &SimpleAgentWrapper, data_json: &str) -> Bindi pub fn verify_json(wrapper: &SimpleAgentWrapper, signed_document: &str) -> BindingResult { wrapper.verify_json(signed_document) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a wrapper for conversion tests. Conversion methods are stateless + /// so we only need a default wrapper (no agent loaded). + fn test_wrapper() -> SimpleAgentWrapper { + let (wrapper, _info) = + SimpleAgentWrapper::ephemeral(Some("ed25519")).expect("ephemeral agent"); + wrapper + } + + #[test] + fn to_yaml_valid_json_succeeds() { + let wrapper = test_wrapper(); + let result = wrapper.to_yaml(r#"{"key": "value"}"#); + assert!(result.is_ok(), "to_yaml should succeed for valid JSON"); + let yaml = result.unwrap(); + assert!(yaml.contains("key"), "YAML should contain 'key'"); + assert!(yaml.contains("value"), "YAML should contain 'value'"); + } + + #[test] + fn from_yaml_valid_yaml_succeeds() { + let wrapper = test_wrapper(); + let result = wrapper.from_yaml("key: value\n"); + assert!(result.is_ok(), "from_yaml should succeed for valid YAML"); + let json = result.unwrap(); + assert!(json.contains("\"key\""), "JSON should contain key"); + assert!(json.contains("\"value\""), "JSON should contain value"); + } + + #[test] + fn to_html_valid_json_succeeds() { + let wrapper = test_wrapper(); + let result = wrapper.to_html(r#"{"key": "value"}"#); + assert!(result.is_ok(), "to_html should succeed for valid JSON"); + let html = result.unwrap(); + assert!(html.contains(""), "HTML should have DOCTYPE"); + assert!( + html.contains(r#"id="jacs-data">"#), + "HTML should have jacs-data script tag" + ); + } + + #[test] + fn from_html_valid_html_succeeds() { + let wrapper = test_wrapper(); + let json = r#"{"key": "value"}"#; + let html = wrapper.to_html(json).unwrap(); + let result = wrapper.from_html(&html); + assert!(result.is_ok(), "from_html should succeed for valid HTML"); + assert_eq!(result.unwrap(), json, "Extracted JSON should match input"); + } + + #[test] + fn yaml_round_trip_preserves_content() { + let wrapper = test_wrapper(); + let json = r#"{"hello": "world", "count": 42}"#; + let yaml = wrapper.to_yaml(json).unwrap(); + let back = wrapper.from_yaml(&yaml).unwrap(); + let original: serde_json::Value = serde_json::from_str(json).unwrap(); + let reconstituted: serde_json::Value = serde_json::from_str(&back).unwrap(); + assert_eq!( + original, reconstituted, + "YAML round-trip should preserve content" + ); + } + + #[test] + fn html_round_trip_preserves_content() { + let wrapper = test_wrapper(); + let json = r#"{"hello": "world", "count": 42}"#; + let html = wrapper.to_html(json).unwrap(); + let back = wrapper.from_html(&html).unwrap(); + assert_eq!(back, json, "HTML round-trip should preserve exact JSON"); + } + + #[test] + fn to_yaml_invalid_json_returns_serialization_failed() { + let wrapper = test_wrapper(); + let result = wrapper.to_yaml("{not valid json}"); + assert!(result.is_err(), "to_yaml should fail for invalid JSON"); + let err = result.unwrap_err(); + assert_eq!( + err.kind, + crate::ErrorKind::SerializationFailed, + "Error should be SerializationFailed, got: {:?}", + err.kind + ); + } + + #[test] + fn from_yaml_invalid_yaml_returns_serialization_failed() { + let wrapper = test_wrapper(); + let result = wrapper.from_yaml("{{{{ not yaml ::::"); + assert!(result.is_err(), "from_yaml should fail for invalid YAML"); + let err = result.unwrap_err(); + assert_eq!( + err.kind, + crate::ErrorKind::SerializationFailed, + "Error should be SerializationFailed, got: {:?}", + err.kind + ); + } + + #[test] + fn from_html_no_script_tag_returns_serialization_failed() { + let wrapper = test_wrapper(); + let result = wrapper.from_html("No jacs data here"); + assert!( + result.is_err(), + "from_html should fail without jacs-data tag" + ); + let err = result.unwrap_err(); + assert_eq!( + err.kind, + crate::ErrorKind::SerializationFailed, + "Error should be SerializationFailed, got: {:?}", + err.kind + ); + } +} diff --git a/jacs-cli/Cargo.toml b/jacs-cli/Cargo.toml index 6fa04086..c4acf0d0 100644 --- a/jacs-cli/Cargo.toml +++ b/jacs-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-cli" -version = "0.9.11" +version = "0.9.12" edition = "2024" rust-version = "1.93" description = "JACS CLI: command-line interface for JSON AI Communication Standard" @@ -23,8 +23,8 @@ attestation = ["jacs/attestation"] keychain = ["jacs/keychain"] [dependencies] -jacs = { version = "0.9.11", path = "../jacs" } -jacs-mcp = { version = "0.9.11", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } +jacs = { version = "0.9.12", path = "../jacs" } +jacs-mcp = { version = "0.9.12", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } clap = { version = "4.5.4", features = ["derive", "cargo"] } rpassword = "7.3.1" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/jacs-cli/src/main.rs b/jacs-cli/src/main.rs index b434eb79..7a24adf5 100644 --- a/jacs-cli/src/main.rs +++ b/jacs-cli/src/main.rs @@ -1159,6 +1159,41 @@ pub fn main() -> Result<(), Box> { .arg_required_else_help(true), ); + let matches = matches.subcommand( + Command::new("convert") + .about( + "Convert JACS documents between JSON, YAML, and HTML formats (no agent required)", + ) + .arg( + Arg::new("to") + .long("to") + .required(true) + .value_parser(["json", "yaml", "html"]) + .help("Target format: json, yaml, or html"), + ) + .arg( + Arg::new("from") + .long("from") + .value_parser(["json", "yaml", "html"]) + .help("Source format (auto-detected from extension if omitted)"), + ) + .arg( + Arg::new("file") + .short('f') + .long("file") + .required(true) + .value_parser(value_parser!(String)) + .help("Input file path (use '-' for stdin)"), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .value_parser(value_parser!(String)) + .help("Output file path (defaults to stdout)"), + ), + ); + let matches = matches.arg_required_else_help(true).get_matches(); match matches.subcommand() { @@ -2550,6 +2585,86 @@ pub fn main() -> Result<(), Box> { } } } + Some(("convert", convert_matches)) => { + use jacs::convert::{html_to_jacs, jacs_to_html, jacs_to_yaml, yaml_to_jacs}; + + let target_format = convert_matches.get_one::("to").unwrap(); + let source_format = convert_matches.get_one::("from"); + let file_path = convert_matches.get_one::("file").unwrap(); + let output_path = convert_matches.get_one::("output"); + + // Auto-detect source format from extension if not explicitly provided + let is_stdin = file_path == "-"; + let detected_format = if let Some(fmt) = source_format { + fmt.clone() + } else if is_stdin { + eprintln!( + "When reading from stdin (-f -), --from is required to specify the source format." + ); + process::exit(1); + } else { + let ext = std::path::Path::new(file_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "json" => "json".to_string(), + "yaml" | "yml" => "yaml".to_string(), + "html" | "htm" => "html".to_string(), + _ => { + eprintln!( + "Cannot auto-detect format for extension '{}'. Use --from to specify.", + ext + ); + process::exit(1); + } + } + }; + + // Read input (from file or stdin) + let input = if is_stdin { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf) + .map_err(|e| format!("Failed to read from stdin: {}", e))?; + buf + } else { + std::fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read '{}': {}", file_path, e))? + }; + + // Convert + let output = match (detected_format.as_str(), target_format.as_str()) { + ("json", "yaml") => jacs_to_yaml(&input).map_err(|e| format!("{}", e))?, + ("yaml", "json") => yaml_to_jacs(&input).map_err(|e| format!("{}", e))?, + ("json", "html") => jacs_to_html(&input).map_err(|e| format!("{}", e))?, + ("html", "json") => html_to_jacs(&input).map_err(|e| format!("{}", e))?, + ("yaml", "html") => { + let json = yaml_to_jacs(&input).map_err(|e| format!("{}", e))?; + jacs_to_html(&json).map_err(|e| format!("{}", e))? + } + ("html", "yaml") => { + let json = html_to_jacs(&input).map_err(|e| format!("{}", e))?; + jacs_to_yaml(&json).map_err(|e| format!("{}", e))? + } + (src, dst) if src == dst => { + // Same format -- just pass through + input + } + (src, dst) => { + eprintln!("Unsupported conversion: {} -> {}", src, dst); + process::exit(1); + } + }; + + // Write output + if let Some(out_path) = output_path { + std::fs::write(out_path, &output) + .map_err(|e| format!("Failed to write '{}': {}", out_path, e))?; + eprintln!("Written to {}", out_path); + } else { + print!("{}", output); + } + } Some(("init", init_matches)) => { let auto_yes = *init_matches.get_one::("yes").unwrap_or(&false); println!("--- Running Config Creation ---"); diff --git a/jacs-cli/tests/cli_convert_tests.rs b/jacs-cli/tests/cli_convert_tests.rs new file mode 100644 index 00000000..988cf5d3 --- /dev/null +++ b/jacs-cli/tests/cli_convert_tests.rs @@ -0,0 +1,190 @@ +//! CLI integration tests for `jacs convert` subcommand. +//! +//! Uses `assert_cmd` to invoke the `jacs` binary and test format conversion. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; +use tempfile::TempDir; + +fn fixtures_raw_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("jacs") + .join("tests") + .join("fixtures") + .join("raw") +} + +fn cmd() -> Command { + Command::cargo_bin("jacs").expect("jacs binary should exist") +} + +#[test] +fn cli_convert_json_to_yaml() { + let fixture = fixtures_raw_dir().join("favorite-fruit.json"); + + cmd() + .args(["convert", "--to", "yaml", "-f"]) + .arg(fixture.to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("favorite-snack")); +} + +#[test] +fn cli_convert_json_to_html() { + let fixture = fixtures_raw_dir().join("favorite-fruit.json"); + + cmd() + .args(["convert", "--to", "html", "-f"]) + .arg(fixture.to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::starts_with("")); +} + +#[test] +fn cli_convert_missing_file_returns_error() { + cmd() + .args(["convert", "--to", "yaml", "-f", "/nonexistent/file.json"]) + .assert() + .failure(); +} + +#[test] +fn cli_convert_output_to_file() { + let fixture = fixtures_raw_dir().join("favorite-fruit.json"); + let tmpdir = TempDir::new().unwrap(); + let output_path = tmpdir.path().join("output.yaml"); + + cmd() + .args(["convert", "--to", "yaml", "-f"]) + .arg(fixture.to_str().unwrap()) + .args(["-o"]) + .arg(output_path.to_str().unwrap()) + .assert() + .success(); + + // Verify the file was written and contains YAML content + let content = std::fs::read_to_string(&output_path).expect("output file should exist"); + assert!( + content.contains("favorite-snack"), + "Output file should contain YAML content" + ); +} + +#[test] +fn cli_convert_round_trip_json_yaml_json() { + let fixture = fixtures_raw_dir().join("favorite-fruit.json"); + let original = std::fs::read_to_string(&fixture).unwrap(); + + let tmpdir = TempDir::new().unwrap(); + let yaml_path = tmpdir.path().join("intermediate.yaml"); + let json_path = tmpdir.path().join("result.json"); + + // JSON -> YAML + cmd() + .args(["convert", "--to", "yaml", "-f"]) + .arg(fixture.to_str().unwrap()) + .args(["-o"]) + .arg(yaml_path.to_str().unwrap()) + .assert() + .success(); + + // YAML -> JSON + cmd() + .args(["convert", "--to", "json", "-f"]) + .arg(yaml_path.to_str().unwrap()) + .args(["-o"]) + .arg(json_path.to_str().unwrap()) + .assert() + .success(); + + // Compare canonical JSON + let result = std::fs::read_to_string(&json_path).unwrap(); + let original_value: serde_json::Value = serde_json::from_str(&original).unwrap(); + let result_value: serde_json::Value = serde_json::from_str(&result).unwrap(); + let original_canonical = jacs::protocol::canonicalize_json(&original_value); + let result_canonical = jacs::protocol::canonicalize_json(&result_value); + assert_eq!( + original_canonical, result_canonical, + "Round-trip should preserve canonical JSON" + ); +} + +#[test] +fn cli_convert_round_trip_json_html_json() { + let fixture = fixtures_raw_dir().join("favorite-fruit.json"); + let original = std::fs::read_to_string(&fixture).unwrap(); + + let tmpdir = TempDir::new().unwrap(); + let html_path = tmpdir.path().join("intermediate.html"); + let json_path = tmpdir.path().join("result.json"); + + // JSON -> HTML + cmd() + .args(["convert", "--to", "html", "-f"]) + .arg(fixture.to_str().unwrap()) + .args(["-o"]) + .arg(html_path.to_str().unwrap()) + .assert() + .success(); + + // HTML -> JSON + cmd() + .args(["convert", "--to", "json", "-f"]) + .arg(html_path.to_str().unwrap()) + .args(["-o"]) + .arg(json_path.to_str().unwrap()) + .assert() + .success(); + + // Compare -- HTML embeds exact JSON, so string comparison works + let result = std::fs::read_to_string(&json_path).unwrap(); + assert_eq!( + original.trim(), + result.trim(), + "HTML round-trip should preserve exact JSON" + ); +} + +#[test] +fn cli_convert_preserves_utf8() { + let fixture = fixtures_raw_dir().join("json-ld.json"); + if !fixture.exists() { + eprintln!("json-ld.json fixture not found; skipping UTF-8 test"); + return; + } + + let tmpdir = TempDir::new().unwrap(); + let yaml_path = tmpdir.path().join("ld.yaml"); + let json_path = tmpdir.path().join("ld.json"); + + // JSON -> YAML -> JSON + cmd() + .args(["convert", "--to", "yaml", "-f"]) + .arg(fixture.to_str().unwrap()) + .args(["-o"]) + .arg(yaml_path.to_str().unwrap()) + .assert() + .success(); + + cmd() + .args(["convert", "--to", "json", "-f"]) + .arg(yaml_path.to_str().unwrap()) + .args(["-o"]) + .arg(json_path.to_str().unwrap()) + .assert() + .success(); + + let original: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&fixture).unwrap()).unwrap(); + let result: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&json_path).unwrap()).unwrap(); + assert_eq!( + jacs::protocol::canonicalize_json(&original), + jacs::protocol::canonicalize_json(&result), + "UTF-8 content should survive CLI round-trip" + ); +} diff --git a/jacs-duckdb/Cargo.toml b/jacs-duckdb/Cargo.toml index 0f2dc53a..ed95b27b 100644 --- a/jacs-duckdb/Cargo.toml +++ b/jacs-duckdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-duckdb" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true description = "DuckDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "duckdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.11", path = "../jacs", default-features = false } +jacs = { version = "0.9.12", path = "../jacs", default-features = false } duckdb = { version = "1.4", features = ["bundled", "json"] } serde_json = "1.0" diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index f10bb989..b0603e16 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.9.11" +version = "0.9.12" edition = "2024" rust-version = "1.93" description = "MCP server for JACS: data provenance and cryptographic signing of agent state" @@ -45,8 +45,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } rmcp = { version = "0.12", features = ["client", "server", "transport-io", "transport-child-process", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"], optional = true } -jacs = { version = "0.9.11", path = "../jacs", default-features = true } -jacs-binding-core = { version = "0.9.11", path = "../binding-core", features = ["a2a"] } +jacs = { version = "0.9.12", path = "../jacs", default-features = true } +jacs-binding-core = { version = "0.9.12", path = "../binding-core", features = ["a2a"] } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" diff --git a/jacs-mcp/contract/jacs-mcp-contract.json b/jacs-mcp/contract/jacs-mcp-contract.json index b3fb3935..357a73b9 100644 --- a/jacs-mcp/contract/jacs-mcp-contract.json +++ b/jacs-mcp/contract/jacs-mcp-contract.json @@ -3,7 +3,7 @@ "server": { "name": "jacs-mcp", "title": "JACS MCP Server", - "version": "0.9.11", + "version": "0.9.12", "website_url": "https://humanassisted.github.io/JACS/", "instructions": "This MCP server provides data provenance and cryptographic signing for agent state files and agent-to-agent messaging. Agent state tools: jacs_sign_state (sign files), jacs_verify_state (verify integrity), jacs_load_state (load with verification), jacs_update_state (update and re-sign), jacs_list_state (list signed docs), jacs_adopt_state (adopt external files). Memory tools: jacs_memory_save (save a memory), jacs_memory_recall (search memories by query), jacs_memory_list (list all memories), jacs_memory_forget (soft-delete a memory), jacs_memory_update (update an existing memory). Messaging tools: jacs_message_send (create and sign a message), jacs_message_update (update and re-sign a message), jacs_message_agree (co-sign/agree to a message), jacs_message_receive (verify and extract a received message). Agent management: jacs_create_agent (create new agent with keys), jacs_reencrypt_key (rotate private key password). A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), jacs_verify_a2a_artifact (verify wrapped artifact), jacs_assess_a2a_agent (assess remote agent trust level). A2A discovery: jacs_export_agent_card (export Agent Card), jacs_generate_well_known (generate .well-known documents), jacs_export_agent (export full agent JSON). Trust store: jacs_trust_agent (add agent to trust store), jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), jacs_list_trusted_agents (list all trusted agent IDs), jacs_is_trusted (check if agent is trusted), jacs_get_trusted_agent (get trusted agent JSON). Attestation: jacs_attest_create (create signed attestation with claims), jacs_attest_verify (verify attestation, optionally with evidence checks), jacs_attest_lift (lift signed document into attestation), jacs_attest_export_dsse (export attestation as DSSE envelope). Security: jacs_audit (read-only security audit and health checks). Audit trail: jacs_audit_log (record events as signed audit entries), jacs_audit_query (search audit trail by action, target, time range), jacs_audit_export (export audit trail as signed bundle). Search: jacs_search (unified search across all signed documents)." }, diff --git a/jacs-postgresql/Cargo.toml b/jacs-postgresql/Cargo.toml index 19dd6ac0..0ed9fcd1 100644 --- a/jacs-postgresql/Cargo.toml +++ b/jacs-postgresql/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-postgresql" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true description = "PostgreSQL storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "postgresql", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.11", path = "../jacs", default-features = false } +jacs = { version = "0.9.12", path = "../jacs", default-features = false } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.0", features = ["rt-multi-thread"] } serde_json = "1.0" diff --git a/jacs-redb/Cargo.toml b/jacs-redb/Cargo.toml index 6e050bb2..896b093b 100644 --- a/jacs-redb/Cargo.toml +++ b/jacs-redb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-redb" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true readme.workspace = true @@ -13,7 +13,7 @@ categories.workspace = true description = "Redb (pure-Rust embedded KV) storage backend for JACS documents" [dependencies] -jacs = { version = "0.9.11", path = "../jacs", default-features = false } +jacs = { version = "0.9.12", path = "../jacs", default-features = false } redb = "3.1" chrono = "0.4.40" serde_json = "1.0" diff --git a/jacs-surrealdb/Cargo.toml b/jacs-surrealdb/Cargo.toml index ee47540e..ba765283 100644 --- a/jacs-surrealdb/Cargo.toml +++ b/jacs-surrealdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-surrealdb" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version.workspace = true description = "SurrealDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "surrealdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.11", path = "../jacs", default-features = false } +jacs = { version = "0.9.12", path = "../jacs", default-features = false } surrealdb = { version = "3.0.2", default-features = false, features = ["kv-mem"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index e874f8a1..88f43a2b 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.9.11" +version = "0.9.12" edition = "2024" rust-version = "1.93" resolver = "3" @@ -95,6 +95,7 @@ hickory-resolver = { version = "0.24", features = ["dnssec-ring"] } hkdf = "0.12" zeroize = "1.8" pbkdf2 = { version = "0.12", features = ["simple"] } +serde_yaml_ng = "0.10" tracing = "0.1" diff --git a/jacs/src/a2a/trust.rs b/jacs/src/a2a/trust.rs index dd41320f..895ba252 100644 --- a/jacs/src/a2a/trust.rs +++ b/jacs/src/a2a/trust.rs @@ -24,6 +24,7 @@ use crate::a2a::extension::verify_agent_card_jws; use crate::a2a::keys::Jwk; use crate::a2a::{AgentCard, JACS_EXTENSION_URI}; use crate::agent::Agent; +use crate::config::{NetworkCapability, ensure_network_access}; use crate::trust; #[cfg(not(target_arch = "wasm32"))] use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -203,6 +204,34 @@ fn agent_card_origin(card: &AgentCard) -> Result { #[cfg(not(target_arch = "wasm32"))] fn fetch_jwks(card: &AgentCard) -> Result, String> { let jwks_url = format!("{}/.well-known/jwks.json", agent_card_origin(card)?); + + #[cfg(test)] + if agent_card_origin(card) + .ok() + .as_deref() + .is_some_and(|origin| origin == "https://local-jwks.invalid") + { + if let Ok(jwks_json) = std::env::var("JACS_TEST_JWKS_JSON") { + let value = serde_json::from_str::(&jwks_json).map_err(|e| { + format!( + "Failed to parse test JWKS JSON from JACS_TEST_JWKS_JSON: {}", + e + ) + })?; + let keys_value = value + .get("keys") + .ok_or_else(|| "Test JWKS JSON did not include a 'keys' array".to_string())? + .clone(); + return serde_json::from_value::>(keys_value).map_err(|e| { + format!( + "Failed to decode test JWKS keys from JACS_TEST_JWKS_JSON: {}", + e + ) + }); + } + } + + ensure_network_access(NetworkCapability::JwksFetch).map_err(|e| e.to_string())?; let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(5)) .build() @@ -445,9 +474,6 @@ mod tests { A2A_PROTOCOL_VERSION, AgentCapabilities, AgentCard, AgentExtension, AgentInterface, }; use serde_json::json; - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::thread; /// Create a minimal Agent Card for testing. fn make_card( @@ -785,33 +811,7 @@ mod tests { assert_eq!(assessment.agent_id, None); } - fn serve_jwks_once(body: String) -> (String, thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind localhost listener"); - let addr = listener.local_addr().expect("listener addr"); - let handle = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept request"); - let mut buf = [0_u8; 4096]; - let read = stream.read(&mut buf).expect("read request"); - let request = String::from_utf8_lossy(&buf[..read]); - let (status, payload) = if request.starts_with("GET /.well-known/jwks.json ") { - ("200 OK", body) - } else { - ("404 Not Found", "{\"error\":\"not found\"}".to_string()) - }; - let response = format!( - "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - status, - payload.len(), - payload - ); - stream - .write_all(response.as_bytes()) - .expect("write response"); - }); - (format!("http://{}", addr), handle) - } - - fn make_signed_card_with_local_jwks() -> (AgentCard, thread::JoinHandle<()>) { + fn make_signed_card_with_test_jwks() -> AgentCard { let agent_id = "550e8400-e29b-41d4-a716-446655440030"; let version = "550e8400-e29b-41d4-a716-446655440031"; let mut card = make_card("signed-jacs-agent", true, Some(agent_id), Some(version)); @@ -819,20 +819,24 @@ mod tests { crate::crypt::ringwrapper::generate_keys().expect("generate ed25519 keys"); let jwk = export_as_jwk(&public_key, "ring-Ed25519", agent_id).expect("export jwk"); let jwks = create_jwk_set(vec![jwk]).to_string(); - let (origin, handle) = serve_jwks_once(jwks); - card.supported_interfaces[0].url = format!("{}/agent/{}", origin, agent_id); + unsafe { + std::env::set_var("JACS_TEST_JWKS_JSON", &jwks); + } + card.supported_interfaces[0].url = format!("https://local-jwks.invalid/agent/{}", agent_id); let jws = sign_agent_card_jws(&card, &private_key, "ring-Ed25519", agent_id).expect("sign card"); - let signed_card = embed_signature_in_agent_card(&card, &jws, Some(agent_id)); - (signed_card, handle) + embed_signature_in_agent_card(&card, &jws, Some(agent_id)) } #[test] + #[serial_test::serial(jacs_env)] fn test_verified_policy_accepts_signed_jacs_agent() { let agent = test_agent(); - let (card, server_handle) = make_signed_card_with_local_jwks(); + let card = make_signed_card_with_test_jwks(); let result = assess_a2a_agent(&agent, &card, A2ATrustPolicy::Verified); - server_handle.join().expect("join jwks server"); + unsafe { + std::env::remove_var("JACS_TEST_JWKS_JSON"); + } assert!( result.allowed, diff --git a/jacs/src/agent/loaders.rs b/jacs/src/agent/loaders.rs index 01837d3f..9447c191 100644 --- a/jacs/src/agent/loaders.rs +++ b/jacs/src/agent/loaders.rs @@ -1,6 +1,7 @@ use crate::agent::Agent; use crate::agent::boilerplate::BoilerPlate; use crate::agent::security::SecurityTraits; +use crate::config::{NetworkCapability, ensure_network_access}; // encrypt/decrypt now use _with_password variants via agent-scoped resolution use crate::error::JacsError; use crate::rate_limit::RateLimiter; @@ -969,6 +970,8 @@ pub fn fetch_remote_public_key(agent_id: &str, version: &str) -> Result &'static str { + match self { + NetworkCapability::DnsLookup => "JACS_ALLOW_DNS", + NetworkCapability::RemoteKeyFetch => "JACS_ALLOW_REMOTE_KEY_FETCH", + NetworkCapability::RegistryLookup => "JACS_ALLOW_REGISTRY", + NetworkCapability::RemoteSchemaFetch => "JACS_ALLOW_REMOTE_SCHEMA_FETCH", + NetworkCapability::JwksFetch => "JACS_ALLOW_JWKS_FETCH", + NetworkCapability::AgentCardFetch => "JACS_ALLOW_AGENT_CARD_FETCH", + } + } + + pub fn description(self) -> &'static str { + match self { + NetworkCapability::DnsLookup => "DNS lookup", + NetworkCapability::RemoteKeyFetch => "remote public-key fetch", + NetworkCapability::RegistryLookup => "registry lookup", + NetworkCapability::RemoteSchemaFetch => "remote schema fetch", + NetworkCapability::JwksFetch => "JWKS fetch", + NetworkCapability::AgentCardFetch => "A2A Agent Card fetch", + } + } +} + +impl fmt::Display for NetworkCapability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.description()) + } +} + +impl FromStr for NetworkCapability { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "dns" | "dns_lookup" => Ok(NetworkCapability::DnsLookup), + "remote_key_fetch" | "key_fetch" | "public_key_fetch" | "registry_key_fetch" => { + Ok(NetworkCapability::RemoteKeyFetch) + } + "registry" | "registry_lookup" => Ok(NetworkCapability::RegistryLookup), + "schema" | "schema_fetch" | "remote_schema_fetch" => { + Ok(NetworkCapability::RemoteSchemaFetch) + } + "jwks" | "jwks_fetch" => Ok(NetworkCapability::JwksFetch), + "agent_card" | "agent_card_fetch" | "a2a_discovery" | "agent_discovery" => { + Ok(NetworkCapability::AgentCardFetch) + } + other => Err(format!( + "Unknown network capability '{}'. Valid values are: dns, remote_key_fetch, registry, remote_schema_fetch, jwks, agent_card_fetch", + other + )), + } + } +} + +fn env_var_truthy(key: &str) -> bool { + match get_env_var(key, false) { + Ok(Some(value)) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + _ => false, + } +} + +/// Returns whether the requested network capability is explicitly allowed. +pub fn is_network_access_allowed(capability: NetworkCapability) -> bool { + env_var_truthy("JACS_ALLOW_NETWORK") || env_var_truthy(capability.env_var()) +} + +/// Enforce explicit opt-in before any network access occurs. +pub fn ensure_network_access(capability: NetworkCapability) -> Result<(), JacsError> { + if is_network_access_allowed(capability) { + return Ok(()); + } + + Err(JacsError::ConfigError(format!( + "{} is disabled by default. Set {}=true to allow it, or JACS_ALLOW_NETWORK=true to allow all JACS network access.", + capability.description(), + capability.env_var(), + ))) +} + impl FromStr for KeyResolutionSource { type Err = String; @@ -165,6 +263,13 @@ Environment Variables Supported: - JACS_DNS_STRICT - JACS_DNS_REQUIRED - JACS_KEY_RESOLUTION (comma-separated: local,dns,registry - controls key lookup order) +- JACS_ALLOW_NETWORK +- JACS_ALLOW_DNS +- JACS_ALLOW_REMOTE_KEY_FETCH +- JACS_ALLOW_REGISTRY +- JACS_ALLOW_REMOTE_SCHEMA_FETCH +- JACS_ALLOW_JWKS_FETCH +- JACS_ALLOW_AGENT_CARD_FETCH Usage: ```rust @@ -1447,9 +1552,19 @@ mod tests { "JACS_DNS_VALIDATE", "JACS_DNS_STRICT", "JACS_DNS_REQUIRED", + "JACS_ALLOW_NETWORK", + "JACS_ALLOW_DNS", + "JACS_ALLOW_REMOTE_KEY_FETCH", + "JACS_ALLOW_REGISTRY", + "JACS_ALLOW_REMOTE_SCHEMA_FETCH", + "JACS_ALLOW_JWKS_FETCH", + "JACS_ALLOW_AGENT_CARD_FETCH", ]; for var in vars { let _ = clear_env_var(var); + unsafe { + std::env::remove_var(var); + } } } @@ -2222,4 +2337,73 @@ mod tests { let _ = clear_env_var("JACS_KEY_RESOLUTION"); } + + #[test] + fn test_network_capability_from_str() { + assert_eq!( + NetworkCapability::from_str("dns").unwrap(), + NetworkCapability::DnsLookup + ); + assert_eq!( + NetworkCapability::from_str("public_key_fetch").unwrap(), + NetworkCapability::RemoteKeyFetch + ); + assert_eq!( + NetworkCapability::from_str("registry").unwrap(), + NetworkCapability::RegistryLookup + ); + assert_eq!( + NetworkCapability::from_str("schema_fetch").unwrap(), + NetworkCapability::RemoteSchemaFetch + ); + assert_eq!( + NetworkCapability::from_str("jwks").unwrap(), + NetworkCapability::JwksFetch + ); + assert_eq!( + NetworkCapability::from_str("agent_card_fetch").unwrap(), + NetworkCapability::AgentCardFetch + ); + assert!(NetworkCapability::from_str("unknown").is_err()); + } + + #[test] + #[serial(jacs_env)] + fn test_network_access_defaults_to_disabled() { + clear_jacs_env_vars(); + + assert!(!is_network_access_allowed(NetworkCapability::DnsLookup)); + let err = ensure_network_access(NetworkCapability::DnsLookup).unwrap_err(); + assert!(err.to_string().contains("JACS_ALLOW_DNS")); + } + + #[test] + #[serial(jacs_env)] + fn test_network_access_capability_override() { + clear_jacs_env_vars(); + set_env_var("JACS_ALLOW_JWKS_FETCH", "true").unwrap(); + + assert!(is_network_access_allowed(NetworkCapability::JwksFetch)); + assert!(ensure_network_access(NetworkCapability::JwksFetch).is_ok()); + assert!(!is_network_access_allowed( + NetworkCapability::RemoteKeyFetch + )); + + let _ = clear_env_var("JACS_ALLOW_JWKS_FETCH"); + } + + #[test] + #[serial(jacs_env)] + fn test_network_access_global_override() { + clear_jacs_env_vars(); + set_env_var("JACS_ALLOW_NETWORK", "true").unwrap(); + + assert!(is_network_access_allowed(NetworkCapability::DnsLookup)); + assert!(is_network_access_allowed( + NetworkCapability::RemoteSchemaFetch + )); + assert!(ensure_network_access(NetworkCapability::AgentCardFetch).is_ok()); + + let _ = clear_env_var("JACS_ALLOW_NETWORK"); + } } diff --git a/jacs/src/convert/html.rs b/jacs/src/convert/html.rs new file mode 100644 index 00000000..1df9bcc7 --- /dev/null +++ b/jacs/src/convert/html.rs @@ -0,0 +1,644 @@ +//! JSON <-> HTML conversion for JACS documents. +//! +//! Provides conversion between JSON and self-contained HTML documents. The +//! HTML embeds the exact JSON in a ` + +"#, + title = title, + metadata_section = metadata_section, + content_section = content_section, + files_section = files_section, + // Escape all "" (case-insensitive) in the JSON would prematurely close the + // "; + + let start = html_str.find(open_tag).ok_or_else(|| { + JacsError::conversion( + "HTML", + "JSON", + "no tag after our opening tag + let json_end = html_str[json_start..].find(close_tag).ok_or_else(|| { + JacsError::conversion( + "HTML", + "JSON", + "found opening jacs-data script tag but no closing tag", + ) + })?; + + let json_str = &html_str[json_start..json_start + json_end]; + + // Reverse the " "<\/" escaping applied by jacs_to_html to prevent + // script injection. This restores the original JSON byte-for-byte. + let json_str = json_str.replace(r"<\/", " String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_contains_doctype() { + let json = r#"{"hello": "world"}"#; + let html = jacs_to_html(json).unwrap(); + assert!( + html.starts_with(""), + "HTML should start with DOCTYPE" + ); + } + + #[test] + fn html_contains_embedded_json() { + let json = r#"{"hello": "world"}"#; + let html = jacs_to_html(json).unwrap(); + assert!( + html.contains(r#""#; + let result = html_to_jacs(html); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("malformed"), + "Should mention malformed JSON: {}", + msg + ); + } + + #[test] + fn html_to_jacs_from_non_html_returns_error() { + let result = html_to_jacs("just plain text, not html at all"); + assert!(result.is_err()); + } + + #[test] + fn html_output_is_self_contained() { + let json = r#"{"hello": "world"}"#; + let html = jacs_to_html(json).unwrap(); + assert!( + !html.contains(r#""), "HTML should have "); + assert!(html.contains(""), "HTML should have "); + assert!(html.contains(""), "HTML should have "); + assert!(html.contains(""), "HTML should have "); + } + + #[test] + fn html_has_charset_utf8() { + let json = r#"{"test": true}"#; + let html = jacs_to_html(json).unwrap(); + assert!( + html.contains("charset=\"UTF-8\"") || html.contains("charset=UTF-8"), + "HTML should declare UTF-8 charset" + ); + } + + #[test] + fn html_inline_css_no_external_links() { + let json = r#"{"test": true}"#; + let html = jacs_to_html(json).unwrap(); + assert!( + html.contains("