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