Skip to main content

dscode_extension_host/
secrets.rs

1/**
2 * Secure Secret Storage
3 *
4 * Uses OS-native secure storage (Keychain/Credential Manager/Secret Service)
5 * to store extension secrets securely. The in-memory cache uses a
6 * per-session encryption key with AES-256-GCM to prevent plaintext
7 * secrets from being readable in memory dumps.
8 */
9use aes_gcm::aead::{Aead, KeyInit, OsRng};
10use aes_gcm::{Aes256Gcm, Nonce};
11use keyring::Entry;
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::Mutex;
15use tracing::warn;
16use zeroize::Zeroize;
17
18const SERVICE_NAME: &str = "com.dscode.secrets";
19
20/// How long cached entries remain valid (5 minutes).
21const CACHE_TTL_SECS: u64 = 300;
22
23/// An encrypted entry in the secret cache.
24struct CachedSecret {
25    /// AES-256-GCM ciphertext (nonce prepended).
26    ciphertext: Vec<u8>,
27    /// Time the entry was cached (seconds since epoch).
28    cached_at: u64,
29}
30
31pub struct SecretStorage {
32    /// In-memory cache for performance.
33    /// Values are encrypted with a per-session AES-256-GCM key to prevent
34    /// plaintext exposure in memory dumps.
35    cache: Mutex<HashMap<String, CachedSecret>>,
36    /// Per-session AES-256-GCM encryption key. Zeroized on drop.
37    enc_key: Mutex<Vec<u8>>,
38    /// Monotonic nonce counter for AES-GCM (never reused with same key).
39    nonce_counter: AtomicU64,
40}
41
42impl SecretStorage {
43    pub fn new() -> Self {
44        // Generate a random per-session AES-256 key
45        let key = Aes256Gcm::generate_key(OsRng);
46        Self {
47            cache: Mutex::new(HashMap::new()),
48            enc_key: Mutex::new(key.to_vec()),
49            nonce_counter: AtomicU64::new(0),
50        }
51    }
52
53    /// Encrypt a plaintext string with the per-session key.
54    /// Returns nonce || ciphertext (nonce is 12 bytes for AES-256-GCM).
55    fn encrypt(&self, plaintext: &str) -> Result<Vec<u8>, String> {
56        let cipher = self.cipher()?;
57        // Use counter-based nonce: 4-byte prefix + 8-byte counter to get 12 bytes
58        let counter = self.nonce_counter.fetch_add(1, Ordering::Relaxed);
59        let nonce_bytes = Self::derive_nonce(counter);
60        let nonce = Nonce::from_slice(&nonce_bytes);
61        let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())
62            .map_err(|e| format!("Encryption failed: {}", e))?;
63        // Prepend nonce so we can decrypt later
64        let mut out = Vec::with_capacity(12 + ciphertext.len());
65        out.extend_from_slice(&nonce_bytes);
66        out.extend_from_slice(&ciphertext);
67        Ok(out)
68    }
69
70    /// Decrypt a nonce||ciphertext blob with the per-session key.
71    fn decrypt(&self, blob: &[u8]) -> Result<String, String> {
72        if blob.len() < 13 {
73            return Err("Ciphertext too short".into());
74        }
75        let cipher = self.cipher()?;
76        let (nonce_bytes, ciphertext) = blob.split_at(12);
77        let nonce = Nonce::from_slice(nonce_bytes);
78        let plaintext = cipher.decrypt(nonce, ciphertext)
79            .map_err(|_| String::from("Decryption failed (tampered or wrong key)"))?;
80        String::from_utf8(plaintext)
81            .map_err(|e| format!("Decrypted value is not valid UTF-8: {}", e))
82    }
83
84    /// Build the AES-256-GCM cipher from the current session key.
85    fn cipher(&self) -> Result<Aes256Gcm, String> {
86        let key_guard = self.enc_key.lock().unwrap_or_else(|e| {
87            warn!("Encryption key lock poisoned, recovering");
88            e.into_inner()
89        });
90        if key_guard.len() != 32 {
91            return Err("Invalid encryption key length".into());
92        }
93        let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&key_guard);
94        Ok(Aes256Gcm::new(key))
95    }
96
97    /// Derive a 12-byte nonce from a counter value.
98    fn derive_nonce(counter: u64) -> [u8; 12] {
99        let mut nonce = [0u8; 12];
100        nonce[4..12].copy_from_slice(&counter.to_be_bytes());
101        nonce
102    }
103
104    /// Get current time in seconds since epoch.
105    fn now_secs() -> u64 {
106        std::time::SystemTime::now()
107            .duration_since(std::time::UNIX_EPOCH)
108            .unwrap_or_default()
109            .as_secs()
110    }
111
112    /// Evict expired entries from the cache.
113    fn evict_expired(cache: &mut HashMap<String, CachedSecret>) {
114        let now = Self::now_secs();
115        cache.retain(|_, entry| now.saturating_sub(entry.cached_at) < CACHE_TTL_SECS);
116    }
117
118    /// Get a secret for an extension
119    pub fn get(&self, extension_id: &str, key: &str) -> Result<Option<String>, String> {
120        let full_key = Self::make_key(extension_id, key);
121
122        // Check cache first
123        {
124            let mut cache = self.cache.lock().unwrap_or_else(|e| {
125                warn!("Secrets cache lock poisoned, recovering");
126                e.into_inner()
127            });
128            Self::evict_expired(&mut cache);
129            if let Some(entry) = cache.get(&full_key) {
130                let plaintext = self.decrypt(&entry.ciphertext)?;
131                return Ok(Some(plaintext));
132            }
133        }
134
135        // Try to get from OS keychain
136        match Entry::new(SERVICE_NAME, &full_key) {
137            Ok(entry) => {
138                match entry.get_password() {
139                    Ok(password) => {
140                        // Cache it (encrypted)
141                        let ciphertext = self.encrypt(&password)?;
142                        let mut cache = self.cache.lock().unwrap_or_else(|e| {
143                            warn!("Secrets cache lock poisoned, recovering");
144                            e.into_inner()
145                        });
146                        cache.insert(full_key, CachedSecret {
147                            ciphertext,
148                            cached_at: Self::now_secs(),
149                        });
150                        Ok(Some(password))
151                    }
152                    Err(keyring::Error::NoEntry) => Ok(None),
153                    Err(e) => Err(format!("Failed to retrieve secret: {}", e)),
154                }
155            }
156            Err(e) => Err(format!("Failed to access keychain: {}", e)),
157        }
158    }
159
160    /// Store a secret for an extension
161    pub fn set(&self, extension_id: &str, key: &str, value: &str) -> Result<(), String> {
162        let full_key = Self::make_key(extension_id, key);
163
164        // Store in OS keychain
165        match Entry::new(SERVICE_NAME, &full_key) {
166            Ok(entry) => {
167                entry.set_password(value).map_err(|e| format!("Failed to store secret: {}", e))?;
168
169                // Update cache (encrypted)
170                let ciphertext = self.encrypt(value)?;
171                let mut cache = self.cache.lock().unwrap_or_else(|e| {
172                    warn!("Secrets cache lock poisoned, recovering");
173                    e.into_inner()
174                });
175                cache.insert(full_key, CachedSecret {
176                    ciphertext,
177                    cached_at: Self::now_secs(),
178                });
179
180                Ok(())
181            }
182            Err(e) => Err(format!("Failed to access keychain: {}", e)),
183        }
184    }
185
186    /// Delete a secret for an extension
187    pub fn delete(&self, extension_id: &str, key: &str) -> Result<(), String> {
188        let full_key = Self::make_key(extension_id, key);
189
190        // Delete from OS keychain
191        match Entry::new(SERVICE_NAME, &full_key) {
192            Ok(entry) => {
193                match entry.delete_password() {
194                    Ok(()) | Err(keyring::Error::NoEntry) => {
195                        // Remove from cache
196                        let mut cache = self.cache.lock().unwrap_or_else(|e| {
197                            warn!("Secrets cache lock poisoned, recovering");
198                            e.into_inner()
199                        });
200                        cache.remove(&full_key);
201                        Ok(())
202                    }
203                    Err(e) => Err(format!("Failed to delete secret: {}", e)),
204                }
205            }
206            Err(e) => Err(format!("Failed to access keychain: {}", e)),
207        }
208    }
209
210    /// Delete all secrets for an extension (used when uninstalling)
211    pub fn delete_all_for_extension(&self, extension_id: &str) -> Result<(), String> {
212        // Remove from cache
213        let mut cache = self.cache.lock().unwrap_or_else(|e| {
214            warn!("Secrets cache lock poisoned, recovering");
215            e.into_inner()
216        });
217        cache.retain(|k, _| !k.starts_with(&format!("{}:", extension_id)));
218        drop(cache);
219
220        // Note: We can't enumerate all keys in the keychain efficiently,
221        // so extensions should clean up their own secrets on uninstall.
222        // Alternatively, we could maintain a registry of keys per extension.
223
224        Ok(())
225    }
226
227    /// Clear all cached secrets. Called on session shutdown to
228    /// minimize the window where secrets are in memory.
229    pub fn clear_cache(&self) {
230        let mut cache = self.cache.lock().unwrap_or_else(|e| {
231            warn!("Secrets cache lock poisoned, recovering");
232            e.into_inner()
233        });
234        cache.clear();
235    }
236
237    /// Create a namespaced key
238    fn make_key(extension_id: &str, key: &str) -> String {
239        format!("{}:{}", extension_id, key)
240    }
241}
242
243impl Default for SecretStorage {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl Drop for SecretStorage {
250    fn drop(&mut self) {
251        // Zeroize the encryption key first
252        if let Ok(mut key) = self.enc_key.lock() {
253            key.zeroize();
254        }
255        // Best-effort clear of the cache on drop to reduce
256        // the window where secrets remain in memory.
257        if let Ok(mut cache) = self.cache.lock() {
258            // Zeroize each ciphertext before clearing
259            for entry in cache.values_mut() {
260                entry.ciphertext.zeroize();
261            }
262            cache.clear();
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_secret_storage() {
273        let storage = SecretStorage::new();
274        let ext_id = "test.extension";
275        let key = "test_secret";
276        let value = "secret_value_123";
277
278        // Store secret
279        storage.set(ext_id, key, value).unwrap();
280
281        // Retrieve secret
282        let retrieved = storage.get(ext_id, key).unwrap();
283        assert_eq!(retrieved, Some(value.to_string()));
284
285        // Delete secret
286        storage.delete(ext_id, key).unwrap();
287
288        // Verify deleted
289        let after_delete = storage.get(ext_id, key).unwrap();
290        assert_eq!(after_delete, None);
291    }
292
293    #[test]
294    fn test_clear_cache() {
295        let storage = SecretStorage::new();
296        storage.set("ext", "key", "value").unwrap();
297
298        // Cache should have the entry
299        {
300            let cache = storage.cache.lock().unwrap();
301            assert!(!cache.is_empty());
302        }
303
304        storage.clear_cache();
305
306        // Cache should be empty
307        {
308            let cache = storage.cache.lock().unwrap();
309            assert!(cache.is_empty());
310        }
311
312        // Clean up from keychain
313        storage.delete("ext", "key").ok();
314    }
315
316    #[test]
317    fn test_encryption_roundtrip() {
318        let storage = SecretStorage::new();
319        let plaintext = "my_secret_password";
320        let encrypted = storage.encrypt(plaintext).unwrap();
321        // Ciphertext should differ from plaintext
322        assert_ne!(&encrypted[12..], plaintext.as_bytes());
323        // Decryption should recover the original
324        let decrypted = storage.decrypt(&encrypted).unwrap();
325        assert_eq!(decrypted, plaintext);
326    }
327
328    #[test]
329    fn test_cache_stores_encrypted() {
330        let storage = SecretStorage::new();
331        storage.set("ext2", "k2", "plaintext_value").unwrap();
332
333        // Verify cache contains encrypted data, not plaintext
334        let cache = storage.cache.lock().unwrap();
335        let entry = cache.get("ext2:k2").unwrap();
336        // The cached ciphertext should not contain the plaintext
337        assert!(!String::from_utf8_lossy(&entry.ciphertext).contains("plaintext_value"));
338    }
339
340    #[test]
341    fn test_nonce_uniqueness() {
342        let storage = SecretStorage::new();
343        let ct1 = storage.encrypt("aaa").unwrap();
344        let ct2 = storage.encrypt("aaa").unwrap();
345        // Nonces (first 12 bytes) must differ
346        assert_ne!(&ct1[..12], &ct2[..12]);
347    }
348
349    #[test]
350    fn test_ttl_eviction() {
351        let storage = SecretStorage::new();
352        storage.set("ext3", "k3", "val").unwrap();
353
354        // Manually expire the entry by setting cached_at to epoch
355        {
356            let mut cache = storage.cache.lock().unwrap();
357            if let Some(entry) = cache.get_mut("ext3:k3") {
358                entry.cached_at = 0; // Set to epoch so TTL check evicts it
359            }
360        }
361
362        // Evict expired entries
363        {
364            let mut cache = storage.cache.lock().unwrap();
365            SecretStorage::evict_expired(&mut cache);
366        }
367
368        // After eviction, cache entry should be gone
369        {
370            let cache = storage.cache.lock().unwrap();
371            assert!(cache.get("ext3:k3").is_none());
372        }
373
374        // Clean up
375        storage.delete("ext3", "k3").ok();
376    }
377}