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 info;
26pub mod init;
27pub mod merge;
28pub mod recipients;
29pub mod recovery;
30pub mod secrets;
31pub mod types;
32pub mod vault;
33
34#[cfg(test)]
36pub mod testutil;
37
38pub use env::{
40 EnvrcStatus, dotenv_has_murk_key, parse_env, read_key_from_dotenv, resolve_key,
41 warn_env_permissions, write_envrc, write_key_to_dotenv,
42};
43pub use export::{
44 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets,
45 parse_and_decrypt_values, resolve_secrets,
46};
47pub use git::{MergeDriverSetupStep, setup_merge_driver};
48pub use info::{InfoEntry, VaultInfo, vault_info};
49pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
50pub use merge::{MergeDriverOutput, run_merge_driver};
51pub use recipients::{
52 RecipientEntry, RevokeResult, authorize_recipient, list_recipients, revoke_recipient,
53};
54pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
55
56use std::collections::{BTreeMap, HashMap};
57use std::path::Path;
58
59use age::secrecy::ExposeSecret;
60use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
61
62pub(crate) fn decrypt_meta(
64 vault: &types::Vault,
65 identity: &age::x25519::Identity,
66) -> Option<types::Meta> {
67 if vault.meta.is_empty() {
68 return None;
69 }
70 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
71 serde_json::from_slice(&plaintext).ok()
72}
73
74pub(crate) fn parse_recipients(pubkeys: &[String]) -> Result<Vec<age::x25519::Recipient>, String> {
76 pubkeys
77 .iter()
78 .map(|pk| crypto::parse_recipient(pk).map_err(|e| e.to_string()))
79 .collect()
80}
81
82pub fn encrypt_value(
84 plaintext: &[u8],
85 recipients: &[age::x25519::Recipient],
86) -> Result<String, String> {
87 let ciphertext = crypto::encrypt(plaintext, recipients).map_err(|e| e.to_string())?;
88 Ok(BASE64.encode(&ciphertext))
89}
90
91pub fn decrypt_value(encoded: &str, identity: &age::x25519::Identity) -> Result<Vec<u8>, String> {
93 let ciphertext = BASE64
94 .decode(encoded)
95 .map_err(|e| format!("invalid base64: {e}"))?;
96 crypto::decrypt(&ciphertext, identity).map_err(|e| e.to_string())
97}
98
99pub fn load_vault(
103 vault_path: &str,
104) -> Result<(types::Vault, types::Murk, age::x25519::Identity), String> {
105 let path = Path::new(vault_path);
106 let secret_key = resolve_key()?;
107
108 let identity =
109 crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
110 format!("invalid MURK_KEY (expected AGE-SECRET-KEY-1...): {e}. Run `murk restore` to recover from your 24-word phrase")
111 })?;
112
113 let vault = vault::read(path).map_err(|e| e.to_string())?;
114 let pubkey = identity.to_public().to_string();
115
116 let mut values = HashMap::new();
118 for (key, entry) in &vault.secrets {
119 let plaintext = decrypt_value(&entry.shared, &identity).map_err(|_| {
120 "decryption failed — your MURK_KEY may not be a recipient of this vault. Check with `murk recipients`".to_string()
121 })?;
122 let value = String::from_utf8(plaintext)
123 .map_err(|e| format!("invalid UTF-8 in secret {key}: {e}"))?;
124 values.insert(key.clone(), value);
125 }
126
127 let mut scoped = HashMap::new();
129 for (key, entry) in &vault.secrets {
130 if let Some(encoded) = entry.scoped.get(&pubkey) {
131 if let Ok(plaintext) = decrypt_value(encoded, &identity) {
132 if let Ok(value) = String::from_utf8(plaintext) {
133 scoped
134 .entry(key.clone())
135 .or_insert_with(HashMap::new)
136 .insert(pubkey.clone(), value);
137 }
138 }
139 }
140 }
141
142 let recipients = if vault.secrets.is_empty() {
144 decrypt_meta(&vault, &identity)
146 .map(|m| m.recipients)
147 .unwrap_or_default()
148 } else {
149 let meta = decrypt_meta(&vault, &identity).ok_or(
151 "integrity check failed: vault has secrets but no meta — vault may have been tampered with"
152 )?;
153 if meta.mac.is_empty() {
154 return Err("integrity check failed: vault has secrets but MAC is empty — vault may have been tampered with".into());
155 }
156 let expected = compute_mac(&vault);
157 if meta.mac != expected {
158 return Err(format!(
159 "integrity check failed: vault may have been tampered with (expected {}, got {})",
160 meta.mac, expected
161 ));
162 }
163 meta.recipients
164 };
165
166 let murk = types::Murk {
167 values,
168 recipients,
169 scoped,
170 };
171
172 Ok((vault, murk, identity))
173}
174
175pub fn save_vault(
178 vault_path: &str,
179 vault: &mut types::Vault,
180 original: &types::Murk,
181 current: &types::Murk,
182) -> Result<(), String> {
183 let recipients = parse_recipients(&vault.recipients)?;
184
185 let recipients_changed = {
187 let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
188 let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
189 current_pks.sort_unstable();
190 original_pks.sort_unstable();
191 current_pks != original_pks
192 };
193
194 let mut new_secrets = BTreeMap::new();
195
196 for (key, value) in ¤t.values {
197 let shared = if !recipients_changed && original.values.get(key) == Some(value) {
199 if let Some(existing) = vault.secrets.get(key) {
201 existing.shared.clone()
202 } else {
203 encrypt_value(value.as_bytes(), &recipients)?
204 }
205 } else {
206 encrypt_value(value.as_bytes(), &recipients)?
207 };
208
209 let mut scoped = vault
211 .secrets
212 .get(key)
213 .map(|e| e.scoped.clone())
214 .unwrap_or_default();
215
216 if let Some(key_scoped) = current.scoped.get(key) {
218 for (pk, val) in key_scoped {
219 let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
220 if original_val == Some(val) {
221 } else {
223 let recipient = crypto::parse_recipient(pk).map_err(|e| e.to_string())?;
225 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
226 }
227 }
228 }
229
230 if let Some(orig_key_scoped) = original.scoped.get(key) {
232 for pk in orig_key_scoped.keys() {
233 let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
234 if !still_present {
235 scoped.remove(pk);
236 }
237 }
238 }
239
240 new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
241 }
242
243 vault.secrets = new_secrets;
244
245 let mac = compute_mac(vault);
247 let meta = types::Meta {
248 recipients: current.recipients.clone(),
249 mac,
250 };
251 let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
252 vault.meta = encrypt_value(&meta_json, &recipients)?;
253
254 vault::write(Path::new(vault_path), vault).map_err(|e| e.to_string())
255}
256
257pub(crate) fn compute_mac(vault: &types::Vault) -> String {
260 use sha2::{Digest, Sha256};
261
262 let mut hasher = Sha256::new();
263
264 for key in vault.secrets.keys() {
266 hasher.update(key.as_bytes());
267 hasher.update(b"\x00");
268 }
269
270 for entry in vault.secrets.values() {
272 hasher.update(entry.shared.as_bytes());
273 hasher.update(b"\x00");
274 }
275
276 let mut pks = vault.recipients.clone();
278 pks.sort();
279 for pk in &pks {
280 hasher.update(pk.as_bytes());
281 hasher.update(b"\x00");
282 }
283
284 let digest = hasher.finalize();
285 format!(
286 "sha256:{}",
287 digest.iter().fold(String::new(), |mut s, b| {
288 use std::fmt::Write;
289 let _ = write!(s, "{b:02x}");
290 s
291 })
292 )
293}
294
295pub(crate) fn now_utc() -> String {
297 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::testutil::*;
304 use std::collections::BTreeMap;
305 use std::fs;
306
307 #[test]
308 fn encrypt_decrypt_value_roundtrip() {
309 let (secret, pubkey) = generate_keypair();
310 let recipient = make_recipient(&pubkey);
311 let identity = make_identity(&secret);
312
313 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
314 let decrypted = decrypt_value(&encoded, &identity).unwrap();
315 assert_eq!(decrypted, b"hello world");
316 }
317
318 #[test]
319 fn decrypt_value_invalid_base64() {
320 let (secret, _) = generate_keypair();
321 let identity = make_identity(&secret);
322
323 let result = decrypt_value("not!valid!base64!!!", &identity);
324 assert!(result.is_err());
325 assert!(result.unwrap_err().contains("invalid base64"));
326 }
327
328 #[test]
329 fn encrypt_value_multiple_recipients() {
330 let (secret_a, pubkey_a) = generate_keypair();
331 let (secret_b, pubkey_b) = generate_keypair();
332
333 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
334 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
335
336 let id_a = make_identity(&secret_a);
338 let id_b = make_identity(&secret_b);
339 assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
340 assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
341 }
342
343 #[test]
344 fn decrypt_value_wrong_key_fails() {
345 let (_, pubkey) = generate_keypair();
346 let (wrong_secret, _) = generate_keypair();
347
348 let recipient = make_recipient(&pubkey);
349 let wrong_identity = make_identity(&wrong_secret);
350
351 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
352 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
353 }
354
355 #[test]
356 fn compute_mac_deterministic() {
357 let vault = types::Vault {
358 version: types::VAULT_VERSION.into(),
359 created: "2026-02-28T00:00:00Z".into(),
360 vault_name: ".murk".into(),
361 repo: String::new(),
362 recipients: vec!["age1abc".into()],
363 schema: BTreeMap::new(),
364 secrets: BTreeMap::new(),
365 meta: String::new(),
366 };
367
368 let mac1 = compute_mac(&vault);
369 let mac2 = compute_mac(&vault);
370 assert_eq!(mac1, mac2);
371 assert!(mac1.starts_with("sha256:"));
372 }
373
374 #[test]
375 fn compute_mac_changes_with_different_secrets() {
376 let mut vault = types::Vault {
377 version: types::VAULT_VERSION.into(),
378 created: "2026-02-28T00:00:00Z".into(),
379 vault_name: ".murk".into(),
380 repo: String::new(),
381 recipients: vec!["age1abc".into()],
382 schema: BTreeMap::new(),
383 secrets: BTreeMap::new(),
384 meta: String::new(),
385 };
386
387 let mac_empty = compute_mac(&vault);
388
389 vault.secrets.insert(
390 "KEY".into(),
391 types::SecretEntry {
392 shared: "ciphertext".into(),
393 scoped: BTreeMap::new(),
394 },
395 );
396
397 let mac_with_secret = compute_mac(&vault);
398 assert_ne!(mac_empty, mac_with_secret);
399 }
400
401 #[test]
402 fn compute_mac_changes_with_different_recipients() {
403 let mut vault = types::Vault {
404 version: types::VAULT_VERSION.into(),
405 created: "2026-02-28T00:00:00Z".into(),
406 vault_name: ".murk".into(),
407 repo: String::new(),
408 recipients: vec!["age1abc".into()],
409 schema: BTreeMap::new(),
410 secrets: BTreeMap::new(),
411 meta: String::new(),
412 };
413
414 let mac1 = compute_mac(&vault);
415 vault.recipients.push("age1xyz".into());
416 let mac2 = compute_mac(&vault);
417 assert_ne!(mac1, mac2);
418 }
419
420 #[test]
421 fn save_vault_preserves_unchanged_ciphertext() {
422 let (secret, pubkey) = generate_keypair();
423 let recipient = make_recipient(&pubkey);
424 let identity = make_identity(&secret);
425
426 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
427 fs::create_dir_all(&dir).unwrap();
428 let path = dir.join("test.murk");
429
430 let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
431 let mut vault = types::Vault {
432 version: types::VAULT_VERSION.into(),
433 created: "2026-02-28T00:00:00Z".into(),
434 vault_name: ".murk".into(),
435 repo: String::new(),
436 recipients: vec![pubkey.clone()],
437 schema: BTreeMap::new(),
438 secrets: BTreeMap::new(),
439 meta: String::new(),
440 };
441 vault.secrets.insert(
442 "KEY1".into(),
443 types::SecretEntry {
444 shared: shared.clone(),
445 scoped: BTreeMap::new(),
446 },
447 );
448
449 let mut recipients_map = HashMap::new();
450 recipients_map.insert(pubkey.clone(), "alice".into());
451 let original = types::Murk {
452 values: HashMap::from([("KEY1".into(), "original".into())]),
453 recipients: recipients_map.clone(),
454 scoped: HashMap::new(),
455 };
456
457 let current = original.clone();
458 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
459
460 assert_eq!(vault.secrets["KEY1"].shared, shared);
461
462 let mut changed = current.clone();
463 changed.values.insert("KEY1".into(), "modified".into());
464 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
465
466 assert_ne!(vault.secrets["KEY1"].shared, shared);
467
468 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
469 assert_eq!(decrypted, b"modified");
470
471 fs::remove_dir_all(&dir).unwrap();
472 }
473
474 #[test]
475 fn save_vault_adds_new_secret() {
476 let (_, pubkey) = generate_keypair();
477 let recipient = make_recipient(&pubkey);
478
479 let dir = std::env::temp_dir().join("murk_test_save_add");
480 fs::create_dir_all(&dir).unwrap();
481 let path = dir.join("test.murk");
482
483 let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
484 let mut vault = types::Vault {
485 version: types::VAULT_VERSION.into(),
486 created: "2026-02-28T00:00:00Z".into(),
487 vault_name: ".murk".into(),
488 repo: String::new(),
489 recipients: vec![pubkey.clone()],
490 schema: BTreeMap::new(),
491 secrets: BTreeMap::new(),
492 meta: String::new(),
493 };
494 vault.secrets.insert(
495 "KEY1".into(),
496 types::SecretEntry {
497 shared,
498 scoped: BTreeMap::new(),
499 },
500 );
501
502 let mut recipients_map = HashMap::new();
503 recipients_map.insert(pubkey.clone(), "alice".into());
504 let original = types::Murk {
505 values: HashMap::from([("KEY1".into(), "val1".into())]),
506 recipients: recipients_map.clone(),
507 scoped: HashMap::new(),
508 };
509
510 let mut current = original.clone();
511 current.values.insert("KEY2".into(), "val2".into());
512
513 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
514
515 assert!(vault.secrets.contains_key("KEY1"));
516 assert!(vault.secrets.contains_key("KEY2"));
517
518 fs::remove_dir_all(&dir).unwrap();
519 }
520
521 #[test]
522 fn save_vault_removes_deleted_secret() {
523 let (_, pubkey) = generate_keypair();
524 let recipient = make_recipient(&pubkey);
525
526 let dir = std::env::temp_dir().join("murk_test_save_remove");
527 fs::create_dir_all(&dir).unwrap();
528 let path = dir.join("test.murk");
529
530 let mut vault = types::Vault {
531 version: types::VAULT_VERSION.into(),
532 created: "2026-02-28T00:00:00Z".into(),
533 vault_name: ".murk".into(),
534 repo: String::new(),
535 recipients: vec![pubkey.clone()],
536 schema: BTreeMap::new(),
537 secrets: BTreeMap::new(),
538 meta: String::new(),
539 };
540 vault.secrets.insert(
541 "KEY1".into(),
542 types::SecretEntry {
543 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
544 scoped: BTreeMap::new(),
545 },
546 );
547 vault.secrets.insert(
548 "KEY2".into(),
549 types::SecretEntry {
550 shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
551 scoped: BTreeMap::new(),
552 },
553 );
554
555 let mut recipients_map = HashMap::new();
556 recipients_map.insert(pubkey.clone(), "alice".into());
557 let original = types::Murk {
558 values: HashMap::from([
559 ("KEY1".into(), "val1".into()),
560 ("KEY2".into(), "val2".into()),
561 ]),
562 recipients: recipients_map.clone(),
563 scoped: HashMap::new(),
564 };
565
566 let mut current = original.clone();
567 current.values.remove("KEY2");
568
569 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
570
571 assert!(vault.secrets.contains_key("KEY1"));
572 assert!(!vault.secrets.contains_key("KEY2"));
573
574 fs::remove_dir_all(&dir).unwrap();
575 }
576
577 #[test]
578 fn save_vault_reencrypts_all_on_recipient_change() {
579 let (secret1, pubkey1) = generate_keypair();
580 let (_, pubkey2) = generate_keypair();
581 let recipient1 = make_recipient(&pubkey1);
582
583 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
584 fs::create_dir_all(&dir).unwrap();
585 let path = dir.join("test.murk");
586
587 let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
588 let mut vault = types::Vault {
589 version: types::VAULT_VERSION.into(),
590 created: "2026-02-28T00:00:00Z".into(),
591 vault_name: ".murk".into(),
592 repo: String::new(),
593 recipients: vec![pubkey1.clone(), pubkey2.clone()],
594 schema: BTreeMap::new(),
595 secrets: BTreeMap::new(),
596 meta: String::new(),
597 };
598 vault.secrets.insert(
599 "KEY1".into(),
600 types::SecretEntry {
601 shared: shared.clone(),
602 scoped: BTreeMap::new(),
603 },
604 );
605
606 let mut recipients_map = HashMap::new();
607 recipients_map.insert(pubkey1.clone(), "alice".into());
608 let original = types::Murk {
609 values: HashMap::from([("KEY1".into(), "val1".into())]),
610 recipients: recipients_map,
611 scoped: HashMap::new(),
612 };
613
614 let mut current_recipients = HashMap::new();
615 current_recipients.insert(pubkey1.clone(), "alice".into());
616 current_recipients.insert(pubkey2.clone(), "bob".into());
617 let current = types::Murk {
618 values: HashMap::from([("KEY1".into(), "val1".into())]),
619 recipients: current_recipients,
620 scoped: HashMap::new(),
621 };
622
623 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
624
625 assert_ne!(vault.secrets["KEY1"].shared, shared);
626
627 let identity1 = make_identity(&secret1);
628 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
629 assert_eq!(decrypted, b"val1");
630
631 fs::remove_dir_all(&dir).unwrap();
632 }
633
634 #[test]
635 fn save_vault_scoped_entry_lifecycle() {
636 let (secret, pubkey) = generate_keypair();
637 let recipient = make_recipient(&pubkey);
638 let identity = make_identity(&secret);
639
640 let dir = std::env::temp_dir().join("murk_test_save_scoped");
641 fs::create_dir_all(&dir).unwrap();
642 let path = dir.join("test.murk");
643
644 let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
645 let mut vault = types::Vault {
646 version: types::VAULT_VERSION.into(),
647 created: "2026-02-28T00:00:00Z".into(),
648 vault_name: ".murk".into(),
649 repo: String::new(),
650 recipients: vec![pubkey.clone()],
651 schema: BTreeMap::new(),
652 secrets: BTreeMap::new(),
653 meta: String::new(),
654 };
655 vault.secrets.insert(
656 "KEY1".into(),
657 types::SecretEntry {
658 shared,
659 scoped: BTreeMap::new(),
660 },
661 );
662
663 let mut recipients_map = HashMap::new();
664 recipients_map.insert(pubkey.clone(), "alice".into());
665 let original = types::Murk {
666 values: HashMap::from([("KEY1".into(), "shared_val".into())]),
667 recipients: recipients_map.clone(),
668 scoped: HashMap::new(),
669 };
670
671 let mut current = original.clone();
673 let mut key_scoped = HashMap::new();
674 key_scoped.insert(pubkey.clone(), "my_override".into());
675 current.scoped.insert("KEY1".into(), key_scoped);
676
677 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
678
679 assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
680 let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
681 assert_eq!(scoped_val, b"my_override");
682
683 let original_with_scoped = current.clone();
685 let mut current_no_scoped = original_with_scoped.clone();
686 current_no_scoped.scoped.remove("KEY1");
687
688 save_vault(
689 path.to_str().unwrap(),
690 &mut vault,
691 &original_with_scoped,
692 ¤t_no_scoped,
693 )
694 .unwrap();
695
696 assert!(vault.secrets["KEY1"].scoped.is_empty());
697
698 fs::remove_dir_all(&dir).unwrap();
699 }
700
701 #[test]
702 fn load_vault_validates_mac() {
703 let (secret, pubkey) = generate_keypair();
704 let recipient = make_recipient(&pubkey);
705 let identity = make_identity(&secret);
706
707 let dir = std::env::temp_dir().join("murk_test_load_mac");
708 fs::create_dir_all(&dir).unwrap();
709 let path = dir.join("test.murk");
710
711 let mut vault = types::Vault {
713 version: types::VAULT_VERSION.into(),
714 created: "2026-02-28T00:00:00Z".into(),
715 vault_name: ".murk".into(),
716 repo: String::new(),
717 recipients: vec![pubkey.clone()],
718 schema: BTreeMap::new(),
719 secrets: BTreeMap::new(),
720 meta: String::new(),
721 };
722 vault.secrets.insert(
723 "KEY1".into(),
724 types::SecretEntry {
725 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
726 scoped: BTreeMap::new(),
727 },
728 );
729
730 let mut recipients_map = HashMap::new();
731 recipients_map.insert(pubkey.clone(), "alice".into());
732 let original = types::Murk {
733 values: HashMap::from([("KEY1".into(), "val1".into())]),
734 recipients: recipients_map,
735 scoped: HashMap::new(),
736 };
737
738 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
739
740 let mut tampered: types::Vault =
742 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
743 tampered.secrets.get_mut("KEY1").unwrap().shared =
744 encrypt_value(b"tampered", &[recipient]).unwrap();
745 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
746
747 unsafe { std::env::set_var("MURK_KEY", secret) };
749 unsafe { std::env::remove_var("MURK_KEY_FILE") };
750 let result = load_vault(path.to_str().unwrap());
751 unsafe { std::env::remove_var("MURK_KEY") };
752
753 let err = result.err().expect("expected MAC validation to fail");
754 assert!(
755 err.contains("integrity check failed"),
756 "expected integrity check failure, got: {err}"
757 );
758
759 fs::remove_dir_all(&dir).unwrap();
760 }
761
762 #[test]
763 fn load_vault_succeeds_with_valid_mac() {
764 let (secret, pubkey) = generate_keypair();
765 let recipient = make_recipient(&pubkey);
766
767 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
768 fs::create_dir_all(&dir).unwrap();
769 let path = dir.join("test.murk");
770
771 let mut vault = types::Vault {
772 version: types::VAULT_VERSION.into(),
773 created: "2026-02-28T00:00:00Z".into(),
774 vault_name: ".murk".into(),
775 repo: String::new(),
776 recipients: vec![pubkey.clone()],
777 schema: BTreeMap::new(),
778 secrets: BTreeMap::new(),
779 meta: String::new(),
780 };
781 vault.secrets.insert(
782 "KEY1".into(),
783 types::SecretEntry {
784 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
785 scoped: BTreeMap::new(),
786 },
787 );
788
789 let mut recipients_map = HashMap::new();
790 recipients_map.insert(pubkey.clone(), "alice".into());
791 let original = types::Murk {
792 values: HashMap::from([("KEY1".into(), "val1".into())]),
793 recipients: recipients_map,
794 scoped: HashMap::new(),
795 };
796
797 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
798
799 unsafe { std::env::set_var("MURK_KEY", secret) };
801 unsafe { std::env::remove_var("MURK_KEY_FILE") };
802 let result = load_vault(path.to_str().unwrap());
803 unsafe { std::env::remove_var("MURK_KEY") };
804
805 assert!(result.is_ok());
806 let (_, murk, _) = result.unwrap();
807 assert_eq!(murk.values["KEY1"], "val1");
808
809 fs::remove_dir_all(&dir).unwrap();
810 }
811
812 #[test]
813 fn load_vault_not_a_recipient() {
814 let (secret, pubkey) = generate_keypair();
815 let (other_secret, other_pubkey) = generate_keypair();
816 let other_recipient = make_recipient(&other_pubkey);
817
818 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
819 let _ = fs::remove_dir_all(&dir);
820 fs::create_dir_all(&dir).unwrap();
821 let path = dir.join("test.murk");
822
823 let mut vault = types::Vault {
825 version: types::VAULT_VERSION.into(),
826 created: "2026-02-28T00:00:00Z".into(),
827 vault_name: ".murk".into(),
828 repo: String::new(),
829 recipients: vec![other_pubkey.clone()],
830 schema: BTreeMap::new(),
831 secrets: BTreeMap::new(),
832 meta: String::new(),
833 };
834 vault.secrets.insert(
835 "KEY1".into(),
836 types::SecretEntry {
837 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
838 scoped: BTreeMap::new(),
839 },
840 );
841
842 let mut recipients_map = HashMap::new();
844 recipients_map.insert(other_pubkey.clone(), "other".into());
845 let original = types::Murk {
846 values: HashMap::from([("KEY1".into(), "val1".into())]),
847 recipients: recipients_map,
848 scoped: HashMap::new(),
849 };
850
851 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
852 unsafe { std::env::remove_var("MURK_KEY_FILE") };
853 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
854
855 unsafe { std::env::set_var("MURK_KEY", secret) };
857 let result = load_vault(path.to_str().unwrap());
858 unsafe { std::env::remove_var("MURK_KEY") };
859
860 let err = match result {
861 Err(e) => e,
862 Ok(_) => panic!("expected load_vault to fail for non-recipient"),
863 };
864 assert!(
865 err.contains("decryption failed"),
866 "expected decryption failure, got: {err}"
867 );
868
869 fs::remove_dir_all(&dir).unwrap();
870 }
871
872 #[test]
873 fn load_vault_zero_secrets() {
874 let (secret, pubkey) = generate_keypair();
875
876 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
877 let _ = fs::remove_dir_all(&dir);
878 fs::create_dir_all(&dir).unwrap();
879 let path = dir.join("test.murk");
880
881 let mut vault = types::Vault {
883 version: types::VAULT_VERSION.into(),
884 created: "2026-02-28T00:00:00Z".into(),
885 vault_name: ".murk".into(),
886 repo: String::new(),
887 recipients: vec![pubkey.clone()],
888 schema: BTreeMap::new(),
889 secrets: BTreeMap::new(),
890 meta: String::new(),
891 };
892
893 let mut recipients_map = HashMap::new();
894 recipients_map.insert(pubkey.clone(), "alice".into());
895 let original = types::Murk {
896 values: HashMap::new(),
897 recipients: recipients_map,
898 scoped: HashMap::new(),
899 };
900
901 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
902
903 unsafe { std::env::set_var("MURK_KEY", secret) };
904 unsafe { std::env::remove_var("MURK_KEY_FILE") };
905 let result = load_vault(path.to_str().unwrap());
906 unsafe { std::env::remove_var("MURK_KEY") };
907
908 assert!(result.is_ok());
909 let (_, murk, _) = result.unwrap();
910 assert!(murk.values.is_empty());
911 assert!(murk.scoped.is_empty());
912
913 fs::remove_dir_all(&dir).unwrap();
914 }
915
916 #[test]
917 fn load_vault_stripped_meta_with_secrets_fails() {
918 let (secret, pubkey) = generate_keypair();
919 let recipient = make_recipient(&pubkey);
920
921 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
922 let _ = fs::remove_dir_all(&dir);
923 fs::create_dir_all(&dir).unwrap();
924 let path = dir.join("test.murk");
925
926 let mut vault = types::Vault {
928 version: types::VAULT_VERSION.into(),
929 created: "2026-02-28T00:00:00Z".into(),
930 vault_name: ".murk".into(),
931 repo: String::new(),
932 recipients: vec![pubkey.clone()],
933 schema: BTreeMap::new(),
934 secrets: BTreeMap::new(),
935 meta: String::new(),
936 };
937 vault.secrets.insert(
938 "KEY1".into(),
939 types::SecretEntry {
940 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
941 scoped: BTreeMap::new(),
942 },
943 );
944
945 let mut recipients_map = HashMap::new();
946 recipients_map.insert(pubkey.clone(), "alice".into());
947 let original = types::Murk {
948 values: HashMap::from([("KEY1".into(), "val1".into())]),
949 recipients: recipients_map,
950 scoped: HashMap::new(),
951 };
952
953 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
954
955 let mut tampered: types::Vault =
957 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
958 tampered.meta = String::new();
959 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
960
961 unsafe { std::env::set_var("MURK_KEY", &secret) };
963 unsafe { std::env::remove_var("MURK_KEY_FILE") };
964 let result = load_vault(path.to_str().unwrap());
965 unsafe { std::env::remove_var("MURK_KEY") };
966
967 let err = result.err().expect("expected MAC validation to fail");
968 assert!(
969 err.contains("integrity check failed"),
970 "expected integrity check failure, got: {err}"
971 );
972
973 fs::remove_dir_all(&dir).unwrap();
974 }
975
976 #[test]
977 fn load_vault_empty_mac_with_secrets_fails() {
978 let (secret, pubkey) = generate_keypair();
979 let recipient = make_recipient(&pubkey);
980
981 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
982 let _ = fs::remove_dir_all(&dir);
983 fs::create_dir_all(&dir).unwrap();
984 let path = dir.join("test.murk");
985
986 let mut vault = types::Vault {
988 version: types::VAULT_VERSION.into(),
989 created: "2026-02-28T00:00:00Z".into(),
990 vault_name: ".murk".into(),
991 repo: String::new(),
992 recipients: vec![pubkey.clone()],
993 schema: BTreeMap::new(),
994 secrets: BTreeMap::new(),
995 meta: String::new(),
996 };
997 vault.secrets.insert(
998 "KEY1".into(),
999 types::SecretEntry {
1000 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1001 scoped: BTreeMap::new(),
1002 },
1003 );
1004
1005 let mut recipients_map = HashMap::new();
1007 recipients_map.insert(pubkey.clone(), "alice".into());
1008 let meta = types::Meta {
1009 recipients: recipients_map,
1010 mac: String::new(),
1011 };
1012 let meta_json = serde_json::to_vec(&meta).unwrap();
1013 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1014
1015 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1017
1018 unsafe { std::env::set_var("MURK_KEY", &secret) };
1020 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1021 let result = load_vault(path.to_str().unwrap());
1022 unsafe { std::env::remove_var("MURK_KEY") };
1023
1024 let err = result.err().expect("expected MAC validation to fail");
1025 assert!(
1026 err.contains("integrity check failed"),
1027 "expected integrity check failure, got: {err}"
1028 );
1029
1030 fs::remove_dir_all(&dir).unwrap();
1031 }
1032
1033 #[test]
1034 fn now_utc_format() {
1035 let ts = now_utc();
1036 assert!(ts.ends_with('Z'));
1037 assert_eq!(ts.len(), 20);
1038 assert_eq!(&ts[4..5], "-");
1039 assert_eq!(&ts[7..8], "-");
1040 assert_eq!(&ts[10..11], "T");
1041 }
1042}