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