Skip to main content

reddb_server/auth/
vault.rs

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