Skip to main content

reddb_server/auth/
vault.rs

1//! Encrypted vault for auth state persistence.
2//!
3//! Stores users and API keys in **a chained set of reserved pages** inside
4//! the main `.rdb` database file instead of a separate `_vault.rdb` file.
5//! The vault header lives at a fixed page id (`VAULT_HEADER_PAGE = 2`)
6//! using `PageType::Vault` and points to a chain of overflow pages
7//! allocated dynamically as the payload grows. The contents are
8//! encrypted with a separate key derived from the certificate supplied
9//! through `REDDB_CERTIFICATE`.
10//!
11//! # On-disk format (v2)
12//!
13//! Header page content (inside the 4 KiB page, after the 32-byte
14//! page header — i.e. the bytes returned by `page.content()`):
15//!
16//! ```text
17//!   [ 4 bytes: magic "RDVT"             ]
18//!   [ 1 byte : version = 2              ]
19//!   [16 bytes: salt (key derivation)    ]
20//!   [ 4 bytes: total_payload_len u32 LE ]   // == NONCE_SIZE + ciphertext_len
21//!   [12 bytes: nonce                    ]
22//!   [ 4 bytes: chain_count u32 LE       ]   // number of data pages
23//!   [ 4 bytes: first_data_page_id u32 LE]   // 0 if no chain (single page)
24//!   [ N bytes: ciphertext fragment      ]   // first slice of the GCM ciphertext+tag
25//! ```
26//!
27//! Each data page (also `PageType::Vault`, allocated by the pager):
28//!
29//! ```text
30//!   [ 4 bytes: magic "RDVD"          ]
31//!   [ 4 bytes: next_page_id u32 LE   ]   // 0 if this is the last
32//!   [ N bytes: ciphertext fragment   ]
33//! ```
34//!
35//! The total ciphertext (with the 16-byte AES-GCM authentication tag at
36//! the end) is the concatenation of every fragment in chain order. We
37//! only call `aes256_gcm_decrypt` after the whole chain is reassembled,
38//! so a partial / corrupted chain produces a clean `Decryption` error
39//! instead of leaking unauthenticated bytes.
40//!
41//! # Plaintext payload format
42//!
43//! Newline-separated records:
44//!
45//! ```text
46//!   MASTER_SECRET:<hex>\n
47//!   SEALED:<true|false>\n
48//!   USER:<username>\t<password_hash>\t<role>\t<enabled>\t<created_at>\t<updated_at>\t<scram_verifier?>\n
49//!   KEY:<username>\t<key_string>\t<name>\t<role>\t<created_at>\n
50//!   KV:<key>\t<hex_value>\n
51//! ```
52//!
53//! # Crash safety
54//!
55//! Save order: encrypt → write all data pages first → finally rewrite
56//! the header page in place. The header page is the commit point: its
57//! `chain_count` + `first_data_page_id` describe whichever chain is
58//! actually usable on the next open. Crashing mid-save leaves the
59//! previous header (and its chain) untouched, so the old vault remains
60//! readable.
61//!
62//! When the new payload is *smaller* than the existing one, the surplus
63//! pages from the old chain are returned to the freelist via
64//! `Pager::free_page` so the file does not grow unbounded.
65
66use 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
76// ---------------------------------------------------------------------------
77// Constants
78// ---------------------------------------------------------------------------
79
80const VAULT_MAGIC: &[u8; 4] = b"RDVT";
81const VAULT_DATA_MAGIC: &[u8; 4] = b"RDVD";
82
83/// Current on-disk vault format. v1 was the legacy fixed two-page
84/// format (pages 2 + 3); v2 introduces the dynamic chain.
85const VAULT_VERSION: u8 = 2;
86
87/// Last legacy version. Pre-1.0 we refuse to migrate it — operators
88/// re-bootstrap with `red bootstrap` to upgrade.
89const VAULT_LEGACY_VERSION: u8 = 1;
90
91const VAULT_AAD: &[u8] = b"reddb-vault";
92
93// The logical-export envelope framing (`RDVX` magic + version + salt + nonce +
94// ciphertext, hex-encoded) lives in `reddb-file`. Key derivation and AES-GCM
95// stay here; we only borrow the AAD, which is part of the frozen wire contract.
96use reddb_file::VAULT_LOGICAL_EXPORT_AAD;
97
98/// Header content layout sizes (after the page's own 32-byte header).
99const 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
106/// AES-256-GCM nonce size.
107const NONCE_SIZE: usize = 12;
108
109/// Header preamble (everything up to and including `total_payload_len`).
110const VAULT_HEADER_PREAMBLE_SIZE: usize =
111    VAULT_MAGIC_SIZE + VAULT_VERSION_SIZE + VAULT_SALT_SIZE + VAULT_PAYLOAD_LEN_SIZE; // 25
112
113/// Total fixed metadata at the start of the header page's content area:
114/// preamble + nonce + chain_count + first_data_page_id.
115const VAULT_HEADER_META_SIZE: usize =
116    VAULT_HEADER_PREAMBLE_SIZE + NONCE_SIZE + VAULT_CHAIN_COUNT_SIZE + VAULT_FIRST_PAGE_ID_SIZE; // 25 + 12 + 4 + 4 = 45
117
118/// Fixed prefix on every data page (magic + next_page_id).
119const VAULT_DATA_PREFIX_SIZE: usize = VAULT_MAGIC_SIZE + 4; // 8 bytes
120
121/// Reserved page id for the vault header (entry point of the chain).
122/// Data pages are allocated dynamically and may live anywhere in the file.
123const VAULT_HEADER_PAGE: u32 = 2;
124
125/// Bytes of ciphertext that fit alongside the metadata in the header page.
126/// CONTENT_SIZE (4064) − VAULT_HEADER_META_SIZE (45) = 4019.
127const VAULT_HEADER_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_HEADER_META_SIZE;
128
129/// Bytes of ciphertext that fit in a single overflow page.
130/// CONTENT_SIZE (4064) − VAULT_DATA_PREFIX_SIZE (8) = 4056.
131const VAULT_DATA_CIPHER_CAPACITY: usize = CONTENT_SIZE - VAULT_DATA_PREFIX_SIZE;
132
133// ---------------------------------------------------------------------------
134// KeyPair -- certificate-based vault seal
135// ---------------------------------------------------------------------------
136
137/// RedDB cryptographic keypair for vault seal and token signing.
138///
139/// At bootstrap time a random `master_secret` is generated.  The
140/// `certificate` is derived from the master secret via HMAC-SHA256 and
141/// given to the admin.  The admin uses the certificate to unseal the
142/// vault on subsequent restarts.
143///
144/// ```text
145/// master_secret  = random_bytes(32)                            // lives in vault
146/// certificate    = HMAC-SHA256(master_secret, "reddb-certificate-v1")  // admin keeps this
147/// vault_key      = Argon2id(certificate, "reddb-vault-seal")   // AES-256-GCM key for vault
148/// ```
149pub struct KeyPair {
150    /// 32-byte master secret (stays encrypted inside the vault).
151    pub master_secret: Vec<u8>,
152    /// 32-byte certificate derived from master secret (admin keeps this).
153    pub certificate: Vec<u8>,
154}
155
156impl KeyPair {
157    /// Generate a fresh keypair at bootstrap time.
158    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    /// Re-derive a keypair from a known master secret (used when
169    /// restoring state from the decrypted vault).
170    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    /// Derive the vault encryption key from a certificate.
179    ///
180    /// This is the only operation that does NOT require the master
181    /// secret -- anyone holding the certificate can unseal the vault.
182    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    /// Sign arbitrary data with the master secret (HMAC-SHA256).
188    pub fn sign(&self, data: &[u8]) -> Vec<u8> {
189        hmac_sha256(&self.master_secret, data).to_vec()
190    }
191
192    /// Verify a signature produced by [`sign`](Self::sign).
193    pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
194        let expected = self.sign(data);
195        constant_time_eq(&expected, signature)
196    }
197
198    /// Certificate as a hex string (what the admin saves).
199    pub fn certificate_hex(&self) -> String {
200        hex::encode(&self.certificate)
201    }
202}
203
204/// Constant-time byte comparison to avoid timing side-channels.
205fn 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// ---------------------------------------------------------------------------
217// VaultError
218// ---------------------------------------------------------------------------
219
220/// Errors produced by vault operations.
221#[derive(Debug)]
222pub enum VaultError {
223    /// No certificate available.
224    NoKey,
225    /// Encryption failed.
226    Encryption,
227    /// Decryption failed (wrong key or corrupt data).
228    Decryption,
229    /// IO error reading/writing vault pages.
230    Io(std::io::Error),
231    /// The vault data is structurally corrupt.
232    Corrupt(String),
233    /// Pager error during page I/O.
234    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 certificate: set REDDB_CERTIFICATE or REDDB_CERTIFICATE_FILE"
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
261fn decode_certificate_hex(certificate_hex: &str) -> Result<Vec<u8>, VaultError> {
262    let certificate = hex::decode(certificate_hex.trim()).map_err(|_| VaultError::NoKey)?;
263    if certificate.len() != 32 {
264        return Err(VaultError::NoKey);
265    }
266    Ok(certificate)
267}
268
269// ---------------------------------------------------------------------------
270// VaultState
271// ---------------------------------------------------------------------------
272
273/// Serializable snapshot of all auth state (users, api keys, bootstrap seal,
274/// the master secret for the certificate-based seal, and a key-value store
275/// for arbitrary encrypted secrets).
276#[derive(Debug, Default)]
277pub struct VaultState {
278    pub users: Vec<User>,
279    /// `(owner UserId, api_key)` pairs. The owner carries tenant scope
280    /// so an API key under `(acme, alice)` reattaches to the correct
281    /// user when a same-named user exists in another tenant.
282    pub api_keys: Vec<(UserId, ApiKey)>,
283    pub bootstrapped: bool,
284    /// The 32-byte master secret stored inside the encrypted vault.
285    /// Present after bootstrap; `None` for legacy vaults that pre-date
286    /// the certificate seal system.
287    pub master_secret: Option<Vec<u8>>,
288    /// Arbitrary encrypted key-value store for secrets.
289    /// Keys use dot-notation with `red.secret.*` prefix (e.g., "red.secret.aes_key").
290    /// Values are hex-encoded bytes or UTF-8 strings.
291    pub kv: std::collections::HashMap<String, String>,
292}
293
294impl VaultState {
295    /// Serialize the vault state to the text payload format.
296    pub fn serialize(&self) -> Vec<u8> {
297        let mut out = String::new();
298
299        // Master secret (if present from certificate-based seal).
300        if let Some(ref secret) = self.master_secret {
301            out.push_str(&format!("MASTER_SECRET:{}\n", hex::encode(secret)));
302        }
303
304        // SEALED line.
305        out.push_str(&format!("SEALED:{}\n", self.bootstrapped));
306
307        // Users.
308        //
309        // USER line tabs: <username> <pw_hash> <role> <enabled>
310        // <created_at> <updated_at> <scram_verifier?> <tenant_id?>
311        //
312        // Field counts accepted on read:
313        //   * 7 fields — pre-tenant USER line (any prior v2 vault
314        //     written before tenant scoping landed).
315        //   * 8 fields — current USER line. The 8th field is the
316        //     tenant id; empty string = platform tenant (`None`).
317        //
318        // Verifier encoding: `<salt_hex>:<iter>:<stored_hex>:<server_hex>`.
319        for user in &self.users {
320            let scram_field = match &user.scram_verifier {
321                Some(v) => format!(
322                    "{}:{}:{}:{}",
323                    hex::encode(&v.salt),
324                    v.iter,
325                    hex::encode(v.stored_key),
326                    hex::encode(v.server_key),
327                ),
328                None => String::new(),
329            };
330            let tenant_field = user.tenant_id.clone().unwrap_or_default();
331            out.push_str(&format!(
332                "USER:{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
333                user.username,
334                user.password_hash,
335                user.role.as_str(),
336                user.enabled,
337                user.created_at,
338                user.updated_at,
339                scram_field,
340                tenant_field,
341            ));
342        }
343
344        // API keys: `KEY:<username>\t<key>\t<name>\t<role>\t<created_at>\t<tenant_id?>`.
345        // The 6th tenant field is empty for platform users and disambiguates
346        // owners when the same username appears under multiple tenants.
347        for (owner, key) in &self.api_keys {
348            let tenant_field = owner.tenant.clone().unwrap_or_default();
349            out.push_str(&format!(
350                "KEY:{}\t{}\t{}\t{}\t{}\t{}\n",
351                owner.username,
352                key.key,
353                key.name,
354                key.role.as_str(),
355                key.created_at,
356                tenant_field,
357            ));
358        }
359
360        // KV entries (hex-encoded values to avoid newline/tab collisions).
361        for (k, v) in &self.kv {
362            out.push_str(&format!("KV:{}\t{}\n", k, hex::encode(v.as_bytes())));
363        }
364
365        out.into_bytes()
366    }
367
368    /// Deserialize the vault state from the text payload format.
369    pub fn deserialize(data: &[u8]) -> Result<Self, VaultError> {
370        let text = std::str::from_utf8(data)
371            .map_err(|_| VaultError::Corrupt("payload is not valid UTF-8".into()))?;
372
373        let mut users = Vec::new();
374        let mut api_keys: Vec<(UserId, ApiKey)> = Vec::new();
375        let mut bootstrapped = false;
376        let mut master_secret: Option<Vec<u8>> = None;
377        let mut kv: std::collections::HashMap<String, String> = std::collections::HashMap::new();
378
379        for line in text.lines() {
380            if line.is_empty() {
381                continue;
382            }
383
384            if let Some(rest) = line.strip_prefix("MASTER_SECRET:") {
385                master_secret = Some(
386                    hex::decode(rest)
387                        .map_err(|_| VaultError::Corrupt("invalid MASTER_SECRET hex".into()))?,
388                );
389            } else if let Some(rest) = line.strip_prefix("SEALED:") {
390                bootstrapped = rest == "true";
391            } else if let Some(rest) = line.strip_prefix("USER:") {
392                let parts: Vec<&str> = rest.split('\t').collect();
393                // 7 fields = pre-tenant v2 USER line; 8 fields = with
394                // tenant id appended; 9 fields = legacy ownership field,
395                // accepted for old vaults and ignored.
396                if !(7..=9).contains(&parts.len()) {
397                    return Err(VaultError::Corrupt(format!(
398                        "USER line has {} fields, expected 7, 8, or 9",
399                        parts.len()
400                    )));
401                }
402                let role = Role::from_str(parts[2])
403                    .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[2])))?;
404                let enabled = parts[3] == "true";
405                let created_at: u128 = parts[4]
406                    .parse()
407                    .map_err(|_| VaultError::Corrupt("invalid created_at".into()))?;
408                let updated_at: u128 = parts[5]
409                    .parse()
410                    .map_err(|_| VaultError::Corrupt("invalid updated_at".into()))?;
411                let scram_verifier = parts
412                    .get(6)
413                    .map(|s| s.trim())
414                    .filter(|s| !s.is_empty())
415                    .map(parse_scram_field)
416                    .transpose()?;
417                let tenant_id = parts
418                    .get(7)
419                    .map(|s| s.trim())
420                    .filter(|s| !s.is_empty())
421                    .map(|s| s.to_string());
422
423                users.push(User {
424                    username: parts[0].to_string(),
425                    tenant_id,
426                    password_hash: parts[1].to_string(),
427                    scram_verifier,
428                    role,
429                    api_keys: Vec::new(), // API keys are attached separately below
430                    created_at,
431                    updated_at,
432                    enabled,
433                });
434            } else if let Some(rest) = line.strip_prefix("KEY:") {
435                let parts: Vec<&str> = rest.split('\t').collect();
436                // 5 fields = pre-tenant; 6 fields = with tenant id.
437                if parts.len() != 5 && parts.len() != 6 {
438                    return Err(VaultError::Corrupt(format!(
439                        "KEY line has {} fields, expected 5 or 6",
440                        parts.len()
441                    )));
442                }
443                let role = Role::from_str(parts[3])
444                    .ok_or_else(|| VaultError::Corrupt(format!("unknown role: {}", parts[3])))?;
445                let created_at: u128 = parts[4]
446                    .parse()
447                    .map_err(|_| VaultError::Corrupt("invalid key created_at".into()))?;
448                let tenant_id = parts
449                    .get(5)
450                    .map(|s| s.trim())
451                    .filter(|s| !s.is_empty())
452                    .map(|s| s.to_string());
453
454                api_keys.push((
455                    UserId {
456                        tenant: tenant_id,
457                        username: parts[0].to_string(),
458                    },
459                    ApiKey {
460                        key: parts[1].to_string(),
461                        name: parts[2].to_string(),
462                        role,
463                        created_at,
464                    },
465                ));
466            } else if let Some(rest) = line.strip_prefix("KV:") {
467                let parts: Vec<&str> = rest.splitn(2, '\t').collect();
468                if parts.len() == 2 {
469                    if let Ok(bytes) = hex::decode(parts[1]) {
470                        if let Ok(value) = String::from_utf8(bytes) {
471                            kv.insert(parts[0].to_string(), value);
472                        }
473                    }
474                }
475            } else {
476                // Unknown line prefix -- skip gracefully for forward compat.
477            }
478        }
479
480        // Re-attach API keys to their owning users. Match on the full
481        // `(tenant, username)` so a key for `(acme, alice)` doesn't
482        // accidentally attach to `(globex, alice)`.
483        for (owner, key) in &api_keys {
484            if let Some(user) = users
485                .iter_mut()
486                .find(|u| u.username == owner.username && u.tenant_id == owner.tenant)
487            {
488                user.api_keys.push(key.clone());
489            }
490        }
491
492        Ok(Self {
493            users,
494            api_keys,
495            bootstrapped,
496            master_secret,
497            kv,
498        })
499    }
500}
501
502// ---------------------------------------------------------------------------
503// Vault
504// ---------------------------------------------------------------------------
505
506/// Encrypted vault for persisting auth state inside reserved pager pages.
507///
508/// The vault key is derived from the certificate supplied via
509/// `REDDB_CERTIFICATE` (or its `_FILE` companion, expanded at process
510/// start). A random salt is generated on first write and persisted inside
511/// the vault page for format continuity.
512pub struct Vault {
513    key: SecureKey,
514    salt: [u8; 16],
515}
516
517/// Argon2id parameters tuned for vault key derivation.
518/// Lighter than the default (16 MB vs 64 MB) so vault open is quick.
519fn vault_argon2_params() -> Argon2Params {
520    Argon2Params {
521        m_cost: 16 * 1024, // 16 MB
522        t_cost: 3,
523        p: 1,
524        tag_len: 32,
525    }
526}
527
528impl Vault {
529    /// Return true when the pager contains a written vault header.
530    pub fn has_saved_state(pager: &Pager) -> bool {
531        pager
532            .read_page_no_checksum(VAULT_HEADER_PAGE)
533            .ok()
534            .map(|page| {
535                let content = page.content();
536                content.len() >= VAULT_MAGIC_SIZE && &content[0..VAULT_MAGIC_SIZE] == VAULT_MAGIC
537            })
538            .unwrap_or(false)
539    }
540
541    /// Open or prepare a vault backed by reserved pager pages.
542    ///
543    /// Key derivation requires `REDDB_CERTIFICATE`. If it is not set,
544    /// returns `NoKey`.
545    ///
546    /// If vault pages already exist in the pager, the salt is read from
547    /// the existing page content.  Otherwise a fresh salt is generated
548    /// and will be written on the first `save()` call.
549    pub fn open(pager: &Pager) -> Result<Self, VaultError> {
550        let cert_hex =
551            crate::utils::env_with_file_fallback("REDDB_CERTIFICATE").ok_or(VaultError::NoKey)?;
552        Self::with_certificate(pager, &cert_hex)
553    }
554
555    fn salt_for_open(pager: &Pager) -> Result<[u8; 16], VaultError> {
556        // Try to read the salt from an existing vault page.
557        match read_vault_salt_from_pager(pager) {
558            Ok(s) => Ok(s),
559            Err(_) => {
560                // No vault pages yet -- generate a fresh salt.
561                let mut salt = [0u8; 16];
562                let mut buf = [0u8; 16];
563                os_random::fill_bytes(&mut buf)
564                    .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
565                salt.copy_from_slice(&buf);
566                Ok(salt)
567            }
568        }
569    }
570
571    /// Open a vault using a certificate hex string (from bootstrap).
572    ///
573    /// The certificate is used to derive the vault encryption key via
574    /// Argon2id.  This is the primary unseal mechanism introduced by the
575    /// certificate-based seal system.
576    pub fn with_certificate(pager: &Pager, certificate_hex: &str) -> Result<Self, VaultError> {
577        let certificate = decode_certificate_hex(certificate_hex)?;
578
579        let key = KeyPair::vault_key_from_certificate(&certificate);
580
581        let salt = Self::salt_for_open(pager)?;
582
583        Ok(Self { key, salt })
584    }
585
586    /// Open a vault from environment variables.
587    ///
588    /// Requires `REDDB_CERTIFICATE`.
589    pub fn from_env(pager: &Pager) -> Result<Self, VaultError> {
590        Self::open(pager)
591    }
592
593    /// Create a vault keyed by a certificate (raw bytes, not hex).
594    ///
595    /// Used during bootstrap when the certificate is freshly generated
596    /// and not yet hex-encoded.
597    pub fn with_certificate_bytes(pager: &Pager, certificate: &[u8]) -> Result<Self, VaultError> {
598        if certificate.len() != 32 {
599            return Err(VaultError::NoKey);
600        }
601        let key = KeyPair::vault_key_from_certificate(certificate);
602
603        let salt = match read_vault_salt_from_pager(pager) {
604            Ok(s) => s,
605            Err(_) => {
606                let mut s = [0u8; 16];
607                os_random::fill_bytes(&mut s)
608                    .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
609                s
610            }
611        };
612
613        Ok(Self { key, salt })
614    }
615
616    /// Encrypt a vault state into a self-contained logical export blob.
617    ///
618    /// The source salt is embedded for envelope framing. Certificate-based
619    /// imports derive the same wrapping key from `REDDB_CERTIFICATE`.
620    /// The blob is hex-encoded so it can live inside JSONL dumps.
621    pub fn seal_logical_export(&self, state: &VaultState) -> Result<String, VaultError> {
622        let plaintext = state.serialize();
623        let mut nonce = [0u8; NONCE_SIZE];
624        os_random::fill_bytes(&mut nonce)
625            .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
626
627        let key_bytes: &[u8] = self.key.as_bytes();
628        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
629        let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_LOGICAL_EXPORT_AAD, &plaintext);
630
631        // Envelope framing (magic + version + salt + nonce + ciphertext, hex)
632        // lives in reddb-file; we only supply the encrypted parts.
633        Ok(reddb_file::encode_vault_logical_export(
634            &self.salt,
635            &nonce,
636            &ciphertext,
637        ))
638    }
639
640    /// Decrypt a logical export blob using the same certificate env var as
641    /// normal vault open.
642    pub fn unseal_logical_export(blob_hex: &str) -> Result<VaultState, VaultError> {
643        let cert_hex =
644            crate::utils::env_with_file_fallback("REDDB_CERTIFICATE").ok_or(VaultError::NoKey)?;
645        Self::unseal_logical_export_with_certificate(blob_hex, &cert_hex)
646    }
647
648    /// Deterministic helper path that ignores vault env vars.
649    pub fn unseal_logical_export_with_certificate(
650        blob_hex: &str,
651        certificate_hex: &str,
652    ) -> Result<VaultState, VaultError> {
653        let (_salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
654        let certificate = decode_certificate_hex(certificate_hex)?;
655        let key = KeyPair::vault_key_from_certificate(&certificate);
656        Self::decrypt_logical_export(&key, &nonce, &ciphertext)
657    }
658
659    fn decode_logical_export(
660        blob_hex: &str,
661    ) -> Result<([u8; VAULT_SALT_SIZE], [u8; NONCE_SIZE], Vec<u8>), VaultError> {
662        // Envelope parsing lives in reddb-file; map its framing error onto our
663        // operator-facing VaultError::Corrupt with the same message text.
664        let env = reddb_file::decode_vault_logical_export(blob_hex)
665            .map_err(|e| VaultError::Corrupt(e.to_string()))?;
666        Ok((env.salt, env.nonce, env.ciphertext))
667    }
668
669    fn decrypt_logical_export(
670        key: &SecureKey,
671        nonce: &[u8; NONCE_SIZE],
672        ciphertext: &[u8],
673    ) -> Result<VaultState, VaultError> {
674        let key_bytes: &[u8] = key.as_bytes();
675        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
676        let plaintext = aes256_gcm_decrypt(key_arr, nonce, VAULT_LOGICAL_EXPORT_AAD, ciphertext)
677            .map_err(|_| VaultError::Decryption)?;
678        VaultState::deserialize(&plaintext)
679    }
680
681    /// Save the given auth state to the encrypted vault pages.
682    ///
683    /// Order of operations is the only thing keeping this crash-safe:
684    ///   1. Encrypt the serialized state under a fresh nonce.
685    ///   2. Allocate (or reuse) the data-page chain and write every
686    ///      data page to disk.
687    ///   3. Free any surplus pages that the previous chain owned.
688    ///   4. Rewrite the header page in place — this is the commit
689    ///      point. After it lands, `load()` will follow the new chain.
690    ///
691    /// A crash anywhere before step 4 leaves the existing header (and
692    /// its chain) intact, so the previous vault snapshot is still
693    /// readable on the next open.
694    pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
695        let plaintext = state.serialize();
696
697        // Fresh nonce per write — required for AES-GCM.
698        let mut nonce = [0u8; NONCE_SIZE];
699        os_random::fill_bytes(&mut nonce)
700            .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
701
702        let key_bytes: &[u8] = self.key.as_bytes();
703        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
704        let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_AAD, &plaintext);
705        // The 16-byte GCM tag is appended to `ciphertext` already; we
706        // treat the whole vector as one opaque blob.
707
708        let cipher_total = ciphertext.len();
709        let payload_len = (NONCE_SIZE + cipher_total) as u32; // for legacy field
710
711        // ---- 1. Plan the chain --------------------------------------
712        //
713        // The header page absorbs the first `VAULT_HEADER_CIPHER_CAPACITY`
714        // bytes of ciphertext; everything after spills into a chain of
715        // data pages with `VAULT_DATA_CIPHER_CAPACITY` bytes each.
716        let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
717        let overflow = cipher_total.saturating_sub(header_chunk_len);
718        let chain_count = overflow.div_ceil(VAULT_DATA_CIPHER_CAPACITY);
719
720        // ---- 2. Reserve VAULT_HEADER_PAGE on a fresh DB -------------
721        //
722        // The pager hands out ids from `page_count` upward. On a brand
723        // new file `page_count == 1` (only the database header at id
724        // 0), so without this dance the next call to `allocate_page`
725        // would happily return id 1 and then id 2 — colliding with our
726        // fixed VAULT_HEADER_PAGE. Burn allocations until `page_count`
727        // is past the header so future `allocate_page(Vault)` calls
728        // for the data chain return ids >= VAULT_HEADER_PAGE + 1.
729        //
730        // We pass `PageType::Vault` so anyone scanning page types sees
731        // the right tag for these reserved slots; the header-page
732        // contents get overwritten below in any case.
733        while pager
734            .page_count()
735            .map_err(|e| VaultError::Pager(e.to_string()))?
736            <= VAULT_HEADER_PAGE
737        {
738            pager
739                .allocate_page(PageType::Vault)
740                .map_err(|e| VaultError::Pager(format!("reserve vault slot: {e}")))?;
741        }
742
743        // ---- 3. Snapshot the previous chain (if any) for later cleanup.
744        //
745        // We do NOT reuse these ids — overwriting an old data page
746        // before the header is rewritten would corrupt the live vault
747        // mid-save (the still-valid header would point at a page that
748        // now has the *new* ciphertext but the *old* nonce). Allocating
749        // fresh pages means the old chain stays byte-identical until
750        // the header commit, so `load()` keeps working through any
751        // crash before step 7.
752        let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
753
754        // ---- 4. Allocate fresh data-page ids for the new chain.
755        //
756        // The pager pulls from the freelist first, so successive
757        // saves recycle ids without growing the file — the old chain
758        // is freed at step 6 below and becomes available the *next*
759        // time we save.
760        let mut new_chain: Vec<u32> = Vec::with_capacity(chain_count);
761        for _ in 0..chain_count {
762            let page = pager
763                .allocate_page(PageType::Vault)
764                .map_err(|e| VaultError::Pager(format!("allocate vault data page: {e}")))?;
765            new_chain.push(page.page_id());
766        }
767
768        // ---- 5. Write data pages. We already know every page id up
769        // front (allocated in step 4), so each page can record its
770        // successor's id directly — no second pass needed. The header
771        // is *not* updated yet, so a crash here leaves `load()`
772        // looking at the previous chain (which is still on disk and
773        // valid because we did not touch its pages).
774        let mut cursor = header_chunk_len;
775        for i in 0..chain_count {
776            let next_id = if i + 1 < chain_count {
777                new_chain[i + 1]
778            } else {
779                0
780            };
781            let take = (cipher_total - cursor).min(VAULT_DATA_CIPHER_CAPACITY);
782            let frag = &ciphertext[cursor..cursor + take];
783            self.write_data_page(pager, new_chain[i], next_id, frag)?;
784            cursor += take;
785        }
786        debug_assert_eq!(cursor, cipher_total, "ciphertext spill accounting mismatch");
787
788        // ---- 6. (Deferred) The old chain pages are freed *after* the
789        // header commit so the freelist doesn't hand them back out
790        // before we've finished swapping over.
791
792        // ---- 7. Rewrite the header page. This is the commit point —
793        // after this write the new chain is authoritative and any
794        // future load() will follow it.
795        let first_data_page = new_chain.first().copied().unwrap_or(0);
796        self.write_header_page(
797            pager,
798            &nonce,
799            payload_len,
800            chain_count as u32,
801            first_data_page,
802            &ciphertext[..header_chunk_len],
803        )?;
804
805        // ---- 8. Flush so a process crash after return doesn't lose
806        // the write. We flush *before* freeing old pages so the new
807        // header is durable on disk before we tell the pager those
808        // old ids are reusable.
809        pager
810            .flush()
811            .map_err(|e| VaultError::Pager(e.to_string()))?;
812
813        // ---- 9. Now safe to free the old chain. The freelist update
814        // makes those page ids reclaimable on the *next* allocation,
815        // which is exactly what we want — the old data is no longer
816        // referenced by the (just-flushed) header.
817        for &id in old_chain.iter() {
818            pager
819                .free_page(id)
820                .map_err(|e| VaultError::Pager(format!("free old vault page {id}: {e}")))?;
821        }
822
823        Ok(())
824    }
825
826    /// Load auth state from the encrypted vault pages.
827    ///
828    /// Returns `Ok(None)` if the vault pages do not exist yet (fresh DB).
829    pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
830        // Header page is the entry point.
831        let page = match pager.read_page_no_checksum(VAULT_HEADER_PAGE) {
832            Ok(p) => p,
833            Err(_) => return Ok(None),
834        };
835
836        let page_content = page.content();
837
838        if page_content.len() < VAULT_HEADER_META_SIZE {
839            return Ok(None);
840        }
841        if &page_content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
842            return Ok(None); // Slot is reserved but never written.
843        }
844
845        let version = page_content[4];
846        if version == VAULT_LEGACY_VERSION {
847            // Pre-1.0: no migration shim. Fail loudly with operator
848            // guidance so this gets surfaced during upgrade and not
849            // hidden behind a generic decryption error.
850            return Err(VaultError::Corrupt(
851                "vault was bootstrapped with the legacy 2-page format \
852                 (pre-RedDB v0.3); re-bootstrap with `red bootstrap` to upgrade"
853                    .to_string(),
854            ));
855        }
856        if version != VAULT_VERSION {
857            return Err(VaultError::Corrupt(format!(
858                "unsupported vault version: {} (expected {})",
859                version, VAULT_VERSION
860            )));
861        }
862
863        // Decode header preamble.
864        let payload_len = u32::from_le_bytes(
865            page_content[21..25]
866                .try_into()
867                .map_err(|_| VaultError::Corrupt("bad payload length bytes".into()))?,
868        ) as usize;
869
870        let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
871        let nonce: [u8; NONCE_SIZE] = page_content[nonce_start..nonce_start + NONCE_SIZE]
872            .try_into()
873            .map_err(|_| VaultError::Corrupt("bad nonce".into()))?;
874
875        let chain_count_off = nonce_start + NONCE_SIZE;
876        let chain_count = u32::from_le_bytes(
877            page_content[chain_count_off..chain_count_off + 4]
878                .try_into()
879                .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
880        ) as usize;
881        let first_id_off = chain_count_off + 4;
882        let mut next_id = u32::from_le_bytes(
883            page_content[first_id_off..first_id_off + 4]
884                .try_into()
885                .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
886        );
887
888        if payload_len < NONCE_SIZE {
889            return Err(VaultError::Corrupt("payload too short for nonce".into()));
890        }
891        let cipher_total = payload_len - NONCE_SIZE;
892
893        // ---- Reassemble ciphertext fragments. ----------------------
894        let mut cipher = Vec::with_capacity(cipher_total);
895        let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
896        let header_cipher_start = VAULT_HEADER_META_SIZE;
897        cipher.extend_from_slice(
898            &page_content[header_cipher_start..header_cipher_start + header_chunk_len],
899        );
900
901        // Walk the data-page chain.
902        let mut hops = 0usize;
903        // Bound the walk: chain_count from the header is the source of
904        // truth. We tolerate next_id pointers but trust chain_count to
905        // avoid getting trapped in a corrupt loop.
906        while cipher.len() < cipher_total {
907            if hops >= chain_count {
908                return Err(VaultError::Corrupt(format!(
909                    "vault chain shorter than declared: {} hops, expected {}",
910                    hops, chain_count
911                )));
912            }
913            if next_id == 0 {
914                return Err(VaultError::Corrupt(
915                    "vault chain ends prematurely (next_id == 0)".to_string(),
916                ));
917            }
918
919            let dp = pager
920                .read_page_no_checksum(next_id)
921                .map_err(|e| VaultError::Pager(format!("vault data page {next_id}: {e}")))?;
922            let dp_content = dp.content();
923            if dp_content.len() < VAULT_DATA_PREFIX_SIZE {
924                return Err(VaultError::Corrupt(format!(
925                    "vault data page {next_id} truncated"
926                )));
927            }
928            if &dp_content[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC {
929                return Err(VaultError::Corrupt(format!(
930                    "vault data page {next_id} has bad magic"
931                )));
932            }
933            let np = u32::from_le_bytes(
934                dp_content[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
935                    .try_into()
936                    .map_err(|_| VaultError::Corrupt("bad next_page_id bytes".into()))?,
937            );
938            let take = (cipher_total - cipher.len()).min(VAULT_DATA_CIPHER_CAPACITY);
939            let frag_start = VAULT_DATA_PREFIX_SIZE;
940            cipher.extend_from_slice(&dp_content[frag_start..frag_start + take]);
941
942            next_id = np;
943            hops += 1;
944        }
945
946        if cipher.len() != cipher_total {
947            return Err(VaultError::Corrupt(format!(
948                "vault truncated: expected {} cipher bytes, got {}",
949                cipher_total,
950                cipher.len()
951            )));
952        }
953        if hops != chain_count {
954            return Err(VaultError::Corrupt(format!(
955                "vault chain length mismatch: walked {} pages, header says {}",
956                hops, chain_count
957            )));
958        }
959
960        // ---- Decrypt the reassembled blob in one shot. -------------
961        let key_bytes: &[u8] = self.key.as_bytes();
962        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
963        let plaintext = aes256_gcm_decrypt(key_arr, &nonce, VAULT_AAD, &cipher)
964            .map_err(|_| VaultError::Decryption)?;
965
966        let state = VaultState::deserialize(&plaintext)?;
967        Ok(Some(state))
968    }
969
970    /// Walk the existing chain (if any) and collect the data-page ids,
971    /// so `save()` can reuse / free them. Returns an error or empty
972    /// vector if the chain isn't intact — callers must treat that as
973    /// "no reusable chain" rather than failing the save outright,
974    /// because a partially-corrupt chain is exactly the case where we
975    /// most want a fresh write to land cleanly.
976    fn read_existing_chain_ids(&self, pager: &Pager) -> Result<Vec<u32>, VaultError> {
977        let header = pager
978            .read_page_no_checksum(VAULT_HEADER_PAGE)
979            .map_err(|e| VaultError::Pager(e.to_string()))?;
980        let content = header.content();
981        if content.len() < VAULT_HEADER_META_SIZE {
982            return Ok(Vec::new());
983        }
984        if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
985            return Ok(Vec::new());
986        }
987        let version = content[4];
988        if version != VAULT_VERSION {
989            // v1 (legacy) had its overflow at fixed page 3; we don't
990            // know if that page is "ours" to free. Safer to leak it
991            // — the operator is re-bootstrapping anyway.
992            return Ok(Vec::new());
993        }
994        let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
995        let chain_count_off = nonce_start + NONCE_SIZE;
996        let chain_count = u32::from_le_bytes(
997            content[chain_count_off..chain_count_off + 4]
998                .try_into()
999                .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1000        ) as usize;
1001        let first_id_off = chain_count_off + 4;
1002        let mut id = u32::from_le_bytes(
1003            content[first_id_off..first_id_off + 4]
1004                .try_into()
1005                .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1006        );
1007
1008        let mut out = Vec::with_capacity(chain_count);
1009        let mut hops = 0usize;
1010        while id != 0 && hops < chain_count {
1011            out.push(id);
1012            // Peek next_id off the data page. Soft-fail on read errors
1013            // — we'd rather leak a page than refuse to save.
1014            match pager.read_page_no_checksum(id) {
1015                Ok(dp) => {
1016                    let dc = dp.content();
1017                    if dc.len() < VAULT_DATA_PREFIX_SIZE
1018                        || &dc[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC
1019                    {
1020                        break;
1021                    }
1022                    id = u32::from_le_bytes(
1023                        dc[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1024                            .try_into()
1025                            .map_err(|_| VaultError::Corrupt("bad next_id".into()))?,
1026                    );
1027                }
1028                Err(_) => break,
1029            }
1030            hops += 1;
1031        }
1032        Ok(out)
1033    }
1034
1035    /// Write the vault header page (magic + version + chain metadata +
1036    /// nonce + first ciphertext fragment). This is the commit point —
1037    /// callers must have flushed every data page first.
1038    fn write_header_page(
1039        &self,
1040        pager: &Pager,
1041        nonce: &[u8; NONCE_SIZE],
1042        payload_len: u32,
1043        chain_count: u32,
1044        first_data_page_id: u32,
1045        cipher_fragment: &[u8],
1046    ) -> Result<(), VaultError> {
1047        debug_assert!(cipher_fragment.len() <= VAULT_HEADER_CIPHER_CAPACITY);
1048
1049        let mut page = Page::new(PageType::Vault, VAULT_HEADER_PAGE);
1050        let bytes = page.as_bytes_mut();
1051        let mut off = HEADER_SIZE;
1052
1053        bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_MAGIC);
1054        off += VAULT_MAGIC_SIZE;
1055
1056        bytes[off] = VAULT_VERSION;
1057        off += VAULT_VERSION_SIZE;
1058
1059        bytes[off..off + VAULT_SALT_SIZE].copy_from_slice(&self.salt);
1060        off += VAULT_SALT_SIZE;
1061
1062        bytes[off..off + 4].copy_from_slice(&payload_len.to_le_bytes());
1063        off += VAULT_PAYLOAD_LEN_SIZE;
1064
1065        bytes[off..off + NONCE_SIZE].copy_from_slice(nonce);
1066        off += NONCE_SIZE;
1067
1068        bytes[off..off + 4].copy_from_slice(&chain_count.to_le_bytes());
1069        off += VAULT_CHAIN_COUNT_SIZE;
1070
1071        bytes[off..off + 4].copy_from_slice(&first_data_page_id.to_le_bytes());
1072        off += VAULT_FIRST_PAGE_ID_SIZE;
1073
1074        debug_assert_eq!(off, HEADER_SIZE + VAULT_HEADER_META_SIZE);
1075
1076        bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1077
1078        pager
1079            .write_page_no_checksum(VAULT_HEADER_PAGE, page)
1080            .map_err(|e| VaultError::Pager(e.to_string()))?;
1081        Ok(())
1082    }
1083
1084    /// Write a data page (magic + next_page_id + ciphertext fragment).
1085    fn write_data_page(
1086        &self,
1087        pager: &Pager,
1088        page_id: u32,
1089        next_page_id: u32,
1090        cipher_fragment: &[u8],
1091    ) -> Result<(), VaultError> {
1092        debug_assert!(cipher_fragment.len() <= VAULT_DATA_CIPHER_CAPACITY);
1093
1094        let mut page = Page::new(PageType::Vault, page_id);
1095        let bytes = page.as_bytes_mut();
1096        let mut off = HEADER_SIZE;
1097
1098        bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_DATA_MAGIC);
1099        off += VAULT_MAGIC_SIZE;
1100
1101        bytes[off..off + 4].copy_from_slice(&next_page_id.to_le_bytes());
1102        off += 4;
1103
1104        bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1105
1106        pager
1107            .write_page_no_checksum(page_id, page)
1108            .map_err(|e| VaultError::Pager(e.to_string()))?;
1109        Ok(())
1110    }
1111}
1112
1113// ---------------------------------------------------------------------------
1114// Helpers
1115// ---------------------------------------------------------------------------
1116
1117/// Decode a SCRAM verifier field of the form
1118/// `<salt_hex>:<iter>:<stored_hex>:<server_hex>` into a `ScramVerifier`.
1119fn parse_scram_field(field: &str) -> Result<crate::auth::scram::ScramVerifier, VaultError> {
1120    let parts: Vec<&str> = field.split(':').collect();
1121    if parts.len() != 4 {
1122        return Err(VaultError::Corrupt(format!(
1123            "SCRAM verifier has {} segments, expected 4",
1124            parts.len()
1125        )));
1126    }
1127    let salt =
1128        hex::decode(parts[0]).map_err(|_| VaultError::Corrupt("invalid SCRAM salt hex".into()))?;
1129    let iter: u32 = parts[1]
1130        .parse()
1131        .map_err(|_| VaultError::Corrupt("invalid SCRAM iter".into()))?;
1132    if iter < crate::auth::scram::MIN_ITER {
1133        return Err(VaultError::Corrupt(format!(
1134            "SCRAM iter {} below minimum {}",
1135            iter,
1136            crate::auth::scram::MIN_ITER
1137        )));
1138    }
1139    let stored_vec = hex::decode(parts[2])
1140        .map_err(|_| VaultError::Corrupt("invalid SCRAM stored_key hex".into()))?;
1141    let server_vec = hex::decode(parts[3])
1142        .map_err(|_| VaultError::Corrupt("invalid SCRAM server_key hex".into()))?;
1143    let stored_key: [u8; 32] = stored_vec
1144        .try_into()
1145        .map_err(|_| VaultError::Corrupt("SCRAM stored_key must be 32 bytes".into()))?;
1146    let server_key: [u8; 32] = server_vec
1147        .try_into()
1148        .map_err(|_| VaultError::Corrupt("SCRAM server_key must be 32 bytes".into()))?;
1149    Ok(crate::auth::scram::ScramVerifier {
1150        salt,
1151        iter,
1152        stored_key,
1153        server_key,
1154    })
1155}
1156
1157/// Read the 16-byte salt from an existing vault page in the pager.
1158///
1159/// Works against both v1 (legacy) and v2 layouts because the salt sits at
1160/// the same offset (5..21) in both — we only need the salt to re-derive
1161/// the key, not to interpret the rest of the page. Callers that intend
1162/// to actually load() will hit the version check there if it's legacy.
1163fn read_vault_salt_from_pager(pager: &Pager) -> Result<[u8; 16], VaultError> {
1164    let page = pager
1165        .read_page_no_checksum(VAULT_HEADER_PAGE)
1166        .map_err(|e| VaultError::Pager(format!("vault page read: {e}")))?;
1167
1168    let content = page.content();
1169    if content.len() < VAULT_HEADER_PREAMBLE_SIZE {
1170        return Err(VaultError::Corrupt("vault page too short".into()));
1171    }
1172    if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1173        return Err(VaultError::Corrupt("bad magic bytes".into()));
1174    }
1175
1176    let mut salt = [0u8; VAULT_SALT_SIZE];
1177    salt.copy_from_slice(&content[5..21]);
1178    Ok(salt)
1179}
1180
1181// ---------------------------------------------------------------------------
1182// Tests
1183// ---------------------------------------------------------------------------
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use crate::auth::{now_ms, ApiKey, Role, User};
1189    use crate::storage::engine::pager::PagerConfig;
1190
1191    fn sample_state() -> VaultState {
1192        let now = now_ms();
1193        VaultState {
1194            users: vec![
1195                User {
1196                    username: "alice".into(),
1197                    tenant_id: None,
1198                    password_hash: "argon2id$aabbccdd$eeff0011".into(),
1199                    scram_verifier: None,
1200                    role: Role::Admin,
1201                    api_keys: vec![ApiKey {
1202                        key: "rk_abc123".into(),
1203                        name: "ci-token".into(),
1204                        role: Role::Write,
1205                        created_at: now,
1206                    }],
1207                    created_at: now,
1208                    updated_at: now,
1209                    enabled: true,
1210                },
1211                User {
1212                    username: "bob".into(),
1213                    tenant_id: None,
1214                    password_hash: "argon2id$11223344$55667788".into(),
1215                    scram_verifier: None,
1216                    role: Role::Read,
1217                    api_keys: vec![],
1218                    created_at: now,
1219                    updated_at: now,
1220                    enabled: false,
1221                },
1222            ],
1223            api_keys: vec![(
1224                UserId::platform("alice"),
1225                ApiKey {
1226                    key: "rk_abc123".into(),
1227                    name: "ci-token".into(),
1228                    role: Role::Write,
1229                    created_at: now,
1230                },
1231            )],
1232            bootstrapped: true,
1233            master_secret: None,
1234            kv: std::collections::HashMap::new(),
1235        }
1236    }
1237
1238    /// Helper to create a temporary pager for testing.
1239    fn temp_pager() -> (Pager, std::path::PathBuf) {
1240        use std::sync::atomic::{AtomicU64, Ordering};
1241        static COUNTER: AtomicU64 = AtomicU64::new(0);
1242        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1243        let tmp_dir =
1244            std::env::temp_dir().join(format!("reddb_vault_test_{}_{}", std::process::id(), id));
1245        std::fs::create_dir_all(&tmp_dir).unwrap();
1246        let db_path = tmp_dir.join("test.rdb");
1247        let pager = Pager::open(&db_path, PagerConfig::default()).unwrap();
1248        (pager, tmp_dir)
1249    }
1250
1251    fn env_lock() -> &'static std::sync::Mutex<()> {
1252        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1253        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1254    }
1255
1256    fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
1257        unsafe {
1258            match value {
1259                Some(value) => std::env::set_var(name, value),
1260                None => std::env::remove_var(name),
1261            }
1262        }
1263    }
1264
1265    #[test]
1266    fn test_vault_state_serialize_deserialize_roundtrip() {
1267        let state = sample_state();
1268        let serialized = state.serialize();
1269        let text = std::str::from_utf8(&serialized).unwrap();
1270
1271        // Verify text format contains expected markers.
1272        assert!(text.contains("SEALED:true"));
1273        assert!(text.contains("USER:alice\t"));
1274        assert!(text.contains("USER:bob\t"));
1275        assert!(text.contains("KEY:alice\trk_abc123\t"));
1276
1277        // Deserialize and verify.
1278        let restored = VaultState::deserialize(&serialized).unwrap();
1279        assert!(restored.bootstrapped);
1280        assert_eq!(restored.users.len(), 2);
1281
1282        let alice = restored
1283            .users
1284            .iter()
1285            .find(|u| u.username == "alice")
1286            .unwrap();
1287        assert_eq!(alice.role, Role::Admin);
1288        assert!(alice.enabled);
1289        assert_eq!(alice.password_hash, "argon2id$aabbccdd$eeff0011");
1290        assert_eq!(alice.api_keys.len(), 1);
1291        assert_eq!(alice.api_keys[0].key, "rk_abc123");
1292        assert_eq!(alice.api_keys[0].name, "ci-token");
1293        assert_eq!(alice.api_keys[0].role, Role::Write);
1294
1295        let bob = restored.users.iter().find(|u| u.username == "bob").unwrap();
1296        assert_eq!(bob.role, Role::Read);
1297        assert!(!bob.enabled);
1298        assert!(bob.api_keys.is_empty());
1299
1300        assert_eq!(restored.api_keys.len(), 1);
1301        assert_eq!(restored.api_keys[0].0.username, "alice");
1302        assert!(restored.api_keys[0].0.tenant.is_none());
1303    }
1304
1305    #[test]
1306    fn test_vault_state_empty() {
1307        let state = VaultState {
1308            users: vec![],
1309            api_keys: vec![],
1310            bootstrapped: false,
1311            master_secret: None,
1312            kv: std::collections::HashMap::new(),
1313        };
1314        let serialized = state.serialize();
1315        let restored = VaultState::deserialize(&serialized).unwrap();
1316        assert!(!restored.bootstrapped);
1317        assert!(restored.users.is_empty());
1318        assert!(restored.api_keys.is_empty());
1319    }
1320
1321    #[test]
1322    fn test_vault_state_deserialize_invalid_utf8() {
1323        let bad_data = vec![0xFF, 0xFE, 0xFD];
1324        let result = VaultState::deserialize(&bad_data);
1325        assert!(result.is_err());
1326    }
1327
1328    #[test]
1329    fn test_vault_state_deserialize_bad_user_line() {
1330        let bad = b"USER:only_two\tfields\n";
1331        let result = VaultState::deserialize(bad);
1332        assert!(result.is_err());
1333    }
1334
1335    #[test]
1336    fn test_vault_state_deserialize_bad_key_line() {
1337        let bad = b"KEY:too\tfew\n";
1338        let result = VaultState::deserialize(bad);
1339        assert!(result.is_err());
1340    }
1341
1342    #[test]
1343    fn test_vault_state_deserialize_unknown_line_skipped() {
1344        let data = b"SEALED:false\nFUTURE:some_data\n";
1345        let result = VaultState::deserialize(data).unwrap();
1346        assert!(!result.bootstrapped);
1347    }
1348
1349    #[test]
1350    fn test_vault_pager_save_load_roundtrip() {
1351        let (pager, tmp_dir) = temp_pager();
1352
1353        let kp = KeyPair::generate();
1354        let certificate = kp.certificate_hex();
1355        let vault = Vault::with_certificate(&pager, &certificate).unwrap();
1356
1357        // Initially no vault pages.
1358        let loaded = vault.load(&pager).unwrap();
1359        assert!(loaded.is_none());
1360
1361        // Save state.
1362        let state = sample_state();
1363        vault.save(&pager, &state).unwrap();
1364
1365        // Load back.
1366        let restored = vault.load(&pager).unwrap().unwrap();
1367        assert!(restored.bootstrapped);
1368        assert_eq!(restored.users.len(), 2);
1369        assert_eq!(restored.api_keys.len(), 1);
1370
1371        let alice = restored
1372            .users
1373            .iter()
1374            .find(|u| u.username == "alice")
1375            .unwrap();
1376        assert_eq!(alice.role, Role::Admin);
1377        assert_eq!(alice.api_keys.len(), 1);
1378
1379        // Re-open vault with same certificate and load again (salt read from page).
1380        let vault2 = Vault::with_certificate(&pager, &certificate).unwrap();
1381        let restored2 = vault2.load(&pager).unwrap().unwrap();
1382        assert!(restored2.bootstrapped);
1383        assert_eq!(restored2.users.len(), 2);
1384
1385        // Clean up.
1386        drop(pager);
1387        let _ = std::fs::remove_dir_all(&tmp_dir);
1388    }
1389
1390    #[test]
1391    fn test_vault_wrong_key_fails_decryption() {
1392        let (pager, tmp_dir) = temp_pager();
1393
1394        // Save with one certificate.
1395        let kp = KeyPair::generate();
1396        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1397        let state = VaultState {
1398            users: vec![],
1399            api_keys: vec![],
1400            bootstrapped: true,
1401            master_secret: None,
1402            kv: std::collections::HashMap::new(),
1403        };
1404        vault.save(&pager, &state).unwrap();
1405
1406        // Try to load with a different certificate.
1407        let wrong = KeyPair::generate();
1408        let vault2 = Vault::with_certificate_bytes(&pager, &wrong.certificate).unwrap();
1409        let result = vault2.load(&pager);
1410
1411        assert!(result.is_err());
1412
1413        // Clean up.
1414        drop(pager);
1415        let _ = std::fs::remove_dir_all(&tmp_dir);
1416    }
1417
1418    #[test]
1419    fn test_vault_no_key_error() {
1420        let (pager, tmp_dir) = temp_pager();
1421
1422        let _guard = env_lock().lock().unwrap();
1423        let old = std::env::var_os("REDDB_CERTIFICATE");
1424        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1425        unsafe {
1426            std::env::remove_var("REDDB_CERTIFICATE");
1427            std::env::remove_var("REDDB_CERTIFICATE_FILE");
1428        }
1429        let result = Vault::open(&pager);
1430        restore_env_var("REDDB_CERTIFICATE", old);
1431        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1432        assert!(matches!(result, Err(VaultError::NoKey)));
1433
1434        // Clean up.
1435        drop(pager);
1436        let _ = std::fs::remove_dir_all(&tmp_dir);
1437    }
1438
1439    #[test]
1440    fn test_vault_open_uses_certificate_env() {
1441        let (pager, tmp_dir) = temp_pager();
1442
1443        let _guard = env_lock().lock().unwrap();
1444        let old = std::env::var_os("REDDB_CERTIFICATE");
1445        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1446        let kp = KeyPair::generate();
1447        unsafe {
1448            std::env::set_var("REDDB_CERTIFICATE", kp.certificate_hex());
1449            std::env::remove_var("REDDB_CERTIFICATE_FILE");
1450        }
1451
1452        // Open from REDDB_CERTIFICATE.
1453        let vault = Vault::open(&pager).unwrap();
1454        let state = VaultState {
1455            users: vec![],
1456            api_keys: vec![],
1457            bootstrapped: false,
1458            master_secret: None,
1459            kv: std::collections::HashMap::new(),
1460        };
1461        vault.save(&pager, &state).unwrap();
1462
1463        // Re-open from the same env certificate.
1464        let vault2 = Vault::open(&pager).unwrap();
1465        let loaded = vault2.load(&pager).unwrap().unwrap();
1466        assert!(!loaded.bootstrapped);
1467
1468        restore_env_var("REDDB_CERTIFICATE", old);
1469        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1470        drop(pager);
1471        let _ = std::fs::remove_dir_all(&tmp_dir);
1472    }
1473
1474    #[test]
1475    fn test_vault_open_uses_certificate_file_env() {
1476        let (pager, tmp_dir) = temp_pager();
1477
1478        let _guard = env_lock().lock().unwrap();
1479        let old = std::env::var_os("REDDB_CERTIFICATE");
1480        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1481        let kp = KeyPair::generate();
1482        let cert_path = tmp_dir.join("certificate");
1483        std::fs::write(&cert_path, format!("{}\n", kp.certificate_hex())).unwrap();
1484        unsafe {
1485            std::env::remove_var("REDDB_CERTIFICATE");
1486            std::env::set_var("REDDB_CERTIFICATE_FILE", &cert_path);
1487        }
1488
1489        let vault = Vault::open(&pager).unwrap();
1490        let state = VaultState {
1491            users: vec![],
1492            api_keys: vec![],
1493            bootstrapped: false,
1494            master_secret: None,
1495            kv: std::collections::HashMap::new(),
1496        };
1497        vault.save(&pager, &state).unwrap();
1498
1499        let vault2 = Vault::open(&pager).unwrap();
1500        let loaded = vault2.load(&pager).unwrap().unwrap();
1501        assert!(!loaded.bootstrapped);
1502
1503        restore_env_var("REDDB_CERTIFICATE", old);
1504        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1505        drop(pager);
1506        let _ = std::fs::remove_dir_all(&tmp_dir);
1507    }
1508
1509    // ---------------------------------------------------------------
1510    // KeyPair and certificate-based seal tests
1511    // ---------------------------------------------------------------
1512
1513    #[test]
1514    fn test_keypair_generate_deterministic_certificate() {
1515        let kp = KeyPair::generate();
1516        assert_eq!(kp.master_secret.len(), 32);
1517        assert_eq!(kp.certificate.len(), 32);
1518
1519        // Re-deriving from the same master secret gives the same certificate.
1520        let kp2 = KeyPair::from_master_secret(kp.master_secret.clone());
1521        assert_eq!(kp.certificate, kp2.certificate);
1522    }
1523
1524    #[test]
1525    fn test_keypair_sign_verify() {
1526        let kp = KeyPair::generate();
1527        let data = b"session:abc123";
1528        let sig = kp.sign(data);
1529        assert!(kp.verify(data, &sig));
1530
1531        // Wrong data fails.
1532        assert!(!kp.verify(b"session:wrong", &sig));
1533
1534        // Wrong signature fails.
1535        let mut bad_sig = sig.clone();
1536        bad_sig[0] ^= 0xFF;
1537        assert!(!kp.verify(data, &bad_sig));
1538    }
1539
1540    #[test]
1541    fn test_keypair_certificate_hex() {
1542        let kp = KeyPair::generate();
1543        let hex_str = kp.certificate_hex();
1544        assert_eq!(hex_str.len(), 64); // 32 bytes = 64 hex chars
1545        let decoded = hex::decode(&hex_str).unwrap();
1546        assert_eq!(decoded, kp.certificate);
1547    }
1548
1549    #[test]
1550    fn test_vault_certificate_seal_roundtrip() {
1551        let (pager, tmp_dir) = temp_pager();
1552
1553        // Generate a keypair and create a vault sealed by its certificate.
1554        let kp = KeyPair::generate();
1555        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1556
1557        // Save state including the master secret.
1558        let state = VaultState {
1559            users: vec![],
1560            api_keys: vec![],
1561            bootstrapped: true,
1562            master_secret: Some(kp.master_secret.clone()),
1563            kv: std::collections::HashMap::new(),
1564        };
1565        vault.save(&pager, &state).unwrap();
1566
1567        // Re-open using the certificate hex string (simulates admin unseal).
1568        let vault2 = Vault::with_certificate(&pager, &kp.certificate_hex()).unwrap();
1569        let loaded = vault2.load(&pager).unwrap().unwrap();
1570        assert!(loaded.bootstrapped);
1571        assert_eq!(loaded.master_secret, Some(kp.master_secret.clone()));
1572
1573        // Verify the master secret can reconstruct the same keypair.
1574        let kp2 = KeyPair::from_master_secret(loaded.master_secret.unwrap());
1575        assert_eq!(kp.certificate, kp2.certificate);
1576
1577        drop(pager);
1578        let _ = std::fs::remove_dir_all(&tmp_dir);
1579    }
1580
1581    #[test]
1582    fn test_vault_certificate_wrong_cert_fails() {
1583        let (pager, tmp_dir) = temp_pager();
1584
1585        // Seal with one keypair.
1586        let kp = KeyPair::generate();
1587        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1588        let state = VaultState {
1589            users: vec![],
1590            api_keys: vec![],
1591            bootstrapped: true,
1592            master_secret: Some(kp.master_secret.clone()),
1593            kv: std::collections::HashMap::new(),
1594        };
1595        vault.save(&pager, &state).unwrap();
1596
1597        // Try to unseal with a different certificate.
1598        let kp2 = KeyPair::generate();
1599        let vault2 = Vault::with_certificate_bytes(&pager, &kp2.certificate).unwrap();
1600        let result = vault2.load(&pager);
1601        assert!(result.is_err());
1602
1603        drop(pager);
1604        let _ = std::fs::remove_dir_all(&tmp_dir);
1605    }
1606
1607    #[test]
1608    fn test_vault_state_master_secret_serialization() {
1609        let secret = vec![0xAA; 32];
1610        let state = VaultState {
1611            users: vec![],
1612            api_keys: vec![],
1613            bootstrapped: true,
1614            master_secret: Some(secret.clone()),
1615            kv: std::collections::HashMap::new(),
1616        };
1617        let serialized = state.serialize();
1618        let text = std::str::from_utf8(&serialized).unwrap();
1619        assert!(text.contains("MASTER_SECRET:"));
1620        assert!(text.contains(&hex::encode(&secret)));
1621
1622        let restored = VaultState::deserialize(&serialized).unwrap();
1623        assert_eq!(restored.master_secret, Some(secret));
1624        assert!(restored.bootstrapped);
1625    }
1626
1627    #[test]
1628    fn test_vault_state_no_master_secret_backward_compat() {
1629        // Legacy vault format without MASTER_SECRET line.
1630        let data = b"SEALED:true\n";
1631        let restored = VaultState::deserialize(data).unwrap();
1632        assert!(restored.master_secret.is_none());
1633        assert!(restored.bootstrapped);
1634    }
1635
1636    #[test]
1637    fn test_vault_state_scram_verifier_roundtrip() {
1638        use crate::auth::scram::ScramVerifier;
1639
1640        let verifier = ScramVerifier::from_password(
1641            "hunter2",
1642            b"reddb-vault-test-salt".to_vec(),
1643            crate::auth::scram::DEFAULT_ITER,
1644        );
1645
1646        let now = now_ms();
1647        let state = VaultState {
1648            users: vec![User {
1649                username: "carol".into(),
1650                tenant_id: None,
1651                password_hash: "argon2id$abc$def".into(),
1652                scram_verifier: Some(verifier.clone()),
1653                role: Role::Admin,
1654                api_keys: vec![],
1655                created_at: now,
1656                updated_at: now,
1657                enabled: true,
1658            }],
1659            api_keys: vec![],
1660            bootstrapped: true,
1661            master_secret: None,
1662            kv: std::collections::HashMap::new(),
1663        };
1664
1665        let bytes = state.serialize();
1666        let restored = VaultState::deserialize(&bytes).unwrap();
1667        let carol = restored
1668            .users
1669            .iter()
1670            .find(|u| u.username == "carol")
1671            .unwrap();
1672        let v = carol.scram_verifier.as_ref().expect("verifier round-trips");
1673        assert_eq!(v.salt, verifier.salt);
1674        assert_eq!(v.iter, verifier.iter);
1675        assert_eq!(v.stored_key, verifier.stored_key);
1676        assert_eq!(v.server_key, verifier.server_key);
1677    }
1678
1679    #[test]
1680    fn test_vault_state_pre_tenant_user_line_still_parses() {
1681        // 7-field pre-tenant USER line (no trailing tenant id). Must
1682        // keep working since vaults written before tenant scoping
1683        // landed have this shape.
1684        let now = now_ms();
1685        let line = format!(
1686            "USER:dave\targon2id$x$y\tread\ttrue\t{}\t{}\t\nSEALED:false\n",
1687            now, now
1688        );
1689        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1690        let dave = restored
1691            .users
1692            .iter()
1693            .find(|u| u.username == "dave")
1694            .unwrap();
1695        assert!(dave.scram_verifier.is_none());
1696        assert!(dave.tenant_id.is_none());
1697    }
1698
1699    #[test]
1700    fn test_vault_state_tenant_user_line_still_parses() {
1701        let now = now_ms();
1702        let line = format!(
1703            "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\nSEALED:false\n",
1704            now, now
1705        );
1706        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1707        let erin = restored
1708            .users
1709            .iter()
1710            .find(|u| u.username == "erin")
1711            .unwrap();
1712        assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1713    }
1714
1715    #[test]
1716    fn test_vault_state_legacy_user_line_with_extra_ownership_field_is_ignored() {
1717        let now = now_ms();
1718        let line = format!(
1719            "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\ttrue\nSEALED:false\n",
1720            now, now
1721        );
1722        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1723        let erin = restored
1724            .users
1725            .iter()
1726            .find(|u| u.username == "erin")
1727            .unwrap();
1728        assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1729    }
1730
1731    #[test]
1732    fn test_vault_state_user_line_with_tenant_roundtrip() {
1733        let now = now_ms();
1734        let state = VaultState {
1735            users: vec![User {
1736                username: "alice".into(),
1737                tenant_id: Some("acme".into()),
1738                password_hash: "argon2id$x$y".into(),
1739                scram_verifier: None,
1740                role: Role::Write,
1741                api_keys: vec![],
1742                created_at: now,
1743                updated_at: now,
1744                enabled: true,
1745            }],
1746            api_keys: vec![],
1747            bootstrapped: true,
1748            master_secret: None,
1749            kv: std::collections::HashMap::new(),
1750        };
1751        let bytes = state.serialize();
1752        let text = std::str::from_utf8(&bytes).unwrap();
1753        // New vault payloads stop writing the legacy ownership field.
1754        assert!(text.contains("\tacme\n"));
1755
1756        let restored = VaultState::deserialize(&bytes).unwrap();
1757        let alice = restored
1758            .users
1759            .iter()
1760            .find(|u| u.username == "alice")
1761            .unwrap();
1762        assert_eq!(alice.tenant_id.as_deref(), Some("acme"));
1763    }
1764
1765    #[test]
1766    fn test_vault_state_key_line_with_tenant_reattaches_correctly() {
1767        // Two same-named users in different tenants. Each owns one
1768        // API key. Reattachment must respect tenant scope.
1769        let now = now_ms();
1770        let state = VaultState {
1771            users: vec![
1772                User {
1773                    username: "alice".into(),
1774                    tenant_id: Some("acme".into()),
1775                    password_hash: "argon2id$x$y".into(),
1776                    scram_verifier: None,
1777                    role: Role::Write,
1778                    api_keys: vec![],
1779                    created_at: now,
1780                    updated_at: now,
1781                    enabled: true,
1782                },
1783                User {
1784                    username: "alice".into(),
1785                    tenant_id: Some("globex".into()),
1786                    password_hash: "argon2id$a$b".into(),
1787                    scram_verifier: None,
1788                    role: Role::Read,
1789                    api_keys: vec![],
1790                    created_at: now,
1791                    updated_at: now,
1792                    enabled: true,
1793                },
1794            ],
1795            api_keys: vec![
1796                (
1797                    UserId::scoped("acme", "alice"),
1798                    ApiKey {
1799                        key: "rk_acme_key".into(),
1800                        name: "deploy".into(),
1801                        role: Role::Write,
1802                        created_at: now,
1803                    },
1804                ),
1805                (
1806                    UserId::scoped("globex", "alice"),
1807                    ApiKey {
1808                        key: "rk_globex_key".into(),
1809                        name: "ci".into(),
1810                        role: Role::Read,
1811                        created_at: now,
1812                    },
1813                ),
1814            ],
1815            bootstrapped: true,
1816            master_secret: None,
1817            kv: std::collections::HashMap::new(),
1818        };
1819        let bytes = state.serialize();
1820        let restored = VaultState::deserialize(&bytes).unwrap();
1821        // The api_keys vector retains both entries with the right
1822        // owners.
1823        assert_eq!(restored.api_keys.len(), 2);
1824        let acme_key = restored
1825            .api_keys
1826            .iter()
1827            .find(|(o, _)| o.tenant.as_deref() == Some("acme"))
1828            .unwrap();
1829        assert_eq!(acme_key.1.key, "rk_acme_key");
1830        let globex_key = restored
1831            .api_keys
1832            .iter()
1833            .find(|(o, _)| o.tenant.as_deref() == Some("globex"))
1834            .unwrap();
1835        assert_eq!(globex_key.1.key, "rk_globex_key");
1836    }
1837
1838    #[test]
1839    fn test_vault_state_scram_iter_below_min_rejected() {
1840        let now = now_ms();
1841        // 33 hex pairs = 33 bytes, but the parse_scram_field iter check
1842        // fires before length validation. Stored/server are 32 hex bytes
1843        // (64 chars) here so we exercise the iter floor specifically.
1844        let stored_hex = "00".repeat(32);
1845        let server_hex = "11".repeat(32);
1846        let line = format!(
1847            "USER:eve\targon2id$x$y\tread\ttrue\t{}\t{}\tdeadbeef:1024:{}:{}\n",
1848            now, now, stored_hex, server_hex
1849        );
1850        match VaultState::deserialize(line.as_bytes()) {
1851            Err(VaultError::Corrupt(msg)) => assert!(msg.contains("below minimum")),
1852            Err(other) => panic!("expected Corrupt iter-floor error, got {other:?}"),
1853            Ok(_) => panic!("expected Corrupt iter-floor error, got Ok"),
1854        }
1855    }
1856
1857    #[test]
1858    fn test_constant_time_eq_function() {
1859        assert!(constant_time_eq(b"hello", b"hello"));
1860        assert!(!constant_time_eq(b"hello", b"world"));
1861        assert!(!constant_time_eq(b"short", b"longer"));
1862        assert!(constant_time_eq(b"", b""));
1863    }
1864}