Skip to main content

reddb_engine/query/local_database/
vault.rs

1//! Local file-owner Vault, using the native RDVX envelope and auth KV payload.
2//! The sealed image is an internal Vault collection, never an ordinary SQL table.
3//! Its WAL action joins the caller's SQL transaction; closing drops pending writes.
4
5use super::*;
6use crate::catalog::CollectionModel;
7use crate::storage::entity::{EntityId, RowData, UnifiedEntity};
8use reddb_crypto::{
9    aes_gcm::{aes256_gcm_decrypt, aes256_gcm_encrypt},
10    os_random,
11};
12use zeroize::Zeroizing;
13
14const COLLECTION: &str = "red.local.vault";
15const PAYLOAD_BYTES_MAX: usize = 1024 * 1024;
16
17/// Session-owned opening key. Contains no password and zeroizes on drop.
18/// A random persisted salt binds it to one Vault identity across file reopen.
19pub struct LocalVaultKey {
20    key: Zeroizing<[u8; 32]>,
21    salt: [u8; 16],
22}
23
24fn failure(code: &'static str) -> LocalReadError {
25    LocalReadError::Vault(code)
26}
27
28impl LocalVaultKey {
29    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
30        if plaintext.len() > PAYLOAD_BYTES_MAX {
31            return Err(failure("VAULT_SIZE_LIMIT"));
32        }
33        let mut nonce = [0u8; 12];
34        os_random::fill_bytes(&mut nonce).map_err(|_| failure("VAULT_RANDOM_FAILED"))?;
35        let ciphertext = aes256_gcm_encrypt(
36            &self.key,
37            &nonce,
38            reddb_file::VAULT_LOGICAL_EXPORT_AAD,
39            plaintext,
40        );
41        hex::decode(reddb_file::encode_vault_logical_export(
42            &self.salt,
43            &nonce,
44            &ciphertext,
45        ))
46        .map_err(|_| failure("VAULT_CORRUPT"))
47    }
48
49    fn unseal(&self, bytes: &[u8]) -> Result<Zeroizing<String>> {
50        if bytes.len() > PAYLOAD_BYTES_MAX + 64 {
51            return Err(failure("VAULT_SIZE_LIMIT"));
52        }
53        let envelope = reddb_file::decode_vault_logical_export(&hex::encode(bytes))
54            .map_err(|_| failure("VAULT_CORRUPT"))?;
55        if envelope.salt != self.salt {
56            return Err(failure("VAULT_LOCKED"));
57        }
58        let plaintext = Zeroizing::new(
59            aes256_gcm_decrypt(
60                &self.key,
61                &envelope.nonce,
62                reddb_file::VAULT_LOGICAL_EXPORT_AAD,
63                &envelope.ciphertext,
64            )
65            .map_err(|_| failure("VAULT_AUTHENTICATION_FAILED"))?,
66        );
67        let text = std::str::from_utf8(&plaintext).map_err(|_| failure("VAULT_CORRUPT"))?;
68        Ok(Zeroizing::new(text.to_string()))
69    }
70}
71
72fn key_valid(key: &str) -> Result<()> {
73    if key.is_empty() || key.len() > 512 || key.chars().any(char::is_control) {
74        return Err(failure("VAULT_INVALID_KEY"));
75    }
76    Ok(())
77}
78
79/// Preserve all non-KV records (MASTER_SECRET, USER, KEY, SEALED and future
80/// auth fields) verbatim. A local credential write must never drop auth state.
81fn payload_entries(payload: &str) -> Result<HashMap<String, Zeroizing<String>>> {
82    let mut entries = HashMap::new();
83    for line in payload.lines() {
84        if let Some(record) = line.strip_prefix("KV:") {
85            let (key, value) = record
86                .split_once('\t')
87                .ok_or_else(|| failure("VAULT_CORRUPT"))?;
88            key_valid(key)?;
89            let bytes = Zeroizing::new(hex::decode(value).map_err(|_| failure("VAULT_CORRUPT"))?);
90            let value = std::str::from_utf8(&bytes).map_err(|_| failure("VAULT_CORRUPT"))?;
91            if entries
92                .insert(key.into(), Zeroizing::new(value.into()))
93                .is_some()
94            {
95                return Err(failure("VAULT_CORRUPT"));
96            }
97        }
98    }
99    Ok(entries)
100}
101
102impl<F: VfsFile> LocalDatabase<F> {
103    fn vault_blob(&self) -> Result<Option<Vec<u8>>> {
104        self.check_healthy()?;
105        if self
106            .transaction
107            .as_ref()
108            .is_some_and(|transaction| transaction.aborted)
109        {
110            return Err(query_error("transaction is aborted; ROLLBACK is required"));
111        }
112        let (resident, _) = self.visible()?;
113        let Some(manager) = resident.get_collection(COLLECTION) else {
114            return Ok(None);
115        };
116        let entity = manager
117            .get(EntityId::new(1))
118            .ok_or_else(|| failure("VAULT_CORRUPT"))?;
119        match entity
120            .data
121            .as_row()
122            .and_then(|row| row.get_by_name("value"))
123        {
124            Some(Value::Secret(bytes)) => Ok(Some(bytes.clone())),
125            _ => Err(failure("VAULT_CORRUPT")),
126        }
127    }
128
129    /// Metadata only; no key derivation and no credential material returned.
130    pub fn vault_initialized(&self) -> Result<bool> {
131        Ok(self.vault_blob()?.is_some())
132    }
133
134    /// Creation is explicit and only admitted outside a transaction. Existing
135    /// files are authenticated first; wrong passwords never replace saved state.
136    pub fn vault_unlock(&mut self, password: &str, create: bool) -> Result<LocalVaultKey> {
137        let blob = self.vault_blob()?;
138        let mut salt = [0u8; 16];
139        if let Some(bytes) = &blob {
140            salt = reddb_file::decode_vault_logical_export(&hex::encode(bytes))
141                .map_err(|_| failure("VAULT_CORRUPT"))?
142                .salt;
143        } else {
144            if !create {
145                return Err(failure("VAULT_NOT_INITIALIZED"));
146            }
147            if self.transaction.is_some() {
148                return Err(query_error("initialize Vault before BEGIN"));
149            }
150            os_random::fill_bytes(&mut salt).map_err(|_| failure("VAULT_RANDOM_FAILED"))?;
151        }
152        let key = LocalVaultKey {
153            salt,
154            key: reddb_crypto::vault::passphrase_key(password, &salt)
155                .map_err(|_| failure("VAULT_INVALID_PASSWORD"))?,
156        };
157        if let Some(bytes) = blob {
158            payload_entries(&key.unseal(&bytes)?)?;
159        } else {
160            let (resident, contracts) = self.visible()?;
161            let staged = copy_resident(resident)?;
162            let mut contracts = contracts.clone();
163            apply(
164                &staged,
165                &[StoreWalAction::CreateCollection {
166                    name: COLLECTION.into(),
167                }],
168            )?;
169            let QueryExpr::CreateTable(query) =
170                reddb_rql::parse_frontend("CREATE TABLE local_vault (value TEXT)")
171                    .map_err(LocalReadError::Parse)?
172                    .into_query_expr()
173            else {
174                unreachable!("static DDL")
175            };
176            let mut contract =
177                crate::catalog::ddl::collection_contract_from_create_table(&query, 0)
178                    .map_err(query_error)?;
179            contract.name = COLLECTION.into();
180            contract.declared_model = CollectionModel::Vault;
181            contract.table_def = None;
182            contract.declared_columns.clear();
183            contracts.insert(COLLECTION.into(), contract);
184            set_contracts(&staged, &contracts);
185            apply(&staged, &[vault_action(key.seal(b"SEALED:false\n")?)])?;
186            self.store.publish_snapshot(&snapshot(&staged))?;
187            self.contracts = contracts;
188        }
189        Ok(key)
190    }
191
192    pub fn vault_keys(&self, key: &LocalVaultKey) -> Result<Vec<String>> {
193        let blob = self
194            .vault_blob()?
195            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
196        let mut keys: Vec<_> = payload_entries(&key.unseal(&blob)?)?.into_keys().collect();
197        keys.sort();
198        Ok(keys)
199    }
200
201    /// Plaintext is returned only through this explicit reveal operation.
202    pub fn vault_reveal(&self, key: &LocalVaultKey, name: &str) -> Result<Option<String>> {
203        key_valid(name)?;
204        let blob = self
205            .vault_blob()?
206            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
207        Ok(payload_entries(&key.unseal(&blob)?)?
208            .remove(name)
209            .map(|value| value.to_string()))
210    }
211
212    /// Mutations stage an encrypted native action alongside ordinary SQL.
213    /// Errors abort an active transaction, just like failed SQL statements.
214    pub fn vault_put(
215        &mut self,
216        key: &LocalVaultKey,
217        name: &str,
218        value: Option<&str>,
219    ) -> Result<()> {
220        let result = self.vault_put_inner(key, name, value);
221        if result.is_err() {
222            if let Some(transaction) = &mut self.transaction {
223                transaction.aborted = true;
224            }
225        }
226        result
227    }
228
229    fn vault_put_inner(
230        &mut self,
231        key: &LocalVaultKey,
232        name: &str,
233        value: Option<&str>,
234    ) -> Result<()> {
235        key_valid(name)?;
236        if value.is_some_and(|value| value.len() > PAYLOAD_BYTES_MAX / 2) {
237            return Err(failure("VAULT_SIZE_LIMIT"));
238        }
239        let blob = self
240            .vault_blob()?
241            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
242        let plaintext = key.unseal(&blob)?;
243        let mut entries = payload_entries(&plaintext)?;
244        match value {
245            Some(value) => {
246                entries.insert(name.into(), Zeroizing::new(value.into()));
247            }
248            None => {
249                entries.remove(name);
250            }
251        }
252        let mut updated = Zeroizing::new(String::new());
253        for line in plaintext.lines().filter(|line| !line.starts_with("KV:")) {
254            updated.push_str(line);
255            updated.push('\n');
256        }
257        for (name, value) in entries {
258            updated.push_str("KV:");
259            updated.push_str(&name);
260            updated.push('\t');
261            let encoded = Zeroizing::new(hex::encode(value.as_bytes()));
262            updated.push_str(&encoded);
263            updated.push('\n');
264        }
265        let action = vault_action(key.seal(updated.as_bytes())?);
266        if let Some(transaction) = &mut self.transaction {
267            if transaction.actions.len() >= super::super::local_read::LOCAL_READ_ROWS_MAX {
268                return Err(LocalReadError::ResourceLimit);
269            }
270            apply(&transaction.resident, std::slice::from_ref(&action))?;
271            transaction.actions.push(action);
272        } else {
273            self.store.publish_actions(&[action])?;
274        }
275        Ok(())
276    }
277}
278
279fn vault_action(blob: Vec<u8>) -> StoreWalAction {
280    let mut entity = UnifiedEntity::table_row(EntityId::new(1), COLLECTION, 1, Vec::new());
281    entity.data = crate::storage::entity::EntityData::Row(RowData::with_names(
282        vec![Value::Secret(blob)],
283        vec!["value".into()],
284    ));
285    StoreWalAction::upsert_entity(COLLECTION, &entity, None, reddb_file::STORE_VERSION_CURRENT)
286}