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 `REDDB_VAULT_KEY`
9//! (or, preferably, a certificate via `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 encryption key available (neither env var nor passphrase).
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 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// ---------------------------------------------------------------------------
262// VaultState
263// ---------------------------------------------------------------------------
264
265/// Serializable snapshot of all auth state (users, api keys, bootstrap seal,
266/// the master secret for the certificate-based seal, and a key-value store
267/// for arbitrary encrypted secrets).
268#[derive(Debug, Default)]
269pub struct VaultState {
270    pub users: Vec<User>,
271    /// `(owner UserId, api_key)` pairs. The owner carries tenant scope
272    /// so an API key under `(acme, alice)` reattaches to the correct
273    /// user when a same-named user exists in another tenant.
274    pub api_keys: Vec<(UserId, ApiKey)>,
275    pub bootstrapped: bool,
276    /// The 32-byte master secret stored inside the encrypted vault.
277    /// Present after bootstrap; `None` for legacy vaults that pre-date
278    /// the certificate seal system.
279    pub master_secret: Option<Vec<u8>>,
280    /// Arbitrary encrypted key-value store for secrets.
281    /// Keys use dot-notation with `red.secret.*` prefix (e.g., "red.secret.aes_key").
282    /// Values are hex-encoded bytes or UTF-8 strings.
283    pub kv: std::collections::HashMap<String, String>,
284}
285
286impl VaultState {
287    /// Serialize the vault state to the text payload format.
288    pub fn serialize(&self) -> Vec<u8> {
289        let mut out = String::new();
290
291        // Master secret (if present from certificate-based seal).
292        if let Some(ref secret) = self.master_secret {
293            out.push_str(&format!("MASTER_SECRET:{}\n", hex::encode(secret)));
294        }
295
296        // SEALED line.
297        out.push_str(&format!("SEALED:{}\n", self.bootstrapped));
298
299        // Users.
300        //
301        // USER line tabs: <username> <pw_hash> <role> <enabled>
302        // <created_at> <updated_at> <scram_verifier?> <tenant_id?>
303        //
304        // Field counts accepted on read:
305        //   * 7 fields — pre-tenant USER line (any prior v2 vault
306        //     written before tenant scoping landed).
307        //   * 8 fields — current USER line. The 8th field is the
308        //     tenant id; empty string = platform tenant (`None`).
309        //
310        // Verifier encoding: `<salt_hex>:<iter>:<stored_hex>:<server_hex>`.
311        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        // API keys: `KEY:<username>\t<key>\t<name>\t<role>\t<created_at>\t<tenant_id?>`.
337        // The 6th tenant field is empty for platform users and disambiguates
338        // owners when the same username appears under multiple tenants.
339        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        // KV entries (hex-encoded values to avoid newline/tab collisions).
353        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    /// Deserialize the vault state from the text payload format.
361    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                // 7 fields = pre-tenant v2 USER line; 8 fields = with
386                // tenant id appended; 9 fields = legacy ownership field,
387                // accepted for old vaults and ignored.
388                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(), // API keys are attached separately below
422                    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                // 5 fields = pre-tenant; 6 fields = with tenant id.
429                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                // Unknown line prefix -- skip gracefully for forward compat.
469            }
470        }
471
472        // Re-attach API keys to their owning users. Match on the full
473        // `(tenant, username)` so a key for `(acme, alice)` doesn't
474        // accidentally attach to `(globex, alice)`.
475        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
494// ---------------------------------------------------------------------------
495// Vault
496// ---------------------------------------------------------------------------
497
498/// Encrypted vault for persisting auth state inside reserved pager pages.
499///
500/// The vault key is derived from `REDDB_VAULT_KEY` env var or a provided
501/// passphrase.  A random salt is generated on first write and persisted
502/// inside the vault page so that re-opening with the same passphrase
503/// produces the same derived key.
504pub struct Vault {
505    key: SecureKey,
506    salt: [u8; 16],
507}
508
509/// Argon2id parameters tuned for vault key derivation.
510/// Lighter than the default (16 MB vs 64 MB) so vault open is quick.
511fn vault_argon2_params() -> Argon2Params {
512    Argon2Params {
513        m_cost: 16 * 1024, // 16 MB
514        t_cost: 3,
515        p: 1,
516        tag_len: 32,
517    }
518}
519
520impl Vault {
521    /// Return true when the pager contains a written vault header.
522    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    /// Open or prepare a vault backed by reserved pager pages.
534    ///
535    /// Key derivation: `REDDB_VAULT_KEY` env var takes priority, then
536    /// the `passphrase` argument.  If neither is set, returns `NoKey`.
537    ///
538    /// If vault pages already exist in the pager, the salt is read from
539    /// the existing page content.  Otherwise a fresh salt is generated
540    /// and will be written on the first `save()` call.
541    pub fn open(pager: &Pager, passphrase: Option<&str>) -> Result<Self, VaultError> {
542        // Try certificate-based opening first (REDDB_CERTIFICATE env var).
543        if let Ok(cert_hex) = std::env::var("REDDB_CERTIFICATE") {
544            return Self::with_certificate(pager, &cert_hex);
545        }
546
547        // Resolve passphrase: env var > argument.
548        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        // Try to read the salt from an existing vault page.
554        let salt = match read_vault_salt_from_pager(pager) {
555            Ok(s) => s,
556            Err(_) => {
557                // No vault pages yet -- generate a fresh salt.
558                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    /// Open a vault using a certificate hex string (from bootstrap).
574    ///
575    /// The certificate is used to derive the vault encryption key via
576    /// Argon2id.  This is the primary unseal mechanism introduced by the
577    /// certificate-based seal system.
578    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        // Try to read the salt from an existing vault page.
584        let salt = match read_vault_salt_from_pager(pager) {
585            Ok(s) => s,
586            Err(_) => {
587                // No vault pages yet -- generate a fresh salt.
588                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    /// Open a vault from environment variables.
599    ///
600    /// Precedence: `REDDB_CERTIFICATE` (primary) > `REDDB_VAULT_KEY` (fallback/deprecated).
601    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    /// Open a vault with an explicit passphrase string (no env vars).
612    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    /// Create a vault keyed by a certificate (raw bytes, not hex).
629    ///
630    /// Used during bootstrap when the certificate is freshly generated
631    /// and not yet hex-encoded.
632    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    /// Encrypt a vault state into a self-contained logical export blob.
649    ///
650    /// The source salt is embedded so passphrase-based imports can derive
651    /// the same wrapping key without having access to the source `.rdb`
652    /// pages. The blob is hex-encoded so it can live inside JSONL dumps.
653    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        // Envelope framing (magic + version + salt + nonce + ciphertext, hex)
664        // lives in reddb-file; we only supply the encrypted parts.
665        Ok(reddb_file::encode_vault_logical_export(
666            &self.salt,
667            &nonce,
668            &ciphertext,
669        ))
670    }
671
672    /// Decrypt a logical export blob using the same key precedence as
673    /// normal vault open: REDDB_CERTIFICATE, REDDB_VAULT_KEY, then the
674    /// explicit passphrase argument.
675    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    /// Deterministic test/helper path that ignores vault env vars.
697    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        // Envelope parsing lives in reddb-file; map its framing error onto our
711        // operator-facing VaultError::Corrupt with the same message text.
712        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    /// Save the given auth state to the encrypted vault pages.
730    ///
731    /// Order of operations is the only thing keeping this crash-safe:
732    ///   1. Encrypt the serialized state under a fresh nonce.
733    ///   2. Allocate (or reuse) the data-page chain and write every
734    ///      data page to disk.
735    ///   3. Free any surplus pages that the previous chain owned.
736    ///   4. Rewrite the header page in place — this is the commit
737    ///      point. After it lands, `load()` will follow the new chain.
738    ///
739    /// A crash anywhere before step 4 leaves the existing header (and
740    /// its chain) intact, so the previous vault snapshot is still
741    /// readable on the next open.
742    pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
743        let plaintext = state.serialize();
744
745        // Fresh nonce per write — required for AES-GCM.
746        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        // The 16-byte GCM tag is appended to `ciphertext` already; we
754        // treat the whole vector as one opaque blob.
755
756        let cipher_total = ciphertext.len();
757        let payload_len = (NONCE_SIZE + cipher_total) as u32; // for legacy field
758
759        // ---- 1. Plan the chain --------------------------------------
760        //
761        // The header page absorbs the first `VAULT_HEADER_CIPHER_CAPACITY`
762        // bytes of ciphertext; everything after spills into a chain of
763        // data pages with `VAULT_DATA_CIPHER_CAPACITY` bytes each.
764        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        // ---- 2. Reserve VAULT_HEADER_PAGE on a fresh DB -------------
769        //
770        // The pager hands out ids from `page_count` upward. On a brand
771        // new file `page_count == 1` (only the database header at id
772        // 0), so without this dance the next call to `allocate_page`
773        // would happily return id 1 and then id 2 — colliding with our
774        // fixed VAULT_HEADER_PAGE. Burn allocations until `page_count`
775        // is past the header so future `allocate_page(Vault)` calls
776        // for the data chain return ids >= VAULT_HEADER_PAGE + 1.
777        //
778        // We pass `PageType::Vault` so anyone scanning page types sees
779        // the right tag for these reserved slots; the header-page
780        // contents get overwritten below in any case.
781        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        // ---- 3. Snapshot the previous chain (if any) for later cleanup.
792        //
793        // We do NOT reuse these ids — overwriting an old data page
794        // before the header is rewritten would corrupt the live vault
795        // mid-save (the still-valid header would point at a page that
796        // now has the *new* ciphertext but the *old* nonce). Allocating
797        // fresh pages means the old chain stays byte-identical until
798        // the header commit, so `load()` keeps working through any
799        // crash before step 7.
800        let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
801
802        // ---- 4. Allocate fresh data-page ids for the new chain.
803        //
804        // The pager pulls from the freelist first, so successive
805        // saves recycle ids without growing the file — the old chain
806        // is freed at step 6 below and becomes available the *next*
807        // time we save.
808        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        // ---- 5. Write data pages. We already know every page id up
817        // front (allocated in step 4), so each page can record its
818        // successor's id directly — no second pass needed. The header
819        // is *not* updated yet, so a crash here leaves `load()`
820        // looking at the previous chain (which is still on disk and
821        // valid because we did not touch its pages).
822        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        // ---- 6. (Deferred) The old chain pages are freed *after* the
837        // header commit so the freelist doesn't hand them back out
838        // before we've finished swapping over.
839
840        // ---- 7. Rewrite the header page. This is the commit point —
841        // after this write the new chain is authoritative and any
842        // future load() will follow it.
843        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        // ---- 8. Flush so a process crash after return doesn't lose
854        // the write. We flush *before* freeing old pages so the new
855        // header is durable on disk before we tell the pager those
856        // old ids are reusable.
857        pager
858            .flush()
859            .map_err(|e| VaultError::Pager(e.to_string()))?;
860
861        // ---- 9. Now safe to free the old chain. The freelist update
862        // makes those page ids reclaimable on the *next* allocation,
863        // which is exactly what we want — the old data is no longer
864        // referenced by the (just-flushed) header.
865        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    /// Load auth state from the encrypted vault pages.
875    ///
876    /// Returns `Ok(None)` if the vault pages do not exist yet (fresh DB).
877    pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
878        // Header page is the entry point.
879        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); // Slot is reserved but never written.
891        }
892
893        let version = page_content[4];
894        if version == VAULT_LEGACY_VERSION {
895            // Pre-1.0: no migration shim. Fail loudly with operator
896            // guidance so this gets surfaced during upgrade and not
897            // hidden behind a generic decryption error.
898            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        // Decode header preamble.
912        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        // ---- Reassemble ciphertext fragments. ----------------------
942        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        // Walk the data-page chain.
950        let mut hops = 0usize;
951        // Bound the walk: chain_count from the header is the source of
952        // truth. We tolerate next_id pointers but trust chain_count to
953        // avoid getting trapped in a corrupt loop.
954        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        // ---- Decrypt the reassembled blob in one shot. -------------
1009        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    /// Walk the existing chain (if any) and collect the data-page ids,
1019    /// so `save()` can reuse / free them. Returns an error or empty
1020    /// vector if the chain isn't intact — callers must treat that as
1021    /// "no reusable chain" rather than failing the save outright,
1022    /// because a partially-corrupt chain is exactly the case where we
1023    /// most want a fresh write to land cleanly.
1024    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            // v1 (legacy) had its overflow at fixed page 3; we don't
1038            // know if that page is "ours" to free. Safer to leak it
1039            // — the operator is re-bootstrapping anyway.
1040            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            // Peek next_id off the data page. Soft-fail on read errors
1061            // — we'd rather leak a page than refuse to save.
1062            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    /// Write the vault header page (magic + version + chain metadata +
1084    /// nonce + first ciphertext fragment). This is the commit point —
1085    /// callers must have flushed every data page first.
1086    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    /// Write a data page (magic + next_page_id + ciphertext fragment).
1133    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
1161// ---------------------------------------------------------------------------
1162// Helpers
1163// ---------------------------------------------------------------------------
1164
1165/// Decode a SCRAM verifier field of the form
1166/// `<salt_hex>:<iter>:<stored_hex>:<server_hex>` into a `ScramVerifier`.
1167fn 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
1205/// Read the 16-byte salt from an existing vault page in the pager.
1206///
1207/// Works against both v1 (legacy) and v2 layouts because the salt sits at
1208/// the same offset (5..21) in both — we only need the salt to re-derive
1209/// the key, not to interpret the rest of the page. Callers that intend
1210/// to actually load() will hit the version check there if it's legacy.
1211fn 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// ---------------------------------------------------------------------------
1230// Tests
1231// ---------------------------------------------------------------------------
1232
1233#[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    /// Helper to create a temporary pager for testing.
1287    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        // Verify text format contains expected markers.
1306        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        // Deserialize and verify.
1312        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        // Initially no vault pages.
1390        let loaded = vault.load(&pager).unwrap();
1391        assert!(loaded.is_none());
1392
1393        // Save state.
1394        let state = sample_state();
1395        vault.save(&pager, &state).unwrap();
1396
1397        // Load back.
1398        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        // Re-open vault with same key and load again (salt read from page).
1412        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        // Clean up.
1418        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        // Save with one key.
1427        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        // Try to load with a different key.
1438        let vault2 = Vault::open(&pager, Some("wrong-key")).unwrap();
1439        let result = vault2.load(&pager);
1440
1441        assert!(result.is_err());
1442
1443        // Clean up.
1444        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        // If REDDB_VAULT_KEY or REDDB_CERTIFICATE happens to be set by another
1454        // test, passphrase=None means we rely on env var.  Without either, it
1455        // should be NoKey.
1456        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                // Env var is set (by another test); open will succeed.
1461                assert!(result.is_ok());
1462            }
1463            false => {
1464                assert!(matches!(result, Err(VaultError::NoKey)));
1465            }
1466        }
1467
1468        // Clean up.
1469        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        // Open with passphrase argument.
1478        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        // Re-open with same passphrase.
1489        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    // ---------------------------------------------------------------
1498    // KeyPair and certificate-based seal tests
1499    // ---------------------------------------------------------------
1500
1501    #[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        // Re-deriving from the same master secret gives the same certificate.
1508        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        // Wrong data fails.
1520        assert!(!kp.verify(b"session:wrong", &sig));
1521
1522        // Wrong signature fails.
1523        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); // 32 bytes = 64 hex chars
1533        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        // Generate a keypair and create a vault sealed by its certificate.
1542        let kp = KeyPair::generate();
1543        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1544
1545        // Save state including the master secret.
1546        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        // Re-open using the certificate hex string (simulates admin unseal).
1556        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        // Verify the master secret can reconstruct the same keypair.
1562        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        // Seal with one keypair.
1574        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        // Try to unseal with a different certificate.
1586        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        // Legacy vault format without MASTER_SECRET line.
1618        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        // 7-field pre-tenant USER line (no trailing tenant id). Must
1670        // keep working since vaults written before tenant scoping
1671        // landed have this shape.
1672        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        // New vault payloads stop writing the legacy ownership field.
1742        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        // Two same-named users in different tenants. Each owns one
1756        // API key. Reattachment must respect tenant scope.
1757        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        // The api_keys vector retains both entries with the right
1810        // owners.
1811        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        // 33 hex pairs = 33 bytes, but the parse_scram_field iter check
1830        // fires before length validation. Stored/server are 32 hex bytes
1831        // (64 chars) here so we exercise the iter floor specifically.
1832        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}