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