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