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_sdk::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 in `~/.auths/pubkeys/<alias>.pub` as `<curve>:<hex>`,
32/// e.g. `ed25519:abcdef...` or `p256:02abcdef...`.
33///
34/// Args:
35/// * `alias` - The key alias (e.g., "default").
36/// * `pubkey` - Raw public key bytes.
37/// * `curve` - The curve type of the key.
38pub fn cache_pubkey(alias: &str, pubkey: &[u8], curve: auths_crypto::CurveType) -> Result<()> {
39    let cache_dir = get_pubkey_cache_dir()?;
40    create_restricted_dir(&cache_dir)
41        .with_context(|| format!("Failed to create pubkey cache directory: {:?}", cache_dir))?;
42
43    let cache_path = get_cache_path(alias)?;
44    let content = format!("{}:{}", curve, hex::encode(pubkey));
45
46    write_sensitive_file(&cache_path, &content)
47        .with_context(|| format!("Failed to write pubkey cache file: {:?}", cache_path))?;
48
49    Ok(())
50}
51
52/// Get a cached public key for the given alias.
53///
54/// Returns the raw key bytes and curve type. Handles legacy cache files
55/// (plain hex without curve prefix) by assuming Ed25519.
56///
57/// Args:
58/// * `alias` - The key alias (e.g., "default").
59pub fn get_cached_pubkey(alias: &str) -> Result<Option<(Vec<u8>, auths_crypto::CurveType)>> {
60    let cache_path = get_cache_path(alias)?;
61
62    if !cache_path.exists() {
63        return Ok(None);
64    }
65
66    let content = fs::read_to_string(&cache_path)
67        .with_context(|| format!("Failed to read pubkey cache file: {:?}", cache_path))?;
68
69    let trimmed = content.trim();
70
71    let (curve, hex_str) = if let Some((curve_tag, hex_part)) = trimmed.split_once(':') {
72        let curve = match curve_tag {
73            "ed25519" => auths_crypto::CurveType::Ed25519,
74            "p256" => auths_crypto::CurveType::P256,
75            other => {
76                return Err(anyhow!(
77                    "Unknown curve in cache file {:?}: {other}",
78                    cache_path
79                ));
80            }
81        };
82        (curve, hex_part)
83    } else {
84        // Legacy format: plain hex, assume Ed25519
85        (auths_crypto::CurveType::Ed25519, trimmed)
86    };
87
88    let pubkey = hex::decode(hex_str)
89        .with_context(|| format!("Invalid hex in pubkey cache file: {:?}", cache_path))?;
90
91    Ok(Some((pubkey, curve)))
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_get_cache_path_returns_pub_extension() {
163        let path = get_cache_path("mykey").unwrap();
164        assert!(path.to_string_lossy().ends_with("mykey.pub"));
165    }
166}