1use crate::credential::OAuthCredentialStorage;
2use crate::error::OAuthError;
3use age::scrypt::{Identity, Recipient};
4use age::secrecy::SecretString;
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::env::var;
10use std::io::ErrorKind;
11#[cfg(unix)]
12use std::os::unix::fs::MetadataExt;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, PoisonError};
15use std::time::SystemTime;
16use utils::fs::{with_file_lock, write_atomic};
17use utils::path::aether_home;
18
19const DEFAULT_PASSWORD_ENV: &str = "AETHER_CREDENTIALS_PASSWORD";
20
21pub struct EncryptedFileCredentialStore {
22 file: EncryptedFile,
23}
24
25impl EncryptedFileCredentialStore {
26 pub fn new(path: PathBuf, passphrase: String) -> Self {
27 Self { file: EncryptedFile { path, passphrase, scrypt_work_factor: None, cache: Arc::default() } }
28 }
29
30 pub fn with_scrypt_work_factor(mut self, log_n: u8) -> Self {
31 self.file.scrypt_work_factor = Some(log_n);
32 self
33 }
34
35 pub fn from_settings(path: Option<PathBuf>, password_env: Option<&str>) -> Result<Self, OAuthError> {
36 let path = path.map_or_else(default_path, Ok)?;
37 let env_var = password_env.unwrap_or(DEFAULT_PASSWORD_ENV);
38 let passphrase = var(env_var).ok().filter(|pass| !pass.is_empty()).ok_or_else(|| {
39 OAuthError::CredentialStore(format!(
40 "Encrypted file credential store requires a passphrase. \
41 Set the {env_var} environment variable or configure a custom `passwordEnv` in settings."
42 ))
43 })?;
44
45 Ok(Self::new(path, passphrase))
46 }
47
48 async fn with_file<T: Send + 'static>(
50 &self,
51 work: impl FnOnce(&EncryptedFile) -> Result<T, OAuthError> + Send + 'static,
52 ) -> Result<T, OAuthError> {
53 let file = self.file.clone();
54 tokio::task::spawn_blocking(move || work(&file))
55 .await
56 .map_err(|error| OAuthError::CredentialStore(format!("Credential store task failed: {error}")))?
57 }
58}
59
60#[async_trait]
61impl OAuthCredentialStorage for EncryptedFileCredentialStore {
62 async fn load(&self, key: &str) -> Result<Option<serde_json::Value>, OAuthError> {
63 let key = key.to_string();
64 self.with_file(move |file| Ok(file.read()?.credentials.get(&key).cloned())).await
65 }
66
67 async fn save(&self, key: &str, value: serde_json::Value) -> Result<(), OAuthError> {
68 let key = key.to_string();
69 self.with_file(move |file| {
70 file.update(|store| {
71 store.credentials.insert(key, value);
72 })
73 })
74 .await
75 }
76
77 async fn delete(&self, key: &str) -> Result<(), OAuthError> {
78 let key = key.to_string();
79 self.with_file(move |file| {
80 file.update(|store| {
81 store.credentials.remove(&key);
82 })
83 })
84 .await
85 }
86
87 fn contains(&self, key: &str) -> bool {
88 self.file.read().is_ok_and(|store| store.credentials.contains_key(key))
89 }
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93struct CredentialStore {
94 credentials: HashMap<String, Value>,
95}
96
97#[derive(Clone)]
98struct EncryptedFile {
99 path: PathBuf,
100 passphrase: String,
101 scrypt_work_factor: Option<u8>,
102 cache: Arc<Mutex<Option<Decrypted>>>,
103}
104
105struct Decrypted {
106 version: Option<FileVersion>,
107 store: CredentialStore,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111struct FileVersion {
112 modified: SystemTime,
113 len: u64,
114 #[cfg(unix)]
115 inode: u64,
116}
117
118impl EncryptedFile {
119 fn read(&self) -> Result<CredentialStore, OAuthError> {
120 let version = file_version(&self.path)?;
121 let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
122 if let Some(decrypted) = cache.as_ref().filter(|decrypted| decrypted.version == version) {
123 return Ok(decrypted.store.clone());
124 }
125 let store = self.decrypt_file(version)?;
126 *cache = Some(Decrypted { version, store: store.clone() });
127 Ok(store)
128 }
129
130 fn update(&self, mutate: impl FnOnce(&mut CredentialStore)) -> Result<(), OAuthError> {
131 with_file_lock(&self.path, || {
132 let mut store = self.read()?;
133 mutate(&mut store);
134
135 let plaintext = serde_json::to_vec(&store)
136 .map_err(|e| OAuthError::CredentialStore(format!("Failed to serialize credentials: {e}")))?;
137 write_atomic(&self.path, &encrypt(&plaintext, &self.passphrase, self.scrypt_work_factor)?)?;
138
139 let version = file_version(&self.path)?;
140 *self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(Decrypted { version, store });
141 Ok(())
142 })
143 }
144
145 fn decrypt_file(&self, version: Option<FileVersion>) -> Result<CredentialStore, OAuthError> {
146 if version.is_none_or(|version| version.len == 0) {
147 return Ok(CredentialStore::default());
148 }
149 let plaintext = decrypt(&std::fs::read(&self.path)?, &self.passphrase)?;
150 serde_json::from_slice(&plaintext)
151 .map_err(|e| OAuthError::CredentialStore(format!("Invalid credential data: {e}")))
152 }
153}
154
155fn file_version(path: &Path) -> Result<Option<FileVersion>, OAuthError> {
156 let metadata = match std::fs::metadata(path) {
157 Ok(metadata) => metadata,
158 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
159 Err(error) => return Err(error.into()),
160 };
161 Ok(Some(FileVersion {
162 modified: metadata.modified()?,
163 len: metadata.len(),
164 #[cfg(unix)]
165 inode: metadata.ino(),
166 }))
167}
168
169fn encrypt(plaintext: &[u8], passphrase: &str, work_factor: Option<u8>) -> Result<Vec<u8>, OAuthError> {
170 let mut recipient = Recipient::new(SecretString::from(passphrase));
171 if let Some(log_n) = work_factor {
172 recipient.set_work_factor(log_n);
173 }
174 age::encrypt(&recipient, plaintext).map_err(|e| OAuthError::CredentialStore(format!("Encryption failed: {e}")))
175}
176
177fn decrypt(ciphertext: &[u8], passphrase: &str) -> Result<Vec<u8>, OAuthError> {
178 age::decrypt(&Identity::new(SecretString::from(passphrase)), ciphertext).map_err(|e| {
179 OAuthError::CredentialStore(format!("Decryption failed — wrong passphrase or corrupted file: {e}"))
180 })
181}
182
183fn default_path() -> Result<PathBuf, OAuthError> {
184 aether_home().map(|home| home.join("credentials.enc")).ok_or_else(|| {
185 OAuthError::CredentialStore(
186 "Could not determine the Aether home directory for the encrypted credential file".to_string(),
187 )
188 })
189}
190
191#[cfg(test)]
192mod tests {
193 use std::fs::write;
194
195 use super::*;
196 use crate::OAuthCredential;
197 use crate::credential::assert_storage_contract;
198 use tempfile::TempDir;
199
200 const TEST_WORK_FACTOR: u8 = 10;
202
203 #[tokio::test]
204 async fn satisfies_the_storage_contract() {
205 let (_dir, store) = temp_store("pass");
206 assert_storage_contract(&store).await;
207 }
208
209 #[tokio::test]
210 async fn credentials_persist_across_instances() {
211 let (dir, store) = temp_store("pass");
212 store.save_credential("server-1", test_credential()).await.unwrap();
213
214 let reopened = store_at(&dir, "pass");
215
216 assert_eq!(reopened.load_credential("server-1").await.unwrap(), Some(test_credential()));
217 }
218
219 #[tokio::test]
220 async fn a_save_through_one_instance_is_visible_to_another_that_already_read() {
221 let (dir, first) = temp_store("pass");
222 let second = store_at(&dir, "pass");
223 assert_eq!(second.load_credential("server-1").await.unwrap(), None);
224
225 first.save_credential("server-1", test_credential()).await.unwrap();
226
227 assert_eq!(second.load_credential("server-1").await.unwrap(), Some(test_credential()));
228 assert!(second.contains("server-1"));
229 }
230
231 #[tokio::test]
232 async fn concurrent_saves_from_two_instances_keep_both_credentials() {
233 let (dir, first) = temp_store("pass");
234 let second = store_at(&dir, "pass");
235
236 let (saved_first, saved_second) = tokio::join!(
237 first.save_credential("server-1", test_credential()),
238 second.save_credential("server-2", test_credential()),
239 );
240 saved_first.unwrap();
241 saved_second.unwrap();
242
243 let reopened = store_at(&dir, "pass");
244 assert!(reopened.contains("server-1") && reopened.contains("server-2"));
245 }
246
247 #[tokio::test]
248 async fn wrong_passphrase_fails_to_load() {
249 let (dir, store) = temp_store("correct-pass");
250 store.save_credential("server-1", test_credential()).await.unwrap();
251
252 let wrong_store = store_at(&dir, "wrong-pass");
253
254 let err = wrong_store.load_credential("server-1").await.unwrap_err();
255 let msg = err.to_string();
256 assert!(msg.contains("Decryption failed"), "Expected decryption error, got: {msg}");
257 }
258
259 #[tokio::test]
260 async fn loads_existing_encrypted_file_format() {
261 let (dir, store) = temp_store("pass");
262 let plaintext = serde_json::to_vec(&serde_json::json!({
263 "credentials": {
264 "codex": {
265 "client_id": "client_1",
266 "access_token": "tok_abc",
267 "refresh_token": "ref_xyz",
268 "expires_at": 9_999_999_999_999_u64
269 }
270 }
271 }))
272 .unwrap();
273 let ciphertext = encrypt(&plaintext, "pass", Some(TEST_WORK_FACTOR)).unwrap();
274
275 write(dir.path().join("creds.enc"), ciphertext).unwrap();
276 let credential = store.load_credential("codex").await.unwrap().expect("existing credential");
277 assert_eq!(credential, test_credential());
278 }
279
280 fn test_credential() -> OAuthCredential {
281 OAuthCredential::new("client_1", "tok_abc").refresh_token("ref_xyz").expires_at(9_999_999_999_999)
282 }
283
284 fn temp_store(passphrase: &str) -> (TempDir, EncryptedFileCredentialStore) {
285 let dir = TempDir::new().unwrap();
286 let store = store_at(&dir, passphrase);
287 (dir, store)
288 }
289
290 fn store_at(dir: &TempDir, passphrase: &str) -> EncryptedFileCredentialStore {
291 EncryptedFileCredentialStore::new(dir.path().join("creds.enc"), passphrase.to_string())
292 .with_scrypt_work_factor(TEST_WORK_FACTOR)
293 }
294}