1use 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
40pub const KEYRING_DB: &str = "keyring";
42pub const KEYRING_META_KEY: &[u8] = b"meta";
44pub const RK_CHECK_KEY: &[u8] = b"rk:check";
47pub const DEK_KEY_PREFIX: &str = "dek:";
49pub const KEYRING_FORMAT_VERSION: u32 = 1;
51pub const RK_CHECK_INFO: &str = "wm/at-rest/rk-check/v1";
53pub const RK_CHECK_PLAINTEXT: &[u8] = b"wm/at-rest/rk-check/v1";
55pub const AT_REST_KEY_FILE: &str = ".at_rest_key";
57const AT_REST_LMDB_DIR: &str = "lmdb";
59pub const AT_REST_KEY_LEN: usize = 32;
61
62#[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
82const WRAP_VERSION: u8 = 1;
84const NONCE_LEN: usize = 24;
85const TAG_LEN: usize = 16;
86const WRAP_HEADER_LEN: usize = 1 + NONCE_LEN;
88const WRAP_MIN_LEN: usize = WRAP_HEADER_LEN + TAG_LEN;
90
91const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum AtRestMode {
108 Off,
110 Keyfile,
113 Passphrase,
116}
117
118impl AtRestMode {
119 #[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 #[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#[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
164fn 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 #[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 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 #[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 #[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 #[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 #[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 #[must_use]
266 pub const fn mode(&self) -> AtRestMode {
267 self.mode
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct Argon2Params {
276 pub m_cost_kib: u32,
278 pub t_cost: u32,
280 pub p_cost: u32,
282 pub version: u32,
284 pub salt_hex: String,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct KeyringMeta {
291 pub format_version: u32,
293 pub mode: AtRestMode,
295 pub key_source: String,
298 pub created_at: String,
300 #[serde(default)]
302 pub argon2: Option<Argon2Params>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum AtRestStatus {
308 Absent,
310 Present(AtRestStatusPresent),
312 Malformed {
315 reason: String,
317 },
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct AtRestStatusPresent {
323 pub meta: KeyringMeta,
325 pub wrapped_deks: usize,
327 pub galaxies: usize,
329 pub key_file: Option<PathBuf>,
332}
333
334pub 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 #[must_use]
347 pub const fn meta(&self) -> &KeyringMeta {
348 &self.meta
349 }
350
351 #[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 #[must_use]
359 pub fn dek_count(&self) -> usize {
360 self.deks.len()
361 }
362
363 #[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
375fn 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
417fn 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
438fn 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
472fn 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(¶ms.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
519fn 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
600enum KeyringPresence {
603 Absent,
606 Meta(Box<KeyringMeta>),
608 Unreadable(String),
610}
611
612fn 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
663pub(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
674pub(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
763struct NewRootKey {
768 rk: Zeroizing<[u8; AT_REST_KEY_LEN]>,
769 key_source: &'static str,
770 argon2: Option<Argon2Params>,
771}
772
773fn 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, ¶ms)?;
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
886fn 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 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
977fn 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
1039pub(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 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 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 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 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 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 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 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 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 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 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}