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