1use crate::crypto::aes_gcm::{aes256_gcm_decrypt, aes256_gcm_encrypt};
67use crate::crypto::hmac::hmac_sha256;
68use crate::crypto::os_random;
69use crate::storage::encryption::argon2id::{derive_key, Argon2Params};
70use crate::storage::encryption::key::SecureKey;
71use crate::storage::engine::page::{Page, PageType, CONTENT_SIZE, HEADER_SIZE};
72use crate::storage::engine::pager::Pager;
73
74use super::{ApiKey, AuthError, Role, User, UserId};
75
76const VAULT_MAGIC: &[u8; 4] = b"RDVT";
81const VAULT_DATA_MAGIC: &[u8; 4] = b"RDVD";
82
83const VAULT_VERSION: u8 = 2;
86const VAULT_PASSPHRASE_VERSION: u8 = 3;
87
88const VAULT_LEGACY_VERSION: u8 = 1;
91
92const VAULT_AAD: &[u8] = b"reddb-vault";
93
94use reddb_file::VAULT_LOGICAL_EXPORT_AAD;
98
99const VAULT_MAGIC_SIZE: usize = 4;
101const VAULT_VERSION_SIZE: usize = 1;
102const VAULT_SALT_SIZE: usize = 16;
103const VAULT_PAYLOAD_LEN_SIZE: usize = 4;
104const VAULT_CHAIN_COUNT_SIZE: usize = 4;
105const VAULT_FIRST_PAGE_ID_SIZE: usize = 4;
106
107const NONCE_SIZE: usize = 12;
109
110const VAULT_HEADER_PREAMBLE_SIZE: usize =
112 VAULT_MAGIC_SIZE + VAULT_VERSION_SIZE + VAULT_SALT_SIZE + VAULT_PAYLOAD_LEN_SIZE; const VAULT_HEADER_META_SIZE: usize =
117 VAULT_HEADER_PREAMBLE_SIZE + NONCE_SIZE + VAULT_CHAIN_COUNT_SIZE + VAULT_FIRST_PAGE_ID_SIZE; const VAULT_DATA_PREFIX_SIZE: usize = VAULT_MAGIC_SIZE + 4; const VAULT_HEADER_PAGE: u32 = 2;
125
126const VAULT_HEADER_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_HEADER_META_SIZE;
129
130const VAULT_DATA_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_DATA_PREFIX_SIZE;
133
134pub struct KeyPair {
151 pub master_secret: Vec<u8>,
153 pub certificate: Vec<u8>,
155}
156
157impl KeyPair {
158 pub fn generate() -> Self {
160 let mut master_secret = vec![0u8; 32];
161 os_random::fill_bytes(&mut master_secret).expect("CSPRNG failed during keypair generation");
162 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
163 Self {
164 master_secret,
165 certificate: certificate.to_vec(),
166 }
167 }
168
169 pub fn from_master_secret(master_secret: Vec<u8>) -> Self {
172 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
173 Self {
174 master_secret,
175 certificate: certificate.to_vec(),
176 }
177 }
178
179 pub fn vault_key_from_certificate(certificate: &[u8]) -> SecureKey {
184 let key_bytes = derive_key(certificate, b"reddb-vault-seal", &vault_argon2_params());
185 SecureKey::new(&key_bytes)
186 }
187
188 pub fn sign(&self, data: &[u8]) -> Vec<u8> {
190 hmac_sha256(&self.master_secret, data).to_vec()
191 }
192
193 pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
195 let expected = self.sign(data);
196 constant_time_eq(&expected, signature)
197 }
198
199 pub fn certificate_hex(&self) -> String {
201 hex::encode(&self.certificate)
202 }
203}
204
205fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
207 crate::crypto::constant_time_eq(a, b)
208}
209
210#[derive(Debug)]
216pub enum VaultError {
217 NoKey,
219 InvalidCredential(String),
220 Encryption,
222 Decryption,
224 Io(std::io::Error),
226 Corrupt(String),
228 Pager(String),
230}
231
232impl std::fmt::Display for VaultError {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Self::NoKey => write!(
236 f,
237 "no vault credential: set REDDB_CERTIFICATE or REDDB_VAULT_PASSPHRASE (or their _FILE variants)"
238 ),
239 Self::InvalidCredential(reason) => write!(f, "invalid vault credential: {reason}"),
240 Self::Encryption => write!(f, "vault encryption failed"),
241 Self::Decryption => write!(f, "vault decryption failed (wrong key or corrupt data)"),
242 Self::Io(err) => write!(f, "vault I/O error: {err}"),
243 Self::Corrupt(msg) => write!(f, "vault corrupt: {msg}"),
244 Self::Pager(msg) => write!(f, "vault pager error: {msg}"),
245 }
246 }
247}
248
249impl std::error::Error for VaultError {}
250
251impl From<VaultError> for AuthError {
252 fn from(err: VaultError) -> Self {
253 AuthError::Internal(err.to_string())
254 }
255}
256
257fn decode_certificate_hex(certificate_hex: &str) -> Result<Vec<u8>, VaultError> {
258 let certificate = hex::decode(certificate_hex.trim()).map_err(|_| VaultError::InvalidCredential("certificate must contain 64 hexadecimal characters; use REDDB_VAULT_PASSPHRASE for a password".into()))?;
259 if certificate.len() != 32 {
260 return Err(VaultError::InvalidCredential(
261 "certificate must contain exactly 32 bytes (64 hexadecimal characters)".into(),
262 ));
263 }
264 Ok(certificate)
265}
266
267#[derive(Default)]
275pub struct VaultState {
276 pub users: Vec<User>,
277 pub api_keys: Vec<(UserId, ApiKey)>,
281 pub bootstrapped: bool,
282 pub master_secret: Option<Vec<u8>>,
286 pub kv: std::collections::HashMap<String, String>,
290}
291
292impl std::fmt::Debug for VaultState {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 f.debug_struct("VaultState")
295 .field("users", &self.users.len())
296 .field("api_keys", &self.api_keys.len())
297 .field("bootstrapped", &self.bootstrapped)
298 .field(
299 "master_secret",
300 &self.master_secret.as_ref().map(|_| "<redacted>"),
301 )
302 .field("kv", &self.kv.len())
303 .finish()
304 }
305}
306
307impl VaultState {
308 pub fn serialize(&self) -> Vec<u8> {
310 let mut out = String::new();
311
312 if let Some(ref secret) = self.master_secret {
314 out.push_str(&format!("MASTER_SECRET:{}\n", hex::encode(secret)));
315 }
316
317 out.push_str(&format!("SEALED:{}\n", self.bootstrapped));
319
320 for user in &self.users {
333 let scram_field = match &user.scram_verifier {
334 Some(v) => format!(
335 "{}:{}:{}:{}",
336 hex::encode(&v.salt),
337 v.iter,
338 hex::encode(v.stored_key),
339 hex::encode(v.server_key),
340 ),
341 None => String::new(),
342 };
343 let tenant_field = user.tenant_id.clone().unwrap_or_default();
344 out.push_str(&format!(
345 "USER:{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
346 user.username,
347 user.password_hash,
348 user.role.as_str(),
349 user.enabled,
350 user.created_at,
351 user.updated_at,
352 scram_field,
353 tenant_field,
354 ));
355 }
356
357 for (owner, key) in &self.api_keys {
362 let tenant_field = owner.tenant.clone().unwrap_or_default();
363 let expires_field = key
364 .expires_at
365 .map(|deadline| deadline.to_string())
366 .unwrap_or_default();
367 out.push_str(&format!(
368 "KEY:{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
369 owner.username,
370 key.key,
371 key.name,
372 key.role.as_str(),
373 key.created_at,
374 tenant_field,
375 expires_field,
376 ));
377 }
378
379 for (k, v) in &self.kv {
381 out.push_str(&format!("KV:{}\t{}\n", k, hex::encode(v.as_bytes())));
382 }
383
384 out.into_bytes()
385 }
386
387 pub fn deserialize(data: &[u8]) -> Result<Self, VaultError> {
389 let text = std::str::from_utf8(data)
390 .map_err(|_| VaultError::Corrupt("payload is not valid UTF-8".into()))?;
391
392 let mut users = Vec::new();
393 let mut api_keys: Vec<(UserId, ApiKey)> = Vec::new();
394 let mut bootstrapped = false;
395 let mut master_secret: Option<Vec<u8>> = None;
396 let mut kv: std::collections::HashMap<String, String> = std::collections::HashMap::new();
397
398 for line in text.lines() {
399 if line.is_empty() {
400 continue;
401 }
402
403 if let Some(rest) = line.strip_prefix("MASTER_SECRET:") {
404 master_secret = Some(
405 hex::decode(rest)
406 .map_err(|_| VaultError::Corrupt("invalid MASTER_SECRET hex".into()))?,
407 );
408 } else if let Some(rest) = line.strip_prefix("SEALED:") {
409 bootstrapped = rest == "true";
410 } else if let Some(rest) = line.strip_prefix("USER:") {
411 let parts: Vec<&str> = rest.split('\t').collect();
412 if !(7..=9).contains(&parts.len()) {
416 return Err(VaultError::Corrupt(format!(
417 "USER line has {} fields, expected 7, 8, or 9",
418 parts.len()
419 )));
420 }
421 let role = Role::from_str(parts[2])
422 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[2])))?;
423 let enabled = parts[3] == "true";
424 let created_at: u128 = parts[4]
425 .parse()
426 .map_err(|_| VaultError::Corrupt("invalid created_at".into()))?;
427 let updated_at: u128 = parts[5]
428 .parse()
429 .map_err(|_| VaultError::Corrupt("invalid updated_at".into()))?;
430 let scram_verifier = parts
431 .get(6)
432 .map(|s| s.trim())
433 .filter(|s| !s.is_empty())
434 .map(parse_scram_field)
435 .transpose()?;
436 let tenant_id = parts
437 .get(7)
438 .map(|s| s.trim())
439 .filter(|s| !s.is_empty())
440 .map(|s| s.to_string());
441
442 users.push(User {
443 username: parts[0].to_string(),
444 tenant_id,
445 password_hash: parts[1].to_string(),
446 scram_verifier,
447 role,
448 api_keys: Vec::new(), created_at,
450 updated_at,
451 enabled,
452 });
453 } else if let Some(rest) = line.strip_prefix("KEY:") {
454 let parts: Vec<&str> = rest.split('\t').collect();
455 if !(5..=7).contains(&parts.len()) {
457 return Err(VaultError::Corrupt(format!(
458 "KEY line has {} fields, expected 5 to 7",
459 parts.len()
460 )));
461 }
462 let role = Role::from_str(parts[3])
463 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[3])))?;
464 let created_at: u128 = parts[4]
465 .parse()
466 .map_err(|_| VaultError::Corrupt("invalid key created_at".into()))?;
467 let tenant_id = parts
468 .get(5)
469 .map(|s| s.trim())
470 .filter(|s| !s.is_empty())
471 .map(|s| s.to_string());
472
473 api_keys.push((
474 UserId {
475 tenant: tenant_id,
476 username: parts[0].to_string(),
477 },
478 ApiKey {
479 key: parts[1].to_string(),
480 name: parts[2].to_string(),
481 role,
482 created_at,
483 expires_at: parts
484 .get(6)
485 .map(|s| s.trim())
486 .filter(|s| !s.is_empty())
487 .and_then(|s| s.parse::<u128>().ok()),
488 },
489 ));
490 } else if let Some(rest) = line.strip_prefix("KV:") {
491 let parts: Vec<&str> = rest.splitn(2, '\t').collect();
492 if parts.len() == 2 {
493 if let Ok(bytes) = hex::decode(parts[1]) {
494 if let Ok(value) = String::from_utf8(bytes) {
495 kv.insert(parts[0].to_string(), value);
496 }
497 }
498 }
499 } else {
500 }
502 }
503
504 for (owner, key) in &api_keys {
508 if let Some(user) = users
509 .iter_mut()
510 .find(|u| u.username == owner.username && u.tenant_id == owner.tenant)
511 {
512 user.api_keys.push(key.clone());
513 }
514 }
515
516 Ok(Self {
517 users,
518 api_keys,
519 bootstrapped,
520 master_secret,
521 kv,
522 })
523 }
524}
525
526pub struct Vault {
537 key: SecureKey,
538 salt: [u8; 16],
539 version: u8,
540}
541
542fn vault_argon2_params() -> Argon2Params {
545 Argon2Params {
546 m_cost: 16 * 1024, t_cost: 3,
548 p: 1,
549 tag_len: 32,
550 }
551}
552
553impl Vault {
554 pub fn has_saved_state(pager: &Pager) -> bool {
556 pager
557 .read_page_no_checksum(VAULT_HEADER_PAGE)
558 .ok()
559 .map(|page| {
560 let content = page.content();
561 content.len() >= VAULT_MAGIC_SIZE && &content[0..VAULT_MAGIC_SIZE] == VAULT_MAGIC
562 })
563 .unwrap_or(false)
564 }
565
566 pub fn open(pager: &Pager) -> Result<Self, VaultError> {
575 match (
576 crate::utils::env_with_file_fallback("REDDB_CERTIFICATE"),
577 crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE"),
578 ) {
579 (Some(_), Some(_)) => Err(VaultError::InvalidCredential(
580 "set either REDDB_CERTIFICATE or REDDB_VAULT_PASSPHRASE, not both".into(),
581 )),
582 (Some(certificate), None) => Self::with_certificate(pager, &certificate),
583 (None, Some(passphrase)) => Self::with_passphrase(pager, &passphrase),
584 (None, None) => Err(VaultError::NoKey),
585 }
586 }
587
588 fn salt_for_open(pager: &Pager) -> Result<[u8; 16], VaultError> {
589 match read_vault_salt_from_pager(pager) {
591 Ok(s) => Ok(s),
592 Err(_) => {
593 let mut salt = [0u8; 16];
595 let mut buf = [0u8; 16];
596 os_random::fill_bytes(&mut buf)
597 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
598 salt.copy_from_slice(&buf);
599 Ok(salt)
600 }
601 }
602 }
603
604 pub fn with_certificate(pager: &Pager, certificate_hex: &str) -> Result<Self, VaultError> {
610 Self::check_credential_format(pager, VAULT_VERSION)?;
611 let certificate = decode_certificate_hex(certificate_hex)?;
612
613 let key = KeyPair::vault_key_from_certificate(&certificate);
614
615 let salt = Self::salt_for_open(pager)?;
616
617 Ok(Self {
618 key,
619 salt,
620 version: VAULT_VERSION,
621 })
622 }
623
624 pub fn with_passphrase(pager: &Pager, passphrase: &str) -> Result<Self, VaultError> {
627 Self::check_credential_format(pager, VAULT_PASSPHRASE_VERSION)?;
628 let salt = Self::salt_for_open(pager)?;
629 let key = Self::passphrase_key(passphrase, &salt)?;
630 Ok(Self {
631 key,
632 salt,
633 version: VAULT_PASSPHRASE_VERSION,
634 })
635 }
636
637 fn passphrase_key(passphrase: &str, salt: &[u8; 16]) -> Result<SecureKey, VaultError> {
638 let bytes = reddb_crypto::vault::passphrase_key(passphrase, salt)
639 .map_err(VaultError::InvalidCredential)?;
640 Ok(SecureKey::new(bytes.as_ref()))
641 }
642
643 fn check_credential_format(pager: &Pager, expected: u8) -> Result<(), VaultError> {
644 if Self::has_saved_state(pager) {
645 let page = pager
646 .read_page_no_checksum(VAULT_HEADER_PAGE)
647 .map_err(|err| VaultError::Pager(err.to_string()))?;
648 let version = page.content()[4];
649 if (version == VAULT_VERSION || version == VAULT_PASSPHRASE_VERSION)
650 && version != expected
651 {
652 return Err(VaultError::InvalidCredential(
653 if version == VAULT_PASSPHRASE_VERSION {
654 "this vault requires a passphrase"
655 } else {
656 "this vault requires a certificate"
657 }
658 .into(),
659 ));
660 }
661 }
662 Ok(())
663 }
664
665 pub fn from_env(pager: &Pager) -> Result<Self, VaultError> {
669 Self::open(pager)
670 }
671
672 pub fn with_certificate_bytes(pager: &Pager, certificate: &[u8]) -> Result<Self, VaultError> {
677 Self::check_credential_format(pager, VAULT_VERSION)?;
678 if certificate.len() != 32 {
679 return Err(VaultError::InvalidCredential(
680 "certificate must contain 32 bytes".into(),
681 ));
682 }
683 let key = KeyPair::vault_key_from_certificate(certificate);
684
685 let salt = match read_vault_salt_from_pager(pager) {
686 Ok(s) => s,
687 Err(_) => {
688 let mut s = [0u8; 16];
689 os_random::fill_bytes(&mut s)
690 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
691 s
692 }
693 };
694
695 Ok(Self {
696 key,
697 salt,
698 version: VAULT_VERSION,
699 })
700 }
701
702 pub fn seal_logical_export(&self, state: &VaultState) -> Result<String, VaultError> {
708 let plaintext = state.serialize();
709 let mut nonce = [0u8; NONCE_SIZE];
710 os_random::fill_bytes(&mut nonce)
711 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
712
713 let key_bytes: &[u8] = self.key.as_bytes();
714 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
715 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_LOGICAL_EXPORT_AAD, &plaintext);
716
717 Ok(reddb_file::encode_vault_logical_export(
720 &self.salt,
721 &nonce,
722 &ciphertext,
723 ))
724 }
725
726 pub fn unseal_logical_export(blob_hex: &str) -> Result<VaultState, VaultError> {
729 match (
730 crate::utils::env_with_file_fallback("REDDB_CERTIFICATE"),
731 crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE"),
732 ) {
733 (Some(_), Some(_)) => Err(VaultError::InvalidCredential(
734 "set a single vault credential".into(),
735 )),
736 (Some(certificate), None) => {
737 Self::unseal_logical_export_with_certificate(blob_hex, &certificate)
738 }
739 (None, Some(passphrase)) => {
740 Self::unseal_logical_export_with_passphrase(blob_hex, &passphrase)
741 }
742 _ => Err(VaultError::NoKey),
743 }
744 }
745
746 pub fn unseal_logical_export_with_certificate(
748 blob_hex: &str,
749 certificate_hex: &str,
750 ) -> Result<VaultState, VaultError> {
751 let (_salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
752 let certificate = decode_certificate_hex(certificate_hex)?;
753 let key = KeyPair::vault_key_from_certificate(&certificate);
754 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
755 }
756
757 pub fn unseal_logical_export_with_passphrase(
758 blob_hex: &str,
759 passphrase: &str,
760 ) -> Result<VaultState, VaultError> {
761 let (salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
762 let key = Self::passphrase_key(passphrase, &salt)?;
763 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
764 }
765
766 fn decode_logical_export(
767 blob_hex: &str,
768 ) -> Result<([u8; VAULT_SALT_SIZE], [u8; NONCE_SIZE], Vec<u8>), VaultError> {
769 let env = reddb_file::decode_vault_logical_export(blob_hex)
772 .map_err(|e| VaultError::Corrupt(e.to_string()))?;
773 Ok((env.salt, env.nonce, env.ciphertext))
774 }
775
776 fn decrypt_logical_export(
777 key: &SecureKey,
778 nonce: &[u8; NONCE_SIZE],
779 ciphertext: &[u8],
780 ) -> Result<VaultState, VaultError> {
781 let key_bytes: &[u8] = key.as_bytes();
782 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
783 let plaintext = aes256_gcm_decrypt(key_arr, nonce, VAULT_LOGICAL_EXPORT_AAD, ciphertext)
784 .map_err(|_| VaultError::Decryption)?;
785 VaultState::deserialize(&plaintext)
786 }
787
788 pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
802 let plaintext = state.serialize();
803
804 let mut nonce = [0u8; NONCE_SIZE];
806 os_random::fill_bytes(&mut nonce)
807 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
808
809 let key_bytes: &[u8] = self.key.as_bytes();
810 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
811 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_AAD, &plaintext);
812 let cipher_total = ciphertext.len();
816 let payload_len = (NONCE_SIZE + cipher_total) as u32; let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
824 let overflow = cipher_total.saturating_sub(header_chunk_len);
825 let chain_count = overflow.div_ceil(VAULT_DATA_CIPHER_CAPACITY);
826
827 while pager
841 .page_count()
842 .map_err(|e| VaultError::Pager(e.to_string()))?
843 <= VAULT_HEADER_PAGE
844 {
845 pager
846 .allocate_page(PageType::Vault)
847 .map_err(|e| VaultError::Pager(format!("reserve vault slot: {e}")))?;
848 }
849
850 let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
860
861 let mut new_chain: Vec<u32> = Vec::with_capacity(chain_count);
868 for _ in 0..chain_count {
869 let page = pager
870 .allocate_page(PageType::Vault)
871 .map_err(|e| VaultError::Pager(format!("allocate vault data page: {e}")))?;
872 new_chain.push(page.page_id());
873 }
874
875 let mut cursor = header_chunk_len;
882 for i in 0..chain_count {
883 let next_id = if i + 1 < chain_count {
884 new_chain[i + 1]
885 } else {
886 0
887 };
888 let take = (cipher_total - cursor).min(VAULT_DATA_CIPHER_CAPACITY);
889 let frag = &ciphertext[cursor..cursor + take];
890 self.write_data_page(pager, new_chain[i], next_id, frag)?;
891 cursor += take;
892 }
893 debug_assert_eq!(cursor, cipher_total, "ciphertext spill accounting mismatch");
894
895 let first_data_page = new_chain.first().copied().unwrap_or(0);
903 self.write_header_page(
904 pager,
905 &nonce,
906 payload_len,
907 chain_count as u32,
908 first_data_page,
909 &ciphertext[..header_chunk_len],
910 )?;
911
912 pager
917 .flush()
918 .map_err(|e| VaultError::Pager(e.to_string()))?;
919
920 for &id in old_chain.iter() {
925 pager
926 .free_page(id)
927 .map_err(|e| VaultError::Pager(format!("free old vault page {id}: {e}")))?;
928 }
929
930 Ok(())
931 }
932
933 pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
937 let page = match pager.read_page_no_checksum(VAULT_HEADER_PAGE) {
939 Ok(p) => p,
940 Err(_) => return Ok(None),
941 };
942
943 let page_content = page.content();
944
945 if page_content.len() < VAULT_HEADER_META_SIZE {
946 return Ok(None);
947 }
948 if &page_content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
949 return Ok(None); }
951
952 let version = page_content[4];
953 if version == VAULT_LEGACY_VERSION {
954 return Err(VaultError::Corrupt(
958 "vault was bootstrapped with the legacy 2-page format \
959 (pre-RedDB v0.3); re-bootstrap with `red bootstrap` to upgrade"
960 .to_string(),
961 ));
962 }
963 if version != self.version {
964 return Err(VaultError::Corrupt(format!(
965 "vault credential format mismatch: stored {version}, opened {}",
966 self.version
967 )));
968 }
969
970 let payload_len = u32::from_le_bytes(
972 page_content[21..25]
973 .try_into()
974 .map_err(|_| VaultError::Corrupt("bad payload length bytes".into()))?,
975 ) as usize;
976
977 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
978 let nonce: [u8; NONCE_SIZE] = page_content[nonce_start..nonce_start + NONCE_SIZE]
979 .try_into()
980 .map_err(|_| VaultError::Corrupt("bad nonce".into()))?;
981
982 let chain_count_off = nonce_start + NONCE_SIZE;
983 let chain_count = u32::from_le_bytes(
984 page_content[chain_count_off..chain_count_off + 4]
985 .try_into()
986 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
987 ) as usize;
988 let first_id_off = chain_count_off + 4;
989 let mut next_id = u32::from_le_bytes(
990 page_content[first_id_off..first_id_off + 4]
991 .try_into()
992 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
993 );
994
995 if payload_len < NONCE_SIZE {
996 return Err(VaultError::Corrupt("payload too short for nonce".into()));
997 }
998 let cipher_total = payload_len - NONCE_SIZE;
999
1000 let mut cipher = Vec::with_capacity(cipher_total);
1002 let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
1003 let header_cipher_start = VAULT_HEADER_META_SIZE;
1004 cipher.extend_from_slice(
1005 &page_content[header_cipher_start..header_cipher_start + header_chunk_len],
1006 );
1007
1008 let mut hops = 0usize;
1010 while cipher.len() < cipher_total {
1014 if hops >= chain_count {
1015 return Err(VaultError::Corrupt(format!(
1016 "vault chain shorter than declared: {} hops, expected {}",
1017 hops, chain_count
1018 )));
1019 }
1020 if next_id == 0 {
1021 return Err(VaultError::Corrupt(
1022 "vault chain ends prematurely (next_id == 0)".to_string(),
1023 ));
1024 }
1025
1026 let dp = pager
1027 .read_page_no_checksum(next_id)
1028 .map_err(|e| VaultError::Pager(format!("vault data page {next_id}: {e}")))?;
1029 let dp_content = dp.content();
1030 if dp_content.len() < VAULT_DATA_PREFIX_SIZE {
1031 return Err(VaultError::Corrupt(format!(
1032 "vault data page {next_id} truncated"
1033 )));
1034 }
1035 if &dp_content[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC {
1036 return Err(VaultError::Corrupt(format!(
1037 "vault data page {next_id} has bad magic"
1038 )));
1039 }
1040 let np = u32::from_le_bytes(
1041 dp_content[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1042 .try_into()
1043 .map_err(|_| VaultError::Corrupt("bad next_page_id bytes".into()))?,
1044 );
1045 let take = (cipher_total - cipher.len()).min(VAULT_DATA_CIPHER_CAPACITY);
1046 let frag_start = VAULT_DATA_PREFIX_SIZE;
1047 cipher.extend_from_slice(&dp_content[frag_start..frag_start + take]);
1048
1049 next_id = np;
1050 hops += 1;
1051 }
1052
1053 if cipher.len() != cipher_total {
1054 return Err(VaultError::Corrupt(format!(
1055 "vault truncated: expected {} cipher bytes, got {}",
1056 cipher_total,
1057 cipher.len()
1058 )));
1059 }
1060 if hops != chain_count {
1061 return Err(VaultError::Corrupt(format!(
1062 "vault chain length mismatch: walked {} pages, header says {}",
1063 hops, chain_count
1064 )));
1065 }
1066
1067 let key_bytes: &[u8] = self.key.as_bytes();
1069 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
1070 let plaintext = aes256_gcm_decrypt(key_arr, &nonce, VAULT_AAD, &cipher)
1071 .map_err(|_| VaultError::Decryption)?;
1072
1073 let state = VaultState::deserialize(&plaintext)?;
1074 Ok(Some(state))
1075 }
1076
1077 fn read_existing_chain_ids(&self, pager: &Pager) -> Result<Vec<u32>, VaultError> {
1084 let header = pager
1085 .read_page_no_checksum(VAULT_HEADER_PAGE)
1086 .map_err(|e| VaultError::Pager(e.to_string()))?;
1087 let content = header.content();
1088 if content.len() < VAULT_HEADER_META_SIZE {
1089 return Ok(Vec::new());
1090 }
1091 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1092 return Ok(Vec::new());
1093 }
1094 let version = content[4];
1095 if version != VAULT_VERSION && version != VAULT_PASSPHRASE_VERSION {
1096 return Ok(Vec::new());
1100 }
1101 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
1102 let chain_count_off = nonce_start + NONCE_SIZE;
1103 let chain_count = u32::from_le_bytes(
1104 content[chain_count_off..chain_count_off + 4]
1105 .try_into()
1106 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1107 ) as usize;
1108 let first_id_off = chain_count_off + 4;
1109 let mut id = u32::from_le_bytes(
1110 content[first_id_off..first_id_off + 4]
1111 .try_into()
1112 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1113 );
1114
1115 let mut out = Vec::with_capacity(chain_count);
1116 let mut hops = 0usize;
1117 while id != 0 && hops < chain_count {
1118 out.push(id);
1119 match pager.read_page_no_checksum(id) {
1122 Ok(dp) => {
1123 let dc = dp.content();
1124 if dc.len() < VAULT_DATA_PREFIX_SIZE
1125 || &dc[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC
1126 {
1127 break;
1128 }
1129 id = u32::from_le_bytes(
1130 dc[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1131 .try_into()
1132 .map_err(|_| VaultError::Corrupt("bad next_id".into()))?,
1133 );
1134 }
1135 Err(_) => break,
1136 }
1137 hops += 1;
1138 }
1139 Ok(out)
1140 }
1141
1142 fn write_header_page(
1146 &self,
1147 pager: &Pager,
1148 nonce: &[u8; NONCE_SIZE],
1149 payload_len: u32,
1150 chain_count: u32,
1151 first_data_page_id: u32,
1152 cipher_fragment: &[u8],
1153 ) -> Result<(), VaultError> {
1154 debug_assert!(cipher_fragment.len() <= VAULT_HEADER_CIPHER_CAPACITY);
1155
1156 let mut page = Page::new(PageType::Vault, VAULT_HEADER_PAGE);
1157 let bytes = page.as_bytes_mut();
1158 let mut off = HEADER_SIZE;
1159
1160 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_MAGIC);
1161 off += VAULT_MAGIC_SIZE;
1162
1163 bytes[off] = self.version;
1164 off += VAULT_VERSION_SIZE;
1165
1166 bytes[off..off + VAULT_SALT_SIZE].copy_from_slice(&self.salt);
1167 off += VAULT_SALT_SIZE;
1168
1169 bytes[off..off + 4].copy_from_slice(&payload_len.to_le_bytes());
1170 off += VAULT_PAYLOAD_LEN_SIZE;
1171
1172 bytes[off..off + NONCE_SIZE].copy_from_slice(nonce);
1173 off += NONCE_SIZE;
1174
1175 bytes[off..off + 4].copy_from_slice(&chain_count.to_le_bytes());
1176 off += VAULT_CHAIN_COUNT_SIZE;
1177
1178 bytes[off..off + 4].copy_from_slice(&first_data_page_id.to_le_bytes());
1179 off += VAULT_FIRST_PAGE_ID_SIZE;
1180
1181 debug_assert_eq!(off, HEADER_SIZE + VAULT_HEADER_META_SIZE);
1182
1183 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1184
1185 pager
1186 .write_page_no_checksum(VAULT_HEADER_PAGE, page)
1187 .map_err(|e| VaultError::Pager(e.to_string()))?;
1188 Ok(())
1189 }
1190
1191 fn write_data_page(
1193 &self,
1194 pager: &Pager,
1195 page_id: u32,
1196 next_page_id: u32,
1197 cipher_fragment: &[u8],
1198 ) -> Result<(), VaultError> {
1199 debug_assert!(cipher_fragment.len() <= VAULT_DATA_CIPHER_CAPACITY);
1200
1201 let mut page = Page::new(PageType::Vault, page_id);
1202 let bytes = page.as_bytes_mut();
1203 let mut off = HEADER_SIZE;
1204
1205 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_DATA_MAGIC);
1206 off += VAULT_MAGIC_SIZE;
1207
1208 bytes[off..off + 4].copy_from_slice(&next_page_id.to_le_bytes());
1209 off += 4;
1210
1211 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1212
1213 pager
1214 .write_page_no_checksum(page_id, page)
1215 .map_err(|e| VaultError::Pager(e.to_string()))?;
1216 Ok(())
1217 }
1218}
1219
1220fn parse_scram_field(field: &str) -> Result<crate::auth::scram::ScramVerifier, VaultError> {
1227 let parts: Vec<&str> = field.split(':').collect();
1228 if parts.len() != 4 {
1229 return Err(VaultError::Corrupt(format!(
1230 "SCRAM verifier has {} segments, expected 4",
1231 parts.len()
1232 )));
1233 }
1234 let salt =
1235 hex::decode(parts[0]).map_err(|_| VaultError::Corrupt("invalid SCRAM salt hex".into()))?;
1236 let iter: u32 = parts[1]
1237 .parse()
1238 .map_err(|_| VaultError::Corrupt("invalid SCRAM iter".into()))?;
1239 if iter < crate::auth::scram::MIN_ITER {
1240 return Err(VaultError::Corrupt(format!(
1241 "SCRAM iter {} below minimum {}",
1242 iter,
1243 crate::auth::scram::MIN_ITER
1244 )));
1245 }
1246 let stored_vec = hex::decode(parts[2])
1247 .map_err(|_| VaultError::Corrupt("invalid SCRAM stored_key hex".into()))?;
1248 let server_vec = hex::decode(parts[3])
1249 .map_err(|_| VaultError::Corrupt("invalid SCRAM server_key hex".into()))?;
1250 let stored_key: [u8; 32] = stored_vec
1251 .try_into()
1252 .map_err(|_| VaultError::Corrupt("SCRAM stored_key must be 32 bytes".into()))?;
1253 let server_key: [u8; 32] = server_vec
1254 .try_into()
1255 .map_err(|_| VaultError::Corrupt("SCRAM server_key must be 32 bytes".into()))?;
1256 Ok(crate::auth::scram::ScramVerifier {
1257 salt,
1258 iter,
1259 stored_key,
1260 server_key,
1261 })
1262}
1263
1264fn read_vault_salt_from_pager(pager: &Pager) -> Result<[u8; 16], VaultError> {
1271 let page = pager
1272 .read_page_no_checksum(VAULT_HEADER_PAGE)
1273 .map_err(|e| VaultError::Pager(format!("vault page read: {e}")))?;
1274
1275 let content = page.content();
1276 if content.len() < VAULT_HEADER_PREAMBLE_SIZE {
1277 return Err(VaultError::Corrupt("vault page too short".into()));
1278 }
1279 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1280 return Err(VaultError::Corrupt("bad magic bytes".into()));
1281 }
1282
1283 let mut salt = [0u8; VAULT_SALT_SIZE];
1284 salt.copy_from_slice(&content[5..21]);
1285 Ok(salt)
1286}
1287
1288#[cfg(test)]
1293mod tests {
1294 use super::*;
1295 use crate::auth::{now_ms, ApiKey, Role, User};
1296 use crate::storage::engine::pager::PagerConfig;
1297
1298 fn sample_state() -> VaultState {
1299 let now = now_ms();
1300 VaultState {
1301 users: vec![
1302 User {
1303 username: "alice".into(),
1304 tenant_id: None,
1305 password_hash: "argon2id$aabbccdd$eeff0011".into(),
1306 scram_verifier: None,
1307 role: Role::Admin,
1308 api_keys: vec![ApiKey {
1309 key: "rk_abc123".into(),
1310 name: "ci-token".into(),
1311 role: Role::Write,
1312 created_at: now,
1313 expires_at: None,
1314 }],
1315 created_at: now,
1316 updated_at: now,
1317 enabled: true,
1318 },
1319 User {
1320 username: "bob".into(),
1321 tenant_id: None,
1322 password_hash: "argon2id$11223344$55667788".into(),
1323 scram_verifier: None,
1324 role: Role::Read,
1325 api_keys: vec![],
1326 created_at: now,
1327 updated_at: now,
1328 enabled: false,
1329 },
1330 ],
1331 api_keys: vec![(
1332 UserId::platform("alice"),
1333 ApiKey {
1334 key: "rk_abc123".into(),
1335 name: "ci-token".into(),
1336 role: Role::Write,
1337 created_at: now,
1338 expires_at: None,
1339 },
1340 )],
1341 bootstrapped: true,
1342 master_secret: None,
1343 kv: std::collections::HashMap::new(),
1344 }
1345 }
1346
1347 fn temp_pager() -> (Pager, std::path::PathBuf) {
1349 use std::sync::atomic::{AtomicU64, Ordering};
1350 static COUNTER: AtomicU64 = AtomicU64::new(0);
1351 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1352 let tmp_dir =
1353 std::env::temp_dir().join(format!("reddb_vault_test_{}_{}", std::process::id(), id));
1354 std::fs::create_dir_all(&tmp_dir).unwrap();
1355 let db_path = tmp_dir.join("test.rdb");
1356 let pager = Pager::open(&db_path, PagerConfig::default()).unwrap();
1357 (pager, tmp_dir)
1358 }
1359
1360 fn env_lock() -> &'static std::sync::Mutex<()> {
1361 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1362 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1363 }
1364
1365 fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
1366 unsafe {
1367 match value {
1368 Some(value) => std::env::set_var(name, value),
1369 None => std::env::remove_var(name),
1370 }
1371 }
1372 }
1373
1374 #[test]
1375 fn portable_vault_preserves_native_auth_payload_and_passphrase_contract() {
1376 use reddb_engine::query::local_database::LocalDatabase;
1377 use reddb_engine::storage::{
1378 entity::{EntityId, RowData, UnifiedEntity},
1379 manager::ManagerConfig,
1380 resident_file::EmbeddedResidentStore,
1381 store_wal::StoreWalAction,
1382 };
1383 use reddb_file::{OpenMode, StdVfs, Vfs};
1384
1385 let (pager, directory) = temp_pager();
1386 let password = "native portable interop password";
1387 let native = Vault::with_passphrase(&pager, password).expect("native key");
1388 let mut state = sample_state();
1389 state.master_secret = Some(vec![42; 32]);
1390 let native_envelope = native.seal_logical_export(&state).expect("native envelope");
1391 let path = directory.join("portable.rdb");
1392 let mut mode = OpenMode::create_new();
1393 mode.read = true;
1394 let file = StdVfs.open(&path, mode).expect("private file");
1395 let mut database = LocalDatabase::create(file, &path, 0).expect("portable database");
1396 database
1397 .vault_unlock(password, true)
1398 .expect("initialize portable collection");
1399 let mut store = EmbeddedResidentStore::open(
1400 database.into_inner(),
1401 &path,
1402 ManagerConfig::default(),
1403 false,
1404 0,
1405 )
1406 .expect("physical store");
1407 let mut entity =
1408 UnifiedEntity::table_row(EntityId::new(1), "red.local.vault", 1, Vec::new());
1409 entity.data = reddb_engine::storage::entity::EntityData::Row(RowData::with_names(
1410 vec![reddb_types::Value::Secret(
1411 hex::decode(native_envelope).expect("hex"),
1412 )],
1413 vec!["value".into()],
1414 ));
1415 store
1416 .publish_actions(&[StoreWalAction::upsert_entity(
1417 "red.local.vault",
1418 &entity,
1419 None,
1420 reddb_file::STORE_VERSION_CURRENT,
1421 )])
1422 .expect("import native envelope");
1423 let mut database =
1424 LocalDatabase::open(store.into_inner(), &path).expect("open imported file");
1425 let key = database
1426 .vault_unlock(password, false)
1427 .expect("native passphrase in portable runtime");
1428 database
1429 .vault_put(&key, "connection:admin", Some("new credential"))
1430 .expect("portable mutation");
1431 let store = EmbeddedResidentStore::open(
1432 database.into_inner(),
1433 &path,
1434 ManagerConfig::default(),
1435 false,
1436 0,
1437 )
1438 .expect("read back");
1439 let (entity, _) = store
1440 .get("red.local.vault", EntityId::new(1))
1441 .expect("record")
1442 .expect("Vault exists");
1443 let Some(reddb_types::Value::Secret(blob)) = entity
1444 .data
1445 .as_row()
1446 .and_then(|row| row.get_by_name("value"))
1447 else {
1448 panic!("sealed Vault")
1449 };
1450 let restored = Vault::unseal_logical_export_with_passphrase(&hex::encode(blob), password)
1451 .expect("portable envelope in native runtime");
1452 assert_eq!(restored.master_secret, state.master_secret);
1453 assert_eq!(restored.bootstrapped, state.bootstrapped);
1454 assert_eq!(restored.users.len(), state.users.len());
1455 for (actual, expected) in restored.users.iter().zip(&state.users) {
1456 assert_eq!(actual.username, expected.username);
1457 assert_eq!(actual.password_hash, expected.password_hash);
1458 assert_eq!(actual.tenant_id, expected.tenant_id);
1459 }
1460 assert_eq!(restored.api_keys.len(), state.api_keys.len());
1461 assert_eq!(
1462 restored.kv.get("connection:admin").map(String::as_str),
1463 Some("new credential")
1464 );
1465 drop(store);
1466 drop(native);
1467 drop(pager);
1468 std::fs::remove_dir_all(directory).expect("remove test files");
1469 }
1470
1471 #[test]
1472 fn test_vault_state_serialize_deserialize_roundtrip() {
1473 let state = sample_state();
1474 let serialized = state.serialize();
1475 let text = std::str::from_utf8(&serialized).unwrap();
1476
1477 assert!(text.contains("SEALED:true"));
1479 assert!(text.contains("USER:alice\t"));
1480 assert!(text.contains("USER:bob\t"));
1481 assert!(text.contains("KEY:alice\trk_abc123\t"));
1482
1483 let restored = VaultState::deserialize(&serialized).unwrap();
1485 assert!(restored.bootstrapped);
1486 assert_eq!(restored.users.len(), 2);
1487
1488 let alice = restored
1489 .users
1490 .iter()
1491 .find(|u| u.username == "alice")
1492 .unwrap();
1493 assert_eq!(alice.role, Role::Admin);
1494 assert!(alice.enabled);
1495 assert_eq!(alice.password_hash, "argon2id$aabbccdd$eeff0011");
1496 assert_eq!(alice.api_keys.len(), 1);
1497 assert_eq!(alice.api_keys[0].key, "rk_abc123");
1498 assert_eq!(alice.api_keys[0].name, "ci-token");
1499 assert_eq!(alice.api_keys[0].role, Role::Write);
1500
1501 let bob = restored.users.iter().find(|u| u.username == "bob").unwrap();
1502 assert_eq!(bob.role, Role::Read);
1503 assert!(!bob.enabled);
1504 assert!(bob.api_keys.is_empty());
1505
1506 assert_eq!(restored.api_keys.len(), 1);
1507 assert_eq!(restored.api_keys[0].0.username, "alice");
1508 assert!(restored.api_keys[0].0.tenant.is_none());
1509 }
1510
1511 #[test]
1512 fn test_vault_state_empty() {
1513 let state = VaultState {
1514 users: vec![],
1515 api_keys: vec![],
1516 bootstrapped: false,
1517 master_secret: None,
1518 kv: std::collections::HashMap::new(),
1519 };
1520 let serialized = state.serialize();
1521 let restored = VaultState::deserialize(&serialized).unwrap();
1522 assert!(!restored.bootstrapped);
1523 assert!(restored.users.is_empty());
1524 assert!(restored.api_keys.is_empty());
1525 }
1526
1527 #[test]
1528 fn test_vault_state_deserialize_invalid_utf8() {
1529 let bad_data = vec![0xFF, 0xFE, 0xFD];
1530 let result = VaultState::deserialize(&bad_data);
1531 assert!(result.is_err());
1532 }
1533
1534 #[test]
1535 fn test_vault_state_deserialize_bad_user_line() {
1536 let bad = b"USER:only_two\tfields\n";
1537 let result = VaultState::deserialize(bad);
1538 assert!(result.is_err());
1539 }
1540
1541 #[test]
1542 fn test_vault_state_deserialize_bad_key_line() {
1543 let bad = b"KEY:too\tfew\n";
1544 let result = VaultState::deserialize(bad);
1545 assert!(result.is_err());
1546 }
1547
1548 #[test]
1549 fn test_vault_state_deserialize_unknown_line_skipped() {
1550 let data = b"SEALED:false\nFUTURE:some_data\n";
1551 let result = VaultState::deserialize(data).unwrap();
1552 assert!(!result.bootstrapped);
1553 }
1554
1555 #[test]
1556 fn test_vault_pager_save_load_roundtrip() {
1557 let (pager, tmp_dir) = temp_pager();
1558
1559 let kp = KeyPair::generate();
1560 let certificate = kp.certificate_hex();
1561 let vault = Vault::with_certificate(&pager, &certificate).unwrap();
1562
1563 let loaded = vault.load(&pager).unwrap();
1565 assert!(loaded.is_none());
1566
1567 let state = sample_state();
1569 vault.save(&pager, &state).unwrap();
1570
1571 let restored = vault.load(&pager).unwrap().unwrap();
1573 assert!(restored.bootstrapped);
1574 assert_eq!(restored.users.len(), 2);
1575 assert_eq!(restored.api_keys.len(), 1);
1576
1577 let alice = restored
1578 .users
1579 .iter()
1580 .find(|u| u.username == "alice")
1581 .unwrap();
1582 assert_eq!(alice.role, Role::Admin);
1583 assert_eq!(alice.api_keys.len(), 1);
1584
1585 let vault2 = Vault::with_certificate(&pager, &certificate).unwrap();
1587 let restored2 = vault2.load(&pager).unwrap().unwrap();
1588 assert!(restored2.bootstrapped);
1589 assert_eq!(restored2.users.len(), 2);
1590
1591 drop(pager);
1593 let _ = std::fs::remove_dir_all(&tmp_dir);
1594 }
1595
1596 #[test]
1597 fn test_vault_wrong_key_fails_decryption() {
1598 let (pager, tmp_dir) = temp_pager();
1599
1600 let kp = KeyPair::generate();
1602 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1603 let state = VaultState {
1604 users: vec![],
1605 api_keys: vec![],
1606 bootstrapped: true,
1607 master_secret: None,
1608 kv: std::collections::HashMap::new(),
1609 };
1610 vault.save(&pager, &state).unwrap();
1611
1612 let wrong = KeyPair::generate();
1614 let vault2 = Vault::with_certificate_bytes(&pager, &wrong.certificate).unwrap();
1615 let result = vault2.load(&pager);
1616
1617 assert!(result.is_err());
1618
1619 drop(pager);
1621 let _ = std::fs::remove_dir_all(&tmp_dir);
1622 }
1623
1624 #[test]
1625 fn test_vault_no_key_error() {
1626 let (pager, tmp_dir) = temp_pager();
1627
1628 let _guard = env_lock().lock().unwrap();
1629 let old = std::env::var_os("REDDB_CERTIFICATE");
1630 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1631 unsafe {
1632 std::env::remove_var("REDDB_CERTIFICATE");
1633 std::env::remove_var("REDDB_CERTIFICATE_FILE");
1634 }
1635 let result = Vault::open(&pager);
1636 restore_env_var("REDDB_CERTIFICATE", old);
1637 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1638 assert!(matches!(result, Err(VaultError::NoKey)));
1639
1640 drop(pager);
1642 let _ = std::fs::remove_dir_all(&tmp_dir);
1643 }
1644
1645 #[test]
1646 fn test_vault_open_uses_certificate_env() {
1647 let (pager, tmp_dir) = temp_pager();
1648
1649 let _guard = env_lock().lock().unwrap();
1650 let old = std::env::var_os("REDDB_CERTIFICATE");
1651 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1652 let kp = KeyPair::generate();
1653 unsafe {
1654 std::env::set_var("REDDB_CERTIFICATE", kp.certificate_hex());
1655 std::env::remove_var("REDDB_CERTIFICATE_FILE");
1656 }
1657
1658 let vault = Vault::open(&pager).unwrap();
1660 let state = VaultState {
1661 users: vec![],
1662 api_keys: vec![],
1663 bootstrapped: false,
1664 master_secret: None,
1665 kv: std::collections::HashMap::new(),
1666 };
1667 vault.save(&pager, &state).unwrap();
1668
1669 let vault2 = Vault::open(&pager).unwrap();
1671 let loaded = vault2.load(&pager).unwrap().unwrap();
1672 assert!(!loaded.bootstrapped);
1673
1674 restore_env_var("REDDB_CERTIFICATE", old);
1675 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1676 drop(pager);
1677 let _ = std::fs::remove_dir_all(&tmp_dir);
1678 }
1679
1680 #[test]
1681 fn test_vault_open_uses_certificate_file_env() {
1682 let (pager, tmp_dir) = temp_pager();
1683
1684 let _guard = env_lock().lock().unwrap();
1685 let old = std::env::var_os("REDDB_CERTIFICATE");
1686 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1687 let kp = KeyPair::generate();
1688 let cert_path = tmp_dir.join("certificate");
1689 std::fs::write(&cert_path, format!("{}\n", kp.certificate_hex())).unwrap();
1690 unsafe {
1691 std::env::remove_var("REDDB_CERTIFICATE");
1692 std::env::set_var("REDDB_CERTIFICATE_FILE", &cert_path);
1693 }
1694
1695 let vault = Vault::open(&pager).unwrap();
1696 let state = VaultState {
1697 users: vec![],
1698 api_keys: vec![],
1699 bootstrapped: false,
1700 master_secret: None,
1701 kv: std::collections::HashMap::new(),
1702 };
1703 vault.save(&pager, &state).unwrap();
1704
1705 let vault2 = Vault::open(&pager).unwrap();
1706 let loaded = vault2.load(&pager).unwrap().unwrap();
1707 assert!(!loaded.bootstrapped);
1708
1709 restore_env_var("REDDB_CERTIFICATE", old);
1710 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1711 drop(pager);
1712 let _ = std::fs::remove_dir_all(&tmp_dir);
1713 }
1714
1715 #[test]
1720 fn test_keypair_generate_deterministic_certificate() {
1721 let kp = KeyPair::generate();
1722 assert_eq!(kp.master_secret.len(), 32);
1723 assert_eq!(kp.certificate.len(), 32);
1724
1725 let kp2 = KeyPair::from_master_secret(kp.master_secret.clone());
1727 assert_eq!(kp.certificate, kp2.certificate);
1728 }
1729
1730 #[test]
1731 fn test_keypair_sign_verify() {
1732 let kp = KeyPair::generate();
1733 let data = b"session:abc123";
1734 let sig = kp.sign(data);
1735 assert!(kp.verify(data, &sig));
1736
1737 assert!(!kp.verify(b"session:wrong", &sig));
1739
1740 let mut bad_sig = sig.clone();
1742 bad_sig[0] ^= 0xFF;
1743 assert!(!kp.verify(data, &bad_sig));
1744 }
1745
1746 #[test]
1747 fn test_keypair_certificate_hex() {
1748 let kp = KeyPair::generate();
1749 let hex_str = kp.certificate_hex();
1750 assert_eq!(hex_str.len(), 64); let decoded = hex::decode(&hex_str).unwrap();
1752 assert_eq!(decoded, kp.certificate);
1753 }
1754
1755 #[test]
1756 fn test_vault_certificate_seal_roundtrip() {
1757 let (pager, tmp_dir) = temp_pager();
1758
1759 let kp = KeyPair::generate();
1761 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1762
1763 let state = VaultState {
1765 users: vec![],
1766 api_keys: vec![],
1767 bootstrapped: true,
1768 master_secret: Some(kp.master_secret.clone()),
1769 kv: std::collections::HashMap::new(),
1770 };
1771 vault.save(&pager, &state).unwrap();
1772
1773 let vault2 = Vault::with_certificate(&pager, &kp.certificate_hex()).unwrap();
1775 let loaded = vault2.load(&pager).unwrap().unwrap();
1776 assert!(loaded.bootstrapped);
1777 assert_eq!(loaded.master_secret, Some(kp.master_secret.clone()));
1778
1779 let kp2 = KeyPair::from_master_secret(loaded.master_secret.unwrap());
1781 assert_eq!(kp.certificate, kp2.certificate);
1782
1783 drop(pager);
1784 let _ = std::fs::remove_dir_all(&tmp_dir);
1785 }
1786
1787 #[test]
1788 fn test_vault_certificate_wrong_cert_fails() {
1789 let (pager, tmp_dir) = temp_pager();
1790
1791 let kp = KeyPair::generate();
1793 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1794 let state = VaultState {
1795 users: vec![],
1796 api_keys: vec![],
1797 bootstrapped: true,
1798 master_secret: Some(kp.master_secret.clone()),
1799 kv: std::collections::HashMap::new(),
1800 };
1801 vault.save(&pager, &state).unwrap();
1802
1803 let kp2 = KeyPair::generate();
1805 let vault2 = Vault::with_certificate_bytes(&pager, &kp2.certificate).unwrap();
1806 let result = vault2.load(&pager);
1807 assert!(result.is_err());
1808
1809 drop(pager);
1810 let _ = std::fs::remove_dir_all(&tmp_dir);
1811 }
1812
1813 #[test]
1814 fn test_vault_state_master_secret_serialization() {
1815 let secret = vec![0xAA; 32];
1816 let state = VaultState {
1817 users: vec![],
1818 api_keys: vec![],
1819 bootstrapped: true,
1820 master_secret: Some(secret.clone()),
1821 kv: std::collections::HashMap::new(),
1822 };
1823 let serialized = state.serialize();
1824 let text = std::str::from_utf8(&serialized).unwrap();
1825 assert!(text.contains("MASTER_SECRET:"));
1826 assert!(text.contains(&hex::encode(&secret)));
1827
1828 let restored = VaultState::deserialize(&serialized).unwrap();
1829 assert_eq!(restored.master_secret, Some(secret));
1830 assert!(restored.bootstrapped);
1831 }
1832
1833 #[test]
1834 fn test_vault_state_no_master_secret_backward_compat() {
1835 let data = b"SEALED:true\n";
1837 let restored = VaultState::deserialize(data).unwrap();
1838 assert!(restored.master_secret.is_none());
1839 assert!(restored.bootstrapped);
1840 }
1841
1842 #[test]
1843 fn test_vault_state_scram_verifier_roundtrip() {
1844 use crate::auth::scram::ScramVerifier;
1845
1846 let verifier = ScramVerifier::from_password(
1847 "hunter2",
1848 b"reddb-vault-test-salt".to_vec(),
1849 crate::auth::scram::DEFAULT_ITER,
1850 );
1851
1852 let now = now_ms();
1853 let state = VaultState {
1854 users: vec![User {
1855 username: "carol".into(),
1856 tenant_id: None,
1857 password_hash: "argon2id$abc$def".into(),
1858 scram_verifier: Some(verifier.clone()),
1859 role: Role::Admin,
1860 api_keys: vec![],
1861 created_at: now,
1862 updated_at: now,
1863 enabled: true,
1864 }],
1865 api_keys: vec![],
1866 bootstrapped: true,
1867 master_secret: None,
1868 kv: std::collections::HashMap::new(),
1869 };
1870
1871 let bytes = state.serialize();
1872 let restored = VaultState::deserialize(&bytes).unwrap();
1873 let carol = restored
1874 .users
1875 .iter()
1876 .find(|u| u.username == "carol")
1877 .unwrap();
1878 let v = carol.scram_verifier.as_ref().expect("verifier round-trips");
1879 assert_eq!(v.salt, verifier.salt);
1880 assert_eq!(v.iter, verifier.iter);
1881 assert_eq!(v.stored_key, verifier.stored_key);
1882 assert_eq!(v.server_key, verifier.server_key);
1883 }
1884
1885 #[test]
1886 fn test_vault_state_pre_tenant_user_line_still_parses() {
1887 let now = now_ms();
1891 let line = format!(
1892 "USER:dave\targon2id$x$y\tread\ttrue\t{}\t{}\t\nSEALED:false\n",
1893 now, now
1894 );
1895 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1896 let dave = restored
1897 .users
1898 .iter()
1899 .find(|u| u.username == "dave")
1900 .unwrap();
1901 assert!(dave.scram_verifier.is_none());
1902 assert!(dave.tenant_id.is_none());
1903 }
1904
1905 #[test]
1906 fn test_vault_state_tenant_user_line_still_parses() {
1907 let now = now_ms();
1908 let line = format!(
1909 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\nSEALED:false\n",
1910 now, now
1911 );
1912 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1913 let erin = restored
1914 .users
1915 .iter()
1916 .find(|u| u.username == "erin")
1917 .unwrap();
1918 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1919 }
1920
1921 #[test]
1922 fn test_vault_state_legacy_user_line_with_extra_ownership_field_is_ignored() {
1923 let now = now_ms();
1924 let line = format!(
1925 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\ttrue\nSEALED:false\n",
1926 now, now
1927 );
1928 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1929 let erin = restored
1930 .users
1931 .iter()
1932 .find(|u| u.username == "erin")
1933 .unwrap();
1934 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1935 }
1936
1937 #[test]
1938 fn test_vault_state_user_line_with_tenant_roundtrip() {
1939 let now = now_ms();
1940 let state = VaultState {
1941 users: vec![User {
1942 username: "alice".into(),
1943 tenant_id: Some("acme".into()),
1944 password_hash: "argon2id$x$y".into(),
1945 scram_verifier: None,
1946 role: Role::Write,
1947 api_keys: vec![],
1948 created_at: now,
1949 updated_at: now,
1950 enabled: true,
1951 }],
1952 api_keys: vec![],
1953 bootstrapped: true,
1954 master_secret: None,
1955 kv: std::collections::HashMap::new(),
1956 };
1957 let bytes = state.serialize();
1958 let text = std::str::from_utf8(&bytes).unwrap();
1959 assert!(text.contains("\tacme\n"));
1961
1962 let restored = VaultState::deserialize(&bytes).unwrap();
1963 let alice = restored
1964 .users
1965 .iter()
1966 .find(|u| u.username == "alice")
1967 .unwrap();
1968 assert_eq!(alice.tenant_id.as_deref(), Some("acme"));
1969 }
1970
1971 #[test]
1972 fn test_vault_state_key_line_with_tenant_reattaches_correctly() {
1973 let now = now_ms();
1976 let state = VaultState {
1977 users: vec![
1978 User {
1979 username: "alice".into(),
1980 tenant_id: Some("acme".into()),
1981 password_hash: "argon2id$x$y".into(),
1982 scram_verifier: None,
1983 role: Role::Write,
1984 api_keys: vec![],
1985 created_at: now,
1986 updated_at: now,
1987 enabled: true,
1988 },
1989 User {
1990 username: "alice".into(),
1991 tenant_id: Some("globex".into()),
1992 password_hash: "argon2id$a$b".into(),
1993 scram_verifier: None,
1994 role: Role::Read,
1995 api_keys: vec![],
1996 created_at: now,
1997 updated_at: now,
1998 enabled: true,
1999 },
2000 ],
2001 api_keys: vec![
2002 (
2003 UserId::scoped("acme", "alice"),
2004 ApiKey {
2005 key: "rk_acme_key".into(),
2006 name: "deploy".into(),
2007 role: Role::Write,
2008 created_at: now,
2009 expires_at: None,
2010 },
2011 ),
2012 (
2013 UserId::scoped("globex", "alice"),
2014 ApiKey {
2015 key: "rk_globex_key".into(),
2016 name: "ci".into(),
2017 role: Role::Read,
2018 created_at: now,
2019 expires_at: None,
2020 },
2021 ),
2022 ],
2023 bootstrapped: true,
2024 master_secret: None,
2025 kv: std::collections::HashMap::new(),
2026 };
2027 let bytes = state.serialize();
2028 let restored = VaultState::deserialize(&bytes).unwrap();
2029 assert_eq!(restored.api_keys.len(), 2);
2032 let acme_key = restored
2033 .api_keys
2034 .iter()
2035 .find(|(o, _)| o.tenant.as_deref() == Some("acme"))
2036 .unwrap();
2037 assert_eq!(acme_key.1.key, "rk_acme_key");
2038 let globex_key = restored
2039 .api_keys
2040 .iter()
2041 .find(|(o, _)| o.tenant.as_deref() == Some("globex"))
2042 .unwrap();
2043 assert_eq!(globex_key.1.key, "rk_globex_key");
2044 }
2045
2046 #[test]
2047 fn test_vault_state_scram_iter_below_min_rejected() {
2048 let now = now_ms();
2049 let stored_hex = "00".repeat(32);
2053 let server_hex = "11".repeat(32);
2054 let line = format!(
2055 "USER:eve\targon2id$x$y\tread\ttrue\t{}\t{}\tdeadbeef:1024:{}:{}\n",
2056 now, now, stored_hex, server_hex
2057 );
2058 match VaultState::deserialize(line.as_bytes()) {
2059 Err(VaultError::Corrupt(msg)) => assert!(msg.contains("below minimum")),
2060 Err(other) => panic!("expected Corrupt iter-floor error, got {other:?}"),
2061 Ok(_) => panic!("expected Corrupt iter-floor error, got Ok"),
2062 }
2063 }
2064
2065 #[test]
2066 fn test_constant_time_eq_function() {
2067 assert!(constant_time_eq(b"hello", b"hello"));
2068 assert!(!constant_time_eq(b"hello", b"world"));
2069 assert!(!constant_time_eq(b"short", b"longer"));
2070 assert!(constant_time_eq(b"", b""));
2071 }
2072}