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