Skip to main content

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