pubkey_cache.rs
1 //! Public key cache for passphrase-free signing. 2 //! 3 //! This module caches public keys in `~/.auths/pubkeys/<alias>.pub` to enable 4 //! truly passphrase-free signing after first use. The agent can use these 5 //! cached public keys to verify which key to use for signing without needing 6 //! to decrypt the private key. 7 8 use anyhow::{Context, Result, anyhow}; 9 use std::fs; 10 use std::path::PathBuf; 11 12 use super::fs::{create_restricted_dir, write_sensitive_file}; 13 14 /// Get the pubkey cache directory path (~/.auths/pubkeys), respecting AUTHS_HOME. 15 fn get_pubkey_cache_dir() -> Result<PathBuf> { 16 Ok(auths_core::paths::auths_home() 17 .map_err(|e| anyhow!(e))? 18 .join("pubkeys")) 19 } 20 21 /// Get the cache file path for a specific alias. 22 fn get_cache_path(alias: &str) -> Result<PathBuf> { 23 let dir = get_pubkey_cache_dir()?; 24 // Sanitize alias to prevent path traversal 25 let safe_alias = alias.replace(['/', '\\', '\0'], "_"); 26 Ok(dir.join(format!("{}.pub", safe_alias))) 27 } 28 29 /// Cache a public key for the given alias. 30 /// 31 /// The public key is stored as hex-encoded bytes in `~/.auths/pubkeys/<alias>.pub`. 32 /// 33 /// # Arguments 34 /// * `alias` - The key alias (e.g., "default"). 35 /// * `pubkey` - The 32-byte Ed25519 public key bytes. 36 /// 37 /// # Returns 38 /// * `Ok(())` on success. 39 /// * `Err` if the cache directory cannot be created or the file cannot be written. 40 pub fn cache_pubkey(alias: &str, pubkey: &[u8]) -> Result<()> { 41 if pubkey.len() != 32 { 42 return Err(anyhow!( 43 "Invalid public key length: expected 32 bytes, got {}", 44 pubkey.len() 45 )); 46 } 47 48 let cache_dir = get_pubkey_cache_dir()?; 49 create_restricted_dir(&cache_dir) 50 .with_context(|| format!("Failed to create pubkey cache directory: {:?}", cache_dir))?; 51 52 let cache_path = get_cache_path(alias)?; 53 let hex_pubkey = hex::encode(pubkey); 54 55 write_sensitive_file(&cache_path, &hex_pubkey) 56 .with_context(|| format!("Failed to write pubkey cache file: {:?}", cache_path))?; 57 58 Ok(()) 59 } 60 61 /// Get a cached public key for the given alias. 62 /// 63 /// # Arguments 64 /// * `alias` - The key alias (e.g., "default"). 65 /// 66 /// # Returns 67 /// * `Ok(Some(Vec<u8>))` - The 32-byte public key if cached. 68 /// * `Ok(None)` - If no cache exists for this alias. 69 /// * `Err` - If there's an error reading or parsing the cache. 70 pub fn get_cached_pubkey(alias: &str) -> Result<Option<Vec<u8>>> { 71 let cache_path = get_cache_path(alias)?; 72 73 if !cache_path.exists() { 74 return Ok(None); 75 } 76 77 let hex_pubkey = fs::read_to_string(&cache_path) 78 .with_context(|| format!("Failed to read pubkey cache file: {:?}", cache_path))?; 79 80 let pubkey = hex::decode(hex_pubkey.trim()) 81 .with_context(|| format!("Invalid hex in pubkey cache file: {:?}", cache_path))?; 82 83 if pubkey.len() != 32 { 84 return Err(anyhow!( 85 "Invalid cached public key length in {:?}: expected 32 bytes, got {}", 86 cache_path, 87 pubkey.len() 88 )); 89 } 90 91 Ok(Some(pubkey)) 92 } 93 94 /// Clear the cached public key for the given alias. 95 /// 96 /// This should be called when a key is deleted or rotated. 97 /// 98 /// # Arguments 99 /// * `alias` - The key alias (e.g., "default"). 100 /// 101 /// # Returns 102 /// * `Ok(true)` - If the cache was cleared. 103 /// * `Ok(false)` - If no cache existed for this alias. 104 /// * `Err` - If there's an error deleting the cache file. 105 pub fn clear_cached_pubkey(alias: &str) -> Result<bool> { 106 let cache_path = get_cache_path(alias)?; 107 108 if !cache_path.exists() { 109 return Ok(false); 110 } 111 112 fs::remove_file(&cache_path) 113 .with_context(|| format!("Failed to remove pubkey cache file: {:?}", cache_path))?; 114 115 Ok(true) 116 } 117 118 /// Clear all cached public keys. 119 /// 120 /// This is useful for a complete cache reset. 121 /// 122 /// # Returns 123 /// * `Ok(usize)` - The number of cache files removed. 124 /// * `Err` - If there's an error accessing the cache directory. 125 pub fn clear_all_cached_pubkeys() -> Result<usize> { 126 let cache_dir = get_pubkey_cache_dir()?; 127 128 if !cache_dir.exists() { 129 return Ok(0); 130 } 131 132 let mut count = 0; 133 for entry in fs::read_dir(&cache_dir) 134 .with_context(|| format!("Failed to read pubkey cache directory: {:?}", cache_dir))? 135 { 136 let entry = entry?; 137 let path = entry.path(); 138 if path.extension().is_some_and(|ext| ext == "pub") { 139 fs::remove_file(&path) 140 .with_context(|| format!("Failed to remove cache file: {:?}", path))?; 141 count += 1; 142 } 143 } 144 145 Ok(count) 146 } 147 148 #[cfg(test)] 149 mod tests { 150 use super::*; 151 152 // Note: These tests use a temporary directory override for isolation. 153 // In production, the actual ~/.auths/pubkeys directory is used. 154 155 #[test] 156 fn test_get_cache_path_sanitizes_alias() { 157 let path = get_cache_path("test/alias").unwrap(); 158 assert!(path.to_string_lossy().contains("test_alias.pub")); 159 } 160 161 #[test] 162 fn test_cache_pubkey_validates_length() { 163 let result = cache_pubkey("test", &[0u8; 16]); 164 assert!(result.is_err()); 165 assert!( 166 result 167 .unwrap_err() 168 .to_string() 169 .contains("expected 32 bytes") 170 ); 171 } 172 }