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
9const 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 .or_else(|| serde_json::from_slice::<HashMap<String, String>>(&data).ok())
50 .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 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 Ok(())
155}
156
157fn 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
194pub 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 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#[cfg(feature = "keyring")]
232pub struct KeyringAuthStore {
233 service: String,
234 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 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 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 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}