/ crates / auths-cli / src / core / pubkey_cache.rs
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  }