1#![warn(clippy::pedantic)]
7#![allow(
8 clippy::doc_markdown,
9 clippy::cast_possible_wrap,
10 clippy::missing_errors_doc,
11 clippy::missing_panics_doc,
12 clippy::must_use_candidate,
13 clippy::similar_names,
14 clippy::unreadable_literal,
15 clippy::too_many_arguments,
16 clippy::implicit_hasher
17)]
18
19pub(crate) mod codename;
21pub mod crypto;
22pub mod edit;
23pub(crate) mod env;
24pub mod error;
25pub(crate) mod export;
26pub(crate) mod git;
27pub mod github;
28pub(crate) mod info;
29pub(crate) mod init;
30pub(crate) mod merge;
31pub(crate) mod recipients;
32pub mod recovery;
33pub mod scan;
34pub(crate) mod secrets;
35pub mod types;
36pub mod vault;
37
38#[cfg(feature = "python")]
39mod python;
40
41#[cfg(test)]
43pub mod testutil;
44
45pub use env::{
47 EnvrcStatus, KeySource, dotenv_has_murk_key, key_file_path, parse_env, resolve_key,
48 resolve_key_for_vault, resolve_key_with_source, warn_env_permissions, write_envrc,
49 write_key_ref_to_dotenv, write_key_to_dotenv, write_key_to_file,
50};
51pub use error::MurkError;
52pub use export::{
53 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
54 parse_and_decrypt_values, resolve_secrets,
55};
56pub use git::{MergeDriverSetupStep, setup_merge_driver};
57pub use github::{GitHubError, fetch_keys};
58pub use info::{InfoEntry, VaultInfo, format_info_lines, vault_info};
59pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
60pub use merge::{MergeDriverOutput, run_merge_driver};
61pub use recipients::{
62 RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
63 list_recipients, revoke_recipient, truncate_pubkey,
64};
65pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
66
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::Path;
69
70pub fn is_valid_key_name(key: &str) -> bool {
73 !key.is_empty()
74 && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
75 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
76}
77
78use age::secrecy::ExposeSecret;
79use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
80
81pub use crypto::{MurkIdentity, MurkRecipient};
83
84pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
86 if vault.meta.is_empty() {
87 return None;
88 }
89 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
90 serde_json::from_slice(&plaintext).ok()
91}
92
93pub(crate) fn parse_recipients(
95 pubkeys: &[String],
96) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
97 pubkeys
98 .iter()
99 .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
100 .collect()
101}
102
103pub fn encrypt_value(
105 plaintext: &[u8],
106 recipients: &[crypto::MurkRecipient],
107) -> Result<String, MurkError> {
108 let ciphertext = crypto::encrypt(plaintext, recipients)?;
109 Ok(BASE64.encode(&ciphertext))
110}
111
112pub fn decrypt_value(encoded: &str, identity: &crypto::MurkIdentity) -> Result<Vec<u8>, MurkError> {
114 let ciphertext = BASE64.decode(encoded).map_err(|e| {
115 MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
116 })?;
117 Ok(crypto::decrypt(&ciphertext, identity)?)
118}
119
120pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
124 Ok(vault::read(Path::new(vault_path))?)
125}
126
127pub fn resolve_vault_path(arg: &str) -> String {
143 use std::path::PathBuf;
144
145 if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
147 return arg.to_string();
148 }
149
150 let Ok(cwd) = std::env::current_dir() else {
151 return arg.to_string();
152 };
153
154 if cwd.join(arg).exists() {
156 return arg.to_string();
157 }
158
159 let home = std::env::var_os("HOME").map(PathBuf::from);
160 let mut dir = cwd.as_path();
161 loop {
162 let candidate = dir.join(arg);
163 if candidate.exists() {
164 return candidate.to_string_lossy().into_owned();
165 }
166 if dir.join(".git").exists() {
168 break;
169 }
170 if let Some(ref h) = home
172 && dir == h.as_path()
173 {
174 break;
175 }
176 match dir.parent() {
177 Some(parent) => dir = parent,
178 None => break,
179 }
180 }
181
182 arg.to_string()
183}
184
185pub fn decrypt_vault(
191 vault: &types::Vault,
192 identity: &crypto::MurkIdentity,
193) -> Result<types::Murk, MurkError> {
194 let pubkey = identity.pubkey_string()?;
195
196 let (recipients, legacy_mac, github_pins) = match decrypt_meta(vault, identity) {
199 Some(meta) if !meta.mac.is_empty() => {
200 let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
201 if !verify_mac(vault, &meta.mac, mac_key.as_ref()) {
202 let expected = compute_mac(vault, mac_key.as_ref());
203 return Err(MurkError::Integrity(format!(
204 "vault may have been tampered with (expected {expected}, got {})",
205 meta.mac
206 )));
207 }
208 let legacy = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
209 (meta.recipients, legacy, meta.github_pins)
210 }
211 Some(meta) if vault.secrets.is_empty() => (meta.recipients, false, meta.github_pins),
212 Some(_) => {
213 return Err(MurkError::Integrity(
214 "vault has secrets but MAC is empty — vault may have been tampered with".into(),
215 ));
216 }
217 None if vault.secrets.is_empty() && vault.meta.is_empty() => {
218 (HashMap::new(), false, HashMap::new())
219 }
220 None => {
221 return Err(MurkError::Integrity(
222 "vault has secrets but no meta — vault may have been tampered with".into(),
223 ));
224 }
225 };
226
227 let mut values = HashMap::new();
229 for (key, entry) in &vault.secrets {
230 if entry.shared.is_empty() {
231 continue;
232 }
233 let plaintext = decrypt_value(&entry.shared, identity).map_err(|_| {
234 MurkError::Crypto(crypto::CryptoError::Decrypt(
235 "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into()
236 ))
237 })?;
238 let value = String::from_utf8(plaintext)
239 .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
240 values.insert(key.clone(), value);
241 }
242
243 let mut scoped = HashMap::new();
245 for (key, entry) in &vault.secrets {
246 if let Some(encoded) = entry.scoped.get(&pubkey)
247 && let Ok(value) = decrypt_value(encoded, identity)
248 .and_then(|pt| String::from_utf8(pt).map_err(|e| MurkError::Secret(e.to_string())))
249 {
250 scoped
251 .entry(key.clone())
252 .or_insert_with(HashMap::new)
253 .insert(pubkey.clone(), value);
254 }
255 }
256
257 Ok(types::Murk {
258 values,
259 recipients,
260 scoped,
261 legacy_mac,
262 github_pins,
263 })
264}
265
266pub fn load_vault(
270 vault_path: &str,
271) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
272 let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
273
274 let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
275 MurkError::Key(format!(
276 "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
277 ))
278 })?;
279
280 let vault = read_vault(vault_path)?;
281 let murk = decrypt_vault(&vault, &identity)?;
282
283 Ok((vault, murk, identity))
284}
285
286pub fn save_vault(
289 vault_path: &str,
290 vault: &mut types::Vault,
291 original: &types::Murk,
292 current: &types::Murk,
293) -> Result<(), MurkError> {
294 let recipients = parse_recipients(&vault.recipients)?;
295
296 let recipients_changed = {
298 let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
299 let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
300 current_pks.sort_unstable();
301 original_pks.sort_unstable();
302 current_pks != original_pks
303 };
304
305 let mut new_secrets = BTreeMap::new();
306
307 let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
309 all_keys.extend(current.scoped.keys());
310
311 for key in all_keys {
312 let shared = if let Some(value) = current.values.get(key) {
313 if !recipients_changed && original.values.get(key) == Some(value) {
314 if let Some(existing) = vault.secrets.get(key) {
315 existing.shared.clone()
316 } else {
317 encrypt_value(value.as_bytes(), &recipients)?
318 }
319 } else {
320 encrypt_value(value.as_bytes(), &recipients)?
321 }
322 } else {
323 String::new()
325 };
326
327 let mut scoped = vault
328 .secrets
329 .get(key)
330 .map(|e| e.scoped.clone())
331 .unwrap_or_default();
332
333 if let Some(key_scoped) = current.scoped.get(key) {
334 for (pk, val) in key_scoped {
335 let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
336 if original_val == Some(val) {
337 } else {
339 let recipient = crypto::parse_recipient(pk)?;
340 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
341 }
342 }
343 }
344
345 if let Some(orig_key_scoped) = original.scoped.get(key) {
346 for pk in orig_key_scoped.keys() {
347 let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
348 if !still_present {
349 scoped.remove(pk);
350 }
351 }
352 }
353
354 new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
355 }
356
357 vault.secrets = new_secrets;
358
359 let mac_key_hex = generate_mac_key();
361 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
362 let mac = compute_mac(vault, Some(&mac_key));
363 let meta = types::Meta {
364 recipients: current.recipients.clone(),
365 mac,
366 mac_key: Some(mac_key_hex),
367 github_pins: current.github_pins.clone(),
368 };
369 let meta_json =
370 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
371 vault.meta = encrypt_value(&meta_json, &recipients)?;
372
373 Ok(vault::write(Path::new(vault_path), vault)?)
374}
375
376pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String {
382 match mac_key {
383 Some(key) => compute_mac_v4(vault, key),
384 None => compute_mac_v2(vault),
385 }
386}
387
388fn compute_mac_v1(vault: &types::Vault) -> String {
390 use sha2::{Digest, Sha256};
391
392 let mut hasher = Sha256::new();
393
394 for key in vault.secrets.keys() {
395 hasher.update(key.as_bytes());
396 hasher.update(b"\x00");
397 }
398
399 for entry in vault.secrets.values() {
400 hasher.update(entry.shared.as_bytes());
401 hasher.update(b"\x00");
402 }
403
404 let mut pks = vault.recipients.clone();
405 pks.sort();
406 for pk in &pks {
407 hasher.update(pk.as_bytes());
408 hasher.update(b"\x00");
409 }
410
411 let digest = hasher.finalize();
412 format!(
413 "sha256:{}",
414 digest.iter().fold(String::new(), |mut s, b| {
415 use std::fmt::Write;
416 let _ = write!(s, "{b:02x}");
417 s
418 })
419 )
420}
421
422fn compute_mac_v2(vault: &types::Vault) -> String {
424 use sha2::{Digest, Sha256};
425
426 let mut hasher = Sha256::new();
427
428 for key in vault.secrets.keys() {
430 hasher.update(key.as_bytes());
431 hasher.update(b"\x00");
432 }
433
434 for entry in vault.secrets.values() {
436 hasher.update(entry.shared.as_bytes());
437 hasher.update(b"\x00");
438
439 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
441 scoped_pks.sort();
442 for pk in scoped_pks {
443 hasher.update(pk.as_bytes());
444 hasher.update(b"\x01");
445 hasher.update(entry.scoped[pk].as_bytes());
446 hasher.update(b"\x00");
447 }
448 }
449
450 let mut pks = vault.recipients.clone();
452 pks.sort();
453 for pk in &pks {
454 hasher.update(pk.as_bytes());
455 hasher.update(b"\x00");
456 }
457
458 let digest = hasher.finalize();
459 format!(
460 "sha256v2:{}",
461 digest.iter().fold(String::new(), |mut s, b| {
462 use std::fmt::Write;
463 let _ = write!(s, "{b:02x}");
464 s
465 })
466 )
467}
468
469fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
471 let mut data = Vec::new();
472
473 for key_name in vault.secrets.keys() {
474 data.extend_from_slice(key_name.as_bytes());
475 data.push(0x00);
476 }
477
478 for entry in vault.secrets.values() {
479 data.extend_from_slice(entry.shared.as_bytes());
480 data.push(0x00);
481
482 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
483 scoped_pks.sort();
484 for pk in scoped_pks {
485 data.extend_from_slice(pk.as_bytes());
486 data.push(0x01);
487 data.extend_from_slice(entry.scoped[pk].as_bytes());
488 data.push(0x00);
489 }
490 }
491
492 let mut pks = vault.recipients.clone();
493 pks.sort();
494 for pk in &pks {
495 data.extend_from_slice(pk.as_bytes());
496 data.push(0x00);
497 }
498
499 let hash = blake3::keyed_hash(key, &data);
500 format!("blake3:{hash}")
501}
502
503fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
506 let mut data = Vec::new();
507
508 for key_name in vault.secrets.keys() {
509 data.extend_from_slice(key_name.as_bytes());
510 data.push(0x00);
511 }
512
513 for entry in vault.secrets.values() {
514 data.extend_from_slice(entry.shared.as_bytes());
515 data.push(0x00);
516
517 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
518 scoped_pks.sort();
519 for pk in scoped_pks {
520 data.extend_from_slice(pk.as_bytes());
521 data.push(0x01);
522 data.extend_from_slice(entry.scoped[pk].as_bytes());
523 data.push(0x00);
524 }
525 }
526
527 let mut pks = vault.recipients.clone();
528 pks.sort();
529 for pk in &pks {
530 data.extend_from_slice(pk.as_bytes());
531 data.push(0x00);
532 }
533
534 for (key_name, entry) in &vault.schema {
537 data.push(0x02);
538 data.extend_from_slice(key_name.as_bytes());
539 data.push(0x00);
540 data.extend_from_slice(entry.description.as_bytes());
541 data.push(0x00);
542 if let Some(example) = &entry.example {
543 data.extend_from_slice(example.as_bytes());
544 }
545 data.push(0x00);
546 for tag in &entry.tags {
547 data.extend_from_slice(tag.as_bytes());
548 data.push(0x00);
549 }
550 }
551
552 let hash = blake3::keyed_hash(key, &data);
553 format!("blake3v2:{hash}")
554}
555
556pub(crate) fn verify_mac(
558 vault: &types::Vault,
559 stored_mac: &str,
560 mac_key: Option<&[u8; 32]>,
561) -> bool {
562 use constant_time_eq::constant_time_eq;
563
564 let expected = if stored_mac.starts_with("blake3v2:") {
565 match mac_key {
566 Some(key) => compute_mac_v4(vault, key),
567 None => return false,
568 }
569 } else if stored_mac.starts_with("blake3:") {
570 match mac_key {
571 Some(key) => compute_mac_v3(vault, key),
572 None => return false,
573 }
574 } else if stored_mac.starts_with("sha256v2:") {
575 compute_mac_v2(vault)
576 } else if stored_mac.starts_with("sha256:") {
577 compute_mac_v1(vault)
578 } else {
579 return false;
580 };
581 constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
582}
583
584pub(crate) fn generate_mac_key() -> String {
586 let key: [u8; 32] = rand::random();
587 key.iter().fold(String::new(), |mut s, b| {
588 use std::fmt::Write;
589 let _ = write!(s, "{b:02x}");
590 s
591 })
592}
593
594pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
596 if hex.len() != 64 {
597 return None;
598 }
599 let mut key = [0u8; 32];
600 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
601 key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
602 }
603 Some(key)
604}
605
606pub(crate) fn now_utc() -> String {
608 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::testutil::*;
615 use std::collections::BTreeMap;
616 use std::fs;
617
618 use crate::testutil::ENV_LOCK;
619
620 #[test]
621 fn resolve_vault_path_finds_in_parent_dir() {
622 let _lock = ENV_LOCK
623 .lock()
624 .unwrap_or_else(std::sync::PoisonError::into_inner);
625 let dir = tempfile::tempdir().unwrap();
626 fs::create_dir(dir.path().join(".git")).unwrap();
628 fs::write(dir.path().join(".murk"), "{}").unwrap();
629 let nested = dir.path().join("a").join("b");
630 fs::create_dir_all(&nested).unwrap();
631
632 let prev = std::env::current_dir().unwrap();
633 std::env::set_current_dir(&nested).unwrap();
634 let got = resolve_vault_path(".murk");
635 std::env::set_current_dir(prev).unwrap();
636
637 assert_eq!(
638 std::fs::canonicalize(&got).unwrap(),
639 std::fs::canonicalize(dir.path().join(".murk")).unwrap()
640 );
641 }
642
643 #[test]
644 fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
645 let _lock = ENV_LOCK
646 .lock()
647 .unwrap_or_else(std::sync::PoisonError::into_inner);
648 let dir = tempfile::tempdir().unwrap();
649 fs::write(dir.path().join(".murk"), "{}").unwrap();
650 let prev = std::env::current_dir().unwrap();
651 std::env::set_current_dir(dir.path()).unwrap();
652 let got = resolve_vault_path(".murk");
653 std::env::set_current_dir(prev).unwrap();
654 assert_eq!(got, ".murk");
655 }
656
657 #[test]
658 fn resolve_vault_path_passes_through_explicit_paths() {
659 assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
660 assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
661 assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
662 }
663
664 #[test]
665 fn resolve_vault_path_stops_at_git_root() {
666 let _lock = ENV_LOCK
667 .lock()
668 .unwrap_or_else(std::sync::PoisonError::into_inner);
669 let dir = tempfile::tempdir().unwrap();
670 fs::write(dir.path().join(".murk"), "{}").unwrap();
672 let repo = dir.path().join("repo");
673 fs::create_dir(&repo).unwrap();
674 fs::create_dir(repo.join(".git")).unwrap();
675 let nested = repo.join("sub");
676 fs::create_dir(&nested).unwrap();
677
678 let prev = std::env::current_dir().unwrap();
679 std::env::set_current_dir(&nested).unwrap();
680 let got = resolve_vault_path(".murk");
681 std::env::set_current_dir(prev).unwrap();
682
683 assert_eq!(got, ".murk");
685 }
686
687 #[test]
688 fn encrypt_decrypt_value_roundtrip() {
689 let (secret, pubkey) = generate_keypair();
690 let recipient = make_recipient(&pubkey);
691 let identity = make_identity(&secret);
692
693 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
694 let decrypted = decrypt_value(&encoded, &identity).unwrap();
695 assert_eq!(decrypted, b"hello world");
696 }
697
698 #[test]
699 fn decrypt_value_invalid_base64() {
700 let (secret, _) = generate_keypair();
701 let identity = make_identity(&secret);
702
703 let result = decrypt_value("not!valid!base64!!!", &identity);
704 assert!(result.is_err());
705 assert!(result.unwrap_err().to_string().contains("invalid base64"));
706 }
707
708 #[test]
709 fn encrypt_value_multiple_recipients() {
710 let (secret_a, pubkey_a) = generate_keypair();
711 let (secret_b, pubkey_b) = generate_keypair();
712
713 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
714 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
715
716 let id_a = make_identity(&secret_a);
718 let id_b = make_identity(&secret_b);
719 assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
720 assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
721 }
722
723 #[test]
724 fn decrypt_value_wrong_key_fails() {
725 let (_, pubkey) = generate_keypair();
726 let (wrong_secret, _) = generate_keypair();
727
728 let recipient = make_recipient(&pubkey);
729 let wrong_identity = make_identity(&wrong_secret);
730
731 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
732 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
733 }
734
735 #[test]
736 fn compute_mac_deterministic() {
737 let vault = types::Vault {
738 version: types::VAULT_VERSION.into(),
739 created: "2026-02-28T00:00:00Z".into(),
740 vault_name: ".murk".into(),
741 repo: String::new(),
742 recipients: vec!["age1abc".into()],
743 schema: BTreeMap::new(),
744 secrets: BTreeMap::new(),
745 meta: String::new(),
746 };
747
748 let key = [0u8; 32];
749 let mac1 = compute_mac(&vault, Some(&key));
750 let mac2 = compute_mac(&vault, Some(&key));
751 assert_eq!(mac1, mac2);
752 assert!(mac1.starts_with("blake3v2:"));
753
754 let mac_legacy = compute_mac(&vault, None);
756 assert!(mac_legacy.starts_with("sha256v2:"));
757 }
758
759 #[test]
760 fn compute_mac_changes_with_different_secrets() {
761 let mut vault = types::Vault {
762 version: types::VAULT_VERSION.into(),
763 created: "2026-02-28T00:00:00Z".into(),
764 vault_name: ".murk".into(),
765 repo: String::new(),
766 recipients: vec!["age1abc".into()],
767 schema: BTreeMap::new(),
768 secrets: BTreeMap::new(),
769 meta: String::new(),
770 };
771
772 let key = [0u8; 32];
773 let mac_empty = compute_mac(&vault, Some(&key));
774
775 vault.secrets.insert(
776 "KEY".into(),
777 types::SecretEntry {
778 shared: "ciphertext".into(),
779 scoped: BTreeMap::new(),
780 },
781 );
782
783 let mac_with_secret = compute_mac(&vault, Some(&key));
784 assert_ne!(mac_empty, mac_with_secret);
785 }
786
787 #[test]
788 fn compute_mac_changes_with_different_recipients() {
789 let mut vault = types::Vault {
790 version: types::VAULT_VERSION.into(),
791 created: "2026-02-28T00:00:00Z".into(),
792 vault_name: ".murk".into(),
793 repo: String::new(),
794 recipients: vec!["age1abc".into()],
795 schema: BTreeMap::new(),
796 secrets: BTreeMap::new(),
797 meta: String::new(),
798 };
799
800 let key = [0u8; 32];
801 let mac1 = compute_mac(&vault, Some(&key));
802 vault.recipients.push("age1xyz".into());
803 let mac2 = compute_mac(&vault, Some(&key));
804 assert_ne!(mac1, mac2);
805 }
806
807 #[test]
808 fn save_vault_preserves_unchanged_ciphertext() {
809 let (secret, pubkey) = generate_keypair();
810 let recipient = make_recipient(&pubkey);
811 let identity = make_identity(&secret);
812
813 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
814 fs::create_dir_all(&dir).unwrap();
815 let path = dir.join("test.murk");
816
817 let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
818 let mut vault = types::Vault {
819 version: types::VAULT_VERSION.into(),
820 created: "2026-02-28T00:00:00Z".into(),
821 vault_name: ".murk".into(),
822 repo: String::new(),
823 recipients: vec![pubkey.clone()],
824 schema: BTreeMap::new(),
825 secrets: BTreeMap::new(),
826 meta: String::new(),
827 };
828 vault.secrets.insert(
829 "KEY1".into(),
830 types::SecretEntry {
831 shared: shared.clone(),
832 scoped: BTreeMap::new(),
833 },
834 );
835
836 let mut recipients_map = HashMap::new();
837 recipients_map.insert(pubkey.clone(), "alice".into());
838 let original = types::Murk {
839 values: HashMap::from([("KEY1".into(), "original".into())]),
840 recipients: recipients_map.clone(),
841 scoped: HashMap::new(),
842 legacy_mac: false,
843 github_pins: HashMap::new(),
844 };
845
846 let current = original.clone();
847 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
848
849 assert_eq!(vault.secrets["KEY1"].shared, shared);
850
851 let mut changed = current.clone();
852 changed.values.insert("KEY1".into(), "modified".into());
853 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
854
855 assert_ne!(vault.secrets["KEY1"].shared, shared);
856
857 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
858 assert_eq!(decrypted, b"modified");
859
860 fs::remove_dir_all(&dir).unwrap();
861 }
862
863 #[test]
864 fn save_vault_adds_new_secret() {
865 let (_, pubkey) = generate_keypair();
866 let recipient = make_recipient(&pubkey);
867
868 let dir = std::env::temp_dir().join("murk_test_save_add");
869 fs::create_dir_all(&dir).unwrap();
870 let path = dir.join("test.murk");
871
872 let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
873 let mut vault = types::Vault {
874 version: types::VAULT_VERSION.into(),
875 created: "2026-02-28T00:00:00Z".into(),
876 vault_name: ".murk".into(),
877 repo: String::new(),
878 recipients: vec![pubkey.clone()],
879 schema: BTreeMap::new(),
880 secrets: BTreeMap::new(),
881 meta: String::new(),
882 };
883 vault.secrets.insert(
884 "KEY1".into(),
885 types::SecretEntry {
886 shared,
887 scoped: BTreeMap::new(),
888 },
889 );
890
891 let mut recipients_map = HashMap::new();
892 recipients_map.insert(pubkey.clone(), "alice".into());
893 let original = types::Murk {
894 values: HashMap::from([("KEY1".into(), "val1".into())]),
895 recipients: recipients_map.clone(),
896 scoped: HashMap::new(),
897 legacy_mac: false,
898 github_pins: HashMap::new(),
899 };
900
901 let mut current = original.clone();
902 current.values.insert("KEY2".into(), "val2".into());
903
904 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
905
906 assert!(vault.secrets.contains_key("KEY1"));
907 assert!(vault.secrets.contains_key("KEY2"));
908
909 fs::remove_dir_all(&dir).unwrap();
910 }
911
912 #[test]
913 fn save_vault_removes_deleted_secret() {
914 let (_, pubkey) = generate_keypair();
915 let recipient = make_recipient(&pubkey);
916
917 let dir = std::env::temp_dir().join("murk_test_save_remove");
918 fs::create_dir_all(&dir).unwrap();
919 let path = dir.join("test.murk");
920
921 let mut vault = types::Vault {
922 version: types::VAULT_VERSION.into(),
923 created: "2026-02-28T00:00:00Z".into(),
924 vault_name: ".murk".into(),
925 repo: String::new(),
926 recipients: vec![pubkey.clone()],
927 schema: BTreeMap::new(),
928 secrets: BTreeMap::new(),
929 meta: String::new(),
930 };
931 vault.secrets.insert(
932 "KEY1".into(),
933 types::SecretEntry {
934 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
935 scoped: BTreeMap::new(),
936 },
937 );
938 vault.secrets.insert(
939 "KEY2".into(),
940 types::SecretEntry {
941 shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
942 scoped: BTreeMap::new(),
943 },
944 );
945
946 let mut recipients_map = HashMap::new();
947 recipients_map.insert(pubkey.clone(), "alice".into());
948 let original = types::Murk {
949 values: HashMap::from([
950 ("KEY1".into(), "val1".into()),
951 ("KEY2".into(), "val2".into()),
952 ]),
953 recipients: recipients_map.clone(),
954 scoped: HashMap::new(),
955 legacy_mac: false,
956 github_pins: HashMap::new(),
957 };
958
959 let mut current = original.clone();
960 current.values.remove("KEY2");
961
962 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
963
964 assert!(vault.secrets.contains_key("KEY1"));
965 assert!(!vault.secrets.contains_key("KEY2"));
966
967 fs::remove_dir_all(&dir).unwrap();
968 }
969
970 #[test]
971 fn save_vault_reencrypts_all_on_recipient_change() {
972 let (secret1, pubkey1) = generate_keypair();
973 let (_, pubkey2) = generate_keypair();
974 let recipient1 = make_recipient(&pubkey1);
975
976 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
977 fs::create_dir_all(&dir).unwrap();
978 let path = dir.join("test.murk");
979
980 let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
981 let mut vault = types::Vault {
982 version: types::VAULT_VERSION.into(),
983 created: "2026-02-28T00:00:00Z".into(),
984 vault_name: ".murk".into(),
985 repo: String::new(),
986 recipients: vec![pubkey1.clone(), pubkey2.clone()],
987 schema: BTreeMap::new(),
988 secrets: BTreeMap::new(),
989 meta: String::new(),
990 };
991 vault.secrets.insert(
992 "KEY1".into(),
993 types::SecretEntry {
994 shared: shared.clone(),
995 scoped: BTreeMap::new(),
996 },
997 );
998
999 let mut recipients_map = HashMap::new();
1000 recipients_map.insert(pubkey1.clone(), "alice".into());
1001 let original = types::Murk {
1002 values: HashMap::from([("KEY1".into(), "val1".into())]),
1003 recipients: recipients_map,
1004 scoped: HashMap::new(),
1005 legacy_mac: false,
1006 github_pins: HashMap::new(),
1007 };
1008
1009 let mut current_recipients = HashMap::new();
1010 current_recipients.insert(pubkey1.clone(), "alice".into());
1011 current_recipients.insert(pubkey2.clone(), "bob".into());
1012 let current = types::Murk {
1013 values: HashMap::from([("KEY1".into(), "val1".into())]),
1014 recipients: current_recipients,
1015 scoped: HashMap::new(),
1016 legacy_mac: false,
1017 github_pins: HashMap::new(),
1018 };
1019
1020 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1021
1022 assert_ne!(vault.secrets["KEY1"].shared, shared);
1023
1024 let identity1 = make_identity(&secret1);
1025 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
1026 assert_eq!(decrypted, b"val1");
1027
1028 fs::remove_dir_all(&dir).unwrap();
1029 }
1030
1031 #[test]
1032 fn save_vault_scoped_entry_lifecycle() {
1033 let (secret, pubkey) = generate_keypair();
1034 let recipient = make_recipient(&pubkey);
1035 let identity = make_identity(&secret);
1036
1037 let dir = std::env::temp_dir().join("murk_test_save_scoped");
1038 fs::create_dir_all(&dir).unwrap();
1039 let path = dir.join("test.murk");
1040
1041 let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
1042 let mut vault = types::Vault {
1043 version: types::VAULT_VERSION.into(),
1044 created: "2026-02-28T00:00:00Z".into(),
1045 vault_name: ".murk".into(),
1046 repo: String::new(),
1047 recipients: vec![pubkey.clone()],
1048 schema: BTreeMap::new(),
1049 secrets: BTreeMap::new(),
1050 meta: String::new(),
1051 };
1052 vault.secrets.insert(
1053 "KEY1".into(),
1054 types::SecretEntry {
1055 shared,
1056 scoped: BTreeMap::new(),
1057 },
1058 );
1059
1060 let mut recipients_map = HashMap::new();
1061 recipients_map.insert(pubkey.clone(), "alice".into());
1062 let original = types::Murk {
1063 values: HashMap::from([("KEY1".into(), "shared_val".into())]),
1064 recipients: recipients_map.clone(),
1065 scoped: HashMap::new(),
1066 legacy_mac: false,
1067 github_pins: HashMap::new(),
1068 };
1069
1070 let mut current = original.clone();
1072 let mut key_scoped = HashMap::new();
1073 key_scoped.insert(pubkey.clone(), "my_override".into());
1074 current.scoped.insert("KEY1".into(), key_scoped);
1075
1076 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1077
1078 assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
1079 let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
1080 assert_eq!(scoped_val, b"my_override");
1081
1082 let original_with_scoped = current.clone();
1084 let mut current_no_scoped = original_with_scoped.clone();
1085 current_no_scoped.scoped.remove("KEY1");
1086
1087 save_vault(
1088 path.to_str().unwrap(),
1089 &mut vault,
1090 &original_with_scoped,
1091 ¤t_no_scoped,
1092 )
1093 .unwrap();
1094
1095 assert!(vault.secrets["KEY1"].scoped.is_empty());
1096
1097 fs::remove_dir_all(&dir).unwrap();
1098 }
1099
1100 #[test]
1101 fn load_vault_validates_mac() {
1102 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1103
1104 let (secret, pubkey) = generate_keypair();
1105 let recipient = make_recipient(&pubkey);
1106 let _identity = make_identity(&secret);
1107
1108 let dir = std::env::temp_dir().join("murk_test_load_mac");
1109 let _ = fs::remove_dir_all(&dir);
1110 fs::create_dir_all(&dir).unwrap();
1111 let path = dir.join("test.murk");
1112
1113 let mut vault = types::Vault {
1115 version: types::VAULT_VERSION.into(),
1116 created: "2026-02-28T00:00:00Z".into(),
1117 vault_name: ".murk".into(),
1118 repo: String::new(),
1119 recipients: vec![pubkey.clone()],
1120 schema: BTreeMap::new(),
1121 secrets: BTreeMap::new(),
1122 meta: String::new(),
1123 };
1124 vault.secrets.insert(
1125 "KEY1".into(),
1126 types::SecretEntry {
1127 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1128 scoped: BTreeMap::new(),
1129 },
1130 );
1131
1132 let mut recipients_map = HashMap::new();
1133 recipients_map.insert(pubkey.clone(), "alice".into());
1134 let original = types::Murk {
1135 values: HashMap::from([("KEY1".into(), "val1".into())]),
1136 recipients: recipients_map,
1137 scoped: HashMap::new(),
1138 legacy_mac: false,
1139 github_pins: HashMap::new(),
1140 };
1141
1142 unsafe { std::env::set_var("MURK_KEY", &secret) };
1144 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1145 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1146
1147 let mut tampered: types::Vault =
1149 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1150 tampered.secrets.get_mut("KEY1").unwrap().shared =
1151 encrypt_value(b"tampered", &[recipient]).unwrap();
1152 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1153
1154 let result = load_vault(path.to_str().unwrap());
1156 unsafe { std::env::remove_var("MURK_KEY") };
1157
1158 let err = result.err().expect("expected MAC validation to fail");
1159 assert!(
1160 err.to_string().contains("integrity check failed"),
1161 "expected integrity check failure, got: {err}"
1162 );
1163
1164 fs::remove_dir_all(&dir).unwrap();
1165 }
1166
1167 #[test]
1168 fn load_vault_succeeds_with_valid_mac() {
1169 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1170
1171 let (secret, pubkey) = generate_keypair();
1172 let recipient = make_recipient(&pubkey);
1173
1174 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
1175 let _ = fs::remove_dir_all(&dir);
1176 fs::create_dir_all(&dir).unwrap();
1177 let path = dir.join("test.murk");
1178
1179 let mut vault = types::Vault {
1180 version: types::VAULT_VERSION.into(),
1181 created: "2026-02-28T00:00:00Z".into(),
1182 vault_name: ".murk".into(),
1183 repo: String::new(),
1184 recipients: vec![pubkey.clone()],
1185 schema: BTreeMap::new(),
1186 secrets: BTreeMap::new(),
1187 meta: String::new(),
1188 };
1189 vault.secrets.insert(
1190 "KEY1".into(),
1191 types::SecretEntry {
1192 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1193 scoped: BTreeMap::new(),
1194 },
1195 );
1196
1197 let mut recipients_map = HashMap::new();
1198 recipients_map.insert(pubkey.clone(), "alice".into());
1199 let original = types::Murk {
1200 values: HashMap::from([("KEY1".into(), "val1".into())]),
1201 recipients: recipients_map,
1202 scoped: HashMap::new(),
1203 legacy_mac: false,
1204 github_pins: HashMap::new(),
1205 };
1206
1207 unsafe { std::env::set_var("MURK_KEY", &secret) };
1208 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1209 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1210
1211 let result = load_vault(path.to_str().unwrap());
1213 unsafe { std::env::remove_var("MURK_KEY") };
1214
1215 assert!(result.is_ok());
1216 let (_, murk, _) = result.unwrap();
1217 assert_eq!(murk.values["KEY1"], "val1");
1218
1219 fs::remove_dir_all(&dir).unwrap();
1220 }
1221
1222 #[test]
1223 fn load_vault_not_a_recipient() {
1224 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1225
1226 let (secret, _pubkey) = generate_keypair();
1227 let (other_secret, other_pubkey) = generate_keypair();
1228 let other_recipient = make_recipient(&other_pubkey);
1229
1230 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
1231 let _ = fs::remove_dir_all(&dir);
1232 fs::create_dir_all(&dir).unwrap();
1233 let path = dir.join("test.murk");
1234
1235 let mut vault = types::Vault {
1237 version: types::VAULT_VERSION.into(),
1238 created: "2026-02-28T00:00:00Z".into(),
1239 vault_name: ".murk".into(),
1240 repo: String::new(),
1241 recipients: vec![other_pubkey.clone()],
1242 schema: BTreeMap::new(),
1243 secrets: BTreeMap::new(),
1244 meta: String::new(),
1245 };
1246 vault.secrets.insert(
1247 "KEY1".into(),
1248 types::SecretEntry {
1249 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
1250 scoped: BTreeMap::new(),
1251 },
1252 );
1253
1254 let mut recipients_map = HashMap::new();
1256 recipients_map.insert(other_pubkey.clone(), "other".into());
1257 let original = types::Murk {
1258 values: HashMap::from([("KEY1".into(), "val1".into())]),
1259 recipients: recipients_map,
1260 scoped: HashMap::new(),
1261 legacy_mac: false,
1262 github_pins: HashMap::new(),
1263 };
1264
1265 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
1266 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1267 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1268
1269 unsafe { std::env::set_var("MURK_KEY", secret) };
1271 let result = load_vault(path.to_str().unwrap());
1272 unsafe { std::env::remove_var("MURK_KEY") };
1273
1274 let err = match result {
1275 Err(e) => e,
1276 Ok(_) => panic!("expected load_vault to fail for non-recipient"),
1277 };
1278 let msg = err.to_string();
1280 assert!(
1281 msg.contains("decryption failed")
1282 || msg.contains("no meta")
1283 || msg.contains("tampered"),
1284 "expected decryption or integrity failure, got: {err}"
1285 );
1286
1287 fs::remove_dir_all(&dir).unwrap();
1288 }
1289
1290 #[test]
1291 fn load_vault_zero_secrets() {
1292 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1293
1294 let (secret, pubkey) = generate_keypair();
1295
1296 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
1297 let _ = fs::remove_dir_all(&dir);
1298 fs::create_dir_all(&dir).unwrap();
1299 let path = dir.join("test.murk");
1300
1301 let mut vault = types::Vault {
1303 version: types::VAULT_VERSION.into(),
1304 created: "2026-02-28T00:00:00Z".into(),
1305 vault_name: ".murk".into(),
1306 repo: String::new(),
1307 recipients: vec![pubkey.clone()],
1308 schema: BTreeMap::new(),
1309 secrets: BTreeMap::new(),
1310 meta: String::new(),
1311 };
1312
1313 let mut recipients_map = HashMap::new();
1314 recipients_map.insert(pubkey.clone(), "alice".into());
1315 let original = types::Murk {
1316 values: HashMap::new(),
1317 recipients: recipients_map,
1318 scoped: HashMap::new(),
1319 legacy_mac: false,
1320 github_pins: HashMap::new(),
1321 };
1322
1323 unsafe { std::env::set_var("MURK_KEY", &secret) };
1324 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1325 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1326
1327 let result = load_vault(path.to_str().unwrap());
1328 unsafe { std::env::remove_var("MURK_KEY") };
1329
1330 assert!(result.is_ok());
1331 let (_, murk, _) = result.unwrap();
1332 assert!(murk.values.is_empty());
1333 assert!(murk.scoped.is_empty());
1334
1335 fs::remove_dir_all(&dir).unwrap();
1336 }
1337
1338 #[test]
1339 fn load_vault_stripped_meta_with_secrets_fails() {
1340 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1341
1342 let (secret, pubkey) = generate_keypair();
1343 let recipient = make_recipient(&pubkey);
1344
1345 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
1346 let _ = fs::remove_dir_all(&dir);
1347 fs::create_dir_all(&dir).unwrap();
1348 let path = dir.join("test.murk");
1349
1350 let mut vault = types::Vault {
1352 version: types::VAULT_VERSION.into(),
1353 created: "2026-02-28T00:00:00Z".into(),
1354 vault_name: ".murk".into(),
1355 repo: String::new(),
1356 recipients: vec![pubkey.clone()],
1357 schema: BTreeMap::new(),
1358 secrets: BTreeMap::new(),
1359 meta: String::new(),
1360 };
1361 vault.secrets.insert(
1362 "KEY1".into(),
1363 types::SecretEntry {
1364 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1365 scoped: BTreeMap::new(),
1366 },
1367 );
1368
1369 let mut recipients_map = HashMap::new();
1370 recipients_map.insert(pubkey.clone(), "alice".into());
1371 let original = types::Murk {
1372 values: HashMap::from([("KEY1".into(), "val1".into())]),
1373 recipients: recipients_map,
1374 scoped: HashMap::new(),
1375 legacy_mac: false,
1376 github_pins: HashMap::new(),
1377 };
1378
1379 unsafe { std::env::set_var("MURK_KEY", &secret) };
1380 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1381 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1382
1383 let mut tampered: types::Vault =
1385 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1386 tampered.meta = String::new();
1387 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1388
1389 let result = load_vault(path.to_str().unwrap());
1391 unsafe { std::env::remove_var("MURK_KEY") };
1392
1393 let err = result.err().expect("expected MAC validation to fail");
1394 assert!(
1395 err.to_string().contains("integrity check failed"),
1396 "expected integrity check failure, got: {err}"
1397 );
1398
1399 fs::remove_dir_all(&dir).unwrap();
1400 }
1401
1402 #[test]
1403 fn load_vault_empty_mac_with_secrets_fails() {
1404 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1405
1406 let (secret, pubkey) = generate_keypair();
1407 let recipient = make_recipient(&pubkey);
1408
1409 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
1410 let _ = fs::remove_dir_all(&dir);
1411 fs::create_dir_all(&dir).unwrap();
1412 let path = dir.join("test.murk");
1413
1414 let mut vault = types::Vault {
1416 version: types::VAULT_VERSION.into(),
1417 created: "2026-02-28T00:00:00Z".into(),
1418 vault_name: ".murk".into(),
1419 repo: String::new(),
1420 recipients: vec![pubkey.clone()],
1421 schema: BTreeMap::new(),
1422 secrets: BTreeMap::new(),
1423 meta: String::new(),
1424 };
1425 vault.secrets.insert(
1426 "KEY1".into(),
1427 types::SecretEntry {
1428 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1429 scoped: BTreeMap::new(),
1430 },
1431 );
1432
1433 let mut recipients_map = HashMap::new();
1435 recipients_map.insert(pubkey.clone(), "alice".into());
1436 let meta = types::Meta {
1437 recipients: recipients_map,
1438 mac: String::new(),
1439 mac_key: None,
1440 github_pins: HashMap::new(),
1441 };
1442 let meta_json = serde_json::to_vec(&meta).unwrap();
1443 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1444
1445 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1447
1448 unsafe { std::env::set_var("MURK_KEY", &secret) };
1450 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1451 let result = load_vault(path.to_str().unwrap());
1452 unsafe { std::env::remove_var("MURK_KEY") };
1453
1454 let err = result.err().expect("expected MAC validation to fail");
1455 assert!(
1456 err.to_string().contains("integrity check failed"),
1457 "expected integrity check failure, got: {err}"
1458 );
1459
1460 fs::remove_dir_all(&dir).unwrap();
1461 }
1462
1463 #[test]
1464 fn compute_mac_changes_with_scoped_entries() {
1465 let mut vault = types::Vault {
1466 version: types::VAULT_VERSION.into(),
1467 created: "2026-02-28T00:00:00Z".into(),
1468 vault_name: ".murk".into(),
1469 repo: String::new(),
1470 recipients: vec!["age1abc".into()],
1471 schema: BTreeMap::new(),
1472 secrets: BTreeMap::new(),
1473 meta: String::new(),
1474 };
1475
1476 vault.secrets.insert(
1477 "KEY".into(),
1478 types::SecretEntry {
1479 shared: "ciphertext".into(),
1480 scoped: BTreeMap::new(),
1481 },
1482 );
1483
1484 let key = [0u8; 32];
1485 let mac_no_scoped = compute_mac(&vault, Some(&key));
1486
1487 vault
1488 .secrets
1489 .get_mut("KEY")
1490 .unwrap()
1491 .scoped
1492 .insert("age1bob".into(), "scoped-ct".into());
1493
1494 let mac_with_scoped = compute_mac(&vault, Some(&key));
1495 assert_ne!(mac_no_scoped, mac_with_scoped);
1496 }
1497
1498 #[test]
1499 fn verify_mac_accepts_v1_prefix() {
1500 let vault = types::Vault {
1501 version: types::VAULT_VERSION.into(),
1502 created: "2026-02-28T00:00:00Z".into(),
1503 vault_name: ".murk".into(),
1504 repo: String::new(),
1505 recipients: vec!["age1abc".into()],
1506 schema: BTreeMap::new(),
1507 secrets: BTreeMap::new(),
1508 meta: String::new(),
1509 };
1510
1511 let key = [0u8; 32];
1512 let v1_mac = compute_mac_v1(&vault);
1513 let v2_mac = compute_mac_v2(&vault);
1514 let v3_mac = compute_mac_v3(&vault, &key);
1515 assert!(verify_mac(&vault, &v1_mac, None));
1516 assert!(verify_mac(&vault, &v2_mac, None));
1517 assert!(verify_mac(&vault, &v3_mac, Some(&key)));
1518 assert!(!verify_mac(&vault, "sha256:bogus", None));
1519 assert!(!verify_mac(&vault, "blake3:bogus", Some(&key)));
1520 assert!(!verify_mac(&vault, "blake3v2:bogus", Some(&key)));
1521 assert!(!verify_mac(&vault, "unknown:prefix", None));
1522
1523 let v4_mac = compute_mac_v4(&vault, &key);
1525 assert!(v4_mac.starts_with("blake3v2:"));
1526 assert!(verify_mac(&vault, &v4_mac, Some(&key)));
1527 }
1528
1529 #[test]
1530 fn compute_mac_changes_with_schema() {
1531 let mut vault = types::Vault {
1532 version: types::VAULT_VERSION.into(),
1533 created: "2026-02-28T00:00:00Z".into(),
1534 vault_name: ".murk".into(),
1535 repo: String::new(),
1536 recipients: vec!["age1abc".into()],
1537 schema: BTreeMap::new(),
1538 secrets: BTreeMap::new(),
1539 meta: String::new(),
1540 };
1541
1542 let key = [0u8; 32];
1543 let mac_no_schema = compute_mac(&vault, Some(&key));
1544
1545 vault.schema.insert(
1546 "API_KEY".into(),
1547 types::SchemaEntry {
1548 description: "Main API key".into(),
1549 tags: vec!["deploy".into()],
1550 ..Default::default()
1551 },
1552 );
1553
1554 let mac_with_schema = compute_mac(&vault, Some(&key));
1555 assert_ne!(mac_no_schema, mac_with_schema);
1556
1557 let mac_before_retag = mac_with_schema;
1559 vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
1560 let mac_after_retag = compute_mac(&vault, Some(&key));
1561 assert_ne!(mac_before_retag, mac_after_retag);
1562 }
1563
1564 #[test]
1565 fn mac_key_roundtrip() {
1566 let hex = generate_mac_key();
1567 assert_eq!(hex.len(), 64);
1568 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1569
1570 let key = decode_mac_key(&hex).expect("valid hex should decode");
1571 let rehex = key.iter().fold(String::new(), |mut s, b| {
1573 use std::fmt::Write;
1574 let _ = write!(s, "{b:02x}");
1575 s
1576 });
1577 assert_eq!(hex, rehex);
1578 }
1579
1580 #[test]
1581 fn decode_mac_key_rejects_bad_input() {
1582 assert!(decode_mac_key("").is_none());
1583 assert!(decode_mac_key("tooshort").is_none());
1584 assert!(decode_mac_key(&"zz".repeat(32)).is_none()); assert!(decode_mac_key(&"aa".repeat(31)).is_none()); assert!(decode_mac_key(&"aa".repeat(33)).is_none()); }
1588
1589 #[test]
1590 fn blake3_mac_different_key_different_mac() {
1591 let vault = types::Vault {
1592 version: types::VAULT_VERSION.into(),
1593 created: "2026-02-28T00:00:00Z".into(),
1594 vault_name: ".murk".into(),
1595 repo: String::new(),
1596 recipients: vec!["age1abc".into()],
1597 schema: BTreeMap::new(),
1598 secrets: BTreeMap::new(),
1599 meta: String::new(),
1600 };
1601
1602 let key1 = [0u8; 32];
1603 let key2 = [1u8; 32];
1604 let mac1 = compute_mac(&vault, Some(&key1));
1605 let mac2 = compute_mac(&vault, Some(&key2));
1606 assert_ne!(mac1, mac2);
1607 }
1608
1609 #[test]
1610 fn valid_key_names() {
1611 assert!(is_valid_key_name("DATABASE_URL"));
1612 assert!(is_valid_key_name("_PRIVATE"));
1613 assert!(is_valid_key_name("A"));
1614 assert!(is_valid_key_name("key123"));
1615 }
1616
1617 #[test]
1618 fn invalid_key_names() {
1619 assert!(!is_valid_key_name(""));
1620 assert!(!is_valid_key_name("123_START"));
1621 assert!(!is_valid_key_name("KEY-NAME"));
1622 assert!(!is_valid_key_name("KEY NAME"));
1623 assert!(!is_valid_key_name("FOO$(bar)"));
1624 assert!(!is_valid_key_name("KEY=VAL"));
1625 }
1626
1627 #[test]
1628 fn now_utc_format() {
1629 let ts = now_utc();
1630 assert!(ts.ends_with('Z'));
1631 assert_eq!(ts.len(), 20);
1632 assert_eq!(&ts[4..5], "-");
1633 assert_eq!(&ts[7..8], "-");
1634 assert_eq!(&ts[10..11], "T");
1635 }
1636}