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