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