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