Skip to main content

tap_agent/
secret_helper.rs

1//! Secret helper for external key management integration
2//!
3//! Provides a git-like secret helper pattern that allows TAP agents to retrieve
4//! private keys from external secret stores (HashiCorp Vault, AWS KMS, 1Password, etc).
5//!
6//! ## Protocol
7//!
8//! The secret helper is invoked as:
9//! ```text
10//! <command> [args...] <did>
11//! ```
12//!
13//! It outputs JSON to stdout:
14//! ```json
15//! {"private_key": "abcdef...", "key_type": "Ed25519", "encoding": "hex"}
16//! ```
17//!
18//! - `private_key` (required): key material
19//! - `key_type` (required): `Ed25519` | `P256` | `Secp256k1`
20//! - `encoding` (optional, default `hex`): `hex` | `base64`
21
22use crate::did::KeyType;
23use crate::error::{Error, Result};
24use serde::Deserialize;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28/// Output format from a secret helper command
29#[derive(Debug, Deserialize)]
30pub struct SecretHelperOutput {
31    /// Private key material (hex or base64 encoded)
32    pub private_key: String,
33    /// Key type string
34    pub key_type: String,
35    /// Encoding format (defaults to "hex")
36    #[serde(default = "default_encoding")]
37    pub encoding: String,
38}
39
40fn default_encoding() -> String {
41    "hex".to_string()
42}
43
44impl SecretHelperOutput {
45    /// Decode the private key bytes and parse the key type
46    pub fn decode(&self) -> Result<(Vec<u8>, KeyType)> {
47        let bytes = match self.encoding.as_str() {
48            "hex" => hex::decode(&self.private_key).map_err(|e| {
49                Error::Cryptography(format!("Failed to decode hex private key: {}", e))
50            })?,
51            "base64" => {
52                use base64::Engine;
53                base64::engine::general_purpose::STANDARD
54                    .decode(&self.private_key)
55                    .map_err(|e| {
56                        Error::Cryptography(format!("Failed to decode base64 private key: {}", e))
57                    })?
58            }
59            other => {
60                return Err(Error::Validation(format!(
61                    "Unsupported encoding: {}",
62                    other
63                )))
64            }
65        };
66
67        let key_type = match self.key_type.as_str() {
68            "Ed25519" => {
69                #[cfg(feature = "crypto-ed25519")]
70                {
71                    KeyType::Ed25519
72                }
73                #[cfg(not(feature = "crypto-ed25519"))]
74                {
75                    return Err(Error::Validation("Ed25519 support not enabled".to_string()));
76                }
77            }
78            "P256" => {
79                #[cfg(feature = "crypto-p256")]
80                {
81                    KeyType::P256
82                }
83                #[cfg(not(feature = "crypto-p256"))]
84                {
85                    return Err(Error::Validation("P256 support not enabled".to_string()));
86                }
87            }
88            "Secp256k1" => {
89                #[cfg(feature = "crypto-secp256k1")]
90                {
91                    KeyType::Secp256k1
92                }
93                #[cfg(not(feature = "crypto-secp256k1"))]
94                {
95                    return Err(Error::Validation(
96                        "Secp256k1 support not enabled".to_string(),
97                    ));
98                }
99            }
100            other => return Err(Error::Validation(format!("Unknown key type: {}", other))),
101        };
102
103        Ok((bytes, key_type))
104    }
105}
106
107/// Configuration for a secret helper command
108#[derive(Debug, Clone)]
109pub struct SecretHelperConfig {
110    /// The command to execute
111    pub command: String,
112    /// Arguments to pass before the DID
113    pub args: Vec<String>,
114}
115
116impl SecretHelperConfig {
117    /// Parse a command string into a SecretHelperConfig
118    ///
119    /// Splits on whitespace. The first token is the command, the rest are arguments.
120    /// The DID will be appended as the final argument at invocation time.
121    pub fn from_command_string(s: &str) -> Result<Self> {
122        let parts: Vec<&str> = s.split_whitespace().collect();
123        if parts.is_empty() {
124            return Err(Error::Validation(
125                "Secret helper command string is empty".to_string(),
126            ));
127        }
128
129        Ok(Self {
130            command: parts[0].to_string(),
131            args: parts[1..].iter().map(|s| s.to_string()).collect(),
132        })
133    }
134
135    /// Invoke the secret helper for a given DID and return the decoded key
136    pub fn get_key(&self, did: &str) -> Result<(Vec<u8>, KeyType)> {
137        let mut cmd = Command::new(&self.command);
138        for arg in &self.args {
139            cmd.arg(arg);
140        }
141        cmd.arg(did);
142
143        // Inherit stderr so the user sees errors from the helper
144        cmd.stderr(std::process::Stdio::inherit());
145
146        let output = cmd.output().map_err(|e| {
147            Error::Storage(format!(
148                "Failed to run secret helper '{}': {}",
149                self.command, e
150            ))
151        })?;
152
153        if !output.status.success() {
154            let code = output.status.code().unwrap_or(-1);
155            return Err(Error::Storage(format!(
156                "Secret helper '{}' exited with code {}",
157                self.command, code
158            )));
159        }
160
161        let stdout = String::from_utf8(output.stdout).map_err(|e| {
162            Error::Storage(format!(
163                "Secret helper produced invalid UTF-8 output: {}",
164                e
165            ))
166        })?;
167
168        let helper_output: SecretHelperOutput = serde_json::from_str(&stdout).map_err(|e| {
169            Error::Storage(format!("Failed to parse secret helper JSON output: {}", e))
170        })?;
171
172        helper_output.decode()
173    }
174}
175
176/// Discover agent DIDs by scanning TAP home directory for `did_*` subdirectories
177///
178/// Each agent creates a directory like `did_key_z6Mk...` when `KeyStorage::create_agent_directory`
179/// is called. This function reverses the sanitization (`_` -> `:`) to recover the DID.
180pub fn discover_agent_dids(tap_root: Option<&Path>) -> Result<Vec<String>> {
181    let tap_dir = if let Some(root) = tap_root {
182        root.to_path_buf()
183    } else if let Ok(tap_home) = std::env::var("TAP_HOME") {
184        PathBuf::from(tap_home)
185    } else if let Ok(test_dir) = std::env::var("TAP_TEST_DIR") {
186        PathBuf::from(test_dir).join(crate::storage::DEFAULT_TAP_DIR)
187    } else {
188        dirs::home_dir()
189            .ok_or_else(|| Error::Storage("Could not determine home directory".to_string()))?
190            .join(crate::storage::DEFAULT_TAP_DIR)
191    };
192
193    if !tap_dir.exists() {
194        return Ok(Vec::new());
195    }
196
197    let mut dids = Vec::new();
198    for entry in std::fs::read_dir(&tap_dir)
199        .map_err(|e| Error::Storage(format!("Failed to read TAP directory: {}", e)))?
200    {
201        let entry =
202            entry.map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
203        let path = entry.path();
204        if path.is_dir() {
205            let dir_name = match path.file_name().and_then(|n| n.to_str()) {
206                Some(name) => name.to_string(),
207                None => continue,
208            };
209            // Only consider directories that look like sanitized DIDs (contain "did_")
210            if dir_name.starts_with("did_") {
211                let did = dir_name.replace('_', ":");
212                dids.push(did);
213            }
214        }
215    }
216
217    dids.sort();
218    Ok(dids)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::key_manager::KeyManager;
225    use tempfile::TempDir;
226
227    /// Write script content to a file, set it executable, and rename to final path.
228    /// The write-then-rename avoids ETXTBSY ("Text file busy") on Linux, which can
229    /// occur when exec races with the kernel releasing a write file descriptor.
230    #[cfg(unix)]
231    fn write_test_script(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf {
232        use std::os::unix::fs::PermissionsExt;
233        let tmp_path = dir.join(format!("{}.tmp", name));
234        let final_path = dir.join(name);
235        std::fs::write(&tmp_path, content).unwrap();
236        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755)).unwrap();
237        std::fs::rename(&tmp_path, &final_path).unwrap();
238        final_path
239    }
240
241    #[test]
242    fn test_from_command_string_simple() {
243        let config = SecretHelperConfig::from_command_string("my-helper").unwrap();
244        assert_eq!(config.command, "my-helper");
245        assert!(config.args.is_empty());
246    }
247
248    #[test]
249    fn test_from_command_string_with_args() {
250        let config = SecretHelperConfig::from_command_string(
251            "vault-helper --vault-addr https://vault.example.com",
252        )
253        .unwrap();
254        assert_eq!(config.command, "vault-helper");
255        assert_eq!(
256            config.args,
257            vec!["--vault-addr", "https://vault.example.com"]
258        );
259    }
260
261    #[test]
262    fn test_from_command_string_empty() {
263        let result = SecretHelperConfig::from_command_string("");
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_secret_helper_output_hex() {
269        let json = r#"{"private_key": "abcdef0123456789", "key_type": "Ed25519"}"#;
270        let output: SecretHelperOutput = serde_json::from_str(json).unwrap();
271        assert_eq!(output.encoding, "hex"); // default
272        let (bytes, key_type) = output.decode().unwrap();
273        assert_eq!(bytes, hex::decode("abcdef0123456789").unwrap());
274        assert_eq!(key_type, KeyType::Ed25519);
275    }
276
277    #[test]
278    fn test_secret_helper_output_base64() {
279        use base64::Engine;
280        let key_bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
281        let b64 = base64::engine::general_purpose::STANDARD.encode(&key_bytes);
282        let json = format!(
283            r#"{{"private_key": "{}", "key_type": "Ed25519", "encoding": "base64"}}"#,
284            b64
285        );
286        let output: SecretHelperOutput = serde_json::from_str(&json).unwrap();
287        let (bytes, _) = output.decode().unwrap();
288        assert_eq!(bytes, key_bytes);
289    }
290
291    #[test]
292    fn test_secret_helper_output_explicit_hex() {
293        let json = r#"{"private_key": "deadbeef", "key_type": "Ed25519", "encoding": "hex"}"#;
294        let output: SecretHelperOutput = serde_json::from_str(json).unwrap();
295        let (bytes, _) = output.decode().unwrap();
296        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
297    }
298
299    #[test]
300    fn test_secret_helper_output_unknown_key_type() {
301        let json = r#"{"private_key": "abcd", "key_type": "RSA"}"#;
302        let output: SecretHelperOutput = serde_json::from_str(json).unwrap();
303        let result = output.decode();
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn test_secret_helper_output_unsupported_encoding() {
309        let json = r#"{"private_key": "abcd", "key_type": "Ed25519", "encoding": "raw"}"#;
310        let output: SecretHelperOutput = serde_json::from_str(json).unwrap();
311        let result = output.decode();
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn test_get_key_with_mock_script() {
317        let temp_dir = TempDir::new().unwrap();
318
319        // Generate a real key to test with
320        let km = crate::agent_key_manager::AgentKeyManager::new();
321        let key = km
322            .generate_key(crate::did::DIDGenerationOptions {
323                key_type: KeyType::Ed25519,
324            })
325            .unwrap();
326        let hex_key = hex::encode(&key.private_key);
327
328        let script_path = write_test_script(
329            temp_dir.path(),
330            "helper.sh",
331            &format!(
332                "#!/bin/sh\necho '{{\"private_key\": \"{}\", \"key_type\": \"Ed25519\"}}'",
333                hex_key
334            ),
335        );
336
337        let config = SecretHelperConfig {
338            command: script_path.to_str().unwrap().to_string(),
339            args: vec![],
340        };
341
342        let (bytes, key_type) = config.get_key(&key.did).unwrap();
343        assert_eq!(bytes, key.private_key);
344        assert_eq!(key_type, KeyType::Ed25519);
345    }
346
347    #[tokio::test]
348    async fn test_secret_helper_roundtrip() {
349        let km = crate::agent_key_manager::AgentKeyManager::new();
350        let key = km
351            .generate_key(crate::did::DIDGenerationOptions {
352                key_type: KeyType::Ed25519,
353            })
354            .unwrap();
355        let hex_key = hex::encode(&key.private_key);
356
357        let temp_dir = TempDir::new().unwrap();
358        let script_path = write_test_script(
359            temp_dir.path(),
360            "helper.sh",
361            &format!(
362                "#!/bin/sh\necho '{{\"private_key\": \"{}\", \"key_type\": \"Ed25519\"}}'",
363                hex_key
364            ),
365        );
366
367        let config = SecretHelperConfig {
368            command: script_path.to_str().unwrap().to_string(),
369            args: vec![],
370        };
371
372        let (bytes, key_type) = config.get_key(&key.did).unwrap();
373        let (_agent, new_did) = crate::agent::TapAgent::from_private_key(&bytes, key_type, false)
374            .await
375            .unwrap();
376        assert_eq!(new_did, key.did);
377    }
378
379    #[test]
380    fn test_get_key_command_not_found() {
381        let config = SecretHelperConfig {
382            command: "/nonexistent/helper".to_string(),
383            args: vec![],
384        };
385        let result = config.get_key("did:key:test");
386        assert!(result.is_err());
387    }
388
389    #[test]
390    fn test_get_key_non_zero_exit() {
391        let temp_dir = TempDir::new().unwrap();
392        let script_path = write_test_script(temp_dir.path(), "fail.sh", "#!/bin/sh\nexit 1");
393
394        let config = SecretHelperConfig {
395            command: script_path.to_str().unwrap().to_string(),
396            args: vec![],
397        };
398        let result = config.get_key("did:key:test");
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_get_key_invalid_json() {
404        let temp_dir = TempDir::new().unwrap();
405        let script_path =
406            write_test_script(temp_dir.path(), "bad-json.sh", "#!/bin/sh\necho 'not json'");
407
408        let config = SecretHelperConfig {
409            command: script_path.to_str().unwrap().to_string(),
410            args: vec![],
411        };
412        let result = config.get_key("did:key:test");
413        assert!(result.is_err());
414    }
415
416    #[test]
417    fn test_discover_agent_dids() {
418        let temp_dir = TempDir::new().unwrap();
419        let tap_dir = temp_dir.path();
420
421        // Create some agent directories
422        std::fs::create_dir(tap_dir.join("did_key_z6Mk1234")).unwrap();
423        std::fs::create_dir(tap_dir.join("did_web_example.com")).unwrap();
424        // Not a DID directory - should be ignored
425        std::fs::create_dir(tap_dir.join("logs")).unwrap();
426        // Create a file - should be ignored
427        std::fs::write(tap_dir.join("keys.json"), "{}").unwrap();
428
429        let dids = discover_agent_dids(Some(tap_dir)).unwrap();
430        assert_eq!(dids.len(), 2);
431        assert!(dids.contains(&"did:key:z6Mk1234".to_string()));
432        assert!(dids.contains(&"did:web:example.com".to_string()));
433    }
434
435    #[test]
436    fn test_discover_agent_dids_empty() {
437        let temp_dir = TempDir::new().unwrap();
438        let dids = discover_agent_dids(Some(temp_dir.path())).unwrap();
439        assert!(dids.is_empty());
440    }
441
442    #[test]
443    fn test_discover_agent_dids_nonexistent() {
444        let dids = discover_agent_dids(Some(Path::new("/nonexistent/path"))).unwrap();
445        assert!(dids.is_empty());
446    }
447}