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 agent;
21pub(crate) mod codename;
22pub mod crypto;
23pub mod edit;
24pub(crate) mod env;
25pub mod error;
26pub(crate) mod export;
27pub(crate) mod git;
28pub mod github;
29pub(crate) mod grants;
30pub(crate) mod groups;
31pub mod hardening;
32pub(crate) mod info;
33pub(crate) mod init;
34pub(crate) mod merge;
35pub(crate) mod policy;
36pub(crate) mod recipients;
37pub mod recovery;
38pub mod scan;
39pub(crate) mod secrets;
40pub mod types;
41pub mod vault;
42
43#[cfg(feature = "python")]
44mod python;
45
46#[cfg(test)]
48pub mod testutil;
49
50pub use agent::{AgentPlan, AgentPlanKey, agent_plan, format_agent_plan_text};
52pub use env::{
53 EnvrcStatus, KeySource, agent_key_file_path, agent_keys_dir, dotenv_has_murk_key,
54 key_file_path, parse_env, resolve_key, resolve_key_for_vault, resolve_key_with_source,
55 warn_env_permissions, write_envrc, write_key_ref_to_dotenv, write_key_to_dotenv,
56 write_key_to_file,
57};
58pub use error::MurkError;
59pub use export::{
60 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
61 parse_and_decrypt_values, resolve_secrets,
62};
63pub use git::{MergeDriverSetupStep, setup_merge_driver};
64pub use github::{GitHubError, fetch_keys};
65pub use grants::{create_grant, parse_ttl, remove_grant, validate_grant_name};
66pub use groups::{
67 add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name,
68};
69pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info};
70pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
71pub use merge::{MergeDriverOutput, run_merge_driver};
72pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity};
73pub use recipients::{
74 RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
75 list_recipients, revoke_recipient, truncate_pubkey,
76};
77pub use secrets::{
78 EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
79 import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
80};
81
82use std::collections::{BTreeMap, BTreeSet, HashMap};
83use std::path::Path;
84
85pub fn is_valid_key_name(key: &str) -> bool {
88 !key.is_empty()
89 && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
90 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
91}
92
93use age::secrecy::ExposeSecret;
94use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
95use zeroize::Zeroizing;
96
97pub use crypto::{MurkIdentity, MurkRecipient};
99
100pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
102 if vault.meta.is_empty() {
103 return None;
104 }
105 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
106 serde_json::from_slice(&plaintext).ok()
107}
108
109pub(crate) fn parse_recipients(
111 pubkeys: &[String],
112) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
113 pubkeys
114 .iter()
115 .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
116 .collect()
117}
118
119pub fn encrypt_value(
121 plaintext: &[u8],
122 recipients: &[crypto::MurkRecipient],
123) -> Result<String, MurkError> {
124 let ciphertext = crypto::encrypt(plaintext, recipients)?;
125 Ok(BASE64.encode(&ciphertext))
126}
127
128pub fn decrypt_value(
132 encoded: &str,
133 identity: &crypto::MurkIdentity,
134) -> Result<Zeroizing<Vec<u8>>, MurkError> {
135 let ciphertext = BASE64.decode(encoded).map_err(|e| {
136 MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
137 })?;
138 Ok(crypto::decrypt(&ciphertext, identity)?)
139}
140
141pub(crate) fn plaintext_bytes_to_zeroizing_string(
147 bytes: &[u8],
148) -> Result<Zeroizing<String>, std::str::Utf8Error> {
149 let s = std::str::from_utf8(bytes)?;
150 Ok(Zeroizing::new(s.to_owned()))
151}
152
153pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
157 Ok(vault::read(Path::new(vault_path))?)
158}
159
160pub fn resolve_vault_path(arg: &str) -> String {
176 use std::path::PathBuf;
177
178 if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
180 return arg.to_string();
181 }
182
183 let Ok(cwd) = std::env::current_dir() else {
184 return arg.to_string();
185 };
186
187 if cwd.join(arg).exists() {
189 return arg.to_string();
190 }
191
192 let home = std::env::var_os("HOME").map(PathBuf::from);
193 let mut dir = cwd.as_path();
194 loop {
195 let candidate = dir.join(arg);
196 if candidate.exists() {
197 return candidate.to_string_lossy().into_owned();
198 }
199 if dir.join(".git").exists() {
201 break;
202 }
203 if let Some(ref h) = home
205 && dir == h.as_path()
206 {
207 break;
208 }
209 match dir.parent() {
210 Some(parent) => dir = parent,
211 None => break,
212 }
213 }
214
215 arg.to_string()
216}
217
218struct MetaState {
222 recipients: HashMap<String, String>,
223 groups: BTreeMap<String, Vec<String>>,
224 grants: BTreeMap<String, types::GrantEntry>,
225 legacy_mac: bool,
226 github_pins: HashMap<String, Vec<String>>,
227}
228
229fn resolve_meta_state(
234 vault: &types::Vault,
235 identity: &crypto::MurkIdentity,
236) -> Result<MetaState, MurkError> {
237 match decrypt_meta(vault, identity) {
238 Some(meta) if !meta.mac.is_empty() => {
239 let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
240 if !verify_mac(
241 vault,
242 &meta.groups,
243 &meta.grants,
244 &meta.mac,
245 mac_key.as_ref(),
246 ) {
247 let expected = compute_mac(vault, &meta.groups, &meta.grants, mac_key.as_ref());
248 return Err(MurkError::Integrity(format!(
249 "vault may have been tampered with (expected {expected}, got {})",
250 meta.mac
251 )));
252 }
253 let legacy_mac = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
254 Ok(MetaState {
255 recipients: meta.recipients,
256 groups: meta.groups,
257 grants: meta.grants,
258 legacy_mac,
259 github_pins: meta.github_pins,
260 })
261 }
262 Some(meta) if vault.secrets.is_empty() => Ok(MetaState {
263 recipients: meta.recipients,
264 groups: meta.groups,
265 grants: meta.grants,
266 legacy_mac: false,
267 github_pins: meta.github_pins,
268 }),
269 Some(_) => Err(MurkError::Integrity(
270 "vault has secrets but MAC is empty — vault may have been tampered with".into(),
271 )),
272 None if vault.secrets.is_empty() && vault.meta.is_empty() => Ok(MetaState {
273 recipients: HashMap::new(),
274 groups: BTreeMap::new(),
275 grants: BTreeMap::new(),
276 legacy_mac: false,
277 github_pins: HashMap::new(),
278 }),
279 None => Err(MurkError::Integrity(
280 "vault has secrets but no meta — vault may have been tampered with".into(),
281 )),
282 }
283}
284
285pub fn decrypt_vault(
291 vault: &types::Vault,
292 identity: &crypto::MurkIdentity,
293) -> Result<types::Murk, MurkError> {
294 let pubkey = identity.pubkey_string()?;
295
296 let MetaState {
299 recipients,
300 groups,
301 grants,
302 legacy_mac,
303 github_pins,
304 } = resolve_meta_state(vault, identity)?;
305
306 let is_agent = grants.values().any(|g| g.pubkey == pubkey);
313
314 let mut values: HashMap<String, Zeroizing<String>> = HashMap::new();
316 for (key, entry) in &vault.secrets {
317 if entry.shared.is_empty() {
318 continue;
319 }
320 let plaintext = match decrypt_value(&entry.shared, identity) {
321 Ok(plaintext) => plaintext,
322 Err(_) if is_agent => continue,
323 Err(_) => {
324 return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
325 "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
326 )));
327 }
328 };
329 let value = plaintext_bytes_to_zeroizing_string(&plaintext)
330 .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
331 values.insert(key.clone(), value);
332 }
333
334 let mut private: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
336 for (key, entry) in &vault.secrets {
337 if let Some(encoded) = entry.private.get(&pubkey)
338 && let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
339 plaintext_bytes_to_zeroizing_string(&pt)
340 .map_err(|e| MurkError::Secret(e.to_string()))
341 })
342 {
343 private
344 .entry(key.clone())
345 .or_default()
346 .insert(pubkey.clone(), value);
347 }
348 }
349
350 let mut grouped: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
354 for (key, entry) in &vault.secrets {
355 for (group, encoded) in &entry.grouped {
356 if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
357 plaintext_bytes_to_zeroizing_string(&pt)
358 .map_err(|e| MurkError::Secret(e.to_string()))
359 }) {
360 grouped
361 .entry(key.clone())
362 .or_default()
363 .insert(group.clone(), value);
364 }
365 }
366 }
367
368 Ok(types::Murk {
369 values,
370 recipients,
371 private,
372 grouped,
373 groups,
374 grants,
375 legacy_mac,
376 github_pins,
377 })
378}
379
380pub fn load_vault(
384 vault_path: &str,
385) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
386 let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
387
388 let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
389 MurkError::Key(format!(
390 "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
391 ))
392 })?;
393
394 let vault = read_vault(vault_path)?;
395 let murk = decrypt_vault(&vault, &identity)?;
396
397 Ok((vault, murk, identity))
398}
399
400fn rebuild_shared(
403 key: &str,
404 vault: &types::Vault,
405 recipients: &[crypto::MurkRecipient],
406 recipients_changed: bool,
407 original: &types::Murk,
408 current: &types::Murk,
409) -> Result<String, MurkError> {
410 let Some(value) = current.values.get(key) else {
411 return Ok(String::new());
413 };
414 if !recipients_changed
416 && original.values.get(key) == Some(value)
417 && let Some(existing) = vault.secrets.get(key)
418 {
419 return Ok(existing.shared.clone());
420 }
421 encrypt_value(value.as_bytes(), recipients)
422}
423
424fn rebuild_private(
427 key: &str,
428 vault: &types::Vault,
429 original: &types::Murk,
430 current: &types::Murk,
431) -> Result<BTreeMap<String, String>, MurkError> {
432 let mut scoped = vault
433 .secrets
434 .get(key)
435 .map(|e| e.private.clone())
436 .unwrap_or_default();
437
438 if let Some(key_scoped) = current.private.get(key) {
439 for (pk, val) in key_scoped {
440 let original_val = original.private.get(key).and_then(|m| m.get(pk));
441 if original_val != Some(val) {
442 let recipient = crypto::parse_recipient(pk)?;
443 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
444 }
445 }
446 }
447
448 if let Some(orig_key_scoped) = original.private.get(key) {
449 for pk in orig_key_scoped.keys() {
450 let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
451 if !still_present {
452 scoped.remove(pk);
453 }
454 }
455 }
456
457 Ok(scoped)
458}
459
460fn rebuild_grouped(
464 key: &str,
465 vault: &types::Vault,
466 changed_groups: &BTreeSet<&str>,
467 original: &types::Murk,
468 current: &types::Murk,
469) -> Result<BTreeMap<String, String>, MurkError> {
470 let mut grouped = vault
471 .secrets
472 .get(key)
473 .map(|e| e.grouped.clone())
474 .unwrap_or_default();
475
476 if let Some(key_grouped) = current.grouped.get(key) {
477 for (group, val) in key_grouped {
478 let members = current.groups.get(group).ok_or_else(|| {
479 MurkError::Secret(format!("secret {key} references unknown group {group}"))
480 })?;
481 let original_val = original.grouped.get(key).and_then(|m| m.get(group));
482 if original_val != Some(val) || changed_groups.contains(group.as_str()) {
483 let group_recipients = parse_recipients(members)?;
484 grouped.insert(
485 group.clone(),
486 encrypt_value(val.as_bytes(), &group_recipients)?,
487 );
488 }
489 }
490 }
491
492 if let Some(orig_key_grouped) = original.grouped.get(key) {
493 for group in orig_key_grouped.keys() {
494 let still_present = current
495 .grouped
496 .get(key)
497 .is_some_and(|m| m.contains_key(group));
498 if !still_present {
499 grouped.remove(group);
500 }
501 }
502 }
503
504 Ok(grouped)
505}
506
507fn resync_grant_private(
516 key: &str,
517 private: &mut BTreeMap<String, String>,
518 original: &types::Murk,
519 current: &types::Murk,
520) -> Result<(), MurkError> {
521 let Some(value) = current.values.get(key) else {
522 return Ok(());
523 };
524 if original.values.get(key) == Some(value) {
525 return Ok(());
526 }
527 for grant in current.grants.values() {
528 if grant.scope.iter().any(|k| k == key) {
529 let recipient = crypto::parse_recipient(&grant.pubkey)?;
530 private.insert(
531 grant.pubkey.clone(),
532 encrypt_value(value.as_bytes(), &[recipient])?,
533 );
534 }
535 }
536 Ok(())
537}
538
539pub fn save_vault(
542 vault_path: &str,
543 vault: &mut types::Vault,
544 original: &types::Murk,
545 current: &types::Murk,
546) -> Result<(), MurkError> {
547 let recipients = parse_recipients(&vault.recipients)?;
550
551 let grant_pubkeys: BTreeSet<&str> =
556 current.grants.values().map(|g| g.pubkey.as_str()).collect();
557 let shared_recipients: Vec<crypto::MurkRecipient> = vault
558 .recipients
559 .iter()
560 .filter(|pk| !grant_pubkeys.contains(pk.as_str()))
561 .map(|pk| crypto::parse_recipient(pk))
562 .collect::<Result<_, _>>()?;
563
564 let shared_recipients_changed = {
569 let orig_grant_pubkeys: BTreeSet<&str> = original
570 .grants
571 .values()
572 .map(|g| g.pubkey.as_str())
573 .collect();
574 let mut current_pks: Vec<&str> = vault
575 .recipients
576 .iter()
577 .map(String::as_str)
578 .filter(|pk| !grant_pubkeys.contains(pk))
579 .collect();
580 let mut original_pks: Vec<&str> = original
581 .recipients
582 .keys()
583 .map(String::as_str)
584 .filter(|pk| !orig_grant_pubkeys.contains(pk))
585 .collect();
586 current_pks.sort_unstable();
587 original_pks.sort_unstable();
588 current_pks != original_pks
589 };
590
591 let changed_groups: BTreeSet<&str> = current
595 .groups
596 .keys()
597 .chain(original.groups.keys())
598 .filter(|g| current.groups.get(*g) != original.groups.get(*g))
599 .map(String::as_str)
600 .collect();
601
602 let mut new_secrets = BTreeMap::new();
603
604 let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
607 all_keys.extend(current.private.keys());
608 all_keys.extend(current.grouped.keys());
609
610 let original_visible: BTreeSet<&String> = original
616 .values
617 .keys()
618 .chain(original.private.keys())
619 .chain(original.grouped.keys())
620 .collect();
621 for key in vault.secrets.keys() {
622 if !original_visible.contains(key) {
623 all_keys.insert(key);
624 }
625 }
626
627 for key in all_keys {
628 let shared = rebuild_shared(
629 key,
630 vault,
631 &shared_recipients,
632 shared_recipients_changed,
633 original,
634 current,
635 )?;
636 let mut private = rebuild_private(key, vault, original, current)?;
637 resync_grant_private(key, &mut private, original, current)?;
638 let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
639 new_secrets.insert(
640 key.clone(),
641 types::SecretEntry {
642 shared,
643 private,
644 grouped,
645 },
646 );
647 }
648
649 vault.secrets = new_secrets;
650
651 let mac_key_hex = generate_mac_key();
653 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
654 let mac = compute_mac(vault, ¤t.groups, ¤t.grants, Some(&mac_key));
655 let meta = types::Meta {
656 recipients: current.recipients.clone(),
657 mac,
658 mac_key: Some(mac_key_hex),
659 github_pins: current.github_pins.clone(),
660 groups: current.groups.clone(),
661 grants: current.grants.clone(),
662 };
663 let meta_json =
664 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
665 vault.meta = encrypt_value(&meta_json, &recipients)?;
666
667 Ok(vault::write(Path::new(vault_path), vault)?)
668}
669
670pub(crate) fn compute_mac(
679 vault: &types::Vault,
680 groups: &BTreeMap<String, Vec<String>>,
681 grants: &BTreeMap<String, types::GrantEntry>,
682 mac_key: Option<&[u8; 32]>,
683) -> String {
684 match mac_key {
685 Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
686 compute_mac_v9(vault, groups, grants, key)
687 }
688 Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
689 Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
690 Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
691 Some(key) => compute_mac_v5(vault, key),
692 None => compute_mac_v2(vault),
693 }
694}
695
696fn compute_mac_v1(vault: &types::Vault) -> String {
698 use sha2::{Digest, Sha256};
699
700 let mut hasher = Sha256::new();
701
702 for key in vault.secrets.keys() {
703 hasher.update(key.as_bytes());
704 hasher.update(b"\x00");
705 }
706
707 for entry in vault.secrets.values() {
708 hasher.update(entry.shared.as_bytes());
709 hasher.update(b"\x00");
710 }
711
712 let mut pks = vault.recipients.clone();
713 pks.sort();
714 for pk in &pks {
715 hasher.update(pk.as_bytes());
716 hasher.update(b"\x00");
717 }
718
719 let digest = hasher.finalize();
720 format!(
721 "sha256:{}",
722 digest.iter().fold(String::new(), |mut s, b| {
723 use std::fmt::Write;
724 let _ = write!(s, "{b:02x}");
725 s
726 })
727 )
728}
729
730fn compute_mac_v2(vault: &types::Vault) -> String {
732 use sha2::{Digest, Sha256};
733
734 let mut hasher = Sha256::new();
735
736 for key in vault.secrets.keys() {
738 hasher.update(key.as_bytes());
739 hasher.update(b"\x00");
740 }
741
742 for entry in vault.secrets.values() {
744 hasher.update(entry.shared.as_bytes());
745 hasher.update(b"\x00");
746
747 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
749 scoped_pks.sort();
750 for pk in scoped_pks {
751 hasher.update(pk.as_bytes());
752 hasher.update(b"\x01");
753 hasher.update(entry.private[pk].as_bytes());
754 hasher.update(b"\x00");
755 }
756 }
757
758 let mut pks = vault.recipients.clone();
760 pks.sort();
761 for pk in &pks {
762 hasher.update(pk.as_bytes());
763 hasher.update(b"\x00");
764 }
765
766 let digest = hasher.finalize();
767 format!(
768 "sha256v2:{}",
769 digest.iter().fold(String::new(), |mut s, b| {
770 use std::fmt::Write;
771 let _ = write!(s, "{b:02x}");
772 s
773 })
774 )
775}
776
777fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
779 let mut data = Vec::new();
780
781 for key_name in vault.secrets.keys() {
782 data.extend_from_slice(key_name.as_bytes());
783 data.push(0x00);
784 }
785
786 for entry in vault.secrets.values() {
787 data.extend_from_slice(entry.shared.as_bytes());
788 data.push(0x00);
789
790 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
791 scoped_pks.sort();
792 for pk in scoped_pks {
793 data.extend_from_slice(pk.as_bytes());
794 data.push(0x01);
795 data.extend_from_slice(entry.private[pk].as_bytes());
796 data.push(0x00);
797 }
798 }
799
800 let mut pks = vault.recipients.clone();
801 pks.sort();
802 for pk in &pks {
803 data.extend_from_slice(pk.as_bytes());
804 data.push(0x00);
805 }
806
807 let hash = blake3::keyed_hash(key, &data);
808 format!("blake3:{hash}")
809}
810
811fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
814 let mut data = Vec::new();
815
816 for key_name in vault.secrets.keys() {
817 data.extend_from_slice(key_name.as_bytes());
818 data.push(0x00);
819 }
820
821 for entry in vault.secrets.values() {
822 data.extend_from_slice(entry.shared.as_bytes());
823 data.push(0x00);
824
825 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
826 scoped_pks.sort();
827 for pk in scoped_pks {
828 data.extend_from_slice(pk.as_bytes());
829 data.push(0x01);
830 data.extend_from_slice(entry.private[pk].as_bytes());
831 data.push(0x00);
832 }
833 }
834
835 let mut pks = vault.recipients.clone();
836 pks.sort();
837 for pk in &pks {
838 data.extend_from_slice(pk.as_bytes());
839 data.push(0x00);
840 }
841
842 for (key_name, entry) in &vault.schema {
845 data.push(0x02);
846 data.extend_from_slice(key_name.as_bytes());
847 data.push(0x00);
848 data.extend_from_slice(entry.description.as_bytes());
849 data.push(0x00);
850 if let Some(example) = &entry.example {
851 data.extend_from_slice(example.as_bytes());
852 }
853 data.push(0x00);
854 for tag in &entry.tags {
855 data.extend_from_slice(tag.as_bytes());
856 data.push(0x00);
857 }
858 }
859
860 let hash = blake3::keyed_hash(key, &data);
861 format!("blake3v2:{hash}")
862}
863
864fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
870 let mut data = Vec::new();
871
872 for key_name in vault.secrets.keys() {
873 data.extend_from_slice(key_name.as_bytes());
874 data.push(0x00);
875 }
876
877 for entry in vault.secrets.values() {
878 data.extend_from_slice(entry.shared.as_bytes());
879 data.push(0x00);
880
881 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
882 scoped_pks.sort();
883 for pk in scoped_pks {
884 data.extend_from_slice(pk.as_bytes());
885 data.push(0x01);
886 data.extend_from_slice(entry.private[pk].as_bytes());
887 data.push(0x00);
888 }
889 }
890
891 let mut pks = vault.recipients.clone();
892 pks.sort();
893 for pk in &pks {
894 data.extend_from_slice(pk.as_bytes());
895 data.push(0x00);
896 }
897
898 for (key_name, entry) in &vault.schema {
903 data.push(0x02);
904 data.extend_from_slice(key_name.as_bytes());
905 data.push(0x00);
906 data.extend_from_slice(entry.description.as_bytes());
907 data.push(0x00);
908 if let Some(example) = &entry.example {
909 data.extend_from_slice(example.as_bytes());
910 }
911 data.push(0x00);
912 for tag in &entry.tags {
913 data.extend_from_slice(tag.as_bytes());
914 data.push(0x00);
915 }
916 if let Some(created) = &entry.created {
919 data.extend_from_slice(created.as_bytes());
920 }
921 data.push(0x00);
922 if let Some(updated) = &entry.updated {
923 data.extend_from_slice(updated.as_bytes());
924 }
925 data.push(0x00);
926 if let Some(days) = entry.rotation_interval_days {
927 data.extend_from_slice(days.to_string().as_bytes());
928 }
929 data.push(0x00);
930 if let Some(expires) = &entry.expires_at {
931 data.extend_from_slice(expires.as_bytes());
932 }
933 data.push(0x00);
934 }
935
936 let hash = blake3::keyed_hash(key, &data);
937 format!("blake3v3:{hash}")
938}
939
940fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
944 for (key_name, entry) in &vault.schema {
945 data.push(0x02);
946 data.extend_from_slice(key_name.as_bytes());
947 data.push(0x00);
948 data.extend_from_slice(entry.description.as_bytes());
949 data.push(0x00);
950 if let Some(example) = &entry.example {
951 data.extend_from_slice(example.as_bytes());
952 }
953 data.push(0x00);
954 for tag in &entry.tags {
955 data.extend_from_slice(tag.as_bytes());
956 data.push(0x00);
957 }
958 if let Some(created) = &entry.created {
959 data.extend_from_slice(created.as_bytes());
960 }
961 data.push(0x00);
962 if let Some(updated) = &entry.updated {
963 data.extend_from_slice(updated.as_bytes());
964 }
965 data.push(0x00);
966 if let Some(days) = entry.rotation_interval_days {
967 data.extend_from_slice(days.to_string().as_bytes());
968 }
969 data.push(0x00);
970 if let Some(expires) = &entry.expires_at {
971 data.extend_from_slice(expires.as_bytes());
972 }
973 data.push(0x00);
974 }
975}
976
977fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
981 for key_name in vault.secrets.keys() {
982 data.extend_from_slice(key_name.as_bytes());
983 data.push(0x00);
984 }
985
986 for entry in vault.secrets.values() {
987 data.extend_from_slice(entry.shared.as_bytes());
988 data.push(0x00);
989
990 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
991 scoped_pks.sort();
992 for pk in scoped_pks {
993 data.extend_from_slice(pk.as_bytes());
994 data.push(0x01);
995 data.extend_from_slice(entry.private[pk].as_bytes());
996 data.push(0x00);
997 }
998
999 let mut group_names: Vec<&String> = entry.grouped.keys().collect();
1002 group_names.sort();
1003 for g in group_names {
1004 data.push(0x03);
1005 data.extend_from_slice(g.as_bytes());
1006 data.push(0x00);
1007 data.extend_from_slice(entry.grouped[g].as_bytes());
1008 data.push(0x00);
1009 }
1010 }
1011
1012 let mut pks = vault.recipients.clone();
1013 pks.sort();
1014 for pk in &pks {
1015 data.extend_from_slice(pk.as_bytes());
1016 data.push(0x00);
1017 }
1018
1019 schema_mac_bytes(vault, data);
1020
1021 for (name, members) in groups {
1024 data.push(0x04);
1025 data.extend_from_slice(name.as_bytes());
1026 data.push(0x00);
1027 let mut sorted = members.clone();
1028 sorted.sort();
1029 for member in &sorted {
1030 data.push(0x05);
1031 data.extend_from_slice(member.as_bytes());
1032 }
1033 }
1034}
1035
1036fn compute_mac_v6(
1041 vault: &types::Vault,
1042 groups: &BTreeMap<String, Vec<String>>,
1043 key: &[u8; 32],
1044) -> String {
1045 let mut data = Vec::new();
1046 v6_mac_bytes(vault, groups, &mut data);
1047 let hash = blake3::keyed_hash(key, &data);
1048 format!("blake3v4:{hash}")
1049}
1050
1051fn v7_mac_bytes(
1060 vault: &types::Vault,
1061 groups: &BTreeMap<String, Vec<String>>,
1062 grants: &BTreeMap<String, types::GrantEntry>,
1063 data: &mut Vec<u8>,
1064) {
1065 v6_mac_bytes(vault, groups, data);
1066
1067 for (name, grant) in grants {
1071 data.push(0x06);
1072 data.extend_from_slice(name.as_bytes());
1073 data.push(0x00);
1074 data.extend_from_slice(grant.pubkey.as_bytes());
1075 data.push(0x00);
1076 data.extend_from_slice(grant.issued_at.as_bytes());
1077 data.push(0x00);
1078 data.extend_from_slice(grant.expires_at.as_bytes());
1079 data.push(0x00);
1080 data.extend_from_slice(grant.issuer.as_bytes());
1081 data.push(0x00);
1082 let mut scope = grant.scope.clone();
1083 scope.sort();
1084 for k in &scope {
1085 data.push(0x07);
1086 data.extend_from_slice(k.as_bytes());
1087 }
1088 }
1089}
1090
1091fn compute_mac_v7(
1092 vault: &types::Vault,
1093 groups: &BTreeMap<String, Vec<String>>,
1094 grants: &BTreeMap<String, types::GrantEntry>,
1095 key: &[u8; 32],
1096) -> String {
1097 let mut data = Vec::new();
1098 v7_mac_bytes(vault, groups, grants, &mut data);
1099 let hash = blake3::keyed_hash(key, &data);
1100 format!("blake3v5:{hash}")
1101}
1102
1103fn v8_mac_bytes(
1107 vault: &types::Vault,
1108 groups: &BTreeMap<String, Vec<String>>,
1109 grants: &BTreeMap<String, types::GrantEntry>,
1110 data: &mut Vec<u8>,
1111) {
1112 v7_mac_bytes(vault, groups, grants, data);
1113
1114 if let Some(policy) = &vault.policy {
1121 data.push(0x08);
1122 let mut tags = policy.agent_allow_tags.clone();
1123 tags.sort();
1124 for tag in &tags {
1125 let bytes = tag.as_bytes();
1126 data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
1129 data.extend_from_slice(bytes);
1130 }
1131 }
1132}
1133
1134fn compute_mac_v8(
1139 vault: &types::Vault,
1140 groups: &BTreeMap<String, Vec<String>>,
1141 grants: &BTreeMap<String, types::GrantEntry>,
1142 key: &[u8; 32],
1143) -> String {
1144 let mut data = Vec::new();
1145 v8_mac_bytes(vault, groups, grants, &mut data);
1146 let hash = blake3::keyed_hash(key, &data);
1147 format!("blake3v6:{hash}")
1148}
1149
1150fn compute_mac_v9(
1156 vault: &types::Vault,
1157 groups: &BTreeMap<String, Vec<String>>,
1158 grants: &BTreeMap<String, types::GrantEntry>,
1159 key: &[u8; 32],
1160) -> String {
1161 let mut data = Vec::new();
1162 v8_mac_bytes(vault, groups, grants, &mut data);
1163
1164 for (key_name, entry) in &vault.schema {
1169 if let Some(revoked_at) = &entry.revoked_at {
1170 data.push(0x09);
1171 data.extend_from_slice(key_name.as_bytes());
1172 data.push(0x00);
1173 data.extend_from_slice(revoked_at.as_bytes());
1174 data.push(0x00);
1175 }
1176 }
1177
1178 let hash = blake3::keyed_hash(key, &data);
1179 format!("blake3v7:{hash}")
1180}
1181
1182pub(crate) fn verify_mac(
1185 vault: &types::Vault,
1186 groups: &BTreeMap<String, Vec<String>>,
1187 grants: &BTreeMap<String, types::GrantEntry>,
1188 stored_mac: &str,
1189 mac_key: Option<&[u8; 32]>,
1190) -> bool {
1191 use constant_time_eq::constant_time_eq;
1192
1193 if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
1197 {
1198 return false;
1199 }
1200
1201 if vault.policy.is_some()
1205 && !stored_mac.starts_with("blake3v6:")
1206 && !stored_mac.starts_with("blake3v7:")
1207 {
1208 return false;
1209 }
1210
1211 if !grants.is_empty()
1214 && !stored_mac.starts_with("blake3v5:")
1215 && !stored_mac.starts_with("blake3v6:")
1216 && !stored_mac.starts_with("blake3v7:")
1217 {
1218 return false;
1219 }
1220
1221 let touches_groups =
1227 !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
1228 if touches_groups
1229 && !stored_mac.starts_with("blake3v4:")
1230 && !stored_mac.starts_with("blake3v5:")
1231 && !stored_mac.starts_with("blake3v6:")
1232 && !stored_mac.starts_with("blake3v7:")
1233 {
1234 return false;
1235 }
1236
1237 let expected = if stored_mac.starts_with("blake3v7:") {
1238 match mac_key {
1239 Some(key) => compute_mac_v9(vault, groups, grants, key),
1240 None => return false,
1241 }
1242 } else if stored_mac.starts_with("blake3v6:") {
1243 match mac_key {
1244 Some(key) => compute_mac_v8(vault, groups, grants, key),
1245 None => return false,
1246 }
1247 } else if stored_mac.starts_with("blake3v5:") {
1248 match mac_key {
1249 Some(key) => compute_mac_v7(vault, groups, grants, key),
1250 None => return false,
1251 }
1252 } else if stored_mac.starts_with("blake3v4:") {
1253 match mac_key {
1254 Some(key) => compute_mac_v6(vault, groups, key),
1255 None => return false,
1256 }
1257 } else if stored_mac.starts_with("blake3v3:") {
1258 match mac_key {
1259 Some(key) => compute_mac_v5(vault, key),
1260 None => return false,
1261 }
1262 } else if stored_mac.starts_with("blake3v2:") {
1263 match mac_key {
1264 Some(key) => compute_mac_v4(vault, key),
1265 None => return false,
1266 }
1267 } else if stored_mac.starts_with("blake3:") {
1268 match mac_key {
1269 Some(key) => compute_mac_v3(vault, key),
1270 None => return false,
1271 }
1272 } else if stored_mac.starts_with("sha256v2:") {
1273 compute_mac_v2(vault)
1274 } else if stored_mac.starts_with("sha256:") {
1275 compute_mac_v1(vault)
1276 } else {
1277 return false;
1278 };
1279 constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
1280}
1281
1282pub(crate) fn generate_mac_key() -> String {
1284 let key: [u8; 32] = rand::random();
1285 key.iter().fold(String::new(), |mut s, b| {
1286 use std::fmt::Write;
1287 let _ = write!(s, "{b:02x}");
1288 s
1289 })
1290}
1291
1292pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
1294 if hex.len() != 64 {
1295 return None;
1296 }
1297 let mut key = [0u8; 32];
1298 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
1299 key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
1300 }
1301 Some(key)
1302}
1303
1304pub(crate) fn now_utc() -> String {
1306 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311 use super::*;
1312 use crate::testutil::*;
1313 use std::collections::BTreeMap;
1314 use std::fs;
1315
1316 use crate::testutil::ENV_LOCK;
1317
1318 #[test]
1319 fn resolve_vault_path_finds_in_parent_dir() {
1320 let _lock = ENV_LOCK
1321 .lock()
1322 .unwrap_or_else(std::sync::PoisonError::into_inner);
1323 let dir = tempfile::tempdir().unwrap();
1324 fs::create_dir(dir.path().join(".git")).unwrap();
1326 fs::write(dir.path().join(".murk"), "{}").unwrap();
1327 let nested = dir.path().join("a").join("b");
1328 fs::create_dir_all(&nested).unwrap();
1329
1330 let prev = std::env::current_dir().unwrap();
1331 std::env::set_current_dir(&nested).unwrap();
1332 let got = resolve_vault_path(".murk");
1333 std::env::set_current_dir(prev).unwrap();
1334
1335 assert_eq!(
1336 std::fs::canonicalize(&got).unwrap(),
1337 std::fs::canonicalize(dir.path().join(".murk")).unwrap()
1338 );
1339 }
1340
1341 #[test]
1342 fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
1343 let _lock = ENV_LOCK
1344 .lock()
1345 .unwrap_or_else(std::sync::PoisonError::into_inner);
1346 let dir = tempfile::tempdir().unwrap();
1347 fs::write(dir.path().join(".murk"), "{}").unwrap();
1348 let prev = std::env::current_dir().unwrap();
1349 std::env::set_current_dir(dir.path()).unwrap();
1350 let got = resolve_vault_path(".murk");
1351 std::env::set_current_dir(prev).unwrap();
1352 assert_eq!(got, ".murk");
1353 }
1354
1355 #[test]
1356 fn resolve_vault_path_passes_through_explicit_paths() {
1357 assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
1358 assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
1359 assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
1360 }
1361
1362 #[test]
1363 fn resolve_vault_path_stops_at_git_root() {
1364 let _lock = ENV_LOCK
1365 .lock()
1366 .unwrap_or_else(std::sync::PoisonError::into_inner);
1367 let dir = tempfile::tempdir().unwrap();
1368 fs::write(dir.path().join(".murk"), "{}").unwrap();
1370 let repo = dir.path().join("repo");
1371 fs::create_dir(&repo).unwrap();
1372 fs::create_dir(repo.join(".git")).unwrap();
1373 let nested = repo.join("sub");
1374 fs::create_dir(&nested).unwrap();
1375
1376 let prev = std::env::current_dir().unwrap();
1377 std::env::set_current_dir(&nested).unwrap();
1378 let got = resolve_vault_path(".murk");
1379 std::env::set_current_dir(prev).unwrap();
1380
1381 assert_eq!(got, ".murk");
1383 }
1384
1385 #[test]
1386 fn encrypt_decrypt_value_roundtrip() {
1387 let (secret, pubkey) = generate_keypair();
1388 let recipient = make_recipient(&pubkey);
1389 let identity = make_identity(&secret);
1390
1391 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
1392 let decrypted = decrypt_value(&encoded, &identity).unwrap();
1393 assert_eq!(&decrypted[..], b"hello world");
1394 }
1395
1396 #[test]
1397 fn decrypt_value_invalid_base64() {
1398 let (secret, _) = generate_keypair();
1399 let identity = make_identity(&secret);
1400
1401 let result = decrypt_value("not!valid!base64!!!", &identity);
1402 assert!(result.is_err());
1403 assert!(result.unwrap_err().to_string().contains("invalid base64"));
1404 }
1405
1406 #[test]
1407 fn encrypt_value_multiple_recipients() {
1408 let (secret_a, pubkey_a) = generate_keypair();
1409 let (secret_b, pubkey_b) = generate_keypair();
1410
1411 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
1412 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
1413
1414 let id_a = make_identity(&secret_a);
1416 let id_b = make_identity(&secret_b);
1417 assert_eq!(
1418 &decrypt_value(&encoded, &id_a).unwrap()[..],
1419 b"shared secret"
1420 );
1421 assert_eq!(
1422 &decrypt_value(&encoded, &id_b).unwrap()[..],
1423 b"shared secret"
1424 );
1425 }
1426
1427 #[test]
1428 fn decrypt_value_wrong_key_fails() {
1429 let (_, pubkey) = generate_keypair();
1430 let (wrong_secret, _) = generate_keypair();
1431
1432 let recipient = make_recipient(&pubkey);
1433 let wrong_identity = make_identity(&wrong_secret);
1434
1435 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
1436 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
1437 }
1438
1439 #[test]
1440 fn compute_mac_deterministic() {
1441 let vault = types::Vault {
1442 version: types::VAULT_VERSION.into(),
1443 created: "2026-02-28T00:00:00Z".into(),
1444 vault_name: ".murk".into(),
1445 repo: String::new(),
1446 recipients: vec!["age1abc".into()],
1447 schema: BTreeMap::new(),
1448 policy: None,
1449 secrets: BTreeMap::new(),
1450 meta: String::new(),
1451 };
1452
1453 let key = [0u8; 32];
1454 let mac1 = compute_mac(
1455 &vault,
1456 &std::collections::BTreeMap::new(),
1457 &std::collections::BTreeMap::new(),
1458 Some(&key),
1459 );
1460 let mac2 = compute_mac(
1461 &vault,
1462 &std::collections::BTreeMap::new(),
1463 &std::collections::BTreeMap::new(),
1464 Some(&key),
1465 );
1466 assert_eq!(mac1, mac2);
1467 assert!(mac1.starts_with("blake3v3:"));
1468
1469 let mac_legacy = compute_mac(
1471 &vault,
1472 &std::collections::BTreeMap::new(),
1473 &std::collections::BTreeMap::new(),
1474 None,
1475 );
1476 assert!(mac_legacy.starts_with("sha256v2:"));
1477 }
1478
1479 #[test]
1480 fn compute_mac_changes_with_different_secrets() {
1481 let mut vault = types::Vault {
1482 version: types::VAULT_VERSION.into(),
1483 created: "2026-02-28T00:00:00Z".into(),
1484 vault_name: ".murk".into(),
1485 repo: String::new(),
1486 recipients: vec!["age1abc".into()],
1487 schema: BTreeMap::new(),
1488 policy: None,
1489 secrets: BTreeMap::new(),
1490 meta: String::new(),
1491 };
1492
1493 let key = [0u8; 32];
1494 let mac_empty = compute_mac(
1495 &vault,
1496 &std::collections::BTreeMap::new(),
1497 &std::collections::BTreeMap::new(),
1498 Some(&key),
1499 );
1500
1501 vault.secrets.insert(
1502 "KEY".into(),
1503 types::SecretEntry {
1504 shared: "ciphertext".into(),
1505 private: BTreeMap::new(),
1506 grouped: std::collections::BTreeMap::default(),
1507 },
1508 );
1509
1510 let mac_with_secret = compute_mac(
1511 &vault,
1512 &std::collections::BTreeMap::new(),
1513 &std::collections::BTreeMap::new(),
1514 Some(&key),
1515 );
1516 assert_ne!(mac_empty, mac_with_secret);
1517 }
1518
1519 #[test]
1520 fn compute_mac_changes_with_different_recipients() {
1521 let mut vault = types::Vault {
1522 version: types::VAULT_VERSION.into(),
1523 created: "2026-02-28T00:00:00Z".into(),
1524 vault_name: ".murk".into(),
1525 repo: String::new(),
1526 recipients: vec!["age1abc".into()],
1527 schema: BTreeMap::new(),
1528 policy: None,
1529 secrets: BTreeMap::new(),
1530 meta: String::new(),
1531 };
1532
1533 let key = [0u8; 32];
1534 let mac1 = compute_mac(
1535 &vault,
1536 &std::collections::BTreeMap::new(),
1537 &std::collections::BTreeMap::new(),
1538 Some(&key),
1539 );
1540 vault.recipients.push("age1xyz".into());
1541 let mac2 = compute_mac(
1542 &vault,
1543 &std::collections::BTreeMap::new(),
1544 &std::collections::BTreeMap::new(),
1545 Some(&key),
1546 );
1547 assert_ne!(mac1, mac2);
1548 }
1549
1550 #[test]
1551 fn save_vault_preserves_unchanged_ciphertext() {
1552 let (secret, pubkey) = generate_keypair();
1553 let recipient = make_recipient(&pubkey);
1554 let identity = make_identity(&secret);
1555
1556 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
1557 fs::create_dir_all(&dir).unwrap();
1558 let path = dir.join("test.murk");
1559
1560 let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
1561 let mut vault = types::Vault {
1562 version: types::VAULT_VERSION.into(),
1563 created: "2026-02-28T00:00:00Z".into(),
1564 vault_name: ".murk".into(),
1565 repo: String::new(),
1566 recipients: vec![pubkey.clone()],
1567 schema: BTreeMap::new(),
1568 policy: None,
1569 secrets: BTreeMap::new(),
1570 meta: String::new(),
1571 };
1572 vault.secrets.insert(
1573 "KEY1".into(),
1574 types::SecretEntry {
1575 shared: shared.clone(),
1576 private: BTreeMap::new(),
1577 grouped: std::collections::BTreeMap::default(),
1578 },
1579 );
1580
1581 let mut recipients_map = HashMap::new();
1582 recipients_map.insert(pubkey.clone(), "alice".into());
1583 let original = types::Murk {
1584 values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
1585 recipients: recipients_map.clone(),
1586 private: HashMap::new(),
1587 legacy_mac: false,
1588 github_pins: HashMap::new(),
1589 ..Default::default()
1590 };
1591
1592 let current = original.clone();
1593 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1594
1595 assert_eq!(vault.secrets["KEY1"].shared, shared);
1596
1597 let mut changed = current.clone();
1598 changed
1599 .values
1600 .insert("KEY1".into(), crate::testutil::secret("modified"));
1601 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
1602
1603 assert_ne!(vault.secrets["KEY1"].shared, shared);
1604
1605 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
1606 assert_eq!(&decrypted[..], b"modified");
1607
1608 fs::remove_dir_all(&dir).unwrap();
1609 }
1610
1611 #[test]
1612 fn save_vault_adds_new_secret() {
1613 let (_, pubkey) = generate_keypair();
1614 let recipient = make_recipient(&pubkey);
1615
1616 let dir = std::env::temp_dir().join("murk_test_save_add");
1617 fs::create_dir_all(&dir).unwrap();
1618 let path = dir.join("test.murk");
1619
1620 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
1621 let mut vault = types::Vault {
1622 version: types::VAULT_VERSION.into(),
1623 created: "2026-02-28T00:00:00Z".into(),
1624 vault_name: ".murk".into(),
1625 repo: String::new(),
1626 recipients: vec![pubkey.clone()],
1627 schema: BTreeMap::new(),
1628 policy: None,
1629 secrets: BTreeMap::new(),
1630 meta: String::new(),
1631 };
1632 vault.secrets.insert(
1633 "KEY1".into(),
1634 types::SecretEntry {
1635 shared,
1636 private: BTreeMap::new(),
1637 grouped: std::collections::BTreeMap::default(),
1638 },
1639 );
1640
1641 let mut recipients_map = HashMap::new();
1642 recipients_map.insert(pubkey.clone(), "alice".into());
1643 let original = types::Murk {
1644 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1645 recipients: recipients_map.clone(),
1646 private: HashMap::new(),
1647 legacy_mac: false,
1648 github_pins: HashMap::new(),
1649 ..Default::default()
1650 };
1651
1652 let mut current = original.clone();
1653 current
1654 .values
1655 .insert("KEY2".into(), crate::testutil::secret("val2"));
1656
1657 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1658
1659 assert!(vault.secrets.contains_key("KEY1"));
1660 assert!(vault.secrets.contains_key("KEY2"));
1661
1662 fs::remove_dir_all(&dir).unwrap();
1663 }
1664
1665 #[test]
1666 fn save_vault_removes_deleted_secret() {
1667 let (_, pubkey) = generate_keypair();
1668 let recipient = make_recipient(&pubkey);
1669
1670 let dir = std::env::temp_dir().join("murk_test_save_remove");
1671 fs::create_dir_all(&dir).unwrap();
1672 let path = dir.join("test.murk");
1673
1674 let mut vault = types::Vault {
1675 version: types::VAULT_VERSION.into(),
1676 created: "2026-02-28T00:00:00Z".into(),
1677 vault_name: ".murk".into(),
1678 repo: String::new(),
1679 recipients: vec![pubkey.clone()],
1680 schema: BTreeMap::new(),
1681 policy: None,
1682 secrets: BTreeMap::new(),
1683 meta: String::new(),
1684 };
1685 vault.secrets.insert(
1686 "KEY1".into(),
1687 types::SecretEntry {
1688 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
1689 private: BTreeMap::new(),
1690 grouped: std::collections::BTreeMap::default(),
1691 },
1692 );
1693 vault.secrets.insert(
1694 "KEY2".into(),
1695 types::SecretEntry {
1696 shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
1697 private: BTreeMap::new(),
1698 grouped: std::collections::BTreeMap::default(),
1699 },
1700 );
1701
1702 let mut recipients_map = HashMap::new();
1703 recipients_map.insert(pubkey.clone(), "alice".into());
1704 let original = types::Murk {
1705 values: HashMap::from([
1706 ("KEY1".into(), crate::testutil::secret("val1")),
1707 ("KEY2".into(), crate::testutil::secret("val2")),
1708 ]),
1709 recipients: recipients_map.clone(),
1710 private: HashMap::new(),
1711 legacy_mac: false,
1712 github_pins: HashMap::new(),
1713 ..Default::default()
1714 };
1715
1716 let mut current = original.clone();
1717 current.values.remove("KEY2");
1718
1719 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1720
1721 assert!(vault.secrets.contains_key("KEY1"));
1722 assert!(!vault.secrets.contains_key("KEY2"));
1723
1724 fs::remove_dir_all(&dir).unwrap();
1725 }
1726
1727 #[test]
1728 fn save_vault_reencrypts_all_on_recipient_change() {
1729 let (secret1, pubkey1) = generate_keypair();
1730 let (_, pubkey2) = generate_keypair();
1731 let recipient1 = make_recipient(&pubkey1);
1732
1733 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
1734 fs::create_dir_all(&dir).unwrap();
1735 let path = dir.join("test.murk");
1736
1737 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
1738 let mut vault = types::Vault {
1739 version: types::VAULT_VERSION.into(),
1740 created: "2026-02-28T00:00:00Z".into(),
1741 vault_name: ".murk".into(),
1742 repo: String::new(),
1743 recipients: vec![pubkey1.clone(), pubkey2.clone()],
1744 schema: BTreeMap::new(),
1745 policy: None,
1746 secrets: BTreeMap::new(),
1747 meta: String::new(),
1748 };
1749 vault.secrets.insert(
1750 "KEY1".into(),
1751 types::SecretEntry {
1752 shared: shared.clone(),
1753 private: BTreeMap::new(),
1754 grouped: std::collections::BTreeMap::default(),
1755 },
1756 );
1757
1758 let mut recipients_map = HashMap::new();
1759 recipients_map.insert(pubkey1.clone(), "alice".into());
1760 let original = types::Murk {
1761 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1762 recipients: recipients_map,
1763 private: HashMap::new(),
1764 legacy_mac: false,
1765 github_pins: HashMap::new(),
1766 ..Default::default()
1767 };
1768
1769 let mut current_recipients = HashMap::new();
1770 current_recipients.insert(pubkey1.clone(), "alice".into());
1771 current_recipients.insert(pubkey2.clone(), "bob".into());
1772 let current = types::Murk {
1773 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1774 recipients: current_recipients,
1775 private: HashMap::new(),
1776 legacy_mac: false,
1777 github_pins: HashMap::new(),
1778 ..Default::default()
1779 };
1780
1781 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1782
1783 assert_ne!(vault.secrets["KEY1"].shared, shared);
1784
1785 let identity1 = make_identity(&secret1);
1786 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
1787 assert_eq!(&decrypted[..], b"val1");
1788
1789 fs::remove_dir_all(&dir).unwrap();
1790 }
1791
1792 #[test]
1793 fn save_vault_scoped_entry_lifecycle() {
1794 let (secret, pubkey) = generate_keypair();
1795 let recipient = make_recipient(&pubkey);
1796 let identity = make_identity(&secret);
1797
1798 let dir = std::env::temp_dir().join("murk_test_save_scoped");
1799 fs::create_dir_all(&dir).unwrap();
1800 let path = dir.join("test.murk");
1801
1802 let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
1803 let mut vault = types::Vault {
1804 version: types::VAULT_VERSION.into(),
1805 created: "2026-02-28T00:00:00Z".into(),
1806 vault_name: ".murk".into(),
1807 repo: String::new(),
1808 recipients: vec![pubkey.clone()],
1809 schema: BTreeMap::new(),
1810 policy: None,
1811 secrets: BTreeMap::new(),
1812 meta: String::new(),
1813 };
1814 vault.secrets.insert(
1815 "KEY1".into(),
1816 types::SecretEntry {
1817 shared,
1818 private: BTreeMap::new(),
1819 grouped: std::collections::BTreeMap::default(),
1820 },
1821 );
1822
1823 let mut recipients_map = HashMap::new();
1824 recipients_map.insert(pubkey.clone(), "alice".into());
1825 let original = types::Murk {
1826 values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
1827 recipients: recipients_map.clone(),
1828 private: HashMap::new(),
1829 legacy_mac: false,
1830 github_pins: HashMap::new(),
1831 ..Default::default()
1832 };
1833
1834 let mut current = original.clone();
1836 let mut key_scoped = HashMap::new();
1837 key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
1838 current.private.insert("KEY1".into(), key_scoped);
1839
1840 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
1841
1842 assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
1843 let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
1844 assert_eq!(&scoped_val[..], b"my_override");
1845
1846 let original_with_scoped = current.clone();
1848 let mut current_no_scoped = original_with_scoped.clone();
1849 current_no_scoped.private.remove("KEY1");
1850
1851 save_vault(
1852 path.to_str().unwrap(),
1853 &mut vault,
1854 &original_with_scoped,
1855 ¤t_no_scoped,
1856 )
1857 .unwrap();
1858
1859 assert!(vault.secrets["KEY1"].private.is_empty());
1860
1861 fs::remove_dir_all(&dir).unwrap();
1862 }
1863
1864 #[test]
1865 fn load_vault_validates_mac() {
1866 let _lock = ENV_LOCK
1867 .lock()
1868 .unwrap_or_else(std::sync::PoisonError::into_inner);
1869
1870 let (secret, pubkey) = generate_keypair();
1871 let recipient = make_recipient(&pubkey);
1872 let _identity = make_identity(&secret);
1873
1874 let dir = std::env::temp_dir().join("murk_test_load_mac");
1875 let _ = fs::remove_dir_all(&dir);
1876 fs::create_dir_all(&dir).unwrap();
1877 let path = dir.join("test.murk");
1878
1879 let mut vault = types::Vault {
1881 version: types::VAULT_VERSION.into(),
1882 created: "2026-02-28T00:00:00Z".into(),
1883 vault_name: ".murk".into(),
1884 repo: String::new(),
1885 recipients: vec![pubkey.clone()],
1886 schema: BTreeMap::new(),
1887 policy: None,
1888 secrets: BTreeMap::new(),
1889 meta: String::new(),
1890 };
1891 vault.secrets.insert(
1892 "KEY1".into(),
1893 types::SecretEntry {
1894 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
1895 private: BTreeMap::new(),
1896 grouped: std::collections::BTreeMap::default(),
1897 },
1898 );
1899
1900 let mut recipients_map = HashMap::new();
1901 recipients_map.insert(pubkey.clone(), "alice".into());
1902 let original = types::Murk {
1903 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1904 recipients: recipients_map,
1905 private: HashMap::new(),
1906 legacy_mac: false,
1907 github_pins: HashMap::new(),
1908 ..Default::default()
1909 };
1910
1911 unsafe { std::env::set_var("MURK_KEY", &secret) };
1913 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1914 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1915
1916 let mut tampered: types::Vault =
1918 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1919 tampered.secrets.get_mut("KEY1").unwrap().shared =
1920 encrypt_value(b"tampered", &[recipient]).unwrap();
1921 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1922
1923 let result = load_vault(path.to_str().unwrap());
1925 unsafe { std::env::remove_var("MURK_KEY") };
1926
1927 let err = result.expect_err("expected MAC validation to fail");
1928 assert!(
1929 err.to_string().contains("integrity check failed"),
1930 "expected integrity check failure, got: {err}"
1931 );
1932
1933 fs::remove_dir_all(&dir).unwrap();
1934 }
1935
1936 #[test]
1937 fn load_vault_succeeds_with_valid_mac() {
1938 let _lock = ENV_LOCK
1939 .lock()
1940 .unwrap_or_else(std::sync::PoisonError::into_inner);
1941
1942 let (secret, pubkey) = generate_keypair();
1943 let recipient = make_recipient(&pubkey);
1944
1945 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
1946 let _ = fs::remove_dir_all(&dir);
1947 fs::create_dir_all(&dir).unwrap();
1948 let path = dir.join("test.murk");
1949
1950 let mut vault = types::Vault {
1951 version: types::VAULT_VERSION.into(),
1952 created: "2026-02-28T00:00:00Z".into(),
1953 vault_name: ".murk".into(),
1954 repo: String::new(),
1955 recipients: vec![pubkey.clone()],
1956 schema: BTreeMap::new(),
1957 policy: None,
1958 secrets: BTreeMap::new(),
1959 meta: String::new(),
1960 };
1961 vault.secrets.insert(
1962 "KEY1".into(),
1963 types::SecretEntry {
1964 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1965 private: BTreeMap::new(),
1966 grouped: std::collections::BTreeMap::default(),
1967 },
1968 );
1969
1970 let mut recipients_map = HashMap::new();
1971 recipients_map.insert(pubkey.clone(), "alice".into());
1972 let original = types::Murk {
1973 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1974 recipients: recipients_map,
1975 private: HashMap::new(),
1976 legacy_mac: false,
1977 github_pins: HashMap::new(),
1978 ..Default::default()
1979 };
1980
1981 unsafe { std::env::set_var("MURK_KEY", &secret) };
1982 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1983 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1984
1985 let result = load_vault(path.to_str().unwrap());
1987 unsafe { std::env::remove_var("MURK_KEY") };
1988
1989 assert!(result.is_ok());
1990 let (_, murk, _) = result.unwrap();
1991 assert_eq!(murk.values["KEY1"].as_str(), "val1");
1992
1993 fs::remove_dir_all(&dir).unwrap();
1994 }
1995
1996 #[test]
1997 fn load_vault_not_a_recipient() {
1998 let _lock = ENV_LOCK
1999 .lock()
2000 .unwrap_or_else(std::sync::PoisonError::into_inner);
2001
2002 let (secret, _pubkey) = generate_keypair();
2003 let (other_secret, other_pubkey) = generate_keypair();
2004 let other_recipient = make_recipient(&other_pubkey);
2005
2006 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
2007 let _ = fs::remove_dir_all(&dir);
2008 fs::create_dir_all(&dir).unwrap();
2009 let path = dir.join("test.murk");
2010
2011 let mut vault = types::Vault {
2013 version: types::VAULT_VERSION.into(),
2014 created: "2026-02-28T00:00:00Z".into(),
2015 vault_name: ".murk".into(),
2016 repo: String::new(),
2017 recipients: vec![other_pubkey.clone()],
2018 schema: BTreeMap::new(),
2019 policy: None,
2020 secrets: BTreeMap::new(),
2021 meta: String::new(),
2022 };
2023 vault.secrets.insert(
2024 "KEY1".into(),
2025 types::SecretEntry {
2026 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
2027 private: BTreeMap::new(),
2028 grouped: std::collections::BTreeMap::default(),
2029 },
2030 );
2031
2032 let mut recipients_map = HashMap::new();
2034 recipients_map.insert(other_pubkey.clone(), "other".into());
2035 let original = types::Murk {
2036 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2037 recipients: recipients_map,
2038 private: HashMap::new(),
2039 legacy_mac: false,
2040 github_pins: HashMap::new(),
2041 ..Default::default()
2042 };
2043
2044 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
2045 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2046 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2047
2048 unsafe { std::env::set_var("MURK_KEY", secret) };
2050 let result = load_vault(path.to_str().unwrap());
2051 unsafe { std::env::remove_var("MURK_KEY") };
2052
2053 let Err(err) = result else {
2054 panic!("expected load_vault to fail for non-recipient");
2055 };
2056 let msg = err.to_string();
2058 assert!(
2059 msg.contains("decryption failed")
2060 || msg.contains("no meta")
2061 || msg.contains("tampered"),
2062 "expected decryption or integrity failure, got: {err}"
2063 );
2064
2065 fs::remove_dir_all(&dir).unwrap();
2066 }
2067
2068 #[test]
2069 fn load_vault_zero_secrets() {
2070 let _lock = ENV_LOCK
2071 .lock()
2072 .unwrap_or_else(std::sync::PoisonError::into_inner);
2073
2074 let (secret, pubkey) = generate_keypair();
2075
2076 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
2077 let _ = fs::remove_dir_all(&dir);
2078 fs::create_dir_all(&dir).unwrap();
2079 let path = dir.join("test.murk");
2080
2081 let mut vault = types::Vault {
2083 version: types::VAULT_VERSION.into(),
2084 created: "2026-02-28T00:00:00Z".into(),
2085 vault_name: ".murk".into(),
2086 repo: String::new(),
2087 recipients: vec![pubkey.clone()],
2088 schema: BTreeMap::new(),
2089 policy: None,
2090 secrets: BTreeMap::new(),
2091 meta: String::new(),
2092 };
2093
2094 let mut recipients_map = HashMap::new();
2095 recipients_map.insert(pubkey.clone(), "alice".into());
2096 let original = types::Murk {
2097 values: HashMap::new(),
2098 recipients: recipients_map,
2099 private: HashMap::new(),
2100 legacy_mac: false,
2101 github_pins: HashMap::new(),
2102 ..Default::default()
2103 };
2104
2105 unsafe { std::env::set_var("MURK_KEY", &secret) };
2106 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2107 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2108
2109 let result = load_vault(path.to_str().unwrap());
2110 unsafe { std::env::remove_var("MURK_KEY") };
2111
2112 assert!(result.is_ok());
2113 let (_, murk, _) = result.unwrap();
2114 assert!(murk.values.is_empty());
2115 assert!(murk.private.is_empty());
2116
2117 fs::remove_dir_all(&dir).unwrap();
2118 }
2119
2120 #[test]
2121 fn load_vault_stripped_meta_with_secrets_fails() {
2122 let _lock = ENV_LOCK
2123 .lock()
2124 .unwrap_or_else(std::sync::PoisonError::into_inner);
2125
2126 let (secret, pubkey) = generate_keypair();
2127 let recipient = make_recipient(&pubkey);
2128
2129 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
2130 let _ = fs::remove_dir_all(&dir);
2131 fs::create_dir_all(&dir).unwrap();
2132 let path = dir.join("test.murk");
2133
2134 let mut vault = types::Vault {
2136 version: types::VAULT_VERSION.into(),
2137 created: "2026-02-28T00:00:00Z".into(),
2138 vault_name: ".murk".into(),
2139 repo: String::new(),
2140 recipients: vec![pubkey.clone()],
2141 schema: BTreeMap::new(),
2142 policy: None,
2143 secrets: BTreeMap::new(),
2144 meta: String::new(),
2145 };
2146 vault.secrets.insert(
2147 "KEY1".into(),
2148 types::SecretEntry {
2149 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2150 private: BTreeMap::new(),
2151 grouped: std::collections::BTreeMap::default(),
2152 },
2153 );
2154
2155 let mut recipients_map = HashMap::new();
2156 recipients_map.insert(pubkey.clone(), "alice".into());
2157 let original = types::Murk {
2158 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2159 recipients: recipients_map,
2160 private: HashMap::new(),
2161 legacy_mac: false,
2162 github_pins: HashMap::new(),
2163 ..Default::default()
2164 };
2165
2166 unsafe { std::env::set_var("MURK_KEY", &secret) };
2167 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2168 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2169
2170 let mut tampered: types::Vault =
2172 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2173 tampered.meta = String::new();
2174 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2175
2176 let result = load_vault(path.to_str().unwrap());
2178 unsafe { std::env::remove_var("MURK_KEY") };
2179
2180 let err = result.expect_err("expected MAC validation to fail");
2181 assert!(
2182 err.to_string().contains("integrity check failed"),
2183 "expected integrity check failure, got: {err}"
2184 );
2185
2186 fs::remove_dir_all(&dir).unwrap();
2187 }
2188
2189 #[test]
2190 fn load_vault_empty_mac_with_secrets_fails() {
2191 let _lock = ENV_LOCK
2192 .lock()
2193 .unwrap_or_else(std::sync::PoisonError::into_inner);
2194
2195 let (secret, pubkey) = generate_keypair();
2196 let recipient = make_recipient(&pubkey);
2197
2198 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
2199 let _ = fs::remove_dir_all(&dir);
2200 fs::create_dir_all(&dir).unwrap();
2201 let path = dir.join("test.murk");
2202
2203 let mut vault = types::Vault {
2205 version: types::VAULT_VERSION.into(),
2206 created: "2026-02-28T00:00:00Z".into(),
2207 vault_name: ".murk".into(),
2208 repo: String::new(),
2209 recipients: vec![pubkey.clone()],
2210 schema: BTreeMap::new(),
2211 policy: None,
2212 secrets: BTreeMap::new(),
2213 meta: String::new(),
2214 };
2215 vault.secrets.insert(
2216 "KEY1".into(),
2217 types::SecretEntry {
2218 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2219 private: BTreeMap::new(),
2220 grouped: std::collections::BTreeMap::default(),
2221 },
2222 );
2223
2224 let mut recipients_map = HashMap::new();
2226 recipients_map.insert(pubkey.clone(), "alice".into());
2227 let meta = types::Meta {
2228 recipients: recipients_map,
2229 mac: String::new(),
2230 mac_key: None,
2231 github_pins: HashMap::new(),
2232 ..Default::default()
2233 };
2234 let meta_json = serde_json::to_vec(&meta).unwrap();
2235 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
2236
2237 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
2239
2240 unsafe { std::env::set_var("MURK_KEY", &secret) };
2242 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2243 let result = load_vault(path.to_str().unwrap());
2244 unsafe { std::env::remove_var("MURK_KEY") };
2245
2246 let err = result.expect_err("expected MAC validation to fail");
2247 assert!(
2248 err.to_string().contains("integrity check failed"),
2249 "expected integrity check failure, got: {err}"
2250 );
2251
2252 fs::remove_dir_all(&dir).unwrap();
2253 }
2254
2255 #[test]
2256 fn compute_mac_changes_with_scoped_entries() {
2257 let mut vault = types::Vault {
2258 version: types::VAULT_VERSION.into(),
2259 created: "2026-02-28T00:00:00Z".into(),
2260 vault_name: ".murk".into(),
2261 repo: String::new(),
2262 recipients: vec!["age1abc".into()],
2263 schema: BTreeMap::new(),
2264 policy: None,
2265 secrets: BTreeMap::new(),
2266 meta: String::new(),
2267 };
2268
2269 vault.secrets.insert(
2270 "KEY".into(),
2271 types::SecretEntry {
2272 shared: "ciphertext".into(),
2273 private: BTreeMap::new(),
2274 grouped: std::collections::BTreeMap::default(),
2275 },
2276 );
2277
2278 let key = [0u8; 32];
2279 let mac_no_scoped = compute_mac(
2280 &vault,
2281 &std::collections::BTreeMap::new(),
2282 &std::collections::BTreeMap::new(),
2283 Some(&key),
2284 );
2285
2286 vault
2287 .secrets
2288 .get_mut("KEY")
2289 .unwrap()
2290 .private
2291 .insert("age1bob".into(), "scoped-ct".into());
2292
2293 let mac_with_scoped = compute_mac(
2294 &vault,
2295 &std::collections::BTreeMap::new(),
2296 &std::collections::BTreeMap::new(),
2297 Some(&key),
2298 );
2299 assert_ne!(mac_no_scoped, mac_with_scoped);
2300 }
2301
2302 #[test]
2303 #[allow(clippy::too_many_lines)] fn verify_mac_accepts_v1_prefix() {
2305 let vault = types::Vault {
2306 version: types::VAULT_VERSION.into(),
2307 created: "2026-02-28T00:00:00Z".into(),
2308 vault_name: ".murk".into(),
2309 repo: String::new(),
2310 recipients: vec!["age1abc".into()],
2311 schema: BTreeMap::new(),
2312 policy: None,
2313 secrets: BTreeMap::new(),
2314 meta: String::new(),
2315 };
2316
2317 let key = [0u8; 32];
2318 let v1_mac = compute_mac_v1(&vault);
2319 let v2_mac = compute_mac_v2(&vault);
2320 let v3_mac = compute_mac_v3(&vault, &key);
2321 assert!(verify_mac(
2322 &vault,
2323 &std::collections::BTreeMap::new(),
2324 &std::collections::BTreeMap::new(),
2325 &v1_mac,
2326 None
2327 ));
2328 assert!(verify_mac(
2329 &vault,
2330 &std::collections::BTreeMap::new(),
2331 &std::collections::BTreeMap::new(),
2332 &v2_mac,
2333 None
2334 ));
2335 assert!(verify_mac(
2336 &vault,
2337 &std::collections::BTreeMap::new(),
2338 &std::collections::BTreeMap::new(),
2339 &v3_mac,
2340 Some(&key)
2341 ));
2342 assert!(!verify_mac(
2343 &vault,
2344 &std::collections::BTreeMap::new(),
2345 &std::collections::BTreeMap::new(),
2346 "sha256:bogus",
2347 None
2348 ));
2349 assert!(!verify_mac(
2350 &vault,
2351 &std::collections::BTreeMap::new(),
2352 &std::collections::BTreeMap::new(),
2353 "blake3:bogus",
2354 Some(&key)
2355 ));
2356 assert!(!verify_mac(
2357 &vault,
2358 &std::collections::BTreeMap::new(),
2359 &std::collections::BTreeMap::new(),
2360 "blake3v2:bogus",
2361 Some(&key)
2362 ));
2363 assert!(!verify_mac(
2364 &vault,
2365 &std::collections::BTreeMap::new(),
2366 &std::collections::BTreeMap::new(),
2367 "blake3v3:bogus",
2368 Some(&key)
2369 ));
2370 assert!(!verify_mac(
2371 &vault,
2372 &std::collections::BTreeMap::new(),
2373 &std::collections::BTreeMap::new(),
2374 "unknown:prefix",
2375 None
2376 ));
2377
2378 let v4_mac = compute_mac_v4(&vault, &key);
2380 assert!(v4_mac.starts_with("blake3v2:"));
2381 assert!(verify_mac(
2382 &vault,
2383 &std::collections::BTreeMap::new(),
2384 &std::collections::BTreeMap::new(),
2385 &v4_mac,
2386 Some(&key)
2387 ));
2388
2389 let v5_mac = compute_mac_v5(&vault, &key);
2391 assert!(v5_mac.starts_with("blake3v3:"));
2392 assert!(verify_mac(
2393 &vault,
2394 &std::collections::BTreeMap::new(),
2395 &std::collections::BTreeMap::new(),
2396 &v5_mac,
2397 Some(&key)
2398 ));
2399 assert!(
2401 compute_mac(
2402 &vault,
2403 &std::collections::BTreeMap::new(),
2404 &std::collections::BTreeMap::new(),
2405 Some(&key)
2406 )
2407 .starts_with("blake3v3:")
2408 );
2409
2410 let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
2412 let v6_mac = compute_mac(
2413 &vault,
2414 &groups,
2415 &std::collections::BTreeMap::new(),
2416 Some(&key),
2417 );
2418 assert!(v6_mac.starts_with("blake3v4:"));
2419 assert!(verify_mac(
2420 &vault,
2421 &groups,
2422 &std::collections::BTreeMap::new(),
2423 &v6_mac,
2424 Some(&key)
2425 ));
2426 let tampered = BTreeMap::from([(
2428 "prod".to_string(),
2429 vec!["age1abc".to_string(), "age1evil".to_string()],
2430 )]);
2431 assert!(!verify_mac(
2432 &vault,
2433 &tampered,
2434 &std::collections::BTreeMap::new(),
2435 &v6_mac,
2436 Some(&key)
2437 ));
2438 }
2439
2440 #[test]
2441 fn verify_mac_rejects_grouped_under_legacy_prefix() {
2442 let mut vault = types::Vault {
2446 version: types::VAULT_VERSION.into(),
2447 created: "2026-02-28T00:00:00Z".into(),
2448 vault_name: ".murk".into(),
2449 repo: String::new(),
2450 recipients: vec!["age1abc".into()],
2451 schema: BTreeMap::new(),
2452 policy: None,
2453 secrets: BTreeMap::new(),
2454 meta: String::new(),
2455 };
2456 let key = [7u8; 32];
2457 let no_groups = BTreeMap::new();
2458 let v5_mac = compute_mac(
2459 &vault,
2460 &no_groups,
2461 &std::collections::BTreeMap::new(),
2462 Some(&key),
2463 );
2464 assert!(v5_mac.starts_with("blake3v3:"));
2465 assert!(verify_mac(
2466 &vault,
2467 &no_groups,
2468 &std::collections::BTreeMap::new(),
2469 &v5_mac,
2470 Some(&key)
2471 ));
2472
2473 vault.secrets.insert(
2475 "STOLEN".into(),
2476 types::SecretEntry {
2477 grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
2478 ..Default::default()
2479 },
2480 );
2481 assert!(!verify_mac(
2482 &vault,
2483 &no_groups,
2484 &std::collections::BTreeMap::new(),
2485 &v5_mac,
2486 Some(&key)
2487 ));
2488 }
2489
2490 #[test]
2491 fn mac_v7_covers_grant_metadata() {
2492 let vault = types::Vault {
2493 version: types::VAULT_VERSION.into(),
2494 created: "2026-02-28T00:00:00Z".into(),
2495 vault_name: ".murk".into(),
2496 repo: String::new(),
2497 recipients: vec!["age1abc".into(), "age1agent".into()],
2498 schema: BTreeMap::new(),
2499 policy: None,
2500 secrets: BTreeMap::new(),
2501 meta: String::new(),
2502 };
2503 let key = [9u8; 32];
2504 let no_groups = BTreeMap::new();
2505
2506 let grants = BTreeMap::from([(
2508 "codex".to_string(),
2509 types::GrantEntry {
2510 pubkey: "age1agent".into(),
2511 scope: vec!["STRIPE_KEY".into()],
2512 issued_at: "2026-02-28T00:00:00Z".into(),
2513 expires_at: "2026-02-28T02:00:00Z".into(),
2514 issuer: "age1abc".into(),
2515 },
2516 )]);
2517 let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
2518 assert!(v7_mac.starts_with("blake3v5:"));
2519 assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
2520
2521 let tampered = BTreeMap::from([(
2523 "codex".to_string(),
2524 types::GrantEntry {
2525 pubkey: "age1agent".into(),
2526 scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
2527 issued_at: "2026-02-28T00:00:00Z".into(),
2528 expires_at: "2026-02-28T02:00:00Z".into(),
2529 issuer: "age1abc".into(),
2530 },
2531 )]);
2532 assert!(!verify_mac(
2533 &vault,
2534 &no_groups,
2535 &tampered,
2536 &v7_mac,
2537 Some(&key)
2538 ));
2539 }
2540
2541 #[test]
2542 fn verify_mac_rejects_grants_under_legacy_prefix() {
2543 let vault = types::Vault {
2547 version: types::VAULT_VERSION.into(),
2548 created: "2026-02-28T00:00:00Z".into(),
2549 vault_name: ".murk".into(),
2550 repo: String::new(),
2551 recipients: vec!["age1abc".into()],
2552 schema: BTreeMap::new(),
2553 policy: None,
2554 secrets: BTreeMap::new(),
2555 meta: String::new(),
2556 };
2557 let key = [3u8; 32];
2558 let no_groups = BTreeMap::new();
2559 let grants = BTreeMap::from([(
2560 "codex".to_string(),
2561 types::GrantEntry {
2562 pubkey: "age1agent".into(),
2563 scope: vec!["STRIPE_KEY".into()],
2564 issued_at: "2026-02-28T00:00:00Z".into(),
2565 expires_at: "2026-02-28T02:00:00Z".into(),
2566 issuer: "age1abc".into(),
2567 },
2568 )]);
2569 let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
2571 assert!(v6_mac.starts_with("blake3v4:"));
2572 assert!(!verify_mac(
2573 &vault,
2574 &no_groups,
2575 &grants,
2576 &v6_mac,
2577 Some(&key)
2578 ));
2579 }
2580
2581 #[test]
2582 fn mac_v8_covers_policy() {
2583 let mut vault = types::Vault {
2584 version: types::VAULT_VERSION.into(),
2585 created: "2026-02-28T00:00:00Z".into(),
2586 vault_name: ".murk".into(),
2587 repo: String::new(),
2588 recipients: vec!["age1abc".into()],
2589 schema: BTreeMap::new(),
2590 policy: Some(types::Policy {
2591 agent_allow_tags: vec!["agents".into()],
2592 }),
2593 secrets: BTreeMap::new(),
2594 meta: String::new(),
2595 };
2596 let key = [11u8; 32];
2597 let no_groups = BTreeMap::new();
2598 let no_grants = BTreeMap::new();
2599
2600 let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
2602 assert!(v8_mac.starts_with("blake3v6:"));
2603 assert!(verify_mac(
2604 &vault,
2605 &no_groups,
2606 &no_grants,
2607 &v8_mac,
2608 Some(&key)
2609 ));
2610
2611 vault.policy = Some(types::Policy {
2613 agent_allow_tags: vec!["agents".into(), "production".into()],
2614 });
2615 assert!(!verify_mac(
2616 &vault,
2617 &no_groups,
2618 &no_grants,
2619 &v8_mac,
2620 Some(&key)
2621 ));
2622 }
2623
2624 #[test]
2625 fn mac_v8_policy_tags_are_unambiguous() {
2626 let base = types::Vault {
2629 version: types::VAULT_VERSION.into(),
2630 created: "2026-02-28T00:00:00Z".into(),
2631 vault_name: ".murk".into(),
2632 repo: String::new(),
2633 recipients: vec!["age1abc".into()],
2634 schema: BTreeMap::new(),
2635 policy: None,
2636 secrets: BTreeMap::new(),
2637 meta: String::new(),
2638 };
2639 let key = [7u8; 32];
2640 let groups = BTreeMap::new();
2641 let grants = BTreeMap::new();
2642
2643 let mut a = base.clone();
2644 a.policy = Some(types::Policy {
2645 agent_allow_tags: vec!["a\tb".into()],
2646 });
2647 let mut b = base.clone();
2648 b.policy = Some(types::Policy {
2649 agent_allow_tags: vec!["a".into(), "b".into()],
2650 });
2651
2652 let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
2653 let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
2654 assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
2655 }
2656
2657 #[test]
2658 fn verify_mac_rejects_policy_under_legacy_prefix() {
2659 let vault = types::Vault {
2663 version: types::VAULT_VERSION.into(),
2664 created: "2026-02-28T00:00:00Z".into(),
2665 vault_name: ".murk".into(),
2666 repo: String::new(),
2667 recipients: vec!["age1abc".into()],
2668 schema: BTreeMap::new(),
2669 policy: Some(types::Policy {
2670 agent_allow_tags: vec!["agents".into()],
2671 }),
2672 secrets: BTreeMap::new(),
2673 meta: String::new(),
2674 };
2675 let key = [5u8; 32];
2676 let no_groups = BTreeMap::new();
2677 let no_grants = BTreeMap::new();
2678 let v5_mac = compute_mac_v5(&vault, &key);
2680 assert!(v5_mac.starts_with("blake3v3:"));
2681 assert!(!verify_mac(
2682 &vault,
2683 &no_groups,
2684 &no_grants,
2685 &v5_mac,
2686 Some(&key)
2687 ));
2688 }
2689
2690 #[test]
2691 fn compute_mac_v5_covers_rotation_metadata() {
2692 let mut vault = types::Vault {
2693 version: types::VAULT_VERSION.into(),
2694 created: "2026-02-28T00:00:00Z".into(),
2695 vault_name: ".murk".into(),
2696 repo: String::new(),
2697 recipients: vec!["age1abc".into()],
2698 schema: BTreeMap::new(),
2699 policy: None,
2700 secrets: BTreeMap::new(),
2701 meta: String::new(),
2702 };
2703 vault.schema.insert(
2704 "API_KEY".into(),
2705 types::SchemaEntry {
2706 description: "Main API key".into(),
2707 updated: Some("2026-02-28T00:00:00Z".into()),
2708 ..Default::default()
2709 },
2710 );
2711
2712 let key = [0u8; 32];
2713 let baseline = compute_mac(
2714 &vault,
2715 &std::collections::BTreeMap::new(),
2716 &std::collections::BTreeMap::new(),
2717 Some(&key),
2718 );
2719
2720 vault
2722 .schema
2723 .get_mut("API_KEY")
2724 .unwrap()
2725 .rotation_interval_days = Some(90);
2726 let with_interval = compute_mac(
2727 &vault,
2728 &std::collections::BTreeMap::new(),
2729 &std::collections::BTreeMap::new(),
2730 Some(&key),
2731 );
2732 assert_ne!(baseline, with_interval);
2733
2734 vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
2736 let with_expiry = compute_mac(
2737 &vault,
2738 &std::collections::BTreeMap::new(),
2739 &std::collections::BTreeMap::new(),
2740 Some(&key),
2741 );
2742 assert_ne!(with_interval, with_expiry);
2743
2744 let mut cleared = vault.clone();
2747 cleared
2748 .schema
2749 .get_mut("API_KEY")
2750 .unwrap()
2751 .rotation_interval_days = None;
2752 cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
2753 assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
2754 }
2755
2756 #[test]
2757 fn compute_mac_v9_covers_revoked_at() {
2758 let mut vault = types::Vault {
2759 version: types::VAULT_VERSION.into(),
2760 created: "2026-02-28T00:00:00Z".into(),
2761 vault_name: ".murk".into(),
2762 repo: String::new(),
2763 recipients: vec!["age1abc".into()],
2764 schema: BTreeMap::new(),
2765 policy: None,
2766 secrets: BTreeMap::new(),
2767 meta: String::new(),
2768 };
2769 vault.schema.insert(
2770 "API_KEY".into(),
2771 types::SchemaEntry {
2772 description: "Main API key".into(),
2773 updated: Some("2026-02-28T00:00:00Z".into()),
2774 ..Default::default()
2775 },
2776 );
2777 let key = [0u8; 32];
2778 let groups = BTreeMap::new();
2779 let grants = BTreeMap::new();
2780
2781 let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
2783 assert!(baseline.starts_with("blake3v3:"));
2784
2785 vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
2787 let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
2788 assert!(with_marker.starts_with("blake3v7:"));
2789 assert_ne!(baseline, with_marker);
2790
2791 assert!(verify_mac(
2794 &vault,
2795 &groups,
2796 &grants,
2797 &with_marker,
2798 Some(&key)
2799 ));
2800 let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
2801 assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
2802
2803 let mut cleared = vault.clone();
2806 cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
2807 assert_eq!(
2808 compute_mac_v8(&vault, &groups, &grants, &key),
2809 compute_mac_v8(&cleared, &groups, &grants, &key)
2810 );
2811 }
2812
2813 #[test]
2814 fn compute_mac_changes_with_schema() {
2815 let mut vault = types::Vault {
2816 version: types::VAULT_VERSION.into(),
2817 created: "2026-02-28T00:00:00Z".into(),
2818 vault_name: ".murk".into(),
2819 repo: String::new(),
2820 recipients: vec!["age1abc".into()],
2821 schema: BTreeMap::new(),
2822 policy: None,
2823 secrets: BTreeMap::new(),
2824 meta: String::new(),
2825 };
2826
2827 let key = [0u8; 32];
2828 let mac_no_schema = compute_mac(
2829 &vault,
2830 &std::collections::BTreeMap::new(),
2831 &std::collections::BTreeMap::new(),
2832 Some(&key),
2833 );
2834
2835 vault.schema.insert(
2836 "API_KEY".into(),
2837 types::SchemaEntry {
2838 description: "Main API key".into(),
2839 tags: vec!["deploy".into()],
2840 ..Default::default()
2841 },
2842 );
2843
2844 let mac_with_schema = compute_mac(
2845 &vault,
2846 &std::collections::BTreeMap::new(),
2847 &std::collections::BTreeMap::new(),
2848 Some(&key),
2849 );
2850 assert_ne!(mac_no_schema, mac_with_schema);
2851
2852 let mac_before_retag = mac_with_schema;
2854 vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
2855 let mac_after_retag = compute_mac(
2856 &vault,
2857 &std::collections::BTreeMap::new(),
2858 &std::collections::BTreeMap::new(),
2859 Some(&key),
2860 );
2861 assert_ne!(mac_before_retag, mac_after_retag);
2862 }
2863
2864 #[test]
2865 fn mac_key_roundtrip() {
2866 let hex = generate_mac_key();
2867 assert_eq!(hex.len(), 64);
2868 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
2869
2870 let key = decode_mac_key(&hex).expect("valid hex should decode");
2871 let rehex = key.iter().fold(String::new(), |mut s, b| {
2873 use std::fmt::Write;
2874 let _ = write!(s, "{b:02x}");
2875 s
2876 });
2877 assert_eq!(hex, rehex);
2878 }
2879
2880 #[test]
2881 fn decode_mac_key_rejects_bad_input() {
2882 assert!(decode_mac_key("").is_none());
2883 assert!(decode_mac_key("tooshort").is_none());
2884 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()); }
2888
2889 #[test]
2890 fn blake3_mac_different_key_different_mac() {
2891 let vault = types::Vault {
2892 version: types::VAULT_VERSION.into(),
2893 created: "2026-02-28T00:00:00Z".into(),
2894 vault_name: ".murk".into(),
2895 repo: String::new(),
2896 recipients: vec!["age1abc".into()],
2897 schema: BTreeMap::new(),
2898 policy: None,
2899 secrets: BTreeMap::new(),
2900 meta: String::new(),
2901 };
2902
2903 let key1 = [0u8; 32];
2904 let key2 = [1u8; 32];
2905 let mac1 = compute_mac(
2906 &vault,
2907 &std::collections::BTreeMap::new(),
2908 &std::collections::BTreeMap::new(),
2909 Some(&key1),
2910 );
2911 let mac2 = compute_mac(
2912 &vault,
2913 &std::collections::BTreeMap::new(),
2914 &std::collections::BTreeMap::new(),
2915 Some(&key2),
2916 );
2917 assert_ne!(mac1, mac2);
2918 }
2919
2920 #[test]
2921 fn valid_key_names() {
2922 assert!(is_valid_key_name("DATABASE_URL"));
2923 assert!(is_valid_key_name("_PRIVATE"));
2924 assert!(is_valid_key_name("A"));
2925 assert!(is_valid_key_name("key123"));
2926 }
2927
2928 #[test]
2929 fn invalid_key_names() {
2930 assert!(!is_valid_key_name(""));
2931 assert!(!is_valid_key_name("123_START"));
2932 assert!(!is_valid_key_name("KEY-NAME"));
2933 assert!(!is_valid_key_name("KEY NAME"));
2934 assert!(!is_valid_key_name("FOO$(bar)"));
2935 assert!(!is_valid_key_name("KEY=VAL"));
2936 }
2937
2938 #[test]
2939 fn now_utc_format() {
2940 let ts = now_utc();
2941 assert!(ts.ends_with('Z'));
2942 assert_eq!(ts.len(), 20);
2943 assert_eq!(&ts[4..5], "-");
2944 assert_eq!(&ts[7..8], "-");
2945 assert_eq!(&ts[10..11], "T");
2946 }
2947}