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