1use 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
24const SALT_SIZE: usize = 16;
26
27const SALT_KEY: &[u8] = b"encryption:salt";
29
30pub struct EncryptedStorage<S: StorageEngine> {
36 inner: S,
37 cipher: Aes256Gcm,
38 _key_material: ZeroizeKey,
40}
41
42struct 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 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 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 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 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 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 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 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 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 {
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 }
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}