eidetica/store/password_store.rs
1//! Password-encrypted store wrapper for transparent encryption of any Store type.
2//!
3//! This module provides [`PasswordStore<S>`], a generic decorator that wraps any
4//! [`Store`] type `S` with AES-256-GCM encryption using Argon2id-derived keys.
5//! The wrapped store's type and configuration are stored encrypted in the `_index`
6//! subtree.
7//!
8//! # Encryption Architecture
9//!
10//! Encryption is transparent to the wrapped store. Data flows as:
11//!
12//! ```text
13//! Write: WrappedStore.put() → JSON → encrypt() → base64 → stored in entry
14//! Read: entry data → base64 decode → decrypt() → JSON → WrappedStore CRDT merge
15//! ```
16//!
17//! The underlying CRDT (e.g., Doc) handles merging of decrypted data from multiple
18//! entry tips. `PasswordStore<S>` delegates `Store::Data` to `S::Data` — encryption
19//! is a transport-level concern, invisible at the type level.
20//!
21//! # Relay Node Support
22//!
23//! Relay nodes without the decryption key can store and forward encrypted entries.
24//! It is unnecessary to decrypt the data before forwarding it to other nodes.
25
26use std::marker::PhantomData;
27use std::sync::{Arc, Mutex};
28
29use aes_gcm::{
30 Aes256Gcm, KeyInit, Nonce,
31 aead::{Aead, AeadCore, OsRng},
32};
33use argon2::{Argon2, Params, password_hash::SaltString};
34use async_trait::async_trait;
35use serde::{Deserialize, Serialize};
36use zeroize::{Zeroize, ZeroizeOnDrop};
37
38use base64ct::{Base64, Encoding};
39
40use crate::{
41 Result, Transaction,
42 crdt::{CRDTError, Doc, doc::Value},
43 store::{Registered, Store, StoreError},
44 transaction::Encryptor,
45};
46
47/// Encrypted data fragment containing ciphertext and nonce.
48///
49/// Used for storing encrypted metadata (e.g., the wrapped store's configuration
50/// in [`PasswordStoreConfig::wrapped_config`]). This is a storage container
51/// with no CRDT semantics.
52#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
53pub struct EncryptedFragment {
54 /// AES-256-GCM encrypted ciphertext.
55 pub ciphertext: Vec<u8>,
56 /// 12-byte nonce for AES-GCM (must be unique per encryption).
57 pub nonce: Vec<u8>,
58}
59
60/// AES-256-GCM nonce size (96 bits / 12 bytes).
61const AES_GCM_NONCE_SIZE: usize = 12;
62
63/// Default Argon2 memory cost in KiB (19 MiB)
64pub const DEFAULT_ARGON2_M_COST: u32 = 19 * 1024;
65/// Default Argon2 time cost (iterations)
66pub const DEFAULT_ARGON2_T_COST: u32 = 2;
67/// Default Argon2 parallelism
68pub const DEFAULT_ARGON2_P_COST: u32 = 1;
69
70/// Encryption metadata stored in _index config (plaintext)
71#[derive(Serialize, Deserialize, Clone, Debug)]
72pub struct EncryptionInfo {
73 /// Encryption algorithm (always "aes-256-gcm" for v1)
74 pub algorithm: String,
75 /// Key derivation function (always "argon2id" for v1)
76 pub kdf: String,
77 /// Base64-encoded salt for Argon2 (16 bytes)
78 pub salt: String,
79 /// Version for future compatibility
80 pub version: String,
81 /// Argon2 memory cost in KiB (defaults to 19 MiB if not specified)
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub argon2_m_cost: Option<u32>,
84 /// Argon2 time cost / iterations (defaults to 2 if not specified)
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub argon2_t_cost: Option<u32>,
87 /// Argon2 parallelism (defaults to 1 if not specified)
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub argon2_p_cost: Option<u32>,
90}
91
92/// Configuration stored in _index for PasswordStore.
93#[derive(Serialize, Deserialize, Clone, Debug)]
94pub struct PasswordStoreConfig {
95 /// Encryption parameters (stored in plaintext in _index).
96 pub encryption: EncryptionInfo,
97 /// Encrypted wrapped store metadata.
98 /// Contains the wrapped store's configuration, e.g: {"type": "docstore:v0", "config": "{}"}
99 pub wrapped_config: EncryptedFragment,
100}
101
102impl From<EncryptedFragment> for Doc {
103 fn from(frag: EncryptedFragment) -> Doc {
104 let mut doc = Doc::new();
105 doc.set("ciphertext", Base64::encode_string(&frag.ciphertext));
106 doc.set("nonce", Base64::encode_string(&frag.nonce));
107 doc
108 }
109}
110
111impl TryFrom<&Doc> for EncryptedFragment {
112 type Error = crate::Error;
113
114 fn try_from(doc: &Doc) -> crate::Result<Self> {
115 let bytes = |key: &str| -> crate::Result<Vec<u8>> {
116 let b64 = doc
117 .get_as::<&str>(key)
118 .ok_or_else(|| CRDTError::ElementNotFound {
119 key: key.to_string(),
120 })?;
121 Base64::decode_vec(b64).map_err(|_| {
122 CRDTError::DeserializationFailed {
123 reason: format!("{key}: invalid base64"),
124 }
125 .into()
126 })
127 };
128
129 Ok(EncryptedFragment {
130 ciphertext: bytes("ciphertext")?,
131 nonce: bytes("nonce")?,
132 })
133 }
134}
135
136impl From<EncryptionInfo> for Doc {
137 fn from(info: EncryptionInfo) -> Doc {
138 // Functionally this is an atomic Doc, however we can save small amount of space
139 // by relying on this always being a sub-Doc of the PasswordStoreConfig type.
140 let mut doc = Doc::new();
141 doc.set("algorithm", info.algorithm);
142 doc.set("kdf", info.kdf);
143 doc.set("salt", info.salt);
144 doc.set("version", info.version);
145 if let Some(m) = info.argon2_m_cost {
146 doc.set("argon2_m_cost", m);
147 }
148 if let Some(t) = info.argon2_t_cost {
149 doc.set("argon2_t_cost", t);
150 }
151 if let Some(p) = info.argon2_p_cost {
152 doc.set("argon2_p_cost", p);
153 }
154 doc
155 }
156}
157
158impl TryFrom<&Doc> for EncryptionInfo {
159 type Error = crate::Error;
160
161 fn try_from(doc: &Doc) -> crate::Result<Self> {
162 let text = |key: &str| -> crate::Result<String> {
163 doc.get_as::<&str>(key).map(String::from).ok_or_else(|| {
164 CRDTError::ElementNotFound {
165 key: key.to_string(),
166 }
167 .into()
168 })
169 };
170
171 let cost = |key: &str| -> crate::Result<Option<u32>> {
172 doc.get_as::<i64>(key)
173 .map(|v| {
174 u32::try_from(v).map_err(|_| {
175 crate::Error::from(CRDTError::DeserializationFailed {
176 reason: format!("{key}: value {v} out of u32 range"),
177 })
178 })
179 })
180 .transpose()
181 };
182
183 Ok(EncryptionInfo {
184 algorithm: text("algorithm")?,
185 kdf: text("kdf")?,
186 salt: text("salt")?,
187 version: text("version")?,
188 argon2_m_cost: cost("argon2_m_cost")?,
189 argon2_t_cost: cost("argon2_t_cost")?,
190 argon2_p_cost: cost("argon2_p_cost")?,
191 })
192 }
193}
194
195impl From<PasswordStoreConfig> for Doc {
196 fn from(config: PasswordStoreConfig) -> Doc {
197 // The config always needs to be written atomically to avoid problems with partial updates.
198 let mut doc = Doc::atomic();
199 doc.set("encryption", Value::Doc(config.encryption.into()));
200 doc.set("wrapped_config", Value::Doc(config.wrapped_config.into()));
201 doc
202 }
203}
204
205impl TryFrom<&Doc> for PasswordStoreConfig {
206 type Error = crate::Error;
207
208 fn try_from(doc: &Doc) -> crate::Result<Self> {
209 let sub = |key: &str| -> crate::Result<&Doc> {
210 match doc.get(key) {
211 Some(Value::Doc(d)) => Ok(d),
212 _ => Err(CRDTError::ElementNotFound {
213 key: key.to_string(),
214 }
215 .into()),
216 }
217 };
218
219 Ok(PasswordStoreConfig {
220 encryption: sub("encryption")?.try_into()?,
221 wrapped_config: sub("wrapped_config")?.try_into()?,
222 })
223 }
224}
225
226impl TryFrom<Doc> for PasswordStoreConfig {
227 type Error = crate::Error;
228
229 fn try_from(doc: Doc) -> crate::Result<Self> {
230 Self::try_from(&doc)
231 }
232}
233
234/// Wrapped store metadata (stored encrypted in config)
235#[derive(Serialize, Deserialize, Clone, Debug)]
236struct WrappedStoreInfo {
237 #[serde(rename = "type")]
238 type_id: String,
239 config: Doc,
240}
241
242/// Internal state of a PasswordStore
243#[derive(Debug, Clone, PartialEq, Eq)]
244enum PasswordStoreState {
245 /// Just created via get_store(), no encryption configured yet
246 Uninitialized,
247 /// Has encryption config, but not yet decrypted for this session
248 Locked,
249 /// Decrypted, ready to use the wrapped store
250 Unlocked,
251}
252
253/// Securely stored password with automatic zeroization
254#[derive(Clone, Zeroize, ZeroizeOnDrop)]
255struct Password {
256 salt: String,
257 password: String,
258 /// Argon2 memory cost in KiB
259 argon2_m_cost: u32,
260 /// Argon2 time cost
261 argon2_t_cost: u32,
262 /// Argon2 parallelism
263 argon2_p_cost: u32,
264}
265
266/// Wrapper for derived key with automatic zeroization
267#[derive(ZeroizeOnDrop)]
268struct DerivedKey {
269 key: Option<Vec<u8>>,
270}
271
272impl DerivedKey {
273 fn new() -> Self {
274 Self { key: None }
275 }
276
277 fn set(&mut self, key: Vec<u8>) {
278 self.key = Some(key);
279 }
280
281 fn get(&self) -> Option<&Vec<u8>> {
282 self.key.as_ref()
283 }
284}
285
286/// Password-based encryptor implementing the Encryptor trait
287///
288/// Provides AES-256-GCM encryption with Argon2id key derivation.
289/// Caches the derived key to avoid expensive re-derivation on every operation.
290struct PasswordEncryptor {
291 password: Password,
292 subtree_name: String,
293 /// Cached derived key (zeroized on drop, thread-safe)
294 derived_key: Arc<Mutex<DerivedKey>>,
295}
296
297impl PasswordEncryptor {
298 /// Create a new PasswordEncryptor
299 fn new(password: Password, subtree_name: String) -> Self {
300 Self {
301 password,
302 subtree_name,
303 derived_key: Arc::new(Mutex::new(DerivedKey::new())),
304 }
305 }
306
307 /// Execute a function with access to the encryption key (with caching)
308 ///
309 /// Provides a reference to the key without cloning it. This avoids
310 /// leaving unzeroized copies of the key in memory. The lock is held
311 /// during key derivation to prevent concurrent derivation races.
312 fn with_key<F, R>(&self, f: F) -> Result<R>
313 where
314 F: FnOnce(&[u8]) -> Result<R>,
315 {
316 let mut guard = self.derived_key.lock().unwrap();
317
318 // Check if key is already cached
319 if let Some(key) = guard.get() {
320 return f(key);
321 }
322
323 // Derive the key (expensive Argon2 operation, but only done once)
324 let mut key = vec![0u8; 32];
325 let salt = SaltString::from_b64(&self.password.salt).map_err(|e| {
326 StoreError::ImplementationError {
327 store: self.subtree_name.clone(),
328 reason: format!("Invalid salt: {e}"),
329 }
330 })?;
331
332 // Build Argon2 with configured parameters
333 let params = Params::new(
334 self.password.argon2_m_cost,
335 self.password.argon2_t_cost,
336 self.password.argon2_p_cost,
337 Some(32), // output length
338 )
339 .map_err(|e| StoreError::ImplementationError {
340 store: self.subtree_name.clone(),
341 reason: format!("Invalid Argon2 parameters: {e}"),
342 })?;
343
344 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
345
346 argon2
347 .hash_password_into(
348 self.password.password.as_bytes(),
349 salt.as_str().as_bytes(),
350 &mut key,
351 )
352 .map_err(|e| StoreError::ImplementationError {
353 store: self.subtree_name.clone(),
354 reason: format!("Key derivation failed: {e}"),
355 })?;
356
357 // Cache the key for future use
358 guard.set(key);
359
360 // Execute function with the derived key (guard still held)
361 f(guard.get().unwrap())
362 }
363}
364
365impl Encryptor for PasswordEncryptor {
366 fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
367 // Wire format: nonce (12 bytes) || ciphertext
368 if ciphertext.len() < AES_GCM_NONCE_SIZE {
369 return Err(StoreError::DeserializationFailed {
370 store: self.subtree_name.clone(),
371 reason: format!(
372 "Ciphertext too short: expected at least {} bytes, got {}",
373 AES_GCM_NONCE_SIZE,
374 ciphertext.len()
375 ),
376 }
377 .into());
378 }
379
380 let (nonce_bytes, encrypted_data) = ciphertext.split_at(AES_GCM_NONCE_SIZE);
381
382 // Use the encryption key without cloning
383 self.with_key(|encryption_key| {
384 // Create cipher
385 let cipher = Aes256Gcm::new_from_slice(encryption_key).map_err(|e| {
386 StoreError::ImplementationError {
387 store: self.subtree_name.clone(),
388 reason: format!("Failed to create cipher: {e}"),
389 }
390 })?;
391
392 // Decrypt
393 let nonce = Nonce::from_slice(nonce_bytes);
394 cipher.decrypt(nonce, encrypted_data).map_err(|_| {
395 StoreError::ImplementationError {
396 store: self.subtree_name.clone(),
397 reason: "Decryption failed".to_string(),
398 }
399 .into()
400 })
401 })
402 }
403
404 fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
405 // Use the encryption key without cloning
406 self.with_key(|encryption_key| {
407 // Create cipher
408 let cipher = Aes256Gcm::new_from_slice(encryption_key).map_err(|e| {
409 StoreError::ImplementationError {
410 store: self.subtree_name.clone(),
411 reason: format!("Failed to create cipher: {e}"),
412 }
413 })?;
414
415 // Generate random nonce
416 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
417
418 // Encrypt
419 let ciphertext =
420 cipher
421 .encrypt(&nonce, plaintext)
422 .map_err(|e| StoreError::ImplementationError {
423 store: self.subtree_name.clone(),
424 reason: format!("Encryption failed: {e}"),
425 })?;
426
427 // Wire format: nonce (12 bytes) || ciphertext
428 let mut result = nonce.to_vec();
429 result.extend(ciphertext);
430 Ok(result)
431 })
432 }
433}
434
435/// Password-encrypted store wrapper.
436///
437/// Wraps any [`Store`] type with transparent AES-256-GCM encryption using
438/// password-derived keys (Argon2id). The type parameter `S` specifies the
439/// wrapped store type, and `PasswordStore<S>` delegates `Store::Data` to
440/// `S::Data` — encryption is a transport-level concern invisible at the type level.
441///
442/// # Type Parameter
443///
444/// * `S` - The wrapped store type (e.g., `DocStore`, `Table<T>`)
445///
446/// # State Machine
447///
448/// PasswordStore has three states (derived from internal fields):
449///
450/// 1. **Uninitialized** - Created via `get_store()`, no encryption configured
451/// 2. **Locked** - Has encryption config, not yet decrypted
452/// 3. **Unlocked** - Decrypted and ready to use
453///
454/// State transitions:
455/// - `get_store()` → Uninitialized (new) or Locked (existing)
456/// - `initialize()` → Unlocked (from Uninitialized only)
457/// - `open()` → Unlocked (from Locked only)
458///
459/// # Security
460///
461/// - **Encryption**: AES-256-GCM authenticated encryption
462/// - **Key Derivation**: Argon2id memory-hard password hashing
463/// - **Nonces**: Unique random nonce per encryption operation
464/// - **Zeroization**: Passwords cleared from memory on drop
465///
466/// # Limitations
467///
468/// - **Password Loss**: Losing the password means permanent data loss
469/// - **Performance**: Encryption/decryption overhead on every operation
470///
471/// # Examples
472///
473/// Creating a new encrypted store:
474///
475/// ```rust,no_run
476/// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
477/// # use eidetica::store::{PasswordStore, DocStore};
478/// # use eidetica::auth::generate_keypair;
479/// # async fn example() -> eidetica::Result<()> {
480/// # let backend = InMemory::new();
481/// # let instance = Instance::open(Box::new(backend)).await?;
482/// # let (private_key, _) = generate_keypair();
483/// # let db = Database::create(&instance, private_key, Doc::new()).await?;
484/// let tx = db.new_transaction().await?;
485/// let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
486/// encrypted.initialize("my_password", Doc::new()).await?;
487///
488/// let docstore = encrypted.inner().await?;
489/// docstore.set("key", "secret value").await?;
490/// tx.commit().await?;
491/// # Ok(())
492/// # }
493/// ```
494///
495/// Opening an existing encrypted store:
496///
497/// ```rust,no_run
498/// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
499/// # use eidetica::store::{PasswordStore, DocStore};
500/// # use eidetica::auth::generate_keypair;
501/// # async fn example() -> eidetica::Result<()> {
502/// # let backend = InMemory::new();
503/// # let instance = Instance::open(Box::new(backend)).await?;
504/// # let (private_key, _) = generate_keypair();
505/// # let db = Database::create(&instance, private_key, Doc::new()).await?;
506/// let tx = db.new_transaction().await?;
507/// let mut store = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
508/// store.open("my_password")?;
509///
510/// let docstore = store.inner().await?;
511/// let value = docstore.get("key").await?;
512/// # Ok(())
513/// # }
514/// ```
515pub struct PasswordStore<S: Store> {
516 /// Subtree name
517 name: String,
518 /// Transaction reference
519 transaction: Transaction,
520 /// Encryption configuration (None if uninitialized)
521 config: Option<PasswordStoreConfig>,
522 /// Cached password (zeroized on drop)
523 cached_password: Option<Password>,
524 /// Decrypted wrapped store info (only available after open())
525 wrapped_info: Option<WrappedStoreInfo>,
526 /// Phantom type for the wrapped store
527 _phantom: PhantomData<S>,
528}
529
530impl<S: Store> PasswordStore<S> {
531 /// Derive the current state from internal fields
532 fn state(&self) -> PasswordStoreState {
533 match (&self.config, &self.cached_password) {
534 (None, _) => PasswordStoreState::Uninitialized,
535 (Some(_), None) => PasswordStoreState::Locked,
536 (Some(_), Some(_)) => PasswordStoreState::Unlocked,
537 }
538 }
539}
540
541impl<S: Store> Registered for PasswordStore<S> {
542 fn type_id() -> &'static str {
543 // Explicitly use v0 to indicate instability
544 "encrypted:password:v0"
545 }
546}
547
548#[async_trait]
549impl<S: Store> Store for PasswordStore<S> {
550 type Data = S::Data;
551
552 async fn new(txn: &Transaction, subtree_name: String) -> Result<Self> {
553 // Try to load config from _index to determine state
554 let index_store = txn.get_index().await?;
555 let info = index_store.get_entry(&subtree_name).await?;
556
557 // Type validation
558 if !Self::supports_type_id(&info.type_id) {
559 return Err(StoreError::TypeMismatch {
560 store: subtree_name,
561 expected: Self::type_id().to_string(),
562 actual: info.type_id,
563 }
564 .into());
565 }
566
567 // Determine state based on config content
568 // Empty Doc means uninitialized, non-empty means locked
569 if info.config.is_empty() {
570 Ok(Self {
571 name: subtree_name,
572 transaction: txn.clone(),
573 config: None,
574 cached_password: None,
575 wrapped_info: None,
576 _phantom: PhantomData,
577 })
578 } else {
579 // Parse the config from the Doc
580 let config: PasswordStoreConfig =
581 info.config.try_into().map_err(|e: crate::Error| {
582 StoreError::DeserializationFailed {
583 store: subtree_name.clone(),
584 reason: format!("Failed to parse PasswordStoreConfig: {e}"),
585 }
586 })?;
587
588 // Validate encryption parameters
589 if config.encryption.algorithm != "aes-256-gcm" {
590 return Err(StoreError::InvalidConfiguration {
591 store: subtree_name,
592 reason: format!(
593 "Unsupported encryption algorithm: {}",
594 config.encryption.algorithm
595 ),
596 }
597 .into());
598 }
599
600 if config.encryption.kdf != "argon2id" {
601 return Err(StoreError::InvalidConfiguration {
602 store: subtree_name,
603 reason: format!("Unsupported KDF: {}", config.encryption.kdf),
604 }
605 .into());
606 }
607
608 Ok(Self {
609 name: subtree_name,
610 transaction: txn.clone(),
611 config: Some(config),
612 cached_password: None,
613 wrapped_info: None,
614 _phantom: PhantomData,
615 })
616 }
617 }
618
619 async fn init(txn: &Transaction, subtree_name: String) -> Result<Self> {
620 // Register in _index with empty config (marks as uninitialized)
621 let index_store = txn.get_index().await?;
622 index_store
623 .set_entry(&subtree_name, Self::type_id(), Self::default_config())
624 .await?;
625
626 Ok(Self {
627 name: subtree_name,
628 transaction: txn.clone(),
629 config: None,
630 cached_password: None,
631 wrapped_info: None,
632 _phantom: PhantomData,
633 })
634 }
635
636 fn name(&self) -> &str {
637 &self.name
638 }
639
640 fn transaction(&self) -> &Transaction {
641 &self.transaction
642 }
643}
644
645impl<S: Store> PasswordStore<S> {
646 /// Initialize encryption on an uninitialized store
647 ///
648 /// This configures encryption for a PasswordStore that was obtained via
649 /// `get_store()`. The wrapped store's type (derived from `S`) and config
650 /// are encrypted and stored in the PasswordStore's configuration in `_index`.
651 ///
652 /// After calling this method, the store transitions to the Unlocked state
653 /// and is ready to use.
654 ///
655 /// # Arguments
656 /// * `password` - Password for encryption (will be zeroized after use)
657 /// * `wrapped_config` - Configuration for wrapped store
658 ///
659 /// # Returns
660 /// Ok(()) on success, the store is now unlocked
661 ///
662 /// # Errors
663 /// - Returns error if store is not in Uninitialized state
664 /// - Returns error if encryption fails
665 ///
666 /// # Examples
667 ///
668 /// ```rust,no_run
669 /// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
670 /// # use eidetica::store::{PasswordStore, DocStore};
671 /// # use eidetica::auth::generate_keypair;
672 /// # async fn example() -> eidetica::Result<()> {
673 /// # let backend = InMemory::new();
674 /// # let instance = Instance::open(Box::new(backend)).await?;
675 /// # let (private_key, _) = generate_keypair();
676 /// # let db = Database::create(&instance, private_key, Doc::new()).await?;
677 /// let tx = db.new_transaction().await?;
678 /// let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
679 /// encrypted.initialize("my_password", Doc::new()).await?;
680 ///
681 /// let docstore = encrypted.inner().await?;
682 /// docstore.set("key", "secret value").await?;
683 /// tx.commit().await?;
684 /// # Ok(())
685 /// # }
686 /// ```
687 pub async fn initialize(
688 &mut self,
689 password: impl Into<String>,
690 wrapped_config: Doc,
691 ) -> Result<()> {
692 // Check state is Uninitialized
693 if self.state() != PasswordStoreState::Uninitialized {
694 return Err(StoreError::InvalidOperation {
695 store: self.name.clone(),
696 operation: "initialize".to_string(),
697 reason: "Store is already initialized - use open() instead".to_string(),
698 }
699 .into());
700 }
701
702 let password = password.into();
703 let wrapped_type_id = S::type_id().to_string();
704
705 // Use default Argon2 parameters
706 let argon2_m_cost = DEFAULT_ARGON2_M_COST;
707 let argon2_t_cost = DEFAULT_ARGON2_T_COST;
708 let argon2_p_cost = DEFAULT_ARGON2_P_COST;
709
710 // Generate encryption parameters
711 let salt = SaltString::generate(&mut OsRng);
712 let salt_str = salt.as_str().to_string();
713
714 // Build Argon2 with configured parameters
715 let params =
716 Params::new(argon2_m_cost, argon2_t_cost, argon2_p_cost, Some(32)).map_err(|e| {
717 StoreError::ImplementationError {
718 store: self.name.clone(),
719 reason: format!("Invalid Argon2 parameters: {e}"),
720 }
721 })?;
722 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
723
724 // Derive encryption key from password
725 let mut encryption_key = vec![0u8; 32];
726 argon2
727 .hash_password_into(
728 password.as_bytes(),
729 salt.as_str().as_bytes(),
730 &mut encryption_key,
731 )
732 .map_err(|e| StoreError::ImplementationError {
733 store: self.name.clone(),
734 reason: format!("Failed to derive encryption key: {e}"),
735 })?;
736
737 // Create cipher
738 let cipher = Aes256Gcm::new_from_slice(&encryption_key).map_err(|e| {
739 StoreError::ImplementationError {
740 store: self.name.clone(),
741 reason: format!("Failed to create cipher: {e}"),
742 }
743 })?;
744
745 // Encrypt wrapped store metadata
746 let wrapped_info = WrappedStoreInfo {
747 type_id: wrapped_type_id,
748 config: wrapped_config,
749 };
750 let wrapped_json = serde_json::to_string(&wrapped_info)?;
751 let config_nonce = Aes256Gcm::generate_nonce(&mut OsRng);
752 let wrapped_config_ciphertext = cipher
753 .encrypt(&config_nonce, wrapped_json.as_bytes())
754 .map_err(|e| StoreError::ImplementationError {
755 store: self.name.clone(),
756 reason: format!("Failed to encrypt wrapped config: {e}"),
757 })?;
758
759 // Zeroize the encryption key
760 encryption_key.zeroize();
761
762 // Create configuration
763 let config = PasswordStoreConfig {
764 encryption: EncryptionInfo {
765 algorithm: "aes-256-gcm".to_string(),
766 kdf: "argon2id".to_string(),
767 salt: salt_str.clone(),
768 version: "v0".to_string(),
769 argon2_m_cost: Some(argon2_m_cost),
770 argon2_t_cost: Some(argon2_t_cost),
771 argon2_p_cost: Some(argon2_p_cost),
772 },
773 wrapped_config: EncryptedFragment {
774 ciphertext: wrapped_config_ciphertext,
775 nonce: config_nonce.to_vec(),
776 },
777 };
778
779 // Update _index with the encryption config
780 self.set_config(config.clone().into()).await?;
781
782 // Cache password and create encryptor
783 let password_cache = Password {
784 salt: salt_str,
785 password,
786 argon2_m_cost,
787 argon2_t_cost,
788 argon2_p_cost,
789 };
790
791 // Register encryptor with transaction (store is now unlocked)
792 let encryptor = Box::new(PasswordEncryptor::new(
793 password_cache.clone(),
794 self.name.clone(),
795 ));
796 self.transaction.register_encryptor(&self.name, encryptor)?;
797
798 // Update internal state
799 self.config = Some(config);
800 self.cached_password = Some(password_cache);
801 self.wrapped_info = Some(wrapped_info);
802
803 Ok(())
804 }
805
806 /// Open (unlock) the encrypted store with a password
807 ///
808 /// This decrypts the wrapped store configuration and caches the password
809 /// for subsequent encrypt/decrypt operations.
810 ///
811 /// # Arguments
812 /// * `password` - Password to decrypt the store
813 ///
814 /// # Returns
815 /// Ok(()) if password is correct, Err otherwise
816 ///
817 /// # Errors
818 /// - Returns error if store is Uninitialized (use `initialize()` first)
819 /// - Returns error if store is already Unlocked
820 /// - Returns error if password is incorrect
821 ///
822 /// # Security
823 /// The password is cached in memory (with zeroization on drop) for
824 /// convenience.
825 pub fn open(&mut self, password: impl Into<String>) -> Result<()> {
826 // Check state
827 match self.state() {
828 PasswordStoreState::Uninitialized => {
829 return Err(StoreError::InvalidOperation {
830 store: self.name.clone(),
831 operation: "open".to_string(),
832 reason: "Store is not initialized - call initialize() first".to_string(),
833 }
834 .into());
835 }
836 PasswordStoreState::Unlocked => {
837 return Err(StoreError::InvalidOperation {
838 store: self.name.clone(),
839 operation: "open".to_string(),
840 reason: "Store is already open".to_string(),
841 }
842 .into());
843 }
844 PasswordStoreState::Locked => {}
845 }
846
847 let config = self.config.as_ref().expect("Locked state requires config");
848 let password = password.into();
849
850 // Get Argon2 parameters from config (with defaults)
851 let argon2_m_cost = config
852 .encryption
853 .argon2_m_cost
854 .unwrap_or(DEFAULT_ARGON2_M_COST);
855 let argon2_t_cost = config
856 .encryption
857 .argon2_t_cost
858 .unwrap_or(DEFAULT_ARGON2_T_COST);
859 let argon2_p_cost = config
860 .encryption
861 .argon2_p_cost
862 .unwrap_or(DEFAULT_ARGON2_P_COST);
863
864 // Derive encryption key
865 let mut encryption_key = vec![0u8; 32];
866 let salt = SaltString::from_b64(&config.encryption.salt).map_err(|e| {
867 StoreError::ImplementationError {
868 store: self.name.clone(),
869 reason: format!("Invalid salt in config: {e}"),
870 }
871 })?;
872
873 // Build Argon2 with configured parameters
874 let params =
875 Params::new(argon2_m_cost, argon2_t_cost, argon2_p_cost, Some(32)).map_err(|e| {
876 StoreError::ImplementationError {
877 store: self.name.clone(),
878 reason: format!("Invalid Argon2 parameters: {e}"),
879 }
880 })?;
881 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
882
883 argon2
884 .hash_password_into(
885 password.as_bytes(),
886 salt.as_str().as_bytes(),
887 &mut encryption_key,
888 )
889 .map_err(|e| StoreError::ImplementationError {
890 store: self.name.clone(),
891 reason: format!("Failed to derive encryption key: {e}"),
892 })?;
893
894 // Decrypt wrapped config
895 let cipher = Aes256Gcm::new_from_slice(&encryption_key).map_err(|e| {
896 StoreError::ImplementationError {
897 store: self.name.clone(),
898 reason: format!("Failed to create cipher: {e}"),
899 }
900 })?;
901
902 // Validate nonce length (must be 12 bytes for AES-GCM)
903 if config.wrapped_config.nonce.len() != 12 {
904 return Err(StoreError::InvalidConfiguration {
905 store: self.name.clone(),
906 reason: format!(
907 "Invalid nonce length: expected 12 bytes, got {}",
908 config.wrapped_config.nonce.len()
909 ),
910 }
911 .into());
912 }
913 let config_nonce = Nonce::from_slice(&config.wrapped_config.nonce);
914
915 let decrypted_config = cipher
916 .decrypt(config_nonce, config.wrapped_config.ciphertext.as_slice())
917 .map_err(|_| StoreError::ImplementationError {
918 store: self.name.clone(),
919 reason: "Failed to decrypt wrapped config - incorrect password?".to_string(),
920 })?;
921
922 // Zeroize encryption key
923 encryption_key.zeroize();
924
925 // Parse wrapped store info
926 let wrapped_info: WrappedStoreInfo =
927 serde_json::from_slice(&decrypted_config).map_err(|e| {
928 StoreError::DeserializationFailed {
929 store: self.name.clone(),
930 reason: format!("Failed to parse wrapped store info: {e}"),
931 }
932 })?;
933
934 // Verify the stored wrapped type matches the static type parameter S
935 if !S::supports_type_id(&wrapped_info.type_id) {
936 return Err(StoreError::TypeMismatch {
937 store: self.name.clone(),
938 expected: S::type_id().to_string(),
939 actual: wrapped_info.type_id,
940 }
941 .into());
942 }
943
944 // Cache password and wrapped info (state is derived from these fields)
945 let password_cache = Password {
946 salt: config.encryption.salt.clone(),
947 password,
948 argon2_m_cost,
949 argon2_t_cost,
950 argon2_p_cost,
951 };
952 self.cached_password = Some(password_cache.clone());
953 self.wrapped_info = Some(wrapped_info);
954
955 // Register encryptor with the transaction for transparent encryption
956 let encryptor = Box::new(PasswordEncryptor::new(password_cache, self.name.clone()));
957 self.transaction.register_encryptor(&self.name, encryptor)?;
958
959 Ok(())
960 }
961
962 /// Check if the store is currently unlocked (password cached)
963 pub fn is_open(&self) -> bool {
964 self.state() == PasswordStoreState::Unlocked
965 }
966
967 /// Check if the store is initialized (has encryption configuration)
968 pub fn is_initialized(&self) -> bool {
969 self.state() != PasswordStoreState::Uninitialized
970 }
971
972 /// Get the wrapped store, providing transparent encryption.
973 ///
974 /// Returns the inner `S` store instance that transparently encrypts data
975 /// on write and decrypts on read. The wrapped store is unaware of
976 /// encryption — all crypto operations are handled by an encryptor
977 /// registered with the transaction during `open()` or `initialize()`.
978 ///
979 /// # Errors
980 /// - Returns error if store is not opened (call `open()` first)
981 ///
982 /// # Examples
983 ///
984 /// ```rust,no_run
985 /// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
986 /// # use eidetica::store::{PasswordStore, DocStore};
987 /// # use eidetica::auth::generate_keypair;
988 /// # async fn example() -> eidetica::Result<()> {
989 /// # let backend = InMemory::new();
990 /// # let instance = Instance::open(Box::new(backend)).await?;
991 /// # let (private_key, _) = generate_keypair();
992 /// # let db = Database::create(&instance, private_key, Doc::new()).await?;
993 /// # let tx = db.new_transaction().await?;
994 /// # let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("test").await?;
995 /// # encrypted.initialize("pass", Doc::new()).await?;
996 /// # tx.commit().await?;
997 /// # let tx2 = db.new_transaction().await?;
998 /// let mut encrypted = tx2.get_store::<PasswordStore<DocStore>>("test").await?;
999 /// encrypted.open("pass")?;
1000 ///
1001 /// let docstore = encrypted.inner().await?;
1002 /// docstore.set("key", "value").await?; // Automatically encrypted
1003 /// # Ok(())
1004 /// # }
1005 /// ```
1006 pub async fn inner(&self) -> Result<S> {
1007 if !self.is_open() {
1008 return Err(StoreError::InvalidOperation {
1009 store: self.name.clone(),
1010 operation: "inner".to_string(),
1011 reason: "Store not opened - call open() first".to_string(),
1012 }
1013 .into());
1014 }
1015
1016 // Create the wrapped store. The transaction has an encryptor registered,
1017 // so it transparently decrypts on read and encrypts on commit.
1018 // We call S::new() directly, bypassing Transaction::get_store() type
1019 // checking, since type consistency was already verified in open().
1020 S::new(&self.transaction, self.name.clone()).await
1021 }
1022}