Skip to main content

wm_memory/
at_rest.rs

1//! At-rest keyring — Q39 slice A: wrapped galaxy DEKs in a `keyring` DBI.
2//!
3//! One root key (RK) per store; one random 256-bit data-encryption key (DEK)
4//! per galaxy, wrapped by `KEK = hkdf32(rk, "wm/galaxy-dek/v1/<galaxy>")`
5//! with XChaCha20-Poly1305 and the same info string as AAD (so a wrapped DEK
6//! cannot be transplanted between galaxies). A `rk:check` row (AEAD wrap of a
7//! fixed known plaintext under RK) is the wrong-key discriminator, written in
8//! the same transaction as the DEKs.
9//!
10//! **Slice A records remain plaintext.** This module lands the key hierarchy
11//! and the open-time unlock path; record AEAD is slice B. Mode B (key file)
12//! never advertises crypto-erasure — its guarantee is physical purge only.
13//!
14//! Design: `docs/Q39_CRYPTO_ERASURE_DESIGN.md` §2/§3/§7.
15//!
16//! Layout decisions:
17//! - The generated key file lives at the store **root**, next to the seal
18//!   key: when the open path's final component is `lmdb` (the standard
19//!   `<store-root>/lmdb` layout) the key is `<store-root>/.at_rest_key`;
20//!   other layouts (tests, custom paths) keep it inside the directory passed
21//!   to the store open. The key therefore lives outside the directory that
22//!   LMDB maintenance/restore tooling rewrites hardest.
23//! - The keyring DBI is **not** part of the store's required schema: legacy
24//!   stores and strict read-only opens never create it, and `ensure_schema`
25//!   leaves it optional.
26//! - A writable open with `WM_AT_REST_MODE=off` onto a store whose keyring
27//!   meta exists is refused (split-brain guard); read-only inspection still
28//!   reports the mode.
29
30use argon2::{Algorithm, Argon2, Params, Version};
31use chacha20poly1305::aead::{Aead, Payload};
32use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
33use lmdb::{Cursor, Database, DatabaseFlags, Environment, Transaction, WriteFlags};
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use wm_core::{CoreError, Galaxy, Result};
38use zeroize::Zeroizing;
39
40/// LMDB DBI holding the at-rest key material.
41pub const KEYRING_DB: &str = "keyring";
42/// Keyring row holding the (non-secret) meta JSON.
43pub const KEYRING_META_KEY: &[u8] = b"meta";
44/// Keyring row holding the wrong-key discriminator (AEAD wrap of a fixed
45/// known plaintext under RK; AAD `wm/at-rest/rk-check/v1`).
46pub const RK_CHECK_KEY: &[u8] = b"rk:check";
47/// Prefix of the `dek:<galaxy-db-name>` rows holding wrapped galaxy DEKs.
48pub const DEK_KEY_PREFIX: &str = "dek:";
49/// Current keyring meta format version.
50pub const KEYRING_FORMAT_VERSION: u32 = 1;
51/// AAD (and known plaintext) for the `rk:check` row.
52pub const RK_CHECK_INFO: &str = "wm/at-rest/rk-check/v1";
53/// Fixed known plaintext wrapped under RK into `rk:check`.
54pub const RK_CHECK_PLAINTEXT: &[u8] = b"wm/at-rest/rk-check/v1";
55/// Default generated key-file name.
56pub const AT_REST_KEY_FILE: &str = ".at_rest_key";
57/// Final path component that marks the standard `<store-root>/lmdb` layout.
58const AT_REST_LMDB_DIR: &str = "lmdb";
59/// Root-key length (and generated key-file length) in bytes.
60pub const AT_REST_KEY_LEN: usize = 32;
61
62/// Resolve the generated key-file path for a store directory.
63///
64/// Standard layouts pass `<store-root>/lmdb` to the store open: the key is
65/// placed at the store root, `<store-root>/.at_rest_key`, next to
66/// `.seal_key` — outside the directory that LMDB maintenance and restore
67/// tooling rewrites. Non-standard layouts (tests, custom paths whose final
68/// component is not `lmdb`) keep the key inside the directory passed in.
69#[must_use]
70pub fn generated_key_path(store_dir: &Path) -> PathBuf {
71    let is_standard = store_dir
72        .file_name()
73        .is_some_and(|name| name == AT_REST_LMDB_DIR);
74    if is_standard {
75        if let Some(root) = store_dir.parent().filter(|p| !p.as_os_str().is_empty()) {
76            return root.join(AT_REST_KEY_FILE);
77        }
78    }
79    store_dir.join(AT_REST_KEY_FILE)
80}
81
82/// Wrap format version byte (row layout: `version || 24-byte nonce || AEAD ct+tag`).
83const WRAP_VERSION: u8 = 1;
84const NONCE_LEN: usize = 24;
85const TAG_LEN: usize = 16;
86/// Version byte + nonce.
87const WRAP_HEADER_LEN: usize = 1 + NONCE_LEN;
88/// Minimum wrapped-secret length (header + tag).
89const WRAP_MIN_LEN: usize = WRAP_HEADER_LEN + TAG_LEN;
90
91/// Argon2id parameters ruled for mode C (OWASP minimum: 19 MiB, t=2, p=1).
92const ARGON2_SALT_LEN: usize = 16;
93const ARGON2_M_COST_KIB: u32 = 19_456;
94const ARGON2_T_COST: u32 = 2;
95const ARGON2_P_COST: u32 = 1;
96const ARGON2_VERSION: u32 = 0x13;
97
98fn mem_err(message: impl Into<String>) -> CoreError {
99    CoreError::Memory(message.into())
100}
101
102// ── Configuration ──────────────────────────────────────────────────────
103
104/// At-rest mode for a store.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum AtRestMode {
108    /// Plaintext pass-through: no keyring, no behavior change.
109    Off,
110    /// Mode B — RK from `WM_AT_REST_ROOT_KEY`, `WM_AT_REST_KEY_FILE`, or a
111    /// generated store-local key file.
112    Keyfile,
113    /// Mode C — RK = Argon2id(passphrase). Real crypto-erasure territory
114    /// (records still plaintext until slice B).
115    Passphrase,
116}
117
118impl AtRestMode {
119    /// Parse the `WM_AT_REST_MODE` spelling (case-insensitive).
120    #[must_use]
121    pub fn parse(value: &str) -> Option<Self> {
122        match value.trim().to_ascii_lowercase().as_str() {
123            "off" => Some(Self::Off),
124            "keyfile" => Some(Self::Keyfile),
125            "passphrase" => Some(Self::Passphrase),
126            _ => None,
127        }
128    }
129
130    /// Canonical lowercase spelling (also the `meta.mode` serde form).
131    #[must_use]
132    pub const fn as_str(self) -> &'static str {
133        match self {
134            Self::Off => "off",
135            Self::Keyfile => "keyfile",
136            Self::Passphrase => "passphrase",
137        }
138    }
139}
140
141impl std::fmt::Display for AtRestMode {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.write_str(self.as_str())
144    }
145}
146
147/// Environment-driven at-rest configuration.
148///
149/// Deliberately no `Debug`: it can carry the root key and the passphrase.
150#[derive(Clone)]
151pub struct AtRestConfig {
152    mode: AtRestMode,
153    root_key: Option<Zeroizing<String>>,
154    key_file: Option<PathBuf>,
155    passphrase: Option<Zeroizing<String>>,
156}
157
158impl Default for AtRestConfig {
159    fn default() -> Self {
160        Self::off()
161    }
162}
163
164/// Parse a raw `WM_AT_REST_MODE` value: unset is `off`, anything
165/// unrecognized is a hard error (fail-closed — a typo must never silently
166/// downgrade a store to plaintext).
167fn mode_from_env_value(value: Option<&str>) -> Result<AtRestMode> {
168    match value {
169        Some(raw) => AtRestMode::parse(raw).ok_or_else(|| {
170            mem_err(format!(
171                "WM_AT_REST_MODE='{raw}' is not off|keyfile|passphrase — refusing to open \
172                 (fail-closed); fix or unset the variable"
173            ))
174        }),
175        None => Ok(AtRestMode::Off),
176    }
177}
178
179impl AtRestConfig {
180    /// Dark by default — zero behavior change.
181    #[must_use]
182    pub const fn off() -> Self {
183        Self {
184            mode: AtRestMode::Off,
185            root_key: None,
186            key_file: None,
187            passphrase: None,
188        }
189    }
190
191    /// Read `WM_AT_REST_MODE` (off | keyfile | passphrase, default off),
192    /// `WM_AT_REST_KEY_FILE`, `WM_AT_REST_ROOT_KEY`, `WM_AT_REST_PASSPHRASE`.
193    ///
194    /// A set-but-unrecognized mode refuses the read (fail-closed): a typo
195    /// must never silently downgrade an open to plaintext pass-through.
196    pub fn from_env() -> Result<Self> {
197        let mode = mode_from_env_value(std::env::var("WM_AT_REST_MODE").ok().as_deref())?;
198        let root_key = std::env::var("WM_AT_REST_ROOT_KEY")
199            .ok()
200            .filter(|v| !v.is_empty())
201            .map(Zeroizing::new);
202        let key_file = std::env::var("WM_AT_REST_KEY_FILE")
203            .ok()
204            .filter(|v| !v.is_empty())
205            .map(PathBuf::from);
206        let passphrase = std::env::var("WM_AT_REST_PASSPHRASE")
207            .ok()
208            .filter(|v| !v.is_empty())
209            .map(Zeroizing::new);
210        Ok(Self {
211            mode,
212            root_key,
213            key_file,
214            passphrase,
215        })
216    }
217
218    /// Mode-B config with an explicit material root key (64 hex chars or 32
219    /// raw bytes). Tests use this to avoid env mutation.
220    #[must_use]
221    pub fn keyfile_with_root_key(material: impl Into<String>) -> Self {
222        Self {
223            mode: AtRestMode::Keyfile,
224            root_key: Some(Zeroizing::new(material.into())),
225            key_file: None,
226            passphrase: None,
227        }
228    }
229
230    /// Mode-B config with an explicit key file (created if missing).
231    #[must_use]
232    pub fn keyfile_with_key_file(path: impl Into<PathBuf>) -> Self {
233        Self {
234            mode: AtRestMode::Keyfile,
235            root_key: None,
236            key_file: Some(path.into()),
237            passphrase: None,
238        }
239    }
240
241    /// Mode-B config that resolves the generated key file
242    /// ([`generated_key_path`]: store root for the standard layout).
243    #[must_use]
244    pub const fn keyfile() -> Self {
245        Self {
246            mode: AtRestMode::Keyfile,
247            root_key: None,
248            key_file: None,
249            passphrase: None,
250        }
251    }
252
253    /// Mode-C config with an explicit passphrase.
254    #[must_use]
255    pub fn passphrase(passphrase: impl Into<String>) -> Self {
256        Self {
257            mode: AtRestMode::Passphrase,
258            root_key: None,
259            key_file: None,
260            passphrase: Some(Zeroizing::new(passphrase.into())),
261        }
262    }
263
264    /// Configured mode.
265    #[must_use]
266    pub const fn mode(&self) -> AtRestMode {
267        self.mode
268    }
269}
270
271// ── Meta / status ──────────────────────────────────────────────────────
272
273/// Non-secret Argon2id parameters recorded in the keyring meta (mode C).
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct Argon2Params {
276    /// Memory cost in KiB.
277    pub m_cost_kib: u32,
278    /// Time cost (iterations).
279    pub t_cost: u32,
280    /// Parallelism.
281    pub p_cost: u32,
282    /// Argon2 version constant (0x13 = v19).
283    pub version: u32,
284    /// Random 16-byte salt, hex-encoded.
285    pub salt_hex: String,
286}
287
288/// Non-secret keyring meta row (JSON).
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct KeyringMeta {
291    /// Keyring format version ([`KEYRING_FORMAT_VERSION`]).
292    pub format_version: u32,
293    /// Mode the store was initialized under.
294    pub mode: AtRestMode,
295    /// How the RK was obtained at initialization: `env_root_key` |
296    /// `key_file` | `generated_key_file` | `argon2id_passphrase`.
297    pub key_source: String,
298    /// RFC 3339 creation timestamp.
299    pub created_at: String,
300    /// Argon2id parameters (mode C only; `None` for mode B).
301    #[serde(default)]
302    pub argon2: Option<Argon2Params>,
303}
304
305/// Read-only at-rest disclosure status (meta only — never resolves the RK).
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum AtRestStatus {
308    /// No keyring in this store: plaintext pass-through.
309    Absent,
310    /// Keyring present and parseable.
311    Present(AtRestStatusPresent),
312    /// Keyring present but the meta row is unreadable/unsupported — the
313    /// store refuses writable opens until repaired (fail-closed).
314    Malformed {
315        /// Human-readable reason for the disclosure line.
316        reason: String,
317    },
318}
319
320/// Parsed keyring status for a store with at-rest state.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct AtRestStatusPresent {
323    /// Non-secret meta.
324    pub meta: KeyringMeta,
325    /// Wrapped `dek:*` rows found in the keyring DBI.
326    pub wrapped_deks: usize,
327    /// Galaxies the keyring is expected to cover (16 in this build).
328    pub galaxies: usize,
329    /// Resolved key-file path when the RK came from a file (generated or
330    /// explicitly configured); `None` for env material / passphrase.
331    pub key_file: Option<PathBuf>,
332}
333
334/// Unlocked keyring state for a writable at-rest store.
335///
336/// Holds the unwrapped galaxy DEKs (zeroized on drop). Slice A verifies them
337/// at open; slice B will use them for record AEAD.
338pub struct AtRestState {
339    meta: KeyringMeta,
340    key_file: Option<PathBuf>,
341    deks: HashMap<String, Zeroizing<[u8; AT_REST_KEY_LEN]>>,
342}
343
344impl AtRestState {
345    /// Non-secret keyring meta.
346    #[must_use]
347    pub const fn meta(&self) -> &KeyringMeta {
348        &self.meta
349    }
350
351    /// Unwrapped DEK for a galaxy, by `Galaxy::db_name()` (slice B seam).
352    #[must_use]
353    pub fn galaxy_dek(&self, galaxy_db_name: &str) -> Option<&[u8; AT_REST_KEY_LEN]> {
354        self.deks.get(galaxy_db_name).map(|dek| &**dek)
355    }
356
357    /// Number of unwrapped DEKs held.
358    #[must_use]
359    pub fn dek_count(&self) -> usize {
360        self.deks.len()
361    }
362
363    /// Status view (meta + counts), never exposing key bytes.
364    #[must_use]
365    pub fn status(&self) -> AtRestStatusPresent {
366        AtRestStatusPresent {
367            meta: self.meta.clone(),
368            wrapped_deks: self.deks.len(),
369            galaxies: Galaxy::all().len(),
370            key_file: self.key_file.clone(),
371        }
372    }
373}
374
375// ── Crypto primitives ──────────────────────────────────────────────────
376
377fn fill_random(buf: &mut [u8]) -> Result<()> {
378    getrandom::fill(buf).map_err(|e| {
379        mem_err(format!(
380            "at-rest entropy source failed ({e}) — refusing to generate weak key material"
381        ))
382    })
383}
384
385fn hex_encode(bytes: &[u8]) -> String {
386    const HEX: &[u8; 16] = b"0123456789abcdef";
387    let mut out = String::with_capacity(bytes.len() * 2);
388    for b in bytes {
389        out.push(HEX[(b >> 4) as usize] as char);
390        out.push(HEX[(b & 0x0f) as usize] as char);
391    }
392    out
393}
394
395fn hex_decode(hex: &str) -> Option<Vec<u8>> {
396    if hex.len() % 2 != 0 {
397        return None;
398    }
399    let mut out = Vec::with_capacity(hex.len() / 2);
400    for chunk in hex.as_bytes().chunks_exact(2) {
401        let hi = hex_val(chunk[0])?;
402        let lo = hex_val(chunk[1])?;
403        out.push((hi << 4) | lo);
404    }
405    Some(out)
406}
407
408const fn hex_val(b: u8) -> Option<u8> {
409    match b {
410        b'0'..=b'9' => Some(b - b'0'),
411        b'a'..=b'f' => Some(b - b'a' + 10),
412        b'A'..=b'F' => Some(b - b'A' + 10),
413        _ => None,
414    }
415}
416
417/// Row layout: `version byte || 24-byte nonce || XChaCha20-Poly1305 ct+tag`.
418fn wrap(key: &[u8; AT_REST_KEY_LEN], info: &str, plaintext: &[u8]) -> Result<Vec<u8>> {
419    let cipher = XChaCha20Poly1305::new(key.into());
420    let mut nonce = [0u8; NONCE_LEN];
421    fill_random(&mut nonce)?;
422    let ciphertext = cipher
423        .encrypt(
424            XNonce::from_slice(&nonce),
425            Payload {
426                msg: plaintext,
427                aad: info.as_bytes(),
428            },
429        )
430        .map_err(|_| mem_err("at-rest wrap failed (AEAD error)"))?;
431    let mut out = Vec::with_capacity(WRAP_HEADER_LEN + ciphertext.len());
432    out.push(WRAP_VERSION);
433    out.extend_from_slice(&nonce);
434    out.extend_from_slice(&ciphertext);
435    Ok(out)
436}
437
438/// Reverse of [`wrap`]; any bitflip in the version byte, nonce, ciphertext,
439/// tag, AAD, or key makes this fail closed.
440fn unwrap_secret(
441    key: &[u8; AT_REST_KEY_LEN],
442    info: &str,
443    blob: &[u8],
444) -> std::result::Result<Zeroizing<Vec<u8>>, String> {
445    if blob.len() < WRAP_MIN_LEN {
446        return Err(format!(
447            "wrapped value is {} bytes (minimum {WRAP_MIN_LEN})",
448            blob.len()
449        ));
450    }
451    if blob[0] != WRAP_VERSION {
452        return Err(format!(
453            "unsupported wrap version {} (expected {WRAP_VERSION})",
454            blob[0]
455        ));
456    }
457    let cipher = XChaCha20Poly1305::new(key.into());
458    let nonce = XNonce::from_slice(&blob[1..WRAP_HEADER_LEN]);
459    let ciphertext = &blob[WRAP_HEADER_LEN..];
460    cipher
461        .decrypt(
462            nonce,
463            Payload {
464                msg: ciphertext,
465                aad: info.as_bytes(),
466            },
467        )
468        .map(Zeroizing::new)
469        .map_err(|_| "ciphertext authentication failed".to_string())
470}
471
472/// Canonical 32-byte root key from `WM_AT_REST_ROOT_KEY`-style material
473/// (64 hex chars decode; 32 raw bytes pass through).
474fn root_key_from_material(material: &str) -> Result<Zeroizing<[u8; AT_REST_KEY_LEN]>> {
475    let bytes = Zeroizing::new(wm_core::kdf::root_bytes(material));
476    if bytes.len() != AT_REST_KEY_LEN {
477        return Err(mem_err(format!(
478            "at-rest root key material is {} bytes after canonicalization, expected \
479             {AT_REST_KEY_LEN} (use 64 hex chars or 32 raw bytes)",
480            bytes.len()
481        )));
482    }
483    let mut out = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
484    out.copy_from_slice(&bytes);
485    Ok(out)
486}
487
488fn argon2_params(salt: &[u8; ARGON2_SALT_LEN]) -> Argon2Params {
489    Argon2Params {
490        m_cost_kib: ARGON2_M_COST_KIB,
491        t_cost: ARGON2_T_COST,
492        p_cost: ARGON2_P_COST,
493        version: ARGON2_VERSION,
494        salt_hex: hex_encode(salt),
495    }
496}
497
498fn derive_rk_from_passphrase(
499    passphrase: &str,
500    params: &Argon2Params,
501) -> Result<Zeroizing<[u8; AT_REST_KEY_LEN]>> {
502    let salt = hex_decode(&params.salt_hex)
503        .ok_or_else(|| mem_err("at-rest keyring meta has a non-hex Argon2 salt"))?;
504    let params = Params::new(
505        params.m_cost_kib,
506        params.t_cost,
507        params.p_cost,
508        Some(AT_REST_KEY_LEN),
509    )
510    .map_err(|e| mem_err(format!("at-rest Argon2 parameters rejected: {e}")))?;
511    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
512    let mut out = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
513    argon
514        .hash_password_into(passphrase.as_bytes(), &salt, out.as_mut())
515        .map_err(|e| mem_err(format!("at-rest Argon2 derivation failed: {e}")))?;
516    Ok(out)
517}
518
519/// Open (read) an existing key file; when `allow_create` is set and the file
520/// is missing, generate 32 bytes with mode 0600. Never regenerates over an
521/// existing file, and never falls back to weak entropy.
522fn load_or_create_key_file(
523    path: &Path,
524    allow_create: bool,
525) -> Result<Zeroizing<[u8; AT_REST_KEY_LEN]>> {
526    if path.exists() {
527        let bytes = Zeroizing::new(std::fs::read(path).map_err(|e| {
528            mem_err(format!(
529                "cannot read at-rest key file {}: {e}",
530                path.display()
531            ))
532        })?);
533        if bytes.len() != AT_REST_KEY_LEN {
534            return Err(mem_err(format!(
535                "at-rest key file {} is {} bytes, expected {AT_REST_KEY_LEN}",
536                path.display(),
537                bytes.len()
538            )));
539        }
540        let mut out = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
541        out.copy_from_slice(&bytes);
542        return Ok(out);
543    }
544    if !allow_create {
545        return Err(mem_err(format!(
546            "at-rest key file is missing at {} — refusing to regenerate (keyring meta exists); \
547             restore the file or provide WM_AT_REST_ROOT_KEY",
548            path.display()
549        )));
550    }
551    let mut key = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
552    fill_random(key.as_mut())?;
553    write_key_file(path, &key)?;
554    Ok(key)
555}
556
557fn write_key_file(path: &Path, key: &[u8; AT_REST_KEY_LEN]) -> Result<()> {
558    if let Some(parent) = path.parent() {
559        std::fs::create_dir_all(parent).map_err(|e| {
560            mem_err(format!(
561                "cannot create key-file directory {}: {e}",
562                parent.display()
563            ))
564        })?;
565    }
566    #[cfg(unix)]
567    {
568        use std::io::Write as _;
569        use std::os::unix::fs::OpenOptionsExt as _;
570        let mut file = std::fs::OpenOptions::new()
571            .write(true)
572            .create_new(true)
573            .mode(0o600)
574            .open(path)
575            .map_err(|e| {
576                mem_err(format!(
577                    "cannot create at-rest key file {}: {e}",
578                    path.display()
579                ))
580            })?;
581        file.write_all(key).map_err(|e| {
582            mem_err(format!(
583                "cannot write at-rest key file {}: {e}",
584                path.display()
585            ))
586        })?;
587    }
588    #[cfg(not(unix))]
589    {
590        std::fs::write(path, key).map_err(|e| {
591            mem_err(format!(
592                "cannot write at-rest key file {}: {e}",
593                path.display()
594            ))
595        })?;
596    }
597    Ok(())
598}
599
600// ── Keyring read/write ─────────────────────────────────────────────────
601
602enum KeyringPresence {
603    /// No keyring DBI, or an empty one (crash between DBI creation and the
604    /// init transaction) — writable opens may (re)initialize.
605    Absent,
606    /// Meta parses.
607    Meta(Box<KeyringMeta>),
608    /// Meta exists but cannot be parsed — fail closed.
609    Unreadable(String),
610}
611
612/// Any rows at all in the keyring DBI? Distinguishes a truly-empty DBI
613/// (crash between `create_db` and the init transaction — safe to
614/// re-initialize) from orphaned key material (`dek:*`/`rk:check` rows with
615/// no `meta` row), which must fail closed rather than be overwritten.
616fn keyring_has_rows<T: Transaction>(tx: &T, db: Database) -> Result<bool> {
617    let mut cursor = tx
618        .open_ro_cursor(db)
619        .map_err(|e| mem_err(format!("LMDB cursor failed for the at-rest keyring: {e}")))?;
620    Ok(cursor.iter().next().is_some())
621}
622
623fn read_presence(env: &Environment) -> Result<KeyringPresence> {
624    let db = match env.open_db(Some(KEYRING_DB)) {
625        Ok(db) => db,
626        Err(lmdb::Error::NotFound) => return Ok(KeyringPresence::Absent),
627        Err(e) => {
628            return Err(mem_err(format!(
629                "LMDB open_db failed for the at-rest keyring: {e}"
630            )));
631        }
632    };
633    let tx = env
634        .begin_ro_txn()
635        .map_err(|e| mem_err(format!("LMDB ro_txn failed (at-rest keyring): {e}")))?;
636    let presence = match tx.get(db, &KEYRING_META_KEY) {
637        Ok(bytes) => match serde_json::from_slice::<KeyringMeta>(bytes) {
638            Ok(meta) => KeyringPresence::Meta(Box::new(meta)),
639            Err(e) => KeyringPresence::Unreadable(e.to_string()),
640        },
641        Err(lmdb::Error::NotFound) => {
642            if keyring_has_rows(&tx, db)? {
643                KeyringPresence::Unreadable(
644                    "the keyring DBI holds `dek:*`/`rk:check` rows but no `meta` row \
645                     (orphaned key material)"
646                        .to_string(),
647                )
648            } else {
649                KeyringPresence::Absent
650            }
651        }
652        Err(e) => {
653            return Err(mem_err(format!(
654                "LMDB read failed for the at-rest keyring meta: {e}"
655            )));
656        }
657    };
658    tx.commit()
659        .map_err(|e| mem_err(format!("LMDB commit failed (at-rest keyring): {e}")))?;
660    Ok(presence)
661}
662
663/// Optionally open the keyring DBI on read paths — never creates.
664pub(crate) fn open_keyring_optional(env: &Environment) -> Result<Option<Database>> {
665    match env.open_db(Some(KEYRING_DB)) {
666        Ok(db) => Ok(Some(db)),
667        Err(lmdb::Error::NotFound) => Ok(None),
668        Err(e) => Err(mem_err(format!(
669            "LMDB open_db failed for the optional at-rest keyring: {e}"
670        ))),
671    }
672}
673
674/// Read-only status from a keyring DBI handle (never resolves the RK).
675pub(crate) fn read_status(env: &Environment, db: Database, store_dir: &Path) -> AtRestStatus {
676    {
677        let tx = match env.begin_ro_txn() {
678            Ok(tx) => tx,
679            Err(e) => {
680                return AtRestStatus::Malformed {
681                    reason: format!("LMDB ro_txn failed: {e}"),
682                };
683            }
684        };
685        let meta = match tx.get(db, &KEYRING_META_KEY) {
686            Ok(bytes) => match serde_json::from_slice::<KeyringMeta>(bytes) {
687                Ok(meta) => meta,
688                Err(e) => {
689                    return AtRestStatus::Malformed {
690                        reason: format!("meta row does not parse as keyring JSON: {e}"),
691                    };
692                }
693            },
694            Err(lmdb::Error::NotFound) => {
695                return match keyring_has_rows(&tx, db) {
696                    Ok(true) => AtRestStatus::Malformed {
697                        reason: "the keyring DBI holds `dek:*`/`rk:check` rows but no `meta` \
698                                 row (orphaned key material)"
699                            .to_string(),
700                    },
701                    Ok(false) => AtRestStatus::Absent,
702                    Err(e) => AtRestStatus::Malformed {
703                        reason: e.to_string(),
704                    },
705                };
706            }
707            Err(e) => {
708                return AtRestStatus::Malformed {
709                    reason: format!("meta row unreadable: {e}"),
710                };
711            }
712        };
713
714        let mut wrapped_deks = 0usize;
715        let mut has_rk_check = false;
716        match tx.open_ro_cursor(db) {
717            Ok(mut cursor) => {
718                for (key, _) in cursor.iter() {
719                    if key.starts_with(DEK_KEY_PREFIX.as_bytes()) {
720                        wrapped_deks += 1;
721                    } else if key == RK_CHECK_KEY {
722                        has_rk_check = true;
723                    }
724                }
725            }
726            Err(e) => {
727                return AtRestStatus::Malformed {
728                    reason: format!("keyring cursor failed: {e}"),
729                };
730            }
731        }
732        let _ = tx.commit();
733
734        if meta.format_version != KEYRING_FORMAT_VERSION {
735            return AtRestStatus::Malformed {
736                reason: format!(
737                    "unsupported keyring format_version {} (this build reads {KEYRING_FORMAT_VERSION})",
738                    meta.format_version
739                ),
740            };
741        }
742        if meta.mode == AtRestMode::Passphrase && meta.argon2.is_none() {
743            return AtRestStatus::Malformed {
744                reason: "mode is 'passphrase' but the Argon2 parameters are missing".to_string(),
745            };
746        }
747        if !has_rk_check {
748            return AtRestStatus::Malformed {
749                reason: "the rk:check wrong-key discriminator row is missing".to_string(),
750            };
751        }
752
753        let key_file = disclosed_key_file(&meta, store_dir);
754        AtRestStatus::Present(AtRestStatusPresent {
755            meta,
756            wrapped_deks,
757            galaxies: Galaxy::all().len(),
758            key_file,
759        })
760    }
761}
762
763// ── Open-time integration ──────────────────────────────────────────────
764
765/// Resolve the RK for a first initialization, generating mode-C salt and the
766/// per-mode key source.
767struct NewRootKey {
768    rk: Zeroizing<[u8; AT_REST_KEY_LEN]>,
769    key_source: &'static str,
770    argon2: Option<Argon2Params>,
771}
772
773/// Key-file path disclosed for a keyring rooted in a file. The generated
774/// store-local path is derivable from the store alone; an explicitly
775/// configured path was an open-time input never recorded in the meta, so it
776/// is disclosed only when this environment still has `WM_AT_REST_KEY_FILE`.
777fn disclosed_key_file(meta: &KeyringMeta, store_dir: &Path) -> Option<PathBuf> {
778    match meta.key_source.as_str() {
779        "generated_key_file" => Some(generated_key_path(store_dir)),
780        "key_file" => std::env::var("WM_AT_REST_KEY_FILE")
781            .ok()
782            .filter(|value| !value.is_empty())
783            .map(PathBuf::from),
784        _ => None,
785    }
786}
787
788fn resolve_new_root_key(store_dir: &Path, config: &AtRestConfig) -> Result<NewRootKey> {
789    match config.mode {
790        AtRestMode::Passphrase => {
791            let passphrase = config.passphrase.as_deref().ok_or_else(|| {
792                mem_err(
793                    "WM_AT_REST_MODE=passphrase requires WM_AT_REST_PASSPHRASE to initialize \
794                     the store",
795                )
796            })?;
797            let mut salt = [0u8; ARGON2_SALT_LEN];
798            fill_random(&mut salt)?;
799            let params = argon2_params(&salt);
800            let rk = derive_rk_from_passphrase(passphrase, &params)?;
801            Ok(NewRootKey {
802                rk,
803                key_source: "argon2id_passphrase",
804                argon2: Some(params),
805            })
806        }
807        AtRestMode::Keyfile => {
808            if let Some(material) = config.root_key.as_deref() {
809                return Ok(NewRootKey {
810                    rk: root_key_from_material(material)?,
811                    key_source: "env_root_key",
812                    argon2: None,
813                });
814            }
815            if let Some(path) = &config.key_file {
816                let rk = load_or_create_key_file(path, true)?;
817                return Ok(NewRootKey {
818                    rk,
819                    key_source: "key_file",
820                    argon2: None,
821                });
822            }
823            let path = generated_key_path(store_dir);
824            let rk = load_or_create_key_file(&path, true)?;
825            Ok(NewRootKey {
826                rk,
827                key_source: "generated_key_file",
828                argon2: None,
829            })
830        }
831        AtRestMode::Off => Err(mem_err(
832            "at-rest keyring initialization requested with mode 'off'",
833        )),
834    }
835}
836
837fn resolve_existing_root_key(
838    store_dir: &Path,
839    config: &AtRestConfig,
840    meta: &KeyringMeta,
841) -> Result<Zeroizing<[u8; AT_REST_KEY_LEN]>> {
842    match config.mode {
843        AtRestMode::Passphrase => {
844            let passphrase = config.passphrase.as_deref().ok_or_else(|| {
845                mem_err("at-rest unlock failed: this store is mode C — set WM_AT_REST_PASSPHRASE")
846            })?;
847            let params = meta.argon2.as_ref().ok_or_else(|| {
848                mem_err("at-rest keyring meta is mode C but has no Argon2 parameters")
849            })?;
850            derive_rk_from_passphrase(passphrase, params)
851        }
852        AtRestMode::Keyfile => {
853            if let Some(material) = config.root_key.as_deref() {
854                return root_key_from_material(material);
855            }
856            if let Some(path) = &config.key_file {
857                return load_or_create_key_file(path, false);
858            }
859            match meta.key_source.as_str() {
860                "generated_key_file" => {
861                    load_or_create_key_file(&generated_key_path(store_dir), false)
862                }
863                "env_root_key" => Err(mem_err(
864                    "at-rest unlock failed: this store's keyring was initialized from \
865                     WM_AT_REST_ROOT_KEY — set it (this is not a wrong-key error, the source \
866                     was simply not provided)",
867                )),
868                "key_file" => Err(mem_err(
869                    "at-rest unlock failed: this store's keyring was initialized from a \
870                     configured key file — set WM_AT_REST_KEY_FILE",
871                )),
872                other => Err(mem_err(format!(
873                    "at-rest unlock failed: unknown key source '{other}' — provide \
874                     WM_AT_REST_ROOT_KEY or WM_AT_REST_KEY_FILE"
875                ))),
876            }
877        }
878        AtRestMode::Off => Err(mem_err("at-rest unlock requested with mode 'off'")),
879    }
880}
881
882fn dek_key(galaxy_db_name: &str) -> String {
883    format!("{DEK_KEY_PREFIX}{galaxy_db_name}")
884}
885
886/// Initialize a fresh keyring: `meta`, `rk:check`, and all 16 wrapped DEKs in
887/// one write transaction. Re-checks for a concurrent first-init inside the
888/// transaction (last-writer-wins keyrings are a split-brain; the loser
889/// unlocks instead of overwriting).
890fn initialize(
891    env: &Environment,
892    store_dir: &Path,
893    config: &AtRestConfig,
894) -> Result<(Database, AtRestState)> {
895    let db = env
896        .create_db(Some(KEYRING_DB), DatabaseFlags::default())
897        .map_err(|e| mem_err(format!("LMDB create_db failed for keyring: {e}")))?;
898    let mut tx = env
899        .begin_rw_txn()
900        .map_err(|e| mem_err(format!("LMDB rw_txn failed (at-rest init): {e}")))?;
901
902    // Lost a concurrent first-init race: the winner's keyring is authoritative.
903    match tx.get(db, &KEYRING_META_KEY) {
904        Ok(existing) => {
905            let meta: KeyringMeta = serde_json::from_slice(existing).map_err(|e| {
906                mem_err(format!(
907                    "at-rest keyring appeared during initialization but its meta does not \
908                     parse: {e}"
909                ))
910            })?;
911            tx.abort();
912            if meta.mode != config.mode {
913                return Err(mem_err(format!(
914                    "at-rest mode mismatch: store was initialized as '{}' but this open \
915                     requested '{}'",
916                    meta.mode, config.mode
917                )));
918            }
919            let state = unlock(env, db, store_dir, config, meta)?;
920            return Ok((db, state));
921        }
922        Err(lmdb::Error::NotFound) => {
923            if keyring_has_rows(&tx, db)? {
924                tx.abort();
925                return Err(mem_err(
926                    "at-rest keyring DBI contains rows but no `meta` row — refusing to \
927                     initialize over orphaned key material (fail-closed); restore the meta \
928                     row or deliberately remove the keyring DBI",
929                ));
930            }
931        }
932        Err(e) => return Err(mem_err(format!("LMDB read failed (at-rest init): {e}"))),
933    }
934
935    let resolved = resolve_new_root_key(store_dir, config)?;
936    let meta = KeyringMeta {
937        format_version: KEYRING_FORMAT_VERSION,
938        mode: config.mode,
939        key_source: resolved.key_source.to_string(),
940        created_at: chrono::Utc::now().to_rfc3339(),
941        argon2: resolved.argon2.clone(),
942    };
943    let meta_json = serde_json::to_vec(&meta)
944        .map_err(|e| mem_err(format!("at-rest meta serialization failed: {e}")))?;
945    let check = wrap(&resolved.rk, RK_CHECK_INFO, RK_CHECK_PLAINTEXT)?;
946
947    let mut deks: HashMap<String, Zeroizing<[u8; AT_REST_KEY_LEN]>> = HashMap::new();
948    for galaxy in Galaxy::all() {
949        let name = galaxy.db_name();
950        let mut dek = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
951        fill_random(dek.as_mut())?;
952        let info = wm_core::kdf::galaxy_dek_info(name);
953        let kek = Zeroizing::new(wm_core::kdf::hkdf32(&resolved.rk[..], &info));
954        let wrapped = wrap(&kek, &info, &dek[..])?;
955        tx.put(db, &dek_key(name), &wrapped, WriteFlags::default())
956            .map_err(|e| mem_err(format!("LMDB put failed (at-rest DEK {name}): {e}")))?;
957        deks.insert(name.to_string(), dek);
958    }
959    tx.put(db, &KEYRING_META_KEY, &meta_json, WriteFlags::default())
960        .map_err(|e| mem_err(format!("LMDB put failed (at-rest meta): {e}")))?;
961    tx.put(db, &RK_CHECK_KEY, &check, WriteFlags::default())
962        .map_err(|e| mem_err(format!("LMDB put failed (at-rest rk:check): {e}")))?;
963    tx.commit()
964        .map_err(|e| mem_err(format!("LMDB commit failed (at-rest init): {e}")))?;
965
966    let key_file = disclosed_key_file(&meta, store_dir);
967    Ok((
968        db,
969        AtRestState {
970            meta,
971            key_file,
972            deks,
973        },
974    ))
975}
976
977/// Unlock an existing keyring: verify `rk:check`, then unwrap every galaxy DEK.
978fn unlock(
979    env: &Environment,
980    db: Database,
981    store_dir: &Path,
982    config: &AtRestConfig,
983    meta: KeyringMeta,
984) -> Result<AtRestState> {
985    let rk = resolve_existing_root_key(store_dir, config, &meta)?;
986    let tx = env
987        .begin_ro_txn()
988        .map_err(|e| mem_err(format!("LMDB ro_txn failed (at-rest unlock): {e}")))?;
989
990    let check_blob = tx.get(db, &RK_CHECK_KEY).map_err(|e| {
991        mem_err(format!(
992            "at-rest keyring is missing its rk:check row ({e}) — refusing to unlock (fail-closed)"
993        ))
994    })?;
995    let check = unwrap_secret(&rk, RK_CHECK_INFO, check_blob).map_err(|reason| {
996        mem_err(format!(
997            "at-rest unlock failed: the provided root key/passphrase does not match this \
998             store's keyring ({reason})"
999        ))
1000    })?;
1001    if check.as_slice() != RK_CHECK_PLAINTEXT {
1002        return Err(mem_err(
1003            "at-rest unlock failed: rk:check plaintext mismatch",
1004        ));
1005    }
1006
1007    let mut deks: HashMap<String, Zeroizing<[u8; AT_REST_KEY_LEN]>> = HashMap::new();
1008    for galaxy in Galaxy::all() {
1009        let name = galaxy.db_name();
1010        let blob = tx.get(db, &dek_key(name)).map_err(|e| {
1011            mem_err(format!(
1012                "at-rest keyring is missing the wrapped DEK row for galaxy '{name}' ({e}) — \
1013                 refusing to unlock (fail-closed)"
1014            ))
1015        })?;
1016        let info = wm_core::kdf::galaxy_dek_info(name);
1017        let kek = Zeroizing::new(wm_core::kdf::hkdf32(&rk[..], &info));
1018        let dek_bytes = unwrap_secret(&kek, &info, blob).map_err(|reason| {
1019            mem_err(format!(
1020                "at-rest keyring corrupt: wrapped DEK '{name}' failed authentication \
1021                 ({reason})"
1022            ))
1023        })?;
1024        let mut dek = Zeroizing::new([0u8; AT_REST_KEY_LEN]);
1025        dek.copy_from_slice(&dek_bytes);
1026        deks.insert(name.to_string(), dek);
1027    }
1028    tx.commit()
1029        .map_err(|e| mem_err(format!("LMDB commit failed (at-rest unlock): {e}")))?;
1030
1031    let key_file = disclosed_key_file(&meta, store_dir);
1032    Ok(AtRestState {
1033        meta,
1034        key_file,
1035        deks,
1036    })
1037}
1038
1039/// Open-time at-rest integration for a writable store.
1040///
1041/// Returns the keyring DBI handle (when a keyring exists) and the unlocked
1042/// state. `off` on a keyring store refuses (split-brain guard); a mode
1043/// mismatch refuses with an actionable message.
1044pub(crate) fn open_at_rest(
1045    env: &Environment,
1046    store_dir: &Path,
1047    config: &AtRestConfig,
1048) -> Result<(Option<Database>, Option<AtRestState>)> {
1049    let presence = read_presence(env)?;
1050    match (config.mode, presence) {
1051        (AtRestMode::Off, KeyringPresence::Absent) => Ok((None, None)),
1052        (AtRestMode::Off, KeyringPresence::Meta(meta)) => Err(mem_err(format!(
1053            "at-rest keyring present (mode '{}') but this writable open requested mode 'off' \
1054             — refusing a plaintext open of an at-rest store (split-brain guard); set \
1055             WM_AT_REST_MODE={} with the matching key source, or use a read-only inspection open",
1056            meta.mode, meta.mode
1057        ))),
1058        (AtRestMode::Off, KeyringPresence::Unreadable(reason)) => Err(mem_err(format!(
1059            "at-rest keyring present but its state is unreadable/malformed ({reason}) — \
1060             refusing writable mode 'off' (fail-closed); inspect with a read-only path"
1061        ))),
1062        (mode, KeyringPresence::Meta(meta)) => {
1063            if meta.mode != mode {
1064                return Err(mem_err(format!(
1065                    "at-rest mode mismatch: store keyring is mode '{}' but WM_AT_REST_MODE is \
1066                     '{}' — set WM_AT_REST_MODE={} and unlock with the matching key source",
1067                    meta.mode, mode, meta.mode
1068                )));
1069            }
1070            let db = env
1071                .open_db(Some(KEYRING_DB))
1072                .map_err(|e| mem_err(format!("LMDB open_db failed for keyring: {e}")))?;
1073            let state = unlock(env, db, store_dir, config, *meta)?;
1074            Ok((Some(db), Some(state)))
1075        }
1076        (_mode, KeyringPresence::Absent) => {
1077            let (db, state) = initialize(env, store_dir, config)?;
1078            Ok((Some(db), Some(state)))
1079        }
1080        (_mode, KeyringPresence::Unreadable(reason)) => Err(mem_err(format!(
1081            "at-rest keyring present but its state is unreadable/malformed ({reason}) — \
1082             refusing to initialize over it (fail-closed); restore the keyring or the key material"
1083        ))),
1084    }
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use crate::memory::Memory;
1091    use crate::store::MemoryStore;
1092    use std::collections::HashSet;
1093
1094    const TEST_MAP: usize = 16 * 1024 * 1024;
1095
1096    fn open_keyfile(store_dir: &Path) -> MemoryStore {
1097        MemoryStore::open_with_at_rest(store_dir, TEST_MAP, &AtRestConfig::keyfile()).unwrap()
1098    }
1099
1100    fn collect_deks(store: &MemoryStore) -> Vec<(String, [u8; AT_REST_KEY_LEN])> {
1101        let state = store.at_rest_state().expect("at-rest state");
1102        Galaxy::all()
1103            .into_iter()
1104            .map(|galaxy| {
1105                let name = galaxy.db_name().to_string();
1106                let dek = *state
1107                    .galaxy_dek(&name)
1108                    .unwrap_or_else(|| panic!("missing DEK for {name}"));
1109                (name, dek)
1110            })
1111            .collect()
1112    }
1113
1114    /// Raw keyring rows, read through an isolated read-only env (never the
1115    /// store under test), for byte-level "did anything rewrite?" checks.
1116    fn raw_keyring_rows(store_dir: &Path) -> Vec<(Vec<u8>, Vec<u8>)> {
1117        let env = Environment::new()
1118            .set_max_dbs(64)
1119            .set_flags(lmdb::EnvironmentFlags::READ_ONLY)
1120            .open(store_dir)
1121            .unwrap();
1122        let db = env.open_db(Some(KEYRING_DB)).unwrap();
1123        let tx = env.begin_ro_txn().unwrap();
1124        let mut cursor = tx.open_ro_cursor(db).unwrap();
1125        let rows: Vec<(Vec<u8>, Vec<u8>)> = cursor
1126            .iter()
1127            .map(|(key, value)| (key.to_vec(), value.to_vec()))
1128            .collect();
1129        drop(cursor);
1130        let _ = tx.commit();
1131        rows
1132    }
1133
1134    /// Write raw keyring rows through an isolated env, simulating crash/
1135    /// tamper states the open paths must classify.
1136    fn write_keyring_rows(store_dir: &Path, rows: &[(&[u8], Vec<u8>)]) {
1137        let env = Environment::new().set_max_dbs(64).open(store_dir).unwrap();
1138        let db = env
1139            .create_db(Some(KEYRING_DB), DatabaseFlags::default())
1140            .unwrap();
1141        let mut tx = env.begin_rw_txn().unwrap();
1142        for (key, value) in rows {
1143            tx.put(db, key, value, WriteFlags::default()).unwrap();
1144        }
1145        tx.commit().unwrap();
1146    }
1147
1148    #[test]
1149    fn generated_key_path_follows_the_layout_rule() {
1150        assert_eq!(
1151            generated_key_path(Path::new("/srv/store/lmdb")),
1152            PathBuf::from("/srv/store/.at_rest_key"),
1153            "standard <store-root>/lmdb layout puts the key at the store root"
1154        );
1155        assert_eq!(
1156            generated_key_path(Path::new("/srv/custom")),
1157            PathBuf::from("/srv/custom/.at_rest_key"),
1158            "non-standard layouts keep the key inside the open path"
1159        );
1160        assert_eq!(
1161            generated_key_path(Path::new("lmdb")),
1162            PathBuf::from("lmdb/.at_rest_key"),
1163            "a bare relative 'lmdb' has no usable parent — keep it local"
1164        );
1165    }
1166
1167    #[test]
1168    fn standard_lmdb_layout_puts_the_generated_key_at_the_store_root() {
1169        let tmp = tempfile::tempdir().unwrap();
1170        let store_root = tmp.path().join("store");
1171        let lmdb = store_root.join("lmdb");
1172        let expected_key = store_root.join(AT_REST_KEY_FILE);
1173
1174        let store = open_keyfile(&lmdb);
1175        assert!(
1176            expected_key.is_file(),
1177            "key must be at the store root: {}",
1178            expected_key.display()
1179        );
1180        assert!(
1181            !lmdb.join(AT_REST_KEY_FILE).exists(),
1182            "key must not live inside the LMDB directory"
1183        );
1184
1185        match store.at_rest_status() {
1186            AtRestStatus::Present(p) => assert_eq!(p.key_file, Some(expected_key.clone())),
1187            other => panic!("expected Present, got {other:?}"),
1188        }
1189        drop(store);
1190
1191        // Read paths resolve the same root path from the meta alone.
1192        match MemoryStore::open_inspection(&lmdb)
1193            .unwrap()
1194            .at_rest_status()
1195        {
1196            AtRestStatus::Present(p) => assert_eq!(p.key_file, Some(expected_key)),
1197            other => panic!("expected Present, got {other:?}"),
1198        }
1199    }
1200
1201    #[test]
1202    fn nonstandard_layout_keeps_the_generated_key_inside_the_store_dir() {
1203        let tmp = tempfile::tempdir().unwrap();
1204        let store_dir = tmp.path().join("memory-store");
1205        drop(open_keyfile(&store_dir));
1206        assert!(store_dir.join(AT_REST_KEY_FILE).is_file());
1207        assert!(
1208            !tmp.path().join(AT_REST_KEY_FILE).exists(),
1209            "non-standard layouts must not write to the parent"
1210        );
1211    }
1212
1213    #[test]
1214    fn keyfile_init_wraps_all_galaxies_and_writes_a_0600_key_file() {
1215        let tmp = tempfile::tempdir().unwrap();
1216        let store = open_keyfile(tmp.path());
1217
1218        let state = store.at_rest_state().expect("keyfile open unlocks");
1219        assert_eq!(state.dek_count(), Galaxy::all().len());
1220        let meta = state.meta();
1221        assert_eq!(meta.format_version, KEYRING_FORMAT_VERSION);
1222        assert_eq!(meta.mode, AtRestMode::Keyfile);
1223        assert_eq!(meta.key_source, "generated_key_file");
1224        assert!(meta.argon2.is_none());
1225
1226        let key_path = tmp.path().join(AT_REST_KEY_FILE);
1227        let bytes = std::fs::read(&key_path).unwrap();
1228        assert_eq!(bytes.len(), AT_REST_KEY_LEN);
1229        #[cfg(unix)]
1230        {
1231            use std::os::unix::fs::PermissionsExt as _;
1232            let mode = std::fs::metadata(&key_path).unwrap().permissions().mode();
1233            assert_eq!(mode & 0o777, 0o600, "key file must be owner-only");
1234        }
1235
1236        let rows = raw_keyring_rows(tmp.path());
1237        let dek_rows = rows
1238            .iter()
1239            .filter(|(key, _)| key.starts_with(DEK_KEY_PREFIX.as_bytes()))
1240            .count();
1241        assert_eq!(dek_rows, Galaxy::all().len(), "one wrapped DEK per galaxy");
1242        assert!(rows.iter().any(|(key, _)| key == KEYRING_META_KEY));
1243        assert!(rows.iter().any(|(key, _)| key == RK_CHECK_KEY));
1244
1245        // Every wrapped row has the documented layout: version, nonce, ct+tag.
1246        for (key, value) in &rows {
1247            if key.starts_with(DEK_KEY_PREFIX.as_bytes()) {
1248                assert_eq!(value[0], WRAP_VERSION);
1249                assert_eq!(value.len(), WRAP_HEADER_LEN + AT_REST_KEY_LEN + TAG_LEN);
1250            }
1251        }
1252    }
1253
1254    #[test]
1255    fn reopen_unwraps_identical_deks_and_deks_differ_per_galaxy() {
1256        let tmp = tempfile::tempdir().unwrap();
1257        let first = collect_deks(&open_keyfile(tmp.path()));
1258        let second = collect_deks(&open_keyfile(tmp.path()));
1259        assert_eq!(first, second, "same key file must unwrap identical DEKs");
1260
1261        let unique: HashSet<[u8; AT_REST_KEY_LEN]> = first.iter().map(|(_, dek)| *dek).collect();
1262        assert_eq!(
1263            unique.len(),
1264            Galaxy::all().len(),
1265            "every galaxy must get its own DEK"
1266        );
1267    }
1268
1269    #[test]
1270    fn wrong_root_key_refuses_unlock_and_never_reinitializes() {
1271        let tmp = tempfile::tempdir().unwrap();
1272        let hex_a = "aa".repeat(AT_REST_KEY_LEN);
1273        let hex_b = "bb".repeat(AT_REST_KEY_LEN);
1274        drop(
1275            MemoryStore::open_with_at_rest(
1276                tmp.path(),
1277                TEST_MAP,
1278                &AtRestConfig::keyfile_with_root_key(hex_a.clone()),
1279            )
1280            .unwrap(),
1281        );
1282
1283        let before = raw_keyring_rows(tmp.path());
1284        let error = match MemoryStore::open_with_at_rest(
1285            tmp.path(),
1286            TEST_MAP,
1287            &AtRestConfig::keyfile_with_root_key(hex_b),
1288        ) {
1289            Ok(_) => panic!("wrong root key must refuse"),
1290            Err(error) => error,
1291        };
1292        assert!(error.to_string().contains("unlock failed"), "{error}");
1293        assert_eq!(
1294            raw_keyring_rows(tmp.path()),
1295            before,
1296            "a failed unlock must never rewrite the keyring"
1297        );
1298
1299        // The original key still opens the store.
1300        let reopened = MemoryStore::open_with_at_rest(
1301            tmp.path(),
1302            TEST_MAP,
1303            &AtRestConfig::keyfile_with_root_key(hex_a),
1304        )
1305        .unwrap();
1306        assert_eq!(reopened.at_rest_state().unwrap().dek_count(), 16);
1307    }
1308
1309    #[test]
1310    fn off_writable_open_of_a_keyring_store_is_refused_but_inspection_reports() {
1311        let tmp = tempfile::tempdir().unwrap();
1312        drop(open_keyfile(tmp.path()));
1313
1314        let error = match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off())
1315        {
1316            Ok(_) => panic!("plaintext writable open of an at-rest store must fail closed"),
1317            Err(error) => error,
1318        };
1319        let message = error.to_string();
1320        assert!(message.contains("keyring"), "{message}");
1321        assert!(message.contains("off"), "{message}");
1322
1323        for status in [
1324            MemoryStore::open_inspection(tmp.path())
1325                .unwrap()
1326                .at_rest_status(),
1327            MemoryStore::open_readonly(tmp.path())
1328                .unwrap()
1329                .at_rest_status(),
1330        ] {
1331            match status {
1332                AtRestStatus::Present(p) => {
1333                    assert_eq!(p.meta.mode, AtRestMode::Keyfile);
1334                    assert_eq!(p.wrapped_deks, 16);
1335                    assert_eq!(p.galaxies, 16);
1336                    assert!(
1337                        p.key_file
1338                            .as_deref()
1339                            .is_some_and(|path| path.ends_with(AT_REST_KEY_FILE))
1340                    );
1341                }
1342                other => panic!("expected Present, got {other:?}"),
1343            }
1344        }
1345    }
1346
1347    #[test]
1348    fn off_creates_no_keyring_dbi_files_or_state() {
1349        let tmp = tempfile::tempdir().unwrap();
1350        let store =
1351            MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap();
1352        assert_eq!(store.at_rest_status(), AtRestStatus::Absent);
1353        assert!(store.at_rest_state().is_none());
1354        assert!(
1355            store.env().open_db(Some(KEYRING_DB)).is_err(),
1356            "off must not create the keyring DBI"
1357        );
1358        assert!(!tmp.path().join(AT_REST_KEY_FILE).exists());
1359        assert!(!tmp.path().join("keyring").exists());
1360    }
1361
1362    #[test]
1363    fn legacy_store_stays_plaintext_and_ensure_schema_leaves_keyring_optional() {
1364        let tmp = tempfile::tempdir().unwrap();
1365        {
1366            let store =
1367                MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap();
1368            let memory = Memory::new(Galaxy::Codex, "legacy plaintext record".to_string());
1369            store.put(Galaxy::Codex, &memory).unwrap();
1370        }
1371        let before = std::fs::read(tmp.path().join("data.mdb")).unwrap();
1372
1373        assert!(MemoryStore::ensure_schema(tmp.path()).unwrap().is_empty());
1374        let store =
1375            MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap();
1376        assert_eq!(store.at_rest_status(), AtRestStatus::Absent);
1377        assert_eq!(
1378            std::fs::read(tmp.path().join("data.mdb")).unwrap(),
1379            before,
1380            "plaintext opens must leave a legacy store byte-identical"
1381        );
1382        let memory = store
1383            .scan(Galaxy::Codex, 10)
1384            .unwrap()
1385            .into_iter()
1386            .find(|m| m.content == "legacy plaintext record")
1387            .expect("legacy record readable");
1388        assert_eq!(memory.metadata.galaxy, Galaxy::Codex);
1389    }
1390
1391    #[test]
1392    fn keyring_store_ensure_schema_is_a_no_op() {
1393        let tmp = tempfile::tempdir().unwrap();
1394        drop(open_keyfile(tmp.path()));
1395        let before = raw_keyring_rows(tmp.path());
1396        assert!(MemoryStore::ensure_schema(tmp.path()).unwrap().is_empty());
1397        assert_eq!(raw_keyring_rows(tmp.path()), before);
1398        // And it still unlocks afterwards.
1399        assert_eq!(collect_deks(&open_keyfile(tmp.path())).len(), 16);
1400    }
1401
1402    #[test]
1403    fn galaxy_bound_aad_swap_and_key_swap_both_fail() {
1404        let key_a = [0x11u8; AT_REST_KEY_LEN];
1405        let key_b = [0x22u8; AT_REST_KEY_LEN];
1406        let info_a = wm_core::kdf::galaxy_dek_info("codex");
1407        let info_b = wm_core::kdf::galaxy_dek_info("sessions");
1408        let dek = [0x33u8; AT_REST_KEY_LEN];
1409
1410        let kek_a = wm_core::kdf::hkdf32(&key_a, &info_a);
1411        let kek_b = wm_core::kdf::hkdf32(&key_b, &info_b);
1412        let wrapped = wrap(&kek_a, &info_a, &dek).unwrap();
1413
1414        assert!(unwrap_secret(&kek_a, &info_a, &wrapped).is_ok());
1415        assert!(
1416            unwrap_secret(&kek_a, &info_b, &wrapped).is_err(),
1417            "info-string AAD swap must fail"
1418        );
1419        assert!(
1420            unwrap_secret(&kek_b, &info_a, &wrapped).is_err(),
1421            "foreign KEK must fail"
1422        );
1423        assert_ne!(kek_a, kek_b, "per-galaxy KEKs must not collide");
1424    }
1425
1426    #[test]
1427    fn passphrase_mode_stores_argon2_params_and_rejects_wrong_passphrase() {
1428        let tmp = tempfile::tempdir().unwrap();
1429        {
1430            let store = MemoryStore::open_with_at_rest(
1431                tmp.path(),
1432                TEST_MAP,
1433                &AtRestConfig::passphrase("correct horse battery staple"),
1434            )
1435            .unwrap();
1436            let meta = store.at_rest_state().unwrap().meta().clone();
1437            assert_eq!(meta.mode, AtRestMode::Passphrase);
1438            assert_eq!(meta.key_source, "argon2id_passphrase");
1439            let argon = meta.argon2.expect("argon2 params stored");
1440            assert_eq!(argon.m_cost_kib, ARGON2_M_COST_KIB);
1441            assert_eq!(argon.t_cost, ARGON2_T_COST);
1442            assert_eq!(argon.p_cost, ARGON2_P_COST);
1443            assert_eq!(argon.version, ARGON2_VERSION);
1444            assert_eq!(argon.salt_hex.len(), ARGON2_SALT_LEN * 2);
1445            assert!(hex_decode(&argon.salt_hex).is_some());
1446        }
1447
1448        let error = match MemoryStore::open_with_at_rest(
1449            tmp.path(),
1450            TEST_MAP,
1451            &AtRestConfig::passphrase("wrong passphrase"),
1452        ) {
1453            Ok(_) => panic!("wrong passphrase must refuse"),
1454            Err(error) => error,
1455        };
1456        assert!(error.to_string().contains("unlock failed"), "{error}");
1457
1458        drop(
1459            MemoryStore::open_with_at_rest(
1460                tmp.path(),
1461                TEST_MAP,
1462                &AtRestConfig::passphrase("correct horse battery staple"),
1463            )
1464            .unwrap(),
1465        );
1466
1467        let mismatch =
1468            match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::keyfile()) {
1469                Ok(_) => panic!("mode mismatch must refuse"),
1470                Err(error) => error,
1471            };
1472        assert!(mismatch.to_string().contains("mode mismatch"), "{mismatch}");
1473    }
1474
1475    #[test]
1476    fn first_init_recovers_from_an_empty_keyring_dbi() {
1477        // Crash simulation: the keyring DBI exists (create_db committed) but
1478        // the meta/DEK transaction never landed.
1479        let tmp = tempfile::tempdir().unwrap();
1480        drop(MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap());
1481        {
1482            let env = Environment::new().set_max_dbs(64).open(tmp.path()).unwrap();
1483            env.create_db(Some(KEYRING_DB), DatabaseFlags::default())
1484                .unwrap();
1485        }
1486
1487        let store = open_keyfile(tmp.path());
1488        let state = store.at_rest_state().unwrap();
1489        assert_eq!(state.dek_count(), 16);
1490        assert_eq!(state.meta().key_source, "generated_key_file");
1491        assert!(
1492            raw_keyring_rows(tmp.path())
1493                .iter()
1494                .any(|(key, _)| key == KEYRING_META_KEY)
1495        );
1496    }
1497
1498    #[test]
1499    fn any_ciphertext_bitflip_fails_unwrap() {
1500        let key = [0x5au8; AT_REST_KEY_LEN];
1501        let info = wm_core::kdf::galaxy_dek_info("codex");
1502        let dek = [0xa5u8; AT_REST_KEY_LEN];
1503        let blob = wrap(&key, &info, &dek).unwrap();
1504        assert!(unwrap_secret(&key, &info, &blob).is_ok());
1505
1506        for byte in 0..blob.len() {
1507            for bit in 0..8u8 {
1508                let mut tampered = blob.clone();
1509                tampered[byte] ^= 1 << bit;
1510                assert!(
1511                    unwrap_secret(&key, &info, &tampered).is_err(),
1512                    "bit {bit} of byte {byte} must fail authentication"
1513                );
1514            }
1515        }
1516        assert!(unwrap_secret(&key, &info, &blob[..WRAP_MIN_LEN - 1]).is_err());
1517    }
1518
1519    #[test]
1520    fn store_roundtrip_under_keyfile() {
1521        let tmp = tempfile::tempdir().unwrap();
1522        let memory = Memory::new(Galaxy::Sessions, "keyfile roundtrip record".to_string());
1523        let id = memory.metadata.id;
1524        {
1525            let store = open_keyfile(tmp.path());
1526            store.put(Galaxy::Sessions, &memory).unwrap();
1527        }
1528        let store = open_keyfile(tmp.path());
1529        let loaded = store.get(Galaxy::Sessions, id).unwrap().unwrap();
1530        assert_eq!(loaded.content, "keyfile roundtrip record");
1531        let deks = collect_deks(&store);
1532        assert_eq!(deks.len(), 16);
1533    }
1534
1535    #[test]
1536    fn writable_off_open_of_empty_keyring_dbi_still_succeeds() {
1537        // An empty keyring DBI without meta is not at-rest state: no DEKs, no
1538        // rk:check, records plaintext — plaintext opens stay allowed.
1539        let tmp = tempfile::tempdir().unwrap();
1540        drop(MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap());
1541        {
1542            let env = Environment::new().set_max_dbs(64).open(tmp.path()).unwrap();
1543            env.create_db(Some(KEYRING_DB), DatabaseFlags::default())
1544                .unwrap();
1545        }
1546        let store =
1547            MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap();
1548        assert_eq!(store.at_rest_status(), AtRestStatus::Absent);
1549    }
1550
1551    #[test]
1552    fn orphan_keyring_rows_without_meta_refuse_init_and_report_malformed() {
1553        // Crash/tamper simulation: DEK and rk:check rows exist but the meta
1554        // row does not. This must fail closed — a keyfile init must never
1555        // overwrite the orphaned wraps.
1556        let tmp = tempfile::tempdir().unwrap();
1557        drop(MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap());
1558        write_keyring_rows(
1559            tmp.path(),
1560            &[
1561                (b"dek:codex", b"orphaned wrapped DEK".to_vec()),
1562                (RK_CHECK_KEY, b"orphaned rk:check".to_vec()),
1563            ],
1564        );
1565        let before = raw_keyring_rows(tmp.path());
1566
1567        match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::keyfile()) {
1568            Ok(_) => panic!("orphan rows must refuse keyfile initialization"),
1569            Err(error) => {
1570                let message = error.to_string();
1571                assert!(message.contains("meta"), "{message}");
1572                assert!(
1573                    message.contains("orphan") && message.contains("fail-closed"),
1574                    "{message}"
1575                );
1576            }
1577        }
1578        match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()) {
1579            Ok(_) => panic!("orphan rows must refuse a plaintext writable open"),
1580            Err(error) => assert!(error.to_string().contains("fail-closed"), "{error}"),
1581        }
1582
1583        for status in [
1584            MemoryStore::open_inspection(tmp.path())
1585                .unwrap()
1586                .at_rest_status(),
1587            MemoryStore::open_readonly(tmp.path())
1588                .unwrap()
1589                .at_rest_status(),
1590        ] {
1591            match status {
1592                AtRestStatus::Malformed { reason } => {
1593                    assert!(reason.contains("meta"), "{reason}");
1594                }
1595                other => panic!("expected Malformed for orphan rows, got {other:?}"),
1596            }
1597        }
1598
1599        assert_eq!(
1600            raw_keyring_rows(tmp.path()),
1601            before,
1602            "refusals must not rewrite the orphaned rows"
1603        );
1604        assert!(
1605            !tmp.path().join(AT_REST_KEY_FILE).exists(),
1606            "a refused init must not write a key file"
1607        );
1608    }
1609
1610    #[test]
1611    fn mode_off_meta_reports_present_but_writable_opens_still_refuse() {
1612        // A parseable meta that records mode 'off' alongside a keyring is a
1613        // contradictory state: read-only inspection can report it (doctor
1614        // grades it), every writable open still refuses.
1615        let tmp = tempfile::tempdir().unwrap();
1616        drop(MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()).unwrap());
1617        let meta = KeyringMeta {
1618            format_version: KEYRING_FORMAT_VERSION,
1619            mode: AtRestMode::Off,
1620            key_source: "generated_key_file".to_string(),
1621            created_at: "2026-09-18T00:00:00Z".to_string(),
1622            argon2: None,
1623        };
1624        write_keyring_rows(
1625            tmp.path(),
1626            &[
1627                (KEYRING_META_KEY, serde_json::to_vec(&meta).unwrap()),
1628                (RK_CHECK_KEY, b"not-a-real-wrap".to_vec()),
1629            ],
1630        );
1631
1632        match MemoryStore::open_inspection(tmp.path())
1633            .unwrap()
1634            .at_rest_status()
1635        {
1636            AtRestStatus::Present(present) => {
1637                assert_eq!(present.meta.mode, AtRestMode::Off);
1638                assert_eq!(present.wrapped_deks, 0);
1639            }
1640            other => panic!("expected Present for a parseable mode-off meta, got {other:?}"),
1641        }
1642        match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::off()) {
1643            Ok(_) => panic!("mode-off keyring must refuse a plaintext writable open"),
1644            Err(error) => assert!(error.to_string().contains("split-brain"), "{error}"),
1645        }
1646        match MemoryStore::open_with_at_rest(tmp.path(), TEST_MAP, &AtRestConfig::keyfile()) {
1647            Ok(_) => panic!("mode-off keyring must refuse a keyfile writable open"),
1648            Err(error) => assert!(error.to_string().contains("mode mismatch"), "{error}"),
1649        }
1650    }
1651
1652    #[test]
1653    fn unrecognized_env_mode_value_is_a_hard_error() {
1654        assert_eq!(mode_from_env_value(None).unwrap(), AtRestMode::Off);
1655        assert_eq!(
1656            mode_from_env_value(Some(" KeyFile ")).unwrap(),
1657            AtRestMode::Keyfile
1658        );
1659        for invalid in ["", "keyfil", "on", "off2", "plaintext"] {
1660            let error = mode_from_env_value(Some(invalid))
1661                .expect_err("unrecognized WM_AT_REST_MODE must be refused");
1662            assert!(
1663                error.to_string().contains("WM_AT_REST_MODE"),
1664                "{invalid}: {error}"
1665            );
1666            assert!(
1667                error.to_string().contains("fail-closed"),
1668                "{invalid}: {error}"
1669            );
1670        }
1671    }
1672}