Skip to main content

sparrow/auth/
store.rs

1use crate::auth::{AuthStore, Credential};
2use chacha20poly1305::aead::{Aead, KeyInit};
3use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
4use secrecy::SecretString;
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9// ─── On-disk credential file ──────────────────────────────────────────────────
10//
11// ChainedAuthStore prefers the OS keychain when available, then falls back to
12// this ChaCha20-Poly1305 encrypted file. The data key is stored separately with
13// restrictive permissions so the auth payload is never persisted as clear JSON.
14
15const AUTH_MAGIC: &[u8] = b"SPARROW-AUTH-V1\n";
16const NONCE_LEN: usize = 12;
17const KEY_LEN: usize = 32;
18
19pub struct EncryptedFileStore {
20    path: PathBuf,
21    key_path: PathBuf,
22    cache: RwLock<HashMap<String, Credential>>,
23}
24
25impl EncryptedFileStore {
26    pub fn new(path: PathBuf) -> Self {
27        let key_path = path.with_extension("key");
28        let store = Self {
29            path,
30            key_path,
31            cache: RwLock::new(HashMap::new()),
32        };
33        store.load_from_file();
34        store
35    }
36
37    fn load_from_file(&self) {
38        if !self.path.exists() {
39            return;
40        }
41        let Ok(data) = std::fs::read(&self.path) else {
42            return;
43        };
44        let parsed: Option<HashMap<String, String>> = self
45            .decrypt_payload(&data)
46            .ok()
47            .and_then(|plain| serde_json::from_slice::<HashMap<String, String>>(&plain).ok())
48            // Migration path for the previous honest-but-plain JSON fallback.
49            .or_else(|| serde_json::from_slice::<HashMap<String, String>>(&data).ok())
50            // Migration path for the old hardcoded-XOR obfuscation.
51            .or_else(|| legacy_xor_decode(&data));
52        if let Some(map) = parsed {
53            let mut cache = self.cache.write().unwrap();
54            for (provider, api_key) in map {
55                cache.insert(
56                    provider,
57                    Credential::ApiKey(SecretString::new(api_key.into_boxed_str())),
58                );
59            }
60        }
61    }
62
63    fn save_to_file(&self) -> anyhow::Result<()> {
64        let cache = self.cache.read().unwrap();
65        let mut map = HashMap::new();
66        for (provider, cred) in cache.iter() {
67            if let Some(key) = cred.expose_key() {
68                map.insert(provider.clone(), key.to_string());
69            }
70        }
71        let json = serde_json::to_vec(&map)?;
72        let encrypted = self.encrypt_payload(&json)?;
73        if let Some(parent) = self.path.parent() {
74            std::fs::create_dir_all(parent)?;
75        }
76        // Atomic-ish write: create tmp, set perms, rename.
77        let tmp = self.path.with_extension("tmp");
78        std::fs::write(&tmp, &encrypted)?;
79        restrict_perms(&tmp)?;
80        std::fs::rename(&tmp, &self.path)?;
81        Ok(())
82    }
83
84    fn encrypt_payload(&self, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
85        let key = self.load_or_create_key()?;
86        let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
87        let mut nonce = [0_u8; NONCE_LEN];
88        rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
89        let ciphertext = cipher
90            .encrypt(Nonce::from_slice(&nonce), plaintext)
91            .map_err(|err| anyhow::anyhow!("auth file encryption failed: {}", err))?;
92        let mut out = Vec::with_capacity(AUTH_MAGIC.len() + NONCE_LEN + ciphertext.len());
93        out.extend_from_slice(AUTH_MAGIC);
94        out.extend_from_slice(&nonce);
95        out.extend_from_slice(&ciphertext);
96        Ok(out)
97    }
98
99    fn decrypt_payload(&self, data: &[u8]) -> anyhow::Result<Vec<u8>> {
100        if !data.starts_with(AUTH_MAGIC) {
101            anyhow::bail!("auth file is not encrypted envelope v1");
102        }
103        let body = &data[AUTH_MAGIC.len()..];
104        if body.len() <= NONCE_LEN {
105            anyhow::bail!("auth file encrypted envelope is truncated");
106        }
107        let (nonce, ciphertext) = body.split_at(NONCE_LEN);
108        let key = self.load_or_create_key()?;
109        let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
110        cipher
111            .decrypt(Nonce::from_slice(nonce), ciphertext)
112            .map_err(|err| anyhow::anyhow!("auth file decryption failed: {}", err))
113    }
114
115    fn load_or_create_key(&self) -> anyhow::Result<[u8; KEY_LEN]> {
116        if self.key_path.exists() {
117            let bytes = std::fs::read(&self.key_path)?;
118            if bytes.len() != KEY_LEN {
119                anyhow::bail!(
120                    "auth file key has invalid length: {} bytes at {}",
121                    bytes.len(),
122                    self.key_path.display()
123                );
124            }
125            let mut key = [0_u8; KEY_LEN];
126            key.copy_from_slice(&bytes);
127            return Ok(key);
128        }
129
130        let mut key = [0_u8; KEY_LEN];
131        rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key);
132        if let Some(parent) = self.key_path.parent() {
133            std::fs::create_dir_all(parent)?;
134        }
135        let tmp = self.key_path.with_extension("key.tmp");
136        std::fs::write(&tmp, key)?;
137        restrict_perms(&tmp)?;
138        std::fs::rename(&tmp, &self.key_path)?;
139        Ok(key)
140    }
141}
142
143#[cfg(unix)]
144fn restrict_perms(path: &std::path::Path) -> anyhow::Result<()> {
145    use std::os::unix::fs::PermissionsExt;
146    let perms = std::fs::Permissions::from_mode(0o600);
147    std::fs::set_permissions(path, perms)?;
148    Ok(())
149}
150
151#[cfg(not(unix))]
152fn restrict_perms(_path: &std::path::Path) -> anyhow::Result<()> {
153    // Windows: rely on the user's profile ACLs (file lives under %APPDATA%).
154    Ok(())
155}
156
157/// Best-effort migration: decode the old XOR-obfuscated format so users who
158/// already wrote credentials with the previous version are not locked out.
159/// Once loaded, the next `save_to_file()` rewrites in plain JSON.
160fn legacy_xor_decode(data: &[u8]) -> Option<HashMap<String, String>> {
161    if data.len() <= 32 {
162        return None;
163    }
164    let key = &data[..16];
165    let payload: Vec<u8> = data[16..]
166        .iter()
167        .enumerate()
168        .map(|(i, b)| b ^ key[i % 16])
169        .collect();
170    let json = String::from_utf8(payload).ok()?;
171    serde_json::from_str::<HashMap<String, String>>(&json).ok()
172}
173
174impl AuthStore for EncryptedFileStore {
175    fn get(&self, provider: &str) -> Option<Credential> {
176        self.cache.read().unwrap().get(provider).cloned()
177    }
178
179    fn set(&self, provider: &str, c: Credential) -> anyhow::Result<()> {
180        self.cache.write().unwrap().insert(provider.to_string(), c);
181        self.save_to_file()
182    }
183
184    fn list(&self) -> Vec<String> {
185        self.cache.read().unwrap().keys().cloned().collect()
186    }
187
188    fn remove(&self, provider: &str) -> anyhow::Result<()> {
189        self.cache.write().unwrap().remove(provider);
190        self.save_to_file()
191    }
192}
193
194// ─── Chained auth store (keychain → encrypted file → env) ──────────────────────
195
196/// Implements the priority chain from §3.2:
197/// OS keychain → encrypted file → env
198pub struct ChainedAuthStore {
199    keychain: Option<Box<dyn AuthStore>>,
200    encrypted: Option<EncryptedFileStore>,
201    env_store: crate::auth::MemoryAuthStore,
202}
203
204impl ChainedAuthStore {
205    pub fn new(config_dir: PathBuf) -> Self {
206        // Try OS keychain (keyring crate). The optional `keyring` dep auto-creates
207        // a feature of the same name; gate on it directly.
208        let keychain: Option<Box<dyn AuthStore>> = {
209            #[cfg(feature = "keyring")]
210            {
211                Some(Box::new(KeyringAuthStore::new()))
212            }
213            #[cfg(not(feature = "keyring"))]
214            {
215                None
216            }
217        };
218
219        let encrypted = Some(EncryptedFileStore::new(config_dir.join("auth.enc")));
220
221        Self {
222            keychain,
223            encrypted,
224            env_store: crate::auth::MemoryAuthStore::new(),
225        }
226    }
227}
228
229// ─── OS keychain backend ───────────────────────────────────────────────────────
230
231#[cfg(feature = "keyring")]
232pub struct KeyringAuthStore {
233    service: String,
234    // Cache of known provider names — keyring crates have no enumerate API.
235    index: RwLock<std::collections::BTreeSet<String>>,
236}
237
238#[cfg(feature = "keyring")]
239impl KeyringAuthStore {
240    pub fn new() -> Self {
241        Self {
242            service: "sparrow".to_string(),
243            index: RwLock::new(std::collections::BTreeSet::new()),
244        }
245    }
246
247    fn entry(&self, provider: &str) -> keyring::Result<keyring::Entry> {
248        keyring::Entry::new(&self.service, provider)
249    }
250}
251
252#[cfg(feature = "keyring")]
253impl AuthStore for KeyringAuthStore {
254    fn get(&self, provider: &str) -> Option<Credential> {
255        let entry = self.entry(provider).ok()?;
256        let secret = entry.get_password().ok()?;
257        self.index.write().unwrap().insert(provider.to_string());
258        Some(Credential::ApiKey(SecretString::new(
259            secret.into_boxed_str(),
260        )))
261    }
262
263    fn set(&self, provider: &str, c: Credential) -> anyhow::Result<()> {
264        let Some(key) = c.expose_key() else {
265            anyhow::bail!("keyring backend only supports api-key credentials");
266        };
267        let entry = self
268            .entry(provider)
269            .map_err(|e| anyhow::anyhow!("keyring entry: {}", e))?;
270        entry
271            .set_password(&key)
272            .map_err(|e| anyhow::anyhow!("keyring set: {}", e))?;
273        self.index.write().unwrap().insert(provider.to_string());
274        Ok(())
275    }
276
277    fn list(&self) -> Vec<String> {
278        self.index.read().unwrap().iter().cloned().collect()
279    }
280
281    fn remove(&self, provider: &str) -> anyhow::Result<()> {
282        let entry = self
283            .entry(provider)
284            .map_err(|e| anyhow::anyhow!("keyring entry: {}", e))?;
285        // delete_credential() is best-effort: a missing entry is not an error.
286        let _ = entry.delete_credential();
287        self.index.write().unwrap().remove(provider);
288        Ok(())
289    }
290}
291
292impl AuthStore for ChainedAuthStore {
293    fn get(&self, provider: &str) -> Option<Credential> {
294        // Priority: keychain → encrypted file → env
295        if let Some(ref kc) = self.keychain {
296            if let c @ Some(_) = kc.get(provider) {
297                return c;
298            }
299        }
300        if let Some(ref enc) = self.encrypted {
301            if let c @ Some(_) = enc.get(provider) {
302                return c;
303            }
304        }
305        self.env_store.get(provider)
306    }
307
308    fn set(&self, provider: &str, c: Credential) -> anyhow::Result<()> {
309        // Store in encrypted file (keychain if available)
310        if let Some(ref kc) = self.keychain {
311            kc.set(provider, c.clone())?;
312        }
313        if let Some(ref enc) = self.encrypted {
314            enc.set(provider, c)?;
315        }
316        Ok(())
317    }
318
319    fn list(&self) -> Vec<String> {
320        let mut all = Vec::new();
321        if let Some(ref kc) = self.keychain {
322            all.extend(kc.list());
323        }
324        if let Some(ref enc) = self.encrypted {
325            all.extend(enc.list());
326        }
327        all.extend(self.env_store.list());
328        all.sort();
329        all.dedup();
330        all
331    }
332
333    fn remove(&self, provider: &str) -> anyhow::Result<()> {
334        if let Some(ref kc) = self.keychain {
335            kc.remove(provider)?;
336        }
337        if let Some(ref enc) = self.encrypted {
338            enc.remove(provider)?;
339        }
340        Ok(())
341    }
342}