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;
73use zeroize::{Zeroize, Zeroizing};
74
75use super::{ApiKey, AuthError, Role, User, UserId};
76
77const VAULT_MAGIC: &[u8; 4] = b"RDVT";
82const VAULT_DATA_MAGIC: &[u8; 4] = b"RDVD";
83
84const VAULT_VERSION: u8 = 2;
87const VAULT_PASSPHRASE_VERSION: u8 = 3;
88
89const VAULT_LEGACY_VERSION: u8 = 1;
92
93const VAULT_AAD: &[u8] = b"reddb-vault";
94
95use reddb_file::VAULT_LOGICAL_EXPORT_AAD;
99
100const VAULT_MAGIC_SIZE: usize = 4;
102const VAULT_VERSION_SIZE: usize = 1;
103const VAULT_SALT_SIZE: usize = 16;
104const VAULT_PAYLOAD_LEN_SIZE: usize = 4;
105const VAULT_CHAIN_COUNT_SIZE: usize = 4;
106const VAULT_FIRST_PAGE_ID_SIZE: usize = 4;
107
108const NONCE_SIZE: usize = 12;
110
111const VAULT_HEADER_PREAMBLE_SIZE: usize =
113 VAULT_MAGIC_SIZE + VAULT_VERSION_SIZE + VAULT_SALT_SIZE + VAULT_PAYLOAD_LEN_SIZE; const VAULT_HEADER_META_SIZE: usize =
118 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;
126
127const VAULT_HEADER_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_HEADER_META_SIZE;
130
131const VAULT_DATA_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_DATA_PREFIX_SIZE;
134
135pub struct KeyPair {
152 pub master_secret: Vec<u8>,
154 pub certificate: Vec<u8>,
156}
157
158impl Drop for KeyPair {
159 fn drop(&mut self) {
160 self.master_secret.zeroize();
161 self.certificate.zeroize();
162 }
163}
164
165impl KeyPair {
166 pub fn generate() -> Self {
168 let mut master_secret = vec![0u8; 32];
169 os_random::fill_bytes(&mut master_secret).expect("CSPRNG failed during keypair generation");
170 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
171 Self {
172 master_secret,
173 certificate: certificate.to_vec(),
174 }
175 }
176
177 pub fn from_master_secret(master_secret: Vec<u8>) -> Self {
180 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
181 Self {
182 master_secret,
183 certificate: certificate.to_vec(),
184 }
185 }
186
187 pub fn vault_key_from_certificate(certificate: &[u8]) -> SecureKey {
192 let key_bytes = derive_key(certificate, b"reddb-vault-seal", &vault_argon2_params());
193 SecureKey::new(&key_bytes)
194 }
195
196 pub fn sign(&self, data: &[u8]) -> Vec<u8> {
198 hmac_sha256(&self.master_secret, data).to_vec()
199 }
200
201 pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
203 let expected = self.sign(data);
204 constant_time_eq(&expected, signature)
205 }
206
207 pub fn certificate_hex(&self) -> String {
209 hex::encode(&self.certificate)
210 }
211}
212
213fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
215 crate::crypto::constant_time_eq(a, b)
216}
217
218#[derive(Debug)]
224pub enum VaultError {
225 NoKey,
227 InvalidCredential(String),
228 Encryption,
230 Decryption,
232 Io(std::io::Error),
234 Corrupt(String),
236 Pager(String),
238}
239
240impl std::fmt::Display for VaultError {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 match self {
243 Self::NoKey => write!(
244 f,
245 "no vault credential: set REDDB_CERTIFICATE or REDDB_VAULT_PASSPHRASE (or their _FILE variants)"
246 ),
247 Self::InvalidCredential(reason) => write!(f, "invalid vault credential: {reason}"),
248 Self::Encryption => write!(f, "vault encryption failed"),
249 Self::Decryption => write!(f, "vault decryption failed (wrong key or corrupt data)"),
250 Self::Io(err) => write!(f, "vault I/O error: {err}"),
251 Self::Corrupt(msg) => write!(f, "vault corrupt: {msg}"),
252 Self::Pager(msg) => write!(f, "vault pager error: {msg}"),
253 }
254 }
255}
256
257impl std::error::Error for VaultError {}
258
259impl From<VaultError> for AuthError {
260 fn from(err: VaultError) -> Self {
261 AuthError::Internal(err.to_string())
262 }
263}
264
265fn decode_certificate_hex(certificate_hex: &str) -> Result<Zeroizing<Vec<u8>>, VaultError> {
266 let certificate = Zeroizing::new(hex::decode(certificate_hex.trim()).map_err(|_| VaultError::InvalidCredential("certificate must contain 64 hexadecimal characters; use REDDB_VAULT_PASSPHRASE for a password".into()))?);
267 if certificate.len() != 32 {
268 return Err(VaultError::InvalidCredential(
269 "certificate must contain exactly 32 bytes (64 hexadecimal characters)".into(),
270 ));
271 }
272 Ok(certificate)
273}
274
275#[derive(Default)]
283pub struct VaultState {
284 pub users: Vec<User>,
285 pub api_keys: Vec<(UserId, ApiKey)>,
289 pub bootstrapped: bool,
290 pub master_secret: Option<Vec<u8>>,
294 pub kv: std::collections::HashMap<String, String>,
298}
299
300impl std::fmt::Debug for VaultState {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 f.debug_struct("VaultState")
303 .field("users", &self.users.len())
304 .field("api_keys", &self.api_keys.len())
305 .field("bootstrapped", &self.bootstrapped)
306 .field(
307 "master_secret",
308 &self.master_secret.as_ref().map(|_| "<redacted>"),
309 )
310 .field("kv", &self.kv.len())
311 .finish()
312 }
313}
314
315impl VaultState {
316 pub fn serialize(&self) -> Vec<u8> {
318 let mut out = String::new();
319
320 if let Some(ref secret) = self.master_secret {
322 out.push_str(&format!("MASTER_SECRET:{}\n", hex::encode(secret)));
323 }
324
325 out.push_str(&format!("SEALED:{}\n", self.bootstrapped));
327
328 for user in &self.users {
341 let scram_field = match &user.scram_verifier {
342 Some(v) => format!(
343 "{}:{}:{}:{}",
344 hex::encode(&v.salt),
345 v.iter,
346 hex::encode(v.stored_key),
347 hex::encode(v.server_key),
348 ),
349 None => String::new(),
350 };
351 let tenant_field = user.tenant_id.clone().unwrap_or_default();
352 out.push_str(&format!(
353 "USER:{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
354 user.username,
355 user.password_hash,
356 user.role.as_str(),
357 user.enabled,
358 user.created_at,
359 user.updated_at,
360 scram_field,
361 tenant_field,
362 ));
363 }
364
365 for (owner, key) in &self.api_keys {
370 let tenant_field = owner.tenant.clone().unwrap_or_default();
371 let expires_field = key
372 .expires_at
373 .map(|deadline| deadline.to_string())
374 .unwrap_or_default();
375 out.push_str(&format!(
376 "KEY:{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
377 owner.username,
378 key.key,
379 key.name,
380 key.role.as_str(),
381 key.created_at,
382 tenant_field,
383 expires_field,
384 ));
385 }
386
387 for (k, v) in &self.kv {
389 out.push_str(&format!("KV:{}\t{}\n", k, hex::encode(v.as_bytes())));
390 }
391
392 out.into_bytes()
393 }
394
395 pub fn deserialize(data: &[u8]) -> Result<Self, VaultError> {
397 let text = std::str::from_utf8(data)
398 .map_err(|_| VaultError::Corrupt("payload is not valid UTF-8".into()))?;
399
400 let mut users = Vec::new();
401 let mut api_keys: Vec<(UserId, ApiKey)> = Vec::new();
402 let mut bootstrapped = false;
403 let mut master_secret: Option<Vec<u8>> = None;
404 let mut kv: std::collections::HashMap<String, String> = std::collections::HashMap::new();
405
406 for line in text.lines() {
407 if line.is_empty() {
408 continue;
409 }
410
411 if let Some(rest) = line.strip_prefix("MASTER_SECRET:") {
412 master_secret = Some(
413 hex::decode(rest)
414 .map_err(|_| VaultError::Corrupt("invalid MASTER_SECRET hex".into()))?,
415 );
416 } else if let Some(rest) = line.strip_prefix("SEALED:") {
417 bootstrapped = rest == "true";
418 } else if let Some(rest) = line.strip_prefix("USER:") {
419 let parts: Vec<&str> = rest.split('\t').collect();
420 if !(7..=9).contains(&parts.len()) {
424 return Err(VaultError::Corrupt(format!(
425 "USER line has {} fields, expected 7, 8, or 9",
426 parts.len()
427 )));
428 }
429 let role = Role::from_str(parts[2])
430 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[2])))?;
431 let enabled = parts[3] == "true";
432 let created_at: u128 = parts[4]
433 .parse()
434 .map_err(|_| VaultError::Corrupt("invalid created_at".into()))?;
435 let updated_at: u128 = parts[5]
436 .parse()
437 .map_err(|_| VaultError::Corrupt("invalid updated_at".into()))?;
438 let scram_verifier = parts
439 .get(6)
440 .map(|s| s.trim())
441 .filter(|s| !s.is_empty())
442 .map(parse_scram_field)
443 .transpose()?;
444 let tenant_id = parts
445 .get(7)
446 .map(|s| s.trim())
447 .filter(|s| !s.is_empty())
448 .map(|s| s.to_string());
449
450 users.push(User {
451 username: parts[0].to_string(),
452 tenant_id,
453 password_hash: parts[1].to_string(),
454 scram_verifier,
455 role,
456 api_keys: Vec::new(), created_at,
458 updated_at,
459 enabled,
460 });
461 } else if let Some(rest) = line.strip_prefix("KEY:") {
462 let parts: Vec<&str> = rest.split('\t').collect();
463 if !(5..=7).contains(&parts.len()) {
465 return Err(VaultError::Corrupt(format!(
466 "KEY line has {} fields, expected 5 to 7",
467 parts.len()
468 )));
469 }
470 let role = Role::from_str(parts[3])
471 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[3])))?;
472 let created_at: u128 = parts[4]
473 .parse()
474 .map_err(|_| VaultError::Corrupt("invalid key created_at".into()))?;
475 let tenant_id = parts
476 .get(5)
477 .map(|s| s.trim())
478 .filter(|s| !s.is_empty())
479 .map(|s| s.to_string());
480
481 api_keys.push((
482 UserId {
483 tenant: tenant_id,
484 username: parts[0].to_string(),
485 },
486 ApiKey {
487 key: parts[1].to_string(),
488 name: parts[2].to_string(),
489 role,
490 created_at,
491 expires_at: parts
492 .get(6)
493 .map(|s| s.trim())
494 .filter(|s| !s.is_empty())
495 .and_then(|s| s.parse::<u128>().ok()),
496 },
497 ));
498 } else if let Some(rest) = line.strip_prefix("KV:") {
499 let parts: Vec<&str> = rest.splitn(2, '\t').collect();
500 if parts.len() == 2 {
501 if let Ok(bytes) = hex::decode(parts[1]) {
502 if let Ok(value) = String::from_utf8(bytes) {
503 kv.insert(parts[0].to_string(), value);
504 }
505 }
506 }
507 } else {
508 }
510 }
511
512 for (owner, key) in &api_keys {
516 if let Some(user) = users
517 .iter_mut()
518 .find(|u| u.username == owner.username && u.tenant_id == owner.tenant)
519 {
520 user.api_keys.push(key.clone());
521 }
522 }
523
524 Ok(Self {
525 users,
526 api_keys,
527 bootstrapped,
528 master_secret,
529 kv,
530 })
531 }
532}
533
534pub struct Vault {
545 key: SecureKey,
546 salt: [u8; 16],
547 version: u8,
548}
549
550fn vault_argon2_params() -> Argon2Params {
553 Argon2Params {
554 m_cost: 16 * 1024, t_cost: 3,
556 p: 1,
557 tag_len: 32,
558 }
559}
560
561impl Vault {
562 pub fn has_saved_state(pager: &Pager) -> bool {
564 pager
565 .read_page_no_checksum(VAULT_HEADER_PAGE)
566 .ok()
567 .map(|page| {
568 let content = page.content();
569 content.len() >= VAULT_MAGIC_SIZE && &content[0..VAULT_MAGIC_SIZE] == VAULT_MAGIC
570 })
571 .unwrap_or(false)
572 }
573
574 pub fn open(pager: &Pager) -> Result<Self, VaultError> {
583 match (
584 crate::utils::env_with_file_fallback("REDDB_CERTIFICATE"),
585 crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE"),
586 ) {
587 (Some(_), Some(_)) => Err(VaultError::InvalidCredential(
588 "set either REDDB_CERTIFICATE or REDDB_VAULT_PASSPHRASE, not both".into(),
589 )),
590 (Some(certificate), None) => Self::with_certificate(pager, &certificate),
591 (None, Some(passphrase)) => Self::with_passphrase(pager, &passphrase),
592 (None, None) => Err(VaultError::NoKey),
593 }
594 }
595
596 fn salt_for_open(pager: &Pager) -> Result<[u8; 16], VaultError> {
597 match read_vault_salt_from_pager(pager) {
599 Ok(s) => Ok(s),
600 Err(_) => {
601 let mut salt = [0u8; 16];
603 let mut buf = [0u8; 16];
604 os_random::fill_bytes(&mut buf)
605 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
606 salt.copy_from_slice(&buf);
607 Ok(salt)
608 }
609 }
610 }
611
612 pub fn with_certificate(pager: &Pager, certificate_hex: &str) -> Result<Self, VaultError> {
618 Self::check_credential_format(pager, VAULT_VERSION)?;
619 let certificate = decode_certificate_hex(certificate_hex)?;
620
621 let key = KeyPair::vault_key_from_certificate(&certificate);
622
623 let salt = Self::salt_for_open(pager)?;
624
625 Ok(Self {
626 key,
627 salt,
628 version: VAULT_VERSION,
629 })
630 }
631
632 pub fn with_passphrase(pager: &Pager, passphrase: &str) -> Result<Self, VaultError> {
635 Self::check_credential_format(pager, VAULT_PASSPHRASE_VERSION)?;
636 let salt = Self::salt_for_open(pager)?;
637 let key = Self::passphrase_key(passphrase, &salt)?;
638 Ok(Self {
639 key,
640 salt,
641 version: VAULT_PASSPHRASE_VERSION,
642 })
643 }
644
645 fn passphrase_key(passphrase: &str, salt: &[u8; 16]) -> Result<SecureKey, VaultError> {
646 let bytes = reddb_crypto::vault::passphrase_key(passphrase, salt)
647 .map_err(VaultError::InvalidCredential)?;
648 Ok(SecureKey::new(bytes.as_ref()))
649 }
650
651 fn check_credential_format(pager: &Pager, expected: u8) -> Result<(), VaultError> {
652 if Self::has_saved_state(pager) {
653 let page = pager
654 .read_page_no_checksum(VAULT_HEADER_PAGE)
655 .map_err(|err| VaultError::Pager(err.to_string()))?;
656 let version = page.content()[4];
657 if (version == VAULT_VERSION || version == VAULT_PASSPHRASE_VERSION)
658 && version != expected
659 {
660 return Err(VaultError::InvalidCredential(
661 if version == VAULT_PASSPHRASE_VERSION {
662 "this vault requires a passphrase"
663 } else {
664 "this vault requires a certificate"
665 }
666 .into(),
667 ));
668 }
669 }
670 Ok(())
671 }
672
673 pub fn from_env(pager: &Pager) -> Result<Self, VaultError> {
677 Self::open(pager)
678 }
679
680 pub fn with_certificate_bytes(pager: &Pager, certificate: &[u8]) -> Result<Self, VaultError> {
685 Self::check_credential_format(pager, VAULT_VERSION)?;
686 if certificate.len() != 32 {
687 return Err(VaultError::InvalidCredential(
688 "certificate must contain 32 bytes".into(),
689 ));
690 }
691 let key = KeyPair::vault_key_from_certificate(certificate);
692
693 let salt = match read_vault_salt_from_pager(pager) {
694 Ok(s) => s,
695 Err(_) => {
696 let mut s = [0u8; 16];
697 os_random::fill_bytes(&mut s)
698 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
699 s
700 }
701 };
702
703 Ok(Self {
704 key,
705 salt,
706 version: VAULT_VERSION,
707 })
708 }
709
710 pub fn seal_logical_export(&self, state: &VaultState) -> Result<String, VaultError> {
716 let plaintext = Zeroizing::new(state.serialize());
717 let mut nonce = [0u8; NONCE_SIZE];
718 os_random::fill_bytes(&mut nonce)
719 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
720
721 let key_bytes: &[u8] = self.key.as_bytes();
722 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
723 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_LOGICAL_EXPORT_AAD, &plaintext);
724
725 Ok(reddb_file::encode_vault_logical_export(
728 &self.salt,
729 &nonce,
730 &ciphertext,
731 ))
732 }
733
734 pub fn unseal_logical_export(blob_hex: &str) -> Result<VaultState, VaultError> {
737 match (
738 crate::utils::env_with_file_fallback("REDDB_CERTIFICATE"),
739 crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE"),
740 ) {
741 (Some(_), Some(_)) => Err(VaultError::InvalidCredential(
742 "set a single vault credential".into(),
743 )),
744 (Some(certificate), None) => {
745 Self::unseal_logical_export_with_certificate(blob_hex, &certificate)
746 }
747 (None, Some(passphrase)) => {
748 Self::unseal_logical_export_with_passphrase(blob_hex, &passphrase)
749 }
750 _ => Err(VaultError::NoKey),
751 }
752 }
753
754 pub fn unseal_logical_export_with_certificate(
756 blob_hex: &str,
757 certificate_hex: &str,
758 ) -> Result<VaultState, VaultError> {
759 let (_salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
760 let certificate = decode_certificate_hex(certificate_hex)?;
761 let key = KeyPair::vault_key_from_certificate(&certificate);
762 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
763 }
764
765 pub fn unseal_logical_export_with_passphrase(
766 blob_hex: &str,
767 passphrase: &str,
768 ) -> Result<VaultState, VaultError> {
769 let (salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
770 let key = Self::passphrase_key(passphrase, &salt)?;
771 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
772 }
773
774 fn decode_logical_export(
775 blob_hex: &str,
776 ) -> Result<([u8; VAULT_SALT_SIZE], [u8; NONCE_SIZE], Vec<u8>), VaultError> {
777 let env = reddb_file::decode_vault_logical_export(blob_hex)
780 .map_err(|e| VaultError::Corrupt(e.to_string()))?;
781 Ok((env.salt, env.nonce, env.ciphertext))
782 }
783
784 fn decrypt_logical_export(
785 key: &SecureKey,
786 nonce: &[u8; NONCE_SIZE],
787 ciphertext: &[u8],
788 ) -> Result<VaultState, VaultError> {
789 let key_bytes: &[u8] = key.as_bytes();
790 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
791 let plaintext = Zeroizing::new(
792 aes256_gcm_decrypt(key_arr, nonce, VAULT_LOGICAL_EXPORT_AAD, ciphertext)
793 .map_err(|_| VaultError::Decryption)?,
794 );
795 VaultState::deserialize(&plaintext)
796 }
797
798 pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
812 let plaintext = Zeroizing::new(state.serialize());
813
814 let mut nonce = [0u8; NONCE_SIZE];
816 os_random::fill_bytes(&mut nonce)
817 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
818
819 let key_bytes: &[u8] = self.key.as_bytes();
820 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
821 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_AAD, &plaintext);
822 let cipher_total = ciphertext.len();
826 let payload_len = u32::try_from(NONCE_SIZE.saturating_add(cipher_total))
827 .map_err(|_| VaultError::Corrupt("vault payload exceeds on-disk format".into()))?;
828
829 let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
835 let overflow = cipher_total.saturating_sub(header_chunk_len);
836 let chain_count = overflow.div_ceil(VAULT_DATA_CIPHER_CAPACITY);
837
838 while pager
852 .page_count()
853 .map_err(|e| VaultError::Pager(e.to_string()))?
854 <= VAULT_HEADER_PAGE
855 {
856 pager
857 .allocate_page(PageType::Vault)
858 .map_err(|e| VaultError::Pager(format!("reserve vault slot: {e}")))?;
859 }
860
861 let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
871
872 let mut new_chain = Vec::new();
879 new_chain
880 .try_reserve_exact(chain_count)
881 .map_err(|_| VaultError::Corrupt("vault page chain is too large".into()))?;
882 for _ in 0..chain_count {
883 let page = pager
884 .allocate_page(PageType::Vault)
885 .map_err(|e| VaultError::Pager(format!("allocate vault data page: {e}")))?;
886 new_chain.push(page.page_id());
887 }
888
889 let mut cursor = header_chunk_len;
896 for i in 0..chain_count {
897 let next_id = if i + 1 < chain_count {
898 new_chain[i + 1]
899 } else {
900 0
901 };
902 let take = (cipher_total - cursor).min(VAULT_DATA_CIPHER_CAPACITY);
903 let frag = &ciphertext[cursor..cursor + take];
904 self.write_data_page(pager, new_chain[i], next_id, frag)?;
905 cursor += take;
906 }
907 debug_assert_eq!(cursor, cipher_total, "ciphertext spill accounting mismatch");
908
909 let first_data_page = new_chain.first().copied().unwrap_or(0);
917 self.write_header_page(
918 pager,
919 &nonce,
920 payload_len,
921 u32::try_from(chain_count)
922 .map_err(|_| VaultError::Corrupt("vault page chain is too large".into()))?,
923 first_data_page,
924 &ciphertext[..header_chunk_len],
925 )?;
926
927 pager
932 .flush()
933 .map_err(|e| VaultError::Pager(e.to_string()))?;
934
935 for &id in old_chain.iter() {
940 pager
941 .free_page(id)
942 .map_err(|e| VaultError::Pager(format!("free old vault page {id}: {e}")))?;
943 }
944
945 Ok(())
946 }
947
948 pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
952 let page = match pager.read_page_no_checksum(VAULT_HEADER_PAGE) {
954 Ok(p) => p,
955 Err(_) => return Ok(None),
956 };
957
958 let page_content = page.content();
959
960 if page_content.len() < VAULT_HEADER_META_SIZE {
961 return Ok(None);
962 }
963 if &page_content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
964 return Ok(None); }
966
967 let version = page_content[4];
968 if version == VAULT_LEGACY_VERSION {
969 return Err(VaultError::Corrupt(
973 "vault was bootstrapped with the legacy 2-page format \
974 (pre-RedDB v0.3); re-bootstrap with `red bootstrap` to upgrade"
975 .to_string(),
976 ));
977 }
978 if version != self.version {
979 return Err(VaultError::Corrupt(format!(
980 "vault credential format mismatch: stored {version}, opened {}",
981 self.version
982 )));
983 }
984
985 let payload_len = u32::from_le_bytes(
987 page_content[21..25]
988 .try_into()
989 .map_err(|_| VaultError::Corrupt("bad payload length bytes".into()))?,
990 ) as usize;
991
992 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
993 let nonce: [u8; NONCE_SIZE] = page_content[nonce_start..nonce_start + NONCE_SIZE]
994 .try_into()
995 .map_err(|_| VaultError::Corrupt("bad nonce".into()))?;
996
997 let chain_count_off = nonce_start + NONCE_SIZE;
998 let chain_count = u32::from_le_bytes(
999 page_content[chain_count_off..chain_count_off + 4]
1000 .try_into()
1001 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1002 ) as usize;
1003 let first_id_off = chain_count_off + 4;
1004 let mut next_id = u32::from_le_bytes(
1005 page_content[first_id_off..first_id_off + 4]
1006 .try_into()
1007 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1008 );
1009
1010 if payload_len < NONCE_SIZE {
1011 return Err(VaultError::Corrupt("payload too short for nonce".into()));
1012 }
1013 let cipher_total = payload_len - NONCE_SIZE;
1014 let expected_chain_count = cipher_total
1015 .saturating_sub(VAULT_HEADER_CIPHER_CAPACITY)
1016 .div_ceil(VAULT_DATA_CIPHER_CAPACITY);
1017 if chain_count != expected_chain_count {
1018 return Err(VaultError::Corrupt(format!(
1019 "vault chain count {chain_count} does not match payload length {payload_len}"
1020 )));
1021 }
1022 let page_count = pager
1023 .page_count()
1024 .map_err(|e| VaultError::Pager(e.to_string()))? as usize;
1025 if chain_count > page_count {
1026 return Err(VaultError::Corrupt(format!(
1027 "vault chain count {chain_count} exceeds database page count {page_count}"
1028 )));
1029 }
1030 if (chain_count == 0) != (next_id == 0) {
1031 return Err(VaultError::Corrupt(
1032 "vault chain head does not match declared chain count".into(),
1033 ));
1034 }
1035
1036 let mut cipher = Vec::new();
1038 cipher
1039 .try_reserve_exact(cipher_total)
1040 .map_err(|_| VaultError::Corrupt("vault ciphertext is too large".into()))?;
1041 let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
1042 let header_cipher_start = VAULT_HEADER_META_SIZE;
1043 cipher.extend_from_slice(
1044 &page_content[header_cipher_start..header_cipher_start + header_chunk_len],
1045 );
1046
1047 let mut hops = 0usize;
1049 while cipher.len() < cipher_total {
1053 if hops >= chain_count {
1054 return Err(VaultError::Corrupt(format!(
1055 "vault chain shorter than declared: {} hops, expected {}",
1056 hops, chain_count
1057 )));
1058 }
1059 if next_id == 0 {
1060 return Err(VaultError::Corrupt(
1061 "vault chain ends prematurely (next_id == 0)".to_string(),
1062 ));
1063 }
1064
1065 let dp = pager
1066 .read_page_no_checksum(next_id)
1067 .map_err(|e| VaultError::Pager(format!("vault data page {next_id}: {e}")))?;
1068 let dp_content = dp.content();
1069 if dp_content.len() < VAULT_DATA_PREFIX_SIZE {
1070 return Err(VaultError::Corrupt(format!(
1071 "vault data page {next_id} truncated"
1072 )));
1073 }
1074 if &dp_content[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC {
1075 return Err(VaultError::Corrupt(format!(
1076 "vault data page {next_id} has bad magic"
1077 )));
1078 }
1079 let np = u32::from_le_bytes(
1080 dp_content[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1081 .try_into()
1082 .map_err(|_| VaultError::Corrupt("bad next_page_id bytes".into()))?,
1083 );
1084 let take = (cipher_total - cipher.len()).min(VAULT_DATA_CIPHER_CAPACITY);
1085 let frag_start = VAULT_DATA_PREFIX_SIZE;
1086 cipher.extend_from_slice(&dp_content[frag_start..frag_start + take]);
1087
1088 next_id = np;
1089 hops += 1;
1090 }
1091
1092 if cipher.len() != cipher_total {
1093 return Err(VaultError::Corrupt(format!(
1094 "vault truncated: expected {} cipher bytes, got {}",
1095 cipher_total,
1096 cipher.len()
1097 )));
1098 }
1099 if hops != chain_count {
1100 return Err(VaultError::Corrupt(format!(
1101 "vault chain length mismatch: walked {} pages, header says {}",
1102 hops, chain_count
1103 )));
1104 }
1105
1106 let key_bytes: &[u8] = self.key.as_bytes();
1108 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
1109 let plaintext = Zeroizing::new(
1110 aes256_gcm_decrypt(key_arr, &nonce, VAULT_AAD, &cipher)
1111 .map_err(|_| VaultError::Decryption)?,
1112 );
1113
1114 let state = VaultState::deserialize(&plaintext)?;
1115 Ok(Some(state))
1116 }
1117
1118 fn read_existing_chain_ids(&self, pager: &Pager) -> Result<Vec<u32>, VaultError> {
1125 let header = pager
1126 .read_page_no_checksum(VAULT_HEADER_PAGE)
1127 .map_err(|e| VaultError::Pager(e.to_string()))?;
1128 let content = header.content();
1129 if content.len() < VAULT_HEADER_META_SIZE {
1130 return Ok(Vec::new());
1131 }
1132 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1133 return Ok(Vec::new());
1134 }
1135 let version = content[4];
1136 if version != VAULT_VERSION && version != VAULT_PASSPHRASE_VERSION {
1137 return Ok(Vec::new());
1141 }
1142 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
1143 let chain_count_off = nonce_start + NONCE_SIZE;
1144 let chain_count = u32::from_le_bytes(
1145 content[chain_count_off..chain_count_off + 4]
1146 .try_into()
1147 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1148 ) as usize;
1149 let page_count = pager
1150 .page_count()
1151 .map_err(|e| VaultError::Pager(e.to_string()))? as usize;
1152 if chain_count > page_count {
1153 return Err(VaultError::Corrupt(format!(
1154 "vault chain count {chain_count} exceeds database page count {page_count}"
1155 )));
1156 }
1157 let first_id_off = chain_count_off + 4;
1158 let mut id = u32::from_le_bytes(
1159 content[first_id_off..first_id_off + 4]
1160 .try_into()
1161 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1162 );
1163
1164 let mut out = Vec::new();
1165 out.try_reserve_exact(chain_count)
1166 .map_err(|_| VaultError::Corrupt("vault page chain is too large".into()))?;
1167 let mut hops = 0usize;
1168 while id != 0 && hops < chain_count {
1169 out.push(id);
1170 match pager.read_page_no_checksum(id) {
1173 Ok(dp) => {
1174 let dc = dp.content();
1175 if dc.len() < VAULT_DATA_PREFIX_SIZE
1176 || &dc[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC
1177 {
1178 break;
1179 }
1180 id = u32::from_le_bytes(
1181 dc[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1182 .try_into()
1183 .map_err(|_| VaultError::Corrupt("bad next_id".into()))?,
1184 );
1185 }
1186 Err(_) => break,
1187 }
1188 hops += 1;
1189 }
1190 Ok(out)
1191 }
1192
1193 fn write_header_page(
1197 &self,
1198 pager: &Pager,
1199 nonce: &[u8; NONCE_SIZE],
1200 payload_len: u32,
1201 chain_count: u32,
1202 first_data_page_id: u32,
1203 cipher_fragment: &[u8],
1204 ) -> Result<(), VaultError> {
1205 debug_assert!(cipher_fragment.len() <= VAULT_HEADER_CIPHER_CAPACITY);
1206
1207 let mut page = Page::new(PageType::Vault, VAULT_HEADER_PAGE);
1208 let bytes = page.as_bytes_mut();
1209 let mut off = HEADER_SIZE;
1210
1211 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_MAGIC);
1212 off += VAULT_MAGIC_SIZE;
1213
1214 bytes[off] = self.version;
1215 off += VAULT_VERSION_SIZE;
1216
1217 bytes[off..off + VAULT_SALT_SIZE].copy_from_slice(&self.salt);
1218 off += VAULT_SALT_SIZE;
1219
1220 bytes[off..off + 4].copy_from_slice(&payload_len.to_le_bytes());
1221 off += VAULT_PAYLOAD_LEN_SIZE;
1222
1223 bytes[off..off + NONCE_SIZE].copy_from_slice(nonce);
1224 off += NONCE_SIZE;
1225
1226 bytes[off..off + 4].copy_from_slice(&chain_count.to_le_bytes());
1227 off += VAULT_CHAIN_COUNT_SIZE;
1228
1229 bytes[off..off + 4].copy_from_slice(&first_data_page_id.to_le_bytes());
1230 off += VAULT_FIRST_PAGE_ID_SIZE;
1231
1232 debug_assert_eq!(off, HEADER_SIZE + VAULT_HEADER_META_SIZE);
1233
1234 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1235
1236 pager
1237 .write_page_no_checksum(VAULT_HEADER_PAGE, page)
1238 .map_err(|e| VaultError::Pager(e.to_string()))?;
1239 Ok(())
1240 }
1241
1242 fn write_data_page(
1244 &self,
1245 pager: &Pager,
1246 page_id: u32,
1247 next_page_id: u32,
1248 cipher_fragment: &[u8],
1249 ) -> Result<(), VaultError> {
1250 debug_assert!(cipher_fragment.len() <= VAULT_DATA_CIPHER_CAPACITY);
1251
1252 let mut page = Page::new(PageType::Vault, page_id);
1253 let bytes = page.as_bytes_mut();
1254 let mut off = HEADER_SIZE;
1255
1256 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_DATA_MAGIC);
1257 off += VAULT_MAGIC_SIZE;
1258
1259 bytes[off..off + 4].copy_from_slice(&next_page_id.to_le_bytes());
1260 off += 4;
1261
1262 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1263
1264 pager
1265 .write_page_no_checksum(page_id, page)
1266 .map_err(|e| VaultError::Pager(e.to_string()))?;
1267 Ok(())
1268 }
1269}
1270
1271fn parse_scram_field(field: &str) -> Result<crate::auth::scram::ScramVerifier, VaultError> {
1278 let parts: Vec<&str> = field.split(':').collect();
1279 if parts.len() != 4 {
1280 return Err(VaultError::Corrupt(format!(
1281 "SCRAM verifier has {} segments, expected 4",
1282 parts.len()
1283 )));
1284 }
1285 let salt =
1286 hex::decode(parts[0]).map_err(|_| VaultError::Corrupt("invalid SCRAM salt hex".into()))?;
1287 let iter: u32 = parts[1]
1288 .parse()
1289 .map_err(|_| VaultError::Corrupt("invalid SCRAM iter".into()))?;
1290 if iter < crate::auth::scram::MIN_ITER {
1291 return Err(VaultError::Corrupt(format!(
1292 "SCRAM iter {} below minimum {}",
1293 iter,
1294 crate::auth::scram::MIN_ITER
1295 )));
1296 }
1297 let stored_vec = hex::decode(parts[2])
1298 .map_err(|_| VaultError::Corrupt("invalid SCRAM stored_key hex".into()))?;
1299 let server_vec = hex::decode(parts[3])
1300 .map_err(|_| VaultError::Corrupt("invalid SCRAM server_key hex".into()))?;
1301 let stored_key: [u8; 32] = stored_vec
1302 .try_into()
1303 .map_err(|_| VaultError::Corrupt("SCRAM stored_key must be 32 bytes".into()))?;
1304 let server_key: [u8; 32] = server_vec
1305 .try_into()
1306 .map_err(|_| VaultError::Corrupt("SCRAM server_key must be 32 bytes".into()))?;
1307 Ok(crate::auth::scram::ScramVerifier {
1308 salt,
1309 iter,
1310 stored_key,
1311 server_key,
1312 })
1313}
1314
1315fn read_vault_salt_from_pager(pager: &Pager) -> Result<[u8; 16], VaultError> {
1322 let page = pager
1323 .read_page_no_checksum(VAULT_HEADER_PAGE)
1324 .map_err(|e| VaultError::Pager(format!("vault page read: {e}")))?;
1325
1326 let content = page.content();
1327 if content.len() < VAULT_HEADER_PREAMBLE_SIZE {
1328 return Err(VaultError::Corrupt("vault page too short".into()));
1329 }
1330 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1331 return Err(VaultError::Corrupt("bad magic bytes".into()));
1332 }
1333
1334 let mut salt = [0u8; VAULT_SALT_SIZE];
1335 salt.copy_from_slice(&content[5..21]);
1336 Ok(salt)
1337}
1338
1339#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use crate::auth::{now_ms, ApiKey, Role, User};
1347 use crate::storage::engine::pager::PagerConfig;
1348
1349 fn sample_state() -> VaultState {
1350 let now = now_ms();
1351 VaultState {
1352 users: vec![
1353 User {
1354 username: "alice".into(),
1355 tenant_id: None,
1356 password_hash: "argon2id$aabbccdd$eeff0011".into(),
1357 scram_verifier: None,
1358 role: Role::Admin,
1359 api_keys: vec![ApiKey {
1360 key: "rk_abc123".into(),
1361 name: "ci-token".into(),
1362 role: Role::Write,
1363 created_at: now,
1364 expires_at: None,
1365 }],
1366 created_at: now,
1367 updated_at: now,
1368 enabled: true,
1369 },
1370 User {
1371 username: "bob".into(),
1372 tenant_id: None,
1373 password_hash: "argon2id$11223344$55667788".into(),
1374 scram_verifier: None,
1375 role: Role::Read,
1376 api_keys: vec![],
1377 created_at: now,
1378 updated_at: now,
1379 enabled: false,
1380 },
1381 ],
1382 api_keys: vec![(
1383 UserId::platform("alice"),
1384 ApiKey {
1385 key: "rk_abc123".into(),
1386 name: "ci-token".into(),
1387 role: Role::Write,
1388 created_at: now,
1389 expires_at: None,
1390 },
1391 )],
1392 bootstrapped: true,
1393 master_secret: None,
1394 kv: std::collections::HashMap::new(),
1395 }
1396 }
1397
1398 fn temp_pager() -> (Pager, std::path::PathBuf) {
1400 use std::sync::atomic::{AtomicU64, Ordering};
1401 static COUNTER: AtomicU64 = AtomicU64::new(0);
1402 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1403 let tmp_dir =
1404 std::env::temp_dir().join(format!("reddb_vault_test_{}_{}", std::process::id(), id));
1405 std::fs::create_dir_all(&tmp_dir).unwrap();
1406 let db_path = tmp_dir.join("test.rdb");
1407 let pager = Pager::open(&db_path, PagerConfig::default()).unwrap();
1408 (pager, tmp_dir)
1409 }
1410
1411 fn env_lock() -> &'static std::sync::Mutex<()> {
1412 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1413 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1414 }
1415
1416 fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
1417 unsafe {
1418 match value {
1419 Some(value) => std::env::set_var(name, value),
1420 None => std::env::remove_var(name),
1421 }
1422 }
1423 }
1424
1425 #[test]
1426 fn portable_vault_preserves_native_auth_payload_and_passphrase_contract() {
1427 use reddb_engine::query::local_database::LocalDatabase;
1428 use reddb_engine::storage::{
1429 entity::{EntityId, RowData, UnifiedEntity},
1430 manager::ManagerConfig,
1431 resident_file::EmbeddedResidentStore,
1432 store_wal::StoreWalAction,
1433 };
1434 use reddb_file::{OpenMode, StdVfs, Vfs};
1435
1436 let (pager, directory) = temp_pager();
1437 let password = "native portable interop password";
1438 let native = Vault::with_passphrase(&pager, password).expect("native key");
1439 let mut state = sample_state();
1440 state.master_secret = Some(vec![42; 32]);
1441 let native_envelope = native.seal_logical_export(&state).expect("native envelope");
1442 let path = directory.join("portable.rdb");
1443 let mut mode = OpenMode::create_new();
1444 mode.read = true;
1445 let file = StdVfs.open(&path, mode).expect("private file");
1446 let mut database = LocalDatabase::create(file, &path, 0).expect("portable database");
1447 database
1448 .vault_unlock(password, true)
1449 .expect("initialize portable collection");
1450 let mut store = EmbeddedResidentStore::open(
1451 database.into_inner(),
1452 &path,
1453 ManagerConfig::default(),
1454 false,
1455 0,
1456 )
1457 .expect("physical store");
1458 let mut entity =
1459 UnifiedEntity::table_row(EntityId::new(1), "red.local.vault", 1, Vec::new());
1460 entity.data = reddb_engine::storage::entity::EntityData::Row(RowData::with_names(
1461 vec![reddb_types::Value::Secret(
1462 hex::decode(native_envelope).expect("hex"),
1463 )],
1464 vec!["value".into()],
1465 ));
1466 store
1467 .publish_actions(&[StoreWalAction::upsert_entity(
1468 "red.local.vault",
1469 &entity,
1470 None,
1471 reddb_file::STORE_VERSION_CURRENT,
1472 )])
1473 .expect("import native envelope");
1474 let mut database =
1475 LocalDatabase::open(store.into_inner(), &path).expect("open imported file");
1476 let key = database
1477 .vault_unlock(password, false)
1478 .expect("native passphrase in portable runtime");
1479 database
1480 .vault_put(&key, "connection:admin", Some("new credential"))
1481 .expect("portable mutation");
1482 let store = EmbeddedResidentStore::open(
1483 database.into_inner(),
1484 &path,
1485 ManagerConfig::default(),
1486 false,
1487 0,
1488 )
1489 .expect("read back");
1490 let (entity, _) = store
1491 .get("red.local.vault", EntityId::new(1))
1492 .expect("record")
1493 .expect("Vault exists");
1494 let Some(reddb_types::Value::Secret(blob)) = entity
1495 .data
1496 .as_row()
1497 .and_then(|row| row.get_by_name("value"))
1498 else {
1499 panic!("sealed Vault")
1500 };
1501 let restored = Vault::unseal_logical_export_with_passphrase(&hex::encode(blob), password)
1502 .expect("portable envelope in native runtime");
1503 assert_eq!(restored.master_secret, state.master_secret);
1504 assert_eq!(restored.bootstrapped, state.bootstrapped);
1505 assert_eq!(restored.users.len(), state.users.len());
1506 for (actual, expected) in restored.users.iter().zip(&state.users) {
1507 assert_eq!(actual.username, expected.username);
1508 assert_eq!(actual.password_hash, expected.password_hash);
1509 assert_eq!(actual.tenant_id, expected.tenant_id);
1510 }
1511 assert_eq!(restored.api_keys.len(), state.api_keys.len());
1512 assert_eq!(
1513 restored.kv.get("connection:admin").map(String::as_str),
1514 Some("new credential")
1515 );
1516 drop(store);
1517 drop(native);
1518 drop(pager);
1519 std::fs::remove_dir_all(directory).expect("remove test files");
1520 }
1521
1522 #[test]
1523 fn test_vault_state_serialize_deserialize_roundtrip() {
1524 let state = sample_state();
1525 let serialized = state.serialize();
1526 let text = std::str::from_utf8(&serialized).unwrap();
1527
1528 assert!(text.contains("SEALED:true"));
1530 assert!(text.contains("USER:alice\t"));
1531 assert!(text.contains("USER:bob\t"));
1532 assert!(text.contains("KEY:alice\trk_abc123\t"));
1533
1534 let restored = VaultState::deserialize(&serialized).unwrap();
1536 assert!(restored.bootstrapped);
1537 assert_eq!(restored.users.len(), 2);
1538
1539 let alice = restored
1540 .users
1541 .iter()
1542 .find(|u| u.username == "alice")
1543 .unwrap();
1544 assert_eq!(alice.role, Role::Admin);
1545 assert!(alice.enabled);
1546 assert_eq!(alice.password_hash, "argon2id$aabbccdd$eeff0011");
1547 assert_eq!(alice.api_keys.len(), 1);
1548 assert_eq!(alice.api_keys[0].key, "rk_abc123");
1549 assert_eq!(alice.api_keys[0].name, "ci-token");
1550 assert_eq!(alice.api_keys[0].role, Role::Write);
1551
1552 let bob = restored.users.iter().find(|u| u.username == "bob").unwrap();
1553 assert_eq!(bob.role, Role::Read);
1554 assert!(!bob.enabled);
1555 assert!(bob.api_keys.is_empty());
1556
1557 assert_eq!(restored.api_keys.len(), 1);
1558 assert_eq!(restored.api_keys[0].0.username, "alice");
1559 assert!(restored.api_keys[0].0.tenant.is_none());
1560 }
1561
1562 #[test]
1563 fn test_vault_state_empty() {
1564 let state = VaultState {
1565 users: vec![],
1566 api_keys: vec![],
1567 bootstrapped: false,
1568 master_secret: None,
1569 kv: std::collections::HashMap::new(),
1570 };
1571 let serialized = state.serialize();
1572 let restored = VaultState::deserialize(&serialized).unwrap();
1573 assert!(!restored.bootstrapped);
1574 assert!(restored.users.is_empty());
1575 assert!(restored.api_keys.is_empty());
1576 }
1577
1578 #[test]
1579 fn test_vault_state_deserialize_invalid_utf8() {
1580 let bad_data = vec![0xFF, 0xFE, 0xFD];
1581 let result = VaultState::deserialize(&bad_data);
1582 assert!(result.is_err());
1583 }
1584
1585 #[test]
1586 fn test_vault_state_deserialize_bad_user_line() {
1587 let bad = b"USER:only_two\tfields\n";
1588 let result = VaultState::deserialize(bad);
1589 assert!(result.is_err());
1590 }
1591
1592 #[test]
1593 fn test_vault_state_deserialize_bad_key_line() {
1594 let bad = b"KEY:too\tfew\n";
1595 let result = VaultState::deserialize(bad);
1596 assert!(result.is_err());
1597 }
1598
1599 #[test]
1600 fn test_vault_state_deserialize_unknown_line_skipped() {
1601 let data = b"SEALED:false\nFUTURE:some_data\n";
1602 let result = VaultState::deserialize(data).unwrap();
1603 assert!(!result.bootstrapped);
1604 }
1605
1606 #[test]
1607 fn test_vault_pager_save_load_roundtrip() {
1608 let (pager, tmp_dir) = temp_pager();
1609
1610 let kp = KeyPair::generate();
1611 let certificate = kp.certificate_hex();
1612 let vault = Vault::with_certificate(&pager, &certificate).unwrap();
1613
1614 let loaded = vault.load(&pager).unwrap();
1616 assert!(loaded.is_none());
1617
1618 let state = sample_state();
1620 vault.save(&pager, &state).unwrap();
1621
1622 let restored = vault.load(&pager).unwrap().unwrap();
1624 assert!(restored.bootstrapped);
1625 assert_eq!(restored.users.len(), 2);
1626 assert_eq!(restored.api_keys.len(), 1);
1627
1628 let alice = restored
1629 .users
1630 .iter()
1631 .find(|u| u.username == "alice")
1632 .unwrap();
1633 assert_eq!(alice.role, Role::Admin);
1634 assert_eq!(alice.api_keys.len(), 1);
1635
1636 let vault2 = Vault::with_certificate(&pager, &certificate).unwrap();
1638 let restored2 = vault2.load(&pager).unwrap().unwrap();
1639 assert!(restored2.bootstrapped);
1640 assert_eq!(restored2.users.len(), 2);
1641
1642 drop(pager);
1644 let _ = std::fs::remove_dir_all(&tmp_dir);
1645 }
1646
1647 #[test]
1648 fn vault_rejects_impossible_chain_header_before_allocating() {
1649 let (pager, tmp_dir) = temp_pager();
1650 let keypair = KeyPair::generate();
1651 let vault = Vault::with_certificate_bytes(&pager, &keypair.certificate).unwrap();
1652 vault.save(&pager, &sample_state()).unwrap();
1653
1654 let mut header = pager.read_page_no_checksum(VAULT_HEADER_PAGE).unwrap();
1655 let bytes = header.as_bytes_mut();
1656 let chain_count_offset = HEADER_SIZE + VAULT_HEADER_PREAMBLE_SIZE + NONCE_SIZE;
1657 bytes[chain_count_offset..chain_count_offset + 4].copy_from_slice(&u32::MAX.to_le_bytes());
1658 pager
1659 .write_page_no_checksum(VAULT_HEADER_PAGE, header)
1660 .unwrap();
1661
1662 assert!(matches!(vault.load(&pager), Err(VaultError::Corrupt(_))));
1663 assert!(matches!(vault.save(&pager, &sample_state()), Ok(())));
1664
1665 drop(pager);
1666 let _ = std::fs::remove_dir_all(&tmp_dir);
1667 }
1668
1669 #[test]
1670 fn test_vault_wrong_key_fails_decryption() {
1671 let (pager, tmp_dir) = temp_pager();
1672
1673 let kp = KeyPair::generate();
1675 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1676 let state = VaultState {
1677 users: vec![],
1678 api_keys: vec![],
1679 bootstrapped: true,
1680 master_secret: None,
1681 kv: std::collections::HashMap::new(),
1682 };
1683 vault.save(&pager, &state).unwrap();
1684
1685 let wrong = KeyPair::generate();
1687 let vault2 = Vault::with_certificate_bytes(&pager, &wrong.certificate).unwrap();
1688 let result = vault2.load(&pager);
1689
1690 assert!(result.is_err());
1691
1692 drop(pager);
1694 let _ = std::fs::remove_dir_all(&tmp_dir);
1695 }
1696
1697 #[test]
1698 fn test_vault_no_key_error() {
1699 let (pager, tmp_dir) = temp_pager();
1700
1701 let _guard = env_lock().lock().unwrap();
1702 let old = std::env::var_os("REDDB_CERTIFICATE");
1703 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1704 unsafe {
1705 std::env::remove_var("REDDB_CERTIFICATE");
1706 std::env::remove_var("REDDB_CERTIFICATE_FILE");
1707 }
1708 let result = Vault::open(&pager);
1709 restore_env_var("REDDB_CERTIFICATE", old);
1710 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1711 assert!(matches!(result, Err(VaultError::NoKey)));
1712
1713 drop(pager);
1715 let _ = std::fs::remove_dir_all(&tmp_dir);
1716 }
1717
1718 #[test]
1719 fn test_vault_open_uses_certificate_env() {
1720 let (pager, tmp_dir) = temp_pager();
1721
1722 let _guard = env_lock().lock().unwrap();
1723 let old = std::env::var_os("REDDB_CERTIFICATE");
1724 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1725 let kp = KeyPair::generate();
1726 unsafe {
1727 std::env::set_var("REDDB_CERTIFICATE", kp.certificate_hex());
1728 std::env::remove_var("REDDB_CERTIFICATE_FILE");
1729 }
1730
1731 let vault = Vault::open(&pager).unwrap();
1733 let state = VaultState {
1734 users: vec![],
1735 api_keys: vec![],
1736 bootstrapped: false,
1737 master_secret: None,
1738 kv: std::collections::HashMap::new(),
1739 };
1740 vault.save(&pager, &state).unwrap();
1741
1742 let vault2 = Vault::open(&pager).unwrap();
1744 let loaded = vault2.load(&pager).unwrap().unwrap();
1745 assert!(!loaded.bootstrapped);
1746
1747 restore_env_var("REDDB_CERTIFICATE", old);
1748 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1749 drop(pager);
1750 let _ = std::fs::remove_dir_all(&tmp_dir);
1751 }
1752
1753 #[test]
1754 fn test_vault_open_uses_certificate_file_env() {
1755 let (pager, tmp_dir) = temp_pager();
1756
1757 let _guard = env_lock().lock().unwrap();
1758 let old = std::env::var_os("REDDB_CERTIFICATE");
1759 let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1760 let kp = KeyPair::generate();
1761 let cert_path = tmp_dir.join("certificate");
1762 std::fs::write(&cert_path, format!("{}\n", kp.certificate_hex())).unwrap();
1763 unsafe {
1764 std::env::remove_var("REDDB_CERTIFICATE");
1765 std::env::set_var("REDDB_CERTIFICATE_FILE", &cert_path);
1766 }
1767
1768 let vault = Vault::open(&pager).unwrap();
1769 let state = VaultState {
1770 users: vec![],
1771 api_keys: vec![],
1772 bootstrapped: false,
1773 master_secret: None,
1774 kv: std::collections::HashMap::new(),
1775 };
1776 vault.save(&pager, &state).unwrap();
1777
1778 let vault2 = Vault::open(&pager).unwrap();
1779 let loaded = vault2.load(&pager).unwrap().unwrap();
1780 assert!(!loaded.bootstrapped);
1781
1782 restore_env_var("REDDB_CERTIFICATE", old);
1783 restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1784 drop(pager);
1785 let _ = std::fs::remove_dir_all(&tmp_dir);
1786 }
1787
1788 #[test]
1793 fn test_keypair_generate_deterministic_certificate() {
1794 let kp = KeyPair::generate();
1795 assert_eq!(kp.master_secret.len(), 32);
1796 assert_eq!(kp.certificate.len(), 32);
1797
1798 let kp2 = KeyPair::from_master_secret(kp.master_secret.clone());
1800 assert_eq!(kp.certificate, kp2.certificate);
1801 }
1802
1803 #[test]
1804 fn test_keypair_sign_verify() {
1805 let kp = KeyPair::generate();
1806 let data = b"session:abc123";
1807 let sig = kp.sign(data);
1808 assert!(kp.verify(data, &sig));
1809
1810 assert!(!kp.verify(b"session:wrong", &sig));
1812
1813 let mut bad_sig = sig.clone();
1815 bad_sig[0] ^= 0xFF;
1816 assert!(!kp.verify(data, &bad_sig));
1817 }
1818
1819 #[test]
1820 fn test_keypair_certificate_hex() {
1821 let kp = KeyPair::generate();
1822 let hex_str = kp.certificate_hex();
1823 assert_eq!(hex_str.len(), 64); let decoded = hex::decode(&hex_str).unwrap();
1825 assert_eq!(decoded, kp.certificate);
1826 }
1827
1828 #[test]
1829 fn test_vault_certificate_seal_roundtrip() {
1830 let (pager, tmp_dir) = temp_pager();
1831
1832 let kp = KeyPair::generate();
1834 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1835
1836 let state = VaultState {
1838 users: vec![],
1839 api_keys: vec![],
1840 bootstrapped: true,
1841 master_secret: Some(kp.master_secret.clone()),
1842 kv: std::collections::HashMap::new(),
1843 };
1844 vault.save(&pager, &state).unwrap();
1845
1846 let vault2 = Vault::with_certificate(&pager, &kp.certificate_hex()).unwrap();
1848 let loaded = vault2.load(&pager).unwrap().unwrap();
1849 assert!(loaded.bootstrapped);
1850 assert_eq!(loaded.master_secret, Some(kp.master_secret.clone()));
1851
1852 let kp2 = KeyPair::from_master_secret(loaded.master_secret.unwrap());
1854 assert_eq!(kp.certificate, kp2.certificate);
1855
1856 drop(pager);
1857 let _ = std::fs::remove_dir_all(&tmp_dir);
1858 }
1859
1860 #[test]
1861 fn test_vault_certificate_wrong_cert_fails() {
1862 let (pager, tmp_dir) = temp_pager();
1863
1864 let kp = KeyPair::generate();
1866 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1867 let state = VaultState {
1868 users: vec![],
1869 api_keys: vec![],
1870 bootstrapped: true,
1871 master_secret: Some(kp.master_secret.clone()),
1872 kv: std::collections::HashMap::new(),
1873 };
1874 vault.save(&pager, &state).unwrap();
1875
1876 let kp2 = KeyPair::generate();
1878 let vault2 = Vault::with_certificate_bytes(&pager, &kp2.certificate).unwrap();
1879 let result = vault2.load(&pager);
1880 assert!(result.is_err());
1881
1882 drop(pager);
1883 let _ = std::fs::remove_dir_all(&tmp_dir);
1884 }
1885
1886 #[test]
1887 fn test_vault_state_master_secret_serialization() {
1888 let secret = vec![0xAA; 32];
1889 let state = VaultState {
1890 users: vec![],
1891 api_keys: vec![],
1892 bootstrapped: true,
1893 master_secret: Some(secret.clone()),
1894 kv: std::collections::HashMap::new(),
1895 };
1896 let serialized = state.serialize();
1897 let text = std::str::from_utf8(&serialized).unwrap();
1898 assert!(text.contains("MASTER_SECRET:"));
1899 assert!(text.contains(&hex::encode(&secret)));
1900
1901 let restored = VaultState::deserialize(&serialized).unwrap();
1902 assert_eq!(restored.master_secret, Some(secret));
1903 assert!(restored.bootstrapped);
1904 }
1905
1906 #[test]
1907 fn test_vault_state_no_master_secret_backward_compat() {
1908 let data = b"SEALED:true\n";
1910 let restored = VaultState::deserialize(data).unwrap();
1911 assert!(restored.master_secret.is_none());
1912 assert!(restored.bootstrapped);
1913 }
1914
1915 #[test]
1916 fn test_vault_state_scram_verifier_roundtrip() {
1917 use crate::auth::scram::ScramVerifier;
1918
1919 let verifier = ScramVerifier::from_password(
1920 "hunter2",
1921 b"reddb-vault-test-salt".to_vec(),
1922 crate::auth::scram::DEFAULT_ITER,
1923 );
1924
1925 let now = now_ms();
1926 let state = VaultState {
1927 users: vec![User {
1928 username: "carol".into(),
1929 tenant_id: None,
1930 password_hash: "argon2id$abc$def".into(),
1931 scram_verifier: Some(verifier.clone()),
1932 role: Role::Admin,
1933 api_keys: vec![],
1934 created_at: now,
1935 updated_at: now,
1936 enabled: true,
1937 }],
1938 api_keys: vec![],
1939 bootstrapped: true,
1940 master_secret: None,
1941 kv: std::collections::HashMap::new(),
1942 };
1943
1944 let bytes = state.serialize();
1945 let restored = VaultState::deserialize(&bytes).unwrap();
1946 let carol = restored
1947 .users
1948 .iter()
1949 .find(|u| u.username == "carol")
1950 .unwrap();
1951 let v = carol.scram_verifier.as_ref().expect("verifier round-trips");
1952 assert_eq!(v.salt, verifier.salt);
1953 assert_eq!(v.iter, verifier.iter);
1954 assert_eq!(v.stored_key, verifier.stored_key);
1955 assert_eq!(v.server_key, verifier.server_key);
1956 }
1957
1958 #[test]
1959 fn test_vault_state_pre_tenant_user_line_still_parses() {
1960 let now = now_ms();
1964 let line = format!(
1965 "USER:dave\targon2id$x$y\tread\ttrue\t{}\t{}\t\nSEALED:false\n",
1966 now, now
1967 );
1968 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1969 let dave = restored
1970 .users
1971 .iter()
1972 .find(|u| u.username == "dave")
1973 .unwrap();
1974 assert!(dave.scram_verifier.is_none());
1975 assert!(dave.tenant_id.is_none());
1976 }
1977
1978 #[test]
1979 fn test_vault_state_tenant_user_line_still_parses() {
1980 let now = now_ms();
1981 let line = format!(
1982 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\nSEALED:false\n",
1983 now, now
1984 );
1985 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1986 let erin = restored
1987 .users
1988 .iter()
1989 .find(|u| u.username == "erin")
1990 .unwrap();
1991 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1992 }
1993
1994 #[test]
1995 fn test_vault_state_legacy_user_line_with_extra_ownership_field_is_ignored() {
1996 let now = now_ms();
1997 let line = format!(
1998 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\ttrue\nSEALED:false\n",
1999 now, now
2000 );
2001 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
2002 let erin = restored
2003 .users
2004 .iter()
2005 .find(|u| u.username == "erin")
2006 .unwrap();
2007 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
2008 }
2009
2010 #[test]
2011 fn test_vault_state_user_line_with_tenant_roundtrip() {
2012 let now = now_ms();
2013 let state = VaultState {
2014 users: vec![User {
2015 username: "alice".into(),
2016 tenant_id: Some("acme".into()),
2017 password_hash: "argon2id$x$y".into(),
2018 scram_verifier: None,
2019 role: Role::Write,
2020 api_keys: vec![],
2021 created_at: now,
2022 updated_at: now,
2023 enabled: true,
2024 }],
2025 api_keys: vec![],
2026 bootstrapped: true,
2027 master_secret: None,
2028 kv: std::collections::HashMap::new(),
2029 };
2030 let bytes = state.serialize();
2031 let text = std::str::from_utf8(&bytes).unwrap();
2032 assert!(text.contains("\tacme\n"));
2034
2035 let restored = VaultState::deserialize(&bytes).unwrap();
2036 let alice = restored
2037 .users
2038 .iter()
2039 .find(|u| u.username == "alice")
2040 .unwrap();
2041 assert_eq!(alice.tenant_id.as_deref(), Some("acme"));
2042 }
2043
2044 #[test]
2045 fn test_vault_state_key_line_with_tenant_reattaches_correctly() {
2046 let now = now_ms();
2049 let state = VaultState {
2050 users: vec![
2051 User {
2052 username: "alice".into(),
2053 tenant_id: Some("acme".into()),
2054 password_hash: "argon2id$x$y".into(),
2055 scram_verifier: None,
2056 role: Role::Write,
2057 api_keys: vec![],
2058 created_at: now,
2059 updated_at: now,
2060 enabled: true,
2061 },
2062 User {
2063 username: "alice".into(),
2064 tenant_id: Some("globex".into()),
2065 password_hash: "argon2id$a$b".into(),
2066 scram_verifier: None,
2067 role: Role::Read,
2068 api_keys: vec![],
2069 created_at: now,
2070 updated_at: now,
2071 enabled: true,
2072 },
2073 ],
2074 api_keys: vec![
2075 (
2076 UserId::scoped("acme", "alice"),
2077 ApiKey {
2078 key: "rk_acme_key".into(),
2079 name: "deploy".into(),
2080 role: Role::Write,
2081 created_at: now,
2082 expires_at: None,
2083 },
2084 ),
2085 (
2086 UserId::scoped("globex", "alice"),
2087 ApiKey {
2088 key: "rk_globex_key".into(),
2089 name: "ci".into(),
2090 role: Role::Read,
2091 created_at: now,
2092 expires_at: None,
2093 },
2094 ),
2095 ],
2096 bootstrapped: true,
2097 master_secret: None,
2098 kv: std::collections::HashMap::new(),
2099 };
2100 let bytes = state.serialize();
2101 let restored = VaultState::deserialize(&bytes).unwrap();
2102 assert_eq!(restored.api_keys.len(), 2);
2105 let acme_key = restored
2106 .api_keys
2107 .iter()
2108 .find(|(o, _)| o.tenant.as_deref() == Some("acme"))
2109 .unwrap();
2110 assert_eq!(acme_key.1.key, "rk_acme_key");
2111 let globex_key = restored
2112 .api_keys
2113 .iter()
2114 .find(|(o, _)| o.tenant.as_deref() == Some("globex"))
2115 .unwrap();
2116 assert_eq!(globex_key.1.key, "rk_globex_key");
2117 }
2118
2119 #[test]
2120 fn test_vault_state_scram_iter_below_min_rejected() {
2121 let now = now_ms();
2122 let stored_hex = "00".repeat(32);
2126 let server_hex = "11".repeat(32);
2127 let line = format!(
2128 "USER:eve\targon2id$x$y\tread\ttrue\t{}\t{}\tdeadbeef:1024:{}:{}\n",
2129 now, now, stored_hex, server_hex
2130 );
2131 match VaultState::deserialize(line.as_bytes()) {
2132 Err(VaultError::Corrupt(msg)) => assert!(msg.contains("below minimum")),
2133 Err(other) => panic!("expected Corrupt iter-floor error, got {other:?}"),
2134 Ok(_) => panic!("expected Corrupt iter-floor error, got Ok"),
2135 }
2136 }
2137
2138 #[test]
2139 fn test_constant_time_eq_function() {
2140 assert!(constant_time_eq(b"hello", b"hello"));
2141 assert!(!constant_time_eq(b"hello", b"world"));
2142 assert!(!constant_time_eq(b"short", b"longer"));
2143 assert!(constant_time_eq(b"", b""));
2144 }
2145}