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