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 mod codename;
21pub mod crypto;
22pub mod env;
23pub mod export;
24pub mod git;
25pub mod github;
26pub mod info;
27pub mod init;
28pub mod merge;
29pub mod recipients;
30pub mod recovery;
31pub mod secrets;
32pub mod types;
33pub mod vault;
34
35#[cfg(test)]
37pub mod testutil;
38
39pub use env::{
41 EnvrcStatus, dotenv_has_murk_key, parse_env, read_key_from_dotenv, resolve_key,
42 warn_env_permissions, write_envrc, write_key_to_dotenv,
43};
44pub use export::{
45 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets,
46 parse_and_decrypt_values, resolve_secrets,
47};
48pub use git::{MergeDriverSetupStep, setup_merge_driver};
49pub use github::{GitHubError, fetch_keys};
50pub use info::{InfoEntry, VaultInfo, vault_info};
51pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
52pub use merge::{MergeDriverOutput, run_merge_driver};
53pub use recipients::{
54 RecipientEntry, RevokeResult, authorize_recipient, list_recipients, revoke_recipient,
55};
56pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
57
58use std::collections::{BTreeMap, HashMap};
59use std::path::Path;
60
61pub fn is_valid_key_name(key: &str) -> bool {
64 !key.is_empty()
65 && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
66 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
67}
68
69use age::secrecy::ExposeSecret;
70use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
71
72pub use crypto::{MurkIdentity, MurkRecipient};
74
75pub(crate) fn decrypt_meta(
77 vault: &types::Vault,
78 identity: &crypto::MurkIdentity,
79) -> Option<types::Meta> {
80 if vault.meta.is_empty() {
81 return None;
82 }
83 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
84 serde_json::from_slice(&plaintext).ok()
85}
86
87pub(crate) fn parse_recipients(pubkeys: &[String]) -> Result<Vec<crypto::MurkRecipient>, String> {
89 pubkeys
90 .iter()
91 .map(|pk| crypto::parse_recipient(pk).map_err(|e| e.to_string()))
92 .collect()
93}
94
95pub fn encrypt_value(
97 plaintext: &[u8],
98 recipients: &[crypto::MurkRecipient],
99) -> Result<String, String> {
100 let ciphertext = crypto::encrypt(plaintext, recipients).map_err(|e| e.to_string())?;
101 Ok(BASE64.encode(&ciphertext))
102}
103
104pub fn decrypt_value(encoded: &str, identity: &crypto::MurkIdentity) -> Result<Vec<u8>, String> {
106 let ciphertext = BASE64
107 .decode(encoded)
108 .map_err(|e| format!("invalid base64: {e}"))?;
109 crypto::decrypt(&ciphertext, identity).map_err(|e| e.to_string())
110}
111
112pub fn load_vault(
116 vault_path: &str,
117) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), String> {
118 let path = Path::new(vault_path);
119 let secret_key = resolve_key()?;
120
121 let identity =
122 crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
123 format!("invalid key: {e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519")
124 })?;
125
126 let vault = vault::read(path).map_err(|e| e.to_string())?;
127 let pubkey = identity.pubkey_string().map_err(|e| e.to_string())?;
128
129 let mut values = HashMap::new();
131 for (key, entry) in &vault.secrets {
132 let plaintext = decrypt_value(&entry.shared, &identity).map_err(|_| {
133 "decryption failed — you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".to_string()
134 })?;
135 let value = String::from_utf8(plaintext)
136 .map_err(|e| format!("invalid UTF-8 in secret {key}: {e}"))?;
137 values.insert(key.clone(), value);
138 }
139
140 let mut scoped = HashMap::new();
142 for (key, entry) in &vault.secrets {
143 if let Some(encoded) = entry.scoped.get(&pubkey)
144 && let Ok(value) = decrypt_value(encoded, &identity)
145 .and_then(|pt| String::from_utf8(pt).map_err(|e| e.to_string()))
146 {
147 scoped
148 .entry(key.clone())
149 .or_insert_with(HashMap::new)
150 .insert(pubkey.clone(), value);
151 }
152 }
153
154 let recipients = if vault.secrets.is_empty() {
156 decrypt_meta(&vault, &identity)
158 .map(|m| m.recipients)
159 .unwrap_or_default()
160 } else {
161 let meta = decrypt_meta(&vault, &identity).ok_or(
163 "integrity check failed: vault has secrets but no meta — vault may have been tampered with"
164 )?;
165 if meta.mac.is_empty() {
166 return Err("integrity check failed: vault has secrets but MAC is empty — vault may have been tampered with".into());
167 }
168 if !verify_mac(&vault, &meta.mac) {
169 let expected = compute_mac(&vault);
170 return Err(format!(
171 "integrity check failed: vault may have been tampered with (expected {expected}, got {})",
172 meta.mac
173 ));
174 }
175 meta.recipients
176 };
177
178 let murk = types::Murk {
179 values,
180 recipients,
181 scoped,
182 };
183
184 Ok((vault, murk, identity))
185}
186
187pub fn save_vault(
190 vault_path: &str,
191 vault: &mut types::Vault,
192 original: &types::Murk,
193 current: &types::Murk,
194) -> Result<(), String> {
195 let recipients = parse_recipients(&vault.recipients)?;
196
197 let recipients_changed = {
199 let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
200 let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
201 current_pks.sort_unstable();
202 original_pks.sort_unstable();
203 current_pks != original_pks
204 };
205
206 let mut new_secrets = BTreeMap::new();
207
208 for (key, value) in ¤t.values {
209 let shared = if !recipients_changed && original.values.get(key) == Some(value) {
211 if let Some(existing) = vault.secrets.get(key) {
213 existing.shared.clone()
214 } else {
215 encrypt_value(value.as_bytes(), &recipients)?
216 }
217 } else {
218 encrypt_value(value.as_bytes(), &recipients)?
219 };
220
221 let mut scoped = vault
223 .secrets
224 .get(key)
225 .map(|e| e.scoped.clone())
226 .unwrap_or_default();
227
228 if let Some(key_scoped) = current.scoped.get(key) {
230 for (pk, val) in key_scoped {
231 let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
232 if original_val == Some(val) {
233 } else {
235 let recipient = crypto::parse_recipient(pk).map_err(|e| e.to_string())?;
237 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
238 }
239 }
240 }
241
242 if let Some(orig_key_scoped) = original.scoped.get(key) {
244 for pk in orig_key_scoped.keys() {
245 let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
246 if !still_present {
247 scoped.remove(pk);
248 }
249 }
250 }
251
252 new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
253 }
254
255 vault.secrets = new_secrets;
256
257 let mac = compute_mac(vault);
259 let meta = types::Meta {
260 recipients: current.recipients.clone(),
261 mac,
262 };
263 let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
264 vault.meta = encrypt_value(&meta_json, &recipients)?;
265
266 vault::write(Path::new(vault_path), vault).map_err(|e| e.to_string())
267}
268
269pub(crate) fn compute_mac(vault: &types::Vault) -> String {
276 compute_mac_v2(vault)
277}
278
279fn compute_mac_v1(vault: &types::Vault) -> String {
281 use sha2::{Digest, Sha256};
282
283 let mut hasher = Sha256::new();
284
285 for key in vault.secrets.keys() {
286 hasher.update(key.as_bytes());
287 hasher.update(b"\x00");
288 }
289
290 for entry in vault.secrets.values() {
291 hasher.update(entry.shared.as_bytes());
292 hasher.update(b"\x00");
293 }
294
295 let mut pks = vault.recipients.clone();
296 pks.sort();
297 for pk in &pks {
298 hasher.update(pk.as_bytes());
299 hasher.update(b"\x00");
300 }
301
302 let digest = hasher.finalize();
303 format!(
304 "sha256:{}",
305 digest.iter().fold(String::new(), |mut s, b| {
306 use std::fmt::Write;
307 let _ = write!(s, "{b:02x}");
308 s
309 })
310 )
311}
312
313fn compute_mac_v2(vault: &types::Vault) -> String {
315 use sha2::{Digest, Sha256};
316
317 let mut hasher = Sha256::new();
318
319 for key in vault.secrets.keys() {
321 hasher.update(key.as_bytes());
322 hasher.update(b"\x00");
323 }
324
325 for entry in vault.secrets.values() {
327 hasher.update(entry.shared.as_bytes());
328 hasher.update(b"\x00");
329
330 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
332 scoped_pks.sort();
333 for pk in scoped_pks {
334 hasher.update(pk.as_bytes());
335 hasher.update(b"\x01");
336 hasher.update(entry.scoped[pk].as_bytes());
337 hasher.update(b"\x00");
338 }
339 }
340
341 let mut pks = vault.recipients.clone();
343 pks.sort();
344 for pk in &pks {
345 hasher.update(pk.as_bytes());
346 hasher.update(b"\x00");
347 }
348
349 let digest = hasher.finalize();
350 format!(
351 "sha256v2:{}",
352 digest.iter().fold(String::new(), |mut s, b| {
353 use std::fmt::Write;
354 let _ = write!(s, "{b:02x}");
355 s
356 })
357 )
358}
359
360pub(crate) fn verify_mac(vault: &types::Vault, stored_mac: &str) -> bool {
362 if stored_mac.starts_with("sha256v2:") {
363 stored_mac == compute_mac_v2(vault)
364 } else if stored_mac.starts_with("sha256:") {
365 stored_mac == compute_mac_v1(vault)
366 } else {
367 false
368 }
369}
370
371pub(crate) fn now_utc() -> String {
373 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::testutil::*;
380 use std::collections::BTreeMap;
381 use std::fs;
382 use std::sync::Mutex;
383
384 static ENV_LOCK: Mutex<()> = Mutex::new(());
386
387 #[test]
388 fn encrypt_decrypt_value_roundtrip() {
389 let (secret, pubkey) = generate_keypair();
390 let recipient = make_recipient(&pubkey);
391 let identity = make_identity(&secret);
392
393 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
394 let decrypted = decrypt_value(&encoded, &identity).unwrap();
395 assert_eq!(decrypted, b"hello world");
396 }
397
398 #[test]
399 fn decrypt_value_invalid_base64() {
400 let (secret, _) = generate_keypair();
401 let identity = make_identity(&secret);
402
403 let result = decrypt_value("not!valid!base64!!!", &identity);
404 assert!(result.is_err());
405 assert!(result.unwrap_err().contains("invalid base64"));
406 }
407
408 #[test]
409 fn encrypt_value_multiple_recipients() {
410 let (secret_a, pubkey_a) = generate_keypair();
411 let (secret_b, pubkey_b) = generate_keypair();
412
413 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
414 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
415
416 let id_a = make_identity(&secret_a);
418 let id_b = make_identity(&secret_b);
419 assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
420 assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
421 }
422
423 #[test]
424 fn decrypt_value_wrong_key_fails() {
425 let (_, pubkey) = generate_keypair();
426 let (wrong_secret, _) = generate_keypair();
427
428 let recipient = make_recipient(&pubkey);
429 let wrong_identity = make_identity(&wrong_secret);
430
431 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
432 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
433 }
434
435 #[test]
436 fn compute_mac_deterministic() {
437 let vault = types::Vault {
438 version: types::VAULT_VERSION.into(),
439 created: "2026-02-28T00:00:00Z".into(),
440 vault_name: ".murk".into(),
441 repo: String::new(),
442 recipients: vec!["age1abc".into()],
443 schema: BTreeMap::new(),
444 secrets: BTreeMap::new(),
445 meta: String::new(),
446 };
447
448 let mac1 = compute_mac(&vault);
449 let mac2 = compute_mac(&vault);
450 assert_eq!(mac1, mac2);
451 assert!(mac1.starts_with("sha256v2:"));
452 }
453
454 #[test]
455 fn compute_mac_changes_with_different_secrets() {
456 let mut vault = types::Vault {
457 version: types::VAULT_VERSION.into(),
458 created: "2026-02-28T00:00:00Z".into(),
459 vault_name: ".murk".into(),
460 repo: String::new(),
461 recipients: vec!["age1abc".into()],
462 schema: BTreeMap::new(),
463 secrets: BTreeMap::new(),
464 meta: String::new(),
465 };
466
467 let mac_empty = compute_mac(&vault);
468
469 vault.secrets.insert(
470 "KEY".into(),
471 types::SecretEntry {
472 shared: "ciphertext".into(),
473 scoped: BTreeMap::new(),
474 },
475 );
476
477 let mac_with_secret = compute_mac(&vault);
478 assert_ne!(mac_empty, mac_with_secret);
479 }
480
481 #[test]
482 fn compute_mac_changes_with_different_recipients() {
483 let mut vault = types::Vault {
484 version: types::VAULT_VERSION.into(),
485 created: "2026-02-28T00:00:00Z".into(),
486 vault_name: ".murk".into(),
487 repo: String::new(),
488 recipients: vec!["age1abc".into()],
489 schema: BTreeMap::new(),
490 secrets: BTreeMap::new(),
491 meta: String::new(),
492 };
493
494 let mac1 = compute_mac(&vault);
495 vault.recipients.push("age1xyz".into());
496 let mac2 = compute_mac(&vault);
497 assert_ne!(mac1, mac2);
498 }
499
500 #[test]
501 fn save_vault_preserves_unchanged_ciphertext() {
502 let (secret, pubkey) = generate_keypair();
503 let recipient = make_recipient(&pubkey);
504 let identity = make_identity(&secret);
505
506 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
507 fs::create_dir_all(&dir).unwrap();
508 let path = dir.join("test.murk");
509
510 let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
511 let mut vault = types::Vault {
512 version: types::VAULT_VERSION.into(),
513 created: "2026-02-28T00:00:00Z".into(),
514 vault_name: ".murk".into(),
515 repo: String::new(),
516 recipients: vec![pubkey.clone()],
517 schema: BTreeMap::new(),
518 secrets: BTreeMap::new(),
519 meta: String::new(),
520 };
521 vault.secrets.insert(
522 "KEY1".into(),
523 types::SecretEntry {
524 shared: shared.clone(),
525 scoped: BTreeMap::new(),
526 },
527 );
528
529 let mut recipients_map = HashMap::new();
530 recipients_map.insert(pubkey.clone(), "alice".into());
531 let original = types::Murk {
532 values: HashMap::from([("KEY1".into(), "original".into())]),
533 recipients: recipients_map.clone(),
534 scoped: HashMap::new(),
535 };
536
537 let current = original.clone();
538 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
539
540 assert_eq!(vault.secrets["KEY1"].shared, shared);
541
542 let mut changed = current.clone();
543 changed.values.insert("KEY1".into(), "modified".into());
544 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
545
546 assert_ne!(vault.secrets["KEY1"].shared, shared);
547
548 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
549 assert_eq!(decrypted, b"modified");
550
551 fs::remove_dir_all(&dir).unwrap();
552 }
553
554 #[test]
555 fn save_vault_adds_new_secret() {
556 let (_, pubkey) = generate_keypair();
557 let recipient = make_recipient(&pubkey);
558
559 let dir = std::env::temp_dir().join("murk_test_save_add");
560 fs::create_dir_all(&dir).unwrap();
561 let path = dir.join("test.murk");
562
563 let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
564 let mut vault = types::Vault {
565 version: types::VAULT_VERSION.into(),
566 created: "2026-02-28T00:00:00Z".into(),
567 vault_name: ".murk".into(),
568 repo: String::new(),
569 recipients: vec![pubkey.clone()],
570 schema: BTreeMap::new(),
571 secrets: BTreeMap::new(),
572 meta: String::new(),
573 };
574 vault.secrets.insert(
575 "KEY1".into(),
576 types::SecretEntry {
577 shared,
578 scoped: BTreeMap::new(),
579 },
580 );
581
582 let mut recipients_map = HashMap::new();
583 recipients_map.insert(pubkey.clone(), "alice".into());
584 let original = types::Murk {
585 values: HashMap::from([("KEY1".into(), "val1".into())]),
586 recipients: recipients_map.clone(),
587 scoped: HashMap::new(),
588 };
589
590 let mut current = original.clone();
591 current.values.insert("KEY2".into(), "val2".into());
592
593 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
594
595 assert!(vault.secrets.contains_key("KEY1"));
596 assert!(vault.secrets.contains_key("KEY2"));
597
598 fs::remove_dir_all(&dir).unwrap();
599 }
600
601 #[test]
602 fn save_vault_removes_deleted_secret() {
603 let (_, pubkey) = generate_keypair();
604 let recipient = make_recipient(&pubkey);
605
606 let dir = std::env::temp_dir().join("murk_test_save_remove");
607 fs::create_dir_all(&dir).unwrap();
608 let path = dir.join("test.murk");
609
610 let mut vault = types::Vault {
611 version: types::VAULT_VERSION.into(),
612 created: "2026-02-28T00:00:00Z".into(),
613 vault_name: ".murk".into(),
614 repo: String::new(),
615 recipients: vec![pubkey.clone()],
616 schema: BTreeMap::new(),
617 secrets: BTreeMap::new(),
618 meta: String::new(),
619 };
620 vault.secrets.insert(
621 "KEY1".into(),
622 types::SecretEntry {
623 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
624 scoped: BTreeMap::new(),
625 },
626 );
627 vault.secrets.insert(
628 "KEY2".into(),
629 types::SecretEntry {
630 shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
631 scoped: BTreeMap::new(),
632 },
633 );
634
635 let mut recipients_map = HashMap::new();
636 recipients_map.insert(pubkey.clone(), "alice".into());
637 let original = types::Murk {
638 values: HashMap::from([
639 ("KEY1".into(), "val1".into()),
640 ("KEY2".into(), "val2".into()),
641 ]),
642 recipients: recipients_map.clone(),
643 scoped: HashMap::new(),
644 };
645
646 let mut current = original.clone();
647 current.values.remove("KEY2");
648
649 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
650
651 assert!(vault.secrets.contains_key("KEY1"));
652 assert!(!vault.secrets.contains_key("KEY2"));
653
654 fs::remove_dir_all(&dir).unwrap();
655 }
656
657 #[test]
658 fn save_vault_reencrypts_all_on_recipient_change() {
659 let (secret1, pubkey1) = generate_keypair();
660 let (_, pubkey2) = generate_keypair();
661 let recipient1 = make_recipient(&pubkey1);
662
663 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
664 fs::create_dir_all(&dir).unwrap();
665 let path = dir.join("test.murk");
666
667 let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
668 let mut vault = types::Vault {
669 version: types::VAULT_VERSION.into(),
670 created: "2026-02-28T00:00:00Z".into(),
671 vault_name: ".murk".into(),
672 repo: String::new(),
673 recipients: vec![pubkey1.clone(), pubkey2.clone()],
674 schema: BTreeMap::new(),
675 secrets: BTreeMap::new(),
676 meta: String::new(),
677 };
678 vault.secrets.insert(
679 "KEY1".into(),
680 types::SecretEntry {
681 shared: shared.clone(),
682 scoped: BTreeMap::new(),
683 },
684 );
685
686 let mut recipients_map = HashMap::new();
687 recipients_map.insert(pubkey1.clone(), "alice".into());
688 let original = types::Murk {
689 values: HashMap::from([("KEY1".into(), "val1".into())]),
690 recipients: recipients_map,
691 scoped: HashMap::new(),
692 };
693
694 let mut current_recipients = HashMap::new();
695 current_recipients.insert(pubkey1.clone(), "alice".into());
696 current_recipients.insert(pubkey2.clone(), "bob".into());
697 let current = types::Murk {
698 values: HashMap::from([("KEY1".into(), "val1".into())]),
699 recipients: current_recipients,
700 scoped: HashMap::new(),
701 };
702
703 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
704
705 assert_ne!(vault.secrets["KEY1"].shared, shared);
706
707 let identity1 = make_identity(&secret1);
708 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
709 assert_eq!(decrypted, b"val1");
710
711 fs::remove_dir_all(&dir).unwrap();
712 }
713
714 #[test]
715 fn save_vault_scoped_entry_lifecycle() {
716 let (secret, pubkey) = generate_keypair();
717 let recipient = make_recipient(&pubkey);
718 let identity = make_identity(&secret);
719
720 let dir = std::env::temp_dir().join("murk_test_save_scoped");
721 fs::create_dir_all(&dir).unwrap();
722 let path = dir.join("test.murk");
723
724 let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
725 let mut vault = types::Vault {
726 version: types::VAULT_VERSION.into(),
727 created: "2026-02-28T00:00:00Z".into(),
728 vault_name: ".murk".into(),
729 repo: String::new(),
730 recipients: vec![pubkey.clone()],
731 schema: BTreeMap::new(),
732 secrets: BTreeMap::new(),
733 meta: String::new(),
734 };
735 vault.secrets.insert(
736 "KEY1".into(),
737 types::SecretEntry {
738 shared,
739 scoped: BTreeMap::new(),
740 },
741 );
742
743 let mut recipients_map = HashMap::new();
744 recipients_map.insert(pubkey.clone(), "alice".into());
745 let original = types::Murk {
746 values: HashMap::from([("KEY1".into(), "shared_val".into())]),
747 recipients: recipients_map.clone(),
748 scoped: HashMap::new(),
749 };
750
751 let mut current = original.clone();
753 let mut key_scoped = HashMap::new();
754 key_scoped.insert(pubkey.clone(), "my_override".into());
755 current.scoped.insert("KEY1".into(), key_scoped);
756
757 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
758
759 assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
760 let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
761 assert_eq!(scoped_val, b"my_override");
762
763 let original_with_scoped = current.clone();
765 let mut current_no_scoped = original_with_scoped.clone();
766 current_no_scoped.scoped.remove("KEY1");
767
768 save_vault(
769 path.to_str().unwrap(),
770 &mut vault,
771 &original_with_scoped,
772 ¤t_no_scoped,
773 )
774 .unwrap();
775
776 assert!(vault.secrets["KEY1"].scoped.is_empty());
777
778 fs::remove_dir_all(&dir).unwrap();
779 }
780
781 #[test]
782 fn load_vault_validates_mac() {
783 let _lock = ENV_LOCK.lock().unwrap();
784
785 let (secret, pubkey) = generate_keypair();
786 let recipient = make_recipient(&pubkey);
787 let _identity = make_identity(&secret);
788
789 let dir = std::env::temp_dir().join("murk_test_load_mac");
790 let _ = fs::remove_dir_all(&dir);
791 fs::create_dir_all(&dir).unwrap();
792 let path = dir.join("test.murk");
793
794 let mut vault = types::Vault {
796 version: types::VAULT_VERSION.into(),
797 created: "2026-02-28T00:00:00Z".into(),
798 vault_name: ".murk".into(),
799 repo: String::new(),
800 recipients: vec![pubkey.clone()],
801 schema: BTreeMap::new(),
802 secrets: BTreeMap::new(),
803 meta: String::new(),
804 };
805 vault.secrets.insert(
806 "KEY1".into(),
807 types::SecretEntry {
808 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
809 scoped: BTreeMap::new(),
810 },
811 );
812
813 let mut recipients_map = HashMap::new();
814 recipients_map.insert(pubkey.clone(), "alice".into());
815 let original = types::Murk {
816 values: HashMap::from([("KEY1".into(), "val1".into())]),
817 recipients: recipients_map,
818 scoped: HashMap::new(),
819 };
820
821 unsafe { std::env::set_var("MURK_KEY", &secret) };
823 unsafe { std::env::remove_var("MURK_KEY_FILE") };
824 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
825
826 let mut tampered: types::Vault =
828 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
829 tampered.secrets.get_mut("KEY1").unwrap().shared =
830 encrypt_value(b"tampered", &[recipient]).unwrap();
831 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
832
833 let result = load_vault(path.to_str().unwrap());
835 unsafe { std::env::remove_var("MURK_KEY") };
836
837 let err = result.err().expect("expected MAC validation to fail");
838 assert!(
839 err.contains("integrity check failed"),
840 "expected integrity check failure, got: {err}"
841 );
842
843 fs::remove_dir_all(&dir).unwrap();
844 }
845
846 #[test]
847 fn load_vault_succeeds_with_valid_mac() {
848 let _lock = ENV_LOCK.lock().unwrap();
849
850 let (secret, pubkey) = generate_keypair();
851 let recipient = make_recipient(&pubkey);
852
853 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
854 let _ = fs::remove_dir_all(&dir);
855 fs::create_dir_all(&dir).unwrap();
856 let path = dir.join("test.murk");
857
858 let mut vault = types::Vault {
859 version: types::VAULT_VERSION.into(),
860 created: "2026-02-28T00:00:00Z".into(),
861 vault_name: ".murk".into(),
862 repo: String::new(),
863 recipients: vec![pubkey.clone()],
864 schema: BTreeMap::new(),
865 secrets: BTreeMap::new(),
866 meta: String::new(),
867 };
868 vault.secrets.insert(
869 "KEY1".into(),
870 types::SecretEntry {
871 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
872 scoped: BTreeMap::new(),
873 },
874 );
875
876 let mut recipients_map = HashMap::new();
877 recipients_map.insert(pubkey.clone(), "alice".into());
878 let original = types::Murk {
879 values: HashMap::from([("KEY1".into(), "val1".into())]),
880 recipients: recipients_map,
881 scoped: HashMap::new(),
882 };
883
884 unsafe { std::env::set_var("MURK_KEY", &secret) };
885 unsafe { std::env::remove_var("MURK_KEY_FILE") };
886 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
887
888 let result = load_vault(path.to_str().unwrap());
890 unsafe { std::env::remove_var("MURK_KEY") };
891
892 assert!(result.is_ok());
893 let (_, murk, _) = result.unwrap();
894 assert_eq!(murk.values["KEY1"], "val1");
895
896 fs::remove_dir_all(&dir).unwrap();
897 }
898
899 #[test]
900 fn load_vault_not_a_recipient() {
901 let _lock = ENV_LOCK.lock().unwrap();
902
903 let (secret, _pubkey) = generate_keypair();
904 let (other_secret, other_pubkey) = generate_keypair();
905 let other_recipient = make_recipient(&other_pubkey);
906
907 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
908 let _ = fs::remove_dir_all(&dir);
909 fs::create_dir_all(&dir).unwrap();
910 let path = dir.join("test.murk");
911
912 let mut vault = types::Vault {
914 version: types::VAULT_VERSION.into(),
915 created: "2026-02-28T00:00:00Z".into(),
916 vault_name: ".murk".into(),
917 repo: String::new(),
918 recipients: vec![other_pubkey.clone()],
919 schema: BTreeMap::new(),
920 secrets: BTreeMap::new(),
921 meta: String::new(),
922 };
923 vault.secrets.insert(
924 "KEY1".into(),
925 types::SecretEntry {
926 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
927 scoped: BTreeMap::new(),
928 },
929 );
930
931 let mut recipients_map = HashMap::new();
933 recipients_map.insert(other_pubkey.clone(), "other".into());
934 let original = types::Murk {
935 values: HashMap::from([("KEY1".into(), "val1".into())]),
936 recipients: recipients_map,
937 scoped: HashMap::new(),
938 };
939
940 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
941 unsafe { std::env::remove_var("MURK_KEY_FILE") };
942 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
943
944 unsafe { std::env::set_var("MURK_KEY", secret) };
946 let result = load_vault(path.to_str().unwrap());
947 unsafe { std::env::remove_var("MURK_KEY") };
948
949 let err = match result {
950 Err(e) => e,
951 Ok(_) => panic!("expected load_vault to fail for non-recipient"),
952 };
953 assert!(
954 err.contains("decryption failed"),
955 "expected decryption failure, got: {err}"
956 );
957
958 fs::remove_dir_all(&dir).unwrap();
959 }
960
961 #[test]
962 fn load_vault_zero_secrets() {
963 let _lock = ENV_LOCK.lock().unwrap();
964
965 let (secret, pubkey) = generate_keypair();
966
967 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
968 let _ = fs::remove_dir_all(&dir);
969 fs::create_dir_all(&dir).unwrap();
970 let path = dir.join("test.murk");
971
972 let mut vault = types::Vault {
974 version: types::VAULT_VERSION.into(),
975 created: "2026-02-28T00:00:00Z".into(),
976 vault_name: ".murk".into(),
977 repo: String::new(),
978 recipients: vec![pubkey.clone()],
979 schema: BTreeMap::new(),
980 secrets: BTreeMap::new(),
981 meta: String::new(),
982 };
983
984 let mut recipients_map = HashMap::new();
985 recipients_map.insert(pubkey.clone(), "alice".into());
986 let original = types::Murk {
987 values: HashMap::new(),
988 recipients: recipients_map,
989 scoped: HashMap::new(),
990 };
991
992 unsafe { std::env::set_var("MURK_KEY", &secret) };
993 unsafe { std::env::remove_var("MURK_KEY_FILE") };
994 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
995
996 let result = load_vault(path.to_str().unwrap());
997 unsafe { std::env::remove_var("MURK_KEY") };
998
999 assert!(result.is_ok());
1000 let (_, murk, _) = result.unwrap();
1001 assert!(murk.values.is_empty());
1002 assert!(murk.scoped.is_empty());
1003
1004 fs::remove_dir_all(&dir).unwrap();
1005 }
1006
1007 #[test]
1008 fn load_vault_stripped_meta_with_secrets_fails() {
1009 let _lock = ENV_LOCK.lock().unwrap();
1010
1011 let (secret, pubkey) = generate_keypair();
1012 let recipient = make_recipient(&pubkey);
1013
1014 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
1015 let _ = fs::remove_dir_all(&dir);
1016 fs::create_dir_all(&dir).unwrap();
1017 let path = dir.join("test.murk");
1018
1019 let mut vault = types::Vault {
1021 version: types::VAULT_VERSION.into(),
1022 created: "2026-02-28T00:00:00Z".into(),
1023 vault_name: ".murk".into(),
1024 repo: String::new(),
1025 recipients: vec![pubkey.clone()],
1026 schema: BTreeMap::new(),
1027 secrets: BTreeMap::new(),
1028 meta: String::new(),
1029 };
1030 vault.secrets.insert(
1031 "KEY1".into(),
1032 types::SecretEntry {
1033 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1034 scoped: BTreeMap::new(),
1035 },
1036 );
1037
1038 let mut recipients_map = HashMap::new();
1039 recipients_map.insert(pubkey.clone(), "alice".into());
1040 let original = types::Murk {
1041 values: HashMap::from([("KEY1".into(), "val1".into())]),
1042 recipients: recipients_map,
1043 scoped: HashMap::new(),
1044 };
1045
1046 unsafe { std::env::set_var("MURK_KEY", &secret) };
1047 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1048 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1049
1050 let mut tampered: types::Vault =
1052 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1053 tampered.meta = String::new();
1054 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1055
1056 let result = load_vault(path.to_str().unwrap());
1058 unsafe { std::env::remove_var("MURK_KEY") };
1059
1060 let err = result.err().expect("expected MAC validation to fail");
1061 assert!(
1062 err.contains("integrity check failed"),
1063 "expected integrity check failure, got: {err}"
1064 );
1065
1066 fs::remove_dir_all(&dir).unwrap();
1067 }
1068
1069 #[test]
1070 fn load_vault_empty_mac_with_secrets_fails() {
1071 let _lock = ENV_LOCK.lock().unwrap();
1072
1073 let (secret, pubkey) = generate_keypair();
1074 let recipient = make_recipient(&pubkey);
1075
1076 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
1077 let _ = fs::remove_dir_all(&dir);
1078 fs::create_dir_all(&dir).unwrap();
1079 let path = dir.join("test.murk");
1080
1081 let mut vault = types::Vault {
1083 version: types::VAULT_VERSION.into(),
1084 created: "2026-02-28T00:00:00Z".into(),
1085 vault_name: ".murk".into(),
1086 repo: String::new(),
1087 recipients: vec![pubkey.clone()],
1088 schema: BTreeMap::new(),
1089 secrets: BTreeMap::new(),
1090 meta: String::new(),
1091 };
1092 vault.secrets.insert(
1093 "KEY1".into(),
1094 types::SecretEntry {
1095 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1096 scoped: BTreeMap::new(),
1097 },
1098 );
1099
1100 let mut recipients_map = HashMap::new();
1102 recipients_map.insert(pubkey.clone(), "alice".into());
1103 let meta = types::Meta {
1104 recipients: recipients_map,
1105 mac: String::new(),
1106 };
1107 let meta_json = serde_json::to_vec(&meta).unwrap();
1108 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1109
1110 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1112
1113 unsafe { std::env::set_var("MURK_KEY", &secret) };
1115 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1116 let result = load_vault(path.to_str().unwrap());
1117 unsafe { std::env::remove_var("MURK_KEY") };
1118
1119 let err = result.err().expect("expected MAC validation to fail");
1120 assert!(
1121 err.contains("integrity check failed"),
1122 "expected integrity check failure, got: {err}"
1123 );
1124
1125 fs::remove_dir_all(&dir).unwrap();
1126 }
1127
1128 #[test]
1129 fn compute_mac_changes_with_scoped_entries() {
1130 let mut vault = types::Vault {
1131 version: types::VAULT_VERSION.into(),
1132 created: "2026-02-28T00:00:00Z".into(),
1133 vault_name: ".murk".into(),
1134 repo: String::new(),
1135 recipients: vec!["age1abc".into()],
1136 schema: BTreeMap::new(),
1137 secrets: BTreeMap::new(),
1138 meta: String::new(),
1139 };
1140
1141 vault.secrets.insert(
1142 "KEY".into(),
1143 types::SecretEntry {
1144 shared: "ciphertext".into(),
1145 scoped: BTreeMap::new(),
1146 },
1147 );
1148
1149 let mac_no_scoped = compute_mac(&vault);
1150
1151 vault
1152 .secrets
1153 .get_mut("KEY")
1154 .unwrap()
1155 .scoped
1156 .insert("age1bob".into(), "scoped-ct".into());
1157
1158 let mac_with_scoped = compute_mac(&vault);
1159 assert_ne!(mac_no_scoped, mac_with_scoped);
1160 }
1161
1162 #[test]
1163 fn verify_mac_accepts_v1_prefix() {
1164 let vault = types::Vault {
1165 version: types::VAULT_VERSION.into(),
1166 created: "2026-02-28T00:00:00Z".into(),
1167 vault_name: ".murk".into(),
1168 repo: String::new(),
1169 recipients: vec!["age1abc".into()],
1170 schema: BTreeMap::new(),
1171 secrets: BTreeMap::new(),
1172 meta: String::new(),
1173 };
1174
1175 let v1_mac = compute_mac_v1(&vault);
1176 let v2_mac = compute_mac_v2(&vault);
1177 assert!(verify_mac(&vault, &v1_mac));
1178 assert!(verify_mac(&vault, &v2_mac));
1179 assert!(!verify_mac(&vault, "sha256:bogus"));
1180 assert!(!verify_mac(&vault, "unknown:prefix"));
1181 }
1182
1183 #[test]
1184 fn valid_key_names() {
1185 assert!(is_valid_key_name("DATABASE_URL"));
1186 assert!(is_valid_key_name("_PRIVATE"));
1187 assert!(is_valid_key_name("A"));
1188 assert!(is_valid_key_name("key123"));
1189 }
1190
1191 #[test]
1192 fn invalid_key_names() {
1193 assert!(!is_valid_key_name(""));
1194 assert!(!is_valid_key_name("123_START"));
1195 assert!(!is_valid_key_name("KEY-NAME"));
1196 assert!(!is_valid_key_name("KEY NAME"));
1197 assert!(!is_valid_key_name("FOO$(bar)"));
1198 assert!(!is_valid_key_name("KEY=VAL"));
1199 }
1200
1201 #[test]
1202 fn now_utc_format() {
1203 let ts = now_utc();
1204 assert!(ts.ends_with('Z'));
1205 assert_eq!(ts.len(), 20);
1206 assert_eq!(&ts[4..5], "-");
1207 assert_eq!(&ts[7..8], "-");
1208 assert_eq!(&ts[10..11], "T");
1209 }
1210}