Skip to main content

aether_auth/
encrypted_file.rs

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    /// Runs blocking file work off the async runtime.
49    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    /// `N = 2^10`, fast enough that the KDF stops dominating the suite.
201    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}