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