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