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