Skip to main content

proton_sdk/
cache.rs

1//! Generic persistent-cache primitives.
2//!
3//! A string key/value store with tags, mirroring the C# `Proton.Sdk.Caching`
4//! layer: the [`CacheRepository`] trait (C# `ICacheRepository`), an in-memory
5//! implementation ([`InMemoryCacheRepository`], C# `InMemoryCacheRepository`),
6//! and an at-rest-encryption wrapper ([`EncryptedCacheRepository`], C#
7//! `EncryptedCacheRepository`).
8//!
9//! Higher layers (the Drive entity/secret caches) serialize typed values to
10//! JSON strings and store them here; a consumer can supply an on-disk
11//! implementation of [`CacheRepository`] (e.g. SQLite) without changing those
12//! layers.
13
14use std::collections::{HashMap, HashSet};
15use std::sync::{Arc, Mutex};
16
17use aes_gcm::aead::{Aead, KeyInit, Payload};
18use aes_gcm::{Aes256Gcm, Nonce};
19use async_trait::async_trait;
20use base64::Engine;
21use hkdf::Hkdf;
22use sha2::Sha256;
23
24use crate::error::{ProtonError, Result};
25
26/// A string key/value cache with secondary tag indexing.
27///
28/// Mirrors C# `ICacheRepository`. Implementations must be cheap to share across
29/// tasks (hence [`Send`] + [`Sync`]); the SDK holds them as
30/// `Arc<dyn CacheRepository>`.
31#[async_trait]
32pub trait CacheRepository: Send + Sync {
33    /// Store `value` under `key`, replacing any existing entry and its tags.
34    async fn set(&self, key: &str, value: &str, tags: &[String]) -> Result<()>;
35
36    /// Fetch the value stored under `key`, or `None` if absent.
37    async fn get(&self, key: &str) -> Result<Option<String>>;
38
39    /// Remove the entry stored under `key` (no-op if absent).
40    async fn remove(&self, key: &str) -> Result<()>;
41
42    /// Remove every entry carrying `tag`.
43    async fn remove_by_tag(&self, tag: &str) -> Result<()>;
44
45    /// Remove every entry.
46    async fn clear(&self) -> Result<()>;
47
48    /// Return every `(key, value)` whose entry carries **all** of `tags`
49    /// (set intersection, matching C# `GetByTags`). An empty `tags` slice
50    /// returns nothing.
51    async fn get_by_tags(&self, tags: &[String]) -> Result<Vec<(String, String)>>;
52}
53
54/// Convenience: store a value with no tags.
55pub async fn set_untagged(repo: &dyn CacheRepository, key: &str, value: &str) -> Result<()> {
56    repo.set(key, value, &[]).await
57}
58
59/// Thread-safe in-memory [`CacheRepository`]. Mirrors C#
60/// `InMemoryCacheRepository`.
61#[derive(Default)]
62pub struct InMemoryCacheRepository {
63    state: Mutex<InMemoryState>,
64}
65
66#[derive(Default)]
67struct InMemoryState {
68    entries: HashMap<String, String>,
69    key_to_tags: HashMap<String, HashSet<String>>,
70    tag_to_keys: HashMap<String, HashSet<String>>,
71}
72
73impl InMemoryCacheRepository {
74    /// Create an empty in-memory cache.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Wrap a new in-memory cache in an [`Arc`] behind the trait object.
80    pub fn shared() -> Arc<dyn CacheRepository> {
81        Arc::new(Self::new())
82    }
83
84    fn clear_tags_for_key(state: &mut InMemoryState, key: &str) {
85        if let Some(tags) = state.key_to_tags.remove(key) {
86            for tag in tags {
87                if let Some(keys) = state.tag_to_keys.get_mut(&tag) {
88                    keys.remove(key);
89                    if keys.is_empty() {
90                        state.tag_to_keys.remove(&tag);
91                    }
92                }
93            }
94        }
95    }
96}
97
98#[async_trait]
99impl CacheRepository for InMemoryCacheRepository {
100    async fn set(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
101        let mut state = self.state.lock().unwrap();
102        Self::clear_tags_for_key(&mut state, key);
103        state.entries.insert(key.to_owned(), value.to_owned());
104        let tag_set: HashSet<String> = tags.iter().cloned().collect();
105        for tag in &tag_set {
106            state
107                .tag_to_keys
108                .entry(tag.clone())
109                .or_default()
110                .insert(key.to_owned());
111        }
112        state.key_to_tags.insert(key.to_owned(), tag_set);
113        Ok(())
114    }
115
116    async fn get(&self, key: &str) -> Result<Option<String>> {
117        let state = self.state.lock().unwrap();
118        Ok(state.entries.get(key).cloned())
119    }
120
121    async fn remove(&self, key: &str) -> Result<()> {
122        let mut state = self.state.lock().unwrap();
123        state.entries.remove(key);
124        Self::clear_tags_for_key(&mut state, key);
125        Ok(())
126    }
127
128    async fn remove_by_tag(&self, tag: &str) -> Result<()> {
129        let mut state = self.state.lock().unwrap();
130        let keys: Vec<String> = state
131            .tag_to_keys
132            .get(tag)
133            .map(|keys| keys.iter().cloned().collect())
134            .unwrap_or_default();
135        for key in keys {
136            state.entries.remove(&key);
137            Self::clear_tags_for_key(&mut state, &key);
138        }
139        Ok(())
140    }
141
142    async fn clear(&self) -> Result<()> {
143        let mut state = self.state.lock().unwrap();
144        state.entries.clear();
145        state.key_to_tags.clear();
146        state.tag_to_keys.clear();
147        Ok(())
148    }
149
150    async fn get_by_tags(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
151        if tags.is_empty() {
152            return Ok(Vec::new());
153        }
154        let state = self.state.lock().unwrap();
155        let mut candidates: Option<HashSet<String>> = None;
156        for tag in tags {
157            match state.tag_to_keys.get(tag) {
158                Some(keys) => {
159                    candidates = Some(match candidates {
160                        Some(existing) => existing.intersection(keys).cloned().collect(),
161                        None => keys.clone(),
162                    });
163                }
164                None => return Ok(Vec::new()),
165            }
166            if candidates.as_ref().is_some_and(|c| c.is_empty()) {
167                return Ok(Vec::new());
168            }
169        }
170        let candidates = candidates.unwrap_or_default();
171        Ok(candidates
172            .into_iter()
173            .filter_map(|key| state.entries.get(&key).map(|v| (key.clone(), v.clone())))
174            .collect())
175    }
176}
177
178/// At-rest-encryption wrapper around any [`CacheRepository`]. Mirrors C#
179/// `EncryptedCacheRepository`.
180///
181/// Each value is encrypted independently: a random 16-byte salt feeds
182/// HKDF-SHA256 (with the entry key mixed into the `info` parameter) to derive a
183/// fresh AES-256-GCM key and 96-bit nonce; the stored payload is
184/// `base64([salt(16)][ciphertext][tag(16)])`. Keys and tags are stored in the
185/// clear (they drive lookup); only values are protected.
186///
187/// A GCM authentication failure on read is treated as tampering or a changed
188/// encryption key: the inner cache is cleared and the read reported as a miss
189/// (matching the C# behavior).
190pub struct EncryptedCacheRepository {
191    inner: Arc<dyn CacheRepository>,
192    encryption_key: Vec<u8>,
193}
194
195const SALT_LEN: usize = 16;
196const KEY_LEN: usize = 32;
197const NONCE_LEN: usize = 12;
198const TAG_LEN: usize = 16;
199const ENCRYPTION_CONTEXT: &[u8] = b"Drive.EncryptedCacheRepository";
200
201impl EncryptedCacheRepository {
202    /// Wrap `inner`, encrypting values under `encryption_key`.
203    pub fn new(inner: Arc<dyn CacheRepository>, encryption_key: impl Into<Vec<u8>>) -> Self {
204        Self {
205            inner,
206            encryption_key: encryption_key.into(),
207        }
208    }
209
210    /// Wrap `inner` and box the result behind the trait object.
211    pub fn shared(
212        inner: Arc<dyn CacheRepository>,
213        encryption_key: impl Into<Vec<u8>>,
214    ) -> Arc<dyn CacheRepository> {
215        Arc::new(Self::new(inner, encryption_key))
216    }
217
218    /// Derive the per-entry AES key + nonce from the salt and entry key.
219    fn derive(&self, salt: &[u8], entry_key: &str) -> Result<([u8; KEY_LEN], [u8; NONCE_LEN])> {
220        let mut info = ENCRYPTION_CONTEXT.to_vec();
221        info.extend_from_slice(entry_key.as_bytes());
222        let hk = Hkdf::<Sha256>::new(Some(salt), &self.encryption_key);
223        let mut okm = [0u8; KEY_LEN + NONCE_LEN];
224        hk.expand(&info, &mut okm)
225            .map_err(|e| ProtonError::invalid_operation(format!("cache HKDF expand: {e}")))?;
226        let mut key = [0u8; KEY_LEN];
227        let mut nonce = [0u8; NONCE_LEN];
228        key.copy_from_slice(&okm[..KEY_LEN]);
229        nonce.copy_from_slice(&okm[KEY_LEN..]);
230        Ok((key, nonce))
231    }
232
233    fn encrypt(&self, entry_key: &str, plaintext: &str) -> Result<String> {
234        let mut salt = [0u8; SALT_LEN];
235        getrandom::fill(&mut salt)
236            .map_err(|e| ProtonError::invalid_operation(format!("cache salt: {e}")))?;
237        let (key, nonce) = self.derive(&salt, entry_key)?;
238        let cipher = Aes256Gcm::new_from_slice(&key)
239            .map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?;
240        // aes-gcm appends the 16-byte tag to the ciphertext, so this yields
241        // `ciphertext || tag` — matching the C# `[salt][ciphertext][tag]` layout.
242        let sealed = cipher
243            .encrypt(
244                &Nonce::from(nonce),
245                Payload {
246                    msg: plaintext.as_bytes(),
247                    aad: &[],
248                },
249            )
250            .map_err(|_| ProtonError::invalid_operation("cache encrypt failed"))?;
251        let mut out = Vec::with_capacity(SALT_LEN + sealed.len());
252        out.extend_from_slice(&salt);
253        out.extend_from_slice(&sealed);
254        Ok(base64::engine::general_purpose::STANDARD.encode(out))
255    }
256
257    /// Decrypt a stored value. `Ok(None)` signals a GCM auth failure (tampered
258    /// or stale entry); the caller clears the cache and treats it as a miss.
259    fn decrypt(&self, entry_key: &str, encoded: &str) -> Result<Option<String>> {
260        let combined = base64::engine::general_purpose::STANDARD
261            .decode(encoded)
262            .map_err(|e| ProtonError::invalid_operation(format!("cache base64: {e}")))?;
263        if combined.len() < SALT_LEN + TAG_LEN {
264            return Err(ProtonError::invalid_operation("cache value too short"));
265        }
266        let (salt, sealed) = combined.split_at(SALT_LEN);
267        let (key, nonce) = self.derive(salt, entry_key)?;
268        let cipher = Aes256Gcm::new_from_slice(&key)
269            .map_err(|e| ProtonError::invalid_operation(format!("cache cipher: {e}")))?;
270        match cipher.decrypt(
271            &Nonce::from(nonce),
272            Payload {
273                msg: sealed,
274                aad: &[],
275            },
276        ) {
277            Ok(plaintext) => {
278                let text = String::from_utf8(plaintext)
279                    .map_err(|e| ProtonError::invalid_operation(format!("cache utf8: {e}")))?;
280                Ok(Some(text))
281            }
282            // Authentication failure: tampering or a changed key. Signal a miss.
283            Err(_) => Ok(None),
284        }
285    }
286}
287
288#[async_trait]
289impl CacheRepository for EncryptedCacheRepository {
290    async fn set(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
291        let encrypted = self.encrypt(key, value)?;
292        self.inner.set(key, &encrypted, tags).await
293    }
294
295    async fn get(&self, key: &str) -> Result<Option<String>> {
296        let Some(encrypted) = self.inner.get(key).await? else {
297            return Ok(None);
298        };
299        match self.decrypt(key, &encrypted)? {
300            Some(value) => Ok(Some(value)),
301            None => {
302                self.inner.clear().await?;
303                Ok(None)
304            }
305        }
306    }
307
308    async fn remove(&self, key: &str) -> Result<()> {
309        self.inner.remove(key).await
310    }
311
312    async fn remove_by_tag(&self, tag: &str) -> Result<()> {
313        self.inner.remove_by_tag(tag).await
314    }
315
316    async fn clear(&self) -> Result<()> {
317        self.inner.clear().await
318    }
319
320    async fn get_by_tags(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
321        let entries = self.inner.get_by_tags(tags).await?;
322        let mut out = Vec::with_capacity(entries.len());
323        for (key, encrypted) in entries {
324            match self.decrypt(&key, &encrypted)? {
325                Some(value) => out.push((key, value)),
326                None => {
327                    self.inner.clear().await?;
328                    return Ok(Vec::new());
329                }
330            }
331        }
332        Ok(out)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn tags(values: &[&str]) -> Vec<String> {
341        values.iter().map(|s| s.to_string()).collect()
342    }
343
344    #[tokio::test]
345    async fn in_memory_round_trips_and_overwrites() {
346        let cache = InMemoryCacheRepository::new();
347        cache.set("k", "v1", &[]).await.unwrap();
348        assert_eq!(cache.get("k").await.unwrap().as_deref(), Some("v1"));
349        cache.set("k", "v2", &[]).await.unwrap();
350        assert_eq!(cache.get("k").await.unwrap().as_deref(), Some("v2"));
351        cache.remove("k").await.unwrap();
352        assert_eq!(cache.get("k").await.unwrap(), None);
353    }
354
355    #[tokio::test]
356    async fn in_memory_get_by_tags_intersects() {
357        let cache = InMemoryCacheRepository::new();
358        cache.set("a", "1", &tags(&["x", "y"])).await.unwrap();
359        cache.set("b", "2", &tags(&["x"])).await.unwrap();
360        cache.set("c", "3", &tags(&["y"])).await.unwrap();
361
362        let mut both = cache.get_by_tags(&tags(&["x", "y"])).await.unwrap();
363        both.sort();
364        assert_eq!(both, vec![("a".to_string(), "1".to_string())]);
365
366        let mut just_x = cache.get_by_tags(&tags(&["x"])).await.unwrap();
367        just_x.sort();
368        assert_eq!(
369            just_x,
370            vec![
371                ("a".to_string(), "1".to_string()),
372                ("b".to_string(), "2".to_string())
373            ]
374        );
375
376        assert!(cache.get_by_tags(&[]).await.unwrap().is_empty());
377    }
378
379    #[tokio::test]
380    async fn in_memory_remove_by_tag_drops_only_tagged() {
381        let cache = InMemoryCacheRepository::new();
382        cache.set("a", "1", &tags(&["x"])).await.unwrap();
383        cache.set("b", "2", &tags(&["y"])).await.unwrap();
384        cache.remove_by_tag("x").await.unwrap();
385        assert_eq!(cache.get("a").await.unwrap(), None);
386        assert_eq!(cache.get("b").await.unwrap().as_deref(), Some("2"));
387        // The tag index is cleaned up too: re-querying yields nothing.
388        assert!(cache.get_by_tags(&tags(&["x"])).await.unwrap().is_empty());
389    }
390
391    #[tokio::test]
392    async fn encrypted_round_trips_and_hides_plaintext() {
393        let inner = InMemoryCacheRepository::shared();
394        let cache = EncryptedCacheRepository::new(inner.clone(), b"hunter2-master-key".to_vec());
395        cache
396            .set("share:1", "secret-value", &tags(&["t"]))
397            .await
398            .unwrap();
399
400        // Stored ciphertext is not the plaintext.
401        let stored = inner.get("share:1").await.unwrap().unwrap();
402        assert_ne!(stored, "secret-value");
403
404        // Round-trips through the wrapper.
405        assert_eq!(
406            cache.get("share:1").await.unwrap().as_deref(),
407            Some("secret-value")
408        );
409        // Tags pass through to the inner store.
410        let by_tag = cache.get_by_tags(&tags(&["t"])).await.unwrap();
411        assert_eq!(
412            by_tag,
413            vec![("share:1".to_string(), "secret-value".to_string())]
414        );
415    }
416
417    #[tokio::test]
418    async fn encrypted_wrong_key_is_a_miss_and_clears() {
419        let inner = InMemoryCacheRepository::shared();
420        EncryptedCacheRepository::new(inner.clone(), b"key-one".to_vec())
421            .set("k", "v", &[])
422            .await
423            .unwrap();
424
425        // A different key fails the GCM tag check → treated as a miss, cache cleared.
426        let other = EncryptedCacheRepository::new(inner.clone(), b"key-two".to_vec());
427        assert_eq!(other.get("k").await.unwrap(), None);
428        assert_eq!(inner.get("k").await.unwrap(), None);
429    }
430
431    #[tokio::test]
432    async fn encrypted_salt_is_random_per_write() {
433        let inner = InMemoryCacheRepository::shared();
434        let cache = EncryptedCacheRepository::new(inner.clone(), b"k".to_vec());
435        cache.set("k", "same", &[]).await.unwrap();
436        let first = inner.get("k").await.unwrap().unwrap();
437        cache.set("k", "same", &[]).await.unwrap();
438        let second = inner.get("k").await.unwrap().unwrap();
439        // Random salt per write ⇒ identical plaintext yields different ciphertext.
440        assert_ne!(first, second);
441    }
442}