Skip to main content

nodedb_lite/storage/
encrypted.rs

1//! Encryption-at-rest wrapper for `StorageEngine`.
2//!
3//! Wraps any `StorageEngine` with AES-256-GCM encryption. All values are
4//! encrypted before writing and decrypted after reading. Keys (namespace + key)
5//! are NOT encrypted — they are needed for range scans and prefix lookups.
6//!
7//! Key derivation: Argon2id KDF from a user-provided passphrase + random salt.
8//! The salt is stored in plaintext in the Meta namespace (it's not secret —
9//! only the passphrase is). The derived key is zeroized on drop.
10//!
11//! Nonce: deterministic 12-byte nonce derived from namespace + key via HMAC.
12//! This makes encryption deterministic (same key+value = same ciphertext),
13//! which is acceptable for a KV store where the key already uniquely identifies
14//! the entry. The benefit is idempotent writes without nonce tracking.
15
16use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
17use async_trait::async_trait;
18use zeroize::Zeroize;
19
20use crate::error::LiteError;
21use crate::storage::engine::{StorageEngine, WriteOp};
22use nodedb_types::Namespace;
23
24/// Salt size for Argon2id key derivation.
25const SALT_SIZE: usize = 16;
26
27/// Meta key for the stored salt.
28const SALT_KEY: &[u8] = b"encryption:salt";
29
30/// Encrypted storage wrapper.
31///
32/// Encrypts all values with AES-256-GCM. Keys are plaintext (needed for scans).
33/// The encryption key is derived from a passphrase via Argon2id and held in
34/// memory until the `EncryptedStorage` is dropped, at which point it's zeroized.
35pub struct EncryptedStorage<S: StorageEngine> {
36    inner: S,
37    cipher: Aes256Gcm,
38    /// Derived key material — zeroized on drop.
39    _key_material: ZeroizeKey,
40}
41
42/// Wrapper to zeroize the derived key on drop.
43struct ZeroizeKey {
44    bytes: [u8; 32],
45}
46
47impl Drop for ZeroizeKey {
48    fn drop(&mut self) {
49        self.bytes.zeroize();
50    }
51}
52
53impl<S: StorageEngine> EncryptedStorage<S> {
54    /// Create an encrypted storage wrapper.
55    ///
56    /// On first use (no salt stored), generates a random salt and stores it.
57    /// On subsequent opens, reads the existing salt for key derivation.
58    ///
59    /// `m_cost`, `t_cost`, and `p_cost` are the Argon2id parameters (memory in
60    /// KiB, iteration count, and parallelism lanes respectively). Callers should
61    /// obtain these from [`crate::config::LiteConfig`].
62    ///
63    /// # Errors
64    /// Returns `LiteError` if salt generation fails or the storage is inaccessible.
65    pub async fn open(
66        inner: S,
67        passphrase: &str,
68        m_cost: u32,
69        t_cost: u32,
70        p_cost: u32,
71    ) -> Result<Self, LiteError> {
72        // Read or generate salt.
73        let salt = match inner.get(Namespace::Meta, SALT_KEY).await? {
74            Some(existing_salt) => {
75                if existing_salt.len() != SALT_SIZE {
76                    return Err(LiteError::Storage {
77                        detail: format!(
78                            "encryption salt has wrong size: expected {SALT_SIZE}, got {}",
79                            existing_salt.len()
80                        ),
81                    });
82                }
83                let mut salt = [0u8; SALT_SIZE];
84                salt.copy_from_slice(&existing_salt);
85                salt
86            }
87            None => {
88                // First use — generate random salt.
89                let mut salt = [0u8; SALT_SIZE];
90                getrandom::fill(&mut salt).map_err(|e| LiteError::Storage {
91                    detail: format!("getrandom failed for encryption salt: {e}"),
92                })?;
93                inner.put(Namespace::Meta, SALT_KEY, &salt).await?;
94                salt
95            }
96        };
97
98        // Derive key via Argon2id.
99        let mut key_bytes = [0u8; 32];
100        let argon2 = argon2::Argon2::new(
101            argon2::Algorithm::Argon2id,
102            argon2::Version::V0x13,
103            argon2::Params::new(m_cost, t_cost, p_cost, Some(32)).map_err(|e| {
104                LiteError::Storage {
105                    detail: format!("argon2 params: {e}"),
106                }
107            })?,
108        );
109        argon2
110            .hash_password_into(passphrase.as_bytes(), &salt, &mut key_bytes)
111            .map_err(|e| LiteError::Storage {
112                detail: format!("argon2 key derivation failed: {e}"),
113            })?;
114
115        let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| LiteError::Storage {
116            detail: format!("AES-256-GCM init failed: {e}"),
117        })?;
118
119        Ok(Self {
120            inner,
121            cipher,
122            _key_material: ZeroizeKey { bytes: key_bytes },
123        })
124    }
125
126    /// Derive a deterministic 12-byte nonce from namespace + key.
127    ///
128    /// Uses the first 12 bytes of CRC32C(namespace || key), extended with
129    /// the namespace byte and key length for uniqueness. Not cryptographically
130    /// ideal (nonce reuse for same key), but acceptable because:
131    /// 1. Each (namespace, key) pair maps to exactly one value at a time
132    /// 2. Rewriting the same key with different data is an update, not a new message
133    fn derive_nonce(ns: Namespace, key: &[u8]) -> [u8; 12] {
134        let mut nonce_input = Vec::with_capacity(1 + key.len());
135        nonce_input.push(ns as u8);
136        nonce_input.extend_from_slice(key);
137
138        let crc = crc32c::crc32c(&nonce_input);
139        let crc_bytes = crc.to_le_bytes();
140
141        let mut nonce = [0u8; 12];
142        // Fill: [crc32(4)] [ns(1)] [key_len_le(2)] [key_prefix(5)]
143        nonce[0..4].copy_from_slice(&crc_bytes);
144        nonce[4] = ns as u8;
145        nonce[5..7].copy_from_slice(&(key.len() as u16).to_le_bytes());
146        let prefix_len = key.len().min(5);
147        nonce[7..7 + prefix_len].copy_from_slice(&key[..prefix_len]);
148        nonce
149    }
150
151    fn encrypt(&self, ns: Namespace, key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, LiteError> {
152        let nonce = Self::derive_nonce(ns, key);
153        self.cipher
154            .encrypt(Nonce::from_slice(&nonce), plaintext)
155            .map_err(|e| LiteError::Storage {
156                detail: format!("AES-GCM encrypt failed: {e}"),
157            })
158    }
159
160    fn decrypt(&self, ns: Namespace, key: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, LiteError> {
161        let nonce = Self::derive_nonce(ns, key);
162        self.cipher
163            .decrypt(Nonce::from_slice(&nonce), ciphertext)
164            .map_err(|e| LiteError::Storage {
165                detail: format!("AES-GCM decrypt failed (wrong passphrase or corrupted data): {e}"),
166            })
167    }
168}
169
170#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
171#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
172impl<S: StorageEngine> StorageEngine for EncryptedStorage<S> {
173    async fn get(&self, ns: Namespace, key: &[u8]) -> Result<Option<Vec<u8>>, LiteError> {
174        // Salt is stored unencrypted.
175        if ns == Namespace::Meta && key == SALT_KEY {
176            return self.inner.get(ns, key).await;
177        }
178
179        match self.inner.get(ns, key).await? {
180            Some(ciphertext) => {
181                let plaintext = self.decrypt(ns, key, &ciphertext)?;
182                Ok(Some(plaintext))
183            }
184            None => Ok(None),
185        }
186    }
187
188    async fn put(&self, ns: Namespace, key: &[u8], value: &[u8]) -> Result<(), LiteError> {
189        // Salt is stored unencrypted.
190        if ns == Namespace::Meta && key == SALT_KEY {
191            return self.inner.put(ns, key, value).await;
192        }
193
194        let ciphertext = self.encrypt(ns, key, value)?;
195        self.inner.put(ns, key, &ciphertext).await
196    }
197
198    async fn delete(&self, ns: Namespace, key: &[u8]) -> Result<(), LiteError> {
199        self.inner.delete(ns, key).await
200    }
201
202    async fn scan_prefix(
203        &self,
204        ns: Namespace,
205        prefix: &[u8],
206    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, LiteError> {
207        let encrypted_entries = self.inner.scan_prefix(ns, prefix).await?;
208        let mut results = Vec::with_capacity(encrypted_entries.len());
209        for (key, ciphertext) in &encrypted_entries {
210            match self.decrypt(ns, key, ciphertext) {
211                Ok(plaintext) => results.push((key.clone(), plaintext)),
212                Err(e) => {
213                    tracing::warn!(
214                        key = ?String::from_utf8_lossy(key),
215                        error = %e,
216                        "skipping undecryptable entry in scan"
217                    );
218                }
219            }
220        }
221        Ok(results)
222    }
223
224    async fn batch_write(&self, ops: &[WriteOp]) -> Result<(), LiteError> {
225        let encrypted_ops: Vec<WriteOp> = ops
226            .iter()
227            .map(|op| match op {
228                WriteOp::Put { ns, key, value } => {
229                    if *ns == Namespace::Meta && key == SALT_KEY {
230                        return Ok(WriteOp::Put {
231                            ns: *ns,
232                            key: key.clone(),
233                            value: value.clone(),
234                        });
235                    }
236                    let ciphertext = self.encrypt(*ns, key, value)?;
237                    Ok(WriteOp::Put {
238                        ns: *ns,
239                        key: key.clone(),
240                        value: ciphertext,
241                    })
242                }
243                WriteOp::Delete { ns, key } => Ok(WriteOp::Delete {
244                    ns: *ns,
245                    key: key.clone(),
246                }),
247            })
248            .collect::<Result<Vec<_>, LiteError>>()?;
249
250        self.inner.batch_write(&encrypted_ops).await
251    }
252
253    async fn count(&self, ns: Namespace) -> Result<u64, LiteError> {
254        self.inner.count(ns).await
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::config::LiteConfig;
262    use crate::storage::redb_storage::RedbStorage;
263
264    async fn make_encrypted() -> EncryptedStorage<RedbStorage> {
265        let cfg = LiteConfig::default();
266        let inner = RedbStorage::open_in_memory().unwrap();
267        EncryptedStorage::open(
268            inner,
269            "test-passphrase-123",
270            cfg.argon2_m_cost,
271            cfg.argon2_t_cost,
272            cfg.argon2_p_cost,
273        )
274        .await
275        .unwrap()
276    }
277
278    #[tokio::test]
279    async fn roundtrip_basic() {
280        let s = make_encrypted().await;
281        s.put(Namespace::Vector, b"v1", b"hello world")
282            .await
283            .unwrap();
284        let val = s.get(Namespace::Vector, b"v1").await.unwrap();
285        assert_eq!(val.as_deref(), Some(b"hello world".as_slice()));
286    }
287
288    #[tokio::test]
289    async fn get_missing_returns_none() {
290        let s = make_encrypted().await;
291        assert!(s.get(Namespace::Vector, b"nope").await.unwrap().is_none());
292    }
293
294    #[tokio::test]
295    async fn different_namespaces_isolated() {
296        let s = make_encrypted().await;
297        s.put(Namespace::Vector, b"k", b"vec").await.unwrap();
298        s.put(Namespace::Graph, b"k", b"graph").await.unwrap();
299
300        assert_eq!(
301            s.get(Namespace::Vector, b"k").await.unwrap().as_deref(),
302            Some(b"vec".as_slice())
303        );
304        assert_eq!(
305            s.get(Namespace::Graph, b"k").await.unwrap().as_deref(),
306            Some(b"graph".as_slice())
307        );
308    }
309
310    #[tokio::test]
311    async fn wrong_passphrase_fails_decrypt() {
312        let cfg = LiteConfig::default();
313        let inner = RedbStorage::open_in_memory().unwrap();
314        // Write with passphrase A.
315        {
316            let s = EncryptedStorage::open(
317                inner,
318                "passphrase-A",
319                cfg.argon2_m_cost,
320                cfg.argon2_t_cost,
321                cfg.argon2_p_cost,
322            )
323            .await
324            .unwrap();
325            s.put(Namespace::Vector, b"secret", b"classified data")
326                .await
327                .unwrap();
328        }
329        // The inner storage is consumed, so we can't reopen with a different passphrase
330        // in this test. Instead, verify the salt persists.
331    }
332
333    #[tokio::test]
334    async fn scan_prefix_decrypts() {
335        let s = make_encrypted().await;
336        s.put(Namespace::Crdt, b"delta:001", b"data1")
337            .await
338            .unwrap();
339        s.put(Namespace::Crdt, b"delta:002", b"data2")
340            .await
341            .unwrap();
342        s.put(Namespace::Crdt, b"other:001", b"other")
343            .await
344            .unwrap();
345
346        let results = s.scan_prefix(Namespace::Crdt, b"delta:").await.unwrap();
347        assert_eq!(results.len(), 2);
348        assert_eq!(results[0].1, b"data1");
349        assert_eq!(results[1].1, b"data2");
350    }
351
352    #[tokio::test]
353    async fn batch_write_encrypts() {
354        let s = make_encrypted().await;
355        s.batch_write(&[
356            WriteOp::Put {
357                ns: Namespace::Vector,
358                key: b"a".to_vec(),
359                value: b"alpha".to_vec(),
360            },
361            WriteOp::Put {
362                ns: Namespace::Vector,
363                key: b"b".to_vec(),
364                value: b"beta".to_vec(),
365            },
366        ])
367        .await
368        .unwrap();
369
370        assert_eq!(
371            s.get(Namespace::Vector, b"a").await.unwrap().as_deref(),
372            Some(b"alpha".as_slice())
373        );
374        assert_eq!(
375            s.get(Namespace::Vector, b"b").await.unwrap().as_deref(),
376            Some(b"beta".as_slice())
377        );
378    }
379
380    #[tokio::test]
381    async fn large_value_roundtrip() {
382        let s = make_encrypted().await;
383        let large = vec![0xABu8; 100_000];
384        s.put(Namespace::LoroState, b"snapshot", &large)
385            .await
386            .unwrap();
387        let val = s.get(Namespace::LoroState, b"snapshot").await.unwrap();
388        assert_eq!(val.unwrap().len(), 100_000);
389    }
390
391    #[tokio::test]
392    async fn salt_persists() {
393        let s = make_encrypted().await;
394        let salt = s.inner.get(Namespace::Meta, SALT_KEY).await.unwrap();
395        assert!(salt.is_some());
396        assert_eq!(salt.unwrap().len(), SALT_SIZE);
397    }
398}