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;
86
87const VAULT_LEGACY_VERSION: u8 = 1;
90
91const VAULT_AAD: &[u8] = b"reddb-vault";
92
93use reddb_file::VAULT_LOGICAL_EXPORT_AAD;
97
98const VAULT_MAGIC_SIZE: usize = 4;
100const VAULT_VERSION_SIZE: usize = 1;
101const VAULT_SALT_SIZE: usize = 16;
102const VAULT_PAYLOAD_LEN_SIZE: usize = 4;
103const VAULT_CHAIN_COUNT_SIZE: usize = 4;
104const VAULT_FIRST_PAGE_ID_SIZE: usize = 4;
105
106const NONCE_SIZE: usize = 12;
108
109const VAULT_HEADER_PREAMBLE_SIZE: usize =
111 VAULT_MAGIC_SIZE + VAULT_VERSION_SIZE + VAULT_SALT_SIZE + VAULT_PAYLOAD_LEN_SIZE; const VAULT_HEADER_META_SIZE: usize =
116 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;
124
125const VAULT_HEADER_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_HEADER_META_SIZE;
128
129const VAULT_DATA_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_DATA_PREFIX_SIZE;
132
133pub struct KeyPair {
150 pub master_secret: Vec<u8>,
152 pub certificate: Vec<u8>,
154}
155
156impl KeyPair {
157 pub fn generate() -> Self {
159 let mut master_secret = vec![0u8; 32];
160 os_random::fill_bytes(&mut master_secret).expect("CSPRNG failed during keypair generation");
161 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
162 Self {
163 master_secret,
164 certificate: certificate.to_vec(),
165 }
166 }
167
168 pub fn from_master_secret(master_secret: Vec<u8>) -> Self {
171 let certificate = hmac_sha256(&master_secret, b"reddb-certificate-v1");
172 Self {
173 master_secret,
174 certificate: certificate.to_vec(),
175 }
176 }
177
178 pub fn vault_key_from_certificate(certificate: &[u8]) -> SecureKey {
183 let key_bytes = derive_key(certificate, b"reddb-vault-seal", &vault_argon2_params());
184 SecureKey::new(&key_bytes)
185 }
186
187 pub fn sign(&self, data: &[u8]) -> Vec<u8> {
189 hmac_sha256(&self.master_secret, data).to_vec()
190 }
191
192 pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
194 let expected = self.sign(data);
195 constant_time_eq(&expected, signature)
196 }
197
198 pub fn certificate_hex(&self) -> String {
200 hex::encode(&self.certificate)
201 }
202}
203
204fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
206 if a.len() != b.len() {
207 return false;
208 }
209 let mut diff: u8 = 0;
210 for (x, y) in a.iter().zip(b.iter()) {
211 diff |= x ^ y;
212 }
213 diff == 0
214}
215
216#[derive(Debug)]
222pub enum VaultError {
223 NoKey,
225 Encryption,
227 Decryption,
229 Io(std::io::Error),
231 Corrupt(String),
233 Pager(String),
235}
236
237impl std::fmt::Display for VaultError {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 Self::NoKey => write!(
241 f,
242 "no vault key: set REDDB_CERTIFICATE (or REDDB_VAULT_KEY) or provide a certificate"
243 ),
244 Self::Encryption => write!(f, "vault encryption failed"),
245 Self::Decryption => write!(f, "vault decryption failed (wrong key or corrupt data)"),
246 Self::Io(err) => write!(f, "vault I/O error: {err}"),
247 Self::Corrupt(msg) => write!(f, "vault corrupt: {msg}"),
248 Self::Pager(msg) => write!(f, "vault pager error: {msg}"),
249 }
250 }
251}
252
253impl std::error::Error for VaultError {}
254
255impl From<VaultError> for AuthError {
256 fn from(err: VaultError) -> Self {
257 AuthError::Internal(err.to_string())
258 }
259}
260
261#[derive(Debug, Default)]
269pub struct VaultState {
270 pub users: Vec<User>,
271 pub api_keys: Vec<(UserId, ApiKey)>,
275 pub bootstrapped: bool,
276 pub master_secret: Option<Vec<u8>>,
280 pub kv: std::collections::HashMap<String, String>,
284}
285
286impl VaultState {
287 pub fn serialize(&self) -> Vec<u8> {
289 let mut out = String::new();
290
291 if let Some(ref secret) = self.master_secret {
293 out.push_str(&format!("MASTER_SECRET:{}\n", hex::encode(secret)));
294 }
295
296 out.push_str(&format!("SEALED:{}\n", self.bootstrapped));
298
299 for user in &self.users {
312 let scram_field = match &user.scram_verifier {
313 Some(v) => format!(
314 "{}:{}:{}:{}",
315 hex::encode(&v.salt),
316 v.iter,
317 hex::encode(v.stored_key),
318 hex::encode(v.server_key),
319 ),
320 None => String::new(),
321 };
322 let tenant_field = user.tenant_id.clone().unwrap_or_default();
323 out.push_str(&format!(
324 "USER:{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
325 user.username,
326 user.password_hash,
327 user.role.as_str(),
328 user.enabled,
329 user.created_at,
330 user.updated_at,
331 scram_field,
332 tenant_field,
333 ));
334 }
335
336 for (owner, key) in &self.api_keys {
340 let tenant_field = owner.tenant.clone().unwrap_or_default();
341 out.push_str(&format!(
342 "KEY:{}\t{}\t{}\t{}\t{}\t{}\n",
343 owner.username,
344 key.key,
345 key.name,
346 key.role.as_str(),
347 key.created_at,
348 tenant_field,
349 ));
350 }
351
352 for (k, v) in &self.kv {
354 out.push_str(&format!("KV:{}\t{}\n", k, hex::encode(v.as_bytes())));
355 }
356
357 out.into_bytes()
358 }
359
360 pub fn deserialize(data: &[u8]) -> Result<Self, VaultError> {
362 let text = std::str::from_utf8(data)
363 .map_err(|_| VaultError::Corrupt("payload is not valid UTF-8".into()))?;
364
365 let mut users = Vec::new();
366 let mut api_keys: Vec<(UserId, ApiKey)> = Vec::new();
367 let mut bootstrapped = false;
368 let mut master_secret: Option<Vec<u8>> = None;
369 let mut kv: std::collections::HashMap<String, String> = std::collections::HashMap::new();
370
371 for line in text.lines() {
372 if line.is_empty() {
373 continue;
374 }
375
376 if let Some(rest) = line.strip_prefix("MASTER_SECRET:") {
377 master_secret = Some(
378 hex::decode(rest)
379 .map_err(|_| VaultError::Corrupt("invalid MASTER_SECRET hex".into()))?,
380 );
381 } else if let Some(rest) = line.strip_prefix("SEALED:") {
382 bootstrapped = rest == "true";
383 } else if let Some(rest) = line.strip_prefix("USER:") {
384 let parts: Vec<&str> = rest.split('\t').collect();
385 if !(7..=9).contains(&parts.len()) {
389 return Err(VaultError::Corrupt(format!(
390 "USER line has {} fields, expected 7, 8, or 9",
391 parts.len()
392 )));
393 }
394 let role = Role::from_str(parts[2])
395 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[2])))?;
396 let enabled = parts[3] == "true";
397 let created_at: u128 = parts[4]
398 .parse()
399 .map_err(|_| VaultError::Corrupt("invalid created_at".into()))?;
400 let updated_at: u128 = parts[5]
401 .parse()
402 .map_err(|_| VaultError::Corrupt("invalid updated_at".into()))?;
403 let scram_verifier = parts
404 .get(6)
405 .map(|s| s.trim())
406 .filter(|s| !s.is_empty())
407 .map(parse_scram_field)
408 .transpose()?;
409 let tenant_id = parts
410 .get(7)
411 .map(|s| s.trim())
412 .filter(|s| !s.is_empty())
413 .map(|s| s.to_string());
414
415 users.push(User {
416 username: parts[0].to_string(),
417 tenant_id,
418 password_hash: parts[1].to_string(),
419 scram_verifier,
420 role,
421 api_keys: Vec::new(), created_at,
423 updated_at,
424 enabled,
425 });
426 } else if let Some(rest) = line.strip_prefix("KEY:") {
427 let parts: Vec<&str> = rest.split('\t').collect();
428 if parts.len() != 5 && parts.len() != 6 {
430 return Err(VaultError::Corrupt(format!(
431 "KEY line has {} fields, expected 5 or 6",
432 parts.len()
433 )));
434 }
435 let role = Role::from_str(parts[3])
436 .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[3])))?;
437 let created_at: u128 = parts[4]
438 .parse()
439 .map_err(|_| VaultError::Corrupt("invalid key created_at".into()))?;
440 let tenant_id = parts
441 .get(5)
442 .map(|s| s.trim())
443 .filter(|s| !s.is_empty())
444 .map(|s| s.to_string());
445
446 api_keys.push((
447 UserId {
448 tenant: tenant_id,
449 username: parts[0].to_string(),
450 },
451 ApiKey {
452 key: parts[1].to_string(),
453 name: parts[2].to_string(),
454 role,
455 created_at,
456 },
457 ));
458 } else if let Some(rest) = line.strip_prefix("KV:") {
459 let parts: Vec<&str> = rest.splitn(2, '\t').collect();
460 if parts.len() == 2 {
461 if let Ok(bytes) = hex::decode(parts[1]) {
462 if let Ok(value) = String::from_utf8(bytes) {
463 kv.insert(parts[0].to_string(), value);
464 }
465 }
466 }
467 } else {
468 }
470 }
471
472 for (owner, key) in &api_keys {
476 if let Some(user) = users
477 .iter_mut()
478 .find(|u| u.username == owner.username && u.tenant_id == owner.tenant)
479 {
480 user.api_keys.push(key.clone());
481 }
482 }
483
484 Ok(Self {
485 users,
486 api_keys,
487 bootstrapped,
488 master_secret,
489 kv,
490 })
491 }
492}
493
494pub struct Vault {
505 key: SecureKey,
506 salt: [u8; 16],
507}
508
509fn vault_argon2_params() -> Argon2Params {
512 Argon2Params {
513 m_cost: 16 * 1024, t_cost: 3,
515 p: 1,
516 tag_len: 32,
517 }
518}
519
520impl Vault {
521 pub fn has_saved_state(pager: &Pager) -> bool {
523 pager
524 .read_page_no_checksum(VAULT_HEADER_PAGE)
525 .ok()
526 .map(|page| {
527 let content = page.content();
528 content.len() >= VAULT_MAGIC_SIZE && &content[0..VAULT_MAGIC_SIZE] == VAULT_MAGIC
529 })
530 .unwrap_or(false)
531 }
532
533 pub fn open(pager: &Pager, passphrase: Option<&str>) -> Result<Self, VaultError> {
542 if let Ok(cert_hex) = std::env::var("REDDB_CERTIFICATE") {
544 return Self::with_certificate(pager, &cert_hex);
545 }
546
547 let passphrase_str = std::env::var("REDDB_VAULT_KEY")
549 .ok()
550 .or_else(|| passphrase.map(|s| s.to_string()))
551 .ok_or(VaultError::NoKey)?;
552
553 let salt = match read_vault_salt_from_pager(pager) {
555 Ok(s) => s,
556 Err(_) => {
557 let mut salt = [0u8; 16];
559 let mut buf = [0u8; 16];
560 os_random::fill_bytes(&mut buf)
561 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
562 salt.copy_from_slice(&buf);
563 salt
564 }
565 };
566
567 let key_bytes = derive_key(passphrase_str.as_bytes(), &salt, &vault_argon2_params());
568 let key = SecureKey::new(&key_bytes);
569
570 Ok(Self { key, salt })
571 }
572
573 pub fn with_certificate(pager: &Pager, certificate_hex: &str) -> Result<Self, VaultError> {
579 let certificate = hex::decode(certificate_hex).map_err(|_| VaultError::NoKey)?;
580
581 let key = KeyPair::vault_key_from_certificate(&certificate);
582
583 let salt = match read_vault_salt_from_pager(pager) {
585 Ok(s) => s,
586 Err(_) => {
587 let mut s = [0u8; 16];
589 os_random::fill_bytes(&mut s)
590 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
591 s
592 }
593 };
594
595 Ok(Self { key, salt })
596 }
597
598 pub fn from_env(pager: &Pager) -> Result<Self, VaultError> {
602 if let Ok(cert_hex) = std::env::var("REDDB_CERTIFICATE") {
603 return Self::with_certificate(pager, &cert_hex);
604 }
605 if let Ok(passphrase) = std::env::var("REDDB_VAULT_KEY") {
606 return Self::open_with_passphrase(pager, &passphrase);
607 }
608 Err(VaultError::NoKey)
609 }
610
611 fn open_with_passphrase(pager: &Pager, passphrase: &str) -> Result<Self, VaultError> {
613 let salt = match read_vault_salt_from_pager(pager) {
614 Ok(s) => s,
615 Err(_) => {
616 let mut s = [0u8; 16];
617 os_random::fill_bytes(&mut s)
618 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
619 s
620 }
621 };
622
623 let key_bytes = derive_key(passphrase.as_bytes(), &salt, &vault_argon2_params());
624 let key = SecureKey::new(&key_bytes);
625 Ok(Self { key, salt })
626 }
627
628 pub fn with_certificate_bytes(pager: &Pager, certificate: &[u8]) -> Result<Self, VaultError> {
633 let key = KeyPair::vault_key_from_certificate(certificate);
634
635 let salt = match read_vault_salt_from_pager(pager) {
636 Ok(s) => s,
637 Err(_) => {
638 let mut s = [0u8; 16];
639 os_random::fill_bytes(&mut s)
640 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
641 s
642 }
643 };
644
645 Ok(Self { key, salt })
646 }
647
648 pub fn seal_logical_export(&self, state: &VaultState) -> Result<String, VaultError> {
654 let plaintext = state.serialize();
655 let mut nonce = [0u8; NONCE_SIZE];
656 os_random::fill_bytes(&mut nonce)
657 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
658
659 let key_bytes: &[u8] = self.key.as_bytes();
660 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
661 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_LOGICAL_EXPORT_AAD, &plaintext);
662
663 Ok(reddb_file::encode_vault_logical_export(
666 &self.salt,
667 &nonce,
668 &ciphertext,
669 ))
670 }
671
672 pub fn unseal_logical_export(
676 blob_hex: &str,
677 passphrase: Option<&str>,
678 ) -> Result<VaultState, VaultError> {
679 let (salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
680
681 let key = if let Ok(cert_hex) = std::env::var("REDDB_CERTIFICATE") {
682 let certificate = hex::decode(cert_hex).map_err(|_| VaultError::NoKey)?;
683 KeyPair::vault_key_from_certificate(&certificate)
684 } else {
685 let passphrase_str = std::env::var("REDDB_VAULT_KEY")
686 .ok()
687 .or_else(|| passphrase.map(|s| s.to_string()))
688 .ok_or(VaultError::NoKey)?;
689 let key_bytes = derive_key(passphrase_str.as_bytes(), &salt, &vault_argon2_params());
690 SecureKey::new(&key_bytes)
691 };
692
693 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
694 }
695
696 pub fn unseal_logical_export_with_passphrase(
698 blob_hex: &str,
699 passphrase: &str,
700 ) -> Result<VaultState, VaultError> {
701 let (salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
702 let key_bytes = derive_key(passphrase.as_bytes(), &salt, &vault_argon2_params());
703 let key = SecureKey::new(&key_bytes);
704 Self::decrypt_logical_export(&key, &nonce, &ciphertext)
705 }
706
707 fn decode_logical_export(
708 blob_hex: &str,
709 ) -> Result<([u8; VAULT_SALT_SIZE], [u8; NONCE_SIZE], Vec<u8>), VaultError> {
710 let env = reddb_file::decode_vault_logical_export(blob_hex)
713 .map_err(|e| VaultError::Corrupt(e.to_string()))?;
714 Ok((env.salt, env.nonce, env.ciphertext))
715 }
716
717 fn decrypt_logical_export(
718 key: &SecureKey,
719 nonce: &[u8; NONCE_SIZE],
720 ciphertext: &[u8],
721 ) -> Result<VaultState, VaultError> {
722 let key_bytes: &[u8] = key.as_bytes();
723 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
724 let plaintext = aes256_gcm_decrypt(key_arr, nonce, VAULT_LOGICAL_EXPORT_AAD, ciphertext)
725 .map_err(|_| VaultError::Decryption)?;
726 VaultState::deserialize(&plaintext)
727 }
728
729 pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
743 let plaintext = state.serialize();
744
745 let mut nonce = [0u8; NONCE_SIZE];
747 os_random::fill_bytes(&mut nonce)
748 .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
749
750 let key_bytes: &[u8] = self.key.as_bytes();
751 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
752 let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_AAD, &plaintext);
753 let cipher_total = ciphertext.len();
757 let payload_len = (NONCE_SIZE + cipher_total) as u32; let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
765 let overflow = cipher_total.saturating_sub(header_chunk_len);
766 let chain_count = overflow.div_ceil(VAULT_DATA_CIPHER_CAPACITY);
767
768 while pager
782 .page_count()
783 .map_err(|e| VaultError::Pager(e.to_string()))?
784 <= VAULT_HEADER_PAGE
785 {
786 pager
787 .allocate_page(PageType::Vault)
788 .map_err(|e| VaultError::Pager(format!("reserve vault slot: {e}")))?;
789 }
790
791 let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
801
802 let mut new_chain: Vec<u32> = Vec::with_capacity(chain_count);
809 for _ in 0..chain_count {
810 let page = pager
811 .allocate_page(PageType::Vault)
812 .map_err(|e| VaultError::Pager(format!("allocate vault data page: {e}")))?;
813 new_chain.push(page.page_id());
814 }
815
816 let mut cursor = header_chunk_len;
823 for i in 0..chain_count {
824 let next_id = if i + 1 < chain_count {
825 new_chain[i + 1]
826 } else {
827 0
828 };
829 let take = (cipher_total - cursor).min(VAULT_DATA_CIPHER_CAPACITY);
830 let frag = &ciphertext[cursor..cursor + take];
831 self.write_data_page(pager, new_chain[i], next_id, frag)?;
832 cursor += take;
833 }
834 debug_assert_eq!(cursor, cipher_total, "ciphertext spill accounting mismatch");
835
836 let first_data_page = new_chain.first().copied().unwrap_or(0);
844 self.write_header_page(
845 pager,
846 &nonce,
847 payload_len,
848 chain_count as u32,
849 first_data_page,
850 &ciphertext[..header_chunk_len],
851 )?;
852
853 pager
858 .flush()
859 .map_err(|e| VaultError::Pager(e.to_string()))?;
860
861 for &id in old_chain.iter() {
866 pager
867 .free_page(id)
868 .map_err(|e| VaultError::Pager(format!("free old vault page {id}: {e}")))?;
869 }
870
871 Ok(())
872 }
873
874 pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
878 let page = match pager.read_page_no_checksum(VAULT_HEADER_PAGE) {
880 Ok(p) => p,
881 Err(_) => return Ok(None),
882 };
883
884 let page_content = page.content();
885
886 if page_content.len() < VAULT_HEADER_META_SIZE {
887 return Ok(None);
888 }
889 if &page_content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
890 return Ok(None); }
892
893 let version = page_content[4];
894 if version == VAULT_LEGACY_VERSION {
895 return Err(VaultError::Corrupt(
899 "vault was bootstrapped with the legacy 2-page format \
900 (pre-RedDB v0.3); re-bootstrap with `red bootstrap` to upgrade"
901 .to_string(),
902 ));
903 }
904 if version != VAULT_VERSION {
905 return Err(VaultError::Corrupt(format!(
906 "unsupported vault version: {} (expected {})",
907 version, VAULT_VERSION
908 )));
909 }
910
911 let payload_len = u32::from_le_bytes(
913 page_content[21..25]
914 .try_into()
915 .map_err(|_| VaultError::Corrupt("bad payload length bytes".into()))?,
916 ) as usize;
917
918 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
919 let nonce: [u8; NONCE_SIZE] = page_content[nonce_start..nonce_start + NONCE_SIZE]
920 .try_into()
921 .map_err(|_| VaultError::Corrupt("bad nonce".into()))?;
922
923 let chain_count_off = nonce_start + NONCE_SIZE;
924 let chain_count = u32::from_le_bytes(
925 page_content[chain_count_off..chain_count_off + 4]
926 .try_into()
927 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
928 ) as usize;
929 let first_id_off = chain_count_off + 4;
930 let mut next_id = u32::from_le_bytes(
931 page_content[first_id_off..first_id_off + 4]
932 .try_into()
933 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
934 );
935
936 if payload_len < NONCE_SIZE {
937 return Err(VaultError::Corrupt("payload too short for nonce".into()));
938 }
939 let cipher_total = payload_len - NONCE_SIZE;
940
941 let mut cipher = Vec::with_capacity(cipher_total);
943 let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
944 let header_cipher_start = VAULT_HEADER_META_SIZE;
945 cipher.extend_from_slice(
946 &page_content[header_cipher_start..header_cipher_start + header_chunk_len],
947 );
948
949 let mut hops = 0usize;
951 while cipher.len() < cipher_total {
955 if hops >= chain_count {
956 return Err(VaultError::Corrupt(format!(
957 "vault chain shorter than declared: {} hops, expected {}",
958 hops, chain_count
959 )));
960 }
961 if next_id == 0 {
962 return Err(VaultError::Corrupt(
963 "vault chain ends prematurely (next_id == 0)".to_string(),
964 ));
965 }
966
967 let dp = pager
968 .read_page_no_checksum(next_id)
969 .map_err(|e| VaultError::Pager(format!("vault data page {next_id}: {e}")))?;
970 let dp_content = dp.content();
971 if dp_content.len() < VAULT_DATA_PREFIX_SIZE {
972 return Err(VaultError::Corrupt(format!(
973 "vault data page {next_id} truncated"
974 )));
975 }
976 if &dp_content[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC {
977 return Err(VaultError::Corrupt(format!(
978 "vault data page {next_id} has bad magic"
979 )));
980 }
981 let np = u32::from_le_bytes(
982 dp_content[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
983 .try_into()
984 .map_err(|_| VaultError::Corrupt("bad next_page_id bytes".into()))?,
985 );
986 let take = (cipher_total - cipher.len()).min(VAULT_DATA_CIPHER_CAPACITY);
987 let frag_start = VAULT_DATA_PREFIX_SIZE;
988 cipher.extend_from_slice(&dp_content[frag_start..frag_start + take]);
989
990 next_id = np;
991 hops += 1;
992 }
993
994 if cipher.len() != cipher_total {
995 return Err(VaultError::Corrupt(format!(
996 "vault truncated: expected {} cipher bytes, got {}",
997 cipher_total,
998 cipher.len()
999 )));
1000 }
1001 if hops != chain_count {
1002 return Err(VaultError::Corrupt(format!(
1003 "vault chain length mismatch: walked {} pages, header says {}",
1004 hops, chain_count
1005 )));
1006 }
1007
1008 let key_bytes: &[u8] = self.key.as_bytes();
1010 let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
1011 let plaintext = aes256_gcm_decrypt(key_arr, &nonce, VAULT_AAD, &cipher)
1012 .map_err(|_| VaultError::Decryption)?;
1013
1014 let state = VaultState::deserialize(&plaintext)?;
1015 Ok(Some(state))
1016 }
1017
1018 fn read_existing_chain_ids(&self, pager: &Pager) -> Result<Vec<u32>, VaultError> {
1025 let header = pager
1026 .read_page_no_checksum(VAULT_HEADER_PAGE)
1027 .map_err(|e| VaultError::Pager(e.to_string()))?;
1028 let content = header.content();
1029 if content.len() < VAULT_HEADER_META_SIZE {
1030 return Ok(Vec::new());
1031 }
1032 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1033 return Ok(Vec::new());
1034 }
1035 let version = content[4];
1036 if version != VAULT_VERSION {
1037 return Ok(Vec::new());
1041 }
1042 let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
1043 let chain_count_off = nonce_start + NONCE_SIZE;
1044 let chain_count = u32::from_le_bytes(
1045 content[chain_count_off..chain_count_off + 4]
1046 .try_into()
1047 .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1048 ) as usize;
1049 let first_id_off = chain_count_off + 4;
1050 let mut id = u32::from_le_bytes(
1051 content[first_id_off..first_id_off + 4]
1052 .try_into()
1053 .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1054 );
1055
1056 let mut out = Vec::with_capacity(chain_count);
1057 let mut hops = 0usize;
1058 while id != 0 && hops < chain_count {
1059 out.push(id);
1060 match pager.read_page_no_checksum(id) {
1063 Ok(dp) => {
1064 let dc = dp.content();
1065 if dc.len() < VAULT_DATA_PREFIX_SIZE
1066 || &dc[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC
1067 {
1068 break;
1069 }
1070 id = u32::from_le_bytes(
1071 dc[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1072 .try_into()
1073 .map_err(|_| VaultError::Corrupt("bad next_id".into()))?,
1074 );
1075 }
1076 Err(_) => break,
1077 }
1078 hops += 1;
1079 }
1080 Ok(out)
1081 }
1082
1083 fn write_header_page(
1087 &self,
1088 pager: &Pager,
1089 nonce: &[u8; NONCE_SIZE],
1090 payload_len: u32,
1091 chain_count: u32,
1092 first_data_page_id: u32,
1093 cipher_fragment: &[u8],
1094 ) -> Result<(), VaultError> {
1095 debug_assert!(cipher_fragment.len() <= VAULT_HEADER_CIPHER_CAPACITY);
1096
1097 let mut page = Page::new(PageType::Vault, VAULT_HEADER_PAGE);
1098 let bytes = page.as_bytes_mut();
1099 let mut off = HEADER_SIZE;
1100
1101 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_MAGIC);
1102 off += VAULT_MAGIC_SIZE;
1103
1104 bytes[off] = VAULT_VERSION;
1105 off += VAULT_VERSION_SIZE;
1106
1107 bytes[off..off + VAULT_SALT_SIZE].copy_from_slice(&self.salt);
1108 off += VAULT_SALT_SIZE;
1109
1110 bytes[off..off + 4].copy_from_slice(&payload_len.to_le_bytes());
1111 off += VAULT_PAYLOAD_LEN_SIZE;
1112
1113 bytes[off..off + NONCE_SIZE].copy_from_slice(nonce);
1114 off += NONCE_SIZE;
1115
1116 bytes[off..off + 4].copy_from_slice(&chain_count.to_le_bytes());
1117 off += VAULT_CHAIN_COUNT_SIZE;
1118
1119 bytes[off..off + 4].copy_from_slice(&first_data_page_id.to_le_bytes());
1120 off += VAULT_FIRST_PAGE_ID_SIZE;
1121
1122 debug_assert_eq!(off, HEADER_SIZE + VAULT_HEADER_META_SIZE);
1123
1124 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1125
1126 pager
1127 .write_page_no_checksum(VAULT_HEADER_PAGE, page)
1128 .map_err(|e| VaultError::Pager(e.to_string()))?;
1129 Ok(())
1130 }
1131
1132 fn write_data_page(
1134 &self,
1135 pager: &Pager,
1136 page_id: u32,
1137 next_page_id: u32,
1138 cipher_fragment: &[u8],
1139 ) -> Result<(), VaultError> {
1140 debug_assert!(cipher_fragment.len() <= VAULT_DATA_CIPHER_CAPACITY);
1141
1142 let mut page = Page::new(PageType::Vault, page_id);
1143 let bytes = page.as_bytes_mut();
1144 let mut off = HEADER_SIZE;
1145
1146 bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_DATA_MAGIC);
1147 off += VAULT_MAGIC_SIZE;
1148
1149 bytes[off..off + 4].copy_from_slice(&next_page_id.to_le_bytes());
1150 off += 4;
1151
1152 bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1153
1154 pager
1155 .write_page_no_checksum(page_id, page)
1156 .map_err(|e| VaultError::Pager(e.to_string()))?;
1157 Ok(())
1158 }
1159}
1160
1161fn parse_scram_field(field: &str) -> Result<crate::auth::scram::ScramVerifier, VaultError> {
1168 let parts: Vec<&str> = field.split(':').collect();
1169 if parts.len() != 4 {
1170 return Err(VaultError::Corrupt(format!(
1171 "SCRAM verifier has {} segments, expected 4",
1172 parts.len()
1173 )));
1174 }
1175 let salt =
1176 hex::decode(parts[0]).map_err(|_| VaultError::Corrupt("invalid SCRAM salt hex".into()))?;
1177 let iter: u32 = parts[1]
1178 .parse()
1179 .map_err(|_| VaultError::Corrupt("invalid SCRAM iter".into()))?;
1180 if iter < crate::auth::scram::MIN_ITER {
1181 return Err(VaultError::Corrupt(format!(
1182 "SCRAM iter {} below minimum {}",
1183 iter,
1184 crate::auth::scram::MIN_ITER
1185 )));
1186 }
1187 let stored_vec = hex::decode(parts[2])
1188 .map_err(|_| VaultError::Corrupt("invalid SCRAM stored_key hex".into()))?;
1189 let server_vec = hex::decode(parts[3])
1190 .map_err(|_| VaultError::Corrupt("invalid SCRAM server_key hex".into()))?;
1191 let stored_key: [u8; 32] = stored_vec
1192 .try_into()
1193 .map_err(|_| VaultError::Corrupt("SCRAM stored_key must be 32 bytes".into()))?;
1194 let server_key: [u8; 32] = server_vec
1195 .try_into()
1196 .map_err(|_| VaultError::Corrupt("SCRAM server_key must be 32 bytes".into()))?;
1197 Ok(crate::auth::scram::ScramVerifier {
1198 salt,
1199 iter,
1200 stored_key,
1201 server_key,
1202 })
1203}
1204
1205fn read_vault_salt_from_pager(pager: &Pager) -> Result<[u8; 16], VaultError> {
1212 let page = pager
1213 .read_page_no_checksum(VAULT_HEADER_PAGE)
1214 .map_err(|e| VaultError::Pager(format!("vault page read: {e}")))?;
1215
1216 let content = page.content();
1217 if content.len() < VAULT_HEADER_PREAMBLE_SIZE {
1218 return Err(VaultError::Corrupt("vault page too short".into()));
1219 }
1220 if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1221 return Err(VaultError::Corrupt("bad magic bytes".into()));
1222 }
1223
1224 let mut salt = [0u8; VAULT_SALT_SIZE];
1225 salt.copy_from_slice(&content[5..21]);
1226 Ok(salt)
1227}
1228
1229#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use crate::auth::{now_ms, ApiKey, Role, User};
1237 use crate::storage::engine::pager::PagerConfig;
1238
1239 fn sample_state() -> VaultState {
1240 let now = now_ms();
1241 VaultState {
1242 users: vec![
1243 User {
1244 username: "alice".into(),
1245 tenant_id: None,
1246 password_hash: "argon2id$aabbccdd$eeff0011".into(),
1247 scram_verifier: None,
1248 role: Role::Admin,
1249 api_keys: vec![ApiKey {
1250 key: "rk_abc123".into(),
1251 name: "ci-token".into(),
1252 role: Role::Write,
1253 created_at: now,
1254 }],
1255 created_at: now,
1256 updated_at: now,
1257 enabled: true,
1258 },
1259 User {
1260 username: "bob".into(),
1261 tenant_id: None,
1262 password_hash: "argon2id$11223344$55667788".into(),
1263 scram_verifier: None,
1264 role: Role::Read,
1265 api_keys: vec![],
1266 created_at: now,
1267 updated_at: now,
1268 enabled: false,
1269 },
1270 ],
1271 api_keys: vec![(
1272 UserId::platform("alice"),
1273 ApiKey {
1274 key: "rk_abc123".into(),
1275 name: "ci-token".into(),
1276 role: Role::Write,
1277 created_at: now,
1278 },
1279 )],
1280 bootstrapped: true,
1281 master_secret: None,
1282 kv: std::collections::HashMap::new(),
1283 }
1284 }
1285
1286 fn temp_pager() -> (Pager, std::path::PathBuf) {
1288 use std::sync::atomic::{AtomicU64, Ordering};
1289 static COUNTER: AtomicU64 = AtomicU64::new(0);
1290 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1291 let tmp_dir =
1292 std::env::temp_dir().join(format!("reddb_vault_test_{}_{}", std::process::id(), id));
1293 std::fs::create_dir_all(&tmp_dir).unwrap();
1294 let db_path = tmp_dir.join("test.rdb");
1295 let pager = Pager::open(&db_path, PagerConfig::default()).unwrap();
1296 (pager, tmp_dir)
1297 }
1298
1299 #[test]
1300 fn test_vault_state_serialize_deserialize_roundtrip() {
1301 let state = sample_state();
1302 let serialized = state.serialize();
1303 let text = std::str::from_utf8(&serialized).unwrap();
1304
1305 assert!(text.contains("SEALED:true"));
1307 assert!(text.contains("USER:alice\t"));
1308 assert!(text.contains("USER:bob\t"));
1309 assert!(text.contains("KEY:alice\trk_abc123\t"));
1310
1311 let restored = VaultState::deserialize(&serialized).unwrap();
1313 assert!(restored.bootstrapped);
1314 assert_eq!(restored.users.len(), 2);
1315
1316 let alice = restored
1317 .users
1318 .iter()
1319 .find(|u| u.username == "alice")
1320 .unwrap();
1321 assert_eq!(alice.role, Role::Admin);
1322 assert!(alice.enabled);
1323 assert_eq!(alice.password_hash, "argon2id$aabbccdd$eeff0011");
1324 assert_eq!(alice.api_keys.len(), 1);
1325 assert_eq!(alice.api_keys[0].key, "rk_abc123");
1326 assert_eq!(alice.api_keys[0].name, "ci-token");
1327 assert_eq!(alice.api_keys[0].role, Role::Write);
1328
1329 let bob = restored.users.iter().find(|u| u.username == "bob").unwrap();
1330 assert_eq!(bob.role, Role::Read);
1331 assert!(!bob.enabled);
1332 assert!(bob.api_keys.is_empty());
1333
1334 assert_eq!(restored.api_keys.len(), 1);
1335 assert_eq!(restored.api_keys[0].0.username, "alice");
1336 assert!(restored.api_keys[0].0.tenant.is_none());
1337 }
1338
1339 #[test]
1340 fn test_vault_state_empty() {
1341 let state = VaultState {
1342 users: vec![],
1343 api_keys: vec![],
1344 bootstrapped: false,
1345 master_secret: None,
1346 kv: std::collections::HashMap::new(),
1347 };
1348 let serialized = state.serialize();
1349 let restored = VaultState::deserialize(&serialized).unwrap();
1350 assert!(!restored.bootstrapped);
1351 assert!(restored.users.is_empty());
1352 assert!(restored.api_keys.is_empty());
1353 }
1354
1355 #[test]
1356 fn test_vault_state_deserialize_invalid_utf8() {
1357 let bad_data = vec![0xFF, 0xFE, 0xFD];
1358 let result = VaultState::deserialize(&bad_data);
1359 assert!(result.is_err());
1360 }
1361
1362 #[test]
1363 fn test_vault_state_deserialize_bad_user_line() {
1364 let bad = b"USER:only_two\tfields\n";
1365 let result = VaultState::deserialize(bad);
1366 assert!(result.is_err());
1367 }
1368
1369 #[test]
1370 fn test_vault_state_deserialize_bad_key_line() {
1371 let bad = b"KEY:too\tfew\n";
1372 let result = VaultState::deserialize(bad);
1373 assert!(result.is_err());
1374 }
1375
1376 #[test]
1377 fn test_vault_state_deserialize_unknown_line_skipped() {
1378 let data = b"SEALED:false\nFUTURE:some_data\n";
1379 let result = VaultState::deserialize(data).unwrap();
1380 assert!(!result.bootstrapped);
1381 }
1382
1383 #[test]
1384 fn test_vault_pager_save_load_roundtrip() {
1385 let (pager, tmp_dir) = temp_pager();
1386
1387 let vault = Vault::open(&pager, Some("test-passphrase-42")).unwrap();
1388
1389 let loaded = vault.load(&pager).unwrap();
1391 assert!(loaded.is_none());
1392
1393 let state = sample_state();
1395 vault.save(&pager, &state).unwrap();
1396
1397 let restored = vault.load(&pager).unwrap().unwrap();
1399 assert!(restored.bootstrapped);
1400 assert_eq!(restored.users.len(), 2);
1401 assert_eq!(restored.api_keys.len(), 1);
1402
1403 let alice = restored
1404 .users
1405 .iter()
1406 .find(|u| u.username == "alice")
1407 .unwrap();
1408 assert_eq!(alice.role, Role::Admin);
1409 assert_eq!(alice.api_keys.len(), 1);
1410
1411 let vault2 = Vault::open(&pager, Some("test-passphrase-42")).unwrap();
1413 let restored2 = vault2.load(&pager).unwrap().unwrap();
1414 assert!(restored2.bootstrapped);
1415 assert_eq!(restored2.users.len(), 2);
1416
1417 drop(pager);
1419 let _ = std::fs::remove_dir_all(&tmp_dir);
1420 }
1421
1422 #[test]
1423 fn test_vault_wrong_key_fails_decryption() {
1424 let (pager, tmp_dir) = temp_pager();
1425
1426 let vault = Vault::open(&pager, Some("correct-key")).unwrap();
1428 let state = VaultState {
1429 users: vec![],
1430 api_keys: vec![],
1431 bootstrapped: true,
1432 master_secret: None,
1433 kv: std::collections::HashMap::new(),
1434 };
1435 vault.save(&pager, &state).unwrap();
1436
1437 let vault2 = Vault::open(&pager, Some("wrong-key")).unwrap();
1439 let result = vault2.load(&pager);
1440
1441 assert!(result.is_err());
1442
1443 drop(pager);
1445 let _ = std::fs::remove_dir_all(&tmp_dir);
1446 }
1447
1448 #[test]
1449 fn test_vault_no_key_error() {
1450 let (pager, tmp_dir) = temp_pager();
1451
1452 let result = Vault::open(&pager, None);
1453 let has_env_key =
1457 std::env::var("REDDB_VAULT_KEY").is_ok() || std::env::var("REDDB_CERTIFICATE").is_ok();
1458 match has_env_key {
1459 true => {
1460 assert!(result.is_ok());
1462 }
1463 false => {
1464 assert!(matches!(result, Err(VaultError::NoKey)));
1465 }
1466 }
1467
1468 drop(pager);
1470 let _ = std::fs::remove_dir_all(&tmp_dir);
1471 }
1472
1473 #[test]
1474 fn test_vault_passphrase_argument() {
1475 let (pager, tmp_dir) = temp_pager();
1476
1477 let vault = Vault::open(&pager, Some("my-passphrase")).unwrap();
1479 let state = VaultState {
1480 users: vec![],
1481 api_keys: vec![],
1482 bootstrapped: false,
1483 master_secret: None,
1484 kv: std::collections::HashMap::new(),
1485 };
1486 vault.save(&pager, &state).unwrap();
1487
1488 let vault2 = Vault::open(&pager, Some("my-passphrase")).unwrap();
1490 let loaded = vault2.load(&pager).unwrap().unwrap();
1491 assert!(!loaded.bootstrapped);
1492
1493 drop(pager);
1494 let _ = std::fs::remove_dir_all(&tmp_dir);
1495 }
1496
1497 #[test]
1502 fn test_keypair_generate_deterministic_certificate() {
1503 let kp = KeyPair::generate();
1504 assert_eq!(kp.master_secret.len(), 32);
1505 assert_eq!(kp.certificate.len(), 32);
1506
1507 let kp2 = KeyPair::from_master_secret(kp.master_secret.clone());
1509 assert_eq!(kp.certificate, kp2.certificate);
1510 }
1511
1512 #[test]
1513 fn test_keypair_sign_verify() {
1514 let kp = KeyPair::generate();
1515 let data = b"session:abc123";
1516 let sig = kp.sign(data);
1517 assert!(kp.verify(data, &sig));
1518
1519 assert!(!kp.verify(b"session:wrong", &sig));
1521
1522 let mut bad_sig = sig.clone();
1524 bad_sig[0] ^= 0xFF;
1525 assert!(!kp.verify(data, &bad_sig));
1526 }
1527
1528 #[test]
1529 fn test_keypair_certificate_hex() {
1530 let kp = KeyPair::generate();
1531 let hex_str = kp.certificate_hex();
1532 assert_eq!(hex_str.len(), 64); let decoded = hex::decode(&hex_str).unwrap();
1534 assert_eq!(decoded, kp.certificate);
1535 }
1536
1537 #[test]
1538 fn test_vault_certificate_seal_roundtrip() {
1539 let (pager, tmp_dir) = temp_pager();
1540
1541 let kp = KeyPair::generate();
1543 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1544
1545 let state = VaultState {
1547 users: vec![],
1548 api_keys: vec![],
1549 bootstrapped: true,
1550 master_secret: Some(kp.master_secret.clone()),
1551 kv: std::collections::HashMap::new(),
1552 };
1553 vault.save(&pager, &state).unwrap();
1554
1555 let vault2 = Vault::with_certificate(&pager, &kp.certificate_hex()).unwrap();
1557 let loaded = vault2.load(&pager).unwrap().unwrap();
1558 assert!(loaded.bootstrapped);
1559 assert_eq!(loaded.master_secret, Some(kp.master_secret.clone()));
1560
1561 let kp2 = KeyPair::from_master_secret(loaded.master_secret.unwrap());
1563 assert_eq!(kp.certificate, kp2.certificate);
1564
1565 drop(pager);
1566 let _ = std::fs::remove_dir_all(&tmp_dir);
1567 }
1568
1569 #[test]
1570 fn test_vault_certificate_wrong_cert_fails() {
1571 let (pager, tmp_dir) = temp_pager();
1572
1573 let kp = KeyPair::generate();
1575 let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1576 let state = VaultState {
1577 users: vec![],
1578 api_keys: vec![],
1579 bootstrapped: true,
1580 master_secret: Some(kp.master_secret.clone()),
1581 kv: std::collections::HashMap::new(),
1582 };
1583 vault.save(&pager, &state).unwrap();
1584
1585 let kp2 = KeyPair::generate();
1587 let vault2 = Vault::with_certificate_bytes(&pager, &kp2.certificate).unwrap();
1588 let result = vault2.load(&pager);
1589 assert!(result.is_err());
1590
1591 drop(pager);
1592 let _ = std::fs::remove_dir_all(&tmp_dir);
1593 }
1594
1595 #[test]
1596 fn test_vault_state_master_secret_serialization() {
1597 let secret = vec![0xAA; 32];
1598 let state = VaultState {
1599 users: vec![],
1600 api_keys: vec![],
1601 bootstrapped: true,
1602 master_secret: Some(secret.clone()),
1603 kv: std::collections::HashMap::new(),
1604 };
1605 let serialized = state.serialize();
1606 let text = std::str::from_utf8(&serialized).unwrap();
1607 assert!(text.contains("MASTER_SECRET:"));
1608 assert!(text.contains(&hex::encode(&secret)));
1609
1610 let restored = VaultState::deserialize(&serialized).unwrap();
1611 assert_eq!(restored.master_secret, Some(secret));
1612 assert!(restored.bootstrapped);
1613 }
1614
1615 #[test]
1616 fn test_vault_state_no_master_secret_backward_compat() {
1617 let data = b"SEALED:true\n";
1619 let restored = VaultState::deserialize(data).unwrap();
1620 assert!(restored.master_secret.is_none());
1621 assert!(restored.bootstrapped);
1622 }
1623
1624 #[test]
1625 fn test_vault_state_scram_verifier_roundtrip() {
1626 use crate::auth::scram::ScramVerifier;
1627
1628 let verifier = ScramVerifier::from_password(
1629 "hunter2",
1630 b"reddb-vault-test-salt".to_vec(),
1631 crate::auth::scram::DEFAULT_ITER,
1632 );
1633
1634 let now = now_ms();
1635 let state = VaultState {
1636 users: vec![User {
1637 username: "carol".into(),
1638 tenant_id: None,
1639 password_hash: "argon2id$abc$def".into(),
1640 scram_verifier: Some(verifier.clone()),
1641 role: Role::Admin,
1642 api_keys: vec![],
1643 created_at: now,
1644 updated_at: now,
1645 enabled: true,
1646 }],
1647 api_keys: vec![],
1648 bootstrapped: true,
1649 master_secret: None,
1650 kv: std::collections::HashMap::new(),
1651 };
1652
1653 let bytes = state.serialize();
1654 let restored = VaultState::deserialize(&bytes).unwrap();
1655 let carol = restored
1656 .users
1657 .iter()
1658 .find(|u| u.username == "carol")
1659 .unwrap();
1660 let v = carol.scram_verifier.as_ref().expect("verifier round-trips");
1661 assert_eq!(v.salt, verifier.salt);
1662 assert_eq!(v.iter, verifier.iter);
1663 assert_eq!(v.stored_key, verifier.stored_key);
1664 assert_eq!(v.server_key, verifier.server_key);
1665 }
1666
1667 #[test]
1668 fn test_vault_state_pre_tenant_user_line_still_parses() {
1669 let now = now_ms();
1673 let line = format!(
1674 "USER:dave\targon2id$x$y\tread\ttrue\t{}\t{}\t\nSEALED:false\n",
1675 now, now
1676 );
1677 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1678 let dave = restored
1679 .users
1680 .iter()
1681 .find(|u| u.username == "dave")
1682 .unwrap();
1683 assert!(dave.scram_verifier.is_none());
1684 assert!(dave.tenant_id.is_none());
1685 }
1686
1687 #[test]
1688 fn test_vault_state_tenant_user_line_still_parses() {
1689 let now = now_ms();
1690 let line = format!(
1691 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\nSEALED:false\n",
1692 now, now
1693 );
1694 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1695 let erin = restored
1696 .users
1697 .iter()
1698 .find(|u| u.username == "erin")
1699 .unwrap();
1700 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1701 }
1702
1703 #[test]
1704 fn test_vault_state_legacy_user_line_with_extra_ownership_field_is_ignored() {
1705 let now = now_ms();
1706 let line = format!(
1707 "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\ttrue\nSEALED:false\n",
1708 now, now
1709 );
1710 let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1711 let erin = restored
1712 .users
1713 .iter()
1714 .find(|u| u.username == "erin")
1715 .unwrap();
1716 assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1717 }
1718
1719 #[test]
1720 fn test_vault_state_user_line_with_tenant_roundtrip() {
1721 let now = now_ms();
1722 let state = VaultState {
1723 users: vec![User {
1724 username: "alice".into(),
1725 tenant_id: Some("acme".into()),
1726 password_hash: "argon2id$x$y".into(),
1727 scram_verifier: None,
1728 role: Role::Write,
1729 api_keys: vec![],
1730 created_at: now,
1731 updated_at: now,
1732 enabled: true,
1733 }],
1734 api_keys: vec![],
1735 bootstrapped: true,
1736 master_secret: None,
1737 kv: std::collections::HashMap::new(),
1738 };
1739 let bytes = state.serialize();
1740 let text = std::str::from_utf8(&bytes).unwrap();
1741 assert!(text.contains("\tacme\n"));
1743
1744 let restored = VaultState::deserialize(&bytes).unwrap();
1745 let alice = restored
1746 .users
1747 .iter()
1748 .find(|u| u.username == "alice")
1749 .unwrap();
1750 assert_eq!(alice.tenant_id.as_deref(), Some("acme"));
1751 }
1752
1753 #[test]
1754 fn test_vault_state_key_line_with_tenant_reattaches_correctly() {
1755 let now = now_ms();
1758 let state = VaultState {
1759 users: vec![
1760 User {
1761 username: "alice".into(),
1762 tenant_id: Some("acme".into()),
1763 password_hash: "argon2id$x$y".into(),
1764 scram_verifier: None,
1765 role: Role::Write,
1766 api_keys: vec![],
1767 created_at: now,
1768 updated_at: now,
1769 enabled: true,
1770 },
1771 User {
1772 username: "alice".into(),
1773 tenant_id: Some("globex".into()),
1774 password_hash: "argon2id$a$b".into(),
1775 scram_verifier: None,
1776 role: Role::Read,
1777 api_keys: vec![],
1778 created_at: now,
1779 updated_at: now,
1780 enabled: true,
1781 },
1782 ],
1783 api_keys: vec![
1784 (
1785 UserId::scoped("acme", "alice"),
1786 ApiKey {
1787 key: "rk_acme_key".into(),
1788 name: "deploy".into(),
1789 role: Role::Write,
1790 created_at: now,
1791 },
1792 ),
1793 (
1794 UserId::scoped("globex", "alice"),
1795 ApiKey {
1796 key: "rk_globex_key".into(),
1797 name: "ci".into(),
1798 role: Role::Read,
1799 created_at: now,
1800 },
1801 ),
1802 ],
1803 bootstrapped: true,
1804 master_secret: None,
1805 kv: std::collections::HashMap::new(),
1806 };
1807 let bytes = state.serialize();
1808 let restored = VaultState::deserialize(&bytes).unwrap();
1809 assert_eq!(restored.api_keys.len(), 2);
1812 let acme_key = restored
1813 .api_keys
1814 .iter()
1815 .find(|(o, _)| o.tenant.as_deref() == Some("acme"))
1816 .unwrap();
1817 assert_eq!(acme_key.1.key, "rk_acme_key");
1818 let globex_key = restored
1819 .api_keys
1820 .iter()
1821 .find(|(o, _)| o.tenant.as_deref() == Some("globex"))
1822 .unwrap();
1823 assert_eq!(globex_key.1.key, "rk_globex_key");
1824 }
1825
1826 #[test]
1827 fn test_vault_state_scram_iter_below_min_rejected() {
1828 let now = now_ms();
1829 let stored_hex = "00".repeat(32);
1833 let server_hex = "11".repeat(32);
1834 let line = format!(
1835 "USER:eve\targon2id$x$y\tread\ttrue\t{}\t{}\tdeadbeef:1024:{}:{}\n",
1836 now, now, stored_hex, server_hex
1837 );
1838 match VaultState::deserialize(line.as_bytes()) {
1839 Err(VaultError::Corrupt(msg)) => assert!(msg.contains("below minimum")),
1840 Err(other) => panic!("expected Corrupt iter-floor error, got {other:?}"),
1841 Ok(_) => panic!("expected Corrupt iter-floor error, got Ok"),
1842 }
1843 }
1844
1845 #[test]
1846 fn test_constant_time_eq_function() {
1847 assert!(constant_time_eq(b"hello", b"hello"));
1848 assert!(!constant_time_eq(b"hello", b"world"));
1849 assert!(!constant_time_eq(b"short", b"longer"));
1850 assert!(constant_time_eq(b"", b""));
1851 }
1852}