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