diff --git a/tap-agent/Cargo.toml b/tap-agent/Cargo.toml index 111b77f9..9e7bd970 100644 --- a/tap-agent/Cargo.toml +++ b/tap-agent/Cargo.toml @@ -52,6 +52,7 @@ web-sys = { version = "0.3", features = [ "Response", "Headers", ], optional = true } +hex = "0.4" aes-gcm = "0.10.3" aes = "0.8" aes-kw = "0.2" diff --git a/tap-agent/src/agent.rs b/tap-agent/src/agent.rs index 6acf94d4..c445460a 100644 --- a/tap-agent/src/agent.rs +++ b/tap-agent/src/agent.rs @@ -547,6 +547,26 @@ impl TapAgent { } } + /// Creates a new TapAgent from a secret helper + /// + /// Invokes the secret helper to retrieve the private key for the given DID, + /// then creates a TapAgent using that key. + /// + /// # Arguments + /// + /// * `config` - The secret helper configuration + /// * `did` - The DID to fetch the key for + /// * `debug` - Whether to enable debug mode + #[cfg(not(target_arch = "wasm32"))] + pub async fn from_secret_helper( + config: &crate::secret_helper::SecretHelperConfig, + did: &str, + debug: bool, + ) -> Result<(Self, String)> { + let (private_key, key_type) = config.get_key(did)?; + Self::from_private_key(&private_key, key_type, debug).await + } + /// Creates a new TapAgent from an existing private key /// /// This function creates a new TapAgent using a provided private key, diff --git a/tap-agent/src/agent_key_manager.rs b/tap-agent/src/agent_key_manager.rs index f958c50e..6bc75c47 100644 --- a/tap-agent/src/agent_key_manager.rs +++ b/tap-agent/src/agent_key_manager.rs @@ -144,6 +144,35 @@ impl AgentKeyManager { Ok(()) } + /// Get the raw private key bytes and key type for a DID + /// + /// Checks generated_keys first (raw bytes), falls back to extracting + /// from the secrets JWK "d" parameter. + pub fn get_private_key(&self, did: &str) -> Result<(Vec, KeyType)> { + // Check generated_keys first (has raw bytes) + if let Ok(generated_keys) = self.generated_keys.read() { + if let Some(key) = generated_keys.get(did) { + return Ok((key.private_key.clone(), key.key_type)); + } + } else { + return Err(Error::FailedToAcquireResolverReadLock); + } + + // Fall back to secrets (JWK format) + if let Ok(secrets) = self.secrets.read() { + if let Some(secret) = secrets.get(did) { + return crate::key_manager::extract_private_key_from_secret(secret); + } + } else { + return Err(Error::FailedToAcquireResolverReadLock); + } + + Err(Error::KeyNotFound(format!( + "Private key not found for DID: {}", + did + ))) + } + /// Save keys to storage if a storage path is configured pub fn save_to_storage(&self) -> Result<()> { // Skip if no storage path is configured @@ -399,6 +428,11 @@ impl KeyManager for AgentKeyManager { Arc::clone(&self.secrets) } + /// Get the raw private key bytes and key type for a DID + fn get_private_key(&self, did: &str) -> Result<(Vec, KeyType)> { + AgentKeyManager::get_private_key(self, did) + } + /// Generate a new key with the specified options fn generate_key(&self, options: DIDGenerationOptions) -> Result { self.generate_key_internal(options, true) @@ -1053,3 +1087,111 @@ impl KeyManagerPacking for AgentKeyManager { .map_err(|e| Error::from(MessageError::KeyManager(e.to_string()))) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::TapAgent; + use crate::did::{DIDGenerationOptions, KeyType}; + use crate::key_manager::KeyManager; + + #[test] + fn test_get_private_key_for_generated_key() { + let km = AgentKeyManager::new(); + let key = km + .generate_key(DIDGenerationOptions { + key_type: KeyType::Ed25519, + }) + .unwrap(); + + let (private_key, key_type) = km.get_private_key(&key.did).unwrap(); + assert_eq!(private_key, key.private_key); + assert_eq!(key_type, KeyType::Ed25519); + } + + #[test] + fn test_get_private_key_for_storage_loaded_key() { + // Simulate a key loaded from storage (only in secrets, not in generated_keys) + let km = AgentKeyManager::new(); + let key = km + .generate_key(DIDGenerationOptions { + key_type: KeyType::Ed25519, + }) + .unwrap(); + + // Create a new key manager and load only the secret (simulating storage load) + let km2 = AgentKeyManager::new(); + let secret = km.secrets().read().unwrap().get(&key.did).cloned().unwrap(); + km2.secrets() + .write() + .unwrap() + .insert(key.did.clone(), secret); + + // Should still be able to extract the private key from the secret + let (private_key, key_type) = km2.get_private_key(&key.did).unwrap(); + assert_eq!(private_key, key.private_key); + assert_eq!(key_type, KeyType::Ed25519); + } + + #[cfg(feature = "crypto-p256")] + #[test] + fn test_get_private_key_p256() { + let km = AgentKeyManager::new(); + let key = km + .generate_key(DIDGenerationOptions { + key_type: KeyType::P256, + }) + .unwrap(); + + let (private_key, key_type) = km.get_private_key(&key.did).unwrap(); + assert_eq!(private_key, key.private_key); + assert_eq!(key_type, KeyType::P256); + } + + #[cfg(feature = "crypto-secp256k1")] + #[test] + fn test_get_private_key_secp256k1() { + let km = AgentKeyManager::new(); + let key = km + .generate_key(DIDGenerationOptions { + key_type: KeyType::Secp256k1, + }) + .unwrap(); + + let (private_key, key_type) = km.get_private_key(&key.did).unwrap(); + assert_eq!(private_key, key.private_key); + assert_eq!(key_type, KeyType::Secp256k1); + } + + #[test] + fn test_get_private_key_unknown_did() { + let km = AgentKeyManager::new(); + let result = km.get_private_key("did:key:nonexistent"); + assert!(result.is_err()); + match result.unwrap_err() { + Error::KeyNotFound(_) => {} // expected + other => panic!("Expected KeyNotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_get_private_key_roundtrip() { + let km = AgentKeyManager::new(); + let key = km + .generate_key(DIDGenerationOptions { + key_type: KeyType::Ed25519, + }) + .unwrap(); + + // Export + let (private_key, key_type) = km.get_private_key(&key.did).unwrap(); + + // Reimport + let (_agent, new_did) = TapAgent::from_private_key(&private_key, key_type, false) + .await + .unwrap(); + + // Same DID + assert_eq!(new_did, key.did); + } +} diff --git a/tap-agent/src/key_manager.rs b/tap-agent/src/key_manager.rs index ac8a0bf7..b2a1c66a 100644 --- a/tap-agent/src/key_manager.rs +++ b/tap-agent/src/key_manager.rs @@ -73,6 +73,9 @@ pub trait KeyManager: Send + Sync + std::fmt::Debug + 'static { /// Get a list of all DIDs in the key manager fn list_keys(&self) -> Result>; + /// Get the raw private key bytes and key type for a DID + fn get_private_key(&self, did: &str) -> Result<(Vec, crate::did::KeyType)>; + /// Add a signing key to the key manager async fn add_signing_key(&self, key: Arc) -> Result<()>; @@ -121,6 +124,45 @@ pub trait KeyManager: Send + Sync + std::fmt::Debug + 'static { async fn decrypt_jwe(&self, jwe: &str, expected_kid: Option<&str>) -> Result>; } +/// Extract private key bytes and key type from a Secret's JWK "d" parameter +pub fn extract_private_key_from_secret(secret: &Secret) -> Result<(Vec, crate::did::KeyType)> { + match &secret.secret_material { + SecretMaterial::JWK { private_key_jwk } => { + let d = private_key_jwk + .get("d") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + Error::KeyNotFound("Secret JWK missing 'd' parameter".to_string()) + })?; + + let private_key = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, d) + .map_err(|e| { + Error::Cryptography(format!("Failed to decode private key from JWK: {}", e)) + })?; + + let kty = private_key_jwk.get("kty").and_then(|v| v.as_str()); + let crv = private_key_jwk.get("crv").and_then(|v| v.as_str()); + + let key_type = match (kty, crv) { + #[cfg(feature = "crypto-ed25519")] + (Some("OKP"), Some("Ed25519")) => crate::did::KeyType::Ed25519, + #[cfg(feature = "crypto-p256")] + (Some("EC"), Some("P-256")) => crate::did::KeyType::P256, + #[cfg(feature = "crypto-secp256k1")] + (Some("EC"), Some("secp256k1")) => crate::did::KeyType::Secp256k1, + _ => { + return Err(Error::KeyNotFound(format!( + "Unsupported key type: kty={:?}, crv={:?}", + kty, crv + ))) + } + }; + + Ok((private_key, key_type)) + } + } +} + /// A default implementation of the KeyManager trait. #[derive(Debug, Clone)] pub struct DefaultKeyManager { @@ -174,6 +216,22 @@ impl KeyManager for DefaultKeyManager { Arc::clone(&self.secrets) } + /// Get the raw private key bytes and key type for a DID + fn get_private_key(&self, did: &str) -> Result<(Vec, crate::did::KeyType)> { + if let Ok(secrets) = self.secrets.read() { + if let Some(secret) = secrets.get(did) { + return extract_private_key_from_secret(secret); + } + } else { + return Err(Error::FailedToAcquireResolverReadLock); + } + + Err(Error::KeyNotFound(format!( + "Private key not found for DID: {}", + did + ))) + } + /// Generate a new key with the specified options fn generate_key(&self, options: DIDGenerationOptions) -> Result { // Generate the key diff --git a/tap-agent/src/lib.rs b/tap-agent/src/lib.rs index fc635a05..774307b1 100644 --- a/tap-agent/src/lib.rs +++ b/tap-agent/src/lib.rs @@ -110,6 +110,10 @@ pub mod oob; /// Payment link functionality pub mod payment_link; +/// Secret helper for external key management +#[cfg(not(target_arch = "wasm32"))] +pub mod secret_helper; + /// Key storage utilities pub mod storage; @@ -132,7 +136,9 @@ pub use did::{ VerificationMaterial, VerificationMethod, VerificationMethodType, }; pub use error::{Error, Result}; -pub use key_manager::{KeyManager, Secret, SecretMaterial, SecretType}; +pub use key_manager::{ + extract_private_key_from_secret, KeyManager, Secret, SecretMaterial, SecretType, +}; pub use storage::{KeyStorage, StoredKey}; // Agent key re-exports @@ -154,6 +160,10 @@ pub use payment_link::{ DEFAULT_PAYMENT_SERVICE_URL, }; +// Secret helper re-exports +#[cfg(not(target_arch = "wasm32"))] +pub use secret_helper::{SecretHelperConfig, SecretHelperOutput}; + // Native-only DID resolver re-exports #[cfg(not(target_arch = "wasm32"))] pub use did::MultiResolver; diff --git a/tap-agent/src/secret_helper.rs b/tap-agent/src/secret_helper.rs new file mode 100644 index 00000000..e8a61d92 --- /dev/null +++ b/tap-agent/src/secret_helper.rs @@ -0,0 +1,447 @@ +//! Secret helper for external key management integration +//! +//! Provides a git-like secret helper pattern that allows TAP agents to retrieve +//! private keys from external secret stores (HashiCorp Vault, AWS KMS, 1Password, etc). +//! +//! ## Protocol +//! +//! The secret helper is invoked as: +//! ```text +//! [args...] +//! ``` +//! +//! It outputs JSON to stdout: +//! ```json +//! {"private_key": "abcdef...", "key_type": "Ed25519", "encoding": "hex"} +//! ``` +//! +//! - `private_key` (required): key material +//! - `key_type` (required): `Ed25519` | `P256` | `Secp256k1` +//! - `encoding` (optional, default `hex`): `hex` | `base64` + +use crate::did::KeyType; +use crate::error::{Error, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Output format from a secret helper command +#[derive(Debug, Deserialize)] +pub struct SecretHelperOutput { + /// Private key material (hex or base64 encoded) + pub private_key: String, + /// Key type string + pub key_type: String, + /// Encoding format (defaults to "hex") + #[serde(default = "default_encoding")] + pub encoding: String, +} + +fn default_encoding() -> String { + "hex".to_string() +} + +impl SecretHelperOutput { + /// Decode the private key bytes and parse the key type + pub fn decode(&self) -> Result<(Vec, KeyType)> { + let bytes = match self.encoding.as_str() { + "hex" => hex::decode(&self.private_key).map_err(|e| { + Error::Cryptography(format!("Failed to decode hex private key: {}", e)) + })?, + "base64" => { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&self.private_key) + .map_err(|e| { + Error::Cryptography(format!("Failed to decode base64 private key: {}", e)) + })? + } + other => { + return Err(Error::Validation(format!( + "Unsupported encoding: {}", + other + ))) + } + }; + + let key_type = match self.key_type.as_str() { + "Ed25519" => { + #[cfg(feature = "crypto-ed25519")] + { + KeyType::Ed25519 + } + #[cfg(not(feature = "crypto-ed25519"))] + { + return Err(Error::Validation("Ed25519 support not enabled".to_string())); + } + } + "P256" => { + #[cfg(feature = "crypto-p256")] + { + KeyType::P256 + } + #[cfg(not(feature = "crypto-p256"))] + { + return Err(Error::Validation("P256 support not enabled".to_string())); + } + } + "Secp256k1" => { + #[cfg(feature = "crypto-secp256k1")] + { + KeyType::Secp256k1 + } + #[cfg(not(feature = "crypto-secp256k1"))] + { + return Err(Error::Validation( + "Secp256k1 support not enabled".to_string(), + )); + } + } + other => return Err(Error::Validation(format!("Unknown key type: {}", other))), + }; + + Ok((bytes, key_type)) + } +} + +/// Configuration for a secret helper command +#[derive(Debug, Clone)] +pub struct SecretHelperConfig { + /// The command to execute + pub command: String, + /// Arguments to pass before the DID + pub args: Vec, +} + +impl SecretHelperConfig { + /// Parse a command string into a SecretHelperConfig + /// + /// Splits on whitespace. The first token is the command, the rest are arguments. + /// The DID will be appended as the final argument at invocation time. + pub fn from_command_string(s: &str) -> Result { + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.is_empty() { + return Err(Error::Validation( + "Secret helper command string is empty".to_string(), + )); + } + + Ok(Self { + command: parts[0].to_string(), + args: parts[1..].iter().map(|s| s.to_string()).collect(), + }) + } + + /// Invoke the secret helper for a given DID and return the decoded key + pub fn get_key(&self, did: &str) -> Result<(Vec, KeyType)> { + let mut cmd = Command::new(&self.command); + for arg in &self.args { + cmd.arg(arg); + } + cmd.arg(did); + + // Inherit stderr so the user sees errors from the helper + cmd.stderr(std::process::Stdio::inherit()); + + let output = cmd.output().map_err(|e| { + Error::Storage(format!( + "Failed to run secret helper '{}': {}", + self.command, e + )) + })?; + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + return Err(Error::Storage(format!( + "Secret helper '{}' exited with code {}", + self.command, code + ))); + } + + let stdout = String::from_utf8(output.stdout).map_err(|e| { + Error::Storage(format!( + "Secret helper produced invalid UTF-8 output: {}", + e + )) + })?; + + let helper_output: SecretHelperOutput = serde_json::from_str(&stdout).map_err(|e| { + Error::Storage(format!("Failed to parse secret helper JSON output: {}", e)) + })?; + + helper_output.decode() + } +} + +/// Discover agent DIDs by scanning TAP home directory for `did_*` subdirectories +/// +/// Each agent creates a directory like `did_key_z6Mk...` when `KeyStorage::create_agent_directory` +/// is called. This function reverses the sanitization (`_` -> `:`) to recover the DID. +pub fn discover_agent_dids(tap_root: Option<&Path>) -> Result> { + let tap_dir = if let Some(root) = tap_root { + root.to_path_buf() + } else if let Ok(tap_home) = std::env::var("TAP_HOME") { + PathBuf::from(tap_home) + } else if let Ok(test_dir) = std::env::var("TAP_TEST_DIR") { + PathBuf::from(test_dir).join(crate::storage::DEFAULT_TAP_DIR) + } else { + dirs::home_dir() + .ok_or_else(|| Error::Storage("Could not determine home directory".to_string()))? + .join(crate::storage::DEFAULT_TAP_DIR) + }; + + if !tap_dir.exists() { + return Ok(Vec::new()); + } + + let mut dids = Vec::new(); + for entry in std::fs::read_dir(&tap_dir) + .map_err(|e| Error::Storage(format!("Failed to read TAP directory: {}", e)))? + { + let entry = + entry.map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?; + let path = entry.path(); + if path.is_dir() { + let dir_name = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + // Only consider directories that look like sanitized DIDs (contain "did_") + if dir_name.starts_with("did_") { + let did = dir_name.replace('_', ":"); + dids.push(did); + } + } + } + + dids.sort(); + Ok(dids) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key_manager::KeyManager; + use tempfile::TempDir; + + /// Write script content to a file, set it executable, and rename to final path. + /// The write-then-rename avoids ETXTBSY ("Text file busy") on Linux, which can + /// occur when exec races with the kernel releasing a write file descriptor. + #[cfg(unix)] + fn write_test_script(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let tmp_path = dir.join(format!("{}.tmp", name)); + let final_path = dir.join(name); + std::fs::write(&tmp_path, content).unwrap(); + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::rename(&tmp_path, &final_path).unwrap(); + final_path + } + + #[test] + fn test_from_command_string_simple() { + let config = SecretHelperConfig::from_command_string("my-helper").unwrap(); + assert_eq!(config.command, "my-helper"); + assert!(config.args.is_empty()); + } + + #[test] + fn test_from_command_string_with_args() { + let config = SecretHelperConfig::from_command_string( + "vault-helper --vault-addr https://vault.example.com", + ) + .unwrap(); + assert_eq!(config.command, "vault-helper"); + assert_eq!( + config.args, + vec!["--vault-addr", "https://vault.example.com"] + ); + } + + #[test] + fn test_from_command_string_empty() { + let result = SecretHelperConfig::from_command_string(""); + assert!(result.is_err()); + } + + #[test] + fn test_secret_helper_output_hex() { + let json = r#"{"private_key": "abcdef0123456789", "key_type": "Ed25519"}"#; + let output: SecretHelperOutput = serde_json::from_str(json).unwrap(); + assert_eq!(output.encoding, "hex"); // default + let (bytes, key_type) = output.decode().unwrap(); + assert_eq!(bytes, hex::decode("abcdef0123456789").unwrap()); + assert_eq!(key_type, KeyType::Ed25519); + } + + #[test] + fn test_secret_helper_output_base64() { + use base64::Engine; + let key_bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8]; + let b64 = base64::engine::general_purpose::STANDARD.encode(&key_bytes); + let json = format!( + r#"{{"private_key": "{}", "key_type": "Ed25519", "encoding": "base64"}}"#, + b64 + ); + let output: SecretHelperOutput = serde_json::from_str(&json).unwrap(); + let (bytes, _) = output.decode().unwrap(); + assert_eq!(bytes, key_bytes); + } + + #[test] + fn test_secret_helper_output_explicit_hex() { + let json = r#"{"private_key": "deadbeef", "key_type": "Ed25519", "encoding": "hex"}"#; + let output: SecretHelperOutput = serde_json::from_str(json).unwrap(); + let (bytes, _) = output.decode().unwrap(); + assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn test_secret_helper_output_unknown_key_type() { + let json = r#"{"private_key": "abcd", "key_type": "RSA"}"#; + let output: SecretHelperOutput = serde_json::from_str(json).unwrap(); + let result = output.decode(); + assert!(result.is_err()); + } + + #[test] + fn test_secret_helper_output_unsupported_encoding() { + let json = r#"{"private_key": "abcd", "key_type": "Ed25519", "encoding": "raw"}"#; + let output: SecretHelperOutput = serde_json::from_str(json).unwrap(); + let result = output.decode(); + assert!(result.is_err()); + } + + #[test] + fn test_get_key_with_mock_script() { + let temp_dir = TempDir::new().unwrap(); + + // Generate a real key to test with + let km = crate::agent_key_manager::AgentKeyManager::new(); + let key = km + .generate_key(crate::did::DIDGenerationOptions { + key_type: KeyType::Ed25519, + }) + .unwrap(); + let hex_key = hex::encode(&key.private_key); + + let script_path = write_test_script( + temp_dir.path(), + "helper.sh", + &format!( + "#!/bin/sh\necho '{{\"private_key\": \"{}\", \"key_type\": \"Ed25519\"}}'", + hex_key + ), + ); + + let config = SecretHelperConfig { + command: script_path.to_str().unwrap().to_string(), + args: vec![], + }; + + let (bytes, key_type) = config.get_key(&key.did).unwrap(); + assert_eq!(bytes, key.private_key); + assert_eq!(key_type, KeyType::Ed25519); + } + + #[tokio::test] + async fn test_secret_helper_roundtrip() { + let km = crate::agent_key_manager::AgentKeyManager::new(); + let key = km + .generate_key(crate::did::DIDGenerationOptions { + key_type: KeyType::Ed25519, + }) + .unwrap(); + let hex_key = hex::encode(&key.private_key); + + let temp_dir = TempDir::new().unwrap(); + let script_path = write_test_script( + temp_dir.path(), + "helper.sh", + &format!( + "#!/bin/sh\necho '{{\"private_key\": \"{}\", \"key_type\": \"Ed25519\"}}'", + hex_key + ), + ); + + let config = SecretHelperConfig { + command: script_path.to_str().unwrap().to_string(), + args: vec![], + }; + + let (bytes, key_type) = config.get_key(&key.did).unwrap(); + let (_agent, new_did) = crate::agent::TapAgent::from_private_key(&bytes, key_type, false) + .await + .unwrap(); + assert_eq!(new_did, key.did); + } + + #[test] + fn test_get_key_command_not_found() { + let config = SecretHelperConfig { + command: "/nonexistent/helper".to_string(), + args: vec![], + }; + let result = config.get_key("did:key:test"); + assert!(result.is_err()); + } + + #[test] + fn test_get_key_non_zero_exit() { + let temp_dir = TempDir::new().unwrap(); + let script_path = write_test_script(temp_dir.path(), "fail.sh", "#!/bin/sh\nexit 1"); + + let config = SecretHelperConfig { + command: script_path.to_str().unwrap().to_string(), + args: vec![], + }; + let result = config.get_key("did:key:test"); + assert!(result.is_err()); + } + + #[test] + fn test_get_key_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let script_path = + write_test_script(temp_dir.path(), "bad-json.sh", "#!/bin/sh\necho 'not json'"); + + let config = SecretHelperConfig { + command: script_path.to_str().unwrap().to_string(), + args: vec![], + }; + let result = config.get_key("did:key:test"); + assert!(result.is_err()); + } + + #[test] + fn test_discover_agent_dids() { + let temp_dir = TempDir::new().unwrap(); + let tap_dir = temp_dir.path(); + + // Create some agent directories + std::fs::create_dir(tap_dir.join("did_key_z6Mk1234")).unwrap(); + std::fs::create_dir(tap_dir.join("did_web_example.com")).unwrap(); + // Not a DID directory - should be ignored + std::fs::create_dir(tap_dir.join("logs")).unwrap(); + // Create a file - should be ignored + std::fs::write(tap_dir.join("keys.json"), "{}").unwrap(); + + let dids = discover_agent_dids(Some(tap_dir)).unwrap(); + assert_eq!(dids.len(), 2); + assert!(dids.contains(&"did:key:z6Mk1234".to_string())); + assert!(dids.contains(&"did:web:example.com".to_string())); + } + + #[test] + fn test_discover_agent_dids_empty() { + let temp_dir = TempDir::new().unwrap(); + let dids = discover_agent_dids(Some(temp_dir.path())).unwrap(); + assert!(dids.is_empty()); + } + + #[test] + fn test_discover_agent_dids_nonexistent() { + let dids = discover_agent_dids(Some(Path::new("/nonexistent/path"))).unwrap(); + assert!(dids.is_empty()); + } +} diff --git a/tap-cli/Cargo.toml b/tap-cli/Cargo.toml index ec8ec7f9..16c4e3b3 100644 --- a/tap-cli/Cargo.toml +++ b/tap-cli/Cargo.toml @@ -43,7 +43,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1.0", features = ["v4"] } # CLI argument parsing -clap = { version = "4.0", features = ["derive"] } +clap = { version = "4.0", features = ["derive", "env"] } # Async traits async-trait = "0.1" diff --git a/tap-cli/src/main.rs b/tap-cli/src/main.rs index cd18843c..d0d9aaad 100644 --- a/tap-cli/src/main.rs +++ b/tap-cli/src/main.rs @@ -59,6 +59,10 @@ struct Cli { #[arg(long, global = true, default_value = "json")] format: String, + /// Secret helper command for external key management (replaces keys.json) + #[arg(long, global = true, env = "TAP_SECRET_HELPER")] + secret_helper: Option, + #[command(subcommand)] command: Commands, } @@ -270,6 +274,36 @@ async fn main() { } async fn resolve_agent(cli: &Cli) -> Result<(Arc, String)> { + // If secret helper is configured, use it instead of keys.json + if let Some(ref helper_cmd) = cli.secret_helper { + use tap_agent::secret_helper::{self, SecretHelperConfig}; + + let config = SecretHelperConfig::from_command_string(helper_cmd)?; + + if let Some(ref did) = cli.agent_did { + info!("Using secret helper for DID: {}", did); + let (agent, did) = TapAgent::from_secret_helper(&config, did, cli.debug).await?; + return Ok((Arc::new(agent), did)); + } + + // No --agent-did: discover DIDs from tap directory + let tap_root = cli.tap_root.as_ref().map(std::path::PathBuf::from); + let dids = secret_helper::discover_agent_dids(tap_root.as_deref())?; + if let Some(did) = dids.first() { + info!( + "Discovered DID {} from tap directory, using secret helper", + did + ); + let (agent, did) = TapAgent::from_secret_helper(&config, did, cli.debug).await?; + return Ok((Arc::new(agent), did)); + } + + return Err(tap_agent::Error::Storage( + "No agent DIDs found in tap directory for secret helper".to_string(), + ) + .into()); + } + if let Some(ref did) = cli.agent_did { info!("Using provided agent DID: {}", did); match TapAgent::from_stored_keys(Some(did.clone()), true).await { diff --git a/tap-http/src/main.rs b/tap-http/src/main.rs index 79f61b48..2d95d6a6 100644 --- a/tap-http/src/main.rs +++ b/tap-http/src/main.rs @@ -39,6 +39,7 @@ struct Args { decision_exec: Option, decision_exec_args: Vec, decision_subscribe: String, + secret_helper: Option, } impl Args { @@ -127,6 +128,9 @@ impl Args { env::var("TAP_DECISION_SUBSCRIBE").unwrap_or_else(|_| "decisions".to_string()) }) }, + secret_helper: args + .opt_value_from_str("--secret-helper")? + .or_else(|| env::var("TAP_SECRET_HELPER").ok()), }; // Check for any remaining arguments (which would be invalid) @@ -168,6 +172,7 @@ AGENT OPTIONS: --tap-root TAP root directory [default: ~/.tap] --db-path Database file path (overrides per-agent default) --logs-dir Event log directory [default: ~/.tap/logs] + --secret-helper Secret helper command for external key management DECISION OPTIONS: -M, --decision-mode Decision handling mode [default: auto] @@ -196,6 +201,7 @@ ENVIRONMENT VARIABLES: TAP_LOGS_DIR Event log directory TAP_STRUCTURED_LOGS Enable structured JSON logging (set to any value) TAP_ENABLE_WEB_DID Enable did:web endpoint (set to any value) + TAP_SECRET_HELPER Secret helper command (replaces keys.json) TAP_DECISION_MODE Decision handling: auto, poll, or exec TAP_DECISION_EXEC Path to external decision executable TAP_DECISION_EXEC_ARGS Comma-separated arguments @@ -313,47 +319,81 @@ async fn main() -> Result<(), Box> { } } - // Create the actual agent - try to load from storage first, create if none exist - let agent = match TapAgent::from_stored_keys(None, true).await { - Ok(agent) => { - info!("Loaded agent from stored keys"); + // Create the actual agent + // If secret helper is configured, use it instead of keys.json + let agent = if let Some(ref helper_cmd) = args.secret_helper { + use tap_agent::secret_helper::{self, SecretHelperConfig}; + + let config = SecretHelperConfig::from_command_string(helper_cmd)?; + let tap_root = args.tap_root.as_ref().map(std::path::PathBuf::from); + + if let Some(ref did) = args.agent_did { + info!("Using secret helper for DID: {}", did); + let (agent, _) = TapAgent::from_secret_helper(&config, did, args.verbose).await?; agent + } else { + // Discover DIDs from tap directory + let dids = secret_helper::discover_agent_dids(tap_root.as_deref())?; + if let Some(did) = dids.first() { + info!( + "Discovered DID {} from tap directory, using secret helper", + did + ); + let (agent, _) = TapAgent::from_secret_helper(&config, did, args.verbose).await?; + agent + } else { + return Err("No agent DIDs found in tap directory for secret helper".into()); + } } - Err(e) => { - info!("No stored keys found ({}), creating new agent...", e); + } else if args.agent_did.is_some() { + // Use specified agent DID from storage + match TapAgent::from_stored_keys(args.agent_did.clone(), true).await { + Ok(agent) => agent, + Err(e) => return Err(format!("Failed to load agent: {}", e).into()), + } + } else { + // Try to load from storage first, create if none exist + match TapAgent::from_stored_keys(None, true).await { + Ok(agent) => { + info!("Loaded agent from stored keys"); + agent + } + Err(e) => { + info!("No stored keys found ({}), creating new agent...", e); - // Create a key manager with storage enabled and generate a new key - let default_key_path = KeyStorage::default_key_path().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "Could not determine default key path", - ) - })?; - let key_manager_builder = - AgentKeyManagerBuilder::new().load_from_path(default_key_path); - let key_manager = key_manager_builder.build()?; + // Create a key manager with storage enabled and generate a new key + let default_key_path = KeyStorage::default_key_path().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not determine default key path", + ) + })?; + let key_manager_builder = + AgentKeyManagerBuilder::new().load_from_path(default_key_path); + let key_manager = key_manager_builder.build()?; - // Generate a new key - let generated_key = key_manager.generate_key(DIDGenerationOptions { - key_type: KeyType::Ed25519, - })?; + // Generate a new key + let generated_key = key_manager.generate_key(DIDGenerationOptions { + key_type: KeyType::Ed25519, + })?; - info!("Generated new agent with DID: {}", generated_key.did); + info!("Generated new agent with DID: {}", generated_key.did); - // Create agent config and build agent - let config = AgentConfig::new(generated_key.did.clone()).with_debug(true); + // Create agent config and build agent + let config = AgentConfig::new(generated_key.did.clone()).with_debug(true); - #[cfg(all(not(target_arch = "wasm32"), test))] - let agent = TapAgent::new(config, Arc::new(key_manager)); + #[cfg(all(not(target_arch = "wasm32"), test))] + let agent = TapAgent::new(config, Arc::new(key_manager)); - #[cfg(all(not(target_arch = "wasm32"), not(test)))] - let agent = TapAgent::new(config, Arc::new(key_manager)); + #[cfg(all(not(target_arch = "wasm32"), not(test)))] + let agent = TapAgent::new(config, Arc::new(key_manager)); - #[cfg(target_arch = "wasm32")] - let agent = TapAgent::new(config, Arc::new(key_manager)); + #[cfg(target_arch = "wasm32")] + let agent = TapAgent::new(config, Arc::new(key_manager)); - info!("New key saved to storage successfully"); - agent + info!("New key saved to storage successfully"); + agent + } } }; let agent_did = agent.get_agent_did().to_string(); // Clone to a String to avoid borrowing issues diff --git a/tap-mcp/Cargo.toml b/tap-mcp/Cargo.toml index c87372ba..10440bf6 100644 --- a/tap-mcp/Cargo.toml +++ b/tap-mcp/Cargo.toml @@ -47,7 +47,7 @@ uuid = { version = "1.0", features = ["v4"] } futures = "0.3" # CLI argument parsing -clap = { version = "4.0", features = ["derive"] } +clap = { version = "4.0", features = ["derive", "env"] } # Async traits async-trait = "0.1" diff --git a/tap-mcp/src/main.rs b/tap-mcp/src/main.rs index 4724d9f9..e12f8547 100644 --- a/tap-mcp/src/main.rs +++ b/tap-mcp/src/main.rs @@ -31,6 +31,10 @@ struct Args { /// Custom TAP root directory [default: ~/.tap] #[arg(long)] tap_root: Option, + + /// Secret helper command for external key management (replaces keys.json) + #[arg(long, env = "TAP_SECRET_HELPER")] + secret_helper: Option, } #[tokio::main] @@ -68,7 +72,33 @@ async fn main() -> Result<()> { info!("Starting TAP-MCP server v{}", env!("CARGO_PKG_VERSION")); // Determine agent - use provided DID or load/create from storage - let (agent, agent_did) = if let Some(did) = args.agent_did { + let (agent, agent_did) = if let Some(ref helper_cmd) = args.secret_helper { + use tap_agent::secret_helper::{self, SecretHelperConfig}; + + let config = SecretHelperConfig::from_command_string(helper_cmd)?; + + if let Some(did) = args.agent_did { + info!("Using secret helper for DID: {}", did); + let (agent, did) = TapAgent::from_secret_helper(&config, &did, args.debug).await?; + (Arc::new(agent), did) + } else { + let tap_root = args.tap_root.as_ref().map(std::path::PathBuf::from); + let dids = secret_helper::discover_agent_dids(tap_root.as_deref())?; + if let Some(did) = dids.first() { + info!( + "Discovered DID {} from tap directory, using secret helper", + did + ); + let (agent, did) = TapAgent::from_secret_helper(&config, did, args.debug).await?; + (Arc::new(agent), did) + } else { + return Err(tap_agent::Error::Storage( + "No agent DIDs found in tap directory for secret helper".to_string(), + ) + .into()); + } + } + } else if let Some(did) = args.agent_did { info!("Using provided agent DID: {}", did); // Try to load the specified agent match TapAgent::from_stored_keys(Some(did.clone()), true).await { diff --git a/tap-wasm/src/wasm_agent.rs b/tap-wasm/src/wasm_agent.rs index 21bd9aae..22322690 100644 --- a/tap-wasm/src/wasm_agent.rs +++ b/tap-wasm/src/wasm_agent.rs @@ -203,21 +203,15 @@ impl WasmTapAgent { return Ok(stored_key.clone()); } - // Otherwise, try to get it from the key manager + // Use get_private_key() which checks both generated_keys and secrets let key_manager = self.agent.agent_key_manager(); - - // Get the agent's DID let did = &self.agent.config.agent_did; - // Get the generated key from the key manager - let generated_key = key_manager - .get_generated_key(did) + let (private_key_bytes, _key_type) = key_manager + .get_private_key(did) .map_err(|e| JsValue::from_str(&format!("Failed to get key for DID {}: {}", did, e)))?; - // Convert private key bytes to hex string - let hex_private_key = hex::encode(&generated_key.private_key); - - Ok(hex_private_key) + Ok(hex::encode(&private_key_bytes)) } /// Export the agent's public key as a hex string