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        let bytes = reddb_crypto::vault::passphrase_key(passphrase, salt)
639            .map_err(VaultError::InvalidCredential)?;
640        Ok(SecureKey::new(bytes.as_ref()))
641    }
642
643    fn check_credential_format(pager: &Pager, expected: u8) -> Result<(), VaultError> {
644        if Self::has_saved_state(pager) {
645            let page = pager
646                .read_page_no_checksum(VAULT_HEADER_PAGE)
647                .map_err(|err| VaultError::Pager(err.to_string()))?;
648            let version = page.content()[4];
649            if (version == VAULT_VERSION || version == VAULT_PASSPHRASE_VERSION)
650                && version != expected
651            {
652                return Err(VaultError::InvalidCredential(
653                    if version == VAULT_PASSPHRASE_VERSION {
654                        "this vault requires a passphrase"
655                    } else {
656                        "this vault requires a certificate"
657                    }
658                    .into(),
659                ));
660            }
661        }
662        Ok(())
663    }
664
665    /// Open a vault from environment variables.
666    ///
667    /// Requires `REDDB_CERTIFICATE`.
668    pub fn from_env(pager: &Pager) -> Result<Self, VaultError> {
669        Self::open(pager)
670    }
671
672    /// Create a vault keyed by a certificate (raw bytes, not hex).
673    ///
674    /// Used during bootstrap when the certificate is freshly generated
675    /// and not yet hex-encoded.
676    pub fn with_certificate_bytes(pager: &Pager, certificate: &[u8]) -> Result<Self, VaultError> {
677        Self::check_credential_format(pager, VAULT_VERSION)?;
678        if certificate.len() != 32 {
679            return Err(VaultError::InvalidCredential(
680                "certificate must contain 32 bytes".into(),
681            ));
682        }
683        let key = KeyPair::vault_key_from_certificate(certificate);
684
685        let salt = match read_vault_salt_from_pager(pager) {
686            Ok(s) => s,
687            Err(_) => {
688                let mut s = [0u8; 16];
689                os_random::fill_bytes(&mut s)
690                    .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
691                s
692            }
693        };
694
695        Ok(Self {
696            key,
697            salt,
698            version: VAULT_VERSION,
699        })
700    }
701
702    /// Encrypt a vault state into a self-contained logical export blob.
703    ///
704    /// The source salt is embedded for envelope framing. Certificate-based
705    /// imports derive the same wrapping key from `REDDB_CERTIFICATE`.
706    /// The blob is hex-encoded so it can live inside JSONL dumps.
707    pub fn seal_logical_export(&self, state: &VaultState) -> Result<String, VaultError> {
708        let plaintext = state.serialize();
709        let mut nonce = [0u8; NONCE_SIZE];
710        os_random::fill_bytes(&mut nonce)
711            .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
712
713        let key_bytes: &[u8] = self.key.as_bytes();
714        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
715        let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_LOGICAL_EXPORT_AAD, &plaintext);
716
717        // Envelope framing (magic + version + salt + nonce + ciphertext, hex)
718        // lives in reddb-file; we only supply the encrypted parts.
719        Ok(reddb_file::encode_vault_logical_export(
720            &self.salt,
721            &nonce,
722            &ciphertext,
723        ))
724    }
725
726    /// Decrypt a logical export blob using the same certificate env var as
727    /// normal vault open.
728    pub fn unseal_logical_export(blob_hex: &str) -> Result<VaultState, VaultError> {
729        match (
730            crate::utils::env_with_file_fallback("REDDB_CERTIFICATE"),
731            crate::utils::env_with_file_fallback("REDDB_VAULT_PASSPHRASE"),
732        ) {
733            (Some(_), Some(_)) => Err(VaultError::InvalidCredential(
734                "set a single vault credential".into(),
735            )),
736            (Some(certificate), None) => {
737                Self::unseal_logical_export_with_certificate(blob_hex, &certificate)
738            }
739            (None, Some(passphrase)) => {
740                Self::unseal_logical_export_with_passphrase(blob_hex, &passphrase)
741            }
742            _ => Err(VaultError::NoKey),
743        }
744    }
745
746    /// Deterministic helper path that ignores vault env vars.
747    pub fn unseal_logical_export_with_certificate(
748        blob_hex: &str,
749        certificate_hex: &str,
750    ) -> Result<VaultState, VaultError> {
751        let (_salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
752        let certificate = decode_certificate_hex(certificate_hex)?;
753        let key = KeyPair::vault_key_from_certificate(&certificate);
754        Self::decrypt_logical_export(&key, &nonce, &ciphertext)
755    }
756
757    pub fn unseal_logical_export_with_passphrase(
758        blob_hex: &str,
759        passphrase: &str,
760    ) -> Result<VaultState, VaultError> {
761        let (salt, nonce, ciphertext) = Self::decode_logical_export(blob_hex)?;
762        let key = Self::passphrase_key(passphrase, &salt)?;
763        Self::decrypt_logical_export(&key, &nonce, &ciphertext)
764    }
765
766    fn decode_logical_export(
767        blob_hex: &str,
768    ) -> Result<([u8; VAULT_SALT_SIZE], [u8; NONCE_SIZE], Vec<u8>), VaultError> {
769        // Envelope parsing lives in reddb-file; map its framing error onto our
770        // operator-facing VaultError::Corrupt with the same message text.
771        let env = reddb_file::decode_vault_logical_export(blob_hex)
772            .map_err(|e| VaultError::Corrupt(e.to_string()))?;
773        Ok((env.salt, env.nonce, env.ciphertext))
774    }
775
776    fn decrypt_logical_export(
777        key: &SecureKey,
778        nonce: &[u8; NONCE_SIZE],
779        ciphertext: &[u8],
780    ) -> Result<VaultState, VaultError> {
781        let key_bytes: &[u8] = key.as_bytes();
782        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
783        let plaintext = aes256_gcm_decrypt(key_arr, nonce, VAULT_LOGICAL_EXPORT_AAD, ciphertext)
784            .map_err(|_| VaultError::Decryption)?;
785        VaultState::deserialize(&plaintext)
786    }
787
788    /// Save the given auth state to the encrypted vault pages.
789    ///
790    /// Order of operations is the only thing keeping this crash-safe:
791    ///   1. Encrypt the serialized state under a fresh nonce.
792    ///   2. Allocate (or reuse) the data-page chain and write every
793    ///      data page to disk.
794    ///   3. Free any surplus pages that the previous chain owned.
795    ///   4. Rewrite the header page in place — this is the commit
796    ///      point. After it lands, `load()` will follow the new chain.
797    ///
798    /// A crash anywhere before step 4 leaves the existing header (and
799    /// its chain) intact, so the previous vault snapshot is still
800    /// readable on the next open.
801    pub fn save(&self, pager: &Pager, state: &VaultState) -> Result<(), VaultError> {
802        let plaintext = state.serialize();
803
804        // Fresh nonce per write — required for AES-GCM.
805        let mut nonce = [0u8; NONCE_SIZE];
806        os_random::fill_bytes(&mut nonce)
807            .map_err(|e| VaultError::Corrupt(format!("CSPRNG failed: {e}")))?;
808
809        let key_bytes: &[u8] = self.key.as_bytes();
810        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Encryption)?;
811        let ciphertext = aes256_gcm_encrypt(key_arr, &nonce, VAULT_AAD, &plaintext);
812        // The 16-byte GCM tag is appended to `ciphertext` already; we
813        // treat the whole vector as one opaque blob.
814
815        let cipher_total = ciphertext.len();
816        let payload_len = (NONCE_SIZE + cipher_total) as u32; // for legacy field
817
818        // ---- 1. Plan the chain --------------------------------------
819        //
820        // The header page absorbs the first `VAULT_HEADER_CIPHER_CAPACITY`
821        // bytes of ciphertext; everything after spills into a chain of
822        // data pages with `VAULT_DATA_CIPHER_CAPACITY` bytes each.
823        let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
824        let overflow = cipher_total.saturating_sub(header_chunk_len);
825        let chain_count = overflow.div_ceil(VAULT_DATA_CIPHER_CAPACITY);
826
827        // ---- 2. Reserve VAULT_HEADER_PAGE on a fresh DB -------------
828        //
829        // The pager hands out ids from `page_count` upward. On a brand
830        // new file `page_count == 1` (only the database header at id
831        // 0), so without this dance the next call to `allocate_page`
832        // would happily return id 1 and then id 2 — colliding with our
833        // fixed VAULT_HEADER_PAGE. Burn allocations until `page_count`
834        // is past the header so future `allocate_page(Vault)` calls
835        // for the data chain return ids >= VAULT_HEADER_PAGE + 1.
836        //
837        // We pass `PageType::Vault` so anyone scanning page types sees
838        // the right tag for these reserved slots; the header-page
839        // contents get overwritten below in any case.
840        while pager
841            .page_count()
842            .map_err(|e| VaultError::Pager(e.to_string()))?
843            <= VAULT_HEADER_PAGE
844        {
845            pager
846                .allocate_page(PageType::Vault)
847                .map_err(|e| VaultError::Pager(format!("reserve vault slot: {e}")))?;
848        }
849
850        // ---- 3. Snapshot the previous chain (if any) for later cleanup.
851        //
852        // We do NOT reuse these ids — overwriting an old data page
853        // before the header is rewritten would corrupt the live vault
854        // mid-save (the still-valid header would point at a page that
855        // now has the *new* ciphertext but the *old* nonce). Allocating
856        // fresh pages means the old chain stays byte-identical until
857        // the header commit, so `load()` keeps working through any
858        // crash before step 7.
859        let old_chain = self.read_existing_chain_ids(pager).unwrap_or_default();
860
861        // ---- 4. Allocate fresh data-page ids for the new chain.
862        //
863        // The pager pulls from the freelist first, so successive
864        // saves recycle ids without growing the file — the old chain
865        // is freed at step 6 below and becomes available the *next*
866        // time we save.
867        let mut new_chain: Vec<u32> = Vec::with_capacity(chain_count);
868        for _ in 0..chain_count {
869            let page = pager
870                .allocate_page(PageType::Vault)
871                .map_err(|e| VaultError::Pager(format!("allocate vault data page: {e}")))?;
872            new_chain.push(page.page_id());
873        }
874
875        // ---- 5. Write data pages. We already know every page id up
876        // front (allocated in step 4), so each page can record its
877        // successor's id directly — no second pass needed. The header
878        // is *not* updated yet, so a crash here leaves `load()`
879        // looking at the previous chain (which is still on disk and
880        // valid because we did not touch its pages).
881        let mut cursor = header_chunk_len;
882        for i in 0..chain_count {
883            let next_id = if i + 1 < chain_count {
884                new_chain[i + 1]
885            } else {
886                0
887            };
888            let take = (cipher_total - cursor).min(VAULT_DATA_CIPHER_CAPACITY);
889            let frag = &ciphertext[cursor..cursor + take];
890            self.write_data_page(pager, new_chain[i], next_id, frag)?;
891            cursor += take;
892        }
893        debug_assert_eq!(cursor, cipher_total, "ciphertext spill accounting mismatch");
894
895        // ---- 6. (Deferred) The old chain pages are freed *after* the
896        // header commit so the freelist doesn't hand them back out
897        // before we've finished swapping over.
898
899        // ---- 7. Rewrite the header page. This is the commit point —
900        // after this write the new chain is authoritative and any
901        // future load() will follow it.
902        let first_data_page = new_chain.first().copied().unwrap_or(0);
903        self.write_header_page(
904            pager,
905            &nonce,
906            payload_len,
907            chain_count as u32,
908            first_data_page,
909            &ciphertext[..header_chunk_len],
910        )?;
911
912        // ---- 8. Flush so a process crash after return doesn't lose
913        // the write. We flush *before* freeing old pages so the new
914        // header is durable on disk before we tell the pager those
915        // old ids are reusable.
916        pager
917            .flush()
918            .map_err(|e| VaultError::Pager(e.to_string()))?;
919
920        // ---- 9. Now safe to free the old chain. The freelist update
921        // makes those page ids reclaimable on the *next* allocation,
922        // which is exactly what we want — the old data is no longer
923        // referenced by the (just-flushed) header.
924        for &id in old_chain.iter() {
925            pager
926                .free_page(id)
927                .map_err(|e| VaultError::Pager(format!("free old vault page {id}: {e}")))?;
928        }
929
930        Ok(())
931    }
932
933    /// Load auth state from the encrypted vault pages.
934    ///
935    /// Returns `Ok(None)` if the vault pages do not exist yet (fresh DB).
936    pub fn load(&self, pager: &Pager) -> Result<Option<VaultState>, VaultError> {
937        // Header page is the entry point.
938        let page = match pager.read_page_no_checksum(VAULT_HEADER_PAGE) {
939            Ok(p) => p,
940            Err(_) => return Ok(None),
941        };
942
943        let page_content = page.content();
944
945        if page_content.len() < VAULT_HEADER_META_SIZE {
946            return Ok(None);
947        }
948        if &page_content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
949            return Ok(None); // Slot is reserved but never written.
950        }
951
952        let version = page_content[4];
953        if version == VAULT_LEGACY_VERSION {
954            // Pre-1.0: no migration shim. Fail loudly with operator
955            // guidance so this gets surfaced during upgrade and not
956            // hidden behind a generic decryption error.
957            return Err(VaultError::Corrupt(
958                "vault was bootstrapped with the legacy 2-page format \
959                 (pre-RedDB v0.3); re-bootstrap with `red bootstrap` to upgrade"
960                    .to_string(),
961            ));
962        }
963        if version != self.version {
964            return Err(VaultError::Corrupt(format!(
965                "vault credential format mismatch: stored {version}, opened {}",
966                self.version
967            )));
968        }
969
970        // Decode header preamble.
971        let payload_len = u32::from_le_bytes(
972            page_content[21..25]
973                .try_into()
974                .map_err(|_| VaultError::Corrupt("bad payload length bytes".into()))?,
975        ) as usize;
976
977        let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
978        let nonce: [u8; NONCE_SIZE] = page_content[nonce_start..nonce_start + NONCE_SIZE]
979            .try_into()
980            .map_err(|_| VaultError::Corrupt("bad nonce".into()))?;
981
982        let chain_count_off = nonce_start + NONCE_SIZE;
983        let chain_count = u32::from_le_bytes(
984            page_content[chain_count_off..chain_count_off + 4]
985                .try_into()
986                .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
987        ) as usize;
988        let first_id_off = chain_count_off + 4;
989        let mut next_id = u32::from_le_bytes(
990            page_content[first_id_off..first_id_off + 4]
991                .try_into()
992                .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
993        );
994
995        if payload_len < NONCE_SIZE {
996            return Err(VaultError::Corrupt("payload too short for nonce".into()));
997        }
998        let cipher_total = payload_len - NONCE_SIZE;
999
1000        // ---- Reassemble ciphertext fragments. ----------------------
1001        let mut cipher = Vec::with_capacity(cipher_total);
1002        let header_chunk_len = cipher_total.min(VAULT_HEADER_CIPHER_CAPACITY);
1003        let header_cipher_start = VAULT_HEADER_META_SIZE;
1004        cipher.extend_from_slice(
1005            &page_content[header_cipher_start..header_cipher_start + header_chunk_len],
1006        );
1007
1008        // Walk the data-page chain.
1009        let mut hops = 0usize;
1010        // Bound the walk: chain_count from the header is the source of
1011        // truth. We tolerate next_id pointers but trust chain_count to
1012        // avoid getting trapped in a corrupt loop.
1013        while cipher.len() < cipher_total {
1014            if hops >= chain_count {
1015                return Err(VaultError::Corrupt(format!(
1016                    "vault chain shorter than declared: {} hops, expected {}",
1017                    hops, chain_count
1018                )));
1019            }
1020            if next_id == 0 {
1021                return Err(VaultError::Corrupt(
1022                    "vault chain ends prematurely (next_id == 0)".to_string(),
1023                ));
1024            }
1025
1026            let dp = pager
1027                .read_page_no_checksum(next_id)
1028                .map_err(|e| VaultError::Pager(format!("vault data page {next_id}: {e}")))?;
1029            let dp_content = dp.content();
1030            if dp_content.len() < VAULT_DATA_PREFIX_SIZE {
1031                return Err(VaultError::Corrupt(format!(
1032                    "vault data page {next_id} truncated"
1033                )));
1034            }
1035            if &dp_content[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC {
1036                return Err(VaultError::Corrupt(format!(
1037                    "vault data page {next_id} has bad magic"
1038                )));
1039            }
1040            let np = u32::from_le_bytes(
1041                dp_content[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1042                    .try_into()
1043                    .map_err(|_| VaultError::Corrupt("bad next_page_id bytes".into()))?,
1044            );
1045            let take = (cipher_total - cipher.len()).min(VAULT_DATA_CIPHER_CAPACITY);
1046            let frag_start = VAULT_DATA_PREFIX_SIZE;
1047            cipher.extend_from_slice(&dp_content[frag_start..frag_start + take]);
1048
1049            next_id = np;
1050            hops += 1;
1051        }
1052
1053        if cipher.len() != cipher_total {
1054            return Err(VaultError::Corrupt(format!(
1055                "vault truncated: expected {} cipher bytes, got {}",
1056                cipher_total,
1057                cipher.len()
1058            )));
1059        }
1060        if hops != chain_count {
1061            return Err(VaultError::Corrupt(format!(
1062                "vault chain length mismatch: walked {} pages, header says {}",
1063                hops, chain_count
1064            )));
1065        }
1066
1067        // ---- Decrypt the reassembled blob in one shot. -------------
1068        let key_bytes: &[u8] = self.key.as_bytes();
1069        let key_arr: &[u8; 32] = key_bytes.try_into().map_err(|_| VaultError::Decryption)?;
1070        let plaintext = aes256_gcm_decrypt(key_arr, &nonce, VAULT_AAD, &cipher)
1071            .map_err(|_| VaultError::Decryption)?;
1072
1073        let state = VaultState::deserialize(&plaintext)?;
1074        Ok(Some(state))
1075    }
1076
1077    /// Walk the existing chain (if any) and collect the data-page ids,
1078    /// so `save()` can reuse / free them. Returns an error or empty
1079    /// vector if the chain isn't intact — callers must treat that as
1080    /// "no reusable chain" rather than failing the save outright,
1081    /// because a partially-corrupt chain is exactly the case where we
1082    /// most want a fresh write to land cleanly.
1083    fn read_existing_chain_ids(&self, pager: &Pager) -> Result<Vec<u32>, VaultError> {
1084        let header = pager
1085            .read_page_no_checksum(VAULT_HEADER_PAGE)
1086            .map_err(|e| VaultError::Pager(e.to_string()))?;
1087        let content = header.content();
1088        if content.len() < VAULT_HEADER_META_SIZE {
1089            return Ok(Vec::new());
1090        }
1091        if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1092            return Ok(Vec::new());
1093        }
1094        let version = content[4];
1095        if version != VAULT_VERSION && version != VAULT_PASSPHRASE_VERSION {
1096            // v1 (legacy) had its overflow at fixed page 3; we don't
1097            // know if that page is "ours" to free. Safer to leak it
1098            // — the operator is re-bootstrapping anyway.
1099            return Ok(Vec::new());
1100        }
1101        let nonce_start = VAULT_HEADER_PREAMBLE_SIZE;
1102        let chain_count_off = nonce_start + NONCE_SIZE;
1103        let chain_count = u32::from_le_bytes(
1104            content[chain_count_off..chain_count_off + 4]
1105                .try_into()
1106                .map_err(|_| VaultError::Corrupt("bad chain_count bytes".into()))?,
1107        ) as usize;
1108        let first_id_off = chain_count_off + 4;
1109        let mut id = u32::from_le_bytes(
1110            content[first_id_off..first_id_off + 4]
1111                .try_into()
1112                .map_err(|_| VaultError::Corrupt("bad first_data_page_id bytes".into()))?,
1113        );
1114
1115        let mut out = Vec::with_capacity(chain_count);
1116        let mut hops = 0usize;
1117        while id != 0 && hops < chain_count {
1118            out.push(id);
1119            // Peek next_id off the data page. Soft-fail on read errors
1120            // — we'd rather leak a page than refuse to save.
1121            match pager.read_page_no_checksum(id) {
1122                Ok(dp) => {
1123                    let dc = dp.content();
1124                    if dc.len() < VAULT_DATA_PREFIX_SIZE
1125                        || &dc[0..VAULT_MAGIC_SIZE] != VAULT_DATA_MAGIC
1126                    {
1127                        break;
1128                    }
1129                    id = u32::from_le_bytes(
1130                        dc[VAULT_MAGIC_SIZE..VAULT_MAGIC_SIZE + 4]
1131                            .try_into()
1132                            .map_err(|_| VaultError::Corrupt("bad next_id".into()))?,
1133                    );
1134                }
1135                Err(_) => break,
1136            }
1137            hops += 1;
1138        }
1139        Ok(out)
1140    }
1141
1142    /// Write the vault header page (magic + version + chain metadata +
1143    /// nonce + first ciphertext fragment). This is the commit point —
1144    /// callers must have flushed every data page first.
1145    fn write_header_page(
1146        &self,
1147        pager: &Pager,
1148        nonce: &[u8; NONCE_SIZE],
1149        payload_len: u32,
1150        chain_count: u32,
1151        first_data_page_id: u32,
1152        cipher_fragment: &[u8],
1153    ) -> Result<(), VaultError> {
1154        debug_assert!(cipher_fragment.len() <= VAULT_HEADER_CIPHER_CAPACITY);
1155
1156        let mut page = Page::new(PageType::Vault, VAULT_HEADER_PAGE);
1157        let bytes = page.as_bytes_mut();
1158        let mut off = HEADER_SIZE;
1159
1160        bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_MAGIC);
1161        off += VAULT_MAGIC_SIZE;
1162
1163        bytes[off] = self.version;
1164        off += VAULT_VERSION_SIZE;
1165
1166        bytes[off..off + VAULT_SALT_SIZE].copy_from_slice(&self.salt);
1167        off += VAULT_SALT_SIZE;
1168
1169        bytes[off..off + 4].copy_from_slice(&payload_len.to_le_bytes());
1170        off += VAULT_PAYLOAD_LEN_SIZE;
1171
1172        bytes[off..off + NONCE_SIZE].copy_from_slice(nonce);
1173        off += NONCE_SIZE;
1174
1175        bytes[off..off + 4].copy_from_slice(&chain_count.to_le_bytes());
1176        off += VAULT_CHAIN_COUNT_SIZE;
1177
1178        bytes[off..off + 4].copy_from_slice(&first_data_page_id.to_le_bytes());
1179        off += VAULT_FIRST_PAGE_ID_SIZE;
1180
1181        debug_assert_eq!(off, HEADER_SIZE + VAULT_HEADER_META_SIZE);
1182
1183        bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1184
1185        pager
1186            .write_page_no_checksum(VAULT_HEADER_PAGE, page)
1187            .map_err(|e| VaultError::Pager(e.to_string()))?;
1188        Ok(())
1189    }
1190
1191    /// Write a data page (magic + next_page_id + ciphertext fragment).
1192    fn write_data_page(
1193        &self,
1194        pager: &Pager,
1195        page_id: u32,
1196        next_page_id: u32,
1197        cipher_fragment: &[u8],
1198    ) -> Result<(), VaultError> {
1199        debug_assert!(cipher_fragment.len() <= VAULT_DATA_CIPHER_CAPACITY);
1200
1201        let mut page = Page::new(PageType::Vault, page_id);
1202        let bytes = page.as_bytes_mut();
1203        let mut off = HEADER_SIZE;
1204
1205        bytes[off..off + VAULT_MAGIC_SIZE].copy_from_slice(VAULT_DATA_MAGIC);
1206        off += VAULT_MAGIC_SIZE;
1207
1208        bytes[off..off + 4].copy_from_slice(&next_page_id.to_le_bytes());
1209        off += 4;
1210
1211        bytes[off..off + cipher_fragment.len()].copy_from_slice(cipher_fragment);
1212
1213        pager
1214            .write_page_no_checksum(page_id, page)
1215            .map_err(|e| VaultError::Pager(e.to_string()))?;
1216        Ok(())
1217    }
1218}
1219
1220// ---------------------------------------------------------------------------
1221// Helpers
1222// ---------------------------------------------------------------------------
1223
1224/// Decode a SCRAM verifier field of the form
1225/// `<salt_hex>:<iter>:<stored_hex>:<server_hex>` into a `ScramVerifier`.
1226fn parse_scram_field(field: &str) -> Result<crate::auth::scram::ScramVerifier, VaultError> {
1227    let parts: Vec<&str> = field.split(':').collect();
1228    if parts.len() != 4 {
1229        return Err(VaultError::Corrupt(format!(
1230            "SCRAM verifier has {} segments, expected 4",
1231            parts.len()
1232        )));
1233    }
1234    let salt =
1235        hex::decode(parts[0]).map_err(|_| VaultError::Corrupt("invalid SCRAM salt hex".into()))?;
1236    let iter: u32 = parts[1]
1237        .parse()
1238        .map_err(|_| VaultError::Corrupt("invalid SCRAM iter".into()))?;
1239    if iter < crate::auth::scram::MIN_ITER {
1240        return Err(VaultError::Corrupt(format!(
1241            "SCRAM iter {} below minimum {}",
1242            iter,
1243            crate::auth::scram::MIN_ITER
1244        )));
1245    }
1246    let stored_vec = hex::decode(parts[2])
1247        .map_err(|_| VaultError::Corrupt("invalid SCRAM stored_key hex".into()))?;
1248    let server_vec = hex::decode(parts[3])
1249        .map_err(|_| VaultError::Corrupt("invalid SCRAM server_key hex".into()))?;
1250    let stored_key: [u8; 32] = stored_vec
1251        .try_into()
1252        .map_err(|_| VaultError::Corrupt("SCRAM stored_key must be 32 bytes".into()))?;
1253    let server_key: [u8; 32] = server_vec
1254        .try_into()
1255        .map_err(|_| VaultError::Corrupt("SCRAM server_key must be 32 bytes".into()))?;
1256    Ok(crate::auth::scram::ScramVerifier {
1257        salt,
1258        iter,
1259        stored_key,
1260        server_key,
1261    })
1262}
1263
1264/// Read the 16-byte salt from an existing vault page in the pager.
1265///
1266/// Works against both v1 (legacy) and v2 layouts because the salt sits at
1267/// the same offset (5..21) in both — we only need the salt to re-derive
1268/// the key, not to interpret the rest of the page. Callers that intend
1269/// to actually load() will hit the version check there if it's legacy.
1270fn read_vault_salt_from_pager(pager: &Pager) -> Result<[u8; 16], VaultError> {
1271    let page = pager
1272        .read_page_no_checksum(VAULT_HEADER_PAGE)
1273        .map_err(|e| VaultError::Pager(format!("vault page read: {e}")))?;
1274
1275    let content = page.content();
1276    if content.len() < VAULT_HEADER_PREAMBLE_SIZE {
1277        return Err(VaultError::Corrupt("vault page too short".into()));
1278    }
1279    if &content[0..VAULT_MAGIC_SIZE] != VAULT_MAGIC {
1280        return Err(VaultError::Corrupt("bad magic bytes".into()));
1281    }
1282
1283    let mut salt = [0u8; VAULT_SALT_SIZE];
1284    salt.copy_from_slice(&content[5..21]);
1285    Ok(salt)
1286}
1287
1288// ---------------------------------------------------------------------------
1289// Tests
1290// ---------------------------------------------------------------------------
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295    use crate::auth::{now_ms, ApiKey, Role, User};
1296    use crate::storage::engine::pager::PagerConfig;
1297
1298    fn sample_state() -> VaultState {
1299        let now = now_ms();
1300        VaultState {
1301            users: vec![
1302                User {
1303                    username: "alice".into(),
1304                    tenant_id: None,
1305                    password_hash: "argon2id$aabbccdd$eeff0011".into(),
1306                    scram_verifier: None,
1307                    role: Role::Admin,
1308                    api_keys: vec![ApiKey {
1309                        key: "rk_abc123".into(),
1310                        name: "ci-token".into(),
1311                        role: Role::Write,
1312                        created_at: now,
1313                        expires_at: None,
1314                    }],
1315                    created_at: now,
1316                    updated_at: now,
1317                    enabled: true,
1318                },
1319                User {
1320                    username: "bob".into(),
1321                    tenant_id: None,
1322                    password_hash: "argon2id$11223344$55667788".into(),
1323                    scram_verifier: None,
1324                    role: Role::Read,
1325                    api_keys: vec![],
1326                    created_at: now,
1327                    updated_at: now,
1328                    enabled: false,
1329                },
1330            ],
1331            api_keys: vec![(
1332                UserId::platform("alice"),
1333                ApiKey {
1334                    key: "rk_abc123".into(),
1335                    name: "ci-token".into(),
1336                    role: Role::Write,
1337                    created_at: now,
1338                    expires_at: None,
1339                },
1340            )],
1341            bootstrapped: true,
1342            master_secret: None,
1343            kv: std::collections::HashMap::new(),
1344        }
1345    }
1346
1347    /// Helper to create a temporary pager for testing.
1348    fn temp_pager() -> (Pager, std::path::PathBuf) {
1349        use std::sync::atomic::{AtomicU64, Ordering};
1350        static COUNTER: AtomicU64 = AtomicU64::new(0);
1351        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1352        let tmp_dir =
1353            std::env::temp_dir().join(format!("reddb_vault_test_{}_{}", std::process::id(), id));
1354        std::fs::create_dir_all(&tmp_dir).unwrap();
1355        let db_path = tmp_dir.join("test.rdb");
1356        let pager = Pager::open(&db_path, PagerConfig::default()).unwrap();
1357        (pager, tmp_dir)
1358    }
1359
1360    fn env_lock() -> &'static std::sync::Mutex<()> {
1361        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1362        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1363    }
1364
1365    fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
1366        unsafe {
1367            match value {
1368                Some(value) => std::env::set_var(name, value),
1369                None => std::env::remove_var(name),
1370            }
1371        }
1372    }
1373
1374    #[test]
1375    fn portable_vault_preserves_native_auth_payload_and_passphrase_contract() {
1376        use reddb_engine::query::local_database::LocalDatabase;
1377        use reddb_engine::storage::{
1378            entity::{EntityId, RowData, UnifiedEntity},
1379            manager::ManagerConfig,
1380            resident_file::EmbeddedResidentStore,
1381            store_wal::StoreWalAction,
1382        };
1383        use reddb_file::{OpenMode, StdVfs, Vfs};
1384
1385        let (pager, directory) = temp_pager();
1386        let password = "native portable interop password";
1387        let native = Vault::with_passphrase(&pager, password).expect("native key");
1388        let mut state = sample_state();
1389        state.master_secret = Some(vec![42; 32]);
1390        let native_envelope = native.seal_logical_export(&state).expect("native envelope");
1391        let path = directory.join("portable.rdb");
1392        let mut mode = OpenMode::create_new();
1393        mode.read = true;
1394        let file = StdVfs.open(&path, mode).expect("private file");
1395        let mut database = LocalDatabase::create(file, &path, 0).expect("portable database");
1396        database
1397            .vault_unlock(password, true)
1398            .expect("initialize portable collection");
1399        let mut store = EmbeddedResidentStore::open(
1400            database.into_inner(),
1401            &path,
1402            ManagerConfig::default(),
1403            false,
1404            0,
1405        )
1406        .expect("physical store");
1407        let mut entity =
1408            UnifiedEntity::table_row(EntityId::new(1), "red.local.vault", 1, Vec::new());
1409        entity.data = reddb_engine::storage::entity::EntityData::Row(RowData::with_names(
1410            vec![reddb_types::Value::Secret(
1411                hex::decode(native_envelope).expect("hex"),
1412            )],
1413            vec!["value".into()],
1414        ));
1415        store
1416            .publish_actions(&[StoreWalAction::upsert_entity(
1417                "red.local.vault",
1418                &entity,
1419                None,
1420                reddb_file::STORE_VERSION_CURRENT,
1421            )])
1422            .expect("import native envelope");
1423        let mut database =
1424            LocalDatabase::open(store.into_inner(), &path).expect("open imported file");
1425        let key = database
1426            .vault_unlock(password, false)
1427            .expect("native passphrase in portable runtime");
1428        database
1429            .vault_put(&key, "connection:admin", Some("new credential"))
1430            .expect("portable mutation");
1431        let store = EmbeddedResidentStore::open(
1432            database.into_inner(),
1433            &path,
1434            ManagerConfig::default(),
1435            false,
1436            0,
1437        )
1438        .expect("read back");
1439        let (entity, _) = store
1440            .get("red.local.vault", EntityId::new(1))
1441            .expect("record")
1442            .expect("Vault exists");
1443        let Some(reddb_types::Value::Secret(blob)) = entity
1444            .data
1445            .as_row()
1446            .and_then(|row| row.get_by_name("value"))
1447        else {
1448            panic!("sealed Vault")
1449        };
1450        let restored = Vault::unseal_logical_export_with_passphrase(&hex::encode(blob), password)
1451            .expect("portable envelope in native runtime");
1452        assert_eq!(restored.master_secret, state.master_secret);
1453        assert_eq!(restored.bootstrapped, state.bootstrapped);
1454        assert_eq!(restored.users.len(), state.users.len());
1455        for (actual, expected) in restored.users.iter().zip(&state.users) {
1456            assert_eq!(actual.username, expected.username);
1457            assert_eq!(actual.password_hash, expected.password_hash);
1458            assert_eq!(actual.tenant_id, expected.tenant_id);
1459        }
1460        assert_eq!(restored.api_keys.len(), state.api_keys.len());
1461        assert_eq!(
1462            restored.kv.get("connection:admin").map(String::as_str),
1463            Some("new credential")
1464        );
1465        drop(store);
1466        drop(native);
1467        drop(pager);
1468        std::fs::remove_dir_all(directory).expect("remove test files");
1469    }
1470
1471    #[test]
1472    fn test_vault_state_serialize_deserialize_roundtrip() {
1473        let state = sample_state();
1474        let serialized = state.serialize();
1475        let text = std::str::from_utf8(&serialized).unwrap();
1476
1477        // Verify text format contains expected markers.
1478        assert!(text.contains("SEALED:true"));
1479        assert!(text.contains("USER:alice\t"));
1480        assert!(text.contains("USER:bob\t"));
1481        assert!(text.contains("KEY:alice\trk_abc123\t"));
1482
1483        // Deserialize and verify.
1484        let restored = VaultState::deserialize(&serialized).unwrap();
1485        assert!(restored.bootstrapped);
1486        assert_eq!(restored.users.len(), 2);
1487
1488        let alice = restored
1489            .users
1490            .iter()
1491            .find(|u| u.username == "alice")
1492            .unwrap();
1493        assert_eq!(alice.role, Role::Admin);
1494        assert!(alice.enabled);
1495        assert_eq!(alice.password_hash, "argon2id$aabbccdd$eeff0011");
1496        assert_eq!(alice.api_keys.len(), 1);
1497        assert_eq!(alice.api_keys[0].key, "rk_abc123");
1498        assert_eq!(alice.api_keys[0].name, "ci-token");
1499        assert_eq!(alice.api_keys[0].role, Role::Write);
1500
1501        let bob = restored.users.iter().find(|u| u.username == "bob").unwrap();
1502        assert_eq!(bob.role, Role::Read);
1503        assert!(!bob.enabled);
1504        assert!(bob.api_keys.is_empty());
1505
1506        assert_eq!(restored.api_keys.len(), 1);
1507        assert_eq!(restored.api_keys[0].0.username, "alice");
1508        assert!(restored.api_keys[0].0.tenant.is_none());
1509    }
1510
1511    #[test]
1512    fn test_vault_state_empty() {
1513        let state = VaultState {
1514            users: vec![],
1515            api_keys: vec![],
1516            bootstrapped: false,
1517            master_secret: None,
1518            kv: std::collections::HashMap::new(),
1519        };
1520        let serialized = state.serialize();
1521        let restored = VaultState::deserialize(&serialized).unwrap();
1522        assert!(!restored.bootstrapped);
1523        assert!(restored.users.is_empty());
1524        assert!(restored.api_keys.is_empty());
1525    }
1526
1527    #[test]
1528    fn test_vault_state_deserialize_invalid_utf8() {
1529        let bad_data = vec![0xFF, 0xFE, 0xFD];
1530        let result = VaultState::deserialize(&bad_data);
1531        assert!(result.is_err());
1532    }
1533
1534    #[test]
1535    fn test_vault_state_deserialize_bad_user_line() {
1536        let bad = b"USER:only_two\tfields\n";
1537        let result = VaultState::deserialize(bad);
1538        assert!(result.is_err());
1539    }
1540
1541    #[test]
1542    fn test_vault_state_deserialize_bad_key_line() {
1543        let bad = b"KEY:too\tfew\n";
1544        let result = VaultState::deserialize(bad);
1545        assert!(result.is_err());
1546    }
1547
1548    #[test]
1549    fn test_vault_state_deserialize_unknown_line_skipped() {
1550        let data = b"SEALED:false\nFUTURE:some_data\n";
1551        let result = VaultState::deserialize(data).unwrap();
1552        assert!(!result.bootstrapped);
1553    }
1554
1555    #[test]
1556    fn test_vault_pager_save_load_roundtrip() {
1557        let (pager, tmp_dir) = temp_pager();
1558
1559        let kp = KeyPair::generate();
1560        let certificate = kp.certificate_hex();
1561        let vault = Vault::with_certificate(&pager, &certificate).unwrap();
1562
1563        // Initially no vault pages.
1564        let loaded = vault.load(&pager).unwrap();
1565        assert!(loaded.is_none());
1566
1567        // Save state.
1568        let state = sample_state();
1569        vault.save(&pager, &state).unwrap();
1570
1571        // Load back.
1572        let restored = vault.load(&pager).unwrap().unwrap();
1573        assert!(restored.bootstrapped);
1574        assert_eq!(restored.users.len(), 2);
1575        assert_eq!(restored.api_keys.len(), 1);
1576
1577        let alice = restored
1578            .users
1579            .iter()
1580            .find(|u| u.username == "alice")
1581            .unwrap();
1582        assert_eq!(alice.role, Role::Admin);
1583        assert_eq!(alice.api_keys.len(), 1);
1584
1585        // Re-open vault with same certificate and load again (salt read from page).
1586        let vault2 = Vault::with_certificate(&pager, &certificate).unwrap();
1587        let restored2 = vault2.load(&pager).unwrap().unwrap();
1588        assert!(restored2.bootstrapped);
1589        assert_eq!(restored2.users.len(), 2);
1590
1591        // Clean up.
1592        drop(pager);
1593        let _ = std::fs::remove_dir_all(&tmp_dir);
1594    }
1595
1596    #[test]
1597    fn test_vault_wrong_key_fails_decryption() {
1598        let (pager, tmp_dir) = temp_pager();
1599
1600        // Save with one certificate.
1601        let kp = KeyPair::generate();
1602        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1603        let state = VaultState {
1604            users: vec![],
1605            api_keys: vec![],
1606            bootstrapped: true,
1607            master_secret: None,
1608            kv: std::collections::HashMap::new(),
1609        };
1610        vault.save(&pager, &state).unwrap();
1611
1612        // Try to load with a different certificate.
1613        let wrong = KeyPair::generate();
1614        let vault2 = Vault::with_certificate_bytes(&pager, &wrong.certificate).unwrap();
1615        let result = vault2.load(&pager);
1616
1617        assert!(result.is_err());
1618
1619        // Clean up.
1620        drop(pager);
1621        let _ = std::fs::remove_dir_all(&tmp_dir);
1622    }
1623
1624    #[test]
1625    fn test_vault_no_key_error() {
1626        let (pager, tmp_dir) = temp_pager();
1627
1628        let _guard = env_lock().lock().unwrap();
1629        let old = std::env::var_os("REDDB_CERTIFICATE");
1630        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1631        unsafe {
1632            std::env::remove_var("REDDB_CERTIFICATE");
1633            std::env::remove_var("REDDB_CERTIFICATE_FILE");
1634        }
1635        let result = Vault::open(&pager);
1636        restore_env_var("REDDB_CERTIFICATE", old);
1637        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1638        assert!(matches!(result, Err(VaultError::NoKey)));
1639
1640        // Clean up.
1641        drop(pager);
1642        let _ = std::fs::remove_dir_all(&tmp_dir);
1643    }
1644
1645    #[test]
1646    fn test_vault_open_uses_certificate_env() {
1647        let (pager, tmp_dir) = temp_pager();
1648
1649        let _guard = env_lock().lock().unwrap();
1650        let old = std::env::var_os("REDDB_CERTIFICATE");
1651        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1652        let kp = KeyPair::generate();
1653        unsafe {
1654            std::env::set_var("REDDB_CERTIFICATE", kp.certificate_hex());
1655            std::env::remove_var("REDDB_CERTIFICATE_FILE");
1656        }
1657
1658        // Open from REDDB_CERTIFICATE.
1659        let vault = Vault::open(&pager).unwrap();
1660        let state = VaultState {
1661            users: vec![],
1662            api_keys: vec![],
1663            bootstrapped: false,
1664            master_secret: None,
1665            kv: std::collections::HashMap::new(),
1666        };
1667        vault.save(&pager, &state).unwrap();
1668
1669        // Re-open from the same env certificate.
1670        let vault2 = Vault::open(&pager).unwrap();
1671        let loaded = vault2.load(&pager).unwrap().unwrap();
1672        assert!(!loaded.bootstrapped);
1673
1674        restore_env_var("REDDB_CERTIFICATE", old);
1675        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1676        drop(pager);
1677        let _ = std::fs::remove_dir_all(&tmp_dir);
1678    }
1679
1680    #[test]
1681    fn test_vault_open_uses_certificate_file_env() {
1682        let (pager, tmp_dir) = temp_pager();
1683
1684        let _guard = env_lock().lock().unwrap();
1685        let old = std::env::var_os("REDDB_CERTIFICATE");
1686        let old_file = std::env::var_os("REDDB_CERTIFICATE_FILE");
1687        let kp = KeyPair::generate();
1688        let cert_path = tmp_dir.join("certificate");
1689        std::fs::write(&cert_path, format!("{}\n", kp.certificate_hex())).unwrap();
1690        unsafe {
1691            std::env::remove_var("REDDB_CERTIFICATE");
1692            std::env::set_var("REDDB_CERTIFICATE_FILE", &cert_path);
1693        }
1694
1695        let vault = Vault::open(&pager).unwrap();
1696        let state = VaultState {
1697            users: vec![],
1698            api_keys: vec![],
1699            bootstrapped: false,
1700            master_secret: None,
1701            kv: std::collections::HashMap::new(),
1702        };
1703        vault.save(&pager, &state).unwrap();
1704
1705        let vault2 = Vault::open(&pager).unwrap();
1706        let loaded = vault2.load(&pager).unwrap().unwrap();
1707        assert!(!loaded.bootstrapped);
1708
1709        restore_env_var("REDDB_CERTIFICATE", old);
1710        restore_env_var("REDDB_CERTIFICATE_FILE", old_file);
1711        drop(pager);
1712        let _ = std::fs::remove_dir_all(&tmp_dir);
1713    }
1714
1715    // ---------------------------------------------------------------
1716    // KeyPair and certificate-based seal tests
1717    // ---------------------------------------------------------------
1718
1719    #[test]
1720    fn test_keypair_generate_deterministic_certificate() {
1721        let kp = KeyPair::generate();
1722        assert_eq!(kp.master_secret.len(), 32);
1723        assert_eq!(kp.certificate.len(), 32);
1724
1725        // Re-deriving from the same master secret gives the same certificate.
1726        let kp2 = KeyPair::from_master_secret(kp.master_secret.clone());
1727        assert_eq!(kp.certificate, kp2.certificate);
1728    }
1729
1730    #[test]
1731    fn test_keypair_sign_verify() {
1732        let kp = KeyPair::generate();
1733        let data = b"session:abc123";
1734        let sig = kp.sign(data);
1735        assert!(kp.verify(data, &sig));
1736
1737        // Wrong data fails.
1738        assert!(!kp.verify(b"session:wrong", &sig));
1739
1740        // Wrong signature fails.
1741        let mut bad_sig = sig.clone();
1742        bad_sig[0] ^= 0xFF;
1743        assert!(!kp.verify(data, &bad_sig));
1744    }
1745
1746    #[test]
1747    fn test_keypair_certificate_hex() {
1748        let kp = KeyPair::generate();
1749        let hex_str = kp.certificate_hex();
1750        assert_eq!(hex_str.len(), 64); // 32 bytes = 64 hex chars
1751        let decoded = hex::decode(&hex_str).unwrap();
1752        assert_eq!(decoded, kp.certificate);
1753    }
1754
1755    #[test]
1756    fn test_vault_certificate_seal_roundtrip() {
1757        let (pager, tmp_dir) = temp_pager();
1758
1759        // Generate a keypair and create a vault sealed by its certificate.
1760        let kp = KeyPair::generate();
1761        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1762
1763        // Save state including the master secret.
1764        let state = VaultState {
1765            users: vec![],
1766            api_keys: vec![],
1767            bootstrapped: true,
1768            master_secret: Some(kp.master_secret.clone()),
1769            kv: std::collections::HashMap::new(),
1770        };
1771        vault.save(&pager, &state).unwrap();
1772
1773        // Re-open using the certificate hex string (simulates admin unseal).
1774        let vault2 = Vault::with_certificate(&pager, &kp.certificate_hex()).unwrap();
1775        let loaded = vault2.load(&pager).unwrap().unwrap();
1776        assert!(loaded.bootstrapped);
1777        assert_eq!(loaded.master_secret, Some(kp.master_secret.clone()));
1778
1779        // Verify the master secret can reconstruct the same keypair.
1780        let kp2 = KeyPair::from_master_secret(loaded.master_secret.unwrap());
1781        assert_eq!(kp.certificate, kp2.certificate);
1782
1783        drop(pager);
1784        let _ = std::fs::remove_dir_all(&tmp_dir);
1785    }
1786
1787    #[test]
1788    fn test_vault_certificate_wrong_cert_fails() {
1789        let (pager, tmp_dir) = temp_pager();
1790
1791        // Seal with one keypair.
1792        let kp = KeyPair::generate();
1793        let vault = Vault::with_certificate_bytes(&pager, &kp.certificate).unwrap();
1794        let state = VaultState {
1795            users: vec![],
1796            api_keys: vec![],
1797            bootstrapped: true,
1798            master_secret: Some(kp.master_secret.clone()),
1799            kv: std::collections::HashMap::new(),
1800        };
1801        vault.save(&pager, &state).unwrap();
1802
1803        // Try to unseal with a different certificate.
1804        let kp2 = KeyPair::generate();
1805        let vault2 = Vault::with_certificate_bytes(&pager, &kp2.certificate).unwrap();
1806        let result = vault2.load(&pager);
1807        assert!(result.is_err());
1808
1809        drop(pager);
1810        let _ = std::fs::remove_dir_all(&tmp_dir);
1811    }
1812
1813    #[test]
1814    fn test_vault_state_master_secret_serialization() {
1815        let secret = vec![0xAA; 32];
1816        let state = VaultState {
1817            users: vec![],
1818            api_keys: vec![],
1819            bootstrapped: true,
1820            master_secret: Some(secret.clone()),
1821            kv: std::collections::HashMap::new(),
1822        };
1823        let serialized = state.serialize();
1824        let text = std::str::from_utf8(&serialized).unwrap();
1825        assert!(text.contains("MASTER_SECRET:"));
1826        assert!(text.contains(&hex::encode(&secret)));
1827
1828        let restored = VaultState::deserialize(&serialized).unwrap();
1829        assert_eq!(restored.master_secret, Some(secret));
1830        assert!(restored.bootstrapped);
1831    }
1832
1833    #[test]
1834    fn test_vault_state_no_master_secret_backward_compat() {
1835        // Legacy vault format without MASTER_SECRET line.
1836        let data = b"SEALED:true\n";
1837        let restored = VaultState::deserialize(data).unwrap();
1838        assert!(restored.master_secret.is_none());
1839        assert!(restored.bootstrapped);
1840    }
1841
1842    #[test]
1843    fn test_vault_state_scram_verifier_roundtrip() {
1844        use crate::auth::scram::ScramVerifier;
1845
1846        let verifier = ScramVerifier::from_password(
1847            "hunter2",
1848            b"reddb-vault-test-salt".to_vec(),
1849            crate::auth::scram::DEFAULT_ITER,
1850        );
1851
1852        let now = now_ms();
1853        let state = VaultState {
1854            users: vec![User {
1855                username: "carol".into(),
1856                tenant_id: None,
1857                password_hash: "argon2id$abc$def".into(),
1858                scram_verifier: Some(verifier.clone()),
1859                role: Role::Admin,
1860                api_keys: vec![],
1861                created_at: now,
1862                updated_at: now,
1863                enabled: true,
1864            }],
1865            api_keys: vec![],
1866            bootstrapped: true,
1867            master_secret: None,
1868            kv: std::collections::HashMap::new(),
1869        };
1870
1871        let bytes = state.serialize();
1872        let restored = VaultState::deserialize(&bytes).unwrap();
1873        let carol = restored
1874            .users
1875            .iter()
1876            .find(|u| u.username == "carol")
1877            .unwrap();
1878        let v = carol.scram_verifier.as_ref().expect("verifier round-trips");
1879        assert_eq!(v.salt, verifier.salt);
1880        assert_eq!(v.iter, verifier.iter);
1881        assert_eq!(v.stored_key, verifier.stored_key);
1882        assert_eq!(v.server_key, verifier.server_key);
1883    }
1884
1885    #[test]
1886    fn test_vault_state_pre_tenant_user_line_still_parses() {
1887        // 7-field pre-tenant USER line (no trailing tenant id). Must
1888        // keep working since vaults written before tenant scoping
1889        // landed have this shape.
1890        let now = now_ms();
1891        let line = format!(
1892            "USER:dave\targon2id$x$y\tread\ttrue\t{}\t{}\t\nSEALED:false\n",
1893            now, now
1894        );
1895        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1896        let dave = restored
1897            .users
1898            .iter()
1899            .find(|u| u.username == "dave")
1900            .unwrap();
1901        assert!(dave.scram_verifier.is_none());
1902        assert!(dave.tenant_id.is_none());
1903    }
1904
1905    #[test]
1906    fn test_vault_state_tenant_user_line_still_parses() {
1907        let now = now_ms();
1908        let line = format!(
1909            "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\nSEALED:false\n",
1910            now, now
1911        );
1912        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1913        let erin = restored
1914            .users
1915            .iter()
1916            .find(|u| u.username == "erin")
1917            .unwrap();
1918        assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1919    }
1920
1921    #[test]
1922    fn test_vault_state_legacy_user_line_with_extra_ownership_field_is_ignored() {
1923        let now = now_ms();
1924        let line = format!(
1925            "USER:erin\targon2id$x$y\twrite\ttrue\t{}\t{}\t\tacme\ttrue\nSEALED:false\n",
1926            now, now
1927        );
1928        let restored = VaultState::deserialize(line.as_bytes()).unwrap();
1929        let erin = restored
1930            .users
1931            .iter()
1932            .find(|u| u.username == "erin")
1933            .unwrap();
1934        assert_eq!(erin.tenant_id.as_deref(), Some("acme"));
1935    }
1936
1937    #[test]
1938    fn test_vault_state_user_line_with_tenant_roundtrip() {
1939        let now = now_ms();
1940        let state = VaultState {
1941            users: vec![User {
1942                username: "alice".into(),
1943                tenant_id: Some("acme".into()),
1944                password_hash: "argon2id$x$y".into(),
1945                scram_verifier: None,
1946                role: Role::Write,
1947                api_keys: vec![],
1948                created_at: now,
1949                updated_at: now,
1950                enabled: true,
1951            }],
1952            api_keys: vec![],
1953            bootstrapped: true,
1954            master_secret: None,
1955            kv: std::collections::HashMap::new(),
1956        };
1957        let bytes = state.serialize();
1958        let text = std::str::from_utf8(&bytes).unwrap();
1959        // New vault payloads stop writing the legacy ownership field.
1960        assert!(text.contains("\tacme\n"));
1961
1962        let restored = VaultState::deserialize(&bytes).unwrap();
1963        let alice = restored
1964            .users
1965            .iter()
1966            .find(|u| u.username == "alice")
1967            .unwrap();
1968        assert_eq!(alice.tenant_id.as_deref(), Some("acme"));
1969    }
1970
1971    #[test]
1972    fn test_vault_state_key_line_with_tenant_reattaches_correctly() {
1973        // Two same-named users in different tenants. Each owns one
1974        // API key. Reattachment must respect tenant scope.
1975        let now = now_ms();
1976        let state = VaultState {
1977            users: vec![
1978                User {
1979                    username: "alice".into(),
1980                    tenant_id: Some("acme".into()),
1981                    password_hash: "argon2id$x$y".into(),
1982                    scram_verifier: None,
1983                    role: Role::Write,
1984                    api_keys: vec![],
1985                    created_at: now,
1986                    updated_at: now,
1987                    enabled: true,
1988                },
1989                User {
1990                    username: "alice".into(),
1991                    tenant_id: Some("globex".into()),
1992                    password_hash: "argon2id$a$b".into(),
1993                    scram_verifier: None,
1994                    role: Role::Read,
1995                    api_keys: vec![],
1996                    created_at: now,
1997                    updated_at: now,
1998                    enabled: true,
1999                },
2000            ],
2001            api_keys: vec![
2002                (
2003                    UserId::scoped("acme", "alice"),
2004                    ApiKey {
2005                        key: "rk_acme_key".into(),
2006                        name: "deploy".into(),
2007                        role: Role::Write,
2008                        created_at: now,
2009                        expires_at: None,
2010                    },
2011                ),
2012                (
2013                    UserId::scoped("globex", "alice"),
2014                    ApiKey {
2015                        key: "rk_globex_key".into(),
2016                        name: "ci".into(),
2017                        role: Role::Read,
2018                        created_at: now,
2019                        expires_at: None,
2020                    },
2021                ),
2022            ],
2023            bootstrapped: true,
2024            master_secret: None,
2025            kv: std::collections::HashMap::new(),
2026        };
2027        let bytes = state.serialize();
2028        let restored = VaultState::deserialize(&bytes).unwrap();
2029        // The api_keys vector retains both entries with the right
2030        // owners.
2031        assert_eq!(restored.api_keys.len(), 2);
2032        let acme_key = restored
2033            .api_keys
2034            .iter()
2035            .find(|(o, _)| o.tenant.as_deref() == Some("acme"))
2036            .unwrap();
2037        assert_eq!(acme_key.1.key, "rk_acme_key");
2038        let globex_key = restored
2039            .api_keys
2040            .iter()
2041            .find(|(o, _)| o.tenant.as_deref() == Some("globex"))
2042            .unwrap();
2043        assert_eq!(globex_key.1.key, "rk_globex_key");
2044    }
2045
2046    #[test]
2047    fn test_vault_state_scram_iter_below_min_rejected() {
2048        let now = now_ms();
2049        // 33 hex pairs = 33 bytes, but the parse_scram_field iter check
2050        // fires before length validation. Stored/server are 32 hex bytes
2051        // (64 chars) here so we exercise the iter floor specifically.
2052        let stored_hex = "00".repeat(32);
2053        let server_hex = "11".repeat(32);
2054        let line = format!(
2055            "USER:eve\targon2id$x$y\tread\ttrue\t{}\t{}\tdeadbeef:1024:{}:{}\n",
2056            now, now, stored_hex, server_hex
2057        );
2058        match VaultState::deserialize(line.as_bytes()) {
2059            Err(VaultError::Corrupt(msg)) => assert!(msg.contains("below minimum")),
2060            Err(other) => panic!("expected Corrupt iter-floor error, got {other:?}"),
2061            Ok(_) => panic!("expected Corrupt iter-floor error, got Ok"),
2062        }
2063    }
2064
2065    #[test]
2066    fn test_constant_time_eq_function() {
2067        assert!(constant_time_eq(b"hello", b"hello"));
2068        assert!(!constant_time_eq(b"hello", b"world"));
2069        assert!(!constant_time_eq(b"short", b"longer"));
2070        assert!(constant_time_eq(b"", b""));
2071    }
2072}