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<S: StorageStrategy> Database<S> {
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 (store, _) = self.visible()?;
113        if !store.contains_collection(COLLECTION)? {
114            return Ok(None);
115        }
116        let entity = store
117            .get_entity(COLLECTION, 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 mut contracts = self.contracts.clone();
161            let QueryExpr::CreateTable(query) =
162                reddb_rql::parse_frontend("CREATE TABLE local_vault (value TEXT)")
163                    .map_err(LocalReadError::Parse)?
164                    .into_query_expr()
165            else {
166                unreachable!("static DDL")
167            };
168            let mut contract =
169                crate::catalog::ddl::collection_contract_from_create_table(&query, 0)
170                    .map_err(query_error)?;
171            contract.name = COLLECTION.into();
172            contract.declared_model = CollectionModel::Vault;
173            contract.table_def = None;
174            contract.declared_columns.clear();
175            contracts.insert(COLLECTION.into(), contract);
176            let actions = [
177                StoreWalAction::CreateCollection {
178                    name: COLLECTION.into(),
179                },
180                vault_action(key.seal(b"SEALED:false\n")?),
181            ];
182            self.storage
183                .publish_catalog_actions(&actions, catalog_bytes(&contracts))?;
184            self.contracts = contracts;
185        }
186        Ok(key)
187    }
188
189    pub fn vault_keys(&self, key: &LocalVaultKey) -> Result<Vec<String>> {
190        let blob = self
191            .vault_blob()?
192            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
193        let mut keys: Vec<_> = payload_entries(&key.unseal(&blob)?)?.into_keys().collect();
194        keys.sort();
195        Ok(keys)
196    }
197
198    /// Plaintext is returned only through this explicit reveal operation.
199    pub fn vault_reveal(&self, key: &LocalVaultKey, name: &str) -> Result<Option<String>> {
200        key_valid(name)?;
201        let blob = self
202            .vault_blob()?
203            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
204        Ok(payload_entries(&key.unseal(&blob)?)?
205            .remove(name)
206            .map(|value| value.to_string()))
207    }
208
209    /// Mutations stage an encrypted native action alongside ordinary SQL.
210    /// Errors abort an active transaction, just like failed SQL statements.
211    pub fn vault_put(
212        &mut self,
213        key: &LocalVaultKey,
214        name: &str,
215        value: Option<&str>,
216    ) -> Result<()> {
217        let result = self.vault_put_inner(key, name, value);
218        if result.is_err() {
219            if let Some(transaction) = &mut self.transaction {
220                transaction.aborted = true;
221            }
222        }
223        result
224    }
225
226    fn vault_put_inner(
227        &mut self,
228        key: &LocalVaultKey,
229        name: &str,
230        value: Option<&str>,
231    ) -> Result<()> {
232        key_valid(name)?;
233        if value.is_some_and(|value| value.len() > PAYLOAD_BYTES_MAX / 2) {
234            return Err(failure("VAULT_SIZE_LIMIT"));
235        }
236        let blob = self
237            .vault_blob()?
238            .ok_or_else(|| failure("VAULT_NOT_INITIALIZED"))?;
239        let plaintext = key.unseal(&blob)?;
240        let mut entries = payload_entries(&plaintext)?;
241        match value {
242            Some(value) => {
243                entries.insert(name.into(), Zeroizing::new(value.into()));
244            }
245            None => {
246                entries.remove(name);
247            }
248        }
249        let mut updated = Zeroizing::new(String::new());
250        for line in plaintext.lines().filter(|line| !line.starts_with("KV:")) {
251            updated.push_str(line);
252            updated.push('\n');
253        }
254        for (name, value) in entries {
255            updated.push_str("KV:");
256            updated.push_str(&name);
257            updated.push('\t');
258            let encoded = Zeroizing::new(hex::encode(value.as_bytes()));
259            updated.push_str(&encoded);
260            updated.push('\n');
261        }
262        let action = vault_action(key.seal(updated.as_bytes())?);
263        if let Some(transaction) = &mut self.transaction {
264            if transaction.actions.len() >= super::super::local_read::LOCAL_READ_ROWS_MAX {
265                return Err(LocalReadError::ResourceLimit);
266            }
267            self.storage
268                .apply_transaction(&transaction.store, std::slice::from_ref(&action))?;
269            transaction.actions.push(action);
270        } else {
271            self.storage.publish_actions(&[action])?;
272        }
273        Ok(())
274    }
275}
276
277impl<F: VfsFile> LocalDatabase<F> {
278    /// Metadata only; no key derivation and no credential material returned.
279    pub fn vault_initialized(&self) -> Result<bool> {
280        self.database.vault_initialized()
281    }
282
283    /// Creation is explicit and only admitted outside a transaction. Existing
284    /// files are authenticated first; wrong passwords never replace saved state.
285    pub fn vault_unlock(&mut self, password: &str, create: bool) -> Result<LocalVaultKey> {
286        self.database.vault_unlock(password, create)
287    }
288
289    pub fn vault_keys(&self, key: &LocalVaultKey) -> Result<Vec<String>> {
290        self.database.vault_keys(key)
291    }
292
293    /// Plaintext is returned only through this explicit reveal operation.
294    pub fn vault_reveal(&self, key: &LocalVaultKey, name: &str) -> Result<Option<String>> {
295        self.database.vault_reveal(key, name)
296    }
297
298    /// Mutations stage an encrypted native action alongside ordinary SQL.
299    /// Errors abort an active transaction, just like failed SQL statements.
300    pub fn vault_put(
301        &mut self,
302        key: &LocalVaultKey,
303        name: &str,
304        value: Option<&str>,
305    ) -> Result<()> {
306        self.database.vault_put(key, name, value)
307    }
308}
309
310fn vault_action(blob: Vec<u8>) -> StoreWalAction {
311    let mut entity = UnifiedEntity::table_row(EntityId::new(1), COLLECTION, 1, Vec::new());
312    entity.data = crate::storage::entity::EntityData::Row(RowData::with_names(
313        vec![Value::Secret(blob)],
314        vec!["value".into()],
315    ));
316    StoreWalAction::upsert_entity(COLLECTION, &entity, None, reddb_file::STORE_VERSION_CURRENT)
317}