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 MIGRATION_LEDGER_KEY: &[u8] = b"migration:v1";
51pub const KEYRING_FORMAT_VERSION: u32 = 1;
53pub const RK_CHECK_INFO: &str = "wm/at-rest/rk-check/v1";
55pub const RK_CHECK_PLAINTEXT: &[u8] = b"wm/at-rest/rk-check/v1";
57pub const AT_REST_KEY_FILE: &str = ".at_rest_key";
59const AT_REST_LMDB_DIR: &str = "lmdb";
61pub const AT_REST_KEY_LEN: usize = 32;
63
64#[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
84const WRAP_VERSION: u8 = 1;
86const NONCE_LEN: usize = 24;
87const TAG_LEN: usize = 16;
88const WRAP_HEADER_LEN: usize = 1 + NONCE_LEN;
90const WRAP_MIN_LEN: usize = WRAP_HEADER_LEN + TAG_LEN;
92
93const 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;
99const ARGON2_M_COST_MAX_KIB: u32 = 1_048_576; const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum AtRestMode {
116 Off,
118 Keyfile,
121 Passphrase,
124}
125
126impl AtRestMode {
127 #[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 #[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#[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
172fn 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 #[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 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 #[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 #[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 #[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 #[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 #[must_use]
274 pub const fn mode(&self) -> AtRestMode {
275 self.mode
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct Argon2Params {
284 pub m_cost_kib: u32,
286 pub t_cost: u32,
288 pub p_cost: u32,
290 pub version: u32,
292 pub salt_hex: String,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct KeyringMeta {
299 pub format_version: u32,
301 pub mode: AtRestMode,
303 pub key_source: String,
306 pub created_at: String,
308 #[serde(default)]
310 pub argon2: Option<Argon2Params>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum AtRestStatus {
316 Absent,
318 Present(AtRestStatusPresent),
320 Malformed {
323 reason: String,
325 },
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct AtRestStatusPresent {
331 pub meta: KeyringMeta,
333 pub wrapped_deks: usize,
335 pub galaxies: usize,
337 pub key_file: Option<PathBuf>,
340}
341
342pub 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 #[must_use]
355 pub const fn meta(&self) -> &KeyringMeta {
356 &self.meta
357 }
358
359 #[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 #[must_use]
367 pub fn dek_count(&self) -> usize {
368 self.deks.len()
369 }
370
371 #[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
383pub(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
425fn 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
446fn 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
480fn 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
506fn 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(¶ms.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
551fn 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
632enum KeyringPresence {
635 Absent,
638 Meta(Box<KeyringMeta>),
640 Unreadable(String),
642}
643
644fn 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 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
705pub(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
716pub(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
805struct NewRootKey {
810 rk: Zeroizing<[u8; AT_REST_KEY_LEN]>,
811 key_source: &'static str,
812 argon2: Option<Argon2Params>,
813}
814
815fn 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, ¶ms)?;
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
928fn 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 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
1027fn 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
1089pub(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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1147pub struct MigrationGalaxyState {
1148 pub encrypted: u64,
1150 #[serde(default)]
1152 pub cursor_hex: String,
1153 #[serde(default)]
1155 pub done: bool,
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1160pub struct MigrationLedger {
1161 #[serde(default = "default_migration_ledger_version")]
1163 pub version: u32,
1164 #[serde(default)]
1166 pub galaxies: std::collections::BTreeMap<String, MigrationGalaxyState>,
1167 #[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
1186pub(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
1209pub(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
1228pub(crate) const fn migration_candidate_key(key: &[u8]) -> bool {
1232 key.len() == 16
1233}
1234
1235pub(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
1245pub(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 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 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 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 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 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 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 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 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 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 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 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}