reddb_engine/query/local_database/
vault.rs1use 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
17pub 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
79fn 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 pub fn vault_initialized(&self) -> Result<bool> {
131 Ok(self.vault_blob()?.is_some())
132 }
133
134 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 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 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}