Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

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, Payload},
35};
36use argon2::{Argon2, Params, password_hash::SaltString};
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
40
41use base64ct::{Base64, Encoding};
42
43use crate::{
44    Result, Transaction,
45    crdt::{CRDTError, Doc, doc::Value},
46    store::{ProjectionDescriptor, Registered, Store, StoreError, StoreStateModel},
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    store_identity: Vec<u8>,
297    /// Cached derived key (zeroized on drop, thread-safe)
298    derived_key: Arc<Mutex<DerivedKey>>,
299}
300
301#[derive(Serialize, Deserialize)]
302struct EncryptedRecordEnvelope<'a> {
303    key: &'a [u8],
304    value: &'a [u8],
305}
306
307#[derive(Deserialize)]
308struct DecryptedRecordEnvelope {
309    key: Vec<u8>,
310    value: Vec<u8>,
311}
312
313impl PasswordEncryptor {
314    /// Create a new PasswordEncryptor
315    fn new(password: Password, subtree_name: String, database_id: &[u8]) -> Self {
316        let mut store_identity = database_id.to_vec();
317        store_identity.push(0);
318        store_identity.extend_from_slice(subtree_name.as_bytes());
319        Self {
320            password,
321            subtree_name,
322            store_identity,
323            derived_key: Arc::new(Mutex::new(DerivedKey::new())),
324        }
325    }
326
327    /// Execute a function with access to the encryption key (with caching)
328    ///
329    /// Provides a reference to the key without cloning it. This avoids
330    /// leaving unzeroized copies of the key in memory. The lock is held
331    /// during key derivation to prevent concurrent derivation races.
332    fn with_key<F, R>(&self, f: F) -> Result<R>
333    where
334        F: FnOnce(&[u8]) -> Result<R>,
335    {
336        let mut guard = self.derived_key.lock().unwrap();
337
338        // Check if key is already cached
339        if let Some(key) = guard.get() {
340            return f(key);
341        }
342
343        // Derive the key (expensive Argon2 operation, but only done once)
344        let mut key = vec![0u8; 32];
345        let salt = SaltString::from_b64(&self.password.salt).map_err(|e| {
346            StoreError::ImplementationError {
347                store: self.subtree_name.clone(),
348                reason: format!("Invalid salt: {e}"),
349            }
350        })?;
351
352        // Build Argon2 with configured parameters
353        let params = Params::new(
354            self.password.argon2_m_cost,
355            self.password.argon2_t_cost,
356            self.password.argon2_p_cost,
357            Some(32), // output length
358        )
359        .map_err(|e| StoreError::ImplementationError {
360            store: self.subtree_name.clone(),
361            reason: format!("Invalid Argon2 parameters: {e}"),
362        })?;
363
364        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
365
366        argon2
367            .hash_password_into(
368                self.password.password.as_bytes(),
369                salt.as_str().as_bytes(),
370                &mut key,
371            )
372            .map_err(|e| StoreError::ImplementationError {
373                store: self.subtree_name.clone(),
374                reason: format!("Key derivation failed: {e}"),
375            })?;
376
377        // Cache the key for future use
378        guard.set(key);
379
380        // Execute function with the derived key (guard still held)
381        f(guard.get().unwrap())
382    }
383
384    fn record_key_material(&self) -> Result<Zeroizing<[u8; 32]>> {
385        self.with_key(|master| {
386            Ok(Zeroizing::new(blake3::derive_key(
387                "eidetica/password-store/record-key/v1",
388                master,
389            )))
390        })
391    }
392
393    fn record_value_material(&self) -> Result<Zeroizing<[u8; 32]>> {
394        self.with_key(|master| {
395            Ok(Zeroizing::new(blake3::derive_key(
396                "eidetica/password-store/record-value/v1",
397                master,
398            )))
399        })
400    }
401}
402
403impl Encryptor for PasswordEncryptor {
404    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
405        // Wire format: nonce (12 bytes) || ciphertext
406        if ciphertext.len() < AES_GCM_NONCE_SIZE {
407            return Err(StoreError::DeserializationFailed {
408                store: self.subtree_name.clone(),
409                reason: format!(
410                    "Ciphertext too short: expected at least {} bytes, got {}",
411                    AES_GCM_NONCE_SIZE,
412                    ciphertext.len()
413                ),
414            }
415            .into());
416        }
417
418        let (nonce_bytes, encrypted_data) = ciphertext.split_at(AES_GCM_NONCE_SIZE);
419
420        // Use the encryption key without cloning
421        self.with_key(|encryption_key| {
422            // Create cipher
423            let cipher = Aes256Gcm::new_from_slice(encryption_key).map_err(|e| {
424                StoreError::ImplementationError {
425                    store: self.subtree_name.clone(),
426                    reason: format!("Failed to create cipher: {e}"),
427                }
428            })?;
429
430            // Decrypt
431            let nonce = Nonce::from_slice(nonce_bytes);
432            cipher.decrypt(nonce, encrypted_data).map_err(|_| {
433                StoreError::ImplementationError {
434                    store: self.subtree_name.clone(),
435                    reason: "Decryption failed".to_string(),
436                }
437                .into()
438            })
439        })
440    }
441
442    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
443        // Use the encryption key without cloning
444        self.with_key(|encryption_key| {
445            // Create cipher
446            let cipher = Aes256Gcm::new_from_slice(encryption_key).map_err(|e| {
447                StoreError::ImplementationError {
448                    store: self.subtree_name.clone(),
449                    reason: format!("Failed to create cipher: {e}"),
450                }
451            })?;
452
453            // Generate random nonce
454            let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
455
456            // Encrypt
457            let ciphertext =
458                cipher
459                    .encrypt(&nonce, plaintext)
460                    .map_err(|e| StoreError::ImplementationError {
461                        store: self.subtree_name.clone(),
462                        reason: format!("Encryption failed: {e}"),
463                    })?;
464
465            // Wire format: nonce (12 bytes) || ciphertext
466            let mut result = nonce.to_vec();
467            result.extend(ciphertext);
468            Ok(result)
469        })
470    }
471
472    fn physical_record_key(&self, logical_key: &[u8]) -> Result<Vec<u8>> {
473        let key = self.record_key_material()?;
474        Ok(blake3::keyed_hash(&key, logical_key).as_bytes().to_vec())
475    }
476
477    fn encrypt_record(&self, logical_key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
478        let key = self.record_value_material()?;
479        let cipher = Aes256Gcm::new_from_slice(key.as_ref()).map_err(|error| {
480            StoreError::ImplementationError {
481                store: self.subtree_name.clone(),
482                reason: format!("Failed to create record cipher: {error}"),
483            }
484        })?;
485        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
486        let physical_key = self.physical_record_key(logical_key)?;
487        let aad = physical_record_aad(&self.store_identity, &physical_key);
488        let plaintext = serde_json::to_vec(&EncryptedRecordEnvelope {
489            key: logical_key,
490            value: plaintext,
491        })?;
492        let ciphertext = cipher
493            .encrypt(
494                &nonce,
495                Payload {
496                    msg: &plaintext,
497                    aad: &aad,
498                },
499            )
500            .map_err(|error| StoreError::ImplementationError {
501                store: self.subtree_name.clone(),
502                reason: format!("Record encryption failed: {error}"),
503            })?;
504        let mut result = nonce.to_vec();
505        result.extend(ciphertext);
506        Ok(result)
507    }
508
509    fn decrypt_record(&self, physical_key: &[u8], ciphertext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
510        if ciphertext.len() < AES_GCM_NONCE_SIZE {
511            return Err(StoreError::DeserializationFailed {
512                store: self.subtree_name.clone(),
513                reason: "Encrypted record is shorter than its nonce".to_string(),
514            }
515            .into());
516        }
517        let (nonce, ciphertext) = ciphertext.split_at(AES_GCM_NONCE_SIZE);
518        let key = self.record_value_material()?;
519        let cipher = Aes256Gcm::new_from_slice(key.as_ref()).map_err(|error| {
520            StoreError::ImplementationError {
521                store: self.subtree_name.clone(),
522                reason: format!("Failed to create record cipher: {error}"),
523            }
524        })?;
525        let aad = physical_record_aad(&self.store_identity, physical_key);
526        let plaintext = cipher
527            .decrypt(
528                Nonce::from_slice(nonce),
529                Payload {
530                    msg: ciphertext,
531                    aad: &aad,
532                },
533            )
534            .map_err(|_| StoreError::DataCorruption {
535                store: self.subtree_name.clone(),
536                reason: "record authentication failed".to_string(),
537            })?;
538        let envelope: DecryptedRecordEnvelope = serde_json::from_slice(&plaintext)?;
539        if self.physical_record_key(&envelope.key)? != physical_key {
540            return Err(StoreError::DataCorruption {
541                store: self.subtree_name.clone(),
542                reason: "record envelope key does not match its physical key".to_string(),
543            }
544            .into());
545        }
546        Ok((envelope.key, envelope.value))
547    }
548
549    fn projection_descriptor(&self, descriptor: ProjectionDescriptor) -> ProjectionDescriptor {
550        ProjectionDescriptor {
551            name: format!("eidetica/password/{}", descriptor.name),
552            version: descriptor.version,
553        }
554    }
555}
556
557fn physical_record_aad(store_identity: &[u8], physical_key: &[u8]) -> Vec<u8> {
558    let mut aad = b"eidetica/password-store/record-envelope/v1\0".to_vec();
559    aad.extend_from_slice(store_identity);
560    aad.push(0);
561    aad.extend_from_slice(physical_key);
562    aad
563}
564
565/// Password-encrypted store wrapper.
566///
567/// Wraps any [`Store`] type with transparent AES-256-GCM encryption using
568/// password-derived keys (Argon2id). The type parameter `S` specifies the
569/// wrapped store type, and `PasswordStore<S>` delegates `Store::Data` to
570/// `S::Data` — encryption is a transport-level concern invisible at the type level.
571///
572/// # Type Parameter
573///
574/// * `S` - The wrapped store type (e.g., `DocStore`, `Table<T>`)
575///
576/// # State Machine
577///
578/// PasswordStore has three states (derived from internal fields):
579///
580/// 1. **Uninitialized** - Created via `get_store()`, no encryption configured
581/// 2. **Locked** - Has encryption config, not yet decrypted
582/// 3. **Unlocked** - Decrypted and ready to use
583///
584/// State transitions:
585/// - `get_store()` → Uninitialized (new) or Locked (existing)
586/// - `initialize()` → Unlocked (from Uninitialized only)
587/// - `open()` → Unlocked (from Locked only)
588///
589/// # Security
590///
591/// - **Encryption**: AES-256-GCM authenticated encryption
592/// - **Key Derivation**: Argon2id memory-hard password hashing
593/// - **Nonces**: Unique random nonce per encryption operation
594/// - **Zeroization**: Passwords cleared from memory on drop
595///
596/// # Limitations
597///
598/// - **Password Loss**: Losing the password means permanent data loss
599/// - **Performance**: Encryption/decryption overhead on every operation
600/// - **Metadata**: Backends can observe record counts, ciphertext sizes, and access patterns
601///
602/// # Examples
603///
604/// Creating a new encrypted store:
605///
606/// ```rust,no_run
607/// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
608/// # use eidetica::store::{PasswordStore, DocStore};
609/// # use eidetica::auth::generate_keypair;
610/// # async fn example() -> eidetica::Result<()> {
611/// # let backend = InMemory::new();
612/// # let instance = Instance::open_backend(Box::new(backend)).await?;
613/// # let (private_key, _) = generate_keypair();
614/// # let db = Database::create(&instance, private_key, Doc::new()).await?;
615/// let tx = db.new_transaction().await?;
616/// let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
617/// encrypted.initialize("my_password", Doc::new()).await?;
618///
619/// let docstore = encrypted.inner().await?;
620/// docstore.set("key", "secret value").await?;
621/// tx.commit().await?;
622/// # Ok(())
623/// # }
624/// ```
625///
626/// Opening an existing encrypted store:
627///
628/// ```rust,no_run
629/// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
630/// # use eidetica::store::{PasswordStore, DocStore};
631/// # use eidetica::auth::generate_keypair;
632/// # async fn example() -> eidetica::Result<()> {
633/// # let backend = InMemory::new();
634/// # let instance = Instance::open_backend(Box::new(backend)).await?;
635/// # let (private_key, _) = generate_keypair();
636/// # let db = Database::create(&instance, private_key, Doc::new()).await?;
637/// let tx = db.new_transaction().await?;
638/// let mut store = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
639/// store.open("my_password")?;
640///
641/// let docstore = store.inner().await?;
642/// let value = docstore.get("key").await?;
643/// # Ok(())
644/// # }
645/// ```
646pub struct PasswordStore<S: Store> {
647    /// Subtree name
648    name: String,
649    /// Transaction reference
650    transaction: Transaction,
651    /// Encryption configuration (None if uninitialized)
652    config: Option<PasswordStoreConfig>,
653    /// Cached password (zeroized on drop)
654    cached_password: Option<Password>,
655    /// Decrypted wrapped store info (only available after open())
656    wrapped_info: Option<WrappedStoreInfo>,
657    /// Phantom type for the wrapped store
658    _phantom: PhantomData<S>,
659}
660
661impl<S: Store> PasswordStore<S> {
662    /// Derive the current state from internal fields
663    fn state(&self) -> PasswordStoreState {
664        match (&self.config, &self.cached_password) {
665            (None, _) => PasswordStoreState::Uninitialized,
666            (Some(_), None) => PasswordStoreState::Locked,
667            (Some(_), Some(_)) => PasswordStoreState::Unlocked,
668        }
669    }
670}
671
672impl<S: Store> Registered for PasswordStore<S> {
673    fn type_id() -> &'static str {
674        // Explicitly use v0 to indicate instability
675        "encrypted:password:v0"
676    }
677}
678
679#[async_trait]
680impl<S: Store> Store for PasswordStore<S> {
681    type Data = S::Data;
682
683    fn state_model() -> StoreStateModel<Self::Data> {
684        let inner = S::state_model();
685        let descriptor = inner.descriptor();
686        inner.with_descriptor(ProjectionDescriptor {
687            name: format!("eidetica/password/{}", descriptor.name),
688            version: descriptor.version,
689        })
690    }
691
692    async fn load(txn: &Transaction, subtree_name: String) -> Result<Self> {
693        // Try to load config from _index to determine state
694        let index_store = txn.get_index().await?;
695        let info = index_store.get_entry(&subtree_name).await?;
696
697        // Type validation
698        if !Self::supports_type_id(&info.type_id) {
699            return Err(StoreError::TypeMismatch {
700                store: subtree_name,
701                expected: Self::type_id().to_string(),
702                actual: info.type_id,
703            }
704            .into());
705        }
706
707        // Determine state based on config content
708        // Empty Doc means uninitialized, non-empty means locked
709        if info.config.is_empty() {
710            Ok(Self {
711                name: subtree_name,
712                transaction: txn.clone(),
713                config: None,
714                cached_password: None,
715                wrapped_info: None,
716                _phantom: PhantomData,
717            })
718        } else {
719            // Parse the config from the Doc
720            let config: PasswordStoreConfig =
721                info.config.try_into().map_err(|e: crate::Error| {
722                    StoreError::DeserializationFailed {
723                        store: subtree_name.clone(),
724                        reason: format!("Failed to parse PasswordStoreConfig: {e}"),
725                    }
726                })?;
727
728            // Validate encryption parameters
729            if config.encryption.algorithm != "aes-256-gcm" {
730                return Err(StoreError::InvalidConfiguration {
731                    store: subtree_name,
732                    reason: format!(
733                        "Unsupported encryption algorithm: {}",
734                        config.encryption.algorithm
735                    ),
736                }
737                .into());
738            }
739
740            if config.encryption.kdf != "argon2id" {
741                return Err(StoreError::InvalidConfiguration {
742                    store: subtree_name,
743                    reason: format!("Unsupported KDF: {}", config.encryption.kdf),
744                }
745                .into());
746            }
747
748            Ok(Self {
749                name: subtree_name,
750                transaction: txn.clone(),
751                config: Some(config),
752                cached_password: None,
753                wrapped_info: None,
754                _phantom: PhantomData,
755            })
756        }
757    }
758
759    async fn register(txn: &Transaction, subtree_name: String) -> Result<Self> {
760        // Register in _index with empty config (marks as uninitialized)
761        let index_store = txn.get_index().await?;
762        index_store
763            .set_entry(&subtree_name, Self::type_id(), Self::default_config())
764            .await?;
765
766        Ok(Self {
767            name: subtree_name,
768            transaction: txn.clone(),
769            config: None,
770            cached_password: None,
771            wrapped_info: None,
772            _phantom: PhantomData,
773        })
774    }
775
776    fn name(&self) -> &str {
777        &self.name
778    }
779
780    fn transaction(&self) -> &Transaction {
781        &self.transaction
782    }
783}
784
785impl<S: Store> PasswordStore<S> {
786    /// Initialize encryption on an uninitialized store
787    ///
788    /// This configures encryption for a PasswordStore that was obtained via
789    /// `get_store()`. The wrapped store's type (derived from `S`) and config
790    /// are encrypted and stored in the PasswordStore's configuration in `_index`.
791    ///
792    /// After calling this method, the store transitions to the Unlocked state
793    /// and is ready to use.
794    ///
795    /// # Arguments
796    /// * `password` - Password for encryption (will be zeroized after use)
797    /// * `wrapped_config` - Configuration for wrapped store
798    ///
799    /// # Returns
800    /// Ok(()) on success, the store is now unlocked
801    ///
802    /// # Errors
803    /// - Returns error if store is not in Uninitialized state
804    /// - Returns error if encryption fails
805    ///
806    /// # Examples
807    ///
808    /// ```rust,no_run
809    /// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
810    /// # use eidetica::store::{PasswordStore, DocStore};
811    /// # use eidetica::auth::generate_keypair;
812    /// # async fn example() -> eidetica::Result<()> {
813    /// # let backend = InMemory::new();
814    /// # let instance = Instance::open_backend(Box::new(backend)).await?;
815    /// # let (private_key, _) = generate_keypair();
816    /// # let db = Database::create(&instance, private_key, Doc::new()).await?;
817    /// let tx = db.new_transaction().await?;
818    /// let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("secrets").await?;
819    /// encrypted.initialize("my_password", Doc::new()).await?;
820    ///
821    /// let docstore = encrypted.inner().await?;
822    /// docstore.set("key", "secret value").await?;
823    /// tx.commit().await?;
824    /// # Ok(())
825    /// # }
826    /// ```
827    pub async fn initialize(
828        &mut self,
829        password: impl Into<String>,
830        wrapped_config: Doc,
831    ) -> Result<()> {
832        // Check state is Uninitialized
833        if self.state() != PasswordStoreState::Uninitialized {
834            return Err(StoreError::InvalidOperation {
835                store: self.name.clone(),
836                operation: "initialize".to_string(),
837                reason: "Store is already initialized - use open() instead".to_string(),
838            }
839            .into());
840        }
841
842        let password = password.into();
843        let wrapped_type_id = S::type_id().to_string();
844
845        // Use default Argon2 parameters
846        let argon2_m_cost = DEFAULT_ARGON2_M_COST;
847        let argon2_t_cost = DEFAULT_ARGON2_T_COST;
848        let argon2_p_cost = DEFAULT_ARGON2_P_COST;
849
850        // Generate encryption parameters
851        let salt = SaltString::generate(&mut OsRng);
852        let salt_str = salt.as_str().to_string();
853
854        // Build Argon2 with configured parameters
855        let params =
856            Params::new(argon2_m_cost, argon2_t_cost, argon2_p_cost, Some(32)).map_err(|e| {
857                StoreError::ImplementationError {
858                    store: self.name.clone(),
859                    reason: format!("Invalid Argon2 parameters: {e}"),
860                }
861            })?;
862        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
863
864        // Derive encryption key from password
865        let mut encryption_key = vec![0u8; 32];
866        argon2
867            .hash_password_into(
868                password.as_bytes(),
869                salt.as_str().as_bytes(),
870                &mut encryption_key,
871            )
872            .map_err(|e| StoreError::ImplementationError {
873                store: self.name.clone(),
874                reason: format!("Failed to derive encryption key: {e}"),
875            })?;
876
877        // Create cipher
878        let cipher = Aes256Gcm::new_from_slice(&encryption_key).map_err(|e| {
879            StoreError::ImplementationError {
880                store: self.name.clone(),
881                reason: format!("Failed to create cipher: {e}"),
882            }
883        })?;
884
885        // Encrypt wrapped store metadata
886        let wrapped_info = WrappedStoreInfo {
887            type_id: wrapped_type_id,
888            config: wrapped_config,
889        };
890        let wrapped_json = serde_json::to_string(&wrapped_info)?;
891        let config_nonce = Aes256Gcm::generate_nonce(&mut OsRng);
892        let wrapped_config_ciphertext = cipher
893            .encrypt(&config_nonce, wrapped_json.as_bytes())
894            .map_err(|e| StoreError::ImplementationError {
895                store: self.name.clone(),
896                reason: format!("Failed to encrypt wrapped config: {e}"),
897            })?;
898
899        // Zeroize the encryption key
900        encryption_key.zeroize();
901
902        // Create configuration
903        let config = PasswordStoreConfig {
904            encryption: EncryptionInfo {
905                algorithm: "aes-256-gcm".to_string(),
906                kdf: "argon2id".to_string(),
907                salt: salt_str.clone(),
908                version: "v0".to_string(),
909                argon2_m_cost: Some(argon2_m_cost),
910                argon2_t_cost: Some(argon2_t_cost),
911                argon2_p_cost: Some(argon2_p_cost),
912            },
913            wrapped_config: EncryptedFragment {
914                ciphertext: wrapped_config_ciphertext,
915                nonce: config_nonce.to_vec(),
916            },
917        };
918
919        // Update _index with the encryption config
920        self.set_config(config.clone().into()).await?;
921
922        // Cache password and create encryptor
923        let password_cache = Password {
924            salt: salt_str,
925            password,
926            argon2_m_cost,
927            argon2_t_cost,
928            argon2_p_cost,
929        };
930
931        // Register encryptor with transaction (store is now unlocked)
932        let encryptor = Box::new(PasswordEncryptor::new(
933            password_cache.clone(),
934            self.name.clone(),
935            self.transaction.database_id().to_string().as_bytes(),
936        ));
937        self.transaction.register_encryptor(&self.name, encryptor)?;
938
939        // Update internal state
940        self.config = Some(config);
941        self.cached_password = Some(password_cache);
942        self.wrapped_info = Some(wrapped_info);
943
944        Ok(())
945    }
946
947    /// Open (unlock) the encrypted store with a password
948    ///
949    /// This decrypts the wrapped store configuration and caches the password
950    /// for subsequent encrypt/decrypt operations.
951    ///
952    /// # Arguments
953    /// * `password` - Password to decrypt the store
954    ///
955    /// # Returns
956    /// Ok(()) if password is correct, Err otherwise
957    ///
958    /// # Errors
959    /// - Returns error if store is Uninitialized (use `initialize()` first)
960    /// - Returns error if store is already Unlocked
961    /// - Returns error if password is incorrect
962    ///
963    /// # Security
964    /// The password is cached in memory (with zeroization on drop) for
965    /// convenience.
966    pub fn open(&mut self, password: impl Into<String>) -> Result<()> {
967        // Check state
968        match self.state() {
969            PasswordStoreState::Uninitialized => {
970                return Err(StoreError::InvalidOperation {
971                    store: self.name.clone(),
972                    operation: "open".to_string(),
973                    reason: "Store is not initialized - call initialize() first".to_string(),
974                }
975                .into());
976            }
977            PasswordStoreState::Unlocked => {
978                return Err(StoreError::InvalidOperation {
979                    store: self.name.clone(),
980                    operation: "open".to_string(),
981                    reason: "Store is already open".to_string(),
982                }
983                .into());
984            }
985            PasswordStoreState::Locked => {}
986        }
987
988        let config = self.config.as_ref().expect("Locked state requires config");
989        let password = password.into();
990
991        // Get Argon2 parameters from config (with defaults)
992        let argon2_m_cost = config
993            .encryption
994            .argon2_m_cost
995            .unwrap_or(DEFAULT_ARGON2_M_COST);
996        let argon2_t_cost = config
997            .encryption
998            .argon2_t_cost
999            .unwrap_or(DEFAULT_ARGON2_T_COST);
1000        let argon2_p_cost = config
1001            .encryption
1002            .argon2_p_cost
1003            .unwrap_or(DEFAULT_ARGON2_P_COST);
1004
1005        // Derive encryption key
1006        let mut encryption_key = vec![0u8; 32];
1007        let salt = SaltString::from_b64(&config.encryption.salt).map_err(|e| {
1008            StoreError::ImplementationError {
1009                store: self.name.clone(),
1010                reason: format!("Invalid salt in config: {e}"),
1011            }
1012        })?;
1013
1014        // Build Argon2 with configured parameters
1015        let params =
1016            Params::new(argon2_m_cost, argon2_t_cost, argon2_p_cost, Some(32)).map_err(|e| {
1017                StoreError::ImplementationError {
1018                    store: self.name.clone(),
1019                    reason: format!("Invalid Argon2 parameters: {e}"),
1020                }
1021            })?;
1022        let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
1023
1024        argon2
1025            .hash_password_into(
1026                password.as_bytes(),
1027                salt.as_str().as_bytes(),
1028                &mut encryption_key,
1029            )
1030            .map_err(|e| StoreError::ImplementationError {
1031                store: self.name.clone(),
1032                reason: format!("Failed to derive encryption key: {e}"),
1033            })?;
1034
1035        // Decrypt wrapped config
1036        let cipher = Aes256Gcm::new_from_slice(&encryption_key).map_err(|e| {
1037            StoreError::ImplementationError {
1038                store: self.name.clone(),
1039                reason: format!("Failed to create cipher: {e}"),
1040            }
1041        })?;
1042
1043        // Validate nonce length (must be 12 bytes for AES-GCM)
1044        if config.wrapped_config.nonce.len() != 12 {
1045            return Err(StoreError::InvalidConfiguration {
1046                store: self.name.clone(),
1047                reason: format!(
1048                    "Invalid nonce length: expected 12 bytes, got {}",
1049                    config.wrapped_config.nonce.len()
1050                ),
1051            }
1052            .into());
1053        }
1054        let config_nonce = Nonce::from_slice(&config.wrapped_config.nonce);
1055
1056        let decrypted_config = cipher
1057            .decrypt(config_nonce, config.wrapped_config.ciphertext.as_slice())
1058            .map_err(|_| StoreError::ImplementationError {
1059                store: self.name.clone(),
1060                reason: "Failed to decrypt wrapped config - incorrect password?".to_string(),
1061            })?;
1062
1063        // Zeroize encryption key
1064        encryption_key.zeroize();
1065
1066        // Parse wrapped store info
1067        let wrapped_info: WrappedStoreInfo =
1068            serde_json::from_slice(&decrypted_config).map_err(|e| {
1069                StoreError::DeserializationFailed {
1070                    store: self.name.clone(),
1071                    reason: format!("Failed to parse wrapped store info: {e}"),
1072                }
1073            })?;
1074
1075        // Verify the stored wrapped type matches the static type parameter S
1076        if !S::supports_type_id(&wrapped_info.type_id) {
1077            return Err(StoreError::TypeMismatch {
1078                store: self.name.clone(),
1079                expected: S::type_id().to_string(),
1080                actual: wrapped_info.type_id,
1081            }
1082            .into());
1083        }
1084
1085        // Cache password and wrapped info (state is derived from these fields)
1086        let password_cache = Password {
1087            salt: config.encryption.salt.clone(),
1088            password,
1089            argon2_m_cost,
1090            argon2_t_cost,
1091            argon2_p_cost,
1092        };
1093        self.cached_password = Some(password_cache.clone());
1094        self.wrapped_info = Some(wrapped_info);
1095
1096        // Register encryptor with the transaction for transparent encryption
1097        let encryptor = Box::new(PasswordEncryptor::new(
1098            password_cache,
1099            self.name.clone(),
1100            self.transaction.database_id().to_string().as_bytes(),
1101        ));
1102        self.transaction.register_encryptor(&self.name, encryptor)?;
1103
1104        Ok(())
1105    }
1106
1107    /// Check if the store is currently unlocked (password cached)
1108    pub fn is_open(&self) -> bool {
1109        self.state() == PasswordStoreState::Unlocked
1110    }
1111
1112    /// Check if the store is initialized (has encryption configuration)
1113    pub fn is_initialized(&self) -> bool {
1114        self.state() != PasswordStoreState::Uninitialized
1115    }
1116
1117    /// Get the wrapped store, providing transparent encryption.
1118    ///
1119    /// Returns the inner `S` store instance that transparently encrypts data
1120    /// on write and decrypts on read. The wrapped store is unaware of
1121    /// encryption — all crypto operations are handled by an encryptor
1122    /// registered with the transaction during `open()` or `initialize()`.
1123    ///
1124    /// # Errors
1125    /// - Returns error if store is not opened (call `open()` first)
1126    ///
1127    /// # Examples
1128    ///
1129    /// ```rust,no_run
1130    /// # use eidetica::{Instance, backend::database::InMemory, crdt::Doc, Database};
1131    /// # use eidetica::store::{PasswordStore, DocStore};
1132    /// # use eidetica::auth::generate_keypair;
1133    /// # async fn example() -> eidetica::Result<()> {
1134    /// # let backend = InMemory::new();
1135    /// # let instance = Instance::open_backend(Box::new(backend)).await?;
1136    /// # let (private_key, _) = generate_keypair();
1137    /// # let db = Database::create(&instance, private_key, Doc::new()).await?;
1138    /// # let tx = db.new_transaction().await?;
1139    /// # let mut encrypted = tx.get_store::<PasswordStore<DocStore>>("test").await?;
1140    /// # encrypted.initialize("pass", Doc::new()).await?;
1141    /// # tx.commit().await?;
1142    /// # let tx2 = db.new_transaction().await?;
1143    /// let mut encrypted = tx2.get_store::<PasswordStore<DocStore>>("test").await?;
1144    /// encrypted.open("pass")?;
1145    ///
1146    /// let docstore = encrypted.inner().await?;
1147    /// docstore.set("key", "value").await?; // Automatically encrypted
1148    /// # Ok(())
1149    /// # }
1150    /// ```
1151    pub async fn inner(&self) -> Result<S> {
1152        if !self.is_open() {
1153            return Err(StoreError::InvalidOperation {
1154                store: self.name.clone(),
1155                operation: "inner".to_string(),
1156                reason: "Store not opened - call open() first".to_string(),
1157            }
1158            .into());
1159        }
1160
1161        // Create the wrapped store. The transaction has an encryptor registered,
1162        // so it transparently decrypts on read and encrypts on commit.
1163        // We call S::load() directly, bypassing Transaction::get_store() type
1164        // checking, since type consistency was already verified in open().
1165        S::load(&self.transaction, self.name.clone()).await
1166    }
1167}
1168#[cfg(test)]
1169mod tests {
1170    use super::*;
1171
1172    fn encryptor(store: &str) -> PasswordEncryptor {
1173        PasswordEncryptor::new(
1174            Password {
1175                salt: "MDEyMzQ1Njc4OUFCQ0RFRg".to_string(),
1176                password: "record-authentication".to_string(),
1177                argon2_m_cost: 8,
1178                argon2_t_cost: 1,
1179                argon2_p_cost: 1,
1180            },
1181            store.to_string(),
1182            b"database",
1183        )
1184    }
1185
1186    #[test]
1187    fn record_keys_and_values_use_separate_domain_material() {
1188        let first = encryptor("first");
1189        let second = encryptor("second");
1190        let master = first
1191            .with_key(|key| Ok(<[u8; 32]>::try_from(key).unwrap()))
1192            .unwrap();
1193        assert_ne!(*first.record_key_material().unwrap(), master);
1194        assert_ne!(*first.record_value_material().unwrap(), master);
1195        assert_ne!(
1196            first.record_key_material().unwrap(),
1197            first.record_value_material().unwrap()
1198        );
1199        assert_eq!(
1200            *first.record_key_material().unwrap(),
1201            *second.record_key_material().unwrap()
1202        );
1203        assert_eq!(
1204            *first.record_value_material().unwrap(),
1205            *second.record_value_material().unwrap()
1206        );
1207    }
1208
1209    #[test]
1210    fn encrypted_records_use_unique_nonces() {
1211        let encryptor = encryptor("first");
1212        let first = encryptor.encrypt_record(b"logical", b"plaintext").unwrap();
1213        let second = encryptor.encrypt_record(b"logical", b"plaintext").unwrap();
1214
1215        assert_ne!(&first[..AES_GCM_NONCE_SIZE], &second[..AES_GCM_NONCE_SIZE]);
1216        assert_ne!(first, second);
1217    }
1218
1219    #[test]
1220    fn encrypted_record_rejects_wrong_store_and_physical_key() {
1221        let first = encryptor("first");
1222        let second = encryptor("second");
1223        let physical_key = first.physical_record_key(b"logical").unwrap();
1224        let ciphertext = first.encrypt_record(b"logical", b"plaintext").unwrap();
1225        assert_eq!(
1226            first.decrypt_record(&physical_key, &ciphertext).unwrap(),
1227            (b"logical".to_vec(), b"plaintext".to_vec())
1228        );
1229        assert!(
1230            first
1231                .decrypt_record(&first.physical_record_key(b"other").unwrap(), &ciphertext)
1232                .is_err(),
1233            "ciphertext moved to another row must be rejected"
1234        );
1235        assert!(
1236            second.decrypt_record(&physical_key, &ciphertext).is_err(),
1237            "ciphertext from another Store must be rejected"
1238        );
1239    }
1240
1241    #[test]
1242    fn encrypted_record_rejects_malformed_and_modified_ciphertext() {
1243        let encryptor = encryptor("first");
1244        let physical_key = encryptor.physical_record_key(b"logical").unwrap();
1245        let mut ciphertext = encryptor.encrypt_record(b"logical", b"plaintext").unwrap();
1246
1247        assert!(
1248            encryptor
1249                .decrypt_record(&physical_key, &ciphertext[..AES_GCM_NONCE_SIZE - 1])
1250                .is_err()
1251        );
1252        *ciphertext.last_mut().unwrap() ^= 1;
1253        assert!(
1254            encryptor
1255                .decrypt_record(&physical_key, &ciphertext)
1256                .is_err()
1257        );
1258    }
1259}