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
12use super::fs::{create_restricted_dir, write_sensitive_file};
13
14/// Get the pubkey cache directory path (~/.auths/pubkeys), respecting AUTHS_HOME.
15fn 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.
22fn 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.
40pub 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.
70pub 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.
105pub 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.
125pub 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)]
149mod 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}