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 mod cli;
22pub(crate) mod codename;
23pub mod crypto;
24pub mod edit;
25pub(crate) mod env;
26pub mod error;
27pub(crate) mod export;
28pub(crate) mod git;
29pub mod github;
30pub(crate) mod grants;
31pub(crate) mod groups;
32pub mod hardening;
33pub(crate) mod info;
34pub(crate) mod init;
35pub(crate) mod merge;
36pub mod pins;
37pub(crate) mod policy;
38pub(crate) mod recipients;
39pub mod recovery;
40pub mod scan;
41pub(crate) mod secrets;
42pub mod signing;
43pub mod types;
44pub mod vault;
45
46#[cfg(feature = "python")]
47mod python;
48
49#[cfg(test)]
51pub mod testutil;
52
53pub use agent::{AgentPlan, AgentPlanKey, agent_plan, format_agent_plan_text};
55pub use env::{
56 EnvrcStatus, KeySource, agent_key_file_path, agent_keys_dir, dotenv_has_murk_key,
57 key_file_path, parse_env, resolve_key, resolve_key_for_vault, resolve_key_with_source,
58 warn_env_permissions, write_envrc, write_key_ref_to_dotenv, write_key_to_dotenv,
59 write_key_to_file,
60};
61pub use error::MurkError;
62pub use export::{
63 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
64 parse_and_decrypt_values, resolve_secrets,
65};
66pub use git::{CommitSignature, MergeDriverSetupStep, last_commit_signature, setup_merge_driver};
67pub use github::{GitHubError, fetch_keys};
68pub use grants::{create_grant, parse_ttl, remove_grant, validate_grant_name};
69pub use groups::{
70 add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name,
71};
72pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info};
73pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
74pub use merge::{MergeDriverOutput, run_merge_driver};
75pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity, is_agent_key_allowed};
76pub use recipients::{
77 RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
78 list_recipients, revoke_recipient, truncate_pubkey,
79};
80pub use secrets::{
81 EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
82 import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
83};
84
85use std::collections::{BTreeMap, BTreeSet, HashMap};
86use std::path::Path;
87
88pub fn is_valid_key_name(key: &str) -> bool {
91 !key.is_empty()
92 && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
93 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
94}
95
96use age::secrecy::ExposeSecret;
97use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
98use zeroize::Zeroizing;
99
100pub use crypto::{MurkIdentity, MurkRecipient};
102
103pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
105 if vault.meta.is_empty() {
106 return None;
107 }
108 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
109 serde_json::from_slice(&plaintext).ok()
110}
111
112pub(crate) fn parse_recipients(
114 pubkeys: &[String],
115) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
116 pubkeys
117 .iter()
118 .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
119 .collect()
120}
121
122pub fn encrypt_value(
124 plaintext: &[u8],
125 recipients: &[crypto::MurkRecipient],
126) -> Result<String, MurkError> {
127 let ciphertext = crypto::encrypt(plaintext, recipients)?;
128 Ok(BASE64.encode(&ciphertext))
129}
130
131pub fn decrypt_value(
135 encoded: &str,
136 identity: &crypto::MurkIdentity,
137) -> Result<Zeroizing<Vec<u8>>, MurkError> {
138 let ciphertext = BASE64.decode(encoded).map_err(|e| {
139 MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
140 })?;
141 Ok(crypto::decrypt(&ciphertext, identity)?)
142}
143
144pub(crate) fn plaintext_bytes_to_zeroizing_string(
150 bytes: &[u8],
151) -> Result<Zeroizing<String>, std::str::Utf8Error> {
152 let s = std::str::from_utf8(bytes)?;
153 Ok(Zeroizing::new(s.to_owned()))
154}
155
156pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
160 Ok(vault::read(Path::new(vault_path))?)
161}
162
163pub fn resolve_vault_path(arg: &str) -> String {
179 use std::path::PathBuf;
180
181 if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
183 return arg.to_string();
184 }
185
186 let Ok(cwd) = std::env::current_dir() else {
187 return arg.to_string();
188 };
189
190 if cwd.join(arg).exists() {
192 return arg.to_string();
193 }
194
195 let home = std::env::var_os("HOME").map(PathBuf::from);
196 let mut dir = cwd.as_path();
197 loop {
198 let candidate = dir.join(arg);
199 if candidate.exists() {
200 return candidate.to_string_lossy().into_owned();
201 }
202 if dir.join(".git").exists() {
204 break;
205 }
206 if let Some(ref h) = home
208 && dir == h.as_path()
209 {
210 break;
211 }
212 match dir.parent() {
213 Some(parent) => dir = parent,
214 None => break,
215 }
216 }
217
218 arg.to_string()
219}
220
221struct MetaState {
225 recipients: HashMap<String, String>,
226 groups: BTreeMap<String, Vec<String>>,
227 grants: BTreeMap<String, types::GrantEntry>,
228 legacy_mac: bool,
229 github_pins: HashMap<String, Vec<String>>,
230 signers: BTreeMap<String, String>,
231 signature: types::SignatureState,
232}
233
234fn check_signature(
238 vault: &types::Vault,
239 meta: &types::Meta,
240) -> Result<types::SignatureState, MurkError> {
241 match &meta.sig {
242 Some(sig) => {
243 if verify_vault_signature(
244 vault,
245 &meta.groups,
246 &meta.grants,
247 &meta.github_pins,
248 &meta.signers,
249 sig,
250 ) {
251 Ok(types::SignatureState::Signed {
252 signer: sig.signer.clone(),
253 anchored: sig.signer.starts_with("ssh-ed25519 "),
257 })
258 } else {
259 Err(MurkError::Integrity(
260 "vault signature is invalid — it may have been tampered with, or a signer's \
261 verifying key changed. Run `murk verify` for details"
262 .into(),
263 ))
264 }
265 }
266 None => Ok(types::SignatureState::Unsigned),
267 }
268}
269
270fn resolve_meta_state(
277 vault: &types::Vault,
278 identity: &crypto::MurkIdentity,
279) -> Result<MetaState, MurkError> {
280 if vault.meta.is_empty() {
281 if vault.secrets.is_empty() {
282 return Ok(MetaState {
283 recipients: HashMap::new(),
284 groups: BTreeMap::new(),
285 grants: BTreeMap::new(),
286 legacy_mac: false,
287 github_pins: HashMap::new(),
288 signers: BTreeMap::new(),
289 signature: types::SignatureState::Unsigned,
290 });
291 }
292 return Err(MurkError::Integrity(
293 "vault has secrets but no meta — vault may have been tampered with".into(),
294 ));
295 }
296
297 let ciphertext = BASE64.decode(&vault.meta).map_err(|_| {
311 MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
312 })?;
313 let plaintext = match crypto::decrypt(&ciphertext, identity) {
314 Ok(plaintext) => plaintext,
315 Err(e) if matches!(identity, crypto::MurkIdentity::Plugin { .. }) => {
318 return Err(e.into());
319 }
320 Err(_) => {
321 if identity
322 .pubkey_string()
323 .is_ok_and(|pk| is_listed_recipient(vault, &pk))
324 {
325 return Err(MurkError::Integrity(
326 "your key is listed as a recipient but cannot decrypt the vault meta — vault may have been tampered with".into(),
327 ));
328 }
329 return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
330 "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
331 )));
332 }
333 };
334 let meta: types::Meta = serde_json::from_slice(&plaintext).map_err(|_| {
335 MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
336 })?;
337
338 if meta.mac.is_empty() {
339 if !vault.secrets.is_empty() {
340 return Err(MurkError::Integrity(
341 "vault has secrets but MAC is empty — vault may have been tampered with".into(),
342 ));
343 }
344 let signature = check_signature(vault, &meta)?;
345 return Ok(MetaState {
346 recipients: meta.recipients,
347 groups: meta.groups,
348 grants: meta.grants,
349 legacy_mac: false,
350 github_pins: meta.github_pins,
351 signers: meta.signers,
352 signature,
353 });
354 }
355
356 let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
357 if !verify_mac(
358 vault,
359 &meta.groups,
360 &meta.grants,
361 &meta.mac,
362 mac_key.as_ref(),
363 ) {
364 let expected = compute_mac(vault, &meta.groups, &meta.grants, mac_key.as_ref());
365 return Err(MurkError::Integrity(format!(
366 "vault may have been tampered with (expected {expected}, got {})",
367 meta.mac
368 )));
369 }
370 let legacy_mac = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
371 let signature = check_signature(vault, &meta)?;
372 Ok(MetaState {
373 recipients: meta.recipients,
374 groups: meta.groups,
375 grants: meta.grants,
376 legacy_mac,
377 github_pins: meta.github_pins,
378 signers: meta.signers,
379 signature,
380 })
381}
382
383fn is_listed_recipient(vault: &types::Vault, pubkey: &str) -> bool {
387 fn ssh_head(s: &str) -> Option<(&str, &str)> {
388 let mut it = s.split_whitespace();
389 match (it.next(), it.next()) {
390 (Some(kind), Some(blob)) if kind.starts_with("ssh-") => Some((kind, blob)),
391 _ => None,
392 }
393 }
394 vault.recipients.iter().any(|r| {
395 r == pubkey || matches!((ssh_head(r), ssh_head(pubkey)), (Some(a), Some(b)) if a == b)
396 })
397}
398
399pub fn decrypt_vault(
405 vault: &types::Vault,
406 identity: &crypto::MurkIdentity,
407) -> Result<types::Murk, MurkError> {
408 let pubkey = identity.pubkey_string()?;
409
410 let MetaState {
413 recipients,
414 groups,
415 grants,
416 legacy_mac,
417 github_pins,
418 signers,
419 signature,
420 } = resolve_meta_state(vault, identity)?;
421
422 let is_agent = grants.values().any(|g| g.pubkey == pubkey);
429
430 let mut values: HashMap<String, Zeroizing<String>> = HashMap::new();
432 for (key, entry) in &vault.secrets {
433 if entry.shared.is_empty() {
434 continue;
435 }
436 let plaintext = match decrypt_value(&entry.shared, identity) {
437 Ok(plaintext) => plaintext,
438 Err(_) if is_agent => continue,
439 Err(_) => {
440 return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
441 "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
442 )));
443 }
444 };
445 let value = plaintext_bytes_to_zeroizing_string(&plaintext)
446 .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
447 values.insert(key.clone(), value);
448 }
449
450 let mut private: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
452 for (key, entry) in &vault.secrets {
453 if let Some(encoded) = entry.private.get(&pubkey)
454 && let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
455 plaintext_bytes_to_zeroizing_string(&pt)
456 .map_err(|e| MurkError::Secret(e.to_string()))
457 })
458 {
459 private
460 .entry(key.clone())
461 .or_default()
462 .insert(pubkey.clone(), value);
463 }
464 }
465
466 let mut grouped: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
470 for (key, entry) in &vault.secrets {
471 for (group, encoded) in &entry.grouped {
472 if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
473 plaintext_bytes_to_zeroizing_string(&pt)
474 .map_err(|e| MurkError::Secret(e.to_string()))
475 }) {
476 grouped
477 .entry(key.clone())
478 .or_default()
479 .insert(group.clone(), value);
480 }
481 }
482 }
483
484 Ok(types::Murk {
485 values,
486 recipients,
487 private,
488 grouped,
489 groups,
490 grants,
491 legacy_mac,
492 github_pins,
493 signers,
494 signature,
495 })
496}
497
498pub fn load_vault(
502 vault_path: &str,
503) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
504 let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
505
506 let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
507 MurkError::Key(format!(
508 "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
509 ))
510 })?;
511
512 let vault = read_vault(vault_path)?;
513 let mut murk = decrypt_vault(&vault, &identity)?;
514
515 match pins::reconcile(vault_path, &murk.signers) {
524 pins::PinVerdict::Conflict { signer } => {
525 return Err(MurkError::Integrity(format!(
526 "signer {signer}'s verifying key changed since first seen — the signer registry \
527 may have been tampered with to forge a signature. Inspect \
528 `git log -p -- {vault_path}`; if the change is legitimate, clear the pin under \
529 ~/.config/murk/signer-pins/ or set MURK_NO_SIGNER_PIN=1"
530 )));
531 }
532 pins::PinVerdict::Ok { first_use } => {
533 if let types::SignatureState::Signed { signer, anchored } = &mut murk.signature
539 && !*anchored
540 && !first_use.contains(signer.as_str())
541 {
542 *anchored = true;
543 }
544 }
545 }
546
547 Ok((vault, murk, identity))
548}
549
550fn rebuild_shared(
553 key: &str,
554 vault: &types::Vault,
555 recipients: &[crypto::MurkRecipient],
556 recipients_changed: bool,
557 original: &types::Murk,
558 current: &types::Murk,
559) -> Result<String, MurkError> {
560 let Some(value) = current.values.get(key) else {
561 return Ok(String::new());
563 };
564 if !recipients_changed
566 && original.values.get(key) == Some(value)
567 && let Some(existing) = vault.secrets.get(key)
568 {
569 return Ok(existing.shared.clone());
570 }
571 encrypt_value(value.as_bytes(), recipients)
572}
573
574fn rebuild_private(
577 key: &str,
578 vault: &types::Vault,
579 original: &types::Murk,
580 current: &types::Murk,
581) -> Result<BTreeMap<String, String>, MurkError> {
582 let mut scoped = vault
583 .secrets
584 .get(key)
585 .map(|e| e.private.clone())
586 .unwrap_or_default();
587
588 if let Some(key_scoped) = current.private.get(key) {
589 for (pk, val) in key_scoped {
590 let original_val = original.private.get(key).and_then(|m| m.get(pk));
591 if original_val != Some(val) {
592 let recipient = crypto::parse_recipient(pk)?;
593 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
594 }
595 }
596 }
597
598 if let Some(orig_key_scoped) = original.private.get(key) {
599 for pk in orig_key_scoped.keys() {
600 let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
601 if !still_present {
602 scoped.remove(pk);
603 }
604 }
605 }
606
607 Ok(scoped)
608}
609
610fn rebuild_grouped(
614 key: &str,
615 vault: &types::Vault,
616 changed_groups: &BTreeSet<&str>,
617 original: &types::Murk,
618 current: &types::Murk,
619) -> Result<BTreeMap<String, String>, MurkError> {
620 let mut grouped = vault
621 .secrets
622 .get(key)
623 .map(|e| e.grouped.clone())
624 .unwrap_or_default();
625
626 if let Some(key_grouped) = current.grouped.get(key) {
627 for (group, val) in key_grouped {
628 let members = current.groups.get(group).ok_or_else(|| {
629 MurkError::Secret(format!("secret {key} references unknown group {group}"))
630 })?;
631 let original_val = original.grouped.get(key).and_then(|m| m.get(group));
632 if original_val != Some(val) || changed_groups.contains(group.as_str()) {
633 let group_recipients = parse_recipients(members)?;
634 grouped.insert(
635 group.clone(),
636 encrypt_value(val.as_bytes(), &group_recipients)?,
637 );
638 }
639 }
640 }
641
642 if let Some(orig_key_grouped) = original.grouped.get(key) {
643 for group in orig_key_grouped.keys() {
644 let still_present = current
645 .grouped
646 .get(key)
647 .is_some_and(|m| m.contains_key(group));
648 if !still_present {
649 grouped.remove(group);
650 }
651 }
652 }
653
654 Ok(grouped)
655}
656
657fn resync_grant_private(
666 key: &str,
667 private: &mut BTreeMap<String, String>,
668 original: &types::Murk,
669 current: &types::Murk,
670) -> Result<(), MurkError> {
671 let Some(value) = current.values.get(key) else {
672 return Ok(());
673 };
674 if original.values.get(key) == Some(value) {
675 return Ok(());
676 }
677 for grant in current.grants.values() {
678 if grant.scope.iter().any(|k| k == key) {
679 let recipient = crypto::parse_recipient(&grant.pubkey)?;
680 private.insert(
681 grant.pubkey.clone(),
682 encrypt_value(value.as_bytes(), &[recipient])?,
683 );
684 }
685 }
686 Ok(())
687}
688
689pub fn save_vault(
692 vault_path: &str,
693 vault: &mut types::Vault,
694 original: &types::Murk,
695 current: &types::Murk,
696) -> Result<(), MurkError> {
697 let recipients = parse_recipients(&vault.recipients)?;
700
701 let grant_pubkeys: BTreeSet<&str> =
706 current.grants.values().map(|g| g.pubkey.as_str()).collect();
707 let shared_recipients: Vec<crypto::MurkRecipient> = vault
708 .recipients
709 .iter()
710 .filter(|pk| !grant_pubkeys.contains(pk.as_str()))
711 .map(|pk| crypto::parse_recipient(pk))
712 .collect::<Result<_, _>>()?;
713
714 let shared_recipients_changed = {
719 let orig_grant_pubkeys: BTreeSet<&str> = original
720 .grants
721 .values()
722 .map(|g| g.pubkey.as_str())
723 .collect();
724 let mut current_pks: Vec<&str> = vault
725 .recipients
726 .iter()
727 .map(String::as_str)
728 .filter(|pk| !grant_pubkeys.contains(pk))
729 .collect();
730 let mut original_pks: Vec<&str> = original
731 .recipients
732 .keys()
733 .map(String::as_str)
734 .filter(|pk| !orig_grant_pubkeys.contains(pk))
735 .collect();
736 current_pks.sort_unstable();
737 original_pks.sort_unstable();
738 current_pks != original_pks
739 };
740
741 let changed_groups: BTreeSet<&str> = current
745 .groups
746 .keys()
747 .chain(original.groups.keys())
748 .filter(|g| current.groups.get(*g) != original.groups.get(*g))
749 .map(String::as_str)
750 .collect();
751
752 let mut new_secrets = BTreeMap::new();
753
754 let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
757 all_keys.extend(current.private.keys());
758 all_keys.extend(current.grouped.keys());
759
760 let original_visible: BTreeSet<&String> = original
766 .values
767 .keys()
768 .chain(original.private.keys())
769 .chain(original.grouped.keys())
770 .collect();
771 for key in vault.secrets.keys() {
772 if !original_visible.contains(key) {
773 all_keys.insert(key);
774 }
775 }
776
777 for key in all_keys {
778 let shared = rebuild_shared(
779 key,
780 vault,
781 &shared_recipients,
782 shared_recipients_changed,
783 original,
784 current,
785 )?;
786 let mut private = rebuild_private(key, vault, original, current)?;
787 resync_grant_private(key, &mut private, original, current)?;
788 let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
789 new_secrets.insert(
790 key.clone(),
791 types::SecretEntry {
792 shared,
793 private,
794 grouped,
795 },
796 );
797 }
798
799 vault.secrets = new_secrets;
800
801 let meta = build_meta(vault_path, vault, current);
802 let meta_json =
803 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
804 vault.meta = encrypt_value(&meta_json, &recipients)?;
805
806 Ok(vault::write(Path::new(vault_path), vault)?)
807}
808
809fn build_meta(vault_path: &str, vault: &types::Vault, current: &types::Murk) -> types::Meta {
814 let mac_key_hex = generate_mac_key();
816 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
817 let mac = compute_mac(vault, ¤t.groups, ¤t.grants, Some(&mac_key));
818
819 let mut signers = current.signers.clone();
822 signers.retain(|pk, _| vault.recipients.iter().any(|r| r == pk));
828 let sig = signing_identity(vault_path).and_then(|identity| {
829 sign_vault(
830 vault,
831 ¤t.groups,
832 ¤t.grants,
833 ¤t.github_pins,
834 &mut signers,
835 &identity,
836 )
837 });
838
839 types::Meta {
840 recipients: current.recipients.clone(),
841 mac,
842 mac_key: Some(mac_key_hex),
843 github_pins: current.github_pins.clone(),
844 groups: current.groups.clone(),
845 grants: current.grants.clone(),
846 signers,
847 sig,
848 }
849}
850
851pub(crate) fn compute_mac(
860 vault: &types::Vault,
861 groups: &BTreeMap<String, Vec<String>>,
862 grants: &BTreeMap<String, types::GrantEntry>,
863 mac_key: Option<&[u8; 32]>,
864) -> String {
865 match mac_key {
866 Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
867 compute_mac_v9(vault, groups, grants, key)
868 }
869 Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
870 Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
871 Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
872 Some(key) => compute_mac_v5(vault, key),
873 None => compute_mac_v2(vault),
874 }
875}
876
877fn compute_mac_v1(vault: &types::Vault) -> String {
879 use sha2::{Digest, Sha256};
880
881 let mut hasher = Sha256::new();
882
883 for key in vault.secrets.keys() {
884 hasher.update(key.as_bytes());
885 hasher.update(b"\x00");
886 }
887
888 for entry in vault.secrets.values() {
889 hasher.update(entry.shared.as_bytes());
890 hasher.update(b"\x00");
891 }
892
893 let mut pks = vault.recipients.clone();
894 pks.sort();
895 for pk in &pks {
896 hasher.update(pk.as_bytes());
897 hasher.update(b"\x00");
898 }
899
900 let digest = hasher.finalize();
901 format!(
902 "sha256:{}",
903 digest.iter().fold(String::new(), |mut s, b| {
904 use std::fmt::Write;
905 let _ = write!(s, "{b:02x}");
906 s
907 })
908 )
909}
910
911fn compute_mac_v2(vault: &types::Vault) -> String {
913 use sha2::{Digest, Sha256};
914
915 let mut hasher = Sha256::new();
916
917 for key in vault.secrets.keys() {
919 hasher.update(key.as_bytes());
920 hasher.update(b"\x00");
921 }
922
923 for entry in vault.secrets.values() {
925 hasher.update(entry.shared.as_bytes());
926 hasher.update(b"\x00");
927
928 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
930 scoped_pks.sort();
931 for pk in scoped_pks {
932 hasher.update(pk.as_bytes());
933 hasher.update(b"\x01");
934 hasher.update(entry.private[pk].as_bytes());
935 hasher.update(b"\x00");
936 }
937 }
938
939 let mut pks = vault.recipients.clone();
941 pks.sort();
942 for pk in &pks {
943 hasher.update(pk.as_bytes());
944 hasher.update(b"\x00");
945 }
946
947 let digest = hasher.finalize();
948 format!(
949 "sha256v2:{}",
950 digest.iter().fold(String::new(), |mut s, b| {
951 use std::fmt::Write;
952 let _ = write!(s, "{b:02x}");
953 s
954 })
955 )
956}
957
958fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
960 let mut data = Vec::new();
961
962 for key_name in vault.secrets.keys() {
963 data.extend_from_slice(key_name.as_bytes());
964 data.push(0x00);
965 }
966
967 for entry in vault.secrets.values() {
968 data.extend_from_slice(entry.shared.as_bytes());
969 data.push(0x00);
970
971 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
972 scoped_pks.sort();
973 for pk in scoped_pks {
974 data.extend_from_slice(pk.as_bytes());
975 data.push(0x01);
976 data.extend_from_slice(entry.private[pk].as_bytes());
977 data.push(0x00);
978 }
979 }
980
981 let mut pks = vault.recipients.clone();
982 pks.sort();
983 for pk in &pks {
984 data.extend_from_slice(pk.as_bytes());
985 data.push(0x00);
986 }
987
988 let hash = blake3::keyed_hash(key, &data);
989 format!("blake3:{hash}")
990}
991
992fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
995 let mut data = Vec::new();
996
997 for key_name in vault.secrets.keys() {
998 data.extend_from_slice(key_name.as_bytes());
999 data.push(0x00);
1000 }
1001
1002 for entry in vault.secrets.values() {
1003 data.extend_from_slice(entry.shared.as_bytes());
1004 data.push(0x00);
1005
1006 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1007 scoped_pks.sort();
1008 for pk in scoped_pks {
1009 data.extend_from_slice(pk.as_bytes());
1010 data.push(0x01);
1011 data.extend_from_slice(entry.private[pk].as_bytes());
1012 data.push(0x00);
1013 }
1014 }
1015
1016 let mut pks = vault.recipients.clone();
1017 pks.sort();
1018 for pk in &pks {
1019 data.extend_from_slice(pk.as_bytes());
1020 data.push(0x00);
1021 }
1022
1023 for (key_name, entry) in &vault.schema {
1026 data.push(0x02);
1027 data.extend_from_slice(key_name.as_bytes());
1028 data.push(0x00);
1029 data.extend_from_slice(entry.description.as_bytes());
1030 data.push(0x00);
1031 if let Some(example) = &entry.example {
1032 data.extend_from_slice(example.as_bytes());
1033 }
1034 data.push(0x00);
1035 for tag in &entry.tags {
1036 data.extend_from_slice(tag.as_bytes());
1037 data.push(0x00);
1038 }
1039 }
1040
1041 let hash = blake3::keyed_hash(key, &data);
1042 format!("blake3v2:{hash}")
1043}
1044
1045fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
1051 let mut data = Vec::new();
1052
1053 for key_name in vault.secrets.keys() {
1054 data.extend_from_slice(key_name.as_bytes());
1055 data.push(0x00);
1056 }
1057
1058 for entry in vault.secrets.values() {
1059 data.extend_from_slice(entry.shared.as_bytes());
1060 data.push(0x00);
1061
1062 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1063 scoped_pks.sort();
1064 for pk in scoped_pks {
1065 data.extend_from_slice(pk.as_bytes());
1066 data.push(0x01);
1067 data.extend_from_slice(entry.private[pk].as_bytes());
1068 data.push(0x00);
1069 }
1070 }
1071
1072 let mut pks = vault.recipients.clone();
1073 pks.sort();
1074 for pk in &pks {
1075 data.extend_from_slice(pk.as_bytes());
1076 data.push(0x00);
1077 }
1078
1079 for (key_name, entry) in &vault.schema {
1084 data.push(0x02);
1085 data.extend_from_slice(key_name.as_bytes());
1086 data.push(0x00);
1087 data.extend_from_slice(entry.description.as_bytes());
1088 data.push(0x00);
1089 if let Some(example) = &entry.example {
1090 data.extend_from_slice(example.as_bytes());
1091 }
1092 data.push(0x00);
1093 for tag in &entry.tags {
1094 data.extend_from_slice(tag.as_bytes());
1095 data.push(0x00);
1096 }
1097 if let Some(created) = &entry.created {
1100 data.extend_from_slice(created.as_bytes());
1101 }
1102 data.push(0x00);
1103 if let Some(updated) = &entry.updated {
1104 data.extend_from_slice(updated.as_bytes());
1105 }
1106 data.push(0x00);
1107 if let Some(days) = entry.rotation_interval_days {
1108 data.extend_from_slice(days.to_string().as_bytes());
1109 }
1110 data.push(0x00);
1111 if let Some(expires) = &entry.expires_at {
1112 data.extend_from_slice(expires.as_bytes());
1113 }
1114 data.push(0x00);
1115 }
1116
1117 let hash = blake3::keyed_hash(key, &data);
1118 format!("blake3v3:{hash}")
1119}
1120
1121fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
1125 for (key_name, entry) in &vault.schema {
1126 data.push(0x02);
1127 data.extend_from_slice(key_name.as_bytes());
1128 data.push(0x00);
1129 data.extend_from_slice(entry.description.as_bytes());
1130 data.push(0x00);
1131 if let Some(example) = &entry.example {
1132 data.extend_from_slice(example.as_bytes());
1133 }
1134 data.push(0x00);
1135 for tag in &entry.tags {
1136 data.extend_from_slice(tag.as_bytes());
1137 data.push(0x00);
1138 }
1139 if let Some(created) = &entry.created {
1140 data.extend_from_slice(created.as_bytes());
1141 }
1142 data.push(0x00);
1143 if let Some(updated) = &entry.updated {
1144 data.extend_from_slice(updated.as_bytes());
1145 }
1146 data.push(0x00);
1147 if let Some(days) = entry.rotation_interval_days {
1148 data.extend_from_slice(days.to_string().as_bytes());
1149 }
1150 data.push(0x00);
1151 if let Some(expires) = &entry.expires_at {
1152 data.extend_from_slice(expires.as_bytes());
1153 }
1154 data.push(0x00);
1155 }
1156}
1157
1158fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
1162 for key_name in vault.secrets.keys() {
1163 data.extend_from_slice(key_name.as_bytes());
1164 data.push(0x00);
1165 }
1166
1167 for entry in vault.secrets.values() {
1168 data.extend_from_slice(entry.shared.as_bytes());
1169 data.push(0x00);
1170
1171 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1172 scoped_pks.sort();
1173 for pk in scoped_pks {
1174 data.extend_from_slice(pk.as_bytes());
1175 data.push(0x01);
1176 data.extend_from_slice(entry.private[pk].as_bytes());
1177 data.push(0x00);
1178 }
1179
1180 let mut group_names: Vec<&String> = entry.grouped.keys().collect();
1183 group_names.sort();
1184 for g in group_names {
1185 data.push(0x03);
1186 data.extend_from_slice(g.as_bytes());
1187 data.push(0x00);
1188 data.extend_from_slice(entry.grouped[g].as_bytes());
1189 data.push(0x00);
1190 }
1191 }
1192
1193 let mut pks = vault.recipients.clone();
1194 pks.sort();
1195 for pk in &pks {
1196 data.extend_from_slice(pk.as_bytes());
1197 data.push(0x00);
1198 }
1199
1200 schema_mac_bytes(vault, data);
1201
1202 for (name, members) in groups {
1205 data.push(0x04);
1206 data.extend_from_slice(name.as_bytes());
1207 data.push(0x00);
1208 let mut sorted = members.clone();
1209 sorted.sort();
1210 for member in &sorted {
1211 data.push(0x05);
1212 data.extend_from_slice(member.as_bytes());
1213 }
1214 }
1215}
1216
1217fn compute_mac_v6(
1222 vault: &types::Vault,
1223 groups: &BTreeMap<String, Vec<String>>,
1224 key: &[u8; 32],
1225) -> String {
1226 let mut data = Vec::new();
1227 v6_mac_bytes(vault, groups, &mut data);
1228 let hash = blake3::keyed_hash(key, &data);
1229 format!("blake3v4:{hash}")
1230}
1231
1232fn v7_mac_bytes(
1241 vault: &types::Vault,
1242 groups: &BTreeMap<String, Vec<String>>,
1243 grants: &BTreeMap<String, types::GrantEntry>,
1244 data: &mut Vec<u8>,
1245) {
1246 v6_mac_bytes(vault, groups, data);
1247
1248 for (name, grant) in grants {
1252 data.push(0x06);
1253 data.extend_from_slice(name.as_bytes());
1254 data.push(0x00);
1255 data.extend_from_slice(grant.pubkey.as_bytes());
1256 data.push(0x00);
1257 data.extend_from_slice(grant.issued_at.as_bytes());
1258 data.push(0x00);
1259 data.extend_from_slice(grant.expires_at.as_bytes());
1260 data.push(0x00);
1261 data.extend_from_slice(grant.issuer.as_bytes());
1262 data.push(0x00);
1263 let mut scope = grant.scope.clone();
1264 scope.sort();
1265 for k in &scope {
1266 data.push(0x07);
1267 data.extend_from_slice(k.as_bytes());
1268 }
1269 }
1270}
1271
1272fn compute_mac_v7(
1273 vault: &types::Vault,
1274 groups: &BTreeMap<String, Vec<String>>,
1275 grants: &BTreeMap<String, types::GrantEntry>,
1276 key: &[u8; 32],
1277) -> String {
1278 let mut data = Vec::new();
1279 v7_mac_bytes(vault, groups, grants, &mut data);
1280 let hash = blake3::keyed_hash(key, &data);
1281 format!("blake3v5:{hash}")
1282}
1283
1284fn v8_mac_bytes(
1288 vault: &types::Vault,
1289 groups: &BTreeMap<String, Vec<String>>,
1290 grants: &BTreeMap<String, types::GrantEntry>,
1291 data: &mut Vec<u8>,
1292) {
1293 v7_mac_bytes(vault, groups, grants, data);
1294
1295 if let Some(policy) = &vault.policy {
1302 data.push(0x08);
1303 let mut tags = policy.agent_allow_tags.clone();
1304 tags.sort();
1305 for tag in &tags {
1306 let bytes = tag.as_bytes();
1307 data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
1310 data.extend_from_slice(bytes);
1311 }
1312 }
1313}
1314
1315fn compute_mac_v8(
1320 vault: &types::Vault,
1321 groups: &BTreeMap<String, Vec<String>>,
1322 grants: &BTreeMap<String, types::GrantEntry>,
1323 key: &[u8; 32],
1324) -> String {
1325 let mut data = Vec::new();
1326 v8_mac_bytes(vault, groups, grants, &mut data);
1327 let hash = blake3::keyed_hash(key, &data);
1328 format!("blake3v6:{hash}")
1329}
1330
1331fn compute_mac_v9(
1337 vault: &types::Vault,
1338 groups: &BTreeMap<String, Vec<String>>,
1339 grants: &BTreeMap<String, types::GrantEntry>,
1340 key: &[u8; 32],
1341) -> String {
1342 let mut data = Vec::new();
1343 v8_mac_bytes(vault, groups, grants, &mut data);
1344
1345 for (key_name, entry) in &vault.schema {
1350 if let Some(revoked_at) = &entry.revoked_at {
1351 data.push(0x09);
1352 data.extend_from_slice(key_name.as_bytes());
1353 data.push(0x00);
1354 data.extend_from_slice(revoked_at.as_bytes());
1355 data.push(0x00);
1356 }
1357 }
1358
1359 let hash = blake3::keyed_hash(key, &data);
1360 format!("blake3v7:{hash}")
1361}
1362
1363pub(crate) fn verify_mac(
1366 vault: &types::Vault,
1367 groups: &BTreeMap<String, Vec<String>>,
1368 grants: &BTreeMap<String, types::GrantEntry>,
1369 stored_mac: &str,
1370 mac_key: Option<&[u8; 32]>,
1371) -> bool {
1372 use constant_time_eq::constant_time_eq;
1373
1374 if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
1378 {
1379 return false;
1380 }
1381
1382 if vault.policy.is_some()
1386 && !stored_mac.starts_with("blake3v6:")
1387 && !stored_mac.starts_with("blake3v7:")
1388 {
1389 return false;
1390 }
1391
1392 if !grants.is_empty()
1395 && !stored_mac.starts_with("blake3v5:")
1396 && !stored_mac.starts_with("blake3v6:")
1397 && !stored_mac.starts_with("blake3v7:")
1398 {
1399 return false;
1400 }
1401
1402 let touches_groups =
1408 !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
1409 if touches_groups
1410 && !stored_mac.starts_with("blake3v4:")
1411 && !stored_mac.starts_with("blake3v5:")
1412 && !stored_mac.starts_with("blake3v6:")
1413 && !stored_mac.starts_with("blake3v7:")
1414 {
1415 return false;
1416 }
1417
1418 let expected = if stored_mac.starts_with("blake3v7:") {
1419 match mac_key {
1420 Some(key) => compute_mac_v9(vault, groups, grants, key),
1421 None => return false,
1422 }
1423 } else if stored_mac.starts_with("blake3v6:") {
1424 match mac_key {
1425 Some(key) => compute_mac_v8(vault, groups, grants, key),
1426 None => return false,
1427 }
1428 } else if stored_mac.starts_with("blake3v5:") {
1429 match mac_key {
1430 Some(key) => compute_mac_v7(vault, groups, grants, key),
1431 None => return false,
1432 }
1433 } else if stored_mac.starts_with("blake3v4:") {
1434 match mac_key {
1435 Some(key) => compute_mac_v6(vault, groups, key),
1436 None => return false,
1437 }
1438 } else if stored_mac.starts_with("blake3v3:") {
1439 match mac_key {
1440 Some(key) => compute_mac_v5(vault, key),
1441 None => return false,
1442 }
1443 } else if stored_mac.starts_with("blake3v2:") {
1444 match mac_key {
1445 Some(key) => compute_mac_v4(vault, key),
1446 None => return false,
1447 }
1448 } else if stored_mac.starts_with("blake3:") {
1449 match mac_key {
1450 Some(key) => compute_mac_v3(vault, key),
1451 None => return false,
1452 }
1453 } else if stored_mac.starts_with("sha256v2:") {
1454 compute_mac_v2(vault)
1455 } else if stored_mac.starts_with("sha256:") {
1456 compute_mac_v1(vault)
1457 } else {
1458 return false;
1459 };
1460 constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
1461}
1462
1463pub(crate) fn generate_mac_key() -> String {
1465 let key: [u8; 32] = rand::random();
1466 key.iter().fold(String::new(), |mut s, b| {
1467 use std::fmt::Write;
1468 let _ = write!(s, "{b:02x}");
1469 s
1470 })
1471}
1472
1473pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
1475 if hex.len() != 64 {
1476 return None;
1477 }
1478 let mut key = [0u8; 32];
1479 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
1480 key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
1481 }
1482 Some(key)
1483}
1484
1485pub(crate) fn now_utc() -> String {
1487 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1488}
1489
1490const SIGNED_VIEW_VERSION: u32 = 1;
1494
1495pub(crate) fn signing_message(
1504 vault: &types::Vault,
1505 groups: &BTreeMap<String, Vec<String>>,
1506 grants: &BTreeMap<String, types::GrantEntry>,
1507 github_pins: &HashMap<String, Vec<String>>,
1508 signers: &BTreeMap<String, String>,
1509) -> Vec<u8> {
1510 #[derive(serde::Serialize)]
1511 struct SignedView<'a> {
1512 v: u32,
1513 version: &'a str,
1514 recipients: Vec<&'a str>,
1515 schema: &'a BTreeMap<String, types::SchemaEntry>,
1516 secrets: &'a BTreeMap<String, types::SecretEntry>,
1517 policy: &'a Option<types::Policy>,
1518 groups: &'a BTreeMap<String, Vec<String>>,
1519 grants: &'a BTreeMap<String, types::GrantEntry>,
1520 github_pins: BTreeMap<&'a str, &'a Vec<String>>,
1521 signers: &'a BTreeMap<String, String>,
1522 }
1523
1524 let mut recipients: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
1525 recipients.sort_unstable();
1526 let pins: BTreeMap<&str, &Vec<String>> =
1527 github_pins.iter().map(|(k, v)| (k.as_str(), v)).collect();
1528
1529 let view = SignedView {
1530 v: SIGNED_VIEW_VERSION,
1531 version: &vault.version,
1532 recipients,
1533 schema: &vault.schema,
1534 secrets: &vault.secrets,
1535 policy: &vault.policy,
1536 groups,
1537 grants,
1538 github_pins: pins,
1539 signers,
1540 };
1541
1542 let mut msg = Vec::with_capacity(256);
1543 msg.extend_from_slice(b"murk.vault.sig.v1\n");
1544 serde_json::to_writer(&mut msg, &view).expect("canonical vault view serializes");
1545 msg
1546}
1547
1548pub(crate) fn sign_vault(
1552 vault: &types::Vault,
1553 groups: &BTreeMap<String, Vec<String>>,
1554 grants: &BTreeMap<String, types::GrantEntry>,
1555 github_pins: &HashMap<String, Vec<String>>,
1556 signers: &mut BTreeMap<String, String>,
1557 identity: &crypto::MurkIdentity,
1558) -> Option<types::VaultSignature> {
1559 let signer = identity.pubkey_string().ok()?;
1560 if !signer_is_recipient(vault, &signer) {
1564 return None;
1565 }
1566 let sk = identity.signing_key()?;
1567 if identity.registers_verifying_key() {
1571 signers.insert(signer.clone(), signing::verifying_key_b64(&sk));
1572 }
1573 let msg = signing_message(vault, groups, grants, github_pins, signers);
1574 Some(types::VaultSignature {
1575 signer,
1576 v: SIGNED_VIEW_VERSION,
1577 sig: signing::sign(&sk, &msg),
1578 })
1579}
1580
1581fn signer_is_recipient(vault: &types::Vault, signer: &str) -> bool {
1585 if signer.starts_with("ssh-ed25519 ") {
1586 vault
1587 .recipients
1588 .iter()
1589 .any(|r| signing::ssh_ed25519_key_eq(r, signer))
1590 } else {
1591 vault.recipients.iter().any(|r| r == signer)
1592 }
1593}
1594
1595pub(crate) fn verify_vault_signature(
1601 vault: &types::Vault,
1602 groups: &BTreeMap<String, Vec<String>>,
1603 grants: &BTreeMap<String, types::GrantEntry>,
1604 github_pins: &HashMap<String, Vec<String>>,
1605 signers: &BTreeMap<String, String>,
1606 sig: &types::VaultSignature,
1607) -> bool {
1608 if sig.v != SIGNED_VIEW_VERSION {
1609 return false;
1610 }
1611 if !signer_is_recipient(vault, &sig.signer) {
1612 return false;
1613 }
1614 let vk = if sig.signer.starts_with("ssh-ed25519 ") {
1615 match signing::ed25519_verifying_key_b64_from_ssh_recipient(&sig.signer) {
1617 Some(vk) => vk,
1618 None => return false,
1619 }
1620 } else {
1621 match signers.get(&sig.signer) {
1622 Some(vk) => vk.clone(),
1623 None => return false,
1624 }
1625 };
1626 let msg = signing_message(vault, groups, grants, github_pins, signers);
1627 signing::verify(&vk, &sig.sig, &msg)
1628}
1629
1630fn signing_identity(vault_path: &str) -> Option<crypto::MurkIdentity> {
1634 use age::secrecy::ExposeSecret;
1635 let secret = env::resolve_key_for_vault(vault_path).ok()?;
1636 crypto::parse_identity(secret.expose_secret()).ok()
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use super::*;
1642 use crate::testutil::*;
1643 use std::collections::BTreeMap;
1644 use std::fs;
1645
1646 use crate::testutil::ENV_LOCK;
1647
1648 #[test]
1649 fn resolve_vault_path_finds_in_parent_dir() {
1650 let _lock = ENV_LOCK
1651 .lock()
1652 .unwrap_or_else(std::sync::PoisonError::into_inner);
1653 let dir = tempfile::tempdir().unwrap();
1654 fs::create_dir(dir.path().join(".git")).unwrap();
1656 fs::write(dir.path().join(".murk"), "{}").unwrap();
1657 let nested = dir.path().join("a").join("b");
1658 fs::create_dir_all(&nested).unwrap();
1659
1660 let prev = std::env::current_dir().unwrap();
1661 std::env::set_current_dir(&nested).unwrap();
1662 let got = resolve_vault_path(".murk");
1663 std::env::set_current_dir(prev).unwrap();
1664
1665 assert_eq!(
1666 std::fs::canonicalize(&got).unwrap(),
1667 std::fs::canonicalize(dir.path().join(".murk")).unwrap()
1668 );
1669 }
1670
1671 #[test]
1672 fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
1673 let _lock = ENV_LOCK
1674 .lock()
1675 .unwrap_or_else(std::sync::PoisonError::into_inner);
1676 let dir = tempfile::tempdir().unwrap();
1677 fs::write(dir.path().join(".murk"), "{}").unwrap();
1678 let prev = std::env::current_dir().unwrap();
1679 std::env::set_current_dir(dir.path()).unwrap();
1680 let got = resolve_vault_path(".murk");
1681 std::env::set_current_dir(prev).unwrap();
1682 assert_eq!(got, ".murk");
1683 }
1684
1685 #[test]
1686 fn resolve_vault_path_passes_through_explicit_paths() {
1687 assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
1688 assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
1689 assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
1690 }
1691
1692 #[test]
1693 fn resolve_vault_path_stops_at_git_root() {
1694 let _lock = ENV_LOCK
1695 .lock()
1696 .unwrap_or_else(std::sync::PoisonError::into_inner);
1697 let dir = tempfile::tempdir().unwrap();
1698 fs::write(dir.path().join(".murk"), "{}").unwrap();
1700 let repo = dir.path().join("repo");
1701 fs::create_dir(&repo).unwrap();
1702 fs::create_dir(repo.join(".git")).unwrap();
1703 let nested = repo.join("sub");
1704 fs::create_dir(&nested).unwrap();
1705
1706 let prev = std::env::current_dir().unwrap();
1707 std::env::set_current_dir(&nested).unwrap();
1708 let got = resolve_vault_path(".murk");
1709 std::env::set_current_dir(prev).unwrap();
1710
1711 assert_eq!(got, ".murk");
1713 }
1714
1715 #[test]
1716 fn encrypt_decrypt_value_roundtrip() {
1717 let (secret, pubkey) = generate_keypair();
1718 let recipient = make_recipient(&pubkey);
1719 let identity = make_identity(&secret);
1720
1721 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
1722 let decrypted = decrypt_value(&encoded, &identity).unwrap();
1723 assert_eq!(&decrypted[..], b"hello world");
1724 }
1725
1726 #[test]
1727 fn decrypt_value_invalid_base64() {
1728 let (secret, _) = generate_keypair();
1729 let identity = make_identity(&secret);
1730
1731 let result = decrypt_value("not!valid!base64!!!", &identity);
1732 assert!(result.is_err());
1733 assert!(result.unwrap_err().to_string().contains("invalid base64"));
1734 }
1735
1736 #[test]
1737 fn encrypt_value_multiple_recipients() {
1738 let (secret_a, pubkey_a) = generate_keypair();
1739 let (secret_b, pubkey_b) = generate_keypair();
1740
1741 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
1742 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
1743
1744 let id_a = make_identity(&secret_a);
1746 let id_b = make_identity(&secret_b);
1747 assert_eq!(
1748 &decrypt_value(&encoded, &id_a).unwrap()[..],
1749 b"shared secret"
1750 );
1751 assert_eq!(
1752 &decrypt_value(&encoded, &id_b).unwrap()[..],
1753 b"shared secret"
1754 );
1755 }
1756
1757 #[test]
1758 fn decrypt_value_wrong_key_fails() {
1759 let (_, pubkey) = generate_keypair();
1760 let (wrong_secret, _) = generate_keypair();
1761
1762 let recipient = make_recipient(&pubkey);
1763 let wrong_identity = make_identity(&wrong_secret);
1764
1765 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
1766 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
1767 }
1768
1769 #[test]
1770 fn compute_mac_deterministic() {
1771 let vault = types::Vault {
1772 version: types::VAULT_VERSION.into(),
1773 created: "2026-02-28T00:00:00Z".into(),
1774 vault_name: ".murk".into(),
1775 repo: String::new(),
1776 recipients: vec!["age1abc".into()],
1777 schema: BTreeMap::new(),
1778 policy: None,
1779 secrets: BTreeMap::new(),
1780 meta: String::new(),
1781 };
1782
1783 let key = [0u8; 32];
1784 let mac1 = compute_mac(
1785 &vault,
1786 &std::collections::BTreeMap::new(),
1787 &std::collections::BTreeMap::new(),
1788 Some(&key),
1789 );
1790 let mac2 = compute_mac(
1791 &vault,
1792 &std::collections::BTreeMap::new(),
1793 &std::collections::BTreeMap::new(),
1794 Some(&key),
1795 );
1796 assert_eq!(mac1, mac2);
1797 assert!(mac1.starts_with("blake3v3:"));
1798
1799 let mac_legacy = compute_mac(
1801 &vault,
1802 &std::collections::BTreeMap::new(),
1803 &std::collections::BTreeMap::new(),
1804 None,
1805 );
1806 assert!(mac_legacy.starts_with("sha256v2:"));
1807 }
1808
1809 #[test]
1810 fn compute_mac_changes_with_different_secrets() {
1811 let mut vault = types::Vault {
1812 version: types::VAULT_VERSION.into(),
1813 created: "2026-02-28T00:00:00Z".into(),
1814 vault_name: ".murk".into(),
1815 repo: String::new(),
1816 recipients: vec!["age1abc".into()],
1817 schema: BTreeMap::new(),
1818 policy: None,
1819 secrets: BTreeMap::new(),
1820 meta: String::new(),
1821 };
1822
1823 let key = [0u8; 32];
1824 let mac_empty = compute_mac(
1825 &vault,
1826 &std::collections::BTreeMap::new(),
1827 &std::collections::BTreeMap::new(),
1828 Some(&key),
1829 );
1830
1831 vault.secrets.insert(
1832 "KEY".into(),
1833 types::SecretEntry {
1834 shared: "ciphertext".into(),
1835 private: BTreeMap::new(),
1836 grouped: std::collections::BTreeMap::default(),
1837 },
1838 );
1839
1840 let mac_with_secret = compute_mac(
1841 &vault,
1842 &std::collections::BTreeMap::new(),
1843 &std::collections::BTreeMap::new(),
1844 Some(&key),
1845 );
1846 assert_ne!(mac_empty, mac_with_secret);
1847 }
1848
1849 #[test]
1850 fn compute_mac_changes_with_different_recipients() {
1851 let mut vault = types::Vault {
1852 version: types::VAULT_VERSION.into(),
1853 created: "2026-02-28T00:00:00Z".into(),
1854 vault_name: ".murk".into(),
1855 repo: String::new(),
1856 recipients: vec!["age1abc".into()],
1857 schema: BTreeMap::new(),
1858 policy: None,
1859 secrets: BTreeMap::new(),
1860 meta: String::new(),
1861 };
1862
1863 let key = [0u8; 32];
1864 let mac1 = compute_mac(
1865 &vault,
1866 &std::collections::BTreeMap::new(),
1867 &std::collections::BTreeMap::new(),
1868 Some(&key),
1869 );
1870 vault.recipients.push("age1xyz".into());
1871 let mac2 = compute_mac(
1872 &vault,
1873 &std::collections::BTreeMap::new(),
1874 &std::collections::BTreeMap::new(),
1875 Some(&key),
1876 );
1877 assert_ne!(mac1, mac2);
1878 }
1879
1880 fn signed_test_vault(pubkey: &str, recipient: &crypto::MurkRecipient) -> types::Vault {
1882 let mut vault = types::Vault {
1883 version: types::VAULT_VERSION.into(),
1884 created: "2026-02-28T00:00:00Z".into(),
1885 vault_name: ".murk".into(),
1886 repo: String::new(),
1887 recipients: vec![pubkey.to_string()],
1888 schema: BTreeMap::new(),
1889 policy: None,
1890 secrets: BTreeMap::new(),
1891 meta: String::new(),
1892 };
1893 vault.secrets.insert(
1894 "API_KEY".into(),
1895 types::SecretEntry {
1896 shared: encrypt_value(b"REAL", std::slice::from_ref(recipient)).unwrap(),
1897 private: BTreeMap::new(),
1898 grouped: BTreeMap::new(),
1899 },
1900 );
1901 vault
1902 }
1903
1904 #[test]
1905 fn sign_and_verify_vault_roundtrips() {
1906 let (secret, pubkey) = generate_keypair();
1907 let identity = make_identity(&secret);
1908 let vault = signed_test_vault(&pubkey, &make_recipient(&pubkey));
1909 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1910
1911 let mut signers = BTreeMap::new();
1912 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1913 assert_eq!(sig.signer, pubkey);
1914 assert!(verify_vault_signature(
1915 &vault, &g, &gr, &pins, &signers, &sig
1916 ));
1917 }
1918
1919 #[test]
1920 fn signature_detects_ciphertext_tampering() {
1921 let (secret, pubkey) = generate_keypair();
1922 let identity = make_identity(&secret);
1923 let recipient = make_recipient(&pubkey);
1924 let mut vault = signed_test_vault(&pubkey, &recipient);
1925 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1926
1927 let mut signers = BTreeMap::new();
1928 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1929
1930 vault.secrets.get_mut("API_KEY").unwrap().shared =
1933 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
1934 assert!(
1935 !verify_vault_signature(&vault, &g, &gr, &pins, &signers, &sig),
1936 "tampered ciphertext must fail signature verification"
1937 );
1938 }
1939
1940 #[test]
1941 fn signature_rejects_non_recipient_signer() {
1942 let (_victim_secret, victim_pub) = generate_keypair();
1946 let (attacker_secret, attacker_pub) = generate_keypair();
1947 let attacker = make_identity(&attacker_secret);
1948 let vault = signed_test_vault(&victim_pub, &make_recipient(&victim_pub));
1949 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1950
1951 let mut signers = BTreeMap::new();
1953 assert!(sign_vault(&vault, &g, &gr, &pins, &mut signers, &attacker).is_none());
1954
1955 let sk = attacker.signing_key().unwrap();
1957 signers.insert(attacker_pub.clone(), signing::verifying_key_b64(&sk));
1958 let msg = signing_message(&vault, &g, &gr, &pins, &signers);
1959 let forged = types::VaultSignature {
1960 signer: attacker_pub,
1961 v: SIGNED_VIEW_VERSION,
1962 sig: signing::sign(&sk, &msg),
1963 };
1964 assert!(!verify_vault_signature(
1965 &vault, &g, &gr, &pins, &signers, &forged
1966 ));
1967 }
1968
1969 #[test]
1970 fn end_to_end_forged_signature_fails_load() {
1971 let _lock = ENV_LOCK
1976 .lock()
1977 .unwrap_or_else(std::sync::PoisonError::into_inner);
1978 let (secret, pubkey) = generate_keypair();
1979 let recipient = make_recipient(&pubkey);
1980 let identity = make_identity(&secret);
1981
1982 let dir = std::env::temp_dir().join("murk_test_forged_sig_load");
1983 let _ = fs::remove_dir_all(&dir);
1984 fs::create_dir_all(&dir).unwrap();
1985 let path = dir.join("test.murk");
1986
1987 let mut vault = signed_test_vault(&pubkey, &recipient);
1989 let original = types::Murk {
1990 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
1991 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
1992 ..Default::default()
1993 };
1994 unsafe { std::env::set_var("MURK_KEY", &secret) };
1995 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1996 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1997
1998 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2000 assert!(matches!(
2001 &murk.signature,
2002 types::SignatureState::Signed { signer, .. } if *signer == pubkey
2003 ));
2004
2005 let mut tampered: types::Vault =
2008 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2009 let stale_meta = decrypt_meta(&tampered, &identity).unwrap();
2010 tampered.secrets.get_mut("API_KEY").unwrap().shared =
2011 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2012 let mac_key_hex = generate_mac_key();
2013 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2014 let forged_mac = compute_mac(
2015 &tampered,
2016 &stale_meta.groups,
2017 &stale_meta.grants,
2018 Some(&mac_key),
2019 );
2020 let forged_meta = types::Meta {
2021 mac: forged_mac,
2022 mac_key: Some(mac_key_hex),
2023 sig: stale_meta.sig.clone(), signers: stale_meta.signers.clone(),
2025 ..stale_meta
2026 };
2027 tampered.meta =
2028 encrypt_value(&serde_json::to_vec(&forged_meta).unwrap(), &[recipient]).unwrap();
2029 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2030
2031 let result = load_vault(path.to_str().unwrap());
2032 unsafe { std::env::remove_var("MURK_KEY") };
2033 let err = result.expect_err("forged-MAC + stale-signature vault must fail to load");
2034 assert!(
2035 err.to_string().contains("signature is invalid"),
2036 "expected signature failure, got: {err}"
2037 );
2038
2039 fs::remove_dir_all(&dir).unwrap();
2040 }
2041
2042 const SSH_ED25519_SK: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
2044 const SSH_ED25519_PK: &str =
2045 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
2046
2047 #[test]
2048 fn ssh_signed_vault_verifies_without_registry() {
2049 let identity = make_identity(SSH_ED25519_SK);
2053 let recipient = make_recipient(SSH_ED25519_PK);
2054 let vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2055 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2056
2057 let mut signers = BTreeMap::new();
2058 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2059 assert_eq!(sig.signer, SSH_ED25519_PK);
2060 assert!(signers.is_empty(), "ssh signer must not be registered");
2061 assert!(verify_vault_signature(
2062 &vault,
2063 &g,
2064 &gr,
2065 &pins,
2066 &BTreeMap::new(),
2067 &sig
2068 ));
2069 }
2070
2071 #[test]
2072 fn ssh_signed_vault_detects_tampering() {
2073 let identity = make_identity(SSH_ED25519_SK);
2074 let recipient = make_recipient(SSH_ED25519_PK);
2075 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2076 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2077
2078 let mut signers = BTreeMap::new();
2079 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2080 vault.secrets.get_mut("API_KEY").unwrap().shared =
2081 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2082 assert!(!verify_vault_signature(
2083 &vault, &g, &gr, &pins, &signers, &sig
2084 ));
2085 }
2086
2087 #[test]
2088 fn ssh_recipient_stored_with_comment_still_signs_and_verifies() {
2089 let identity = make_identity(SSH_ED25519_SK);
2093 let recipient = make_recipient(SSH_ED25519_PK);
2094 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2095 vault.recipients = vec![format!("{SSH_ED25519_PK} someone@host")];
2096 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2097
2098 let mut signers = BTreeMap::new();
2099 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity)
2100 .expect("comment-bearing recipient must still sign");
2101 assert!(verify_vault_signature(
2102 &vault, &g, &gr, &pins, &signers, &sig
2103 ));
2104 }
2105
2106 #[test]
2107 fn ssh_end_to_end_save_and_load_reports_signed() {
2108 let _lock = ENV_LOCK
2109 .lock()
2110 .unwrap_or_else(std::sync::PoisonError::into_inner);
2111 let recipient = make_recipient(SSH_ED25519_PK);
2112
2113 let dir = std::env::temp_dir().join("murk_test_ssh_e2e_sign");
2114 let _ = fs::remove_dir_all(&dir);
2115 fs::create_dir_all(&dir).unwrap();
2116 let path = dir.join("test.murk");
2117
2118 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2119 let original = types::Murk {
2120 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2121 recipients: HashMap::from([(SSH_ED25519_PK.to_string(), "alice".to_string())]),
2122 ..Default::default()
2123 };
2124 unsafe { std::env::set_var("MURK_KEY", SSH_ED25519_SK) };
2125 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2126 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2127
2128 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2129 unsafe { std::env::remove_var("MURK_KEY") };
2130 assert_eq!(
2132 murk.signature,
2133 types::SignatureState::Signed {
2134 signer: SSH_ED25519_PK.to_string(),
2135 anchored: true,
2136 }
2137 );
2138
2139 fs::remove_dir_all(&dir).unwrap();
2140 }
2141
2142 #[test]
2143 fn save_prunes_stale_signer_registry_entries() {
2144 let _lock = ENV_LOCK
2147 .lock()
2148 .unwrap_or_else(std::sync::PoisonError::into_inner);
2149 let (secret, pubkey) = generate_keypair();
2150 let recipient = make_recipient(&pubkey);
2151
2152 let dir = std::env::temp_dir().join("murk_test_prune_signers");
2153 let _ = fs::remove_dir_all(&dir);
2154 fs::create_dir_all(&dir).unwrap();
2155 let path = dir.join("test.murk");
2156
2157 let mut vault = signed_test_vault(&pubkey, &recipient);
2158 let current = types::Murk {
2159 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2160 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2161 signers: BTreeMap::from([(
2163 "age1stalerevokedrecipient".to_string(),
2164 BASE64.encode([9u8; 32]),
2165 )]),
2166 ..Default::default()
2167 };
2168 unsafe { std::env::set_var("MURK_KEY", &secret) };
2169 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2170 save_vault(path.to_str().unwrap(), &mut vault, ¤t, ¤t).unwrap();
2171
2172 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2173 unsafe { std::env::remove_var("MURK_KEY") };
2174 assert!(
2175 !murk.signers.contains_key("age1stalerevokedrecipient"),
2176 "stale non-recipient signer entry must be pruned"
2177 );
2178 assert!(
2179 murk.signers.contains_key(&pubkey),
2180 "live signer must remain"
2181 );
2182 assert!(matches!(
2183 murk.signature,
2184 types::SignatureState::Signed { signer, .. } if signer == pubkey
2185 ));
2186
2187 fs::remove_dir_all(&dir).unwrap();
2188 }
2189
2190 #[test]
2191 fn registry_vk_swap_rejected_by_pin_on_load() {
2192 let _lock = ENV_LOCK
2197 .lock()
2198 .unwrap_or_else(std::sync::PoisonError::into_inner);
2199 let home = tempfile::tempdir().unwrap();
2201 let prev_home = std::env::var_os("HOME");
2202 unsafe { std::env::set_var("HOME", home.path()) };
2203 unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2204
2205 let (secret, pubkey) = generate_keypair();
2206 let recipient = make_recipient(&pubkey);
2207 let identity = make_identity(&secret);
2208
2209 let dir = std::env::temp_dir().join("murk_test_vk_swap");
2210 let _ = fs::remove_dir_all(&dir);
2211 fs::create_dir_all(&dir).unwrap();
2212 let path = dir.join("test.murk");
2213 let ps = path.to_str().unwrap();
2214
2215 let mut vault = signed_test_vault(&pubkey, &recipient);
2217 let original = types::Murk {
2218 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2219 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2220 ..Default::default()
2221 };
2222 unsafe { std::env::set_var("MURK_KEY", &secret) };
2223 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2224 save_vault(ps, &mut vault, &original, &original).unwrap();
2225 load_vault(ps).unwrap(); let att_sk = signing::signing_key_from_age_bytes(&[42u8; 32]);
2230 let att_vk = signing::verifying_key_b64(&att_sk);
2231 let mut tampered: types::Vault =
2232 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2233 let stale = decrypt_meta(&tampered, &identity).unwrap();
2234 tampered.secrets.get_mut("API_KEY").unwrap().shared =
2235 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2236 let mut signers = stale.signers.clone();
2237 signers.insert(pubkey.clone(), att_vk);
2238 let msg = signing_message(
2239 &tampered,
2240 &stale.groups,
2241 &stale.grants,
2242 &stale.github_pins,
2243 &signers,
2244 );
2245 let forged_sig = types::VaultSignature {
2246 signer: pubkey.clone(),
2247 v: SIGNED_VIEW_VERSION,
2248 sig: signing::sign(&att_sk, &msg),
2249 };
2250 assert!(verify_vault_signature(
2252 &tampered,
2253 &stale.groups,
2254 &stale.grants,
2255 &stale.github_pins,
2256 &signers,
2257 &forged_sig
2258 ));
2259 let mac_key_hex = generate_mac_key();
2260 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2261 let mac = compute_mac(&tampered, &stale.groups, &stale.grants, Some(&mac_key));
2262 let forged_meta = types::Meta {
2263 mac,
2264 mac_key: Some(mac_key_hex),
2265 sig: Some(forged_sig),
2266 signers,
2267 ..stale
2268 };
2269 tampered.meta = encrypt_value(
2270 &serde_json::to_vec(&forged_meta).unwrap(),
2271 std::slice::from_ref(&recipient),
2272 )
2273 .unwrap();
2274 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2275
2276 let err = load_vault(ps).unwrap_err();
2278 unsafe { std::env::remove_var("MURK_KEY") };
2279 match prev_home {
2280 Some(v) => unsafe { std::env::set_var("HOME", v) },
2281 None => unsafe { std::env::remove_var("HOME") },
2282 }
2283 assert!(
2284 err.to_string().contains("verifying key changed"),
2285 "expected pin failure, got: {err}"
2286 );
2287
2288 fs::remove_dir_all(&dir).unwrap();
2289 }
2290
2291 #[test]
2292 fn age_signature_first_use_then_anchored() {
2293 let _lock = ENV_LOCK
2297 .lock()
2298 .unwrap_or_else(std::sync::PoisonError::into_inner);
2299 let home = tempfile::tempdir().unwrap();
2300 let prev_home = std::env::var_os("HOME");
2301 unsafe { std::env::set_var("HOME", home.path()) };
2302 unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2303
2304 let (secret, pubkey) = generate_keypair();
2305 let recipient = make_recipient(&pubkey);
2306 let dir = std::env::temp_dir().join("murk_test_anchor_transition");
2307 let _ = fs::remove_dir_all(&dir);
2308 fs::create_dir_all(&dir).unwrap();
2309 let path = dir.join("test.murk");
2310 let ps = path.to_str().unwrap();
2311
2312 let mut vault = signed_test_vault(&pubkey, &recipient);
2313 let original = types::Murk {
2314 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2315 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2316 ..Default::default()
2317 };
2318 unsafe { std::env::set_var("MURK_KEY", &secret) };
2319 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2320 save_vault(ps, &mut vault, &original, &original).unwrap();
2321
2322 let first = load_vault(ps).unwrap().1;
2323 let second = load_vault(ps).unwrap().1;
2324 unsafe { std::env::remove_var("MURK_KEY") };
2325 match prev_home {
2326 Some(v) => unsafe { std::env::set_var("HOME", v) },
2327 None => unsafe { std::env::remove_var("HOME") },
2328 }
2329
2330 assert_eq!(
2331 first.signature,
2332 types::SignatureState::Signed {
2333 signer: pubkey.clone(),
2334 anchored: false,
2335 },
2336 "first load of an age key is trust-on-first-use"
2337 );
2338 assert_eq!(
2339 second.signature,
2340 types::SignatureState::Signed {
2341 signer: pubkey,
2342 anchored: true,
2343 },
2344 "second load is anchored by the pin"
2345 );
2346
2347 fs::remove_dir_all(&dir).unwrap();
2348 }
2349
2350 #[test]
2351 fn save_vault_preserves_unchanged_ciphertext() {
2352 let (secret, pubkey) = generate_keypair();
2353 let recipient = make_recipient(&pubkey);
2354 let identity = make_identity(&secret);
2355
2356 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
2357 fs::create_dir_all(&dir).unwrap();
2358 let path = dir.join("test.murk");
2359
2360 let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
2361 let mut vault = types::Vault {
2362 version: types::VAULT_VERSION.into(),
2363 created: "2026-02-28T00:00:00Z".into(),
2364 vault_name: ".murk".into(),
2365 repo: String::new(),
2366 recipients: vec![pubkey.clone()],
2367 schema: BTreeMap::new(),
2368 policy: None,
2369 secrets: BTreeMap::new(),
2370 meta: String::new(),
2371 };
2372 vault.secrets.insert(
2373 "KEY1".into(),
2374 types::SecretEntry {
2375 shared: shared.clone(),
2376 private: BTreeMap::new(),
2377 grouped: std::collections::BTreeMap::default(),
2378 },
2379 );
2380
2381 let mut recipients_map = HashMap::new();
2382 recipients_map.insert(pubkey.clone(), "alice".into());
2383 let original = types::Murk {
2384 values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
2385 recipients: recipients_map.clone(),
2386 private: HashMap::new(),
2387 legacy_mac: false,
2388 github_pins: HashMap::new(),
2389 ..Default::default()
2390 };
2391
2392 let current = original.clone();
2393 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2394
2395 assert_eq!(vault.secrets["KEY1"].shared, shared);
2396
2397 let mut changed = current.clone();
2398 changed
2399 .values
2400 .insert("KEY1".into(), crate::testutil::secret("modified"));
2401 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
2402
2403 assert_ne!(vault.secrets["KEY1"].shared, shared);
2404
2405 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
2406 assert_eq!(&decrypted[..], b"modified");
2407
2408 fs::remove_dir_all(&dir).unwrap();
2409 }
2410
2411 #[test]
2412 fn save_vault_adds_new_secret() {
2413 let (_, pubkey) = generate_keypair();
2414 let recipient = make_recipient(&pubkey);
2415
2416 let dir = std::env::temp_dir().join("murk_test_save_add");
2417 fs::create_dir_all(&dir).unwrap();
2418 let path = dir.join("test.murk");
2419
2420 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
2421 let mut vault = types::Vault {
2422 version: types::VAULT_VERSION.into(),
2423 created: "2026-02-28T00:00:00Z".into(),
2424 vault_name: ".murk".into(),
2425 repo: String::new(),
2426 recipients: vec![pubkey.clone()],
2427 schema: BTreeMap::new(),
2428 policy: None,
2429 secrets: BTreeMap::new(),
2430 meta: String::new(),
2431 };
2432 vault.secrets.insert(
2433 "KEY1".into(),
2434 types::SecretEntry {
2435 shared,
2436 private: BTreeMap::new(),
2437 grouped: std::collections::BTreeMap::default(),
2438 },
2439 );
2440
2441 let mut recipients_map = HashMap::new();
2442 recipients_map.insert(pubkey.clone(), "alice".into());
2443 let original = types::Murk {
2444 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2445 recipients: recipients_map.clone(),
2446 private: HashMap::new(),
2447 legacy_mac: false,
2448 github_pins: HashMap::new(),
2449 ..Default::default()
2450 };
2451
2452 let mut current = original.clone();
2453 current
2454 .values
2455 .insert("KEY2".into(), crate::testutil::secret("val2"));
2456
2457 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2458
2459 assert!(vault.secrets.contains_key("KEY1"));
2460 assert!(vault.secrets.contains_key("KEY2"));
2461
2462 fs::remove_dir_all(&dir).unwrap();
2463 }
2464
2465 #[test]
2466 fn save_vault_removes_deleted_secret() {
2467 let (_, pubkey) = generate_keypair();
2468 let recipient = make_recipient(&pubkey);
2469
2470 let dir = std::env::temp_dir().join("murk_test_save_remove");
2471 fs::create_dir_all(&dir).unwrap();
2472 let path = dir.join("test.murk");
2473
2474 let mut vault = types::Vault {
2475 version: types::VAULT_VERSION.into(),
2476 created: "2026-02-28T00:00:00Z".into(),
2477 vault_name: ".murk".into(),
2478 repo: String::new(),
2479 recipients: vec![pubkey.clone()],
2480 schema: BTreeMap::new(),
2481 policy: None,
2482 secrets: BTreeMap::new(),
2483 meta: String::new(),
2484 };
2485 vault.secrets.insert(
2486 "KEY1".into(),
2487 types::SecretEntry {
2488 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2489 private: BTreeMap::new(),
2490 grouped: std::collections::BTreeMap::default(),
2491 },
2492 );
2493 vault.secrets.insert(
2494 "KEY2".into(),
2495 types::SecretEntry {
2496 shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
2497 private: BTreeMap::new(),
2498 grouped: std::collections::BTreeMap::default(),
2499 },
2500 );
2501
2502 let mut recipients_map = HashMap::new();
2503 recipients_map.insert(pubkey.clone(), "alice".into());
2504 let original = types::Murk {
2505 values: HashMap::from([
2506 ("KEY1".into(), crate::testutil::secret("val1")),
2507 ("KEY2".into(), crate::testutil::secret("val2")),
2508 ]),
2509 recipients: recipients_map.clone(),
2510 private: HashMap::new(),
2511 legacy_mac: false,
2512 github_pins: HashMap::new(),
2513 ..Default::default()
2514 };
2515
2516 let mut current = original.clone();
2517 current.values.remove("KEY2");
2518
2519 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2520
2521 assert!(vault.secrets.contains_key("KEY1"));
2522 assert!(!vault.secrets.contains_key("KEY2"));
2523
2524 fs::remove_dir_all(&dir).unwrap();
2525 }
2526
2527 #[test]
2528 fn save_vault_reencrypts_all_on_recipient_change() {
2529 let (secret1, pubkey1) = generate_keypair();
2530 let (_, pubkey2) = generate_keypair();
2531 let recipient1 = make_recipient(&pubkey1);
2532
2533 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
2534 fs::create_dir_all(&dir).unwrap();
2535 let path = dir.join("test.murk");
2536
2537 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
2538 let mut vault = types::Vault {
2539 version: types::VAULT_VERSION.into(),
2540 created: "2026-02-28T00:00:00Z".into(),
2541 vault_name: ".murk".into(),
2542 repo: String::new(),
2543 recipients: vec![pubkey1.clone(), pubkey2.clone()],
2544 schema: BTreeMap::new(),
2545 policy: None,
2546 secrets: BTreeMap::new(),
2547 meta: String::new(),
2548 };
2549 vault.secrets.insert(
2550 "KEY1".into(),
2551 types::SecretEntry {
2552 shared: shared.clone(),
2553 private: BTreeMap::new(),
2554 grouped: std::collections::BTreeMap::default(),
2555 },
2556 );
2557
2558 let mut recipients_map = HashMap::new();
2559 recipients_map.insert(pubkey1.clone(), "alice".into());
2560 let original = types::Murk {
2561 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2562 recipients: recipients_map,
2563 private: HashMap::new(),
2564 legacy_mac: false,
2565 github_pins: HashMap::new(),
2566 ..Default::default()
2567 };
2568
2569 let mut current_recipients = HashMap::new();
2570 current_recipients.insert(pubkey1.clone(), "alice".into());
2571 current_recipients.insert(pubkey2.clone(), "bob".into());
2572 let current = types::Murk {
2573 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2574 recipients: current_recipients,
2575 private: HashMap::new(),
2576 legacy_mac: false,
2577 github_pins: HashMap::new(),
2578 ..Default::default()
2579 };
2580
2581 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2582
2583 assert_ne!(vault.secrets["KEY1"].shared, shared);
2584
2585 let identity1 = make_identity(&secret1);
2586 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
2587 assert_eq!(&decrypted[..], b"val1");
2588
2589 fs::remove_dir_all(&dir).unwrap();
2590 }
2591
2592 #[test]
2593 fn save_vault_scoped_entry_lifecycle() {
2594 let (secret, pubkey) = generate_keypair();
2595 let recipient = make_recipient(&pubkey);
2596 let identity = make_identity(&secret);
2597
2598 let dir = std::env::temp_dir().join("murk_test_save_scoped");
2599 fs::create_dir_all(&dir).unwrap();
2600 let path = dir.join("test.murk");
2601
2602 let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
2603 let mut vault = types::Vault {
2604 version: types::VAULT_VERSION.into(),
2605 created: "2026-02-28T00:00:00Z".into(),
2606 vault_name: ".murk".into(),
2607 repo: String::new(),
2608 recipients: vec![pubkey.clone()],
2609 schema: BTreeMap::new(),
2610 policy: None,
2611 secrets: BTreeMap::new(),
2612 meta: String::new(),
2613 };
2614 vault.secrets.insert(
2615 "KEY1".into(),
2616 types::SecretEntry {
2617 shared,
2618 private: BTreeMap::new(),
2619 grouped: std::collections::BTreeMap::default(),
2620 },
2621 );
2622
2623 let mut recipients_map = HashMap::new();
2624 recipients_map.insert(pubkey.clone(), "alice".into());
2625 let original = types::Murk {
2626 values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
2627 recipients: recipients_map.clone(),
2628 private: HashMap::new(),
2629 legacy_mac: false,
2630 github_pins: HashMap::new(),
2631 ..Default::default()
2632 };
2633
2634 let mut current = original.clone();
2636 let mut key_scoped = HashMap::new();
2637 key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
2638 current.private.insert("KEY1".into(), key_scoped);
2639
2640 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2641
2642 assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
2643 let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
2644 assert_eq!(&scoped_val[..], b"my_override");
2645
2646 let original_with_scoped = current.clone();
2648 let mut current_no_scoped = original_with_scoped.clone();
2649 current_no_scoped.private.remove("KEY1");
2650
2651 save_vault(
2652 path.to_str().unwrap(),
2653 &mut vault,
2654 &original_with_scoped,
2655 ¤t_no_scoped,
2656 )
2657 .unwrap();
2658
2659 assert!(vault.secrets["KEY1"].private.is_empty());
2660
2661 fs::remove_dir_all(&dir).unwrap();
2662 }
2663
2664 #[test]
2665 fn load_vault_validates_mac() {
2666 let _lock = ENV_LOCK
2667 .lock()
2668 .unwrap_or_else(std::sync::PoisonError::into_inner);
2669
2670 let (secret, pubkey) = generate_keypair();
2671 let recipient = make_recipient(&pubkey);
2672 let _identity = make_identity(&secret);
2673
2674 let dir = std::env::temp_dir().join("murk_test_load_mac");
2675 let _ = fs::remove_dir_all(&dir);
2676 fs::create_dir_all(&dir).unwrap();
2677 let path = dir.join("test.murk");
2678
2679 let mut vault = types::Vault {
2681 version: types::VAULT_VERSION.into(),
2682 created: "2026-02-28T00:00:00Z".into(),
2683 vault_name: ".murk".into(),
2684 repo: String::new(),
2685 recipients: vec![pubkey.clone()],
2686 schema: BTreeMap::new(),
2687 policy: None,
2688 secrets: BTreeMap::new(),
2689 meta: String::new(),
2690 };
2691 vault.secrets.insert(
2692 "KEY1".into(),
2693 types::SecretEntry {
2694 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2695 private: BTreeMap::new(),
2696 grouped: std::collections::BTreeMap::default(),
2697 },
2698 );
2699
2700 let mut recipients_map = HashMap::new();
2701 recipients_map.insert(pubkey.clone(), "alice".into());
2702 let original = types::Murk {
2703 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2704 recipients: recipients_map,
2705 private: HashMap::new(),
2706 legacy_mac: false,
2707 github_pins: HashMap::new(),
2708 ..Default::default()
2709 };
2710
2711 unsafe { std::env::set_var("MURK_KEY", &secret) };
2713 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2714 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2715
2716 let mut tampered: types::Vault =
2718 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2719 tampered.secrets.get_mut("KEY1").unwrap().shared =
2720 encrypt_value(b"tampered", &[recipient]).unwrap();
2721 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2722
2723 let result = load_vault(path.to_str().unwrap());
2725 unsafe { std::env::remove_var("MURK_KEY") };
2726
2727 let err = result.expect_err("expected MAC validation to fail");
2728 assert!(
2729 err.to_string().contains("integrity check failed"),
2730 "expected integrity check failure, got: {err}"
2731 );
2732
2733 fs::remove_dir_all(&dir).unwrap();
2734 }
2735
2736 #[test]
2737 fn load_vault_succeeds_with_valid_mac() {
2738 let _lock = ENV_LOCK
2739 .lock()
2740 .unwrap_or_else(std::sync::PoisonError::into_inner);
2741
2742 let (secret, pubkey) = generate_keypair();
2743 let recipient = make_recipient(&pubkey);
2744
2745 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
2746 let _ = fs::remove_dir_all(&dir);
2747 fs::create_dir_all(&dir).unwrap();
2748 let path = dir.join("test.murk");
2749
2750 let mut vault = types::Vault {
2751 version: types::VAULT_VERSION.into(),
2752 created: "2026-02-28T00:00:00Z".into(),
2753 vault_name: ".murk".into(),
2754 repo: String::new(),
2755 recipients: vec![pubkey.clone()],
2756 schema: BTreeMap::new(),
2757 policy: None,
2758 secrets: BTreeMap::new(),
2759 meta: String::new(),
2760 };
2761 vault.secrets.insert(
2762 "KEY1".into(),
2763 types::SecretEntry {
2764 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2765 private: BTreeMap::new(),
2766 grouped: std::collections::BTreeMap::default(),
2767 },
2768 );
2769
2770 let mut recipients_map = HashMap::new();
2771 recipients_map.insert(pubkey.clone(), "alice".into());
2772 let original = types::Murk {
2773 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2774 recipients: recipients_map,
2775 private: HashMap::new(),
2776 legacy_mac: false,
2777 github_pins: HashMap::new(),
2778 ..Default::default()
2779 };
2780
2781 unsafe { std::env::set_var("MURK_KEY", &secret) };
2782 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2783 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2784
2785 let result = load_vault(path.to_str().unwrap());
2787 unsafe { std::env::remove_var("MURK_KEY") };
2788
2789 assert!(result.is_ok());
2790 let (_, murk, _) = result.unwrap();
2791 assert_eq!(murk.values["KEY1"].as_str(), "val1");
2792
2793 fs::remove_dir_all(&dir).unwrap();
2794 }
2795
2796 #[test]
2797 fn load_vault_not_a_recipient() {
2798 let _lock = ENV_LOCK
2799 .lock()
2800 .unwrap_or_else(std::sync::PoisonError::into_inner);
2801
2802 let (secret, _pubkey) = generate_keypair();
2803 let (other_secret, other_pubkey) = generate_keypair();
2804 let other_recipient = make_recipient(&other_pubkey);
2805
2806 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
2807 let _ = fs::remove_dir_all(&dir);
2808 fs::create_dir_all(&dir).unwrap();
2809 let path = dir.join("test.murk");
2810
2811 let mut vault = types::Vault {
2813 version: types::VAULT_VERSION.into(),
2814 created: "2026-02-28T00:00:00Z".into(),
2815 vault_name: ".murk".into(),
2816 repo: String::new(),
2817 recipients: vec![other_pubkey.clone()],
2818 schema: BTreeMap::new(),
2819 policy: None,
2820 secrets: BTreeMap::new(),
2821 meta: String::new(),
2822 };
2823 vault.secrets.insert(
2824 "KEY1".into(),
2825 types::SecretEntry {
2826 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
2827 private: BTreeMap::new(),
2828 grouped: std::collections::BTreeMap::default(),
2829 },
2830 );
2831
2832 let mut recipients_map = HashMap::new();
2834 recipients_map.insert(other_pubkey.clone(), "other".into());
2835 let original = types::Murk {
2836 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2837 recipients: recipients_map,
2838 private: HashMap::new(),
2839 legacy_mac: false,
2840 github_pins: HashMap::new(),
2841 ..Default::default()
2842 };
2843
2844 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
2845 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2846 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2847
2848 unsafe { std::env::set_var("MURK_KEY", secret) };
2850 let result = load_vault(path.to_str().unwrap());
2851 unsafe { std::env::remove_var("MURK_KEY") };
2852
2853 let Err(err) = result else {
2854 panic!("expected load_vault to fail for non-recipient");
2855 };
2856 let msg = err.to_string();
2859 assert!(
2860 msg.contains("not a recipient"),
2861 "expected not-a-recipient error, got: {err}"
2862 );
2863 assert!(
2864 !msg.contains("tampered"),
2865 "unauthorized key must not look like tampering, got: {err}"
2866 );
2867
2868 fs::remove_dir_all(&dir).unwrap();
2869 }
2870
2871 #[test]
2872 fn load_vault_zero_secrets() {
2873 let _lock = ENV_LOCK
2874 .lock()
2875 .unwrap_or_else(std::sync::PoisonError::into_inner);
2876
2877 let (secret, pubkey) = generate_keypair();
2878
2879 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
2880 let _ = fs::remove_dir_all(&dir);
2881 fs::create_dir_all(&dir).unwrap();
2882 let path = dir.join("test.murk");
2883
2884 let mut vault = types::Vault {
2886 version: types::VAULT_VERSION.into(),
2887 created: "2026-02-28T00:00:00Z".into(),
2888 vault_name: ".murk".into(),
2889 repo: String::new(),
2890 recipients: vec![pubkey.clone()],
2891 schema: BTreeMap::new(),
2892 policy: None,
2893 secrets: BTreeMap::new(),
2894 meta: String::new(),
2895 };
2896
2897 let mut recipients_map = HashMap::new();
2898 recipients_map.insert(pubkey.clone(), "alice".into());
2899 let original = types::Murk {
2900 values: HashMap::new(),
2901 recipients: recipients_map,
2902 private: HashMap::new(),
2903 legacy_mac: false,
2904 github_pins: HashMap::new(),
2905 ..Default::default()
2906 };
2907
2908 unsafe { std::env::set_var("MURK_KEY", &secret) };
2909 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2910 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2911
2912 let result = load_vault(path.to_str().unwrap());
2913 unsafe { std::env::remove_var("MURK_KEY") };
2914
2915 assert!(result.is_ok());
2916 let (_, murk, _) = result.unwrap();
2917 assert!(murk.values.is_empty());
2918 assert!(murk.private.is_empty());
2919
2920 fs::remove_dir_all(&dir).unwrap();
2921 }
2922
2923 #[test]
2924 fn load_vault_stripped_meta_with_secrets_fails() {
2925 let _lock = ENV_LOCK
2926 .lock()
2927 .unwrap_or_else(std::sync::PoisonError::into_inner);
2928
2929 let (secret, pubkey) = generate_keypair();
2930 let recipient = make_recipient(&pubkey);
2931
2932 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
2933 let _ = fs::remove_dir_all(&dir);
2934 fs::create_dir_all(&dir).unwrap();
2935 let path = dir.join("test.murk");
2936
2937 let mut vault = types::Vault {
2939 version: types::VAULT_VERSION.into(),
2940 created: "2026-02-28T00:00:00Z".into(),
2941 vault_name: ".murk".into(),
2942 repo: String::new(),
2943 recipients: vec![pubkey.clone()],
2944 schema: BTreeMap::new(),
2945 policy: None,
2946 secrets: BTreeMap::new(),
2947 meta: String::new(),
2948 };
2949 vault.secrets.insert(
2950 "KEY1".into(),
2951 types::SecretEntry {
2952 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2953 private: BTreeMap::new(),
2954 grouped: std::collections::BTreeMap::default(),
2955 },
2956 );
2957
2958 let mut recipients_map = HashMap::new();
2959 recipients_map.insert(pubkey.clone(), "alice".into());
2960 let original = types::Murk {
2961 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2962 recipients: recipients_map,
2963 private: HashMap::new(),
2964 legacy_mac: false,
2965 github_pins: HashMap::new(),
2966 ..Default::default()
2967 };
2968
2969 unsafe { std::env::set_var("MURK_KEY", &secret) };
2970 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2971 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2972
2973 let mut tampered: types::Vault =
2975 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2976 tampered.meta = String::new();
2977 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2978
2979 let result = load_vault(path.to_str().unwrap());
2981
2982 let err = result.expect_err("expected MAC validation to fail");
2983 assert!(
2984 err.to_string().contains("integrity check failed"),
2985 "expected integrity check failure, got: {err}"
2986 );
2987
2988 let mut garbled: types::Vault =
2992 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2993 garbled.meta = "not-base64!!".into();
2994 fs::write(&path, serde_json::to_string_pretty(&garbled).unwrap()).unwrap();
2995
2996 let result = load_vault(path.to_str().unwrap());
2997
2998 let err = result.expect_err("expected corrupt meta to fail");
2999 assert!(
3000 err.to_string().contains("integrity check failed"),
3001 "expected integrity check failure, got: {err}"
3002 );
3003
3004 let mut flipped: types::Vault =
3008 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3009 flipped.meta = BASE64.encode(b"flipped ciphertext bytes");
3010 fs::write(&path, serde_json::to_string_pretty(&flipped).unwrap()).unwrap();
3011
3012 let result = load_vault(path.to_str().unwrap());
3013 unsafe { std::env::remove_var("MURK_KEY") };
3014
3015 let err = result.expect_err("expected flipped meta to fail");
3016 assert!(
3017 err.to_string().contains("integrity check failed"),
3018 "expected integrity check failure, got: {err}"
3019 );
3020
3021 fs::remove_dir_all(&dir).unwrap();
3022 }
3023
3024 #[test]
3025 fn load_vault_empty_mac_with_secrets_fails() {
3026 let _lock = ENV_LOCK
3027 .lock()
3028 .unwrap_or_else(std::sync::PoisonError::into_inner);
3029
3030 let (secret, pubkey) = generate_keypair();
3031 let recipient = make_recipient(&pubkey);
3032
3033 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
3034 let _ = fs::remove_dir_all(&dir);
3035 fs::create_dir_all(&dir).unwrap();
3036 let path = dir.join("test.murk");
3037
3038 let mut vault = types::Vault {
3040 version: types::VAULT_VERSION.into(),
3041 created: "2026-02-28T00:00:00Z".into(),
3042 vault_name: ".murk".into(),
3043 repo: String::new(),
3044 recipients: vec![pubkey.clone()],
3045 schema: BTreeMap::new(),
3046 policy: None,
3047 secrets: BTreeMap::new(),
3048 meta: String::new(),
3049 };
3050 vault.secrets.insert(
3051 "KEY1".into(),
3052 types::SecretEntry {
3053 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
3054 private: BTreeMap::new(),
3055 grouped: std::collections::BTreeMap::default(),
3056 },
3057 );
3058
3059 let mut recipients_map = HashMap::new();
3061 recipients_map.insert(pubkey.clone(), "alice".into());
3062 let meta = types::Meta {
3063 recipients: recipients_map,
3064 mac: String::new(),
3065 mac_key: None,
3066 github_pins: HashMap::new(),
3067 ..Default::default()
3068 };
3069 let meta_json = serde_json::to_vec(&meta).unwrap();
3070 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
3071
3072 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
3074
3075 unsafe { std::env::set_var("MURK_KEY", &secret) };
3077 unsafe { std::env::remove_var("MURK_KEY_FILE") };
3078 let result = load_vault(path.to_str().unwrap());
3079 unsafe { std::env::remove_var("MURK_KEY") };
3080
3081 let err = result.expect_err("expected MAC validation to fail");
3082 assert!(
3083 err.to_string().contains("integrity check failed"),
3084 "expected integrity check failure, got: {err}"
3085 );
3086
3087 fs::remove_dir_all(&dir).unwrap();
3088 }
3089
3090 #[test]
3091 fn compute_mac_changes_with_scoped_entries() {
3092 let mut vault = types::Vault {
3093 version: types::VAULT_VERSION.into(),
3094 created: "2026-02-28T00:00:00Z".into(),
3095 vault_name: ".murk".into(),
3096 repo: String::new(),
3097 recipients: vec!["age1abc".into()],
3098 schema: BTreeMap::new(),
3099 policy: None,
3100 secrets: BTreeMap::new(),
3101 meta: String::new(),
3102 };
3103
3104 vault.secrets.insert(
3105 "KEY".into(),
3106 types::SecretEntry {
3107 shared: "ciphertext".into(),
3108 private: BTreeMap::new(),
3109 grouped: std::collections::BTreeMap::default(),
3110 },
3111 );
3112
3113 let key = [0u8; 32];
3114 let mac_no_scoped = compute_mac(
3115 &vault,
3116 &std::collections::BTreeMap::new(),
3117 &std::collections::BTreeMap::new(),
3118 Some(&key),
3119 );
3120
3121 vault
3122 .secrets
3123 .get_mut("KEY")
3124 .unwrap()
3125 .private
3126 .insert("age1bob".into(), "scoped-ct".into());
3127
3128 let mac_with_scoped = compute_mac(
3129 &vault,
3130 &std::collections::BTreeMap::new(),
3131 &std::collections::BTreeMap::new(),
3132 Some(&key),
3133 );
3134 assert_ne!(mac_no_scoped, mac_with_scoped);
3135 }
3136
3137 #[test]
3138 #[allow(clippy::too_many_lines)] fn verify_mac_accepts_v1_prefix() {
3140 let vault = types::Vault {
3141 version: types::VAULT_VERSION.into(),
3142 created: "2026-02-28T00:00:00Z".into(),
3143 vault_name: ".murk".into(),
3144 repo: String::new(),
3145 recipients: vec!["age1abc".into()],
3146 schema: BTreeMap::new(),
3147 policy: None,
3148 secrets: BTreeMap::new(),
3149 meta: String::new(),
3150 };
3151
3152 let key = [0u8; 32];
3153 let v1_mac = compute_mac_v1(&vault);
3154 let v2_mac = compute_mac_v2(&vault);
3155 let v3_mac = compute_mac_v3(&vault, &key);
3156 assert!(verify_mac(
3157 &vault,
3158 &std::collections::BTreeMap::new(),
3159 &std::collections::BTreeMap::new(),
3160 &v1_mac,
3161 None
3162 ));
3163 assert!(verify_mac(
3164 &vault,
3165 &std::collections::BTreeMap::new(),
3166 &std::collections::BTreeMap::new(),
3167 &v2_mac,
3168 None
3169 ));
3170 assert!(verify_mac(
3171 &vault,
3172 &std::collections::BTreeMap::new(),
3173 &std::collections::BTreeMap::new(),
3174 &v3_mac,
3175 Some(&key)
3176 ));
3177 assert!(!verify_mac(
3178 &vault,
3179 &std::collections::BTreeMap::new(),
3180 &std::collections::BTreeMap::new(),
3181 "sha256:bogus",
3182 None
3183 ));
3184 assert!(!verify_mac(
3185 &vault,
3186 &std::collections::BTreeMap::new(),
3187 &std::collections::BTreeMap::new(),
3188 "blake3:bogus",
3189 Some(&key)
3190 ));
3191 assert!(!verify_mac(
3192 &vault,
3193 &std::collections::BTreeMap::new(),
3194 &std::collections::BTreeMap::new(),
3195 "blake3v2:bogus",
3196 Some(&key)
3197 ));
3198 assert!(!verify_mac(
3199 &vault,
3200 &std::collections::BTreeMap::new(),
3201 &std::collections::BTreeMap::new(),
3202 "blake3v3:bogus",
3203 Some(&key)
3204 ));
3205 assert!(!verify_mac(
3206 &vault,
3207 &std::collections::BTreeMap::new(),
3208 &std::collections::BTreeMap::new(),
3209 "unknown:prefix",
3210 None
3211 ));
3212
3213 let v4_mac = compute_mac_v4(&vault, &key);
3215 assert!(v4_mac.starts_with("blake3v2:"));
3216 assert!(verify_mac(
3217 &vault,
3218 &std::collections::BTreeMap::new(),
3219 &std::collections::BTreeMap::new(),
3220 &v4_mac,
3221 Some(&key)
3222 ));
3223
3224 let v5_mac = compute_mac_v5(&vault, &key);
3226 assert!(v5_mac.starts_with("blake3v3:"));
3227 assert!(verify_mac(
3228 &vault,
3229 &std::collections::BTreeMap::new(),
3230 &std::collections::BTreeMap::new(),
3231 &v5_mac,
3232 Some(&key)
3233 ));
3234 assert!(
3236 compute_mac(
3237 &vault,
3238 &std::collections::BTreeMap::new(),
3239 &std::collections::BTreeMap::new(),
3240 Some(&key)
3241 )
3242 .starts_with("blake3v3:")
3243 );
3244
3245 let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
3247 let v6_mac = compute_mac(
3248 &vault,
3249 &groups,
3250 &std::collections::BTreeMap::new(),
3251 Some(&key),
3252 );
3253 assert!(v6_mac.starts_with("blake3v4:"));
3254 assert!(verify_mac(
3255 &vault,
3256 &groups,
3257 &std::collections::BTreeMap::new(),
3258 &v6_mac,
3259 Some(&key)
3260 ));
3261 let tampered = BTreeMap::from([(
3263 "prod".to_string(),
3264 vec!["age1abc".to_string(), "age1evil".to_string()],
3265 )]);
3266 assert!(!verify_mac(
3267 &vault,
3268 &tampered,
3269 &std::collections::BTreeMap::new(),
3270 &v6_mac,
3271 Some(&key)
3272 ));
3273 }
3274
3275 #[test]
3276 fn verify_mac_rejects_grouped_under_legacy_prefix() {
3277 let mut vault = types::Vault {
3281 version: types::VAULT_VERSION.into(),
3282 created: "2026-02-28T00:00:00Z".into(),
3283 vault_name: ".murk".into(),
3284 repo: String::new(),
3285 recipients: vec!["age1abc".into()],
3286 schema: BTreeMap::new(),
3287 policy: None,
3288 secrets: BTreeMap::new(),
3289 meta: String::new(),
3290 };
3291 let key = [7u8; 32];
3292 let no_groups = BTreeMap::new();
3293 let v5_mac = compute_mac(
3294 &vault,
3295 &no_groups,
3296 &std::collections::BTreeMap::new(),
3297 Some(&key),
3298 );
3299 assert!(v5_mac.starts_with("blake3v3:"));
3300 assert!(verify_mac(
3301 &vault,
3302 &no_groups,
3303 &std::collections::BTreeMap::new(),
3304 &v5_mac,
3305 Some(&key)
3306 ));
3307
3308 vault.secrets.insert(
3310 "STOLEN".into(),
3311 types::SecretEntry {
3312 grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
3313 ..Default::default()
3314 },
3315 );
3316 assert!(!verify_mac(
3317 &vault,
3318 &no_groups,
3319 &std::collections::BTreeMap::new(),
3320 &v5_mac,
3321 Some(&key)
3322 ));
3323 }
3324
3325 #[test]
3326 fn mac_v7_covers_grant_metadata() {
3327 let vault = types::Vault {
3328 version: types::VAULT_VERSION.into(),
3329 created: "2026-02-28T00:00:00Z".into(),
3330 vault_name: ".murk".into(),
3331 repo: String::new(),
3332 recipients: vec!["age1abc".into(), "age1agent".into()],
3333 schema: BTreeMap::new(),
3334 policy: None,
3335 secrets: BTreeMap::new(),
3336 meta: String::new(),
3337 };
3338 let key = [9u8; 32];
3339 let no_groups = BTreeMap::new();
3340
3341 let grants = BTreeMap::from([(
3343 "codex".to_string(),
3344 types::GrantEntry {
3345 pubkey: "age1agent".into(),
3346 scope: vec!["STRIPE_KEY".into()],
3347 issued_at: "2026-02-28T00:00:00Z".into(),
3348 expires_at: "2026-02-28T02:00:00Z".into(),
3349 issuer: "age1abc".into(),
3350 },
3351 )]);
3352 let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
3353 assert!(v7_mac.starts_with("blake3v5:"));
3354 assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
3355
3356 let tampered = BTreeMap::from([(
3358 "codex".to_string(),
3359 types::GrantEntry {
3360 pubkey: "age1agent".into(),
3361 scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
3362 issued_at: "2026-02-28T00:00:00Z".into(),
3363 expires_at: "2026-02-28T02:00:00Z".into(),
3364 issuer: "age1abc".into(),
3365 },
3366 )]);
3367 assert!(!verify_mac(
3368 &vault,
3369 &no_groups,
3370 &tampered,
3371 &v7_mac,
3372 Some(&key)
3373 ));
3374 }
3375
3376 #[test]
3377 fn verify_mac_rejects_grants_under_legacy_prefix() {
3378 let vault = types::Vault {
3382 version: types::VAULT_VERSION.into(),
3383 created: "2026-02-28T00:00:00Z".into(),
3384 vault_name: ".murk".into(),
3385 repo: String::new(),
3386 recipients: vec!["age1abc".into()],
3387 schema: BTreeMap::new(),
3388 policy: None,
3389 secrets: BTreeMap::new(),
3390 meta: String::new(),
3391 };
3392 let key = [3u8; 32];
3393 let no_groups = BTreeMap::new();
3394 let grants = BTreeMap::from([(
3395 "codex".to_string(),
3396 types::GrantEntry {
3397 pubkey: "age1agent".into(),
3398 scope: vec!["STRIPE_KEY".into()],
3399 issued_at: "2026-02-28T00:00:00Z".into(),
3400 expires_at: "2026-02-28T02:00:00Z".into(),
3401 issuer: "age1abc".into(),
3402 },
3403 )]);
3404 let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
3406 assert!(v6_mac.starts_with("blake3v4:"));
3407 assert!(!verify_mac(
3408 &vault,
3409 &no_groups,
3410 &grants,
3411 &v6_mac,
3412 Some(&key)
3413 ));
3414 }
3415
3416 #[test]
3417 fn mac_v8_covers_policy() {
3418 let mut vault = types::Vault {
3419 version: types::VAULT_VERSION.into(),
3420 created: "2026-02-28T00:00:00Z".into(),
3421 vault_name: ".murk".into(),
3422 repo: String::new(),
3423 recipients: vec!["age1abc".into()],
3424 schema: BTreeMap::new(),
3425 policy: Some(types::Policy {
3426 agent_allow_tags: vec!["agents".into()],
3427 }),
3428 secrets: BTreeMap::new(),
3429 meta: String::new(),
3430 };
3431 let key = [11u8; 32];
3432 let no_groups = BTreeMap::new();
3433 let no_grants = BTreeMap::new();
3434
3435 let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
3437 assert!(v8_mac.starts_with("blake3v6:"));
3438 assert!(verify_mac(
3439 &vault,
3440 &no_groups,
3441 &no_grants,
3442 &v8_mac,
3443 Some(&key)
3444 ));
3445
3446 vault.policy = Some(types::Policy {
3448 agent_allow_tags: vec!["agents".into(), "production".into()],
3449 });
3450 assert!(!verify_mac(
3451 &vault,
3452 &no_groups,
3453 &no_grants,
3454 &v8_mac,
3455 Some(&key)
3456 ));
3457 }
3458
3459 #[test]
3460 fn mac_v8_policy_tags_are_unambiguous() {
3461 let base = types::Vault {
3464 version: types::VAULT_VERSION.into(),
3465 created: "2026-02-28T00:00:00Z".into(),
3466 vault_name: ".murk".into(),
3467 repo: String::new(),
3468 recipients: vec!["age1abc".into()],
3469 schema: BTreeMap::new(),
3470 policy: None,
3471 secrets: BTreeMap::new(),
3472 meta: String::new(),
3473 };
3474 let key = [7u8; 32];
3475 let groups = BTreeMap::new();
3476 let grants = BTreeMap::new();
3477
3478 let mut a = base.clone();
3479 a.policy = Some(types::Policy {
3480 agent_allow_tags: vec!["a\tb".into()],
3481 });
3482 let mut b = base.clone();
3483 b.policy = Some(types::Policy {
3484 agent_allow_tags: vec!["a".into(), "b".into()],
3485 });
3486
3487 let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
3488 let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
3489 assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
3490 }
3491
3492 #[test]
3493 fn verify_mac_rejects_policy_under_legacy_prefix() {
3494 let vault = types::Vault {
3498 version: types::VAULT_VERSION.into(),
3499 created: "2026-02-28T00:00:00Z".into(),
3500 vault_name: ".murk".into(),
3501 repo: String::new(),
3502 recipients: vec!["age1abc".into()],
3503 schema: BTreeMap::new(),
3504 policy: Some(types::Policy {
3505 agent_allow_tags: vec!["agents".into()],
3506 }),
3507 secrets: BTreeMap::new(),
3508 meta: String::new(),
3509 };
3510 let key = [5u8; 32];
3511 let no_groups = BTreeMap::new();
3512 let no_grants = BTreeMap::new();
3513 let v5_mac = compute_mac_v5(&vault, &key);
3515 assert!(v5_mac.starts_with("blake3v3:"));
3516 assert!(!verify_mac(
3517 &vault,
3518 &no_groups,
3519 &no_grants,
3520 &v5_mac,
3521 Some(&key)
3522 ));
3523 }
3524
3525 #[test]
3526 fn compute_mac_v5_covers_rotation_metadata() {
3527 let mut vault = types::Vault {
3528 version: types::VAULT_VERSION.into(),
3529 created: "2026-02-28T00:00:00Z".into(),
3530 vault_name: ".murk".into(),
3531 repo: String::new(),
3532 recipients: vec!["age1abc".into()],
3533 schema: BTreeMap::new(),
3534 policy: None,
3535 secrets: BTreeMap::new(),
3536 meta: String::new(),
3537 };
3538 vault.schema.insert(
3539 "API_KEY".into(),
3540 types::SchemaEntry {
3541 description: "Main API key".into(),
3542 updated: Some("2026-02-28T00:00:00Z".into()),
3543 ..Default::default()
3544 },
3545 );
3546
3547 let key = [0u8; 32];
3548 let baseline = compute_mac(
3549 &vault,
3550 &std::collections::BTreeMap::new(),
3551 &std::collections::BTreeMap::new(),
3552 Some(&key),
3553 );
3554
3555 vault
3557 .schema
3558 .get_mut("API_KEY")
3559 .unwrap()
3560 .rotation_interval_days = Some(90);
3561 let with_interval = compute_mac(
3562 &vault,
3563 &std::collections::BTreeMap::new(),
3564 &std::collections::BTreeMap::new(),
3565 Some(&key),
3566 );
3567 assert_ne!(baseline, with_interval);
3568
3569 vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
3571 let with_expiry = compute_mac(
3572 &vault,
3573 &std::collections::BTreeMap::new(),
3574 &std::collections::BTreeMap::new(),
3575 Some(&key),
3576 );
3577 assert_ne!(with_interval, with_expiry);
3578
3579 let mut cleared = vault.clone();
3582 cleared
3583 .schema
3584 .get_mut("API_KEY")
3585 .unwrap()
3586 .rotation_interval_days = None;
3587 cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
3588 assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
3589 }
3590
3591 #[test]
3592 fn compute_mac_v9_covers_revoked_at() {
3593 let mut vault = types::Vault {
3594 version: types::VAULT_VERSION.into(),
3595 created: "2026-02-28T00:00:00Z".into(),
3596 vault_name: ".murk".into(),
3597 repo: String::new(),
3598 recipients: vec!["age1abc".into()],
3599 schema: BTreeMap::new(),
3600 policy: None,
3601 secrets: BTreeMap::new(),
3602 meta: String::new(),
3603 };
3604 vault.schema.insert(
3605 "API_KEY".into(),
3606 types::SchemaEntry {
3607 description: "Main API key".into(),
3608 updated: Some("2026-02-28T00:00:00Z".into()),
3609 ..Default::default()
3610 },
3611 );
3612 let key = [0u8; 32];
3613 let groups = BTreeMap::new();
3614 let grants = BTreeMap::new();
3615
3616 let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
3618 assert!(baseline.starts_with("blake3v3:"));
3619
3620 vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
3622 let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
3623 assert!(with_marker.starts_with("blake3v7:"));
3624 assert_ne!(baseline, with_marker);
3625
3626 assert!(verify_mac(
3629 &vault,
3630 &groups,
3631 &grants,
3632 &with_marker,
3633 Some(&key)
3634 ));
3635 let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
3636 assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
3637
3638 let mut cleared = vault.clone();
3641 cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
3642 assert_eq!(
3643 compute_mac_v8(&vault, &groups, &grants, &key),
3644 compute_mac_v8(&cleared, &groups, &grants, &key)
3645 );
3646 }
3647
3648 #[test]
3649 fn compute_mac_changes_with_schema() {
3650 let mut vault = types::Vault {
3651 version: types::VAULT_VERSION.into(),
3652 created: "2026-02-28T00:00:00Z".into(),
3653 vault_name: ".murk".into(),
3654 repo: String::new(),
3655 recipients: vec!["age1abc".into()],
3656 schema: BTreeMap::new(),
3657 policy: None,
3658 secrets: BTreeMap::new(),
3659 meta: String::new(),
3660 };
3661
3662 let key = [0u8; 32];
3663 let mac_no_schema = compute_mac(
3664 &vault,
3665 &std::collections::BTreeMap::new(),
3666 &std::collections::BTreeMap::new(),
3667 Some(&key),
3668 );
3669
3670 vault.schema.insert(
3671 "API_KEY".into(),
3672 types::SchemaEntry {
3673 description: "Main API key".into(),
3674 tags: vec!["deploy".into()],
3675 ..Default::default()
3676 },
3677 );
3678
3679 let mac_with_schema = compute_mac(
3680 &vault,
3681 &std::collections::BTreeMap::new(),
3682 &std::collections::BTreeMap::new(),
3683 Some(&key),
3684 );
3685 assert_ne!(mac_no_schema, mac_with_schema);
3686
3687 let mac_before_retag = mac_with_schema;
3689 vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
3690 let mac_after_retag = compute_mac(
3691 &vault,
3692 &std::collections::BTreeMap::new(),
3693 &std::collections::BTreeMap::new(),
3694 Some(&key),
3695 );
3696 assert_ne!(mac_before_retag, mac_after_retag);
3697 }
3698
3699 #[test]
3700 fn mac_key_roundtrip() {
3701 let hex = generate_mac_key();
3702 assert_eq!(hex.len(), 64);
3703 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
3704
3705 let key = decode_mac_key(&hex).expect("valid hex should decode");
3706 let rehex = key.iter().fold(String::new(), |mut s, b| {
3708 use std::fmt::Write;
3709 let _ = write!(s, "{b:02x}");
3710 s
3711 });
3712 assert_eq!(hex, rehex);
3713 }
3714
3715 #[test]
3716 fn decode_mac_key_rejects_bad_input() {
3717 assert!(decode_mac_key("").is_none());
3718 assert!(decode_mac_key("tooshort").is_none());
3719 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()); }
3723
3724 #[test]
3725 fn blake3_mac_different_key_different_mac() {
3726 let vault = types::Vault {
3727 version: types::VAULT_VERSION.into(),
3728 created: "2026-02-28T00:00:00Z".into(),
3729 vault_name: ".murk".into(),
3730 repo: String::new(),
3731 recipients: vec!["age1abc".into()],
3732 schema: BTreeMap::new(),
3733 policy: None,
3734 secrets: BTreeMap::new(),
3735 meta: String::new(),
3736 };
3737
3738 let key1 = [0u8; 32];
3739 let key2 = [1u8; 32];
3740 let mac1 = compute_mac(
3741 &vault,
3742 &std::collections::BTreeMap::new(),
3743 &std::collections::BTreeMap::new(),
3744 Some(&key1),
3745 );
3746 let mac2 = compute_mac(
3747 &vault,
3748 &std::collections::BTreeMap::new(),
3749 &std::collections::BTreeMap::new(),
3750 Some(&key2),
3751 );
3752 assert_ne!(mac1, mac2);
3753 }
3754
3755 #[test]
3756 fn valid_key_names() {
3757 assert!(is_valid_key_name("DATABASE_URL"));
3758 assert!(is_valid_key_name("_PRIVATE"));
3759 assert!(is_valid_key_name("A"));
3760 assert!(is_valid_key_name("key123"));
3761 }
3762
3763 #[test]
3764 fn invalid_key_names() {
3765 assert!(!is_valid_key_name(""));
3766 assert!(!is_valid_key_name("123_START"));
3767 assert!(!is_valid_key_name("KEY-NAME"));
3768 assert!(!is_valid_key_name("KEY NAME"));
3769 assert!(!is_valid_key_name("FOO$(bar)"));
3770 assert!(!is_valid_key_name("KEY=VAL"));
3771 }
3772
3773 #[test]
3774 fn now_utc_format() {
3775 let ts = now_utc();
3776 assert!(ts.ends_with('Z'));
3777 assert_eq!(ts.len(), 20);
3778 assert_eq!(&ts[4..5], "-");
3779 assert_eq!(&ts[7..8], "-");
3780 assert_eq!(&ts[10..11], "T");
3781 }
3782}