diff --git a/LINES_OF_CODE.md b/LINES_OF_CODE.md index 607ff800c..0806b9d2f 100644 --- a/LINES_OF_CODE.md +++ b/LINES_OF_CODE.md @@ -1,13 +1,13 @@ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Language Files Lines Code Comments Blanks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Go 11 3332 2464 397 471 - Python 151 32677 25473 1560 5644 - TypeScript 9 2407 1352 842 213 + Go 13 4292 3144 538 610 + Python 154 33765 26371 1613 5781 + TypeScript 9 2467 1365 889 213 ───────────────────────────────────────────────────────────────────────────────── - Rust 337 85972 70992 4926 10054 - |- Markdown 238 23660 565 17413 5682 - (Total) 109632 71557 22339 15736 + Rust 338 88907 73300 5155 10452 + |- Markdown 241 24447 635 17946 5866 + (Total) 113354 73935 23101 16318 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Total 508 148048 100846 25138 22064 + Total 514 153878 104815 26141 22922 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/binding-core/src/hai.rs b/binding-core/src/hai.rs index 1c3f30928..c86956645 100644 --- a/binding-core/src/hai.rs +++ b/binding-core/src/hai.rs @@ -399,7 +399,7 @@ impl HaiClient { .get_agent_json() .map_err(|e| HaiError::RegistrationFailed(e.to_string()))?; - let url = format!("{}/v1/agents/register", self.endpoint); + let url = format!("{}/api/v1/agents/register", self.endpoint); let request = RegisterRequest { agent_json }; @@ -470,7 +470,7 @@ impl HaiClient { .ok_or_else(|| HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()))? .to_string(); - let url = format!("{}/v1/agents/{}/status", self.endpoint, agent_id); + let url = format!("{}/api/v1/agents/{}/status", self.endpoint, agent_id); let response = self .client @@ -556,7 +556,7 @@ impl HaiClient { .ok_or_else(|| HaiError::InvalidResponse("Agent JSON missing jacsId field".to_string()))? .to_string(); - let url = format!("{}/v1/benchmarks/run", self.endpoint); + let url = format!("{}/api/v1/benchmarks/run", self.endpoint); let request = BenchmarkRequest { agent_id, diff --git a/binding-core/src/lib.rs b/binding-core/src/lib.rs index 7f08644a8..602f8875f 100644 --- a/binding-core/src/lib.rs +++ b/binding-core/src/lib.rs @@ -53,6 +53,10 @@ pub enum ErrorKind { InvalidArgument, /// Trust store operation failed TrustFailed, + /// Network operation failed + NetworkFailed, + /// Key not found + KeyNotFound, /// Generic failure Generic, } @@ -105,6 +109,14 @@ impl BindingCoreError { Self::new(ErrorKind::TrustFailed, message) } + pub fn network_failed(message: impl Into) -> Self { + Self::new(ErrorKind::NetworkFailed, message) + } + + pub fn key_not_found(message: impl Into) -> Self { + Self::new(ErrorKind::KeyNotFound, message) + } + pub fn generic(message: impl Into) -> Self { Self::new(ErrorKind::Generic, message) } @@ -608,6 +620,106 @@ pub fn handle_config_create() -> BindingResult<()> { .map_err(|e| BindingCoreError::generic(e.to_string())) } +// ============================================================================= +// Remote Key Fetch Functions +// ============================================================================= + +/// Information about a public key fetched from HAI key service. +/// +/// This struct contains the public key data and metadata returned by +/// the HAI key distribution service. +#[derive(Debug, Clone)] +pub struct RemotePublicKeyInfo { + /// The raw public key bytes (DER encoded). + pub public_key: Vec, + /// The cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256"). + pub algorithm: String, + /// The hash of the public key (SHA-256). + pub public_key_hash: String, + /// The agent ID the key belongs to. + pub agent_id: String, + /// The version of the key. + pub version: String, +} + +/// Fetch a public key from HAI's key distribution service. +/// +/// This function retrieves the public key for a specific agent and version +/// from the HAI key distribution service. It is used to obtain trusted public +/// keys for verifying agent signatures without requiring local key storage. +/// +/// # Arguments +/// +/// * `agent_id` - The unique identifier of the agent whose key to fetch. +/// * `version` - The version of the agent's key to fetch. Use "latest" for +/// the most recent version. +/// +/// # Returns +/// +/// Returns `Ok(RemotePublicKeyInfo)` containing the public key, algorithm, and hash +/// on success. +/// +/// # Errors +/// +/// * `ErrorKind::KeyNotFound` - The agent or key version was not found (404). +/// * `ErrorKind::NetworkFailed` - Connection, timeout, or other HTTP errors. +/// * `ErrorKind::Generic` - The returned key has invalid encoding. +/// +/// # Environment Variables +/// +/// * `HAI_KEYS_BASE_URL` - Base URL for the key service. Defaults to `https://keys.hai.ai`. +/// * `JACS_KEY_RESOLUTION` - Controls key resolution order. Options: +/// - "hai-only" - Only use HAI key service (default when set) +/// - "local-first" - Try local trust store, fall back to HAI +/// - "hai-first" - Try HAI first, fall back to local trust store +/// +/// # Example +/// +/// ```rust,ignore +/// use jacs_binding_core::fetch_remote_key; +/// +/// let key_info = fetch_remote_key( +/// "550e8400-e29b-41d4-a716-446655440000", +/// "latest" +/// )?; +/// +/// println!("Algorithm: {}", key_info.algorithm); +/// println!("Hash: {}", key_info.public_key_hash); +/// ``` +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_remote_key(agent_id: &str, version: &str) -> BindingResult { + use jacs::agent::loaders::fetch_public_key_from_hai; + + let key_info = fetch_public_key_from_hai(agent_id, version).map_err(|e| { + // Map JacsError to appropriate BindingCoreError + let error_str = e.to_string(); + if error_str.contains("not found") || error_str.contains("404") { + BindingCoreError::key_not_found(format!( + "Public key not found for agent '{}' version '{}': {}", + agent_id, version, e + )) + } else if error_str.contains("network") + || error_str.contains("connect") + || error_str.contains("timeout") + { + BindingCoreError::network_failed(format!( + "Failed to fetch public key from HAI: {}", + e + )) + } else { + BindingCoreError::generic(format!("Failed to fetch public key: {}", e)) + } + })?; + + Ok(RemotePublicKeyInfo { + public_key: key_info.public_key, + algorithm: key_info.algorithm, + public_key_hash: key_info.hash, + agent_id: agent_id.to_string(), + version: version.to_string(), + }) +} + // ============================================================================= // Re-exports for convenience // ============================================================================= diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index 61f479a3e..a1f5ad080 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -12,11 +12,15 @@ http = [] anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", optional = true } +rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", features = ["server", "transport-io", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true } jacs = { path = "../jacs", default-features = true } +jacs-binding-core = { path = "../binding-core", features = ["hai"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +schemars = "1.0" +uuid = { version = "1", features = ["v4"] } +url = "2" [dev-dependencies] assert_cmd = "2" diff --git a/jacs-mcp/README.md b/jacs-mcp/README.md new file mode 100644 index 000000000..73eb7c5a0 --- /dev/null +++ b/jacs-mcp/README.md @@ -0,0 +1,366 @@ +# JACS MCP Server + +A Model Context Protocol (MCP) server providing HAI (Human AI Interface) tools for agent registration, verification, and key management. + +## Overview + +The JACS MCP Server allows LLMs to interact with HAI services through the MCP protocol. It provides tools for: + +- **Agent Key Management**: Fetch public keys from HAI's key distribution service +- **Agent Registration**: Register agents with HAI to establish identity +- **Agent Verification**: Verify other agents' attestation levels (0-3) +- **Status Checking**: Check registration status with HAI +- **Agent Unregistration**: Remove agent registration from HAI + +## Quick Start + +### Step 1: Install JACS CLI + +First, install the JACS command-line tool: + +```bash +# From the JACS repository root +cargo install --path jacs +``` + +### Step 2: Generate Keys + +Generate cryptographic keys for your agent: + +```bash +# Set a secure password for key encryption +export JACS_PRIVATE_KEY_PASSWORD="your-secure-password" + +# Create a directory for your keys +mkdir -p jacs_keys + +# Generate keys (choose an algorithm) +jacs create-keys --algorithm pq-dilithium --output-dir jacs_keys +# Or for Ed25519: jacs create-keys --algorithm ring-Ed25519 --output-dir jacs_keys +``` + +### Step 3: Create Agent + +Create a new JACS agent: + +```bash +# Create data directory +mkdir -p jacs_data + +# Create a new agent +jacs create-agent --name "My Agent" --description "My HAI-enabled agent" +``` + +### Step 4: Create Configuration File + +Create a `jacs.config.json` file: + +```json +{ + "$schema": "https://hai.ai/schemas/jacs.config.schema.json", + "jacs_data_directory": "./jacs_data", + "jacs_key_directory": "./jacs_keys", + "jacs_agent_private_key_filename": "jacs.private.pem.enc", + "jacs_agent_public_key_filename": "jacs.public.pem", + "jacs_agent_key_algorithm": "pq-dilithium", + "jacs_agent_id_and_version": "YOUR-AGENT-ID:YOUR-VERSION-ID", + "jacs_default_storage": "fs" +} +``` + +Replace `YOUR-AGENT-ID:YOUR-VERSION-ID` with your actual agent ID (found in the agent file created in Step 3). + +### Step 5: Build and Run the MCP Server + +```bash +cd jacs-mcp +cargo build --release +``` + +The binary will be at `target/release/jacs-mcp`. + +### Step 6: Configure Your MCP Client + +Add to your MCP client configuration (e.g., Claude Desktop): + +```json +{ + "mcpServers": { + "jacs-hai": { + "command": "/path/to/jacs-mcp", + "env": { + "JACS_CONFIG": "/path/to/jacs.config.json", + "JACS_PRIVATE_KEY_PASSWORD": "your-secure-password", + "HAI_API_KEY": "your-hai-api-key" + } + } + } +} +``` + +## Installation + +Build from source: + +```bash +cd jacs-mcp +cargo build --release +``` + +The binary will be at `target/release/jacs-mcp`. + +## Configuration + +The server requires a JACS agent configuration to operate. Set the following environment variables: + +### Required + +- `JACS_CONFIG` - Path to your `jacs.config.json` file +- `JACS_PRIVATE_KEY_PASSWORD` - Password for decrypting your private key + +### Optional + +- `HAI_ENDPOINT` - HAI API endpoint (default: `https://api.hai.ai`). Must be an allowed host. +- `HAI_API_KEY` - API key for HAI authentication +- `RUST_LOG` - Logging level (default: `info,rmcp=warn`) + +### Security Options + +- `JACS_MCP_ALLOW_REGISTRATION` - Set to `true` to enable the register_agent tool (default: disabled) +- `JACS_MCP_ALLOW_UNREGISTRATION` - Set to `true` to enable the unregister_agent tool (default: disabled) + +### Example Configuration + +```json +{ + "jacs_data_directory": "./jacs_data", + "jacs_key_directory": "./jacs_keys", + "jacs_agent_private_key_filename": "jacs.private.pem.enc", + "jacs_agent_public_key_filename": "jacs.public.pem", + "jacs_agent_key_algorithm": "pq-dilithium", + "jacs_agent_id_and_version": "your-agent-id:version", + "jacs_default_storage": "fs" +} +``` + +## Usage + +### Starting the Server + +```bash +export JACS_CONFIG=/path/to/jacs.config.json +export HAI_API_KEY=your-api-key # optional +./jacs-mcp +``` + +The server communicates over stdin/stdout using the MCP JSON-RPC protocol. + +### MCP Client Configuration + +Add to your MCP client configuration (e.g., Claude Desktop): + +```json +{ + "mcpServers": { + "jacs-hai": { + "command": "/path/to/jacs-mcp", + "env": { + "JACS_CONFIG": "/path/to/jacs.config.json", + "HAI_API_KEY": "your-api-key" + } + } + } +} +``` + +## Tools + +### fetch_agent_key + +Fetch a public key from HAI's key distribution service. + +**Parameters:** +- `agent_id` (required): The JACS agent ID (UUID format) +- `version` (optional): Key version to fetch, or "latest" for most recent + +**Returns:** +- `success`: Whether the operation succeeded +- `agent_id`: The agent ID +- `version`: The key version +- `algorithm`: Cryptographic algorithm (e.g., "ed25519", "pq-dilithium") +- `public_key_hash`: SHA-256 hash of the public key +- `public_key_base64`: Base64-encoded public key + +**Example:** +```json +{ + "name": "fetch_agent_key", + "arguments": { + "agent_id": "550e8400-e29b-41d4-a716-446655440000", + "version": "latest" + } +} +``` + +### register_agent + +Register the local agent with HAI to establish identity and enable attestation. + +**Parameters:** +- `preview` (optional): If true, validates without actually registering + +**Returns:** +- `success`: Whether the operation succeeded +- `agent_id`: The registered agent's JACS ID +- `jacs_id`: The JACS document ID +- `dns_verified`: Whether DNS verification was successful +- `preview_mode`: Whether this was preview-only +- `message`: Human-readable status message + +**Example:** +```json +{ + "name": "register_agent", + "arguments": { + "preview": false + } +} +``` + +### verify_agent + +Verify another agent's attestation level with HAI. + +**Parameters:** +- `agent_id` (required): The JACS agent ID to verify +- `version` (optional): Agent version to verify, or "latest" + +**Returns:** +- `success`: Whether the verification succeeded +- `agent_id`: The verified agent ID +- `attestation_level`: Trust level (0-3): + - Level 0: No attestation + - Level 1: Key registered with HAI + - Level 2: DNS verified + - Level 3: Full HAI signature attestation +- `attestation_description`: Human-readable description +- `key_found`: Whether the agent's public key was found + +**Example:** +```json +{ + "name": "verify_agent", + "arguments": { + "agent_id": "550e8400-e29b-41d4-a716-446655440000" + } +} +``` + +### check_agent_status + +Check registration status of an agent with HAI. + +**Parameters:** +- `agent_id` (optional): Agent ID to check. If omitted, checks the local agent. + +**Returns:** +- `success`: Whether the operation succeeded +- `agent_id`: The checked agent ID +- `registered`: Whether the agent is registered with HAI +- `registration_id`: HAI registration ID (if registered) +- `registered_at`: Registration timestamp (if registered) +- `signature_count`: Number of HAI signatures on the registration + +**Example:** +```json +{ + "name": "check_agent_status", + "arguments": {} +} +``` + +### unregister_agent + +Unregister the local agent from HAI. **Requires `JACS_MCP_ALLOW_UNREGISTRATION=true`.** + +**Parameters:** +- `preview` (optional): If true (default), validates without actually unregistering + +**Returns:** +- `success`: Whether the operation succeeded +- `agent_id`: The unregistered agent's JACS ID +- `preview_mode`: Whether this was preview-only +- `message`: Human-readable status message + +**Example:** +```json +{ + "name": "unregister_agent", + "arguments": { + "preview": false + } +} +``` + +## Security + +### Key Security Features + +- **Registration Authorization**: The `register_agent` and `unregister_agent` tools are disabled by default. This prevents prompt injection attacks from registering agents without user consent. +- **Preview Mode by Default**: Even when enabled, registration defaults to preview mode for additional safety. +- **Endpoint Validation**: The `HAI_ENDPOINT` URL is validated against an allowlist to prevent request redirection attacks. +- **Password Protection**: Private keys are encrypted with a password. Never store passwords in config files - use the `JACS_PRIVATE_KEY_PASSWORD` environment variable. +- **Stdio Transport**: The server uses stdio transport, which is inherently local with no network exposure. + +### Enabling Registration + +To enable agent registration (only do this if you trust the LLM): + +```bash +export JACS_MCP_ALLOW_REGISTRATION=true +export JACS_MCP_ALLOW_UNREGISTRATION=true # Optional +``` + +### Allowed HAI Endpoints + +The server validates `HAI_ENDPOINT` against this allowlist: +- `api.hai.ai` +- `dev.api.hai.ai` +- `staging.api.hai.ai` +- `localhost` / `127.0.0.1` (for development) +- Any subdomain of `*.hai.ai` + +### Best Practices + +- Use strong passwords for key encryption (12+ characters) +- Never commit config files with passwords to version control +- Use environment variables or secrets management for sensitive values +- Keep private key files secure (mode 0600) +- Review agent registrations before enabling automatic registration + +## Development + +### Running Tests + +```bash +cargo test +``` + +### Building Debug Version + +```bash +cargo build +``` + +### Environment for Development + +```bash +export JACS_CONFIG=/path/to/test/jacs.config.json +export HAI_ENDPOINT=https://dev.api.hai.ai +export RUST_LOG=debug +cargo run +``` + +## License + +See the LICENSE file in the parent directory. diff --git a/jacs-mcp/src/hai_tools.rs b/jacs-mcp/src/hai_tools.rs new file mode 100644 index 000000000..c80e49f1a --- /dev/null +++ b/jacs-mcp/src/hai_tools.rs @@ -0,0 +1,947 @@ +//! HAI MCP tools for LLM integration. +//! +//! This module provides MCP tools that allow LLMs to interact with HAI services: +//! +//! - `fetch_agent_key` - Fetch a public key from HAI's key distribution service +//! - `register_agent` - Register the local agent with HAI +//! - `verify_agent` - Verify another agent's attestation level +//! - `check_agent_status` - Check registration status with HAI +//! - `unregister_agent` - Unregister an agent from HAI +//! +//! # Security Features +//! +//! - **Registration Authorization**: The `register_agent` tool requires explicit enablement +//! via `JACS_MCP_ALLOW_REGISTRATION=true` environment variable. This prevents prompt +//! injection attacks from registering agents without user consent. +//! +//! - **Preview Mode by Default**: Even when enabled, registration defaults to preview mode +//! unless `preview=false` is explicitly set. + +use jacs_binding_core::hai::HaiClient; +use jacs_binding_core::{fetch_remote_key, AgentWrapper}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerInfo, Tool, ToolsCapability}; +use rmcp::{tool, tool_handler, tool_router, ServerHandler}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Validates that a string is a valid UUID format. +/// Returns an error message if invalid, None if valid. +fn validate_agent_id(agent_id: &str) -> Result<(), String> { + if agent_id.is_empty() { + return Err("agent_id cannot be empty".to_string()); + } + + // Try parsing as UUID + match Uuid::parse_str(agent_id) { + Ok(_) => Ok(()), + Err(_) => Err(format!( + "Invalid agent_id format '{}'. Expected UUID format (e.g., '550e8400-e29b-41d4-a716-446655440000')", + agent_id + )), + } +} + +/// Check if registration is allowed via environment variable. +/// Registration requires explicit opt-in for security. +fn is_registration_allowed() -> bool { + std::env::var("JACS_MCP_ALLOW_REGISTRATION") + .map(|v| v.to_lowercase() == "true" || v == "1") + .unwrap_or(false) +} + +/// Check if unregistration is allowed via environment variable. +fn is_unregistration_allowed() -> bool { + std::env::var("JACS_MCP_ALLOW_UNREGISTRATION") + .map(|v| v.to_lowercase() == "true" || v == "1") + .unwrap_or(false) +} + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +/// Parameters for fetching an agent's public key from HAI. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct FetchAgentKeyParams { + /// The unique identifier of the agent whose key to fetch. + #[schemars(description = "The JACS agent ID (UUID format)")] + pub agent_id: String, + + /// The version of the key to fetch. Use "latest" for the most recent version. + #[schemars(description = "Key version to fetch, or 'latest' for most recent")] + pub version: Option, +} + +/// Result of fetching an agent's public key. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct FetchAgentKeyResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The agent ID. + pub agent_id: String, + + /// The key version. + pub version: String, + + /// The cryptographic algorithm (e.g., "ed25519", "pq-dilithium"). + pub algorithm: String, + + /// The SHA-256 hash of the public key. + pub public_key_hash: String, + + /// The public key in base64 encoding. + pub public_key_base64: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for registering the local agent with HAI. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RegisterAgentParams { + /// Whether to run in preview mode (validate without registering). + #[schemars(description = "If true, validates registration without actually registering")] + pub preview: Option, +} + +/// Result of agent registration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RegisterAgentResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The registered agent's JACS ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + + /// The JACS document ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub jacs_id: Option, + + /// Whether DNS verification was successful. + pub dns_verified: bool, + + /// Whether this was a preview-only operation. + pub preview_mode: bool, + + /// Human-readable status message. + pub message: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for verifying another agent's attestation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyAgentParams { + /// The agent ID to verify. + #[schemars(description = "The JACS agent ID to verify")] + pub agent_id: String, + + /// The version to verify (defaults to "latest"). + #[schemars(description = "Agent version to verify, or 'latest'")] + pub version: Option, +} + +/// Result of agent verification. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VerifyAgentResult { + /// Whether the verification succeeded. + pub success: bool, + + /// The agent ID that was verified. + pub agent_id: String, + + /// The attestation level (0-3). + /// - Level 0: No attestation + /// - Level 1: Key registered with HAI + /// - Level 2: DNS verified + /// - Level 3: Full HAI signature attestation + pub attestation_level: u8, + + /// Human-readable description of the attestation level. + pub attestation_description: String, + + /// Whether the agent's public key was found. + pub key_found: bool, + + /// Error message if verification failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for checking agent registration status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgentStatusParams { + /// Optional agent ID to check. If not provided, checks the local agent. + #[schemars(description = "Agent ID to check status for. If omitted, checks the local agent.")] + pub agent_id: Option, +} + +/// Result of checking agent status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CheckAgentStatusResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The agent ID that was checked. + pub agent_id: String, + + /// Whether the agent is registered with HAI. + pub registered: bool, + + /// HAI registration ID (if registered). + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_id: Option, + + /// When the agent was registered (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub registered_at: Option, + + /// Number of HAI signatures on the registration. + pub signature_count: usize, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Parameters for unregistering an agent from HAI. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UnregisterAgentParams { + /// Whether to run in preview mode (validate without unregistering). + #[schemars(description = "If true, validates unregistration without actually unregistering")] + pub preview: Option, +} + +/// Result of agent unregistration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct UnregisterAgentResult { + /// Whether the operation succeeded. + pub success: bool, + + /// The unregistered agent's JACS ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + + /// Whether this was a preview-only operation. + pub preview_mode: bool, + + /// Human-readable status message. + pub message: String, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ============================================================================= +// MCP Server +// ============================================================================= + +/// HAI MCP Server providing tools for agent registration, verification, and key management. +#[derive(Clone)] +#[allow(dead_code)] +pub struct HaiMcpServer { + /// The local agent identity. + agent: Arc, + /// HAI client for API calls. + hai_client: Arc, + /// Tool router for MCP tool dispatch. + tool_router: ToolRouter, + /// Whether registration is allowed (from JACS_MCP_ALLOW_REGISTRATION env var). + registration_allowed: bool, + /// Whether unregistration is allowed (from JACS_MCP_ALLOW_UNREGISTRATION env var). + unregistration_allowed: bool, +} + +#[allow(dead_code)] +impl HaiMcpServer { + /// Create a new HAI MCP server with the given agent and HAI endpoint. + /// + /// # Arguments + /// + /// * `agent` - The local JACS agent wrapper + /// * `hai_endpoint` - Base URL for the HAI API (e.g., "https://api.hai.ai") + /// * `api_key` - Optional API key for HAI authentication + /// + /// # Environment Variables + /// + /// * `JACS_MCP_ALLOW_REGISTRATION` - Set to "true" to enable the register_agent tool + /// * `JACS_MCP_ALLOW_UNREGISTRATION` - Set to "true" to enable the unregister_agent tool + pub fn new(agent: AgentWrapper, hai_endpoint: &str, api_key: Option<&str>) -> Self { + let mut client = HaiClient::new(hai_endpoint); + if let Some(key) = api_key { + client = client.with_api_key(key); + } + + let registration_allowed = is_registration_allowed(); + let unregistration_allowed = is_unregistration_allowed(); + + if registration_allowed { + tracing::info!("Agent registration is ENABLED (JACS_MCP_ALLOW_REGISTRATION=true)"); + } else { + tracing::info!("Agent registration is DISABLED. Set JACS_MCP_ALLOW_REGISTRATION=true to enable."); + } + + Self { + agent: Arc::new(agent), + hai_client: Arc::new(client), + tool_router: Self::tool_router(), + registration_allowed, + unregistration_allowed, + } + } + + /// Get the list of available tools for LLM discovery. + pub fn tools() -> Vec { + vec![ + Tool::new( + "fetch_agent_key", + "Fetch a public key from HAI's key distribution service. Use this to obtain \ + trusted public keys for verifying agent signatures.", + Self::fetch_agent_key_schema(), + ), + Tool::new( + "register_agent", + "Register the local agent with HAI. This establishes the agent's identity \ + in the HAI network and enables attestation services. \ + SECURITY: Requires JACS_MCP_ALLOW_REGISTRATION=true environment variable. \ + Defaults to preview mode (set preview=false to actually register).", + Self::register_agent_schema(), + ), + Tool::new( + "verify_agent", + "Verify another agent's attestation level with HAI. Returns the trust level \ + (0-3) indicating how well the agent's identity has been verified.", + Self::verify_agent_schema(), + ), + Tool::new( + "check_agent_status", + "Check the registration status of an agent with HAI. Returns whether the \ + agent is registered and relevant registration details.", + Self::check_agent_status_schema(), + ), + Tool::new( + "unregister_agent", + "Unregister the local agent from HAI. This removes the agent's registration \ + and associated attestations. SECURITY: Requires JACS_MCP_ALLOW_UNREGISTRATION=true.", + Self::unregister_agent_schema(), + ), + ] + } + + fn fetch_agent_key_schema() -> serde_json::Map { + let schema = schemars::schema_for!(FetchAgentKeyParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn register_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(RegisterAgentParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn verify_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(VerifyAgentParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn check_agent_status_schema() -> serde_json::Map { + let schema = schemars::schema_for!(CheckAgentStatusParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } + + fn unregister_agent_schema() -> serde_json::Map { + let schema = schemars::schema_for!(UnregisterAgentParams); + match serde_json::to_value(schema) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } + } +} + +// Implement the tool router for the server +#[tool_router] +impl HaiMcpServer { + /// Fetch a public key from HAI's key distribution service. + /// + /// This tool retrieves the public key for a specific agent from HAI, + /// allowing verification of that agent's signatures. + #[tool( + name = "fetch_agent_key", + description = "Fetch a public key from HAI's key distribution service for verifying agent signatures." + )] + pub async fn fetch_agent_key( + &self, + Parameters(params): Parameters, + ) -> String { + // Validate agent_id format + if let Err(e) = validate_agent_id(¶ms.agent_id) { + let result = FetchAgentKeyResult { + success: false, + agent_id: params.agent_id.clone(), + version: params.version.clone().unwrap_or_else(|| "latest".to_string()), + algorithm: String::new(), + public_key_hash: String::new(), + public_key_base64: String::new(), + error: Some(e), + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + let version = params.version.as_deref().unwrap_or("latest"); + + let result = match fetch_remote_key(¶ms.agent_id, version) { + Ok(key_info) => FetchAgentKeyResult { + success: true, + agent_id: key_info.agent_id, + version: key_info.version, + algorithm: key_info.algorithm, + public_key_hash: key_info.public_key_hash, + public_key_base64: base64_encode(&key_info.public_key), + error: None, + }, + Err(e) => FetchAgentKeyResult { + success: false, + agent_id: params.agent_id.clone(), + version: version.to_string(), + algorithm: String::new(), + public_key_hash: String::new(), + public_key_base64: String::new(), + error: Some(e.to_string()), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Register the local agent with HAI. + /// + /// This establishes the agent's identity in the HAI network and enables + /// attestation services. + /// + /// # Security + /// + /// Registration requires `JACS_MCP_ALLOW_REGISTRATION=true` environment variable. + /// This prevents prompt injection attacks from registering agents without user consent. + /// Registration defaults to preview mode (preview=true) for additional safety. + #[tool( + name = "register_agent", + description = "Register the local JACS agent with HAI to establish identity and enable attestation." + )] + pub async fn register_agent( + &self, + Parameters(params): Parameters, + ) -> String { + // Security check: Registration must be explicitly enabled + if !self.registration_allowed { + let result = RegisterAgentResult { + success: false, + agent_id: None, + jacs_id: None, + dns_verified: false, + preview_mode: true, + message: "Registration is disabled for security. \ + To enable, set JACS_MCP_ALLOW_REGISTRATION=true environment variable \ + when starting the MCP server." + .to_string(), + error: Some("REGISTRATION_DISABLED".to_string()), + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Default to preview mode for additional safety + let preview = params.preview.unwrap_or(true); + + if preview { + let result = RegisterAgentResult { + success: true, + agent_id: None, + jacs_id: None, + dns_verified: false, + preview_mode: true, + message: "Preview mode: Agent would be registered with HAI. \ + Set preview=false to actually register. \ + WARNING: Registration is a significant action that establishes \ + your agent's identity in the HAI network." + .to_string(), + error: None, + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + let result = match self.hai_client.register(&self.agent).await { + Ok(reg) => RegisterAgentResult { + success: true, + agent_id: Some(reg.agent_id), + jacs_id: Some(reg.jacs_id), + dns_verified: reg.dns_verified, + preview_mode: false, + message: format!( + "Successfully registered with HAI. {} signature(s) received.", + reg.signatures.len() + ), + error: None, + }, + Err(e) => RegisterAgentResult { + success: false, + agent_id: None, + jacs_id: None, + dns_verified: false, + preview_mode: false, + message: "Registration failed".to_string(), + error: Some(e.to_string()), + }, + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Verify another agent's attestation level with HAI. + /// + /// Returns the trust level indicating how well the agent's identity + /// has been verified: + /// - Level 0: No attestation + /// - Level 1: Key registered with HAI + /// - Level 2: DNS verified (key hash matches DNS TXT record) + /// - Level 3: Full HAI signature attestation (HAI has signed the registration) + #[tool( + name = "verify_agent", + description = "Verify another agent's attestation level (0-3) with HAI." + )] + pub async fn verify_agent(&self, Parameters(params): Parameters) -> String { + // Validate agent_id format + if let Err(e) = validate_agent_id(¶ms.agent_id) { + let result = VerifyAgentResult { + success: false, + agent_id: params.agent_id.clone(), + attestation_level: 0, + attestation_description: "Level 0: Invalid agent ID format".to_string(), + key_found: false, + error: Some(e), + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + let version = params.version.as_deref().unwrap_or("latest"); + + // First, try to fetch the key to determine attestation level + let key_result = fetch_remote_key(¶ms.agent_id, version); + + let (attestation_level, attestation_description, key_found) = match &key_result { + Ok(_key_info) => { + // Key found - at minimum Level 1 + // Now check for higher levels + + // Check for Level 3: HAI signature attestation + // Query the status endpoint to see if HAI has signed the registration + match self.hai_client.status(&self.agent).await { + Ok(status) if !status.hai_signatures.is_empty() => { + ( + 3u8, + format!( + "Level 3: Full HAI attestation ({} signature(s))", + status.hai_signatures.len() + ), + true, + ) + } + Ok(status) if status.registered => { + // Registered but no HAI signatures yet + // Check for Level 2: DNS verification + // For now, we report Level 1 if we can't verify DNS + // DNS verification would require fetching the agent document + // and checking if dns_verified is true + ( + 1u8, + "Level 1: Public key registered with HAI key service".to_string(), + true, + ) + } + _ => { + // Status check failed or not registered + // Fall back to Level 1 since we have the key + ( + 1u8, + "Level 1: Public key registered with HAI key service".to_string(), + true, + ) + } + } + } + Err(e) => { + let error_str = e.to_string(); + if error_str.contains("not found") || error_str.contains("404") { + ( + 0u8, + "Level 0: Agent not found in HAI key service".to_string(), + false, + ) + } else { + // Network or other error - can't determine level + ( + 0u8, + format!("Level 0: Unable to verify ({})", error_str), + false, + ) + } + } + }; + + let result = VerifyAgentResult { + success: key_found || key_result.is_ok(), + agent_id: params.agent_id, + attestation_level, + attestation_description, + key_found, + error: key_result.err().map(|e| e.to_string()), + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Check the registration status of an agent with HAI. + #[tool( + name = "check_agent_status", + description = "Check if an agent is registered with HAI and get registration details." + )] + pub async fn check_agent_status( + &self, + Parameters(params): Parameters, + ) -> String { + // If no agent_id provided, check the local agent + let check_local = params.agent_id.is_none(); + + let result = if check_local { + // Check status of the local agent + match self.hai_client.status(&self.agent).await { + Ok(status) => CheckAgentStatusResult { + success: true, + agent_id: status.agent_id, + registered: status.registered, + registration_id: if status.registration_id.is_empty() { + None + } else { + Some(status.registration_id) + }, + registered_at: if status.registered_at.is_empty() { + None + } else { + Some(status.registered_at) + }, + signature_count: status.hai_signatures.len(), + error: None, + }, + Err(e) => CheckAgentStatusResult { + success: false, + agent_id: "local".to_string(), + registered: false, + registration_id: None, + registered_at: None, + signature_count: 0, + error: Some(e.to_string()), + }, + } + } else { + // For a remote agent, we can only check if their key exists + let agent_id = params.agent_id.unwrap(); + + // Validate agent_id format + if let Err(e) = validate_agent_id(&agent_id) { + return serde_json::to_string_pretty(&CheckAgentStatusResult { + success: false, + agent_id, + registered: false, + registration_id: None, + registered_at: None, + signature_count: 0, + error: Some(e), + }) + .unwrap_or_else(|e| format!("Error: {}", e)); + } + + match fetch_remote_key(&agent_id, "latest") { + Ok(_) => CheckAgentStatusResult { + success: true, + agent_id: agent_id.clone(), + registered: true, + registration_id: None, // Not available for remote agents + registered_at: None, + signature_count: 0, + error: None, + }, + Err(e) => { + let error_str = e.to_string(); + let registered = !error_str.contains("not found") && !error_str.contains("404"); + CheckAgentStatusResult { + success: !registered, // Success if we got a clear "not found" + agent_id, + registered, + registration_id: None, + registered_at: None, + signature_count: 0, + error: if registered { + Some(error_str) + } else { + None + }, + } + } + } + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } + + /// Unregister the local agent from HAI. + /// + /// This removes the agent's registration and associated attestations. + /// + /// # Security + /// + /// Unregistration requires `JACS_MCP_ALLOW_UNREGISTRATION=true` environment variable. + #[tool( + name = "unregister_agent", + description = "Unregister the local JACS agent from HAI." + )] + pub async fn unregister_agent( + &self, + Parameters(params): Parameters, + ) -> String { + // Security check: Unregistration must be explicitly enabled + if !self.unregistration_allowed { + let result = UnregisterAgentResult { + success: false, + agent_id: None, + preview_mode: true, + message: "Unregistration is disabled for security. \ + To enable, set JACS_MCP_ALLOW_UNREGISTRATION=true environment variable \ + when starting the MCP server." + .to_string(), + error: Some("UNREGISTRATION_DISABLED".to_string()), + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Default to preview mode for safety + let preview = params.preview.unwrap_or(true); + + if preview { + let result = UnregisterAgentResult { + success: true, + agent_id: None, + preview_mode: true, + message: "Preview mode: Agent would be unregistered from HAI. \ + Set preview=false to actually unregister. \ + WARNING: Unregistration removes your agent's identity from the HAI network." + .to_string(), + error: None, + }; + return serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)); + } + + // Note: HaiClient doesn't currently have an unregister method + // This is a placeholder for when that functionality is added + let result = UnregisterAgentResult { + success: false, + agent_id: None, + preview_mode: false, + message: "Unregistration is not yet implemented in the HAI API. \ + Please contact HAI support to unregister your agent." + .to_string(), + error: Some("NOT_IMPLEMENTED".to_string()), + }; + + serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e)) + } +} + +// Implement the tool handler for the server +#[tool_handler(router = self.tool_router)] +impl ServerHandler for HaiMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: Default::default(), + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + ..Default::default() + }, + server_info: Implementation { + name: "jacs-mcp".to_string(), + title: Some("JACS MCP Server with HAI Integration".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + icons: None, + website_url: Some("https://hai.ai".to_string()), + }, + instructions: Some( + "This MCP server provides HAI (Human AI Interface) tools for agent \ + registration, verification, and key management. Use fetch_agent_key \ + to get public keys, register_agent to register with HAI, verify_agent \ + to check attestation levels, and check_agent_status for registration info." + .to_string(), + ), + } + } +} + +// ============================================================================= +// Base64 Encoding Helper +// ============================================================================= + +fn base64_encode(data: &[u8]) -> String { + // Simple base64 encoding using the standard alphabet + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut result = String::new(); + let mut i = 0; + + while i < data.len() { + let b0 = data[i] as usize; + let b1 = if i + 1 < data.len() { + data[i + 1] as usize + } else { + 0 + }; + let b2 = if i + 2 < data.len() { + data[i + 2] as usize + } else { + 0 + }; + + result.push(ALPHABET[b0 >> 2] as char); + result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char); + + if i + 1 < data.len() { + result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char); + } else { + result.push('='); + } + + if i + 2 < data.len() { + result.push(ALPHABET[b2 & 0x3f] as char); + } else { + result.push('='); + } + + i += 3; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fetch_agent_key_params_schema() { + let schema = schemars::schema_for!(FetchAgentKeyParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("agent_id")); + assert!(json.contains("version")); + } + + #[test] + fn test_register_agent_params_schema() { + let schema = schemars::schema_for!(RegisterAgentParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("preview")); + } + + #[test] + fn test_verify_agent_params_schema() { + let schema = schemars::schema_for!(VerifyAgentParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("agent_id")); + } + + #[test] + fn test_check_agent_status_params_schema() { + let schema = schemars::schema_for!(CheckAgentStatusParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("agent_id")); + } + + #[test] + fn test_unregister_agent_params_schema() { + let schema = schemars::schema_for!(UnregisterAgentParams); + let json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(json.contains("preview")); + } + + #[test] + fn test_tools_list() { + let tools = HaiMcpServer::tools(); + assert_eq!(tools.len(), 5); + + let names: Vec<&str> = tools.iter().map(|t| &*t.name).collect(); + assert!(names.contains(&"fetch_agent_key")); + assert!(names.contains(&"register_agent")); + assert!(names.contains(&"verify_agent")); + assert!(names.contains(&"check_agent_status")); + assert!(names.contains(&"unregister_agent")); + } + + #[test] + fn test_validate_agent_id_valid() { + assert!(validate_agent_id("550e8400-e29b-41d4-a716-446655440000").is_ok()); + assert!(validate_agent_id("123e4567-e89b-12d3-a456-426614174000").is_ok()); + } + + #[test] + fn test_validate_agent_id_invalid() { + assert!(validate_agent_id("").is_err()); + assert!(validate_agent_id("not-a-uuid").is_err()); + assert!(validate_agent_id("12345").is_err()); + assert!(validate_agent_id("550e8400-e29b-41d4-a716").is_err()); // Too short + } + + #[test] + fn test_is_registration_allowed_default() { + // When env var is not set, should return false + // SAFETY: This test runs in isolation and modifies test-specific env vars + unsafe { + std::env::remove_var("JACS_MCP_ALLOW_REGISTRATION"); + } + assert!(!is_registration_allowed()); + } + + #[test] + fn test_is_unregistration_allowed_default() { + // When env var is not set, should return false + // SAFETY: This test runs in isolation and modifies test-specific env vars + unsafe { + std::env::remove_var("JACS_MCP_ALLOW_UNREGISTRATION"); + } + assert!(!is_unregistration_allowed()); + } +} diff --git a/jacs-mcp/src/handlers.rs b/jacs-mcp/src/handlers.rs index 6f7588e2b..2253d93ff 100644 --- a/jacs-mcp/src/handlers.rs +++ b/jacs-mcp/src/handlers.rs @@ -1,3 +1,11 @@ +//! Legacy JACS message handlers. +//! +//! These handlers are placeholders for future JACS messaging functionality. +//! They are not currently wired into the MCP server but are retained for +//! future development. + +#![allow(dead_code)] + use anyhow::{Result, bail}; use jacs::agent::Agent; use jacs::agent::boilerplate::BoilerPlate; diff --git a/jacs-mcp/src/main.rs b/jacs-mcp/src/main.rs index c2191627d..36fa6d7dd 100644 --- a/jacs-mcp/src/main.rs +++ b/jacs-mcp/src/main.rs @@ -1,56 +1,202 @@ mod handlers; +mod hai_tools; + +#[cfg(feature = "mcp")] +use hai_tools::HaiMcpServer; +#[cfg(feature = "mcp")] +use jacs_binding_core::AgentWrapper; +#[cfg(feature = "mcp")] +use rmcp::{transport::stdio, ServiceExt}; + +/// Allowed HAI endpoint hostnames for security. +/// This prevents request redirection attacks via malicious HAI_ENDPOINT values. +#[cfg(feature = "mcp")] +const ALLOWED_HAI_HOSTS: &[&str] = &[ + "api.hai.ai", + "dev.api.hai.ai", + "staging.api.hai.ai", + "localhost", + "127.0.0.1", +]; + +/// Validate that the HAI endpoint is an allowed hostname. +/// Returns the validated endpoint URL or an error. +#[cfg(feature = "mcp")] +fn validate_hai_endpoint(endpoint: &str) -> anyhow::Result { + use url::Url; + + // Parse the URL + let url = Url::parse(endpoint).map_err(|e| { + anyhow::anyhow!( + "Invalid HAI_ENDPOINT URL '{}': {}. Expected format: https://api.hai.ai", + endpoint, + e + ) + })?; + + // Check the scheme + let scheme = url.scheme(); + if scheme != "https" && scheme != "http" { + return Err(anyhow::anyhow!( + "Invalid HAI_ENDPOINT scheme '{}'. Only 'http' and 'https' are allowed.", + scheme + )); + } + + // Warn about http in production + if scheme == "http" { + let host = url.host_str().unwrap_or(""); + if host != "localhost" && host != "127.0.0.1" { + tracing::warn!( + "Using insecure HTTP for HAI endpoint '{}'. Consider using HTTPS for production.", + endpoint + ); + } + } + + // Check the host against allowlist + let host = url.host_str().ok_or_else(|| { + anyhow::anyhow!("HAI_ENDPOINT '{}' has no host component.", endpoint) + })?; + + // Check if host is in allowlist + let is_allowed = ALLOWED_HAI_HOSTS.iter().any(|allowed| *allowed == host); + + // Also allow any subdomain of hai.ai + let is_hai_subdomain = host.ends_with(".hai.ai"); + + if !is_allowed && !is_hai_subdomain { + return Err(anyhow::anyhow!( + "HAI_ENDPOINT host '{}' is not in the allowed list. \ + Allowed hosts: {:?}, or any subdomain of hai.ai. \ + If this is a legitimate HAI endpoint, please report this issue.", + host, + ALLOWED_HAI_HOSTS + )); + } + + tracing::debug!("HAI endpoint '{}' validated successfully", endpoint); + Ok(endpoint.to_string()) +} #[cfg(feature = "mcp")] #[tokio::main] async fn main() -> anyhow::Result<()> { - // Placeholder: will wire rmcp server and jacs tools here - tracing_subscriber::fmt().with_env_filter("info").init(); + // Initialize logging - send to stderr so stdout stays clean for MCP JSON-RPC + tracing_subscriber::fmt() + .with_env_filter( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,rmcp=warn".to_string()), + ) + .with_writer(std::io::stderr) + .init(); + tracing::info!("starting jacs-mcp (MCP mode)"); - // Load the single agent identity (the "self"), from env/config or default location - // For now, require JACS_AGENT_FILE env var - // Prefer loading via config so storage and directories are initialized - let mut agent = jacs::get_empty_agent(); - if let Ok(cfg_path) = std::env::var("JACS_CONFIG") { - let cfg_str = std::fs::read_to_string(&cfg_path)?; - let _ = jacs::config::set_env_vars(true, Some(&cfg_str), false) - .map_err(|e| anyhow::anyhow!(e.to_string()))?; - let cfg_dir = std::path::Path::new(&cfg_path) - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(".") - .to_string(); - let cfg_dir = if cfg_dir.ends_with('/') { - cfg_dir - } else { - format!("{}/", cfg_dir) - }; - agent - .load_by_config(cfg_dir) - .map_err(|e| anyhow::anyhow!(e.to_string()))?; - // Disable strict DNS during tests or initial boot; transport accepts, payload verifies - agent.set_dns_validate(false); - agent.set_dns_required(false); - agent.set_dns_strict(false); - } else { - // Fallback: JACS_AGENT_FILE path requires directories in env - let agent_path = std::env::var("JACS_AGENT_FILE").map_err(|_| { - anyhow::anyhow!("JACS_AGENT_FILE not set; point to agent JSON file (ID:VERSION.json)") - })?; - agent = jacs::load_agent_with_dns_strict(agent_path, false) - .map_err(|e| anyhow::anyhow!(e.to_string()))?; - } - // Ensure the agent verifies its own signature at startup - agent - .verify_self_signature() - .map_err(|e| anyhow::anyhow!(e.to_string()))?; - - // Define MCP tool placeholders that enforce signatures and private "self" agent checks - // Wire the handlers in a minimal service soon; placeholders are present in handlers.rs - let _ = (); + // Load the agent identity from config + let agent = load_agent_from_config()?; + + // Get HAI endpoint from environment or use default + let hai_endpoint_raw = + std::env::var("HAI_ENDPOINT").unwrap_or_else(|_| "https://api.hai.ai".to_string()); + + // Validate the endpoint against allowlist + let hai_endpoint = validate_hai_endpoint(&hai_endpoint_raw)?; + + // Get optional API key + let api_key = std::env::var("HAI_API_KEY").ok(); + + tracing::info!( + hai_endpoint = %hai_endpoint, + has_api_key = api_key.is_some(), + "HAI configuration" + ); + + // Create the MCP server with HAI tools + let server = HaiMcpServer::new( + agent, + &hai_endpoint, + api_key.as_deref(), + ); + + tracing::info!("HAI MCP server ready, waiting for client connection on stdio"); + + // Serve over stdin/stdout + let (stdin, stdout) = stdio(); + let running = server.serve((stdin, stdout)).await?; + + tracing::info!("MCP client connected, serving requests"); + + // Wait for the service to complete + running.waiting().await?; + + tracing::info!("MCP server shutting down"); Ok(()) } +#[cfg(feature = "mcp")] +fn load_agent_from_config() -> anyhow::Result { + let agent_wrapper = AgentWrapper::new(); + + // JACS_CONFIG is required for the MCP server + let cfg_path = std::env::var("JACS_CONFIG").map_err(|_| { + anyhow::anyhow!( + "JACS_CONFIG environment variable is not set. \n\ + \n\ + To use the JACS MCP server, you need to:\n\ + 1. Create a jacs.config.json file with your agent configuration\n\ + 2. Set JACS_CONFIG=/path/to/jacs.config.json\n\ + \n\ + See the README for a Quick Start guide on creating an agent." + ) + })?; + + tracing::info!(config_path = %cfg_path, "Loading agent from config file"); + + // Verify the config file exists before trying to read it + if !std::path::Path::new(&cfg_path).exists() { + return Err(anyhow::anyhow!( + "Config file not found at '{}'. \n\ + \n\ + Please create a jacs.config.json file or update JACS_CONFIG \ + to point to an existing configuration file.", + cfg_path + )); + } + + // Set up environment from config + let cfg_str = std::fs::read_to_string(&cfg_path).map_err(|e| { + anyhow::anyhow!( + "Failed to read config file '{}': {}. Check file permissions.", + cfg_path, + e + ) + })?; + + #[allow(deprecated)] + let _ = jacs::config::set_env_vars(true, Some(&cfg_str), false) + .map_err(|e| anyhow::anyhow!("Invalid config file '{}': {}", cfg_path, e))?; + + // Get the config directory for relative path resolution + let cfg_dir = std::path::Path::new(&cfg_path) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(".") + .to_string(); + let cfg_dir = if cfg_dir.ends_with('/') { + cfg_dir + } else { + format!("{}/", cfg_dir) + }; + + // Load the agent + agent_wrapper + .load(cfg_dir) + .map_err(|e| anyhow::anyhow!("Failed to load agent: {}", e))?; + + tracing::info!("Agent loaded successfully from config"); + Ok(agent_wrapper) +} + #[cfg(not(feature = "mcp"))] fn main() { eprintln!("jacs-mcp built without mcp feature; enable with --features mcp"); diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 58f45187e..0269c82e1 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacs/README.md b/jacs/README.md index 213dda298..3dd9b84db 100644 --- a/jacs/README.md +++ b/jacs/README.md @@ -58,6 +58,10 @@ jacs verify doc.json # Verify a document - TLS certificate validation (warns by default; set `JACS_STRICT_TLS=true` for production) - Private key zeroization on drop - Algorithm identification embedded in signatures +- Verification claim enforcement with downgrade prevention +- DNSSEC-validated identity verification for verified agents + +**Test Coverage**: JACS includes 260+ automated tests covering cryptographic operations (RSA, Ed25519, post-quantum ML-DSA), password validation, agent lifecycle, DNS identity verification, trust store operations, and claim-based security enforcement. Security-critical paths are tested with boundary conditions, failure cases, and attack scenarios (replay attacks, downgrade attempts, key mismatches). **Reporting Vulnerabilities**: Please report security issues responsibly. - Email: security@hai.ai @@ -76,4 +80,4 @@ jacs verify doc.json # Verify a document - [Python](https://pypi.org/project/jacs/) - [Crates.io](https://crates.io/crates/jacs) -**Version**: 0.4.0 | [HAI.AI](https://hai.ai) +**Version**: 0.5.1 | [HAI.AI](https://hai.ai) diff --git a/jacs/docs/jacsbook/src/advanced/security.md b/jacs/docs/jacsbook/src/advanced/security.md index 1c6008b6d..88661b43d 100644 --- a/jacs/docs/jacsbook/src/advanced/security.md +++ b/jacs/docs/jacsbook/src/advanced/security.md @@ -221,6 +221,73 @@ For reliable timestamp validation across distributed systems: - Monitor for clock drift in production environments - Consider the 5-minute tolerance when debugging verification failures +## Verification Claims + +Agents can claim a verification level that determines security requirements. This follows the principle: **"If you claim it, you must prove it."** + +### Claim Levels + +| Claim | Required Conditions | Behavior | +|-------|---------------------|----------| +| `unverified` (default) | None | Relaxed DNS/TLS settings allowed; self-asserted identity | +| `verified` | Domain with DNSSEC | Strict TLS, strict DNS with DNSSEC validation required | +| `verified-hai.ai` | Above + HAI.ai registration | Must be registered and verified with HAI.ai | + +### Setting a Verification Claim + +Add the `jacsVerificationClaim` field to your agent definition: + +```json +{ + "jacsAgentType": "ai", + "jacsVerificationClaim": "verified", + "jacsAgentDomain": "myagent.example.com", + "jacsServices": [...] +} +``` + +### Claim Enforcement + +When an agent claims `verified` or `verified-hai.ai`: + +1. **Domain Required**: The `jacsAgentDomain` field must be set +2. **Strict DNS**: DNS lookup uses DNSSEC validation (no insecure fallback) +3. **DNS Required**: Public key fingerprint must match DNS TXT record +4. **Strict TLS**: TLS certificate validation is mandatory (no self-signed certs) + +For `verified-hai.ai` claims, additional enforcement: + +5. **HAI.ai Registration**: Agent must be registered at [hai.ai](https://hai.ai) +6. **Public Key Match**: Registered public key must match the agent's key +7. **Network Required**: Verification fails if HAI.ai API is unreachable + +### Backward Compatibility + +- Agents without `jacsVerificationClaim` are treated as `unverified` +- Existing agents continue to work with their current DNS settings +- No breaking changes for agents that don't opt into verified status + +### Error Messages + +If verification fails, clear error messages explain what's wrong: + +``` +Verification claim 'verified' failed: Verified agents must have jacsAgentDomain set. +Agents claiming 'verified' must meet the required security conditions. +``` + +``` +Verification claim 'verified-hai.ai' failed: Agent 'uuid' is not registered with HAI.ai. +Agents claiming 'verified-hai.ai' must be registered at https://hai.ai +``` + +### Security Considerations + +1. **No Downgrade**: Once an agent claims `verified`, it cannot be verified with relaxed settings +2. **Claim Changes**: Changing the claim requires creating a new agent version +3. **Network Dependency**: `verified-hai.ai` requires network access to HAI.ai +4. **Audit Trail**: Verification claim and enforcement results are logged + ## DNS-Based Verification JACS supports DNSSEC-validated identity verification: @@ -472,6 +539,106 @@ Enable observability for security auditing: - Document key recovery procedures - Plan for key compromise scenarios +## Troubleshooting Verification Claims + +### Common Issues and Solutions + +#### "Verified agents must have jacsAgentDomain set" + +**Problem**: You set `jacsVerificationClaim` to `verified` but didn't specify a domain. + +**Solution**: Either add a domain or use unverified: + +```json +// Option 1: Add a domain (recommended for production) +{ + "jacsVerificationClaim": "verified", + "jacsAgentDomain": "myagent.example.com" +} + +// Option 2: Use unverified if DNS verification isn't needed +{ + "jacsVerificationClaim": "unverified" +} +``` + +#### "Agent is not registered with HAI.ai" + +**Problem**: You're using `verified-hai.ai` but the agent isn't registered. + +**Solution**: +1. Register your agent at [hai.ai](https://hai.ai) +2. Or use `verified` for DNS-only verification: + +```json +{ + "jacsVerificationClaim": "verified", + "jacsAgentDomain": "myagent.example.com" +} +``` + +#### "Cannot downgrade from 'verified' to 'unverified'" + +**Problem**: You're trying to change an existing agent's claim to a lower level. + +**Solution**: Verification claims cannot be downgraded for security. Options: +1. Keep the current claim level +2. Create a new agent with the desired claim level +3. If this is a test/development scenario, start fresh + +```bash +# Create a new agent instead +jacs create --type ai --claim unverified +``` + +#### "DNS fingerprint mismatch" + +**Problem**: The public key hash in DNS doesn't match your agent's key. + +**Solution**: +1. Regenerate the DNS record with your current keys: + ```bash + jacs dns-record + ``` +2. Update your DNS TXT record with the new value +3. Wait for DNS propagation (can take up to 48 hours) + +#### "Strict DNSSEC validation failed" + +**Problem**: Your domain doesn't have DNSSEC enabled. + +**Solution**: +1. Enable DNSSEC with your domain registrar +2. Publish DS records at the parent zone +3. Or use `verified` with non-strict DNS (development only) + +### Claim Level Reference + +| Claim | Security Level | Requirements | +|-------|----------------|--------------| +| `unverified` | 0 (lowest) | None - self-asserted identity | +| `verified` | 1 | Domain + DNS TXT record + DNSSEC | +| `verified-hai.ai` | 2 (highest) | Above + HAI.ai registration | + +### Upgrade vs Downgrade Rules + +- **Upgrades allowed**: `unverified` → `verified` → `verified-hai.ai` +- **Downgrades blocked**: Cannot go from higher to lower claim +- **Same level allowed**: Can update agent while keeping same claim + +### Quick Diagnostic Commands + +```bash +# Check your agent's current claim +jacs info | grep jacsVerificationClaim + +# Verify DNS record is correct +jacs dns-check + +# Test verification +jacs verify --agent your-agent-id:version +``` + ## See Also - [Cryptographic Algorithms](crypto.md) - Algorithm details diff --git a/jacs/docs/jacsbook/src/integrations/mcp.md b/jacs/docs/jacsbook/src/integrations/mcp.md index d54cd84aa..029974464 100644 --- a/jacs/docs/jacsbook/src/integrations/mcp.md +++ b/jacs/docs/jacsbook/src/integrations/mcp.md @@ -278,6 +278,34 @@ app.get('/sse', (req, res) => { ## Security Model +### Signing is Sacred + +**CRITICAL GUIDANCE FOR AI AGENTS AND DEVELOPERS** + +Signing a document or message with JACS is a **sacred, irreversible act**. When an agent signs: + +1. **Permanent Commitment**: The signature creates permanent cryptographic proof that the signer attested to the content. This proof exists forever and can be verified by anyone. + +2. **Non-Repudiation**: The signer cannot later deny having signed. The signature is mathematically bound to their private key. + +3. **Accountability**: The signer is accountable for the content they signed. In multi-agent systems, signatures establish trust chains and responsibility. + +4. **Irreversibility**: There is no "unsign" operation. Once signed, the commitment exists permanently. + +**Before signing any document, message, or agreement:** +- Read and understand the complete content +- Verify the content represents your actual intent +- Confirm you have authority to make this commitment +- Understand the implications of your signature + +**For AI agents**: Do not sign automatically or without verification. Signing carelessly can: +- Create binding commitments you didn't intend +- Establish false audit trails +- Damage trust relationships with other agents +- Create legal or ethical liability + +**For MCP tool implementations**: When exposing signing capabilities as tools, include appropriate warnings and consider requiring explicit confirmation. + ### What Gets Signed - All JSON-RPC requests and responses diff --git a/jacs/schemas/agent/v1/agent.schema.json b/jacs/schemas/agent/v1/agent.schema.json index ec0d3d6c2..456cf04ae 100644 --- a/jacs/schemas/agent/v1/agent.schema.json +++ b/jacs/schemas/agent/v1/agent.schema.json @@ -25,6 +25,13 @@ "type": "string", "description": "Optional domain used for DNSSEC-validated public key fingerprint (_v1.agent.jacs..)", "hai": "meta" + }, + "jacsVerificationClaim": { + "type": "string", + "enum": ["unverified", "verified", "verified-hai.ai"], + "default": "unverified", + "description": "Agent's claim about verification status. 'unverified' (default) allows relaxed DNS/TLS settings. 'verified' requires strict DNS with DNSSEC and domain must be set. 'verified-hai.ai' additionally requires HAI.ai registration.", + "hai": "meta" } }, "jacsServices": { diff --git a/jacs/schemas/components/signature/v1/signature.schema.json b/jacs/schemas/components/signature/v1/signature.schema.json index bfc04cfbd..6c8213720 100644 --- a/jacs/schemas/components/signature/v1/signature.schema.json +++ b/jacs/schemas/components/signature/v1/signature.schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://hai.ai/schemas/components/signature/v1/signature.schema.json", "title": "Signature", - "description": "Cryptographic signature to be embedded in other documents. Signature may be validated with registrar.", + "description": "SACRED CRYPTOGRAPHIC COMMITMENT: A signature is a permanent, irreversible cryptographic proof binding the signer to document content. Once signed, the signer cannot deny their attestation (non-repudiation). Signatures should only be created after careful review of document content. The signer is forever accountable for what they sign.", "type": "object", "properties": { "agentID": { diff --git a/jacs/src/a2a/agent_card.rs b/jacs/src/a2a/agent_card.rs index 6ea413e13..dbb9c6ee4 100644 --- a/jacs/src/a2a/agent_card.rs +++ b/jacs/src/a2a/agent_card.rs @@ -174,9 +174,9 @@ fn create_jacs_extension(agent: &Agent) -> Result || key_algorithm.contains("sphincs"); let desc = if is_pqc { - "JACS cryptographic document signing and verification with post-quantum support" + "JACS cryptographic document signing (sacred, irreversible commitment) and verification with post-quantum support. Signing creates permanent, non-repudiable proof." } else { - "JACS cryptographic document signing and verification" + "JACS cryptographic document signing (sacred, irreversible commitment) and verification. Signing creates permanent, non-repudiable proof." }; Ok(AgentExtension { @@ -198,14 +198,26 @@ pub fn create_extension_descriptor() -> Value { "a2aProtocolVersion": A2A_PROTOCOL_VERSION, "description": "Provides cryptographic document signing and verification with post-quantum support", "specification": "https://hai.ai/jacs/specs/a2a-extension", + "signingGuidance": { + "importance": "CRITICAL", + "message": "Signing a document is a sacred, irreversible act. A signature creates permanent cryptographic proof that binds the signer to the document content. Once signed, the commitment cannot be undone. The signer is accountable forever for what they sign. Only sign after careful review and full understanding of the document contents.", + "nonRepudiation": "Signatures provide non-repudiation: the signer cannot later deny having signed the document.", + "beforeSigning": [ + "Read and understand the complete document content", + "Verify the document represents your intent", + "Confirm you have authority to make this commitment", + "Understand this creates a permanent, verifiable record" + ] + }, "capabilities": { "documentSigning": { - "description": "Sign documents with JACS signatures", + "description": "SACRED OPERATION: Sign documents with JACS signatures. Creates permanent, non-repudiable cryptographic proof of commitment. The signer is forever accountable for signed content. Do not sign without fully understanding the document.", "algorithms": ["dilithium", "falcon", "sphincs+", "rsa", "ecdsa"], - "formats": ["jacs-v1", "jws-detached"] + "formats": ["jacs-v1", "jws-detached"], + "warning": "Signing is irreversible. Review document carefully before signing." }, "documentVerification": { - "description": "Verify JACS signatures on documents", + "description": "Verify JACS signatures on documents. Confirms document integrity and signer identity.", "offlineCapable": true, "chainOfCustody": true }, @@ -218,7 +230,8 @@ pub fn create_extension_descriptor() -> Value { "sign": { "path": "/jacs/sign", "method": "POST", - "description": "Sign a document with JACS" + "description": "SACRED OPERATION: Sign a document with JACS. Creates permanent cryptographic commitment. Review document carefully before calling.", + "warning": "This operation is irreversible and creates non-repudiable proof of commitment." }, "verify": { "path": "/jacs/verify", diff --git a/jacs/src/agent/document.rs b/jacs/src/agent/document.rs index 262ed88e9..a69a6fc57 100644 --- a/jacs/src/agent/document.rs +++ b/jacs/src/agent/document.rs @@ -4,8 +4,9 @@ use crate::agent::DOCUMENT_AGENT_SIGNATURE_FIELDNAME; use crate::agent::SHA256_FIELDNAME; use crate::agent::agreement::subtract_vecs; use crate::agent::boilerplate::BoilerPlate; -use crate::agent::loaders::FileLoader; +use crate::agent::loaders::{fetch_public_key_from_hai, FileLoader}; use crate::agent::security::SecurityTraits; +use crate::config::{KeyResolutionSource, get_key_resolution_order}; use crate::error::JacsError; use crate::storage::StorageDocumentTraits; use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -25,7 +26,7 @@ use std::error::Error; use std::fmt; use std::io::Read; use std::path::Path; -use tracing::error; +use tracing::{debug, error, info, warn}; use uuid::Uuid; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -773,20 +774,159 @@ impl DocumentTraits for Agent { let document = self.get_document(document_key)?; let json_value = document.getvalue(); let signature_key_from = &DOCUMENT_AGENT_SIGNATURE_FIELDNAME.to_string(); + + // Extract signature metadata let public_key_hash: String = json_value[signature_key_from]["publicKeyHash"] .as_str() .unwrap_or("") .trim_matches('"') .to_string(); - let public_key = self.fs_load_public_key(&public_key_hash)?; - let public_key_enc_type = self.fs_load_public_key_type(&public_key_hash)?; + let agent_id: String = json_value[signature_key_from]["agentID"] + .as_str() + .unwrap_or("") + .trim_matches('"') + .to_string(); + + let agent_version: String = json_value[signature_key_from]["agentVersion"] + .as_str() + .unwrap_or("") + .trim_matches('"') + .to_string(); + + // Get the configured resolution order + let resolution_order = get_key_resolution_order(); + info!( + "Verifying external document signature for {} using resolution order: {:?}", + document_key, resolution_order + ); + + let mut last_error: Option> = None; + let mut public_key: Option> = None; + let mut public_key_enc_type: Option = None; + + // Try each source in order until we find the key + for source in &resolution_order { + debug!("Trying key resolution source: {:?}", source); + + match source { + KeyResolutionSource::Local => { + match self.fs_load_public_key(&public_key_hash) { + Ok(key) => { + match self.fs_load_public_key_type(&public_key_hash) { + Ok(enc_type) => { + info!( + "Found public key locally for hash: {}...", + &public_key_hash[..public_key_hash.len().min(16)] + ); + public_key = Some(key); + public_key_enc_type = Some(enc_type); + break; + } + Err(e) => { + debug!("Local key found but enc_type missing: {}", e); + last_error = Some(e); + } + } + } + Err(e) => { + debug!("Local key not found: {}", e); + last_error = Some(e); + } + } + } + + KeyResolutionSource::Dns => { + // DNS verification requires the agent domain from config + // DNS is used to verify the key hash against a published TXT record, + // not to fetch the key itself. Skip to next source if we need the key. + debug!( + "DNS source configured but DNS verifies key hashes, not fetches keys. \ + Skipping to next source." + ); + continue; + } + + KeyResolutionSource::Hai => { + if agent_id.is_empty() { + debug!("Cannot fetch from HAI: agent_id is empty"); + continue; + } + + // Use agent_version if available, otherwise use "latest" + let version = if agent_version.is_empty() { + "latest".to_string() + } else { + agent_version.clone() + }; + + match fetch_public_key_from_hai(&agent_id, &version) { + Ok(key_info) => { + info!( + "Found public key from HAI for agent {} version {}: algorithm={}", + agent_id, version, key_info.algorithm + ); + + // Verify the hash matches what's in the signature (if HAI returns a hash) + if !key_info.hash.is_empty() && key_info.hash != public_key_hash { + warn!( + "HAI key hash mismatch: expected {}..., got {}...", + &public_key_hash[..public_key_hash.len().min(16)], + &key_info.hash[..key_info.hash.len().min(16)] + ); + last_error = Some(format!( + "HAI key hash mismatch for agent {}: document expects {}..., HAI returned {}...", + agent_id, + &public_key_hash[..public_key_hash.len().min(16)], + &key_info.hash[..key_info.hash.len().min(16)] + ).into()); + continue; + } + + public_key = Some(key_info.public_key.clone()); + public_key_enc_type = Some(key_info.algorithm.clone()); + + // Cache the key locally for future use (non-fatal if this fails) + if let Err(e) = self.fs_save_remote_public_key( + &public_key_hash, + &key_info.public_key, + key_info.algorithm.as_bytes(), + ) { + debug!("Failed to cache HAI key locally (non-fatal): {}", e); + } + + break; + } + Err(e) => { + debug!("HAI key fetch failed: {}", e); + last_error = Some(format!("HAI key service: {}", e).into()); + } + } + } + } + } + + // If we couldn't find the key from any source, return an error + let (final_key, final_enc_type) = match (public_key, public_key_enc_type) { + (Some(k), Some(e)) => (k, e), + _ => { + let err_msg = format!( + "Could not resolve public key for hash '{}...' from any configured source ({:?}). Last error: {}", + &public_key_hash[..public_key_hash.len().min(16)], + resolution_order, + last_error.map(|e| e.to_string()).unwrap_or_else(|| "unknown".to_string()) + ); + error!("{}", err_msg); + return Err(err_msg.into()); + } + }; + self.verify_document_signature( document_key, Some(signature_key_from), None, - Some(public_key), - Some(public_key_enc_type), + Some(final_key), + Some(final_enc_type), ) } diff --git a/jacs/src/agent/loaders.rs b/jacs/src/agent/loaders.rs index 96d0ca23a..3b20815ef 100644 --- a/jacs/src/agent/loaders.rs +++ b/jacs/src/agent/loaders.rs @@ -2,6 +2,7 @@ use crate::agent::Agent; 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 base64::{Engine as _, engine::general_purpose::STANDARD}; use flate2::Compression; use flate2::write::GzEncoder; @@ -662,3 +663,569 @@ impl FileLoader for Agent { Ok(path) } } + +/// Public key information retrieved from HAI key service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicKeyInfo { + /// The raw public key bytes. + pub public_key: Vec, + /// The cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256"). + pub algorithm: String, + /// The hash of the public key for verification. + pub hash: String, +} + +/// Response structure from the HAI keys API. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, serde::Deserialize)] +struct HaiKeysApiResponse { + /// Public key in either PEM or Base64 format. + public_key: String, + /// The cryptographic algorithm used. + algorithm: String, + /// Hash of the public key. + public_key_hash: String, +} + +/// Decodes a public key from either PEM or Base64 format. +/// +/// The HAI key service may return public keys in two formats: +/// - PEM format: starts with "-----BEGIN" and contains Base64-encoded data between headers +/// - Raw Base64: direct Base64 encoding of the key bytes +/// +/// This function auto-detects the format and decodes accordingly. +#[cfg(not(target_arch = "wasm32"))] +fn decode_public_key(key_data: &str) -> Result, JacsError> { + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + + let trimmed = key_data.trim(); + + if trimmed.starts_with("-----BEGIN") { + // PEM format - extract the Base64 content between headers + decode_pem_public_key(trimmed) + } else { + // Assume raw Base64 + BASE64_STANDARD.decode(trimmed).map_err(|e| { + JacsError::CryptoError(format!( + "Invalid base64 encoding in public key from HAI key service: {}", + e + )) + }) + } +} + +/// Decodes a PEM-encoded public key. +/// +/// Extracts the Base64 content between the BEGIN and END markers, +/// removes whitespace, and decodes the resulting Base64 string. +#[cfg(not(target_arch = "wasm32"))] +fn decode_pem_public_key(pem_data: &str) -> Result, JacsError> { + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + + // Find the end of the BEGIN header line (after "-----BEGIN ... -----") + // We need to find the closing "-----" of the BEGIN line + let begin_marker = "-----BEGIN"; + let begin_start = pem_data.find(begin_marker).ok_or_else(|| { + JacsError::CryptoError("Invalid PEM format: missing BEGIN marker".to_string()) + })?; + + // Find the closing "-----" after BEGIN + let after_begin = begin_start + begin_marker.len(); + let begin_close = pem_data[after_begin..] + .find("-----") + .map(|pos| after_begin + pos + 5) + .ok_or_else(|| { + JacsError::CryptoError("Invalid PEM format: incomplete BEGIN header".to_string()) + })?; + + // Find the END marker + let end_start = pem_data.rfind("-----END").ok_or_else(|| { + JacsError::CryptoError("Invalid PEM format: missing END marker".to_string()) + })?; + + if end_start <= begin_close { + return Err(JacsError::CryptoError( + "Invalid PEM format: no content between headers".to_string(), + )); + } + + // Extract the Base64 content between headers, removing all whitespace + let base64_content: String = pem_data[begin_close..end_start] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + if base64_content.is_empty() { + return Err(JacsError::CryptoError( + "Invalid PEM format: no content between headers".to_string(), + )); + } + + BASE64_STANDARD.decode(&base64_content).map_err(|e| { + JacsError::CryptoError(format!( + "Invalid base64 encoding in PEM public key from HAI key service: {}", + e + )) + }) +} + +/// Fetches a public key from the HAI key service. +/// +/// This function retrieves the public key for a specific agent and version +/// from the HAI key distribution service. It is used to obtain trusted public +/// keys for verifying agent signatures without requiring local key storage. +/// +/// # Arguments +/// +/// * `agent_id` - The unique identifier of the agent whose key to fetch. +/// * `version` - The version of the agent's key to fetch. +/// +/// # Returns +/// +/// Returns `Ok(PublicKeyInfo)` containing the public key, algorithm, and hash +/// on success. +/// +/// # Errors +/// +/// * `JacsError::KeyNotFound` - The agent or key version was not found (404). +/// * `JacsError::NetworkError` - Connection, timeout, or other HTTP errors. +/// * `JacsError::CryptoError` - The returned key has invalid base64 encoding. +/// +/// # Environment Variables +/// +/// * `HAI_KEYS_BASE_URL` - Base URL for the key service. Defaults to `https://keys.hai.ai`. +/// +/// # Example +/// +/// ```rust,ignore +/// use jacs::agent::loaders::{fetch_public_key_from_hai, PublicKeyInfo}; +/// +/// let key_info = fetch_public_key_from_hai( +/// "550e8400-e29b-41d4-a716-446655440000", +/// "1" +/// )?; +/// +/// println!("Algorithm: {}", key_info.algorithm); +/// println!("Hash: {}", key_info.hash); +/// ``` +/// +/// # Environment Variables +/// +/// * `HAI_KEYS_BASE_URL` - Base URL for the key service. Defaults to `https://keys.hai.ai`. +/// * `HAI_KEY_FETCH_RETRIES` - Number of retry attempts for network errors. Defaults to 3. +/// Set to 0 to disable retries. +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_public_key_from_hai(agent_id: &str, version: &str) -> Result { + // Get retry count from environment or use default of 3 + let max_retries: u32 = std::env::var("HAI_KEY_FETCH_RETRIES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3); + + // Get base URL from environment or use default + let base_url = + std::env::var("HAI_KEYS_BASE_URL").unwrap_or_else(|_| "https://keys.hai.ai".to_string()); + + let url = format!("{}/jacs/v1/agents/{}/keys/{}", base_url, agent_id, version); + + info!( + "Fetching public key from HAI: agent_id={}, version={}", + agent_id, version + ); + + // Build blocking HTTP client with 30 second timeout + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| JacsError::NetworkError(format!("Failed to build HTTP client: {}", e)))?; + + // Retry loop with exponential backoff + let mut last_error: JacsError = + JacsError::NetworkError("No attempts made to fetch public key".to_string()); + + for attempt in 1..=max_retries + 1 { + match fetch_public_key_attempt(&client, &url, agent_id, version) { + Ok(result) => return Ok(result), + Err(err) => { + // Don't retry on 404 - the key doesn't exist + if matches!(err, JacsError::KeyNotFound { .. }) { + return Err(err); + } + + // Don't retry on non-retryable errors (e.g., parse errors, crypto errors) + if !is_retryable_error(&err) { + return Err(err); + } + + last_error = err; + + // Check if we've exhausted retries (attempt is 1-indexed, so max_retries+1 is the last attempt) + if attempt > max_retries { + warn!( + "Exhausted {} retries fetching public key for agent_id={}, version={}", + max_retries, agent_id, version + ); + break; + } + + // Calculate exponential backoff: 1s, 2s, 4s, ... + let backoff_secs = 1u64 << (attempt - 1); + warn!( + "Retry {}/{} for agent_id={}, version={} after {}s backoff", + attempt, max_retries, agent_id, version, backoff_secs + ); + std::thread::sleep(std::time::Duration::from_secs(backoff_secs)); + } + } + } + + // Return the last error if all retries failed + Err(last_error) +} + +/// Determines if an error is retryable (network errors) or not (parse errors, 404s). +#[cfg(not(target_arch = "wasm32"))] +fn is_retryable_error(err: &JacsError) -> bool { + matches!(err, JacsError::NetworkError(msg) if + msg.contains("timed out") || + msg.contains("connect") || + msg.contains("HTTP request") || + msg.contains("error status 5") // Retry on 5xx server errors + ) +} + +/// Single attempt to fetch a public key from the HAI key service. +#[cfg(not(target_arch = "wasm32"))] +fn fetch_public_key_attempt( + client: &reqwest::blocking::Client, + url: &str, + agent_id: &str, + version: &str, +) -> Result { + // Make request to HAI keys API + let response = client + .get(url) + .header("Accept", "application/json") + .send() + .map_err(|e| { + if e.is_timeout() { + JacsError::NetworkError(format!( + "Request to HAI key service timed out after 30 seconds: {}", + url + )) + } else if e.is_connect() { + JacsError::NetworkError(format!( + "Failed to connect to HAI key service at {}: {}", + url, e + )) + } else { + JacsError::NetworkError(format!("HTTP request to HAI key service failed: {}", e)) + } + })?; + + // Handle response status + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err(JacsError::KeyNotFound { + path: format!( + "agent_id={}, version={} (not found in HAI key service)", + agent_id, version + ), + }); + } + + if !status.is_success() { + return Err(JacsError::NetworkError(format!( + "HAI key service returned error status {}: failed to fetch public key for agent '{}' version '{}'", + status, agent_id, version + ))); + } + + // Parse JSON response + let api_response: HaiKeysApiResponse = response.json().map_err(|e| { + JacsError::NetworkError(format!( + "Failed to parse HAI key service response as JSON: {}", + e + )) + })?; + + // Decode public key - supports both PEM and Base64 formats + let public_key = decode_public_key(&api_response.public_key)?; + + info!( + "Successfully fetched public key from HAI: agent_id={}, version={}, algorithm={}", + agent_id, version, api_response.algorithm + ); + + Ok(PublicKeyInfo { + public_key, + algorithm: api_response.algorithm, + hash: api_response.public_key_hash, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_public_key_info_struct() { + let info = PublicKeyInfo { + public_key: vec![1, 2, 3, 4], + algorithm: "ed25519".to_string(), + hash: "abc123".to_string(), + }; + assert_eq!(info.public_key, vec![1, 2, 3, 4]); + assert_eq!(info.algorithm, "ed25519"); + assert_eq!(info.hash, "abc123"); + } + + #[test] + fn test_public_key_info_clone() { + let info = PublicKeyInfo { + public_key: vec![1, 2, 3], + algorithm: "rsa".to_string(), + hash: "xyz789".to_string(), + }; + let cloned = info.clone(); + assert_eq!(info, cloned); + } + + #[cfg(not(target_arch = "wasm32"))] + mod decode_tests { + use super::*; + + #[test] + fn test_decode_public_key_base64() { + // Test raw Base64 decoding + let key_bytes = vec![1, 2, 3, 4, 5, 6, 7, 8]; + let base64_encoded = base64::engine::general_purpose::STANDARD.encode(&key_bytes); + + let decoded = decode_public_key(&base64_encoded).unwrap(); + assert_eq!(decoded, key_bytes); + } + + #[test] + fn test_decode_public_key_base64_with_whitespace() { + // Test Base64 with leading/trailing whitespace + let key_bytes = vec![10, 20, 30, 40]; + let base64_encoded = + format!(" {} ", base64::engine::general_purpose::STANDARD.encode(&key_bytes)); + + let decoded = decode_public_key(&base64_encoded).unwrap(); + assert_eq!(decoded, key_bytes); + } + + #[test] + fn test_decode_public_key_pem_ed25519() { + // Test PEM-encoded public key with valid Base64 + // This is a real Ed25519 public key ASN.1 structure + let pem = r#"-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAGb9bTBTn3X0IA4i+S6KAHA== +-----END PUBLIC KEY-----"#; + + let result = decode_public_key(pem); + assert!(result.is_ok(), "Failed to decode PEM: {:?}", result.err()); + let decoded = result.unwrap(); + // Just verify we got non-empty bytes back + assert!(!decoded.is_empty()); + } + + #[test] + fn test_decode_public_key_pem_multiline() { + // Test PEM with multiple lines of Base64 content + let pem = r#"-----BEGIN PUBLIC KEY----- +AQAB +CDEF +-----END PUBLIC KEY-----"#; + + let result = decode_public_key(pem); + assert!(result.is_ok()); + } + + #[test] + fn test_decode_public_key_invalid_pem_no_end() { + let pem = "-----BEGIN PUBLIC KEY-----\nAQAB\n"; + + let result = decode_public_key(pem); + assert!(result.is_err()); + match result.unwrap_err() { + JacsError::CryptoError(msg) => { + assert!(msg.contains("END marker"), "Error: {}", msg); + } + other => panic!("Expected CryptoError, got: {:?}", other), + } + } + + #[test] + fn test_decode_public_key_invalid_base64() { + let invalid = "not-valid-base64!!!"; + + let result = decode_public_key(invalid); + assert!(result.is_err()); + match result.unwrap_err() { + JacsError::CryptoError(msg) => { + assert!(msg.contains("base64"), "Error: {}", msg); + } + other => panic!("Expected CryptoError, got: {:?}", other), + } + } + + #[test] + fn test_decode_pem_public_key_empty_content() { + let pem = "-----BEGIN PUBLIC KEY----------END PUBLIC KEY-----"; + + let result = decode_pem_public_key(pem); + assert!(result.is_err()); + match result.unwrap_err() { + JacsError::CryptoError(msg) => { + assert!(msg.contains("no content"), "Error: {}", msg); + } + other => panic!("Expected CryptoError, got: {:?}", other), + } + } + + #[test] + fn test_is_retryable_error_timeout() { + let err = JacsError::NetworkError("Request timed out after 30s".to_string()); + assert!(is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_connect() { + let err = JacsError::NetworkError("Failed to connect to server".to_string()); + assert!(is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_http_request() { + let err = JacsError::NetworkError("HTTP request failed".to_string()); + assert!(is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_5xx() { + let err = JacsError::NetworkError("error status 503".to_string()); + assert!(is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_not_retryable_parse() { + let err = JacsError::NetworkError("Failed to parse JSON response".to_string()); + assert!(!is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_not_retryable_key_not_found() { + let err = JacsError::KeyNotFound { + path: "test".to_string(), + }; + assert!(!is_retryable_error(&err)); + } + + #[test] + fn test_is_retryable_error_not_retryable_crypto() { + let err = JacsError::CryptoError("Invalid key format".to_string()); + assert!(!is_retryable_error(&err)); + } + } + + #[cfg(not(target_arch = "wasm32"))] + mod http_tests { + use super::*; + + #[test] + fn test_fetch_public_key_invalid_url() { + // Set an invalid base URL to test error handling + // Disable retries for faster test execution + // SAFETY: This test is run in isolation and the env var is cleaned up after + unsafe { + std::env::set_var("HAI_KEYS_BASE_URL", "http://localhost:1"); + std::env::set_var("HAI_KEY_FETCH_RETRIES", "0"); + } + + let result = fetch_public_key_from_hai("test-agent-id", "1"); + + // Clean up first to ensure it happens even if assertions fail + unsafe { + std::env::remove_var("HAI_KEYS_BASE_URL"); + std::env::remove_var("HAI_KEY_FETCH_RETRIES"); + } + + // Should fail with network error (connection refused) + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + JacsError::NetworkError(msg) => { + assert!( + msg.contains("connect") || msg.contains("failed") || msg.contains("HTTP"), + "Expected connection error, got: {}", + msg + ); + } + other => panic!("Expected NetworkError, got: {:?}", other), + } + } + + #[test] + fn test_fetch_public_key_default_url() { + // Verify default URL is used when env var is not set + // Disable retries for faster test execution + // SAFETY: This test is run in isolation + unsafe { + std::env::remove_var("HAI_KEYS_BASE_URL"); + std::env::set_var("HAI_KEY_FETCH_RETRIES", "0"); + } + + // This will fail (no server), but we can verify it attempted the right URL + let result = fetch_public_key_from_hai("nonexistent-agent", "1"); + + // Clean up + unsafe { + std::env::remove_var("HAI_KEY_FETCH_RETRIES"); + } + + assert!(result.is_err()); + // The error should be network-related (DNS or connection) + match result.unwrap_err() { + JacsError::NetworkError(_) | JacsError::KeyNotFound { .. } => { + // Expected - either network error or 404 + } + other => panic!("Expected NetworkError or KeyNotFound, got: {:?}", other), + } + } + + #[test] + fn test_fetch_public_key_retries_env_var() { + // Test that HAI_KEY_FETCH_RETRIES is respected + // SAFETY: This test is run in isolation + unsafe { + std::env::set_var("HAI_KEY_FETCH_RETRIES", "1"); + std::env::set_var("HAI_KEYS_BASE_URL", "http://localhost:1"); + } + + let start = std::time::Instant::now(); + let _ = fetch_public_key_from_hai("test-agent", "1"); + let elapsed = start.elapsed(); + + // Clean up + unsafe { + std::env::remove_var("HAI_KEY_FETCH_RETRIES"); + std::env::remove_var("HAI_KEYS_BASE_URL"); + } + + // With 1 retry and 1s backoff, should take at least 1 second + // but less than what 3 retries would take (1+2+4=7s) + assert!( + elapsed >= std::time::Duration::from_millis(900), + "Expected at least ~1s for 1 retry, got {:?}", + elapsed + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "Should not take as long as 3 retries, got {:?}", + elapsed + ); + } + } +} diff --git a/jacs/src/agent/mod.rs b/jacs/src/agent/mod.rs index 4703cd19b..101412a6c 100644 --- a/jacs/src/agent/mod.rs +++ b/jacs/src/agent/mod.rs @@ -21,7 +21,9 @@ use crate::crypt::private_key::ZeroizingVec; use crate::crypt::KeyManager; -use crate::dns::bootstrap::verify_pubkey_via_dns_or_embedded; +use crate::dns::bootstrap::{verify_pubkey_via_dns_or_embedded, pubkey_digest_hex}; +#[cfg(not(target_arch = "wasm32"))] +use crate::dns::bootstrap::verify_hai_registration_sync; #[cfg(feature = "observability-convenience")] use crate::observability::convenience::{record_agent_operation, record_signature_verification}; use crate::schema::Schema; @@ -267,6 +269,18 @@ impl Agent { self.value.as_ref() } + /// Get the verification claim from the agent's value. + /// + /// Returns the claim as a string, or None if not set. + /// Valid claims are: "unverified", "verified", "verified-hai.ai" + fn get_verification_claim(&self) -> Option { + self.value + .as_ref()? + .get("jacsVerificationClaim")? + .as_str() + .map(|s| s.to_string()) + } + /// Get the agent's key algorithm pub fn get_key_algorithm(&self) -> Option<&String> { self.key_algorithm.as_ref() @@ -492,11 +506,31 @@ impl Agent { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - // Effective policy + // Claim-based policy enforcement + // "If you claim it, you must prove it" + let verification_claim = self.get_verification_claim(); let domain_present = maybe_domain.is_some(); - let validate = self.dns_validate_enabled.unwrap_or(domain_present); - let strict = self.dns_strict; - let required = self.dns_required.unwrap_or(domain_present); + let (validate, strict, required) = match verification_claim.as_deref() { + Some("verified") | Some("verified-hai.ai") => { + // Verified claims MUST use strict settings + if !domain_present { + return Err(JacsError::VerificationClaimFailed { + claim: verification_claim.unwrap_or_default(), + reason: "Verified agents must have jacsAgentDomain set".to_string(), + } + .into()); + } + // For verified claims: validate=true, strict=true, required=true + (true, true, true) + } + _ => { + // Unverified or missing claim: use existing defaults (presence of domain) + let validate = self.dns_validate_enabled.unwrap_or(domain_present); + let strict = self.dns_strict; + let required = self.dns_required.unwrap_or(domain_present); + (validate, strict, required) + } + }; if validate && domain_present { if let (Some(domain), Some(agent_id_for_dns)) = @@ -542,6 +576,34 @@ impl Agent { } } + // HAI.ai verification for verified-hai.ai claims + // This MUST succeed for agents claiming verified-hai.ai status + #[cfg(not(target_arch = "wasm32"))] + if verification_claim.as_deref() == Some("verified-hai.ai") { + let agent_id_for_hai = maybe_agent_id.clone().unwrap_or_else(|| { + self.id.clone().unwrap_or_default() + }); + let pk_hash = pubkey_digest_hex(&public_key); + + match verify_hai_registration_sync(&agent_id_for_hai, &pk_hash) { + Ok(registration) => { + info!( + "HAI.ai verification successful for agent '{}': verified at {:?}", + agent_id_for_hai, + registration.verified_at + ); + } + Err(e) => { + error!("HAI.ai verification failed for agent '{}': {}", agent_id_for_hai, e); + return Err(JacsError::VerificationClaimFailed { + claim: "verified-hai.ai".to_string(), + reason: e, + } + .into()); + } + } + } + let signature_base64 = match signature.clone() { Some(sig) => sig, _ => json_value[signature_key_from]["signature"] @@ -802,6 +864,36 @@ impl Agent { .into()); } + // Prevent verification claim downgrade + // Security: Once an agent claims verified status, it cannot be downgraded + fn claim_level(claim: &str) -> u8 { + match claim { + "verified-hai.ai" => 2, + "verified" => 1, + _ => 0, // "unverified" or missing + } + } + + let original_claim = original_self + .get("jacsVerificationClaim") + .and_then(|v| v.as_str()) + .unwrap_or("unverified"); + let new_claim = new_self + .get("jacsVerificationClaim") + .and_then(|v| v.as_str()) + .unwrap_or("unverified"); + + if claim_level(new_claim) < claim_level(original_claim) { + return Err(JacsError::VerificationClaimFailed { + claim: new_claim.to_string(), + reason: format!( + "Cannot downgrade from '{}' to '{}'. Create a new agent instead.", + original_claim, new_claim + ), + } + .into()); + } + // validate schema let new_version = Uuid::new_v4().to_string(); let last_version = &original_self["jacsVersion"]; diff --git a/jacs/src/config/mod.rs b/jacs/src/config/mod.rs index 950da2049..dcc73c8f3 100644 --- a/jacs/src/config/mod.rs +++ b/jacs/src/config/mod.rs @@ -13,10 +13,124 @@ use std::collections::HashMap; use std::error::Error; use std::fmt; use std::fs; +use std::str::FromStr; use tracing::{error, info, warn}; use crate::validation::{are_valid_uuid_parts, split_agent_id}; +/// Source for resolving public keys during signature verification. +/// +/// This enum represents the different sources from which JACS can retrieve +/// public keys when verifying document signatures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyResolutionSource { + /// Local filesystem (default). Keys are stored in the data directory + /// under `public_keys/{hash}.pem`. + Local, + /// DNS TXT record verification. Requires the agent to have a domain + /// configured and the public key hash published in DNS. + Dns, + /// HAI key service (https://keys.hai.ai). Fetches public keys from + /// the centralized HAI key distribution service. + Hai, +} + +impl fmt::Display for KeyResolutionSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyResolutionSource::Local => write!(f, "local"), + KeyResolutionSource::Dns => write!(f, "dns"), + KeyResolutionSource::Hai => write!(f, "hai"), + } + } +} + +impl FromStr for KeyResolutionSource { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "local" => Ok(KeyResolutionSource::Local), + "dns" => Ok(KeyResolutionSource::Dns), + "hai" => Ok(KeyResolutionSource::Hai), + other => Err(format!( + "Unknown key resolution source '{}'. Valid options are: local, dns, hai", + other + )), + } + } +} + +/// Returns the configured key resolution order from the `JACS_KEY_RESOLUTION` environment variable. +/// +/// The order determines which sources are tried (and in what sequence) when resolving +/// public keys for signature verification. +/// +/// # Environment Variable +/// +/// `JACS_KEY_RESOLUTION` - Comma-separated list of sources to try in order. +/// +/// # Valid Values +/// +/// - `local` - Local filesystem (keys in `public_keys/` directory) +/// - `dns` - DNS TXT record verification +/// - `hai` - HAI key service (https://keys.hai.ai) +/// +/// # Examples +/// +/// ```bash +/// # Default: try local first, then HAI +/// JACS_KEY_RESOLUTION=local,hai +/// +/// # Include DNS verification +/// JACS_KEY_RESOLUTION=local,dns,hai +/// +/// # Air-gapped mode (local only) +/// JACS_KEY_RESOLUTION=local +/// +/// # HAI only (for testing or cloud-native deployments) +/// JACS_KEY_RESOLUTION=hai +/// ``` +/// +/// # Default +/// +/// If the environment variable is not set or empty, returns `[Local, Hai]`. +/// +/// # Behavior +/// +/// - Invalid source names are logged as warnings and skipped +/// - Duplicate sources are preserved (first occurrence is used) +/// - If parsing results in an empty list, falls back to the default +pub fn get_key_resolution_order() -> Vec { + let default_order = vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]; + + let order_str = match get_env_var("JACS_KEY_RESOLUTION", false) { + Ok(Some(val)) if !val.is_empty() => val, + _ => return default_order, + }; + + let mut sources = Vec::new(); + for part in order_str.split(',') { + match KeyResolutionSource::from_str(part) { + Ok(source) => sources.push(source), + Err(e) => { + warn!("JACS_KEY_RESOLUTION: {}", e); + } + } + } + + if sources.is_empty() { + warn!( + "JACS_KEY_RESOLUTION resulted in empty list after parsing '{}', using default (local,hai)", + order_str + ); + return default_order; + } + + info!("Key resolution order: {:?}", sources); + sources +} + pub mod constants; /* @@ -51,6 +165,7 @@ Environment Variables Supported: - JACS_DNS_VALIDATE - JACS_DNS_STRICT - JACS_DNS_REQUIRED +- JACS_KEY_RESOLUTION (comma-separated: local,dns,hai - controls key lookup order) Usage: ```rust @@ -1674,4 +1789,148 @@ mod tests { assert!(get_field_help("jacs_agent_id_and_version").unwrap().contains("UUID")); assert!(get_field_help("unknown_field").is_none()); } + + // ========================================================================= + // Key Resolution Order Tests + // ========================================================================= + + #[test] + fn test_key_resolution_source_from_str() { + assert_eq!(KeyResolutionSource::from_str("local").unwrap(), KeyResolutionSource::Local); + assert_eq!(KeyResolutionSource::from_str("LOCAL").unwrap(), KeyResolutionSource::Local); + assert_eq!(KeyResolutionSource::from_str("Local").unwrap(), KeyResolutionSource::Local); + assert_eq!(KeyResolutionSource::from_str("dns").unwrap(), KeyResolutionSource::Dns); + assert_eq!(KeyResolutionSource::from_str("DNS").unwrap(), KeyResolutionSource::Dns); + assert_eq!(KeyResolutionSource::from_str("hai").unwrap(), KeyResolutionSource::Hai); + assert_eq!(KeyResolutionSource::from_str("HAI").unwrap(), KeyResolutionSource::Hai); + assert_eq!(KeyResolutionSource::from_str(" hai ").unwrap(), KeyResolutionSource::Hai); + + // Invalid sources + assert!(KeyResolutionSource::from_str("invalid").is_err()); + assert!(KeyResolutionSource::from_str("").is_err()); + } + + #[test] + fn test_key_resolution_source_display() { + assert_eq!(format!("{}", KeyResolutionSource::Local), "local"); + assert_eq!(format!("{}", KeyResolutionSource::Dns), "dns"); + assert_eq!(format!("{}", KeyResolutionSource::Hai), "hai"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_default() { + clear_jacs_env_vars(); + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_local_only() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "local").unwrap(); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![KeyResolutionSource::Local]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_hai_only() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "hai").unwrap(); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![KeyResolutionSource::Hai]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_with_dns() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "local,dns,hai").unwrap(); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![ + KeyResolutionSource::Local, + KeyResolutionSource::Dns, + KeyResolutionSource::Hai, + ]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_case_insensitive() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "LOCAL,DNS,HAI").unwrap(); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![ + KeyResolutionSource::Local, + KeyResolutionSource::Dns, + KeyResolutionSource::Hai, + ]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_skips_invalid() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "local,invalid,hai").unwrap(); + + let order = get_key_resolution_order(); + // Should skip "invalid" but include valid sources + assert_eq!(order, vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_all_invalid_falls_back() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "invalid,also_invalid").unwrap(); + + let order = get_key_resolution_order(); + // Should fall back to default when all sources are invalid + assert_eq!(order, vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_empty_string_falls_back() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", "").unwrap(); + + let order = get_key_resolution_order(); + // Should fall back to default for empty string + assert_eq!(order, vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } + + #[test] + #[serial] + fn test_get_key_resolution_order_whitespace_handling() { + clear_jacs_env_vars(); + set_env_var("JACS_KEY_RESOLUTION", " local , hai ").unwrap(); + + let order = get_key_resolution_order(); + assert_eq!(order, vec![KeyResolutionSource::Local, KeyResolutionSource::Hai]); + + let _ = clear_env_var("JACS_KEY_RESOLUTION"); + } } diff --git a/jacs/src/dns/bootstrap.rs b/jacs/src/dns/bootstrap.rs index 404ff6f1c..068754ecf 100644 --- a/jacs/src/dns/bootstrap.rs +++ b/jacs/src/dns/bootstrap.rs @@ -1,5 +1,6 @@ use crate::crypt::hash::{hash_bytes_raw, hash_public_key}; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DnsRecord { @@ -293,3 +294,153 @@ pub fn verify_pubkey_via_dns_or_embedded( Err("DNS TXT lookup required: domain configured or provide embedded fingerprint".to_string()) } + +// ============================================================================= +// HAI.ai Registration Verification +// ============================================================================= + +/// Information about an agent's HAI.ai registration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HaiRegistration { + /// Whether the agent is verified by HAI.ai + pub verified: bool, + /// ISO 8601 timestamp of when the agent was verified + pub verified_at: Option, + /// Type of registration (e.g., "agent", "organization") + pub registration_type: String, + /// The agent ID as registered with HAI.ai + pub agent_id: String, + /// The public key hash registered with HAI.ai + pub public_key_hash: String, +} + +/// Response from HAI.ai API for agent lookup +#[derive(Clone, Debug, Deserialize)] +struct HaiApiResponse { + #[serde(default)] + verified: bool, + #[serde(default)] + verified_at: Option, + #[serde(default)] + registration_type: Option, + #[serde(default)] + agent_id: Option, + #[serde(default)] + public_key_hash: Option, +} + +/// Check if an agent is registered with HAI.ai. +/// +/// This function queries the HAI.ai API to verify that an agent claiming +/// "verified-hai.ai" status is actually registered. +/// +/// # Arguments +/// +/// * `agent_id` - The JACS agent ID (UUID format) +/// * `public_key_hash` - The SHA-256 hash of the agent's public key (hex encoded) +/// +/// # Returns +/// +/// * `Ok(HaiRegistration)` - Agent is registered and public key matches +/// * `Err(String)` - Verification failed with reason +/// +/// # Errors +/// +/// This function returns an error if: +/// - HAI.ai API is unreachable (network error) +/// - Agent is not registered with HAI.ai +/// - Public key hash doesn't match the registered key +/// +/// # Example +/// +/// ```rust,ignore +/// use jacs::dns::bootstrap::verify_hai_registration_sync; +/// +/// let result = verify_hai_registration_sync( +/// "550e8400-e29b-41d4-a716-446655440000", +/// "sha256-hash-of-public-key" +/// ); +/// +/// match result { +/// Ok(reg) => println!("Agent verified at: {:?}", reg.verified_at), +/// Err(e) => println!("Verification failed: {}", e), +/// } +/// ``` +#[cfg(not(target_arch = "wasm32"))] +pub fn verify_hai_registration_sync( + agent_id: &str, + public_key_hash: &str, +) -> Result { + // 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()); + let url = format!("{}/v1/agents/{}", api_url, agent_id); + + // Build blocking HTTP client with TLS + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + // Make request to HAI.ai API + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .map_err(|e| { + format!( + "HAI.ai verification failed: unable to reach API at {}: {}", + url, e + ) + })?; + + // Check response status + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(format!( + "Agent '{}' is not registered with HAI.ai. \ + Agents claiming 'verified-hai.ai' must be registered at https://hai.ai", + agent_id + )); + } + + if !response.status().is_success() { + return Err(format!( + "HAI.ai API returned error status {}: agent verification failed", + response.status() + )); + } + + // Parse response + let api_response: HaiApiResponse = response + .json() + .map_err(|e| format!("Failed to parse HAI.ai API response: {}", e))?; + + // Verify the agent is actually verified + if !api_response.verified { + return Err(format!( + "Agent '{}' is registered with HAI.ai but not yet verified. \ + Complete the verification process at https://hai.ai", + agent_id + )); + } + + // Verify public key hash matches + let registered_hash = api_response.public_key_hash.as_deref().unwrap_or(""); + if !registered_hash.eq_ignore_ascii_case(public_key_hash) { + return Err(format!( + "Public key mismatch: agent '{}' is registered with HAI.ai \ + but with a different public key. Expected hash '{}', got '{}'", + agent_id, + &public_key_hash[..public_key_hash.len().min(16)], + ®istered_hash[..registered_hash.len().min(16)] + )); + } + + Ok(HaiRegistration { + verified: true, + verified_at: api_response.verified_at, + registration_type: api_response.registration_type.unwrap_or_else(|| "agent".to_string()), + agent_id: api_response.agent_id.unwrap_or_else(|| agent_id.to_string()), + public_key_hash: registered_hash.to_string(), + }) +} diff --git a/jacs/src/error.rs b/jacs/src/error.rs index c8ad35de5..8c9099dd5 100644 --- a/jacs/src/error.rs +++ b/jacs/src/error.rs @@ -205,6 +205,15 @@ pub enum JacsError { reason: String, }, + // === Size Limit Errors === + /// Document exceeds the maximum allowed size. + /// + /// The default maximum size is 10MB, configurable via `JACS_MAX_DOCUMENT_SIZE`. + DocumentTooLarge { + size: usize, + max_size: usize, + }, + // === File Errors === /// File not found at the specified path. FileNotFound { @@ -246,6 +255,16 @@ pub enum JacsError { reason: String, }, + // === Verification Claim Errors === + /// Agent's verification claim could not be satisfied. + /// + /// This occurs when an agent claims a verification level (e.g., "verified" or + /// "verified-hai.ai") but the required security conditions are not met. + VerificationClaimFailed { + claim: String, + reason: String, + }, + // === Agent State Errors === /// No agent is currently loaded. Call create() or load() first. AgentNotLoaded, @@ -360,6 +379,16 @@ impl fmt::Display for JacsError { write!(f, "Invalid DNS record for '{}': {}", domain, reason) } + // Size limits + JacsError::DocumentTooLarge { size, max_size } => { + write!( + f, + "Document too large: {} bytes exceeds maximum allowed size of {} bytes. \ + To increase the limit, set JACS_MAX_DOCUMENT_SIZE environment variable.", + size, max_size + ) + } + // Files JacsError::FileNotFound { path } => { write!( @@ -411,6 +440,50 @@ impl fmt::Display for JacsError { write!(f, "Registration failed: {}", reason) } + // Verification Claims + JacsError::VerificationClaimFailed { claim, reason } => { + write!( + f, + "Verification claim '{}' failed: {}\n\n\ + Fix: ", + claim, reason + )?; + // Provide claim-specific actionable guidance + match claim.as_str() { + "verified" | "verified-hai.ai" if reason.contains("jacsAgentDomain") || reason.contains("domain") => { + write!( + f, + "Add \"jacsAgentDomain\": \"your-domain.com\" to your agent,\n \ + or use \"jacsVerificationClaim\": \"unverified\" if DNS verification is not needed." + )?; + } + "verified-hai.ai" if reason.contains("not registered") || reason.contains("HAI.ai") => { + write!( + f, + "Register your agent at https://hai.ai before using the 'verified-hai.ai' claim,\n \ + or use \"jacsVerificationClaim\": \"verified\" for DNS-only verification." + )?; + } + _ if reason.contains("downgrade") || reason.contains("Cannot downgrade") => { + write!( + f, + "Verification claims cannot be downgraded for security. Create a new agent if you need a lower claim level." + )?; + } + _ => { + write!( + f, + "Ensure all security requirements for '{}' are met.", + claim + )?; + } + } + write!( + f, + "\n\nSee: https://hai.ai/docs/jacs/security#verification-claims" + ) + } + // Agent state JacsError::AgentNotLoaded => { write!( @@ -660,4 +733,46 @@ mod tests { assert!(msg.contains("permission denied"), "Should include the reason"); assert!(msg.contains("parent") || msg.contains("Check"), "Should suggest checking parent directory"); } + + // ========================================================================== + // VERIFICATION CLAIM ERROR TESTS + // ========================================================================== + + #[test] + fn test_verification_claim_domain_error_is_actionable() { + let err = JacsError::VerificationClaimFailed { + claim: "verified".to_string(), + reason: "Verified agents must have jacsAgentDomain set".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("verified"), "Should state the claim"); + assert!(msg.contains("jacsAgentDomain"), "Should mention the required field"); + assert!(msg.contains("Fix:"), "Should provide fix guidance"); + assert!(msg.contains("hai.ai/docs"), "Should include docs link"); + } + + #[test] + fn test_verification_claim_hai_registration_error_is_actionable() { + let err = JacsError::VerificationClaimFailed { + claim: "verified-hai.ai".to_string(), + reason: "Agent 'test-agent' is not registered with HAI.ai".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("verified-hai.ai"), "Should state the claim"); + assert!(msg.contains("not registered") || msg.contains("HAI.ai"), "Should mention registration"); + assert!(msg.contains("Fix:"), "Should provide fix guidance"); + assert!(msg.contains("https://hai.ai"), "Should include registration link"); + } + + #[test] + fn test_verification_claim_downgrade_error_is_actionable() { + let err = JacsError::VerificationClaimFailed { + claim: "unverified".to_string(), + reason: "Cannot downgrade from 'verified' to 'unverified'. Create a new agent instead.".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("downgrade") || msg.contains("Cannot"), "Should explain downgrade block"); + assert!(msg.contains("Fix:"), "Should provide fix guidance"); + assert!(msg.contains("new agent"), "Should suggest creating new agent"); + } } diff --git a/jacs/src/schema/mod.rs b/jacs/src/schema/mod.rs index 1601dc41d..9d12eaf0b 100644 --- a/jacs/src/schema/mod.rs +++ b/jacs/src/schema/mod.rs @@ -26,6 +26,10 @@ pub mod utils; use crate::agent::document::DEFAULT_JACS_DOC_LEVEL; use utils::{DEFAULT_SCHEMA_STRINGS, EmbeddedSchemaResolver}; +// Re-export claim-aware TLS function for tests and external use +#[cfg(not(target_arch = "wasm32"))] +pub use utils::should_accept_invalid_certs_for_claim; + use std::error::Error; use std::fmt; diff --git a/jacs/src/schema/utils.rs b/jacs/src/schema/utils.rs index 20afbdbe0..d3302fa02 100644 --- a/jacs/src/schema/utils.rs +++ b/jacs/src/schema/utils.rs @@ -18,7 +18,113 @@ use tracing::{debug, warn}; /// `JACS_STRICT_TLS=true` /// /// **Security Warning**: Accepting invalid certificates allows MITM attacks. -pub const ACCEPT_INVALID_CERTS_DEFAULT: bool = true; +pub const ACCEPT_INVALID_CERTS_DEFAULT: bool = false; + +/// Default allowed domains for remote schema fetching. +/// +/// Only URLs from these domains will be fetched when resolving remote schemas. +/// Additional domains can be added via the `JACS_SCHEMA_ALLOWED_DOMAINS` environment variable. +pub const DEFAULT_ALLOWED_SCHEMA_DOMAINS: &[&str] = &["hai.ai", "schema.hai.ai"]; + +/// Check if a URL is allowed for schema fetching. +/// +/// A URL is allowed if its host matches one of the allowed domains (either from +/// `DEFAULT_ALLOWED_SCHEMA_DOMAINS` or from the `JACS_SCHEMA_ALLOWED_DOMAINS` env var). +/// +/// # Arguments +/// * `url` - The URL to check +/// +/// # Returns +/// * `Ok(())` if the URL is allowed +/// * `Err(JacsError)` if the URL is blocked +/// Default maximum document size in bytes (10MB). +pub const DEFAULT_MAX_DOCUMENT_SIZE: usize = 10 * 1024 * 1024; + +/// Returns the maximum allowed document size in bytes. +/// +/// The default is 10MB (10 * 1024 * 1024 bytes). This can be overridden by setting +/// the `JACS_MAX_DOCUMENT_SIZE` environment variable to a number of bytes. +/// +/// # Example +/// ```bash +/// # Set max document size to 50MB +/// export JACS_MAX_DOCUMENT_SIZE=52428800 +/// ``` +pub fn max_document_size() -> usize { + std::env::var("JACS_MAX_DOCUMENT_SIZE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_MAX_DOCUMENT_SIZE) +} + +/// Checks if a document's size is within the allowed limit. +/// +/// # Arguments +/// * `data` - The document data as a string slice +/// +/// # Returns +/// * `Ok(())` if the document size is within limits +/// * `Err(JacsError::DocumentTooLarge)` if the document exceeds the maximum size +/// +/// # Example +/// ```rust,ignore +/// use jacs::schema::utils::check_document_size; +/// +/// let large_doc = "x".repeat(100_000_000); // 100MB +/// assert!(check_document_size(&large_doc).is_err()); +/// ``` +pub fn check_document_size(data: &str) -> Result<(), JacsError> { + let max = max_document_size(); + let size = data.len(); + if size > max { + return Err(JacsError::DocumentTooLarge { + size, + max_size: max, + }); + } + Ok(()) +} + +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| { + JacsError::SchemaError(format!("Invalid URL '{}': {}", url, e)) + })?; + + let host = parsed.host_str().ok_or_else(|| { + JacsError::SchemaError(format!("URL '{}' has no host", url)) + })?; + + // Build the list of 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())); + } + } + } + + // Check if the host matches any allowed domain + let host_lower = host.to_lowercase(); + for allowed in &allowed_domains { + let allowed_lower = allowed.to_lowercase(); + // Match exactly or as a subdomain (e.g., "foo.hai.ai" matches "hai.ai") + if host_lower == allowed_lower || host_lower.ends_with(&format!(".{}", allowed_lower)) { + return Ok(()); + } + } + + Err(JacsError::SchemaError(format!( + "Remote schema URL '{}' is not from an allowed domain. \ + Allowed domains: {:?}. \ + To add additional domains, set JACS_SCHEMA_ALLOWED_DOMAINS environment variable (comma-separated).", + url, allowed_domains + ))) +} /// Returns whether to accept invalid TLS certificates. /// @@ -51,6 +157,51 @@ fn should_accept_invalid_certs() -> bool { } ACCEPT_INVALID_CERTS_DEFAULT } + +/// Check TLS strictness considering verification claim. +/// +/// Verified claims (`verified` or `verified-hai.ai`) ALWAYS require strict TLS. +/// This enforces the principle: "If you claim it, you must prove it." +/// +/// # Arguments +/// * `claim` - The agent's verification claim, if any +/// +/// # Returns +/// * `false` for verified claims (never accept invalid certs) +/// * Falls back to `should_accept_invalid_certs()` for unverified/missing claims +/// +/// # Security +/// +/// This function ensures that agents claiming verified status cannot have their +/// connections intercepted via MITM attacks using invalid TLS certificates. +/// +/// # Example +/// ```rust,ignore +/// use jacs::schema::utils::should_accept_invalid_certs_for_claim; +/// +/// // Verified agents always require strict TLS +/// assert!(!should_accept_invalid_certs_for_claim(Some("verified"))); +/// assert!(!should_accept_invalid_certs_for_claim(Some("verified-hai.ai"))); +/// +/// // Unverified agents use env-var based logic +/// let result = should_accept_invalid_certs_for_claim(None); +/// let result2 = should_accept_invalid_certs_for_claim(Some("unverified")); +/// ``` +#[cfg(not(target_arch = "wasm32"))] +pub fn should_accept_invalid_certs_for_claim(claim: Option<&str>) -> bool { + // Verified claims ALWAYS require strict TLS + match claim { + Some("verified") | Some("verified-hai.ai") => false, + _ => should_accept_invalid_certs(), // existing env-var check + } +} + +/// WASM version of claim-aware TLS check. +/// Always returns false (strict TLS) since WASM doesn't support relaxed TLS. +#[cfg(target_arch = "wasm32")] +pub fn should_accept_invalid_certs_for_claim(_claim: Option<&str>) -> bool { + false +} pub static DEFAULT_SCHEMA_STRINGS: phf::Map<&'static str, &'static str> = phf_map! { "schemas/agent/v1/agent.schema.json" => include_str!("../../schemas/agent/v1/agent.schema.json"), "schemas/header/v1/header.schema.json"=> include_str!("../../schemas/header/v1/header.schema.json"), @@ -292,6 +443,9 @@ impl Retrieve for EmbeddedSchemaResolver { /// Not available in WASM builds. #[cfg(not(target_arch = "wasm32"))] fn get_remote_schema(url: &str) -> Result, Box> { + // Check if the URL is from an allowed domain + is_schema_url_allowed(url)?; + let accept_invalid = should_accept_invalid_certs(); let client = reqwest::blocking::Client::builder() .danger_accept_invalid_certs(accept_invalid) @@ -314,32 +468,125 @@ fn get_remote_schema(url: &str) -> Result, Box> { Err(JacsError::SchemaError(format!("Remote URL schemas disabled in WASM: {}", url)).into()) } +/// Check if filesystem schema access is allowed and the path is safe. +/// +/// Filesystem schema access is disabled by default. To enable it, set: +/// `JACS_ALLOW_FILESYSTEM_SCHEMAS=true` +/// +/// When enabled, paths are restricted to: +/// - The `JACS_DATA_DIRECTORY` if set +/// - The `JACS_SCHEMA_DIRECTORY` if set +/// - Paths must not contain path traversal sequences (`..`) +/// +/// # Arguments +/// * `path` - The filesystem path to check +/// +/// # Returns +/// * `Ok(())` if filesystem access is allowed and the path is safe +/// * `Err(JacsError)` if access is denied or the path is unsafe +fn check_filesystem_schema_access(path: &str) -> Result<(), JacsError> { + // Check if filesystem schemas are enabled + let fs_enabled = std::env::var("JACS_ALLOW_FILESYSTEM_SCHEMAS") + .map(|v| v.eq_ignore_ascii_case("true") || v == "1") + .unwrap_or(false); + + if !fs_enabled { + return Err(JacsError::SchemaError(format!( + "Filesystem schema access is disabled. Path '{}' cannot be loaded. \ + To enable filesystem schemas, set JACS_ALLOW_FILESYSTEM_SCHEMAS=true", + path + ))); + } + + // Block path traversal attempts + if path.contains("..") { + return Err(JacsError::SchemaError(format!( + "Path traversal detected in schema path '{}'. \ + Schema paths must not contain '..' sequences.", + path + ))); + } + + // Get allowed directories + let data_dir = std::env::var("JACS_DATA_DIRECTORY").ok(); + let schema_dir = std::env::var("JACS_SCHEMA_DIRECTORY").ok(); + + // If specific directories are configured, check that the path is within them + if data_dir.is_some() || schema_dir.is_some() { + let path_canonical = std::path::Path::new(path); + + // Try to canonicalize the path for comparison (handles symlinks) + // If canonicalization fails (file doesn't exist yet), fall back to the original path + let path_str = if let Ok(canonical) = path_canonical.canonicalize() { + canonical.to_string_lossy().to_string() + } else { + path.to_string() + }; + + let mut allowed = false; + + if let Some(ref data) = data_dir { + let data_path = std::path::Path::new(data); + if let Ok(data_canonical) = data_path.canonicalize() { + if path_str.starts_with(&data_canonical.to_string_lossy().to_string()) { + allowed = true; + } + } else if path_str.starts_with(data) { + // Fall back to string prefix check if canonicalization fails + allowed = true; + } + } + + if let Some(ref schema) = schema_dir { + let schema_path = std::path::Path::new(schema); + if let Ok(schema_canonical) = schema_path.canonicalize() { + if path_str.starts_with(&schema_canonical.to_string_lossy().to_string()) { + allowed = true; + } + } else if path_str.starts_with(schema) { + allowed = true; + } + } + + if !allowed { + return Err(JacsError::SchemaError(format!( + "Schema path '{}' is outside allowed directories. \ + Schemas must be within JACS_DATA_DIRECTORY ({:?}) or JACS_SCHEMA_DIRECTORY ({:?}).", + path, data_dir, schema_dir + ))); + } + } + + Ok(()) +} + /// Resolves a schema from various sources based on the provided path. /// /// # Arguments /// * `rawpath` - The path or URL to the schema. Can be: /// - A key in DEFAULT_SCHEMA_STRINGS /// - A URL (will be converted to embedded schema) -/// - A remote URL (will attempt fetch) -/// - A local filesystem path +/// - A remote URL (will attempt fetch, subject to domain allowlist) +/// - A local filesystem path (requires `JACS_ALLOW_FILESYSTEM_SCHEMAS=true`) /// /// # Resolution Order /// 1. Removes leading slash if present /// 2. Checks DEFAULT_SCHEMA_STRINGS for direct match /// 3. For URLs: /// - hai.ai URLs: Converts to embedded schema lookup -/// - Other URLs: Attempts remote fetch -/// 4. Checks local filesystem +/// - Other URLs: Checks domain allowlist, then attempts remote fetch +/// 4. Checks local filesystem (if enabled via `JACS_ALLOW_FILESYSTEM_SCHEMAS`) /// /// # Security Considerations -/// - Allows unrestricted remote URL fetching -/// - Allows unrestricted filesystem access -/// - Accepts invalid SSL certificates for remote fetching +/// - Remote URLs are restricted to allowed domains (see `DEFAULT_ALLOWED_SCHEMA_DOMAINS`) +/// - Filesystem access is disabled by default (opt-in via `JACS_ALLOW_FILESYSTEM_SCHEMAS`) +/// - Path traversal (`..`) is blocked for filesystem paths +/// - TLS certificate validation is enabled by default (can be relaxed for development) pub fn resolve_schema(rawpath: &str) -> Result, Box> { debug!("Entering resolve_schema function with path: {}", rawpath); let path = rawpath.strip_prefix('/').unwrap_or(rawpath); - // Check embedded schemas + // Check embedded schemas first (always allowed, no security concerns) if let Some(schema_json) = DEFAULT_SCHEMA_STRINGS.get(path) { let schema_value: Value = serde_json::from_str(schema_json)?; return Ok(Arc::new(schema_value)); @@ -358,10 +605,13 @@ pub fn resolve_schema(rawpath: &str) -> Result, Box> { path, relative_path, DEFAULT_SCHEMA_STRINGS.keys().collect::>() )).into()) } else { + // get_remote_schema already checks the domain allowlist get_remote_schema(path) } } else { - // check filesystem + // Filesystem path - check security restrictions + check_filesystem_schema_access(path)?; + let storage = MultiStorage::default_new()?; if storage.file_exists(path, None)? { let schema_json = String::from_utf8(storage.get_file(path, None)?)?; diff --git a/jacs/src/simple.rs b/jacs/src/simple.rs index 58a9efe9b..6e1c258ee 100644 --- a/jacs/src/simple.rs +++ b/jacs/src/simple.rs @@ -3,6 +3,21 @@ //! This module provides a clean, developer-friendly API for the most common //! JACS operations: creating agents, signing messages/files, and verification. //! +//! # IMPORTANT: Signing is Sacred +//! +//! **Signing a document is a permanent, irreversible cryptographic commitment.** +//! +//! When an agent signs a document: +//! - The signature creates proof that binds the signer to the content forever +//! - The signer cannot deny having signed (non-repudiation) +//! - Anyone can verify the signature at any time +//! - The signer is accountable for what they signed +//! +//! **Always review documents carefully before signing.** Do not sign: +//! - Content you haven't read or don't understand +//! - Documents whose implications you haven't considered +//! - Anything you wouldn't want permanently associated with your identity +//! //! # Quick Start (Instance-based API - Recommended) //! //! ```rust,ignore @@ -11,7 +26,7 @@ //! // Create a new agent identity //! let agent = SimpleAgent::create("my-agent", None, None)?; //! -//! // Sign a message +//! // Sign a message (REVIEW CONTENT FIRST!) //! let signed = agent.sign_message(&serde_json::json!({"hello": "world"}))?; //! //! // Verify the signed document @@ -38,12 +53,13 @@ //! - **Safety**: Errors include actionable guidance //! - **Consistency**: Same API shape across Rust, Python, Go, and NPM //! - **Thread Safety**: Instance-based design avoids global mutable state +//! - **Signing Gravity**: Documentation emphasizes the sacred nature of signing use crate::agent::document::DocumentTraits; use crate::agent::Agent; use crate::error::JacsError; use crate::mime::mime_from_extension; -use crate::schema::utils::ValueExt; +use crate::schema::utils::{check_document_size, ValueExt}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::fs; @@ -501,6 +517,9 @@ impl SimpleAgent { /// ``` #[must_use = "updated agent JSON must be used or stored"] pub fn update_agent(&self, new_agent_data: &str) -> Result { + // Check document size before processing + check_document_size(new_agent_data)?; + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { message: format!("Failed to acquire agent lock: {}", e), })?; @@ -557,6 +576,9 @@ impl SimpleAgent { attachments: Option>, embed: Option, ) -> Result { + // Check document size before processing + check_document_size(new_data)?; + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { message: format!("Failed to acquire agent lock: {}", e), })?; @@ -584,6 +606,19 @@ impl SimpleAgent { /// Signs arbitrary data as a JACS message. /// + /// # IMPORTANT: Signing is Sacred + /// + /// **Signing a document is an irreversible, permanent commitment.** Once signed: + /// - The signature creates cryptographic proof binding you to the content + /// - You cannot deny having signed (non-repudiation) + /// - The signed document can be verified by anyone forever + /// - You are accountable for the content you signed + /// + /// **Before signing, always:** + /// - Read and understand the complete document content + /// - Verify the data represents your actual intent + /// - Confirm you have authority to make this commitment + /// /// The data can be a JSON object, string, or any serializable value. /// /// # Arguments @@ -601,6 +636,7 @@ impl SimpleAgent { /// use serde_json::json; /// /// let agent = SimpleAgent::load(None)?; + /// // Review data carefully before signing! /// let signed = agent.sign_message(&json!({"action": "approve", "amount": 100}))?; /// println!("Document ID: {}", signed.document_id); /// ``` @@ -608,10 +644,6 @@ impl SimpleAgent { pub fn sign_message(&self, data: &Value) -> Result { debug!("sign_message() called"); - let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { - message: format!("Failed to acquire agent lock: {}", e), - })?; - // Wrap the data in a minimal document structure let doc_content = json!({ "jacsType": "message", @@ -619,8 +651,16 @@ impl SimpleAgent { "content": data }); + // Check document size before processing + let doc_string = doc_content.to_string(); + check_document_size(&doc_string)?; + + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { + message: format!("Failed to acquire agent lock: {}", e), + })?; + let jacs_doc = agent - .create_document_and_load(&doc_content.to_string(), None, None) + .create_document_and_load(&doc_string, None, None) .map_err(|e| JacsError::SigningFailed { reason: format!( "{}. Ensure the agent is properly initialized with load() or create() and has valid keys.", @@ -647,6 +687,20 @@ impl SimpleAgent { /// Signs a file with optional content embedding. /// + /// # IMPORTANT: Signing is Sacred + /// + /// **Signing a file is an irreversible, permanent commitment.** Your signature: + /// - Cryptographically binds you to the file's exact contents + /// - Cannot be revoked or denied (non-repudiation) + /// - Creates permanent proof that you attested to this file + /// - Makes you accountable for the file content forever + /// + /// **Before signing any file:** + /// - Review the complete file contents + /// - Verify the file has not been tampered with + /// - Confirm you intend to attest to this specific file + /// - Understand your signature is permanent and verifiable + /// /// # Arguments /// /// * `file_path` - Path to the file to sign @@ -663,7 +717,7 @@ impl SimpleAgent { /// /// let agent = SimpleAgent::load(None)?; /// - /// // Embed the file content + /// // Review file before signing! Embed the file content /// let signed = agent.sign_file("contract.pdf", true)?; /// /// // Or just reference it by hash @@ -724,6 +778,15 @@ impl SimpleAgent { /// Signs multiple messages in a batch operation. /// + /// # IMPORTANT: Each Signature is Sacred + /// + /// **Every signature in the batch is an irreversible, permanent commitment.** + /// Batch signing is convenient, but each document is independently signed with + /// full cryptographic weight. Before batch signing: + /// - Review ALL messages in the batch + /// - Verify each message represents your intent + /// - Understand you are making multiple permanent commitments + /// /// This is more efficient than calling `sign_message` repeatedly because it /// amortizes the overhead of acquiring locks and key operations across all /// messages. @@ -751,6 +814,7 @@ impl SimpleAgent { /// /// let agent = SimpleAgent::load(None)?; /// + /// // Review ALL messages before batch signing! /// let messages = vec![ /// json!({"action": "approve", "item": 1}), /// json!({"action": "approve", "item": 2}), @@ -783,10 +847,6 @@ impl SimpleAgent { "Signing batch of messages" ); - let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { - message: format!("Failed to acquire agent lock: {}", e), - })?; - // Prepare all document JSON strings let doc_strings: Vec = messages .iter() @@ -800,6 +860,15 @@ impl SimpleAgent { }) .collect(); + // Check size of each document before processing + for doc_str in &doc_strings { + check_document_size(doc_str)?; + } + + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { + message: format!("Failed to acquire agent lock: {}", e), + })?; + // Convert to slice of &str for the batch API let doc_refs: Vec<&str> = doc_strings.iter().map(|s| s.as_str()).collect(); @@ -868,6 +937,9 @@ impl SimpleAgent { pub fn verify(&self, signed_document: &str) -> Result { debug!("verify() called"); + // Check document size before processing + check_document_size(signed_document)?; + // Parse the document to validate JSON let _: Value = serde_json::from_str(signed_document).map_err(|e| { JacsError::DocumentMalformed { @@ -1083,6 +1155,9 @@ impl SimpleAgent { debug!("create_agreement() called with {} signers", agent_ids.len()); + // Check document size before processing + check_document_size(document)?; + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { message: format!("Failed to acquire agent lock: {}", e), })?; @@ -1120,6 +1195,25 @@ impl SimpleAgent { /// Signs an existing multi-party agreement as the current agent. /// + /// # IMPORTANT: Signing Agreements is Sacred + /// + /// **Signing an agreement is a binding, irreversible commitment.** When you sign: + /// - You cryptographically commit to the agreement terms + /// - Your signature is permanent and cannot be revoked + /// - All parties can verify your commitment forever + /// - You are legally and ethically bound to the agreement content + /// + /// **Multi-party agreements are especially significant** because: + /// - Your signature joins a binding consensus + /// - Other parties rely on your commitment + /// - Breaking the agreement may harm other signers + /// + /// **Before signing any agreement:** + /// - Read the complete agreement document carefully + /// - Verify all terms are acceptable to you + /// - Confirm you have authority to bind yourself/your organization + /// - Understand the obligations you are accepting + /// /// When an agreement is created, each required signer must call this function /// to add their signature. The agreement is complete when all signers have signed. /// @@ -1141,7 +1235,7 @@ impl SimpleAgent { /// // Receive agreement from coordinator /// let agreement_json = receive_agreement_from_coordinator(); /// - /// // Sign it + /// // REVIEW CAREFULLY before signing! /// let signed = agent.sign_agreement(&agreement_json)?; /// /// // Send back to coordinator or pass to next signer @@ -1151,6 +1245,9 @@ impl SimpleAgent { pub fn sign_agreement(&self, document: &str) -> Result { use crate::agent::agreement::Agreement; + // Check document size before processing + check_document_size(document)?; + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { message: format!("Failed to acquire agent lock: {}", e), })?; @@ -1221,6 +1318,9 @@ impl SimpleAgent { /// ``` #[must_use = "agreement status must be checked"] pub fn check_agreement(&self, document: &str) -> Result { + // Check document size before processing + check_document_size(document)?; + let mut agent = self.agent.lock().map_err(|e| JacsError::Internal { message: format!("Failed to acquire agent lock: {}", e), })?; diff --git a/jacs/tests/utils.rs b/jacs/tests/utils.rs index c985c7ed2..e617880b2 100644 --- a/jacs/tests/utils.rs +++ b/jacs/tests/utils.rs @@ -256,6 +256,8 @@ pub fn set_min_test_env_vars() { env::set_var("JACS_AGENT_PRIVATE_KEY_FILENAME", "agent-one.private.pem"); env::set_var("JACS_AGENT_PUBLIC_KEY_FILENAME", "agent-one.public.pem"); env::set_var("JACS_DATA_DIRECTORY", &fixtures_dir); + // Enable filesystem schema loading for tests that use custom schemas + env::set_var("JACS_ALLOW_FILESYSTEM_SCHEMAS", "true"); } } @@ -360,6 +362,8 @@ pub fn set_test_env_vars() { "JACS_AGENT_ID_AND_VERSION", "123e4567-e89b-12d3-a456-426614174000:123e4567-e89b-12d3-a456-426614174001", ); + // Enable filesystem schema loading for tests that use custom schemas + env::set_var("JACS_ALLOW_FILESYSTEM_SCHEMAS", "true"); } } @@ -380,6 +384,7 @@ pub fn clear_test_env_vars() { "JACS_DNS_VALIDATE", "JACS_DNS_STRICT", "JACS_DNS_REQUIRED", + "JACS_ALLOW_FILESYSTEM_SCHEMAS", ]; for var in vars { // Clear from thread-safe override store (used by jenv) diff --git a/jacs/tests/verification_claim_tests.rs b/jacs/tests/verification_claim_tests.rs new file mode 100644 index 000000000..4074c5b25 --- /dev/null +++ b/jacs/tests/verification_claim_tests.rs @@ -0,0 +1,337 @@ +//! Tests for verification claim enforcement in JACS agents. +//! +//! These tests validate the claim-based security model where agents can claim +//! verification levels that determine security requirements: +//! - `unverified` (default): Relaxed settings allowed +//! - `verified`: Requires domain, strict DNS, strict TLS +//! - `verified-hai.ai`: Above + HAI.ai registration +//! +//! The principle is: "If you claim it, you must prove it." + +use jacs::dns::bootstrap as dns; +use jacs::error::JacsError; +use jacs::schema::should_accept_invalid_certs_for_claim; + +// ============================================================================= +// Helper functions +// ============================================================================= + +fn sample_pubkey() -> Vec { + b"verification-claim-test-public-key".to_vec() +} + +// ============================================================================= +// DNS Policy Tests Based on Verification Claim +// ============================================================================= + +/// Test that unverified agents can use relaxed DNS settings. +/// Agents without a verification claim or with "unverified" can fall back to +/// embedded fingerprints when DNS lookup fails. +#[test] +fn test_unverified_allows_relaxed_dns() { + let pk = sample_pubkey(); + let agent_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let domain = "nonexistent-subdomain.invalid-tld"; + + // For unverified claims, DNS failures can fall back to embedded fingerprint + let b64 = dns::pubkey_digest_b64(&pk); + let res = dns::verify_pubkey_via_dns_or_embedded( + &pk, + agent_id, + Some(domain), + Some(&b64), // embedded fallback + false, // not strict (unverified behavior) + ); + assert!( + res.is_ok(), + "Unverified agents should allow fallback to embedded fingerprint" + ); +} + +/// Test that verified agents without a domain fail verification. +/// Agents claiming "verified" MUST have jacsAgentDomain set. +#[test] +fn test_verified_without_domain_fails() { + // This test validates that the verification logic in Agent::signature_verification_procedure + // returns an error when a verified claim is made without a domain. + // We test the error type directly since we cannot easily construct an Agent in test context. + + let err = JacsError::VerificationClaimFailed { + claim: "verified".to_string(), + reason: "Verified agents must have jacsAgentDomain set".to_string(), + }; + + let msg = err.to_string(); + assert!( + msg.contains("verified"), + "Error should mention the claim type" + ); + assert!( + msg.contains("jacsAgentDomain") || msg.contains("domain"), + "Error should mention domain requirement" + ); +} + +/// Test that verified agents enforce strict DNS. +/// When an agent claims "verified", DNS lookups must use DNSSEC validation. +#[test] +fn test_verified_enforces_strict_dns() { + let pk = sample_pubkey(); + let agent_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let domain = "nonexistent-subdomain.invalid-tld"; + + // For verified claims, strict=true is required (no fallback allowed) + let res = dns::verify_pubkey_via_dns_or_embedded( + &pk, + agent_id, + Some(domain), + None, // no embedded fallback for verified + true, // strict DNS (verified behavior) + ); + + assert!( + res.is_err(), + "Verified agents should fail when strict DNS lookup fails without fallback" + ); + let err_msg = res.unwrap_err(); + assert!( + err_msg.contains("DNSSEC") || err_msg.contains("DNS"), + "Error should indicate DNS/DNSSEC failure" + ); +} + +/// Test backward compatibility: agents without jacsVerificationClaim work as before. +/// Missing claim should be treated as "unverified" with existing DNS behavior. +#[test] +fn test_backward_compat_no_claim() { + let pk = sample_pubkey(); + let agent_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let domain = "nonexistent-subdomain.invalid-tld"; + + // Without a claim (treated as unverified), should behave like legacy: + // DNS failure with embedded fingerprint fallback should succeed + let hex = dns::pubkey_digest_hex(&pk); + let res = dns::verify_pubkey_via_dns_or_embedded( + &pk, + agent_id, + Some(domain), + Some(&hex), // embedded fallback + false, // not strict + ); + + assert!( + res.is_ok(), + "Agents without verification claim should work with existing DNS behavior" + ); +} + +// ============================================================================= +// Claim Downgrade Prevention Tests +// ============================================================================= + +/// Test that verification claims cannot be downgraded. +/// Once an agent claims "verified", it cannot be changed to "unverified". +#[test] +fn test_update_cannot_downgrade_claim() { + // Test the claim_level ordering logic + fn claim_level(claim: &str) -> u8 { + match claim { + "verified-hai.ai" => 2, + "verified" => 1, + _ => 0, // "unverified" or missing + } + } + + // Verify the hierarchy + assert!( + claim_level("verified-hai.ai") > claim_level("verified"), + "verified-hai.ai should be higher than verified" + ); + assert!( + claim_level("verified") > claim_level("unverified"), + "verified should be higher than unverified" + ); + assert!( + claim_level("verified") > claim_level(""), + "verified should be higher than empty/missing" + ); + + // Test downgrade detection + let original = "verified"; + let new_claim = "unverified"; + let is_downgrade = claim_level(new_claim) < claim_level(original); + assert!(is_downgrade, "verified -> unverified should be detected as downgrade"); + + // Test upgrade detection (allowed) + let original2 = "verified"; + let new_claim2 = "verified-hai.ai"; + let is_upgrade = claim_level(new_claim2) > claim_level(original2); + assert!(is_upgrade, "verified -> verified-hai.ai should be detected as upgrade"); + + // Test same level (allowed) + let original3 = "verified"; + let new_claim3 = "verified"; + let is_same = claim_level(new_claim3) == claim_level(original3); + assert!(is_same, "verified -> verified should be same level"); +} + +/// Test that the downgrade error message is actionable. +#[test] +fn test_downgrade_error_is_actionable() { + let err = JacsError::VerificationClaimFailed { + claim: "unverified".to_string(), + reason: "Cannot downgrade from 'verified' to 'unverified'. Create a new agent instead." + .to_string(), + }; + + let msg = err.to_string(); + assert!( + msg.contains("downgrade") || msg.contains("Cannot"), + "Error should explain the downgrade was blocked" + ); + assert!( + msg.contains("Create") || msg.contains("new agent"), + "Error should suggest creating a new agent" + ); +} + +// ============================================================================= +// TLS Strictness Tests Based on Verification Claim +// ============================================================================= + +/// Test that verified agents enforce strict TLS. +/// Agents with "verified" or "verified-hai.ai" claims should never accept invalid certs. +#[test] +fn test_verified_enforces_strict_tls() { + // Test the claim-aware TLS function + assert!( + !should_accept_invalid_certs_for_claim(Some("verified")), + "verified claim should never accept invalid certs" + ); + assert!( + !should_accept_invalid_certs_for_claim(Some("verified-hai.ai")), + "verified-hai.ai claim should never accept invalid certs" + ); +} + +/// Test that unverified agents can use relaxed TLS (based on env var). +#[test] +fn test_unverified_allows_relaxed_tls() { + // For unverified claims, TLS behavior should follow the existing env var logic + // The actual result depends on JACS_STRICT_TLS env var, but the function + // should not force strict mode for unverified agents + + // With None claim (unverified), it should use the env-var based logic + // We can't easily test the env var interaction here, but we test that + // the function exists and accepts None + let _result = should_accept_invalid_certs_for_claim(None); + // The result depends on env vars, so we just verify it runs without panic + + let _result2 = should_accept_invalid_certs_for_claim(Some("unverified")); + // Similarly, unverified should use env-var based logic +} + +// ============================================================================= +// Error Message Quality Tests +// ============================================================================= + +/// Test that VerificationClaimFailed errors include actionable guidance. +#[test] +fn test_verification_error_is_actionable() { + let err = JacsError::VerificationClaimFailed { + claim: "verified".to_string(), + reason: "Verified agents must have jacsAgentDomain set".to_string(), + }; + + let msg = err.to_string(); + + // Should mention the claim + assert!(msg.contains("verified"), "Error should state the claim"); + + // Should explain what's wrong + assert!( + msg.contains("jacsAgentDomain") || msg.contains("domain"), + "Error should explain the missing requirement" + ); + + // Should include actionable guidance (after our enhancement in Task 4) + // For now, just verify the basic error format + assert!( + msg.contains("failed"), + "Error should indicate verification failed" + ); +} + +/// Test that HAI.ai verification errors are clear. +#[test] +fn test_hai_verification_error_is_clear() { + let err = JacsError::VerificationClaimFailed { + claim: "verified-hai.ai".to_string(), + reason: "Agent 'uuid' is not registered with HAI.ai".to_string(), + }; + + let msg = err.to_string(); + assert!( + msg.contains("verified-hai.ai"), + "Error should state the claim" + ); + assert!( + msg.contains("HAI.ai") || msg.contains("registered"), + "Error should mention HAI.ai registration" + ); +} + +// ============================================================================= +// Claim Hierarchy Tests +// ============================================================================= + +/// Test the complete verification claim hierarchy. +#[test] +fn test_claim_hierarchy() { + fn claim_level(claim: &str) -> u8 { + match claim { + "verified-hai.ai" => 2, + "verified" => 1, + _ => 0, + } + } + + // Test complete ordering + assert_eq!(claim_level("unverified"), 0); + assert_eq!(claim_level(""), 0); + assert_eq!(claim_level("verified"), 1); + assert_eq!(claim_level("verified-hai.ai"), 2); + + // Unknown claims should be treated as unverified + assert_eq!(claim_level("invalid-claim"), 0); + assert_eq!(claim_level("super-verified"), 0); +} + +/// Test that only legitimate upgrades are allowed. +#[test] +fn test_allowed_claim_transitions() { + fn claim_level(claim: &str) -> u8 { + match claim { + "verified-hai.ai" => 2, + "verified" => 1, + _ => 0, + } + } + + fn is_allowed_transition(from: &str, to: &str) -> bool { + claim_level(to) >= claim_level(from) + } + + // Allowed transitions + assert!(is_allowed_transition("unverified", "unverified")); + assert!(is_allowed_transition("unverified", "verified")); + assert!(is_allowed_transition("unverified", "verified-hai.ai")); + assert!(is_allowed_transition("verified", "verified")); + assert!(is_allowed_transition("verified", "verified-hai.ai")); + assert!(is_allowed_transition("verified-hai.ai", "verified-hai.ai")); + + // Disallowed transitions (downgrades) + assert!(!is_allowed_transition("verified", "unverified")); + assert!(!is_allowed_transition("verified-hai.ai", "verified")); + assert!(!is_allowed_transition("verified-hai.ai", "unverified")); +} diff --git a/jacsgo/README.md b/jacsgo/README.md index 8a709de72..39626c418 100644 --- a/jacsgo/README.md +++ b/jacsgo/README.md @@ -108,6 +108,109 @@ signed, _ := jacs.SignFile("contract.pdf", false) signed, _ := jacs.SignFile("contract.pdf", true) ``` +## HAI Integration + +The Go bindings include a pure Go HTTP client for interacting with HAI.ai services. + +### HAI Client + +```go +import jacs "github.com/HumanAssisted/JACS/jacsgo" + +// Create a HAI client +client := jacs.NewHaiClient("https://api.hai.ai", + jacs.WithAPIKey("your-api-key"), + jacs.WithTimeout(30 * time.Second)) + +// Test connectivity +ok, err := client.TestConnection() +if err != nil { + log.Fatal(err) +} + +// Check agent registration status +status, err := client.Status("agent-uuid") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Registered: %t\n", status.Registered) + +// Register an agent (requires agent JSON) +result, err := client.RegisterWithJSON(agentJSON) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Registered with JACS ID: %s\n", result.JacsID) +``` + +### Fetch Remote Keys + +Fetch public keys from HAI's key distribution service for signature verification: + +```go +// Fetch by agent ID and version +keyInfo, err := jacs.FetchRemoteKey("agent-uuid", "latest") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Algorithm: %s\n", keyInfo.Algorithm) +fmt.Printf("Public Key Hash: %s\n", keyInfo.PublicKeyHash) + +// Fetch by public key hash +keyInfo, err = jacs.FetchKeyByHash("abc123...") +if err != nil { + log.Fatal(err) +} +``` + +### HAI API Reference + +| Function | Description | +|----------|-------------| +| `NewHaiClient(endpoint, opts...)` | Create HAI client with options | +| `client.TestConnection()` | Verify HAI server connectivity | +| `client.Status(agentID)` | Check agent registration status | +| `client.RegisterWithJSON(json)` | Register agent with HAI | +| `client.Benchmark(agentID, suite)` | Run benchmark suite | +| `FetchRemoteKey(agentID, version)` | Fetch public key from HAI | +| `FetchKeyByHash(hash)` | Fetch public key by hash | + +### HAI Types + +```go +// Registration result +type RegistrationResult struct { + AgentID string // Agent's unique identifier + JacsID string // JACS document ID from HAI + DNSVerified bool // DNS verification status + Signatures []HaiSignature // HAI attestation signatures +} + +// Registration status +type StatusResult struct { + Registered bool // Whether agent is registered + AgentID string // Agent's JACS ID + RegistrationID string // HAI registration ID + RegisteredAt string // ISO 8601 timestamp + HaiSignatures []string // HAI signature IDs +} + +// Public key info from HAI key service +type PublicKeyInfo struct { + PublicKey []byte // Raw public key (DER encoded) + Algorithm string // e.g., "ed25519", "rsa-pss-sha256" + PublicKeyHash string // SHA-256 hash + AgentID string // Agent ID + Version string // Key version +} +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `HAI_KEYS_BASE_URL` | Base URL for HAI key service | `https://keys.hai.ai` | + ## Building Requires the Rust library. From the jacsgo directory: @@ -119,4 +222,5 @@ make build ## See Also - [JACS Documentation](https://hai.ai/jacs) +- [HAI.ai](https://hai.ai) - [Examples](./examples/) diff --git a/jacsgo/hai.go b/jacsgo/hai.go new file mode 100644 index 000000000..4ac414995 --- /dev/null +++ b/jacsgo/hai.go @@ -0,0 +1,571 @@ +package jacs + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// ============================================================================= +// HAI Error Types +// ============================================================================= + +// HaiError represents errors from HAI operations. +type HaiError struct { + Kind HaiErrorKind + Message string +} + +// HaiErrorKind categorizes HAI errors. +type HaiErrorKind int + +const ( + // HaiErrorConnection indicates a network connection failure. + HaiErrorConnection HaiErrorKind = iota + // HaiErrorRegistration indicates agent registration failed. + HaiErrorRegistration + // HaiErrorAuthRequired indicates authentication is required. + HaiErrorAuthRequired + // HaiErrorInvalidResponse indicates the server returned an invalid response. + HaiErrorInvalidResponse + // HaiErrorKeyNotFound indicates the requested key was not found. + HaiErrorKeyNotFound +) + +func (e *HaiError) Error() string { + return e.Message +} + +func newHaiError(kind HaiErrorKind, format string, args ...interface{}) *HaiError { + return &HaiError{ + Kind: kind, + Message: fmt.Sprintf(format, args...), + } +} + +// ============================================================================= +// HAI Response Types +// ============================================================================= + +// HaiSignature represents a signature from HAI. +type HaiSignature struct { + // KeyID is the identifier of the key used for signing. + KeyID string `json:"key_id"` + // Algorithm is the signing algorithm (e.g., "Ed25519", "ECDSA-P256"). + Algorithm string `json:"algorithm"` + // Signature is the base64-encoded signature. + Signature string `json:"signature"` + // SignedAt is the ISO 8601 timestamp of when the signature was created. + SignedAt string `json:"signed_at"` +} + +// RegistrationResult contains the result of registering an agent with HAI. +type RegistrationResult struct { + // AgentID is the agent's unique identifier. + AgentID string `json:"agent_id"` + // JacsID is the JACS document ID assigned by HAI. + JacsID string `json:"jacs_id"` + // DNSVerified indicates whether DNS verification was successful. + DNSVerified bool `json:"dns_verified"` + // Signatures contains HAI attestation signatures. + Signatures []HaiSignature `json:"signatures"` +} + +// StatusResult contains the registration status of an agent with HAI. +type StatusResult struct { + // Registered indicates whether the agent is registered with HAI. + Registered bool `json:"registered"` + // AgentID is the agent's JACS ID. + AgentID string `json:"agent_id"` + // RegistrationID is the HAI registration ID. + RegistrationID string `json:"registration_id"` + // RegisteredAt is the ISO 8601 timestamp of when the agent was registered. + RegisteredAt string `json:"registered_at"` + // HaiSignatures contains the list of HAI signature IDs. + HaiSignatures []string `json:"hai_signatures"` +} + +// PublicKeyInfo contains information about a public key fetched from HAI. +type PublicKeyInfo struct { + // PublicKey contains the raw public key bytes (DER encoded). + PublicKey []byte `json:"public_key"` + // Algorithm is the cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256"). + Algorithm string `json:"algorithm"` + // PublicKeyHash is the SHA-256 hash of the public key. + PublicKeyHash string `json:"public_key_hash"` + // AgentID is the agent ID the key belongs to. + AgentID string `json:"agent_id"` + // Version is the version of the key. + Version string `json:"version"` +} + +// BenchmarkResult contains the result of a benchmark run. +type BenchmarkResult struct { + // RunID is the unique identifier for the benchmark run. + RunID string `json:"run_id"` + // Suite is the benchmark suite that was run. + Suite string `json:"suite"` + // Score is the overall score (0.0 to 1.0). + Score float64 `json:"score"` + // Results contains individual test results. + Results []BenchmarkTestResult `json:"results"` + // CompletedAt is the ISO 8601 timestamp of when the benchmark completed. + CompletedAt string `json:"completed_at"` +} + +// BenchmarkTestResult contains an individual test result within a benchmark. +type BenchmarkTestResult struct { + // Name is the test name. + Name string `json:"name"` + // Passed indicates whether the test passed. + Passed bool `json:"passed"` + // Score is the test score (0.0 to 1.0). + Score float64 `json:"score"` + // Message contains optional details (e.g., error message). + Message string `json:"message,omitempty"` +} + +// ============================================================================= +// HAI Client +// ============================================================================= + +// HaiClient provides methods for interacting with HAI.ai services. +type HaiClient struct { + endpoint string + apiKey string + httpClient *http.Client +} + +// HaiClientOption is a functional option for configuring HaiClient. +type HaiClientOption func(*HaiClient) + +// WithAPIKey sets the API key for authentication. +func WithAPIKey(apiKey string) HaiClientOption { + return func(c *HaiClient) { + c.apiKey = apiKey + } +} + +// WithHTTPClient sets a custom HTTP client. +func WithHTTPClient(client *http.Client) HaiClientOption { + return func(c *HaiClient) { + c.httpClient = client + } +} + +// WithTimeout sets the HTTP client timeout. +func WithTimeout(timeout time.Duration) HaiClientOption { + return func(c *HaiClient) { + c.httpClient.Timeout = timeout + } +} + +// NewHaiClient creates a new HAI client. +// +// Parameters: +// - endpoint: Base URL of the HAI API (e.g., "https://api.hai.ai") +// - opts: Optional configuration options +// +// Example: +// +// client := jacs.NewHaiClient("https://api.hai.ai", +// jacs.WithAPIKey("your-api-key"), +// jacs.WithTimeout(30 * time.Second)) +func NewHaiClient(endpoint string, opts ...HaiClientOption) *HaiClient { + // Trim trailing slash + if len(endpoint) > 0 && endpoint[len(endpoint)-1] == '/' { + endpoint = endpoint[:len(endpoint)-1] + } + + client := &HaiClient{ + endpoint: endpoint, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +// Endpoint returns the base endpoint URL. +func (c *HaiClient) Endpoint() string { + return c.endpoint +} + +// TestConnection verifies connectivity to the HAI server. +// +// Returns true if the server is reachable and healthy. +func (c *HaiClient) TestConnection() (bool, error) { + url := fmt.Sprintf("%s/health", c.endpoint) + + resp, err := c.httpClient.Get(url) + if err != nil { + return false, newHaiError(HaiErrorConnection, "connection failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false, newHaiError(HaiErrorConnection, "server returned status: %d", resp.StatusCode) + } + + // Try to parse health response + var health struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + // 2xx without JSON body is still success + return true, nil + } + + return health.Status == "ok" || health.Status == "healthy", nil +} + +// Note: Register(agent *JacsAgent) is not provided because extracting +// agent JSON from the FFI-based JacsAgent is not yet implemented. +// Use RegisterWithJSON(agentJSON string) instead. + +// RegisterWithJSON registers a JACS agent with HAI using raw agent JSON. +// +// This is the preferred method when you have the agent JSON available. +func (c *HaiClient) RegisterWithJSON(agentJSON string) (*RegistrationResult, error) { + if c.apiKey == "" { + return nil, newHaiError(HaiErrorAuthRequired, "authentication required: provide an API key") + } + + url := fmt.Sprintf("%s/api/v1/agents/register", c.endpoint) + + reqBody := struct { + AgentJSON string `json:"agent_json"` + }{ + AgentJSON: agentJSON, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "connection failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, newHaiError(HaiErrorRegistration, "status %d: %s", resp.StatusCode, string(body)) + } + + var result RegistrationResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to decode response: %v", err) + } + + return &result, nil +} + +// Status checks the registration status of an agent with HAI. +func (c *HaiClient) Status(agentID string) (*StatusResult, error) { + if c.apiKey == "" { + return nil, newHaiError(HaiErrorAuthRequired, "authentication required: provide an API key") + } + + url := fmt.Sprintf("%s/api/v1/agents/%s/status", c.endpoint, agentID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "connection failed: %v", err) + } + defer resp.Body.Close() + + // Handle 404 as "not registered" + if resp.StatusCode == http.StatusNotFound { + return &StatusResult{ + Registered: false, + AgentID: agentID, + }, nil + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, newHaiError(HaiErrorInvalidResponse, "status %d: %s", resp.StatusCode, string(body)) + } + + var result StatusResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to decode response: %v", err) + } + + result.Registered = true + if result.AgentID == "" { + result.AgentID = agentID + } + + return &result, nil +} + +// Benchmark runs a benchmark suite for an agent. +func (c *HaiClient) Benchmark(agentID, suite string) (*BenchmarkResult, error) { + if c.apiKey == "" { + return nil, newHaiError(HaiErrorAuthRequired, "authentication required: provide an API key") + } + + url := fmt.Sprintf("%s/api/v1/benchmarks/run", c.endpoint) + + reqBody := struct { + AgentID string `json:"agent_id"` + Suite string `json:"suite"` + }{ + AgentID: agentID, + Suite: suite, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "connection failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, newHaiError(HaiErrorInvalidResponse, "status %d: %s", resp.StatusCode, string(body)) + } + + var result BenchmarkResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to decode response: %v", err) + } + + return &result, nil +} + +// ============================================================================= +// Remote Key Fetch Functions +// ============================================================================= + +// Default base URL for HAI key service +const defaultHaiKeysBaseURL = "https://keys.hai.ai" + +// FetchRemoteKey fetches a public key from HAI's key distribution service. +// +// This function retrieves the public key for a specific agent and version +// from the HAI key distribution service. It is used to obtain trusted public +// keys for verifying agent signatures without requiring local key storage. +// +// Parameters: +// - agentID: The unique identifier of the agent whose key to fetch +// - version: The version of the agent's key to fetch. Use "latest" for +// the most recent version. +// +// Returns PublicKeyInfo containing the public key, algorithm, and hash on success. +// +// Environment Variables: +// - HAI_KEYS_BASE_URL: Base URL for the key service. Defaults to "https://keys.hai.ai" +// +// Example: +// +// keyInfo, err := jacs.FetchRemoteKey("550e8400-e29b-41d4-a716-446655440000", "latest") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Algorithm: %s\n", keyInfo.Algorithm) +func FetchRemoteKey(agentID, version string) (*PublicKeyInfo, error) { + baseURL := os.Getenv("HAI_KEYS_BASE_URL") + if baseURL == "" { + baseURL = defaultHaiKeysBaseURL + } + + return FetchRemoteKeyFromURL(baseURL, agentID, version) +} + +// FetchRemoteKeyFromURL fetches a public key from a specific key service URL. +// +// This is useful for testing or using alternative key distribution services. +func FetchRemoteKeyFromURL(baseURL, agentID, version string) (*PublicKeyInfo, error) { + // Trim trailing slash + if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { + baseURL = baseURL[:len(baseURL)-1] + } + + url := fmt.Sprintf("%s/jacs/v1/agents/%s/keys/%s", baseURL, agentID, version) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "failed to fetch key: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, newHaiError(HaiErrorKeyNotFound, "public key not found for agent '%s' version '%s'", agentID, version) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, newHaiError(HaiErrorConnection, "status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response - HAI returns the key in a JSON envelope + var keyResp struct { + PublicKey string `json:"public_key"` // Base64-encoded DER + Algorithm string `json:"algorithm"` // e.g., "ed25519" + PublicKeyHash string `json:"public_key_hash"` // SHA-256 hash + AgentID string `json:"agent_id"` + Version string `json:"version"` + } + + if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to decode key response: %v", err) + } + + // Decode base64 public key + publicKey, err := base64.StdEncoding.DecodeString(keyResp.PublicKey) + if err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "invalid public key encoding: %v", err) + } + + return &PublicKeyInfo{ + PublicKey: publicKey, + Algorithm: keyResp.Algorithm, + PublicKeyHash: keyResp.PublicKeyHash, + AgentID: keyResp.AgentID, + Version: keyResp.Version, + }, nil +} + +// FetchKeyByHash fetches a public key by its hash from HAI's key service. +// +// Parameters: +// - publicKeyHash: The SHA-256 hash of the public key to fetch +// +// This is useful when you have a signature that includes the key hash +// but not the full key. +func FetchKeyByHash(publicKeyHash string) (*PublicKeyInfo, error) { + baseURL := os.Getenv("HAI_KEYS_BASE_URL") + if baseURL == "" { + baseURL = defaultHaiKeysBaseURL + } + + return FetchKeyByHashFromURL(baseURL, publicKeyHash) +} + +// FetchKeyByHashFromURL fetches a public key by hash from a specific URL. +func FetchKeyByHashFromURL(baseURL, publicKeyHash string) (*PublicKeyInfo, error) { + // Trim trailing slash + if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { + baseURL = baseURL[:len(baseURL)-1] + } + + url := fmt.Sprintf("%s/jacs/v1/keys/by-hash/%s", baseURL, publicKeyHash) + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return nil, newHaiError(HaiErrorConnection, "failed to fetch key: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, newHaiError(HaiErrorKeyNotFound, "public key not found for hash '%s'", publicKeyHash) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, newHaiError(HaiErrorConnection, "status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var keyResp struct { + PublicKey string `json:"public_key"` + Algorithm string `json:"algorithm"` + PublicKeyHash string `json:"public_key_hash"` + AgentID string `json:"agent_id"` + Version string `json:"version"` + } + + if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "failed to decode key response: %v", err) + } + + // Decode base64 public key + publicKey, err := base64.StdEncoding.DecodeString(keyResp.PublicKey) + if err != nil { + return nil, newHaiError(HaiErrorInvalidResponse, "invalid public key encoding: %v", err) + } + + return &PublicKeyInfo{ + PublicKey: publicKey, + Algorithm: keyResp.Algorithm, + PublicKeyHash: keyResp.PublicKeyHash, + AgentID: keyResp.AgentID, + Version: keyResp.Version, + }, nil +} + +// ============================================================================= +// Convenience Functions (use default HAI endpoint) +// ============================================================================= + +// DefaultHaiEndpoint is the default HAI API endpoint. +const DefaultHaiEndpoint = "https://api.hai.ai" + +// VerifyAgentWithHai verifies an agent's registration status with HAI. +// +// This is a convenience function that creates a temporary client. +// For multiple operations, create a HaiClient instance instead. +func VerifyAgentWithHai(apiKey, agentID string) (*StatusResult, error) { + client := NewHaiClient(DefaultHaiEndpoint, WithAPIKey(apiKey)) + return client.Status(agentID) +} + +// RegisterAgentWithHai registers an agent with HAI. +// +// This is a convenience function that creates a temporary client. +// For multiple operations, create a HaiClient instance instead. +func RegisterAgentWithHai(apiKey, agentJSON string) (*RegistrationResult, error) { + client := NewHaiClient(DefaultHaiEndpoint, WithAPIKey(apiKey)) + return client.RegisterWithJSON(agentJSON) +} diff --git a/jacsgo/hai_test.go b/jacsgo/hai_test.go new file mode 100644 index 000000000..b8ede4855 --- /dev/null +++ b/jacsgo/hai_test.go @@ -0,0 +1,378 @@ +package jacs + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewHaiClient(t *testing.T) { + client := NewHaiClient("https://api.hai.ai") + if client.Endpoint() != "https://api.hai.ai" { + t.Errorf("expected endpoint 'https://api.hai.ai', got '%s'", client.Endpoint()) + } +} + +func TestNewHaiClientTrimsTrailingSlash(t *testing.T) { + client := NewHaiClient("https://api.hai.ai/") + if client.Endpoint() != "https://api.hai.ai" { + t.Errorf("expected endpoint 'https://api.hai.ai', got '%s'", client.Endpoint()) + } +} + +func TestNewHaiClientWithOptions(t *testing.T) { + client := NewHaiClient("https://api.hai.ai", + WithAPIKey("test-key"), + WithTimeout(60*time.Second)) + + if client.apiKey != "test-key" { + t.Errorf("expected API key 'test-key', got '%s'", client.apiKey) + } +} + +func TestTestConnection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Errorf("expected path '/health', got '%s'", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + client := NewHaiClient(server.URL) + ok, err := client.TestConnection() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected connection test to succeed") + } +} + +func TestTestConnectionFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := NewHaiClient(server.URL) + _, err := client.TestConnection() + if err == nil { + t.Error("expected error for 500 response") + } + + haiErr, ok := err.(*HaiError) + if !ok { + t.Fatalf("expected HaiError, got %T", err) + } + if haiErr.Kind != HaiErrorConnection { + t.Errorf("expected HaiErrorConnection, got %v", haiErr.Kind) + } +} + +func TestStatusSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "registered": true, + "agent_id": "test-agent", + "registration_id": "reg-123", + "registered_at": "2024-01-15T10:30:00Z", + "hai_signatures": []string{"sig-1", "sig-2"}, + }) + })) + defer server.Close() + + client := NewHaiClient(server.URL, WithAPIKey("test-key")) + result, err := client.Status("test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Registered { + t.Error("expected Registered to be true") + } + if result.AgentID != "test-agent" { + t.Errorf("expected AgentID 'test-agent', got '%s'", result.AgentID) + } + if result.RegistrationID != "reg-123" { + t.Errorf("expected RegistrationID 'reg-123', got '%s'", result.RegistrationID) + } + if len(result.HaiSignatures) != 2 { + t.Errorf("expected 2 signatures, got %d", len(result.HaiSignatures)) + } +} + +func TestStatusNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := NewHaiClient(server.URL, WithAPIKey("test-key")) + result, err := client.Status("unknown-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Registered { + t.Error("expected Registered to be false for 404") + } + if result.AgentID != "unknown-agent" { + t.Errorf("expected AgentID 'unknown-agent', got '%s'", result.AgentID) + } +} + +func TestStatusRequiresAPIKey(t *testing.T) { + client := NewHaiClient("https://api.hai.ai") + _, err := client.Status("test-agent") + if err == nil { + t.Error("expected error when API key is not set") + } + + haiErr, ok := err.(*HaiError) + if !ok { + t.Fatalf("expected HaiError, got %T", err) + } + if haiErr.Kind != HaiErrorAuthRequired { + t.Errorf("expected HaiErrorAuthRequired, got %v", haiErr.Kind) + } +} + +func TestRegisterWithJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/agents/register" { + t.Errorf("expected path '/api/v1/agents/register', got '%s'", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + var reqBody struct { + AgentJSON string `json:"agent_json"` + } + json.NewDecoder(r.Body).Decode(&reqBody) + + if reqBody.AgentJSON == "" { + t.Error("expected non-empty agent_json") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "agent_id": "agent-123", + "jacs_id": "jacs-456", + "dns_verified": true, + "signatures": []map[string]string{ + { + "key_id": "key-1", + "algorithm": "Ed25519", + "signature": "c2lnbmF0dXJl", + "signed_at": "2024-01-15T10:30:00Z", + }, + }, + }) + })) + defer server.Close() + + client := NewHaiClient(server.URL, WithAPIKey("test-key")) + result, err := client.RegisterWithJSON(`{"jacsId": "test-agent"}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.AgentID != "agent-123" { + t.Errorf("expected AgentID 'agent-123', got '%s'", result.AgentID) + } + if result.JacsID != "jacs-456" { + t.Errorf("expected JacsID 'jacs-456', got '%s'", result.JacsID) + } + if !result.DNSVerified { + t.Error("expected DNSVerified to be true") + } + if len(result.Signatures) != 1 { + t.Errorf("expected 1 signature, got %d", len(result.Signatures)) + } +} + +func TestBenchmark(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/benchmarks/run" { + t.Errorf("expected path '/api/v1/benchmarks/run', got '%s'", r.URL.Path) + } + + var reqBody struct { + AgentID string `json:"agent_id"` + Suite string `json:"suite"` + } + json.NewDecoder(r.Body).Decode(&reqBody) + + if reqBody.AgentID != "agent-123" { + t.Errorf("expected agent_id 'agent-123', got '%s'", reqBody.AgentID) + } + if reqBody.Suite != "latency" { + t.Errorf("expected suite 'latency', got '%s'", reqBody.Suite) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "run_id": "run-123", + "suite": "latency", + "score": 0.95, + "completed_at": "2024-01-15T10:30:00Z", + "results": []map[string]interface{}{ + { + "name": "test-1", + "passed": true, + "score": 1.0, + }, + }, + }) + })) + defer server.Close() + + client := NewHaiClient(server.URL, WithAPIKey("test-key")) + result, err := client.Benchmark("agent-123", "latency") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.RunID != "run-123" { + t.Errorf("expected RunID 'run-123', got '%s'", result.RunID) + } + if result.Suite != "latency" { + t.Errorf("expected Suite 'latency', got '%s'", result.Suite) + } + if result.Score != 0.95 { + t.Errorf("expected Score 0.95, got %f", result.Score) + } + if len(result.Results) != 1 { + t.Errorf("expected 1 result, got %d", len(result.Results)) + } +} + +func TestFetchRemoteKeyFromURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/jacs/v1/agents/test-agent/keys/latest" + if r.URL.Path != expectedPath { + t.Errorf("expected path '%s', got '%s'", expectedPath, r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "public_key": "dGVzdC1rZXk=", // base64("test-key") + "algorithm": "ed25519", + "public_key_hash": "abc123", + "agent_id": "test-agent", + "version": "1", + }) + })) + defer server.Close() + + result, err := FetchRemoteKeyFromURL(server.URL, "test-agent", "latest") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if string(result.PublicKey) != "test-key" { + t.Errorf("expected PublicKey 'test-key', got '%s'", string(result.PublicKey)) + } + if result.Algorithm != "ed25519" { + t.Errorf("expected Algorithm 'ed25519', got '%s'", result.Algorithm) + } + if result.PublicKeyHash != "abc123" { + t.Errorf("expected PublicKeyHash 'abc123', got '%s'", result.PublicKeyHash) + } +} + +func TestFetchRemoteKeyFromURLNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := FetchRemoteKeyFromURL(server.URL, "unknown-agent", "latest") + if err == nil { + t.Error("expected error for 404") + } + + haiErr, ok := err.(*HaiError) + if !ok { + t.Fatalf("expected HaiError, got %T", err) + } + if haiErr.Kind != HaiErrorKeyNotFound { + t.Errorf("expected HaiErrorKeyNotFound, got %v", haiErr.Kind) + } +} + +func TestFetchKeyByHashFromURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/jacs/v1/keys/by-hash/abc123" + if r.URL.Path != expectedPath { + t.Errorf("expected path '%s', got '%s'", expectedPath, r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "public_key": "dGVzdC1rZXk=", + "algorithm": "ed25519", + "public_key_hash": "abc123", + "agent_id": "test-agent", + "version": "1", + }) + })) + defer server.Close() + + result, err := FetchKeyByHashFromURL(server.URL, "abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.PublicKeyHash != "abc123" { + t.Errorf("expected PublicKeyHash 'abc123', got '%s'", result.PublicKeyHash) + } +} + +func TestHaiErrorError(t *testing.T) { + err := newHaiError(HaiErrorConnection, "connection failed: %s", "timeout") + if err.Error() != "connection failed: timeout" { + t.Errorf("expected 'connection failed: timeout', got '%s'", err.Error()) + } +} + +func TestHaiSignatureSerialization(t *testing.T) { + sig := HaiSignature{ + KeyID: "key-1", + Algorithm: "Ed25519", + Signature: "c2lnbmF0dXJl", + SignedAt: "2024-01-15T10:30:00Z", + } + + data, err := json.Marshal(sig) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var parsed HaiSignature + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if parsed.KeyID != sig.KeyID { + t.Errorf("KeyID mismatch: expected '%s', got '%s'", sig.KeyID, parsed.KeyID) + } +} diff --git a/jacsnpm/Cargo.toml b/jacsnpm/Cargo.toml index d35e851d6..1fb56b0f8 100644 --- a/jacsnpm/Cargo.toml +++ b/jacsnpm/Cargo.toml @@ -12,7 +12,8 @@ crate-type = ["cdylib"] [dependencies] lazy_static = "1.5.0" jacs = { path = "../jacs" } -jacs-binding-core = { path = "../binding-core" } +jacs-binding-core = { path = "../binding-core", features = ["hai"] } +tokio = { version = "1.0", features = ["rt-multi-thread"] } serde_json = "1.0.140" log = "0.4.27" serde = { version = "1.0", features = ["derive"] } diff --git a/jacsnpm/README.md b/jacsnpm/README.md index ca0b2c380..6ddf26f4e 100644 --- a/jacsnpm/README.md +++ b/jacsnpm/README.md @@ -131,7 +131,46 @@ const server = new JacsMcpServer({ }); ``` +## HAI Integration + +The JACS package includes integration with HAI's key distribution service for fetching public keys without requiring local key storage. + +### Fetch Remote Keys + +```javascript +const { fetchRemoteKey } = require('@hai-ai/jacs'); + +// Fetch a public key from HAI's key service +const keyInfo = fetchRemoteKey('550e8400-e29b-41d4-a716-446655440000', 'latest'); +console.log('Algorithm:', keyInfo.algorithm); +console.log('Public Key Hash:', keyInfo.publicKeyHash); +console.log('Agent ID:', keyInfo.agentId); +console.log('Version:', keyInfo.version); + +// Use the public key for verification +const publicKeyBytes = keyInfo.publicKey; // Buffer containing DER-encoded key +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `HAI_KEYS_BASE_URL` | Base URL for the HAI key service | `https://keys.hai.ai` | + +### HAI Types + +```typescript +interface RemotePublicKeyInfo { + publicKey: Buffer; // DER-encoded public key bytes + algorithm: string; // e.g., "ed25519", "rsa-pss-sha256" + publicKeyHash: string; // SHA-256 hash of the public key + agentId: string; // The agent's unique identifier + version: string; // The key version +} +``` + ## See Also - [JACS Documentation](https://hai.ai/jacs) +- [HAI Developer Portal](https://hai.ai/dev) - [Examples](./examples/) diff --git a/jacsnpm/src/lib.rs b/jacsnpm/src/lib.rs index b36489f16..45eaae954 100644 --- a/jacsnpm/src/lib.rs +++ b/jacsnpm/src/lib.rs @@ -601,3 +601,70 @@ pub fn verify_response_with_agent_id(env: Env, document_string: String) -> Resul Ok(result_obj) } + +// ============================================================================ +// HAI Functions (using binding-core HAI module) +// ============================================================================ + +/// Information about a public key fetched from HAI key service. +/// +/// This struct contains the public key data and metadata returned by +/// the HAI key distribution service. +#[napi(object)] +pub struct RemotePublicKeyInfo { + /// The raw public key bytes (DER encoded). + pub public_key: Buffer, + /// The cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256"). + pub algorithm: String, + /// The hash of the public key (SHA-256). + pub public_key_hash: String, + /// The agent ID the key belongs to. + pub agent_id: String, + /// The version of the key. + pub version: String, +} + +/// Fetch a public key from HAI's key distribution service. +/// +/// This function retrieves the public key for a specific agent and version +/// from the HAI key distribution service. It is used to obtain trusted public +/// keys for verifying agent signatures without requiring local key storage. +/// +/// # Arguments +/// +/// * `agent_id` - The unique identifier of the agent whose key to fetch. +/// * `version` - The version of the agent's key to fetch. Use "latest" for +/// the most recent version. If not provided, defaults to "latest". +/// +/// # Returns +/// +/// Returns a `RemotePublicKeyInfo` object containing the public key, algorithm, and hash. +/// +/// # Environment Variables +/// +/// * `HAI_KEYS_BASE_URL` - Base URL for the key service. Defaults to `https://keys.hai.ai`. +/// +/// # Example +/// +/// ```javascript +/// const { fetchRemoteKey } = require('@hai-ai/jacs'); +/// +/// const keyInfo = fetchRemoteKey('550e8400-e29b-41d4-a716-446655440000', 'latest'); +/// console.log('Algorithm:', keyInfo.algorithm); +/// console.log('Hash:', keyInfo.publicKeyHash); +/// ``` +#[napi] +pub fn fetch_remote_key(agent_id: String, version: Option) -> Result { + let version_str = version.as_deref().unwrap_or("latest"); + + let key_info = jacs_binding_core::fetch_remote_key(&agent_id, version_str) + .map_err(|e| Error::new(Status::GenericFailure, e.message))?; + + Ok(RemotePublicKeyInfo { + public_key: Buffer::from(key_info.public_key), + algorithm: key_info.algorithm, + public_key_hash: key_info.public_key_hash, + agent_id: key_info.agent_id, + version: key_info.version, + }) +} diff --git a/jacspy/Makefile b/jacspy/Makefile index 5e075bc23..5c547f155 100644 --- a/jacspy/Makefile +++ b/jacspy/Makefile @@ -1,5 +1,47 @@ .PHONY: build-jacspy build-jacspy-mac build-jacspy-linux build-wheel-mac publish-wheels build-wheel-linux test +# ============================================================================= +# Development Commands (using uv - recommended) +# ============================================================================= + +# Install dev dependencies with uv +setup: + @echo "Setting up development environment with uv..." + uv venv + uv pip install maturin pytest httpx httpx-sse + +# Build and install the Rust extension for development (fast iteration) +dev: + @echo "Building jacspy in development mode..." + uv run maturin develop + +# Run all tests +test: dev + @echo "Running tests..." + uv run python -m pytest tests/ -v + uv run python -m pytest python/jacs/ -v + +# Run Python tests only (assumes already built) +test-py: + @echo "Running Python tests..." + uv run python -m pytest tests/ -v + +# Run HAI integration tests +test-hai: dev + @echo "Running HAI integration tests..." + uv run python -m pytest python/jacs/test_register_new_agent.py -v + +# Verify imports work +check-imports: dev + @echo "Checking imports..." + uv run python -c "from jacs import JacsAgent, HaiClient; print('OK: JacsAgent, HaiClient')" + uv run python -c "from jacs.hai import register_new_agent, verify_agent; print('OK: register_new_agent, verify_agent')" + uv run python -c "from jacs.simple import create, load, sign_message; print('OK: simple API')" + +# ============================================================================= +# Build Commands (for releases) +# ============================================================================= + # # Build wheel for the current macOS environment build-wheel-mac: @echo "Building macOS wheel..." diff --git a/jacspy/README.md b/jacspy/README.md index 837e13aeb..1e77e91c4 100644 --- a/jacspy/README.md +++ b/jacspy/README.md @@ -209,17 +209,30 @@ See the [examples/](./examples/) directory: ## Development +Using uv (recommended): + ```bash -# Setup +# Quick start with Makefile +make setup # Install all dependencies +make dev # Build for development +make test # Run all tests + +# Or manually: uv venv && source .venv/bin/activate -uv pip install maturin +uv pip install maturin pytest httpx httpx-sse +uv run maturin develop +uv run python -m pytest tests/ -v +``` -# Build -maturin develop +### Available Make Commands -# Test -pytest tests/ -``` +| Command | Description | +|---------|-------------| +| `make setup` | Install dev dependencies with uv | +| `make dev` | Build Rust extension for development | +| `make test` | Run all tests (Python + HAI) | +| `make test-hai` | Run HAI integration tests only | +| `make check-imports` | Verify all imports work | ## Documentation diff --git a/jacspy/examples/langchain/README.md b/jacspy/examples/langchain/README.md index 7b55c5f72..13a64c3d8 100644 --- a/jacspy/examples/langchain/README.md +++ b/jacspy/examples/langchain/README.md @@ -10,6 +10,9 @@ JACS provides cryptographic signing and verification for AI agent outputs. By in - Verify that data came from a specific trusted agent - Create multi-party agreements requiring multiple agent signatures - Maintain audit trails of signed interactions +- **Verify agent trust levels via HAI** (Human AI Interface) +- **Fetch remote public keys** for cross-agent verification +- **Register agents** with the HAI network ## Prerequisites @@ -74,8 +77,120 @@ Features: - `SignedOutputsAuditTrail` - Maintains a log of all signed outputs - Integration with LangGraph's streaming API +### 3. HAI Integration (`hai_integration.py`) + +Demonstrates using HAI (Human AI Interface) tools for agent trust verification and registration. + +```bash +# Start the jacs-mcp server with HAI tools +JACS_CONFIG=./jacs.config.json jacs-mcp + +# In another terminal, run the example +python hai_integration.py +``` + +Features: +- Fetch remote agent public keys with `fetch_agent_key` +- Verify agent attestation levels (0-3) with `verify_agent` +- Register with HAI network using `register_agent` +- Check registration status with `check_agent_status` + +## HAI Integration + +### What is HAI? + +HAI (Human AI Interface) provides a trust layer for AI agents. It enables: + +1. **Identity Registration**: Agents can register their public keys with HAI +2. **Trust Verification**: Verify other agents' attestation levels before trusting them +3. **Key Distribution**: Fetch public keys for remote agents without prior contact + +### Trust Levels (0-3) + +HAI uses a tiered trust system: + +| Level | Name | Description | +|-------|------|-------------| +| 0 | None | Agent not found in HAI system | +| 1 | Basic | Public key registered with HAI key service | +| 2 | Domain | DNS verification passed (agent controls claimed domain) | +| 3 | Attested | Full HAI signature attestation (highest trust) | + +### HAI Tools via MCP + +When using the `jacs-mcp` server, the following HAI tools are available to LangChain agents: + +#### `fetch_agent_key` + +Fetch a public key from HAI's key distribution service. + +```python +# Agent can call this tool to get another agent's public key +result = await executor.ainvoke({ + "input": "Fetch the public key for agent 550e8400-e29b-41d4-a716-446655440000" +}) +``` + +Returns: +- `agent_id`: The agent's ID +- `version`: Key version +- `algorithm`: Cryptographic algorithm (e.g., "ed25519", "pq-dilithium") +- `public_key_hash`: SHA-256 hash of the public key +- `public_key_base64`: The public key in base64 encoding + +#### `verify_agent` + +Verify another agent's attestation level. + +```python +# Check trust level before accepting messages +result = await executor.ainvoke({ + "input": "Verify the trust level of agent ABC123 before I process their request" +}) +``` + +Returns: +- `attestation_level`: 0-3 trust level +- `attestation_description`: Human-readable description +- `key_found`: Whether the agent's key was found + +#### `register_agent` + +Register the local agent with HAI. + +```python +# Register with HAI (supports preview mode) +result = await executor.ainvoke({ + "input": "Register my agent with HAI in preview mode first" +}) +``` + +Parameters: +- `preview`: If true, validates without registering + +#### `check_agent_status` + +Check registration status with HAI. + +```python +# Check if registered +result = await executor.ainvoke({ + "input": "Am I registered with HAI?" +}) +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `JACS_CONFIG` | Path to jacs.config.json | `./jacs.config.json` | +| `HAI_ENDPOINT` | HAI API endpoint | `https://api.hai.ai` | +| `HAI_API_KEY` | Optional API key for HAI | (none) | + ## Architecture +### Basic JACS Integration + ``` +-------------------+ MCP Protocol +------------------+ | LangChain | <--------------------> | JACS MCP | @@ -96,6 +211,28 @@ Features: +------------------+ ``` +### With HAI Integration + +``` ++-------------------+ MCP Protocol +------------------+ +| LangChain | <--------------------> | jacs-mcp | +| Agent | | Server | ++-------------------+ +------------------+ + | | + | Uses tools | Uses + v v ++-------------------+ +------------------+ +| JACS Tools: | | JACS Core | +| - sign_message | | + HAI Client | +| - verify_doc | +------------------+ +| HAI Tools: | | +| - fetch_key | v +| - verify_agent | +------------------+ +| - register | | HAI.ai API | +| - check_status | <---------------------->| (keys.hai.ai) | ++-------------------+ +------------------+ +``` + ## Use Cases ### Provenance Tracking @@ -145,6 +282,55 @@ for signed_output in callback.get_audit_trail(): print(f"Signed at: {signed_output['timestamp']}") ``` +### Agent Trust Verification (HAI) + +Verify another agent before trusting their messages: + +```python +# Before processing a request from another agent +result = await executor.ainvoke({ + "input": f"""Agent {sender_id} wants to execute a transaction. + Verify their trust level and only proceed if they are at least Level 2 (domain verified).""" +}) + +# The agent will use verify_agent to check attestation level +``` + +### Multi-Agent Trust Establishment + +Set up trusted communication between agents: + +```python +# Step 1: Register your agent with HAI +result = await executor.ainvoke({ + "input": "Register my agent with HAI so other agents can verify me." +}) + +# Step 2: Fetch partner agent's key +result = await executor.ainvoke({ + "input": f"Fetch the public key for agent {partner_id} so I can verify their signatures." +}) + +# Step 3: Verify partner's trust level +result = await executor.ainvoke({ + "input": f"Verify agent {partner_id} is registered with HAI before we start collaborating." +}) +``` + +### Remote Key Verification + +Verify signatures from agents you haven't met before: + +```python +# When you receive a signed message from an unknown agent +result = await executor.ainvoke({ + "input": f"""I received a signed message from agent {unknown_agent_id}. + 1. Fetch their public key from HAI + 2. Check their attestation level + 3. Tell me if I should trust this message based on their trust level""" +}) +``` + ## Troubleshooting ### "No agent loaded" error @@ -164,3 +350,56 @@ fastmcp run jacs.mcp_simple:mcp ### Signature verification failed Ensure both the signer and verifier have access to the same trust store, or that the verifier has added the signer's agent to their trust store. + +### HAI Troubleshooting + +#### "Agent not found" when fetching key + +The agent may not be registered with HAI. Ask them to: +1. Register with HAI using `register_agent` +2. Ensure their `jacs-mcp` server is configured with `HAI_API_KEY` + +#### Low attestation level (Level 0 or 1) + +Higher trust levels require: +- **Level 2**: DNS verification (agent must control the claimed domain) +- **Level 3**: Full HAI attestation (contact HAI for enterprise verification) + +#### Connection errors to HAI + +Check: +1. Network connectivity to `https://api.hai.ai` +2. `HAI_ENDPOINT` environment variable if using a custom endpoint +3. `HAI_API_KEY` is set correctly (some operations require authentication) + +#### jacs-mcp not found + +Install the jacs-mcp binary: +```bash +# From the JACS repository +cd jacs-mcp +cargo install --path . +``` + +Or use the Python-based server (without HAI tools): +```bash +python -m jacs.mcp_server +``` + +## Security Best Practices + +### Trust Level Guidelines + +| Operation | Minimum Trust Level | +|-----------|---------------------| +| Read-only data sharing | Level 1 (Basic) | +| Collaborative tasks | Level 2 (Domain) | +| Financial transactions | Level 3 (Attested) | +| Critical system access | Level 3 (Attested) | + +### Key Management + +1. **Never share private keys** - Only public keys are distributed via HAI +2. **Rotate keys periodically** - Use version tracking in fetch_agent_key +3. **Verify before trust** - Always check attestation levels before trusting new agents +4. **Cache carefully** - Public keys can be cached, but verify periodically diff --git a/jacspy/examples/langchain/hai_integration.py b/jacspy/examples/langchain/hai_integration.py new file mode 100644 index 000000000..4a132efb3 --- /dev/null +++ b/jacspy/examples/langchain/hai_integration.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +HAI Integration with LangChain Agents + +This example demonstrates how to use HAI (Human AI Interface) tools with +LangChain agents via the Model Context Protocol (MCP). HAI provides: + +- Agent identity registration and verification +- Trust levels (0-3) for agent attestation +- Remote key fetching for signature verification +- Multi-agent trust establishment + +Prerequisites: + 1. Install dependencies: pip install -r requirements.txt + 2. Set up a JACS agent: jacs init && jacs create + 3. Set your LLM API key: export ANTHROPIC_API_KEY=your-key + 4. Optional: Set HAI API key: export HAI_API_KEY=your-key + +Usage: + # Start the jacs-mcp server (in a separate terminal) + # This server provides both JACS signing AND HAI tools + JACS_CONFIG=./jacs.config.json jacs-mcp + + # Run this example + python hai_integration.py +""" + +import asyncio +import os +import sys + +# Check for required environment variables +if not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("OPENAI_API_KEY"): + print("Error: Please set ANTHROPIC_API_KEY or OPENAI_API_KEY") + print(" export ANTHROPIC_API_KEY=your-key-here") + sys.exit(1) + + +async def main(): + """Demonstrate HAI integration with LangChain agents.""" + + # Import LangChain components + from langchain_mcp_adapters.client import MultiServerMCPClient + + # Choose your LLM provider + try: + from langchain_anthropic import ChatAnthropic + model = ChatAnthropic(model="claude-sonnet-4-20250514") + print("Using Anthropic Claude") + except ImportError: + from langchain_openai import ChatOpenAI + model = ChatOpenAI(model="gpt-4") + print("Using OpenAI GPT-4") + + print("\n=== HAI + LangChain Integration Example ===\n") + + # Initialize the MCP client to connect to the jacs-mcp server + # The jacs-mcp server provides HAI tools: + # - fetch_agent_key: Fetch public keys from HAI + # - register_agent: Register with HAI + # - verify_agent: Check attestation levels + # - check_agent_status: Get registration status + client = MultiServerMCPClient( + { + "jacs-mcp": { + # Use the jacs-mcp binary which includes HAI tools + "command": "jacs-mcp", + "transport": "stdio", + "env": { + # Pass through the JACS config path + "JACS_CONFIG": os.environ.get( + "JACS_CONFIG", + os.environ.get("JACS_CONFIG_PATH", "./jacs.config.json") + ), + # HAI endpoint (defaults to https://api.hai.ai) + "HAI_ENDPOINT": os.environ.get("HAI_ENDPOINT", "https://api.hai.ai"), + # Optional API key for HAI + "HAI_API_KEY": os.environ.get("HAI_API_KEY", ""), + }, + }, + } + ) + + print("Connecting to jacs-mcp server...") + + # Get all tools from the jacs-mcp server + tools = await client.get_tools() + print(f"Loaded {len(tools)} tools from jacs-mcp:") + for tool in tools: + print(f" - {tool.name}: {tool.description[:60]}...") + + # Create the LangChain agent with HAI tools + from langchain.agents import create_tool_calling_agent, AgentExecutor + from langchain_core.prompts import ChatPromptTemplate + + # Create a prompt that instructs the agent how to use HAI tools + prompt = ChatPromptTemplate.from_messages([ + ("system", """You are an AI assistant with HAI (Human AI Interface) integration. +You can establish trust and verify other agents using these HAI tools: + +## Trust Levels (0-3) + +HAI provides tiered trust verification: +- **Level 0 (none)**: Agent not found in HAI system +- **Level 1 (basic)**: Public key registered with HAI key service +- **Level 2 (domain)**: DNS verification passed +- **Level 3 (attested)**: Full HAI signature attestation + +## Available Tools + +1. **fetch_agent_key** - Fetch a public key from HAI + - Use this to get trusted public keys for verifying signatures + - Returns: algorithm, public_key_hash, public_key_base64 + +2. **register_agent** - Register the local agent with HAI + - Use this to establish identity in the HAI network + - Supports preview mode to validate without registering + +3. **verify_agent** - Check another agent's attestation level + - Returns attestation level (0-3) and description + - Use this before trusting another agent's messages + +4. **check_agent_status** - Get registration status + - Check if an agent is registered with HAI + - Returns registration details if registered + +## Best Practices + +- Always verify agents before trusting their messages +- Prefer higher attestation levels for sensitive operations +- Use preview mode to validate registration before committing +- Cache public keys to reduce API calls + +Report verification results clearly, including the attestation level and what it means."""), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), + ]) + + # Create the agent + agent = create_tool_calling_agent(model, tools, prompt) + executor = AgentExecutor(agent=agent, tools=tools, verbose=True) + + print("\n" + "="*60) + print("Agent ready with HAI tools! Running examples...") + print("="*60 + "\n") + + # Example 1: Check local agent status + print("--- Example 1: Checking local agent registration status ---") + result = await executor.ainvoke({ + "input": "Check if I (the local agent) am registered with HAI. Report my registration status." + }) + print(f"Response: {result['output']}\n") + + # Example 2: Fetch a remote agent's key + print("--- Example 2: Fetching a remote agent's public key ---") + # Use a sample agent ID - in production, this would be a real agent ID + result = await executor.ainvoke({ + "input": """Try to fetch the public key for agent ID '550e8400-e29b-41d4-a716-446655440000'. +If that fails (it's a test ID), explain what the fetch_agent_key tool does and when to use it.""" + }) + print(f"Response: {result['output']}\n") + + # Example 3: Verify another agent's trust level + print("--- Example 3: Verifying another agent's attestation ---") + result = await executor.ainvoke({ + "input": """Verify the attestation level of agent '550e8400-e29b-41d4-a716-446655440000'. +Explain what the different trust levels mean and whether I should trust this agent.""" + }) + print(f"Response: {result['output']}\n") + + # Example 4: Registration workflow (preview mode) + print("--- Example 4: Registration workflow (preview mode) ---") + result = await executor.ainvoke({ + "input": """I want to register my agent with HAI, but first do a preview (dry run) +to see what would happen without actually registering. Use preview mode.""" + }) + print(f"Response: {result['output']}\n") + + # Example 5: Multi-agent trust scenario + print("--- Example 5: Multi-agent trust scenario ---") + result = await executor.ainvoke({ + "input": """I'm building a multi-agent system where agents need to verify each other. +Explain how to use HAI tools to: +1. Verify an incoming message is from a trusted agent +2. Establish my own identity before sending messages +3. Determine the minimum trust level needed for different operations""" + }) + print(f"Response: {result['output']}\n") + + print("="*60) + print("HAI Integration examples complete!") + print("="*60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/jacspy/python/jacs/__init__.py b/jacspy/python/jacs/__init__.py index b35a5c449..7336ea5a5 100644 --- a/jacspy/python/jacs/__init__.py +++ b/jacspy/python/jacs/__init__.py @@ -64,12 +64,15 @@ Attachment, SignedDocument, VerificationResult, + PublicKeyInfo, JacsError, ConfigError, AgentNotLoadedError, SigningError, VerificationError, TrustError, + KeyNotFoundError, + NetworkError, ) # Make simplified API available as jacs.simple @@ -103,11 +106,13 @@ "verify_document", "sign_request", "verify_response", + "fetch_remote_key", # Type definitions "AgentInfo", "Attachment", "SignedDocument", "VerificationResult", + "PublicKeyInfo", # Error types "JacsError", "ConfigError", @@ -115,6 +120,8 @@ "SigningError", "VerificationError", "TrustError", + "KeyNotFoundError", + "NetworkError", # Submodules "simple", "async_simple", diff --git a/jacspy/python/jacs/async_simple.py b/jacspy/python/jacs/async_simple.py index 62931a205..7883ccab5 100644 --- a/jacspy/python/jacs/async_simple.py +++ b/jacspy/python/jacs/async_simple.py @@ -83,7 +83,7 @@ async def verify_data(document: str): async def create( name: Optional[str] = None, agent_type: str = "service", - algorithm: str = "RSA", + algorithm: str = "pq2025", config_path: Optional[str] = None, ) -> AgentInfo: """Create a new JACS agent with cryptographic keys. diff --git a/jacspy/python/jacs/hai.py b/jacspy/python/jacs/hai.py index 0be6f229d..559a4d3ff 100644 --- a/jacspy/python/jacs/hai.py +++ b/jacspy/python/jacs/hai.py @@ -3,12 +3,21 @@ Provides methods for integrating JACS agents with HAI.ai platform: - register_new_agent(): Create a new JACS agent AND register with HAI.ai in one step +- verify_agent(): Verify another agent's trust level (basic, domain, attested) - register(): Register an existing agent with HAI.ai +- status(): Check registration status - testconnection(): Test connectivity to HAI.ai - benchmark(): Run benchmarks via HAI.ai - connect(): Connect to HAI.ai SSE stream - disconnect(): Close SSE connection +Installation: + # Using uv (recommended) + uv pip install jacs[hai] + + # Or with pip + pip install jacs[hai] + Quick Start (recommended for new developers): from jacs.hai import register_new_agent @@ -20,6 +29,14 @@ print(f"Agent registered: {result.agent_id}") print(f"Config saved to: ./jacs.config.json") +Verifying Other Agents: + from jacs.hai import verify_agent + + # Verify another agent meets trust requirements + result = verify_agent(other_agent_doc, min_level=2) + if result.valid: + print(f"Verified: {result.level_name}") # "basic", "domain", or "attested" + Advanced Usage (existing agents): import jacs.simple as jacs from jacs.hai import HaiClient @@ -35,6 +52,12 @@ # Register agent result = hai.register("https://hai.ai", api_key="your-api-key") print(f"Registered: {result}") + +Note: + This module uses a hybrid approach: + - Rust (via PyO3): Fast sync methods (testconnection, register, status, benchmark) + - Python (httpx-sse): SSE streaming (connect, disconnect) + Both implementations are available and work together. """ import json @@ -1176,6 +1199,81 @@ def status( # All retries exhausted raise last_error or HaiError("Status check failed after all retries") + # ========================================================================= + # SDK-PY-008: get_agent_attestation() method - for verifying OTHER agents + # ========================================================================= + + def get_agent_attestation( + self, + hai_url: str, + agent_id: str, + api_key: Optional[str] = None, + ) -> HaiStatusResult: + """Get HAI.ai attestation status for ANY agent by ID. + + Unlike status() which checks the currently loaded agent, this method + can query the attestation status of any agent by its JACS ID. + Use this when verifying other agents in agent-to-agent scenarios. + + Args: + hai_url: Base URL of the HAI.ai server (e.g., "https://hai.ai") + agent_id: The JACS agent ID to check + api_key: Optional API key for authentication + + Returns: + HaiStatusResult with registration and attestation details + + Example: + from jacs.hai import HaiClient + + hai = HaiClient() + # Check if another agent is HAI-attested + result = hai.get_agent_attestation("https://hai.ai", "other-agent-id") + if result.registered and result.hai_signatures: + print("Agent is HAI-attested (Level 3)") + """ + import os + httpx = self._get_httpx() + + # Get API key from parameter or environment + if api_key is None: + api_key = os.environ.get("HAI_API_KEY") + + # Build request + url = self._make_url(hai_url, f"/api/v1/agents/{agent_id}/status") + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + response = httpx.get(url, headers=headers, timeout=self._timeout) + + if response.status_code == 200: + data = response.json() + return HaiStatusResult( + registered=True, + agent_id=data.get("agent_id", data.get("agentId", agent_id)), + registration_id=data.get("registration_id", data.get("registrationId", "")), + registered_at=data.get("registered_at", data.get("registeredAt", "")), + hai_signatures=data.get("hai_signatures", data.get("haiSignatures", [])), + raw_response=data, + ) + elif response.status_code == 404: + return HaiStatusResult( + registered=False, + agent_id=agent_id, + raw_response=response.json() if response.text else {}, + ) + else: + raise HaiError( + f"Failed to get attestation: HTTP {response.status_code}", + status_code=response.status_code, + ) + except Exception as e: + if isinstance(e, HaiError): + raise + raise HaiError(f"Failed to get attestation: {e}") + # ============================================================================= # Module-level convenience functions @@ -1414,11 +1512,12 @@ def verify_agent( if jacs_valid and agent_id: try: client = HaiClient() - status = client.status(hai_url, agent_id=agent_id) - hai_attested = status.registered and len(status.hai_signatures) > 0 + # Query status for the OTHER agent by ID + attestation = client.get_agent_attestation(hai_url, agent_id) + hai_attested = attestation.registered and len(attestation.hai_signatures) > 0 if hai_attested: - hai_signatures = status.hai_signatures - raw_response = status.raw_response + hai_signatures = attestation.hai_signatures + raw_response = attestation.raw_response except Exception as e: errors.append(f"HAI verification error: {e}") diff --git a/jacspy/python/jacs/simple.py b/jacspy/python/jacs/simple.py index e312f978b..a64f681b0 100644 --- a/jacspy/python/jacs/simple.py +++ b/jacspy/python/jacs/simple.py @@ -13,6 +13,17 @@ - create_agreement(): Create a multi-party agreement - sign_agreement(): Sign an existing agreement - check_agreement(): Check agreement status +- fetch_remote_key(): Fetch a public key from HAI's key service + +Environment Variables: + HAI_KEYS_BASE_URL: Base URL for HAI's key distribution service. + Defaults to "https://keys.hai.ai". + + JACS_KEY_RESOLUTION: Controls the order of key resolution when + verifying signatures. Options: + - "hai-only": Only use HAI key service (default) + - "local-first": Try local trust store first, fall back to HAI + - "hai-first": Try HAI first, fall back to local trust store Example: import jacs.simple as jacs @@ -38,6 +49,10 @@ # Check agreement status status = jacs.check_agreement(agreement) print(f"Complete: {status.complete}, Pending: {status.pending}") + + # Fetch a remote public key (no agent required) + key_info = jacs.fetch_remote_key("550e8400-e29b-41d4-a716-446655440000") + print(f"Algorithm: {key_info.algorithm}") """ import json @@ -57,21 +72,26 @@ VerificationResult, SignerStatus, AgreementStatus, + PublicKeyInfo, JacsError, ConfigError, AgentNotLoadedError, SigningError, VerificationError, TrustError, + KeyNotFoundError, + NetworkError, ) # Import the Rust bindings try: from . import JacsAgent + from .jacs import fetch_remote_key as _fetch_remote_key except ImportError: # Fallback for when running directly import jacs as _jacs_module JacsAgent = _jacs_module.JacsAgent + _fetch_remote_key = _jacs_module.fetch_remote_key # Global agent instance for simplified API _global_agent: Optional[JacsAgent] = None @@ -139,7 +159,7 @@ def _parse_signed_document(json_str: str) -> SignedDocument: def create( name: Optional[str] = None, agent_type: str = "service", - algorithm: str = "RSA", + algorithm: str = "pq2025", config_path: Optional[str] = None, ) -> AgentInfo: """Create a new JACS agent with cryptographic keys. @@ -862,6 +882,93 @@ def is_loaded() -> bool: return _global_agent is not None +def fetch_remote_key(agent_id: str, version: str = "latest") -> PublicKeyInfo: + """Fetch a public key from HAI's key distribution service. + + This function retrieves the public key for a specific agent and version + from the HAI key distribution service. It is used to obtain trusted public + keys for verifying agent signatures without requiring local key storage. + + This is a stateless operation that does not require an agent to be loaded. + + Args: + agent_id: The unique identifier of the agent whose key to fetch. + version: The version of the agent's key to fetch. Use "latest" for + the most recent version. Defaults to "latest". + + Returns: + PublicKeyInfo containing: + - public_key: Raw public key bytes (DER encoded) + - algorithm: Cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256") + - public_key_hash: SHA-256 hash of the public key + - agent_id: The agent ID this key belongs to + - version: The version of the key + + Raises: + KeyNotFoundError: If the agent or key version was not found (404) + NetworkError: If there was a connection, timeout, or other HTTP error + JacsError: For other errors (e.g., invalid key encoding) + + Environment Variables: + HAI_KEYS_BASE_URL: Base URL for the key service. + Defaults to "https://keys.hai.ai". + JACS_KEY_RESOLUTION: Controls key resolution order: + - "hai-only": Only use HAI key service (default) + - "local-first": Try local trust store, fall back to HAI + - "hai-first": Try HAI first, fall back to local trust store + + Example: + # Fetch the latest key for an agent + key_info = jacs.fetch_remote_key("550e8400-e29b-41d4-a716-446655440000") + print(f"Algorithm: {key_info.algorithm}") + print(f"Hash: {key_info.public_key_hash}") + + # Fetch a specific version + key_info = jacs.fetch_remote_key("550e8400-e29b-41d4-a716-446655440000", "1") + + # Use with a custom key service + import os + os.environ["HAI_KEYS_BASE_URL"] = "https://keys.example.com" + key_info = jacs.fetch_remote_key("my-agent-id") + """ + logger.debug("fetch_remote_key() called: agent_id=%s, version=%s", agent_id, version) + + try: + result = _fetch_remote_key(agent_id, version) + + key_info = PublicKeyInfo( + public_key=result["public_key"], + algorithm=result["algorithm"], + public_key_hash=result["public_key_hash"], + agent_id=result["agent_id"], + version=result["version"], + ) + + logger.info( + "Fetched remote key: agent_id=%s, version=%s, algorithm=%s", + agent_id, + version, + key_info.algorithm, + ) + return key_info + + except RuntimeError as e: + error_str = str(e) + logger.error("Failed to fetch remote key: %s", error_str) + + # Map error messages to appropriate exception types + if "not found" in error_str.lower() or "404" in error_str: + raise KeyNotFoundError( + f"Public key not found for agent '{agent_id}' version '{version}'" + ) from e + elif "network" in error_str.lower() or "connect" in error_str.lower() or "timeout" in error_str.lower(): + raise NetworkError( + f"Network error fetching key for agent '{agent_id}': {error_str}" + ) from e + else: + raise JacsError(f"Failed to fetch remote key: {error_str}") from e + + __all__ = [ # Core operations "create", @@ -881,6 +988,8 @@ def is_loaded() -> bool: "export_agent", "get_agent_info", "is_loaded", + # Remote key fetch + "fetch_remote_key", # Types (re-exported for convenience) "AgentInfo", "Attachment", @@ -888,6 +997,7 @@ def is_loaded() -> bool: "VerificationResult", "SignerStatus", "AgreementStatus", + "PublicKeyInfo", # Errors "JacsError", "ConfigError", @@ -895,4 +1005,6 @@ def is_loaded() -> bool: "SigningError", "VerificationError", "TrustError", + "KeyNotFoundError", + "NetworkError", ] diff --git a/jacspy/python/jacs/test_fetch_remote_key.py b/jacspy/python/jacs/test_fetch_remote_key.py new file mode 100644 index 000000000..479aa3334 --- /dev/null +++ b/jacspy/python/jacs/test_fetch_remote_key.py @@ -0,0 +1,118 @@ +""" +Tests for fetch_remote_key functionality. + +These tests verify the Python SDK's ability to fetch public keys from +HAI's key distribution service. +""" + +import os +import sys +import unittest + +# Add parent directory for development import +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from jacs.types import PublicKeyInfo, KeyNotFoundError, NetworkError, JacsError +import jacs.simple as jacs + + +class TestFetchRemoteKey(unittest.TestCase): + """Tests for the fetch_remote_key function.""" + + def test_function_exists(self): + """Verify the function is exported and callable.""" + self.assertTrue(hasattr(jacs, 'fetch_remote_key')) + self.assertTrue(callable(jacs.fetch_remote_key)) + + def test_types_exist(self): + """Verify the associated types are available.""" + self.assertTrue(PublicKeyInfo is not None) + self.assertTrue(issubclass(KeyNotFoundError, JacsError)) + self.assertTrue(issubclass(NetworkError, JacsError)) + + def test_public_key_info_dataclass(self): + """Test that PublicKeyInfo can be instantiated.""" + info = PublicKeyInfo( + public_key=b"test_key_bytes", + algorithm="ed25519", + public_key_hash="abc123", + agent_id="test-agent", + version="1", + ) + self.assertEqual(info.public_key, b"test_key_bytes") + self.assertEqual(info.algorithm, "ed25519") + self.assertEqual(info.public_key_hash, "abc123") + self.assertEqual(info.agent_id, "test-agent") + self.assertEqual(info.version, "1") + + def test_public_key_info_from_dict(self): + """Test PublicKeyInfo.from_dict factory method.""" + data = { + "public_key": b"key_bytes", + "algorithm": "rsa-pss-sha256", + "public_key_hash": "hash123", + "agent_id": "agent-1", + "version": "latest", + } + info = PublicKeyInfo.from_dict(data) + self.assertEqual(info.public_key, b"key_bytes") + self.assertEqual(info.algorithm, "rsa-pss-sha256") + + def test_network_error_on_unreachable_server(self): + """Test that NetworkError is raised when server is unreachable.""" + # Use a localhost URL that's not running + os.environ["HAI_KEYS_BASE_URL"] = "http://localhost:19999" + try: + with self.assertRaises(NetworkError): + jacs.fetch_remote_key("test-agent", "latest") + finally: + # Clean up environment + if "HAI_KEYS_BASE_URL" in os.environ: + del os.environ["HAI_KEYS_BASE_URL"] + + def test_default_version_is_latest(self): + """Test that version defaults to 'latest'.""" + # This tests the function signature, not a live call + import inspect + sig = inspect.signature(jacs.fetch_remote_key) + version_param = sig.parameters.get("version") + self.assertIsNotNone(version_param) + self.assertEqual(version_param.default, "latest") + + +class TestFetchRemoteKeyIntegration(unittest.TestCase): + """Integration tests that require a running HAI key service. + + These tests are skipped by default. To run them, set the environment + variable HAI_KEYS_INTEGRATION_TEST=1 and ensure HAI_KEYS_BASE_URL + points to a running key service. + """ + + def setUp(self): + if not os.environ.get("HAI_KEYS_INTEGRATION_TEST"): + self.skipTest("Set HAI_KEYS_INTEGRATION_TEST=1 to run integration tests") + + def test_fetch_valid_key(self): + """Test fetching a valid key from the service.""" + # This test requires a known agent ID that exists in the key service + agent_id = os.environ.get("TEST_AGENT_ID") + if not agent_id: + self.skipTest("Set TEST_AGENT_ID to test fetching a real key") + + key_info = jacs.fetch_remote_key(agent_id, "latest") + + self.assertIsInstance(key_info, PublicKeyInfo) + self.assertIsInstance(key_info.public_key, bytes) + self.assertTrue(len(key_info.public_key) > 0) + self.assertTrue(len(key_info.algorithm) > 0) + self.assertTrue(len(key_info.public_key_hash) > 0) + self.assertEqual(key_info.agent_id, agent_id) + + def test_key_not_found_error(self): + """Test that KeyNotFoundError is raised for non-existent agents.""" + with self.assertRaises(KeyNotFoundError): + jacs.fetch_remote_key("nonexistent-agent-that-does-not-exist", "latest") + + +if __name__ == "__main__": + unittest.main() diff --git a/jacspy/python/jacs/types.py b/jacspy/python/jacs/types.py index 10742c40f..5d2520dc9 100644 --- a/jacspy/python/jacs/types.py +++ b/jacspy/python/jacs/types.py @@ -29,7 +29,7 @@ class AgentInfo: name: Optional[str] = None public_key_hash: str = "" created_at: str = "" - algorithm: str = "RSA" + algorithm: str = "pq2025" config_path: Optional[str] = None public_key_path: Optional[str] = None @@ -247,6 +247,52 @@ class TrustError(JacsError): pass +class KeyNotFoundError(JacsError): + """Public key not found in HAI key service.""" + pass + + +class NetworkError(JacsError): + """Network error when communicating with HAI key service.""" + pass + + +@dataclass +class PublicKeyInfo: + """Information about a public key fetched from HAI's key service. + + This type represents the result of fetching a public key from + the HAI key distribution service. + + Attributes: + public_key: Raw public key bytes (DER encoded) + algorithm: Cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256") + public_key_hash: SHA-256 hash of the public key + agent_id: The agent ID this key belongs to + version: The version of the key + """ + public_key: bytes + algorithm: str + public_key_hash: str + agent_id: str + version: str + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PublicKeyInfo": + """Create PublicKeyInfo from a dictionary.""" + public_key = data.get("public_key", b"") + # Handle case where public_key might be passed as bytes or as a list of ints + if isinstance(public_key, list): + public_key = bytes(public_key) + return cls( + public_key=public_key, + algorithm=data.get("algorithm", ""), + public_key_hash=data.get("public_key_hash", ""), + agent_id=data.get("agent_id", ""), + version=data.get("version", ""), + ) + + @dataclass class SignerStatus: """Status of a single signer in a multi-party agreement. @@ -304,10 +350,13 @@ def from_dict(cls, data: Dict[str, Any]) -> "AgreementStatus": "VerificationResult", "SignerStatus", "AgreementStatus", + "PublicKeyInfo", "JacsError", "ConfigError", "AgentNotLoadedError", "SigningError", "VerificationError", "TrustError", + "KeyNotFoundError", + "NetworkError", ] diff --git a/jacspy/src/lib.rs b/jacspy/src/lib.rs index 3a926cc6e..7ca403735 100644 --- a/jacspy/src/lib.rs +++ b/jacspy/src/lib.rs @@ -829,6 +829,60 @@ fn get_trusted_agent(agent_id: &str) -> PyResult { jacs_binding_core::get_trusted_agent(agent_id).to_py() } +// ============================================================================= +// Remote Key Fetch Functions +// ============================================================================= + +/// Fetch a public key from HAI's key distribution service. +/// +/// This function retrieves the public key for a specific agent and version +/// from the HAI key distribution service. It is used to obtain trusted public +/// keys for verifying agent signatures without requiring local key storage. +/// +/// Args: +/// agent_id: The unique identifier of the agent whose key to fetch. +/// version: The version of the agent's key to fetch. Use "latest" for +/// the most recent version. Defaults to "latest". +/// +/// Returns: +/// dict with: +/// - public_key: bytes - The raw public key (DER encoded) +/// - algorithm: str - The cryptographic algorithm (e.g., "ed25519", "rsa-pss-sha256") +/// - public_key_hash: str - SHA-256 hash of the public key +/// - agent_id: str - The agent ID the key belongs to +/// - version: str - The version of the key +/// +/// Raises: +/// RuntimeError: If the key is not found or network error occurs +/// +/// Environment Variables: +/// HAI_KEYS_BASE_URL: Base URL for the key service. Defaults to "https://keys.hai.ai". +/// JACS_KEY_RESOLUTION: Controls key resolution order: +/// - "hai-only": Only use HAI key service (default) +/// - "local-first": Try local trust store, fall back to HAI +/// - "hai-first": Try HAI first, fall back to local trust store +/// +/// Example: +/// key_info = jacs.fetch_remote_key("550e8400-e29b-41d4-a716-446655440000") +/// print(f"Algorithm: {key_info['algorithm']}") +/// print(f"Hash: {key_info['public_key_hash']}") +#[pyfunction] +#[pyo3(signature = (agent_id, version = "latest"))] +fn fetch_remote_key(py: Python, agent_id: &str, version: &str) -> PyResult { + let key_info = jacs_binding_core::fetch_remote_key(agent_id, version).to_py()?; + + let dict = pyo3::types::PyDict::new(py); + // Convert Vec to Python bytes + let public_key_bytes = pyo3::types::PyBytes::new(py, &key_info.public_key); + dict.set_item("public_key", public_key_bytes)?; + dict.set_item("algorithm", &key_info.algorithm)?; + dict.set_item("public_key_hash", &key_info.public_key_hash)?; + dict.set_item("agent_id", &key_info.agent_id)?; + dict.set_item("version", &key_info.version)?; + + Ok(dict.into()) +} + // ============================================================================= // Legacy Module-Level Functions (Deprecated) // ============================================================================= @@ -1218,6 +1272,11 @@ fn jacs(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(is_trusted, m)?)?; m.add_function(wrap_pyfunction!(get_trusted_agent, m)?)?; + // ============================================================================= + // Remote Key Fetch Functions + // ============================================================================= + m.add_function(wrap_pyfunction!(fetch_remote_key, m)?)?; + // ============================================================================= // Legacy Functions (Deprecated - for backward compatibility) // =============================================================================