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 signature_downgraded: false,
496 })
497}
498
499pub fn load_vault(
503 vault_path: &str,
504) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
505 let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
506
507 let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
508 MurkError::Key(format!(
509 "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
510 ))
511 })?;
512
513 let vault = read_vault(vault_path)?;
514 let mut murk = decrypt_vault(&vault, &identity)?;
515
516 let currently_signed = matches!(murk.signature, types::SignatureState::Signed { .. });
525 match pins::reconcile(vault_path, &murk.signers, currently_signed) {
526 pins::PinVerdict::Conflict { signer } => {
527 return Err(MurkError::Integrity(format!(
528 "signer {signer}'s verifying key changed since first seen — the signer registry \
529 may have been tampered with to forge a signature. Inspect \
530 `git log -p -- {vault_path}`; if the change is legitimate, clear the pin under \
531 ~/.config/murk/signer-pins/ or set MURK_NO_SIGNER_PIN=1"
532 )));
533 }
534 pins::PinVerdict::Ok {
535 first_use,
536 downgraded,
537 } => {
538 if let types::SignatureState::Signed { signer, anchored } = &mut murk.signature
544 && !*anchored
545 && !first_use.contains(signer.as_str())
546 {
547 *anchored = true;
548 }
549 if downgraded {
555 if crate::hardening::strict_mode() {
556 return Err(MurkError::Integrity(format!(
557 "vault {vault_path} was signed before and is now unsigned — the \
558 signature was stripped, or a merge left it unsigned. Refusing under \
559 MURK_STRICT. Review `git log -p -- {vault_path}` and re-sign after \
560 checking `murk diff`; if you deliberately moved to a non-signing key, \
561 clear the pin under ~/.config/murk/signer-pins/ or set \
562 MURK_NO_SIGNER_PIN=1."
563 )));
564 }
565 murk.signature_downgraded = true;
566 }
567 }
568 }
569
570 Ok((vault, murk, identity))
571}
572
573fn rebuild_shared(
576 key: &str,
577 vault: &types::Vault,
578 recipients: &[crypto::MurkRecipient],
579 recipients_changed: bool,
580 original: &types::Murk,
581 current: &types::Murk,
582) -> Result<String, MurkError> {
583 let Some(value) = current.values.get(key) else {
584 return Ok(String::new());
586 };
587 if !recipients_changed
589 && original.values.get(key) == Some(value)
590 && let Some(existing) = vault.secrets.get(key)
591 {
592 return Ok(existing.shared.clone());
593 }
594 encrypt_value(value.as_bytes(), recipients)
595}
596
597fn rebuild_private(
600 key: &str,
601 vault: &types::Vault,
602 original: &types::Murk,
603 current: &types::Murk,
604) -> Result<BTreeMap<String, String>, MurkError> {
605 let mut scoped = vault
606 .secrets
607 .get(key)
608 .map(|e| e.private.clone())
609 .unwrap_or_default();
610
611 if let Some(key_scoped) = current.private.get(key) {
612 for (pk, val) in key_scoped {
613 let original_val = original.private.get(key).and_then(|m| m.get(pk));
614 if original_val != Some(val) {
615 let recipient = crypto::parse_recipient(pk)?;
616 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
617 }
618 }
619 }
620
621 if let Some(orig_key_scoped) = original.private.get(key) {
622 for pk in orig_key_scoped.keys() {
623 let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
624 if !still_present {
625 scoped.remove(pk);
626 }
627 }
628 }
629
630 Ok(scoped)
631}
632
633fn rebuild_grouped(
637 key: &str,
638 vault: &types::Vault,
639 changed_groups: &BTreeSet<&str>,
640 original: &types::Murk,
641 current: &types::Murk,
642) -> Result<BTreeMap<String, String>, MurkError> {
643 let mut grouped = vault
644 .secrets
645 .get(key)
646 .map(|e| e.grouped.clone())
647 .unwrap_or_default();
648
649 if let Some(key_grouped) = current.grouped.get(key) {
650 for (group, val) in key_grouped {
651 let members = current.groups.get(group).ok_or_else(|| {
652 MurkError::Secret(format!("secret {key} references unknown group {group}"))
653 })?;
654 let original_val = original.grouped.get(key).and_then(|m| m.get(group));
655 if original_val != Some(val) || changed_groups.contains(group.as_str()) {
656 let group_recipients = parse_recipients(members)?;
657 grouped.insert(
658 group.clone(),
659 encrypt_value(val.as_bytes(), &group_recipients)?,
660 );
661 }
662 }
663 }
664
665 if let Some(orig_key_grouped) = original.grouped.get(key) {
666 for group in orig_key_grouped.keys() {
667 let still_present = current
668 .grouped
669 .get(key)
670 .is_some_and(|m| m.contains_key(group));
671 if !still_present {
672 grouped.remove(group);
673 }
674 }
675 }
676
677 Ok(grouped)
678}
679
680fn resync_grant_private(
689 key: &str,
690 private: &mut BTreeMap<String, String>,
691 original: &types::Murk,
692 current: &types::Murk,
693) -> Result<(), MurkError> {
694 let Some(value) = current.values.get(key) else {
695 return Ok(());
696 };
697 if original.values.get(key) == Some(value) {
698 return Ok(());
699 }
700 for grant in current.grants.values() {
701 if grant.scope.iter().any(|k| k == key) {
702 let recipient = crypto::parse_recipient(&grant.pubkey)?;
703 private.insert(
704 grant.pubkey.clone(),
705 encrypt_value(value.as_bytes(), &[recipient])?,
706 );
707 }
708 }
709 Ok(())
710}
711
712pub fn save_vault(
715 vault_path: &str,
716 vault: &mut types::Vault,
717 original: &types::Murk,
718 current: &types::Murk,
719) -> Result<(), MurkError> {
720 let recipients = parse_recipients(&vault.recipients)?;
723
724 let grant_pubkeys: BTreeSet<&str> =
729 current.grants.values().map(|g| g.pubkey.as_str()).collect();
730 let shared_recipients: Vec<crypto::MurkRecipient> = vault
731 .recipients
732 .iter()
733 .filter(|pk| !grant_pubkeys.contains(pk.as_str()))
734 .map(|pk| crypto::parse_recipient(pk))
735 .collect::<Result<_, _>>()?;
736
737 let shared_recipients_changed = {
742 let orig_grant_pubkeys: BTreeSet<&str> = original
743 .grants
744 .values()
745 .map(|g| g.pubkey.as_str())
746 .collect();
747 let mut current_pks: Vec<&str> = vault
748 .recipients
749 .iter()
750 .map(String::as_str)
751 .filter(|pk| !grant_pubkeys.contains(pk))
752 .collect();
753 let mut original_pks: Vec<&str> = original
754 .recipients
755 .keys()
756 .map(String::as_str)
757 .filter(|pk| !orig_grant_pubkeys.contains(pk))
758 .collect();
759 current_pks.sort_unstable();
760 original_pks.sort_unstable();
761 current_pks != original_pks
762 };
763
764 let changed_groups: BTreeSet<&str> = current
768 .groups
769 .keys()
770 .chain(original.groups.keys())
771 .filter(|g| current.groups.get(*g) != original.groups.get(*g))
772 .map(String::as_str)
773 .collect();
774
775 let mut new_secrets = BTreeMap::new();
776
777 let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
780 all_keys.extend(current.private.keys());
781 all_keys.extend(current.grouped.keys());
782
783 let original_visible: BTreeSet<&String> = original
789 .values
790 .keys()
791 .chain(original.private.keys())
792 .chain(original.grouped.keys())
793 .collect();
794 for key in vault.secrets.keys() {
795 if !original_visible.contains(key) {
796 all_keys.insert(key);
797 }
798 }
799
800 for key in all_keys {
801 let shared = rebuild_shared(
802 key,
803 vault,
804 &shared_recipients,
805 shared_recipients_changed,
806 original,
807 current,
808 )?;
809 let mut private = rebuild_private(key, vault, original, current)?;
810 resync_grant_private(key, &mut private, original, current)?;
811 let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
812 new_secrets.insert(
813 key.clone(),
814 types::SecretEntry {
815 shared,
816 private,
817 grouped,
818 },
819 );
820 }
821
822 vault.secrets = new_secrets;
823
824 let meta = build_meta(vault_path, vault, current);
825 let meta_json =
826 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
827 vault.meta = encrypt_value(&meta_json, &recipients)?;
828
829 Ok(vault::write(Path::new(vault_path), vault)?)
830}
831
832fn build_meta(vault_path: &str, vault: &types::Vault, current: &types::Murk) -> types::Meta {
837 let mac_key_hex = generate_mac_key();
839 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
840 let mac = compute_mac(vault, ¤t.groups, ¤t.grants, Some(&mac_key));
841
842 let mut signers = current.signers.clone();
845 signers.retain(|pk, _| vault.recipients.iter().any(|r| r == pk));
851 let sig = signing_identity(vault_path).and_then(|identity| {
852 sign_vault(
853 vault,
854 ¤t.groups,
855 ¤t.grants,
856 ¤t.github_pins,
857 &mut signers,
858 &identity,
859 )
860 });
861
862 types::Meta {
863 recipients: current.recipients.clone(),
864 mac,
865 mac_key: Some(mac_key_hex),
866 github_pins: current.github_pins.clone(),
867 groups: current.groups.clone(),
868 grants: current.grants.clone(),
869 signers,
870 sig,
871 }
872}
873
874pub(crate) fn compute_mac(
883 vault: &types::Vault,
884 groups: &BTreeMap<String, Vec<String>>,
885 grants: &BTreeMap<String, types::GrantEntry>,
886 mac_key: Option<&[u8; 32]>,
887) -> String {
888 match mac_key {
889 Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
890 compute_mac_v9(vault, groups, grants, key)
891 }
892 Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
893 Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
894 Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
895 Some(key) => compute_mac_v5(vault, key),
896 None => compute_mac_v2(vault),
897 }
898}
899
900fn compute_mac_v1(vault: &types::Vault) -> String {
902 use sha2::{Digest, Sha256};
903
904 let mut hasher = Sha256::new();
905
906 for key in vault.secrets.keys() {
907 hasher.update(key.as_bytes());
908 hasher.update(b"\x00");
909 }
910
911 for entry in vault.secrets.values() {
912 hasher.update(entry.shared.as_bytes());
913 hasher.update(b"\x00");
914 }
915
916 let mut pks = vault.recipients.clone();
917 pks.sort();
918 for pk in &pks {
919 hasher.update(pk.as_bytes());
920 hasher.update(b"\x00");
921 }
922
923 let digest = hasher.finalize();
924 format!(
925 "sha256:{}",
926 digest.iter().fold(String::new(), |mut s, b| {
927 use std::fmt::Write;
928 let _ = write!(s, "{b:02x}");
929 s
930 })
931 )
932}
933
934fn compute_mac_v2(vault: &types::Vault) -> String {
936 use sha2::{Digest, Sha256};
937
938 let mut hasher = Sha256::new();
939
940 for key in vault.secrets.keys() {
942 hasher.update(key.as_bytes());
943 hasher.update(b"\x00");
944 }
945
946 for entry in vault.secrets.values() {
948 hasher.update(entry.shared.as_bytes());
949 hasher.update(b"\x00");
950
951 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
953 scoped_pks.sort();
954 for pk in scoped_pks {
955 hasher.update(pk.as_bytes());
956 hasher.update(b"\x01");
957 hasher.update(entry.private[pk].as_bytes());
958 hasher.update(b"\x00");
959 }
960 }
961
962 let mut pks = vault.recipients.clone();
964 pks.sort();
965 for pk in &pks {
966 hasher.update(pk.as_bytes());
967 hasher.update(b"\x00");
968 }
969
970 let digest = hasher.finalize();
971 format!(
972 "sha256v2:{}",
973 digest.iter().fold(String::new(), |mut s, b| {
974 use std::fmt::Write;
975 let _ = write!(s, "{b:02x}");
976 s
977 })
978 )
979}
980
981fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
983 let mut data = Vec::new();
984
985 for key_name in vault.secrets.keys() {
986 data.extend_from_slice(key_name.as_bytes());
987 data.push(0x00);
988 }
989
990 for entry in vault.secrets.values() {
991 data.extend_from_slice(entry.shared.as_bytes());
992 data.push(0x00);
993
994 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
995 scoped_pks.sort();
996 for pk in scoped_pks {
997 data.extend_from_slice(pk.as_bytes());
998 data.push(0x01);
999 data.extend_from_slice(entry.private[pk].as_bytes());
1000 data.push(0x00);
1001 }
1002 }
1003
1004 let mut pks = vault.recipients.clone();
1005 pks.sort();
1006 for pk in &pks {
1007 data.extend_from_slice(pk.as_bytes());
1008 data.push(0x00);
1009 }
1010
1011 let hash = blake3::keyed_hash(key, &data);
1012 format!("blake3:{hash}")
1013}
1014
1015fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
1018 let mut data = Vec::new();
1019
1020 for key_name in vault.secrets.keys() {
1021 data.extend_from_slice(key_name.as_bytes());
1022 data.push(0x00);
1023 }
1024
1025 for entry in vault.secrets.values() {
1026 data.extend_from_slice(entry.shared.as_bytes());
1027 data.push(0x00);
1028
1029 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1030 scoped_pks.sort();
1031 for pk in scoped_pks {
1032 data.extend_from_slice(pk.as_bytes());
1033 data.push(0x01);
1034 data.extend_from_slice(entry.private[pk].as_bytes());
1035 data.push(0x00);
1036 }
1037 }
1038
1039 let mut pks = vault.recipients.clone();
1040 pks.sort();
1041 for pk in &pks {
1042 data.extend_from_slice(pk.as_bytes());
1043 data.push(0x00);
1044 }
1045
1046 for (key_name, entry) in &vault.schema {
1049 data.push(0x02);
1050 data.extend_from_slice(key_name.as_bytes());
1051 data.push(0x00);
1052 data.extend_from_slice(entry.description.as_bytes());
1053 data.push(0x00);
1054 if let Some(example) = &entry.example {
1055 data.extend_from_slice(example.as_bytes());
1056 }
1057 data.push(0x00);
1058 for tag in &entry.tags {
1059 data.extend_from_slice(tag.as_bytes());
1060 data.push(0x00);
1061 }
1062 }
1063
1064 let hash = blake3::keyed_hash(key, &data);
1065 format!("blake3v2:{hash}")
1066}
1067
1068fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
1074 let mut data = Vec::new();
1075
1076 for key_name in vault.secrets.keys() {
1077 data.extend_from_slice(key_name.as_bytes());
1078 data.push(0x00);
1079 }
1080
1081 for entry in vault.secrets.values() {
1082 data.extend_from_slice(entry.shared.as_bytes());
1083 data.push(0x00);
1084
1085 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1086 scoped_pks.sort();
1087 for pk in scoped_pks {
1088 data.extend_from_slice(pk.as_bytes());
1089 data.push(0x01);
1090 data.extend_from_slice(entry.private[pk].as_bytes());
1091 data.push(0x00);
1092 }
1093 }
1094
1095 let mut pks = vault.recipients.clone();
1096 pks.sort();
1097 for pk in &pks {
1098 data.extend_from_slice(pk.as_bytes());
1099 data.push(0x00);
1100 }
1101
1102 for (key_name, entry) in &vault.schema {
1107 data.push(0x02);
1108 data.extend_from_slice(key_name.as_bytes());
1109 data.push(0x00);
1110 data.extend_from_slice(entry.description.as_bytes());
1111 data.push(0x00);
1112 if let Some(example) = &entry.example {
1113 data.extend_from_slice(example.as_bytes());
1114 }
1115 data.push(0x00);
1116 for tag in &entry.tags {
1117 data.extend_from_slice(tag.as_bytes());
1118 data.push(0x00);
1119 }
1120 if let Some(created) = &entry.created {
1123 data.extend_from_slice(created.as_bytes());
1124 }
1125 data.push(0x00);
1126 if let Some(updated) = &entry.updated {
1127 data.extend_from_slice(updated.as_bytes());
1128 }
1129 data.push(0x00);
1130 if let Some(days) = entry.rotation_interval_days {
1131 data.extend_from_slice(days.to_string().as_bytes());
1132 }
1133 data.push(0x00);
1134 if let Some(expires) = &entry.expires_at {
1135 data.extend_from_slice(expires.as_bytes());
1136 }
1137 data.push(0x00);
1138 }
1139
1140 let hash = blake3::keyed_hash(key, &data);
1141 format!("blake3v3:{hash}")
1142}
1143
1144fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
1148 for (key_name, entry) in &vault.schema {
1149 data.push(0x02);
1150 data.extend_from_slice(key_name.as_bytes());
1151 data.push(0x00);
1152 data.extend_from_slice(entry.description.as_bytes());
1153 data.push(0x00);
1154 if let Some(example) = &entry.example {
1155 data.extend_from_slice(example.as_bytes());
1156 }
1157 data.push(0x00);
1158 for tag in &entry.tags {
1159 data.extend_from_slice(tag.as_bytes());
1160 data.push(0x00);
1161 }
1162 if let Some(created) = &entry.created {
1163 data.extend_from_slice(created.as_bytes());
1164 }
1165 data.push(0x00);
1166 if let Some(updated) = &entry.updated {
1167 data.extend_from_slice(updated.as_bytes());
1168 }
1169 data.push(0x00);
1170 if let Some(days) = entry.rotation_interval_days {
1171 data.extend_from_slice(days.to_string().as_bytes());
1172 }
1173 data.push(0x00);
1174 if let Some(expires) = &entry.expires_at {
1175 data.extend_from_slice(expires.as_bytes());
1176 }
1177 data.push(0x00);
1178 }
1179}
1180
1181fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
1185 for key_name in vault.secrets.keys() {
1186 data.extend_from_slice(key_name.as_bytes());
1187 data.push(0x00);
1188 }
1189
1190 for entry in vault.secrets.values() {
1191 data.extend_from_slice(entry.shared.as_bytes());
1192 data.push(0x00);
1193
1194 let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1195 scoped_pks.sort();
1196 for pk in scoped_pks {
1197 data.extend_from_slice(pk.as_bytes());
1198 data.push(0x01);
1199 data.extend_from_slice(entry.private[pk].as_bytes());
1200 data.push(0x00);
1201 }
1202
1203 let mut group_names: Vec<&String> = entry.grouped.keys().collect();
1206 group_names.sort();
1207 for g in group_names {
1208 data.push(0x03);
1209 data.extend_from_slice(g.as_bytes());
1210 data.push(0x00);
1211 data.extend_from_slice(entry.grouped[g].as_bytes());
1212 data.push(0x00);
1213 }
1214 }
1215
1216 let mut pks = vault.recipients.clone();
1217 pks.sort();
1218 for pk in &pks {
1219 data.extend_from_slice(pk.as_bytes());
1220 data.push(0x00);
1221 }
1222
1223 schema_mac_bytes(vault, data);
1224
1225 for (name, members) in groups {
1228 data.push(0x04);
1229 data.extend_from_slice(name.as_bytes());
1230 data.push(0x00);
1231 let mut sorted = members.clone();
1232 sorted.sort();
1233 for member in &sorted {
1234 data.push(0x05);
1235 data.extend_from_slice(member.as_bytes());
1236 }
1237 }
1238}
1239
1240fn compute_mac_v6(
1245 vault: &types::Vault,
1246 groups: &BTreeMap<String, Vec<String>>,
1247 key: &[u8; 32],
1248) -> String {
1249 let mut data = Vec::new();
1250 v6_mac_bytes(vault, groups, &mut data);
1251 let hash = blake3::keyed_hash(key, &data);
1252 format!("blake3v4:{hash}")
1253}
1254
1255fn v7_mac_bytes(
1264 vault: &types::Vault,
1265 groups: &BTreeMap<String, Vec<String>>,
1266 grants: &BTreeMap<String, types::GrantEntry>,
1267 data: &mut Vec<u8>,
1268) {
1269 v6_mac_bytes(vault, groups, data);
1270
1271 for (name, grant) in grants {
1275 data.push(0x06);
1276 data.extend_from_slice(name.as_bytes());
1277 data.push(0x00);
1278 data.extend_from_slice(grant.pubkey.as_bytes());
1279 data.push(0x00);
1280 data.extend_from_slice(grant.issued_at.as_bytes());
1281 data.push(0x00);
1282 data.extend_from_slice(grant.expires_at.as_bytes());
1283 data.push(0x00);
1284 data.extend_from_slice(grant.issuer.as_bytes());
1285 data.push(0x00);
1286 let mut scope = grant.scope.clone();
1287 scope.sort();
1288 for k in &scope {
1289 data.push(0x07);
1290 data.extend_from_slice(k.as_bytes());
1291 }
1292 }
1293}
1294
1295fn compute_mac_v7(
1296 vault: &types::Vault,
1297 groups: &BTreeMap<String, Vec<String>>,
1298 grants: &BTreeMap<String, types::GrantEntry>,
1299 key: &[u8; 32],
1300) -> String {
1301 let mut data = Vec::new();
1302 v7_mac_bytes(vault, groups, grants, &mut data);
1303 let hash = blake3::keyed_hash(key, &data);
1304 format!("blake3v5:{hash}")
1305}
1306
1307fn v8_mac_bytes(
1311 vault: &types::Vault,
1312 groups: &BTreeMap<String, Vec<String>>,
1313 grants: &BTreeMap<String, types::GrantEntry>,
1314 data: &mut Vec<u8>,
1315) {
1316 v7_mac_bytes(vault, groups, grants, data);
1317
1318 if let Some(policy) = &vault.policy {
1325 data.push(0x08);
1326 let mut tags = policy.agent_allow_tags.clone();
1327 tags.sort();
1328 for tag in &tags {
1329 let bytes = tag.as_bytes();
1330 data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
1333 data.extend_from_slice(bytes);
1334 }
1335 }
1336}
1337
1338fn compute_mac_v8(
1343 vault: &types::Vault,
1344 groups: &BTreeMap<String, Vec<String>>,
1345 grants: &BTreeMap<String, types::GrantEntry>,
1346 key: &[u8; 32],
1347) -> String {
1348 let mut data = Vec::new();
1349 v8_mac_bytes(vault, groups, grants, &mut data);
1350 let hash = blake3::keyed_hash(key, &data);
1351 format!("blake3v6:{hash}")
1352}
1353
1354fn compute_mac_v9(
1360 vault: &types::Vault,
1361 groups: &BTreeMap<String, Vec<String>>,
1362 grants: &BTreeMap<String, types::GrantEntry>,
1363 key: &[u8; 32],
1364) -> String {
1365 let mut data = Vec::new();
1366 v8_mac_bytes(vault, groups, grants, &mut data);
1367
1368 for (key_name, entry) in &vault.schema {
1373 if let Some(revoked_at) = &entry.revoked_at {
1374 data.push(0x09);
1375 data.extend_from_slice(key_name.as_bytes());
1376 data.push(0x00);
1377 data.extend_from_slice(revoked_at.as_bytes());
1378 data.push(0x00);
1379 }
1380 }
1381
1382 let hash = blake3::keyed_hash(key, &data);
1383 format!("blake3v7:{hash}")
1384}
1385
1386pub(crate) fn verify_mac(
1389 vault: &types::Vault,
1390 groups: &BTreeMap<String, Vec<String>>,
1391 grants: &BTreeMap<String, types::GrantEntry>,
1392 stored_mac: &str,
1393 mac_key: Option<&[u8; 32]>,
1394) -> bool {
1395 use constant_time_eq::constant_time_eq;
1396
1397 if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
1401 {
1402 return false;
1403 }
1404
1405 if vault.policy.is_some()
1409 && !stored_mac.starts_with("blake3v6:")
1410 && !stored_mac.starts_with("blake3v7:")
1411 {
1412 return false;
1413 }
1414
1415 if !grants.is_empty()
1418 && !stored_mac.starts_with("blake3v5:")
1419 && !stored_mac.starts_with("blake3v6:")
1420 && !stored_mac.starts_with("blake3v7:")
1421 {
1422 return false;
1423 }
1424
1425 let touches_groups =
1431 !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
1432 if touches_groups
1433 && !stored_mac.starts_with("blake3v4:")
1434 && !stored_mac.starts_with("blake3v5:")
1435 && !stored_mac.starts_with("blake3v6:")
1436 && !stored_mac.starts_with("blake3v7:")
1437 {
1438 return false;
1439 }
1440
1441 let expected = if stored_mac.starts_with("blake3v7:") {
1442 match mac_key {
1443 Some(key) => compute_mac_v9(vault, groups, grants, key),
1444 None => return false,
1445 }
1446 } else if stored_mac.starts_with("blake3v6:") {
1447 match mac_key {
1448 Some(key) => compute_mac_v8(vault, groups, grants, key),
1449 None => return false,
1450 }
1451 } else if stored_mac.starts_with("blake3v5:") {
1452 match mac_key {
1453 Some(key) => compute_mac_v7(vault, groups, grants, key),
1454 None => return false,
1455 }
1456 } else if stored_mac.starts_with("blake3v4:") {
1457 match mac_key {
1458 Some(key) => compute_mac_v6(vault, groups, key),
1459 None => return false,
1460 }
1461 } else if stored_mac.starts_with("blake3v3:") {
1462 match mac_key {
1463 Some(key) => compute_mac_v5(vault, key),
1464 None => return false,
1465 }
1466 } else if stored_mac.starts_with("blake3v2:") {
1467 match mac_key {
1468 Some(key) => compute_mac_v4(vault, key),
1469 None => return false,
1470 }
1471 } else if stored_mac.starts_with("blake3:") {
1472 match mac_key {
1473 Some(key) => compute_mac_v3(vault, key),
1474 None => return false,
1475 }
1476 } else if stored_mac.starts_with("sha256v2:") {
1477 compute_mac_v2(vault)
1478 } else if stored_mac.starts_with("sha256:") {
1479 compute_mac_v1(vault)
1480 } else {
1481 return false;
1482 };
1483 constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
1484}
1485
1486pub(crate) fn generate_mac_key() -> String {
1488 let key: [u8; 32] = rand::random();
1489 key.iter().fold(String::new(), |mut s, b| {
1490 use std::fmt::Write;
1491 let _ = write!(s, "{b:02x}");
1492 s
1493 })
1494}
1495
1496pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
1498 if hex.len() != 64 {
1499 return None;
1500 }
1501 let mut key = [0u8; 32];
1502 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
1503 key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
1504 }
1505 Some(key)
1506}
1507
1508pub(crate) fn now_utc() -> String {
1510 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1511}
1512
1513const SIGNED_VIEW_VERSION: u32 = 1;
1517
1518pub(crate) fn signing_message(
1527 vault: &types::Vault,
1528 groups: &BTreeMap<String, Vec<String>>,
1529 grants: &BTreeMap<String, types::GrantEntry>,
1530 github_pins: &HashMap<String, Vec<String>>,
1531 signers: &BTreeMap<String, String>,
1532) -> Vec<u8> {
1533 #[derive(serde::Serialize)]
1534 struct SignedView<'a> {
1535 v: u32,
1536 version: &'a str,
1537 recipients: Vec<&'a str>,
1538 schema: &'a BTreeMap<String, types::SchemaEntry>,
1539 secrets: &'a BTreeMap<String, types::SecretEntry>,
1540 policy: &'a Option<types::Policy>,
1541 groups: &'a BTreeMap<String, Vec<String>>,
1542 grants: &'a BTreeMap<String, types::GrantEntry>,
1543 github_pins: BTreeMap<&'a str, &'a Vec<String>>,
1544 signers: &'a BTreeMap<String, String>,
1545 }
1546
1547 let mut recipients: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
1548 recipients.sort_unstable();
1549 let pins: BTreeMap<&str, &Vec<String>> =
1550 github_pins.iter().map(|(k, v)| (k.as_str(), v)).collect();
1551
1552 let view = SignedView {
1553 v: SIGNED_VIEW_VERSION,
1554 version: &vault.version,
1555 recipients,
1556 schema: &vault.schema,
1557 secrets: &vault.secrets,
1558 policy: &vault.policy,
1559 groups,
1560 grants,
1561 github_pins: pins,
1562 signers,
1563 };
1564
1565 let mut msg = Vec::with_capacity(256);
1566 msg.extend_from_slice(b"murk.vault.sig.v1\n");
1567 serde_json::to_writer(&mut msg, &view).expect("canonical vault view serializes");
1568 msg
1569}
1570
1571pub(crate) fn sign_vault(
1575 vault: &types::Vault,
1576 groups: &BTreeMap<String, Vec<String>>,
1577 grants: &BTreeMap<String, types::GrantEntry>,
1578 github_pins: &HashMap<String, Vec<String>>,
1579 signers: &mut BTreeMap<String, String>,
1580 identity: &crypto::MurkIdentity,
1581) -> Option<types::VaultSignature> {
1582 let signer = identity.pubkey_string().ok()?;
1583 if !signer_is_recipient(vault, &signer) {
1587 return None;
1588 }
1589 let sk = identity.signing_key()?;
1590 if identity.registers_verifying_key() {
1594 signers.insert(signer.clone(), signing::verifying_key_b64(&sk));
1595 }
1596 let msg = signing_message(vault, groups, grants, github_pins, signers);
1597 Some(types::VaultSignature {
1598 signer,
1599 v: SIGNED_VIEW_VERSION,
1600 sig: signing::sign(&sk, &msg),
1601 })
1602}
1603
1604fn signer_is_recipient(vault: &types::Vault, signer: &str) -> bool {
1608 if signer.starts_with("ssh-ed25519 ") {
1609 vault
1610 .recipients
1611 .iter()
1612 .any(|r| signing::ssh_ed25519_key_eq(r, signer))
1613 } else {
1614 vault.recipients.iter().any(|r| r == signer)
1615 }
1616}
1617
1618pub(crate) fn verify_vault_signature(
1624 vault: &types::Vault,
1625 groups: &BTreeMap<String, Vec<String>>,
1626 grants: &BTreeMap<String, types::GrantEntry>,
1627 github_pins: &HashMap<String, Vec<String>>,
1628 signers: &BTreeMap<String, String>,
1629 sig: &types::VaultSignature,
1630) -> bool {
1631 if sig.v != SIGNED_VIEW_VERSION {
1632 return false;
1633 }
1634 if !signer_is_recipient(vault, &sig.signer) {
1635 return false;
1636 }
1637 let vk = if sig.signer.starts_with("ssh-ed25519 ") {
1638 match signing::ed25519_verifying_key_b64_from_ssh_recipient(&sig.signer) {
1640 Some(vk) => vk,
1641 None => return false,
1642 }
1643 } else {
1644 match signers.get(&sig.signer) {
1645 Some(vk) => vk.clone(),
1646 None => return false,
1647 }
1648 };
1649 let msg = signing_message(vault, groups, grants, github_pins, signers);
1650 signing::verify(&vk, &sig.sig, &msg)
1651}
1652
1653fn signing_identity(vault_path: &str) -> Option<crypto::MurkIdentity> {
1657 use age::secrecy::ExposeSecret;
1658 let secret = env::resolve_key_for_vault(vault_path).ok()?;
1659 crypto::parse_identity(secret.expose_secret()).ok()
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664 use super::*;
1665 use crate::testutil::*;
1666 use std::collections::BTreeMap;
1667 use std::fs;
1668
1669 use crate::testutil::ENV_LOCK;
1670
1671 #[test]
1672 fn resolve_vault_path_finds_in_parent_dir() {
1673 let _lock = ENV_LOCK
1674 .lock()
1675 .unwrap_or_else(std::sync::PoisonError::into_inner);
1676 let dir = tempfile::tempdir().unwrap();
1677 fs::create_dir(dir.path().join(".git")).unwrap();
1679 fs::write(dir.path().join(".murk"), "{}").unwrap();
1680 let nested = dir.path().join("a").join("b");
1681 fs::create_dir_all(&nested).unwrap();
1682
1683 let prev = std::env::current_dir().unwrap();
1684 std::env::set_current_dir(&nested).unwrap();
1685 let got = resolve_vault_path(".murk");
1686 std::env::set_current_dir(prev).unwrap();
1687
1688 assert_eq!(
1689 std::fs::canonicalize(&got).unwrap(),
1690 std::fs::canonicalize(dir.path().join(".murk")).unwrap()
1691 );
1692 }
1693
1694 #[test]
1695 fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
1696 let _lock = ENV_LOCK
1697 .lock()
1698 .unwrap_or_else(std::sync::PoisonError::into_inner);
1699 let dir = tempfile::tempdir().unwrap();
1700 fs::write(dir.path().join(".murk"), "{}").unwrap();
1701 let prev = std::env::current_dir().unwrap();
1702 std::env::set_current_dir(dir.path()).unwrap();
1703 let got = resolve_vault_path(".murk");
1704 std::env::set_current_dir(prev).unwrap();
1705 assert_eq!(got, ".murk");
1706 }
1707
1708 #[test]
1709 fn resolve_vault_path_passes_through_explicit_paths() {
1710 assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
1711 assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
1712 assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
1713 }
1714
1715 #[test]
1716 fn resolve_vault_path_stops_at_git_root() {
1717 let _lock = ENV_LOCK
1718 .lock()
1719 .unwrap_or_else(std::sync::PoisonError::into_inner);
1720 let dir = tempfile::tempdir().unwrap();
1721 fs::write(dir.path().join(".murk"), "{}").unwrap();
1723 let repo = dir.path().join("repo");
1724 fs::create_dir(&repo).unwrap();
1725 fs::create_dir(repo.join(".git")).unwrap();
1726 let nested = repo.join("sub");
1727 fs::create_dir(&nested).unwrap();
1728
1729 let prev = std::env::current_dir().unwrap();
1730 std::env::set_current_dir(&nested).unwrap();
1731 let got = resolve_vault_path(".murk");
1732 std::env::set_current_dir(prev).unwrap();
1733
1734 assert_eq!(got, ".murk");
1736 }
1737
1738 #[test]
1739 fn encrypt_decrypt_value_roundtrip() {
1740 let (secret, pubkey) = generate_keypair();
1741 let recipient = make_recipient(&pubkey);
1742 let identity = make_identity(&secret);
1743
1744 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
1745 let decrypted = decrypt_value(&encoded, &identity).unwrap();
1746 assert_eq!(&decrypted[..], b"hello world");
1747 }
1748
1749 #[test]
1750 fn decrypt_value_invalid_base64() {
1751 let (secret, _) = generate_keypair();
1752 let identity = make_identity(&secret);
1753
1754 let result = decrypt_value("not!valid!base64!!!", &identity);
1755 assert!(result.is_err());
1756 assert!(result.unwrap_err().to_string().contains("invalid base64"));
1757 }
1758
1759 #[test]
1760 fn encrypt_value_multiple_recipients() {
1761 let (secret_a, pubkey_a) = generate_keypair();
1762 let (secret_b, pubkey_b) = generate_keypair();
1763
1764 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
1765 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
1766
1767 let id_a = make_identity(&secret_a);
1769 let id_b = make_identity(&secret_b);
1770 assert_eq!(
1771 &decrypt_value(&encoded, &id_a).unwrap()[..],
1772 b"shared secret"
1773 );
1774 assert_eq!(
1775 &decrypt_value(&encoded, &id_b).unwrap()[..],
1776 b"shared secret"
1777 );
1778 }
1779
1780 #[test]
1781 fn decrypt_value_wrong_key_fails() {
1782 let (_, pubkey) = generate_keypair();
1783 let (wrong_secret, _) = generate_keypair();
1784
1785 let recipient = make_recipient(&pubkey);
1786 let wrong_identity = make_identity(&wrong_secret);
1787
1788 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
1789 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
1790 }
1791
1792 #[test]
1793 fn compute_mac_deterministic() {
1794 let vault = types::Vault {
1795 version: types::VAULT_VERSION.into(),
1796 created: "2026-02-28T00:00:00Z".into(),
1797 vault_name: ".murk".into(),
1798 repo: String::new(),
1799 recipients: vec!["age1abc".into()],
1800 schema: BTreeMap::new(),
1801 policy: None,
1802 secrets: BTreeMap::new(),
1803 meta: String::new(),
1804 };
1805
1806 let key = [0u8; 32];
1807 let mac1 = compute_mac(
1808 &vault,
1809 &std::collections::BTreeMap::new(),
1810 &std::collections::BTreeMap::new(),
1811 Some(&key),
1812 );
1813 let mac2 = compute_mac(
1814 &vault,
1815 &std::collections::BTreeMap::new(),
1816 &std::collections::BTreeMap::new(),
1817 Some(&key),
1818 );
1819 assert_eq!(mac1, mac2);
1820 assert!(mac1.starts_with("blake3v3:"));
1821
1822 let mac_legacy = compute_mac(
1824 &vault,
1825 &std::collections::BTreeMap::new(),
1826 &std::collections::BTreeMap::new(),
1827 None,
1828 );
1829 assert!(mac_legacy.starts_with("sha256v2:"));
1830 }
1831
1832 #[test]
1833 fn compute_mac_changes_with_different_secrets() {
1834 let mut vault = types::Vault {
1835 version: types::VAULT_VERSION.into(),
1836 created: "2026-02-28T00:00:00Z".into(),
1837 vault_name: ".murk".into(),
1838 repo: String::new(),
1839 recipients: vec!["age1abc".into()],
1840 schema: BTreeMap::new(),
1841 policy: None,
1842 secrets: BTreeMap::new(),
1843 meta: String::new(),
1844 };
1845
1846 let key = [0u8; 32];
1847 let mac_empty = compute_mac(
1848 &vault,
1849 &std::collections::BTreeMap::new(),
1850 &std::collections::BTreeMap::new(),
1851 Some(&key),
1852 );
1853
1854 vault.secrets.insert(
1855 "KEY".into(),
1856 types::SecretEntry {
1857 shared: "ciphertext".into(),
1858 private: BTreeMap::new(),
1859 grouped: std::collections::BTreeMap::default(),
1860 },
1861 );
1862
1863 let mac_with_secret = compute_mac(
1864 &vault,
1865 &std::collections::BTreeMap::new(),
1866 &std::collections::BTreeMap::new(),
1867 Some(&key),
1868 );
1869 assert_ne!(mac_empty, mac_with_secret);
1870 }
1871
1872 #[test]
1873 fn compute_mac_changes_with_different_recipients() {
1874 let mut vault = types::Vault {
1875 version: types::VAULT_VERSION.into(),
1876 created: "2026-02-28T00:00:00Z".into(),
1877 vault_name: ".murk".into(),
1878 repo: String::new(),
1879 recipients: vec!["age1abc".into()],
1880 schema: BTreeMap::new(),
1881 policy: None,
1882 secrets: BTreeMap::new(),
1883 meta: String::new(),
1884 };
1885
1886 let key = [0u8; 32];
1887 let mac1 = compute_mac(
1888 &vault,
1889 &std::collections::BTreeMap::new(),
1890 &std::collections::BTreeMap::new(),
1891 Some(&key),
1892 );
1893 vault.recipients.push("age1xyz".into());
1894 let mac2 = compute_mac(
1895 &vault,
1896 &std::collections::BTreeMap::new(),
1897 &std::collections::BTreeMap::new(),
1898 Some(&key),
1899 );
1900 assert_ne!(mac1, mac2);
1901 }
1902
1903 fn signed_test_vault(pubkey: &str, recipient: &crypto::MurkRecipient) -> types::Vault {
1905 let mut vault = types::Vault {
1906 version: types::VAULT_VERSION.into(),
1907 created: "2026-02-28T00:00:00Z".into(),
1908 vault_name: ".murk".into(),
1909 repo: String::new(),
1910 recipients: vec![pubkey.to_string()],
1911 schema: BTreeMap::new(),
1912 policy: None,
1913 secrets: BTreeMap::new(),
1914 meta: String::new(),
1915 };
1916 vault.secrets.insert(
1917 "API_KEY".into(),
1918 types::SecretEntry {
1919 shared: encrypt_value(b"REAL", std::slice::from_ref(recipient)).unwrap(),
1920 private: BTreeMap::new(),
1921 grouped: BTreeMap::new(),
1922 },
1923 );
1924 vault
1925 }
1926
1927 #[test]
1928 fn sign_and_verify_vault_roundtrips() {
1929 let (secret, pubkey) = generate_keypair();
1930 let identity = make_identity(&secret);
1931 let vault = signed_test_vault(&pubkey, &make_recipient(&pubkey));
1932 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1933
1934 let mut signers = BTreeMap::new();
1935 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1936 assert_eq!(sig.signer, pubkey);
1937 assert!(verify_vault_signature(
1938 &vault, &g, &gr, &pins, &signers, &sig
1939 ));
1940 }
1941
1942 #[test]
1943 fn signature_detects_ciphertext_tampering() {
1944 let (secret, pubkey) = generate_keypair();
1945 let identity = make_identity(&secret);
1946 let recipient = make_recipient(&pubkey);
1947 let mut vault = signed_test_vault(&pubkey, &recipient);
1948 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1949
1950 let mut signers = BTreeMap::new();
1951 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1952
1953 vault.secrets.get_mut("API_KEY").unwrap().shared =
1956 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
1957 assert!(
1958 !verify_vault_signature(&vault, &g, &gr, &pins, &signers, &sig),
1959 "tampered ciphertext must fail signature verification"
1960 );
1961 }
1962
1963 #[test]
1964 fn signature_rejects_non_recipient_signer() {
1965 let (_victim_secret, victim_pub) = generate_keypair();
1969 let (attacker_secret, attacker_pub) = generate_keypair();
1970 let attacker = make_identity(&attacker_secret);
1971 let vault = signed_test_vault(&victim_pub, &make_recipient(&victim_pub));
1972 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1973
1974 let mut signers = BTreeMap::new();
1976 assert!(sign_vault(&vault, &g, &gr, &pins, &mut signers, &attacker).is_none());
1977
1978 let sk = attacker.signing_key().unwrap();
1980 signers.insert(attacker_pub.clone(), signing::verifying_key_b64(&sk));
1981 let msg = signing_message(&vault, &g, &gr, &pins, &signers);
1982 let forged = types::VaultSignature {
1983 signer: attacker_pub,
1984 v: SIGNED_VIEW_VERSION,
1985 sig: signing::sign(&sk, &msg),
1986 };
1987 assert!(!verify_vault_signature(
1988 &vault, &g, &gr, &pins, &signers, &forged
1989 ));
1990 }
1991
1992 #[test]
1993 fn end_to_end_forged_signature_fails_load() {
1994 let _lock = ENV_LOCK
1999 .lock()
2000 .unwrap_or_else(std::sync::PoisonError::into_inner);
2001 let (secret, pubkey) = generate_keypair();
2002 let recipient = make_recipient(&pubkey);
2003 let identity = make_identity(&secret);
2004
2005 let dir = std::env::temp_dir().join("murk_test_forged_sig_load");
2006 let _ = fs::remove_dir_all(&dir);
2007 fs::create_dir_all(&dir).unwrap();
2008 let path = dir.join("test.murk");
2009
2010 let mut vault = signed_test_vault(&pubkey, &recipient);
2012 let original = types::Murk {
2013 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2014 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2015 ..Default::default()
2016 };
2017 unsafe { std::env::set_var("MURK_KEY", &secret) };
2018 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2019 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2020
2021 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2023 assert!(matches!(
2024 &murk.signature,
2025 types::SignatureState::Signed { signer, .. } if *signer == pubkey
2026 ));
2027
2028 let mut tampered: types::Vault =
2031 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2032 let stale_meta = decrypt_meta(&tampered, &identity).unwrap();
2033 tampered.secrets.get_mut("API_KEY").unwrap().shared =
2034 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2035 let mac_key_hex = generate_mac_key();
2036 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2037 let forged_mac = compute_mac(
2038 &tampered,
2039 &stale_meta.groups,
2040 &stale_meta.grants,
2041 Some(&mac_key),
2042 );
2043 let forged_meta = types::Meta {
2044 mac: forged_mac,
2045 mac_key: Some(mac_key_hex),
2046 sig: stale_meta.sig.clone(), signers: stale_meta.signers.clone(),
2048 ..stale_meta
2049 };
2050 tampered.meta =
2051 encrypt_value(&serde_json::to_vec(&forged_meta).unwrap(), &[recipient]).unwrap();
2052 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2053
2054 let result = load_vault(path.to_str().unwrap());
2055 unsafe { std::env::remove_var("MURK_KEY") };
2056 let err = result.expect_err("forged-MAC + stale-signature vault must fail to load");
2057 assert!(
2058 err.to_string().contains("signature is invalid"),
2059 "expected signature failure, got: {err}"
2060 );
2061
2062 fs::remove_dir_all(&dir).unwrap();
2063 }
2064
2065 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-----";
2067 const SSH_ED25519_PK: &str =
2068 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
2069
2070 #[test]
2071 fn ssh_signed_vault_verifies_without_registry() {
2072 let identity = make_identity(SSH_ED25519_SK);
2076 let recipient = make_recipient(SSH_ED25519_PK);
2077 let vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2078 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2079
2080 let mut signers = BTreeMap::new();
2081 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2082 assert_eq!(sig.signer, SSH_ED25519_PK);
2083 assert!(signers.is_empty(), "ssh signer must not be registered");
2084 assert!(verify_vault_signature(
2085 &vault,
2086 &g,
2087 &gr,
2088 &pins,
2089 &BTreeMap::new(),
2090 &sig
2091 ));
2092 }
2093
2094 #[test]
2095 fn ssh_signed_vault_detects_tampering() {
2096 let identity = make_identity(SSH_ED25519_SK);
2097 let recipient = make_recipient(SSH_ED25519_PK);
2098 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2099 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2100
2101 let mut signers = BTreeMap::new();
2102 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2103 vault.secrets.get_mut("API_KEY").unwrap().shared =
2104 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2105 assert!(!verify_vault_signature(
2106 &vault, &g, &gr, &pins, &signers, &sig
2107 ));
2108 }
2109
2110 #[test]
2111 fn ssh_recipient_stored_with_comment_still_signs_and_verifies() {
2112 let identity = make_identity(SSH_ED25519_SK);
2116 let recipient = make_recipient(SSH_ED25519_PK);
2117 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2118 vault.recipients = vec![format!("{SSH_ED25519_PK} someone@host")];
2119 let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2120
2121 let mut signers = BTreeMap::new();
2122 let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity)
2123 .expect("comment-bearing recipient must still sign");
2124 assert!(verify_vault_signature(
2125 &vault, &g, &gr, &pins, &signers, &sig
2126 ));
2127 }
2128
2129 #[test]
2130 fn ssh_end_to_end_save_and_load_reports_signed() {
2131 let _lock = ENV_LOCK
2132 .lock()
2133 .unwrap_or_else(std::sync::PoisonError::into_inner);
2134 let recipient = make_recipient(SSH_ED25519_PK);
2135
2136 let dir = std::env::temp_dir().join("murk_test_ssh_e2e_sign");
2137 let _ = fs::remove_dir_all(&dir);
2138 fs::create_dir_all(&dir).unwrap();
2139 let path = dir.join("test.murk");
2140
2141 let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2142 let original = types::Murk {
2143 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2144 recipients: HashMap::from([(SSH_ED25519_PK.to_string(), "alice".to_string())]),
2145 ..Default::default()
2146 };
2147 unsafe { std::env::set_var("MURK_KEY", SSH_ED25519_SK) };
2148 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2149 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2150
2151 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2152 unsafe { std::env::remove_var("MURK_KEY") };
2153 assert_eq!(
2155 murk.signature,
2156 types::SignatureState::Signed {
2157 signer: SSH_ED25519_PK.to_string(),
2158 anchored: true,
2159 }
2160 );
2161
2162 fs::remove_dir_all(&dir).unwrap();
2163 }
2164
2165 #[test]
2166 fn save_prunes_stale_signer_registry_entries() {
2167 let _lock = ENV_LOCK
2170 .lock()
2171 .unwrap_or_else(std::sync::PoisonError::into_inner);
2172 let (secret, pubkey) = generate_keypair();
2173 let recipient = make_recipient(&pubkey);
2174
2175 let dir = std::env::temp_dir().join("murk_test_prune_signers");
2176 let _ = fs::remove_dir_all(&dir);
2177 fs::create_dir_all(&dir).unwrap();
2178 let path = dir.join("test.murk");
2179
2180 let mut vault = signed_test_vault(&pubkey, &recipient);
2181 let current = types::Murk {
2182 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2183 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2184 signers: BTreeMap::from([(
2186 "age1stalerevokedrecipient".to_string(),
2187 BASE64.encode([9u8; 32]),
2188 )]),
2189 ..Default::default()
2190 };
2191 unsafe { std::env::set_var("MURK_KEY", &secret) };
2192 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2193 save_vault(path.to_str().unwrap(), &mut vault, ¤t, ¤t).unwrap();
2194
2195 let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2196 unsafe { std::env::remove_var("MURK_KEY") };
2197 assert!(
2198 !murk.signers.contains_key("age1stalerevokedrecipient"),
2199 "stale non-recipient signer entry must be pruned"
2200 );
2201 assert!(
2202 murk.signers.contains_key(&pubkey),
2203 "live signer must remain"
2204 );
2205 assert!(matches!(
2206 murk.signature,
2207 types::SignatureState::Signed { signer, .. } if signer == pubkey
2208 ));
2209
2210 fs::remove_dir_all(&dir).unwrap();
2211 }
2212
2213 #[test]
2214 fn registry_vk_swap_rejected_by_pin_on_load() {
2215 let _lock = ENV_LOCK
2220 .lock()
2221 .unwrap_or_else(std::sync::PoisonError::into_inner);
2222 let home = tempfile::tempdir().unwrap();
2224 let prev_home = std::env::var_os("HOME");
2225 unsafe { std::env::set_var("HOME", home.path()) };
2226 unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2227
2228 let (secret, pubkey) = generate_keypair();
2229 let recipient = make_recipient(&pubkey);
2230 let identity = make_identity(&secret);
2231
2232 let dir = std::env::temp_dir().join("murk_test_vk_swap");
2233 let _ = fs::remove_dir_all(&dir);
2234 fs::create_dir_all(&dir).unwrap();
2235 let path = dir.join("test.murk");
2236 let ps = path.to_str().unwrap();
2237
2238 let mut vault = signed_test_vault(&pubkey, &recipient);
2240 let original = types::Murk {
2241 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2242 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2243 ..Default::default()
2244 };
2245 unsafe { std::env::set_var("MURK_KEY", &secret) };
2246 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2247 save_vault(ps, &mut vault, &original, &original).unwrap();
2248 load_vault(ps).unwrap(); let att_sk = signing::signing_key_from_age_bytes(&[42u8; 32]);
2253 let att_vk = signing::verifying_key_b64(&att_sk);
2254 let mut tampered: types::Vault =
2255 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2256 let stale = decrypt_meta(&tampered, &identity).unwrap();
2257 tampered.secrets.get_mut("API_KEY").unwrap().shared =
2258 encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2259 let mut signers = stale.signers.clone();
2260 signers.insert(pubkey.clone(), att_vk);
2261 let msg = signing_message(
2262 &tampered,
2263 &stale.groups,
2264 &stale.grants,
2265 &stale.github_pins,
2266 &signers,
2267 );
2268 let forged_sig = types::VaultSignature {
2269 signer: pubkey.clone(),
2270 v: SIGNED_VIEW_VERSION,
2271 sig: signing::sign(&att_sk, &msg),
2272 };
2273 assert!(verify_vault_signature(
2275 &tampered,
2276 &stale.groups,
2277 &stale.grants,
2278 &stale.github_pins,
2279 &signers,
2280 &forged_sig
2281 ));
2282 let mac_key_hex = generate_mac_key();
2283 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2284 let mac = compute_mac(&tampered, &stale.groups, &stale.grants, Some(&mac_key));
2285 let forged_meta = types::Meta {
2286 mac,
2287 mac_key: Some(mac_key_hex),
2288 sig: Some(forged_sig),
2289 signers,
2290 ..stale
2291 };
2292 tampered.meta = encrypt_value(
2293 &serde_json::to_vec(&forged_meta).unwrap(),
2294 std::slice::from_ref(&recipient),
2295 )
2296 .unwrap();
2297 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2298
2299 let err = load_vault(ps).unwrap_err();
2301 unsafe { std::env::remove_var("MURK_KEY") };
2302 match prev_home {
2303 Some(v) => unsafe { std::env::set_var("HOME", v) },
2304 None => unsafe { std::env::remove_var("HOME") },
2305 }
2306 assert!(
2307 err.to_string().contains("verifying key changed"),
2308 "expected pin failure, got: {err}"
2309 );
2310
2311 fs::remove_dir_all(&dir).unwrap();
2312 }
2313
2314 #[test]
2315 fn age_signature_first_use_then_anchored() {
2316 let _lock = ENV_LOCK
2320 .lock()
2321 .unwrap_or_else(std::sync::PoisonError::into_inner);
2322 let home = tempfile::tempdir().unwrap();
2323 let prev_home = std::env::var_os("HOME");
2324 unsafe { std::env::set_var("HOME", home.path()) };
2325 unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2326
2327 let (secret, pubkey) = generate_keypair();
2328 let recipient = make_recipient(&pubkey);
2329 let dir = std::env::temp_dir().join("murk_test_anchor_transition");
2330 let _ = fs::remove_dir_all(&dir);
2331 fs::create_dir_all(&dir).unwrap();
2332 let path = dir.join("test.murk");
2333 let ps = path.to_str().unwrap();
2334
2335 let mut vault = signed_test_vault(&pubkey, &recipient);
2336 let original = types::Murk {
2337 values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2338 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2339 ..Default::default()
2340 };
2341 unsafe { std::env::set_var("MURK_KEY", &secret) };
2342 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2343 save_vault(ps, &mut vault, &original, &original).unwrap();
2344
2345 let first = load_vault(ps).unwrap().1;
2346 let second = load_vault(ps).unwrap().1;
2347 unsafe { std::env::remove_var("MURK_KEY") };
2348 match prev_home {
2349 Some(v) => unsafe { std::env::set_var("HOME", v) },
2350 None => unsafe { std::env::remove_var("HOME") },
2351 }
2352
2353 assert_eq!(
2354 first.signature,
2355 types::SignatureState::Signed {
2356 signer: pubkey.clone(),
2357 anchored: false,
2358 },
2359 "first load of an age key is trust-on-first-use"
2360 );
2361 assert_eq!(
2362 second.signature,
2363 types::SignatureState::Signed {
2364 signer: pubkey,
2365 anchored: true,
2366 },
2367 "second load is anchored by the pin"
2368 );
2369
2370 fs::remove_dir_all(&dir).unwrap();
2371 }
2372
2373 #[test]
2374 fn save_vault_preserves_unchanged_ciphertext() {
2375 let (secret, pubkey) = generate_keypair();
2376 let recipient = make_recipient(&pubkey);
2377 let identity = make_identity(&secret);
2378
2379 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
2380 fs::create_dir_all(&dir).unwrap();
2381 let path = dir.join("test.murk");
2382
2383 let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
2384 let mut vault = types::Vault {
2385 version: types::VAULT_VERSION.into(),
2386 created: "2026-02-28T00:00:00Z".into(),
2387 vault_name: ".murk".into(),
2388 repo: String::new(),
2389 recipients: vec![pubkey.clone()],
2390 schema: BTreeMap::new(),
2391 policy: None,
2392 secrets: BTreeMap::new(),
2393 meta: String::new(),
2394 };
2395 vault.secrets.insert(
2396 "KEY1".into(),
2397 types::SecretEntry {
2398 shared: shared.clone(),
2399 private: BTreeMap::new(),
2400 grouped: std::collections::BTreeMap::default(),
2401 },
2402 );
2403
2404 let mut recipients_map = HashMap::new();
2405 recipients_map.insert(pubkey.clone(), "alice".into());
2406 let original = types::Murk {
2407 values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
2408 recipients: recipients_map.clone(),
2409 private: HashMap::new(),
2410 legacy_mac: false,
2411 github_pins: HashMap::new(),
2412 ..Default::default()
2413 };
2414
2415 let current = original.clone();
2416 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2417
2418 assert_eq!(vault.secrets["KEY1"].shared, shared);
2419
2420 let mut changed = current.clone();
2421 changed
2422 .values
2423 .insert("KEY1".into(), crate::testutil::secret("modified"));
2424 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
2425
2426 assert_ne!(vault.secrets["KEY1"].shared, shared);
2427
2428 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
2429 assert_eq!(&decrypted[..], b"modified");
2430
2431 fs::remove_dir_all(&dir).unwrap();
2432 }
2433
2434 #[test]
2435 fn save_vault_adds_new_secret() {
2436 let (_, pubkey) = generate_keypair();
2437 let recipient = make_recipient(&pubkey);
2438
2439 let dir = std::env::temp_dir().join("murk_test_save_add");
2440 fs::create_dir_all(&dir).unwrap();
2441 let path = dir.join("test.murk");
2442
2443 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
2444 let mut vault = types::Vault {
2445 version: types::VAULT_VERSION.into(),
2446 created: "2026-02-28T00:00:00Z".into(),
2447 vault_name: ".murk".into(),
2448 repo: String::new(),
2449 recipients: vec![pubkey.clone()],
2450 schema: BTreeMap::new(),
2451 policy: None,
2452 secrets: BTreeMap::new(),
2453 meta: String::new(),
2454 };
2455 vault.secrets.insert(
2456 "KEY1".into(),
2457 types::SecretEntry {
2458 shared,
2459 private: BTreeMap::new(),
2460 grouped: std::collections::BTreeMap::default(),
2461 },
2462 );
2463
2464 let mut recipients_map = HashMap::new();
2465 recipients_map.insert(pubkey.clone(), "alice".into());
2466 let original = types::Murk {
2467 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2468 recipients: recipients_map.clone(),
2469 private: HashMap::new(),
2470 legacy_mac: false,
2471 github_pins: HashMap::new(),
2472 ..Default::default()
2473 };
2474
2475 let mut current = original.clone();
2476 current
2477 .values
2478 .insert("KEY2".into(), crate::testutil::secret("val2"));
2479
2480 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2481
2482 assert!(vault.secrets.contains_key("KEY1"));
2483 assert!(vault.secrets.contains_key("KEY2"));
2484
2485 fs::remove_dir_all(&dir).unwrap();
2486 }
2487
2488 #[test]
2489 fn save_vault_removes_deleted_secret() {
2490 let (_, pubkey) = generate_keypair();
2491 let recipient = make_recipient(&pubkey);
2492
2493 let dir = std::env::temp_dir().join("murk_test_save_remove");
2494 fs::create_dir_all(&dir).unwrap();
2495 let path = dir.join("test.murk");
2496
2497 let mut vault = types::Vault {
2498 version: types::VAULT_VERSION.into(),
2499 created: "2026-02-28T00:00:00Z".into(),
2500 vault_name: ".murk".into(),
2501 repo: String::new(),
2502 recipients: vec![pubkey.clone()],
2503 schema: BTreeMap::new(),
2504 policy: None,
2505 secrets: BTreeMap::new(),
2506 meta: String::new(),
2507 };
2508 vault.secrets.insert(
2509 "KEY1".into(),
2510 types::SecretEntry {
2511 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2512 private: BTreeMap::new(),
2513 grouped: std::collections::BTreeMap::default(),
2514 },
2515 );
2516 vault.secrets.insert(
2517 "KEY2".into(),
2518 types::SecretEntry {
2519 shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
2520 private: BTreeMap::new(),
2521 grouped: std::collections::BTreeMap::default(),
2522 },
2523 );
2524
2525 let mut recipients_map = HashMap::new();
2526 recipients_map.insert(pubkey.clone(), "alice".into());
2527 let original = types::Murk {
2528 values: HashMap::from([
2529 ("KEY1".into(), crate::testutil::secret("val1")),
2530 ("KEY2".into(), crate::testutil::secret("val2")),
2531 ]),
2532 recipients: recipients_map.clone(),
2533 private: HashMap::new(),
2534 legacy_mac: false,
2535 github_pins: HashMap::new(),
2536 ..Default::default()
2537 };
2538
2539 let mut current = original.clone();
2540 current.values.remove("KEY2");
2541
2542 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2543
2544 assert!(vault.secrets.contains_key("KEY1"));
2545 assert!(!vault.secrets.contains_key("KEY2"));
2546
2547 fs::remove_dir_all(&dir).unwrap();
2548 }
2549
2550 #[test]
2551 fn save_vault_reencrypts_all_on_recipient_change() {
2552 let (secret1, pubkey1) = generate_keypair();
2553 let (_, pubkey2) = generate_keypair();
2554 let recipient1 = make_recipient(&pubkey1);
2555
2556 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
2557 fs::create_dir_all(&dir).unwrap();
2558 let path = dir.join("test.murk");
2559
2560 let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
2561 let mut vault = types::Vault {
2562 version: types::VAULT_VERSION.into(),
2563 created: "2026-02-28T00:00:00Z".into(),
2564 vault_name: ".murk".into(),
2565 repo: String::new(),
2566 recipients: vec![pubkey1.clone(), pubkey2.clone()],
2567 schema: BTreeMap::new(),
2568 policy: None,
2569 secrets: BTreeMap::new(),
2570 meta: String::new(),
2571 };
2572 vault.secrets.insert(
2573 "KEY1".into(),
2574 types::SecretEntry {
2575 shared: shared.clone(),
2576 private: BTreeMap::new(),
2577 grouped: std::collections::BTreeMap::default(),
2578 },
2579 );
2580
2581 let mut recipients_map = HashMap::new();
2582 recipients_map.insert(pubkey1.clone(), "alice".into());
2583 let original = types::Murk {
2584 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2585 recipients: recipients_map,
2586 private: HashMap::new(),
2587 legacy_mac: false,
2588 github_pins: HashMap::new(),
2589 ..Default::default()
2590 };
2591
2592 let mut current_recipients = HashMap::new();
2593 current_recipients.insert(pubkey1.clone(), "alice".into());
2594 current_recipients.insert(pubkey2.clone(), "bob".into());
2595 let current = types::Murk {
2596 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2597 recipients: current_recipients,
2598 private: HashMap::new(),
2599 legacy_mac: false,
2600 github_pins: HashMap::new(),
2601 ..Default::default()
2602 };
2603
2604 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2605
2606 assert_ne!(vault.secrets["KEY1"].shared, shared);
2607
2608 let identity1 = make_identity(&secret1);
2609 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
2610 assert_eq!(&decrypted[..], b"val1");
2611
2612 fs::remove_dir_all(&dir).unwrap();
2613 }
2614
2615 #[test]
2616 fn save_vault_scoped_entry_lifecycle() {
2617 let (secret, pubkey) = generate_keypair();
2618 let recipient = make_recipient(&pubkey);
2619 let identity = make_identity(&secret);
2620
2621 let dir = std::env::temp_dir().join("murk_test_save_scoped");
2622 fs::create_dir_all(&dir).unwrap();
2623 let path = dir.join("test.murk");
2624
2625 let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
2626 let mut vault = types::Vault {
2627 version: types::VAULT_VERSION.into(),
2628 created: "2026-02-28T00:00:00Z".into(),
2629 vault_name: ".murk".into(),
2630 repo: String::new(),
2631 recipients: vec![pubkey.clone()],
2632 schema: BTreeMap::new(),
2633 policy: None,
2634 secrets: BTreeMap::new(),
2635 meta: String::new(),
2636 };
2637 vault.secrets.insert(
2638 "KEY1".into(),
2639 types::SecretEntry {
2640 shared,
2641 private: BTreeMap::new(),
2642 grouped: std::collections::BTreeMap::default(),
2643 },
2644 );
2645
2646 let mut recipients_map = HashMap::new();
2647 recipients_map.insert(pubkey.clone(), "alice".into());
2648 let original = types::Murk {
2649 values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
2650 recipients: recipients_map.clone(),
2651 private: HashMap::new(),
2652 legacy_mac: false,
2653 github_pins: HashMap::new(),
2654 ..Default::default()
2655 };
2656
2657 let mut current = original.clone();
2659 let mut key_scoped = HashMap::new();
2660 key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
2661 current.private.insert("KEY1".into(), key_scoped);
2662
2663 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
2664
2665 assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
2666 let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
2667 assert_eq!(&scoped_val[..], b"my_override");
2668
2669 let original_with_scoped = current.clone();
2671 let mut current_no_scoped = original_with_scoped.clone();
2672 current_no_scoped.private.remove("KEY1");
2673
2674 save_vault(
2675 path.to_str().unwrap(),
2676 &mut vault,
2677 &original_with_scoped,
2678 ¤t_no_scoped,
2679 )
2680 .unwrap();
2681
2682 assert!(vault.secrets["KEY1"].private.is_empty());
2683
2684 fs::remove_dir_all(&dir).unwrap();
2685 }
2686
2687 #[test]
2688 fn load_vault_validates_mac() {
2689 let _lock = ENV_LOCK
2690 .lock()
2691 .unwrap_or_else(std::sync::PoisonError::into_inner);
2692
2693 let (secret, pubkey) = generate_keypair();
2694 let recipient = make_recipient(&pubkey);
2695 let _identity = make_identity(&secret);
2696
2697 let dir = std::env::temp_dir().join("murk_test_load_mac");
2698 let _ = fs::remove_dir_all(&dir);
2699 fs::create_dir_all(&dir).unwrap();
2700 let path = dir.join("test.murk");
2701
2702 let mut vault = types::Vault {
2704 version: types::VAULT_VERSION.into(),
2705 created: "2026-02-28T00:00:00Z".into(),
2706 vault_name: ".murk".into(),
2707 repo: String::new(),
2708 recipients: vec![pubkey.clone()],
2709 schema: BTreeMap::new(),
2710 policy: None,
2711 secrets: BTreeMap::new(),
2712 meta: String::new(),
2713 };
2714 vault.secrets.insert(
2715 "KEY1".into(),
2716 types::SecretEntry {
2717 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2718 private: BTreeMap::new(),
2719 grouped: std::collections::BTreeMap::default(),
2720 },
2721 );
2722
2723 let mut recipients_map = HashMap::new();
2724 recipients_map.insert(pubkey.clone(), "alice".into());
2725 let original = types::Murk {
2726 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2727 recipients: recipients_map,
2728 private: HashMap::new(),
2729 legacy_mac: false,
2730 github_pins: HashMap::new(),
2731 ..Default::default()
2732 };
2733
2734 unsafe { std::env::set_var("MURK_KEY", &secret) };
2736 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2737 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2738
2739 let mut tampered: types::Vault =
2741 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2742 tampered.secrets.get_mut("KEY1").unwrap().shared =
2743 encrypt_value(b"tampered", &[recipient]).unwrap();
2744 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2745
2746 let result = load_vault(path.to_str().unwrap());
2748 unsafe { std::env::remove_var("MURK_KEY") };
2749
2750 let err = result.expect_err("expected MAC validation to fail");
2751 assert!(
2752 err.to_string().contains("integrity check failed"),
2753 "expected integrity check failure, got: {err}"
2754 );
2755
2756 fs::remove_dir_all(&dir).unwrap();
2757 }
2758
2759 #[test]
2760 fn load_vault_succeeds_with_valid_mac() {
2761 let _lock = ENV_LOCK
2762 .lock()
2763 .unwrap_or_else(std::sync::PoisonError::into_inner);
2764
2765 let (secret, pubkey) = generate_keypair();
2766 let recipient = make_recipient(&pubkey);
2767
2768 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
2769 let _ = fs::remove_dir_all(&dir);
2770 fs::create_dir_all(&dir).unwrap();
2771 let path = dir.join("test.murk");
2772
2773 let mut vault = types::Vault {
2774 version: types::VAULT_VERSION.into(),
2775 created: "2026-02-28T00:00:00Z".into(),
2776 vault_name: ".murk".into(),
2777 repo: String::new(),
2778 recipients: vec![pubkey.clone()],
2779 schema: BTreeMap::new(),
2780 policy: None,
2781 secrets: BTreeMap::new(),
2782 meta: String::new(),
2783 };
2784 vault.secrets.insert(
2785 "KEY1".into(),
2786 types::SecretEntry {
2787 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2788 private: BTreeMap::new(),
2789 grouped: std::collections::BTreeMap::default(),
2790 },
2791 );
2792
2793 let mut recipients_map = HashMap::new();
2794 recipients_map.insert(pubkey.clone(), "alice".into());
2795 let original = types::Murk {
2796 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2797 recipients: recipients_map,
2798 private: HashMap::new(),
2799 legacy_mac: false,
2800 github_pins: HashMap::new(),
2801 ..Default::default()
2802 };
2803
2804 unsafe { std::env::set_var("MURK_KEY", &secret) };
2805 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2806 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2807
2808 let result = load_vault(path.to_str().unwrap());
2810 unsafe { std::env::remove_var("MURK_KEY") };
2811
2812 assert!(result.is_ok());
2813 let (_, murk, _) = result.unwrap();
2814 assert_eq!(murk.values["KEY1"].as_str(), "val1");
2815
2816 fs::remove_dir_all(&dir).unwrap();
2817 }
2818
2819 #[test]
2820 fn load_vault_detects_signature_downgrade() {
2821 let _lock = ENV_LOCK
2822 .lock()
2823 .unwrap_or_else(std::sync::PoisonError::into_inner);
2824
2825 let (secret, pubkey) = generate_keypair();
2826 let recipient = make_recipient(&pubkey);
2827 let identity = make_identity(&secret);
2828
2829 let dir = std::env::temp_dir().join("murk_test_sig_downgrade");
2830 let _ = fs::remove_dir_all(&dir);
2831 fs::create_dir_all(&dir).unwrap();
2832 let path = dir.join("test.murk");
2833 let path_str = path.to_str().unwrap();
2834
2835 let prev_home = std::env::var_os("HOME");
2837 let home = dir.join("home");
2838 fs::create_dir_all(&home).unwrap();
2839 unsafe {
2840 std::env::set_var("HOME", &home);
2841 std::env::remove_var("MURK_NO_SIGNER_PIN");
2842 std::env::remove_var("MURK_AGENT");
2843 std::env::set_var("MURK_KEY", &secret);
2844 std::env::remove_var("MURK_KEY_FILE");
2845 }
2846
2847 let mut vault = types::Vault {
2849 version: types::VAULT_VERSION.into(),
2850 created: "2026-02-28T00:00:00Z".into(),
2851 vault_name: ".murk".into(),
2852 repo: String::new(),
2853 recipients: vec![pubkey.clone()],
2854 schema: BTreeMap::new(),
2855 policy: None,
2856 secrets: BTreeMap::new(),
2857 meta: String::new(),
2858 };
2859 vault.secrets.insert(
2860 "KEY1".into(),
2861 types::SecretEntry {
2862 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2863 private: BTreeMap::new(),
2864 grouped: std::collections::BTreeMap::default(),
2865 },
2866 );
2867 let original = types::Murk {
2868 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2869 recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2870 ..Default::default()
2871 };
2872 save_vault(path_str, &mut vault, &original, &original).unwrap();
2873
2874 let (_, murk, _) = load_vault(path_str).unwrap();
2876 assert!(matches!(
2877 murk.signature,
2878 types::SignatureState::Signed { .. }
2879 ));
2880 assert!(!murk.signature_downgraded);
2881
2882 let mut on_disk = read_vault(path_str).unwrap();
2885 let mut meta = decrypt_meta(&on_disk, &identity).unwrap();
2886 meta.sig = None;
2887 let meta_json = serde_json::to_vec(&meta).unwrap();
2888 on_disk.meta = encrypt_value(&meta_json, std::slice::from_ref(&recipient)).unwrap();
2889 vault::write(std::path::Path::new(path_str), &on_disk).unwrap();
2890
2891 let (_, murk2, _) = load_vault(path_str).unwrap();
2893 assert!(matches!(murk2.signature, types::SignatureState::Unsigned));
2894 assert!(
2895 murk2.signature_downgraded,
2896 "a previously-signed vault loading unsigned must flag a downgrade"
2897 );
2898
2899 unsafe { std::env::set_var("MURK_STRICT", "1") };
2901 let strict = load_vault(path_str);
2902 unsafe { std::env::remove_var("MURK_STRICT") };
2903 assert!(
2904 strict.is_err(),
2905 "MURK_STRICT must refuse a signed->unsigned downgrade"
2906 );
2907
2908 unsafe {
2909 std::env::remove_var("MURK_KEY");
2910 match prev_home {
2911 Some(v) => std::env::set_var("HOME", v),
2912 None => std::env::remove_var("HOME"),
2913 }
2914 }
2915 fs::remove_dir_all(&dir).unwrap();
2916 }
2917
2918 #[test]
2919 fn load_vault_not_a_recipient() {
2920 let _lock = ENV_LOCK
2921 .lock()
2922 .unwrap_or_else(std::sync::PoisonError::into_inner);
2923
2924 let (secret, _pubkey) = generate_keypair();
2925 let (other_secret, other_pubkey) = generate_keypair();
2926 let other_recipient = make_recipient(&other_pubkey);
2927
2928 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
2929 let _ = fs::remove_dir_all(&dir);
2930 fs::create_dir_all(&dir).unwrap();
2931 let path = dir.join("test.murk");
2932
2933 let mut vault = types::Vault {
2935 version: types::VAULT_VERSION.into(),
2936 created: "2026-02-28T00:00:00Z".into(),
2937 vault_name: ".murk".into(),
2938 repo: String::new(),
2939 recipients: vec![other_pubkey.clone()],
2940 schema: BTreeMap::new(),
2941 policy: None,
2942 secrets: BTreeMap::new(),
2943 meta: String::new(),
2944 };
2945 vault.secrets.insert(
2946 "KEY1".into(),
2947 types::SecretEntry {
2948 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
2949 private: BTreeMap::new(),
2950 grouped: std::collections::BTreeMap::default(),
2951 },
2952 );
2953
2954 let mut recipients_map = HashMap::new();
2956 recipients_map.insert(other_pubkey.clone(), "other".into());
2957 let original = types::Murk {
2958 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2959 recipients: recipients_map,
2960 private: HashMap::new(),
2961 legacy_mac: false,
2962 github_pins: HashMap::new(),
2963 ..Default::default()
2964 };
2965
2966 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
2967 unsafe { std::env::remove_var("MURK_KEY_FILE") };
2968 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2969
2970 unsafe { std::env::set_var("MURK_KEY", secret) };
2972 let result = load_vault(path.to_str().unwrap());
2973 unsafe { std::env::remove_var("MURK_KEY") };
2974
2975 let Err(err) = result else {
2976 panic!("expected load_vault to fail for non-recipient");
2977 };
2978 let msg = err.to_string();
2981 assert!(
2982 msg.contains("not a recipient"),
2983 "expected not-a-recipient error, got: {err}"
2984 );
2985 assert!(
2986 !msg.contains("tampered"),
2987 "unauthorized key must not look like tampering, got: {err}"
2988 );
2989
2990 fs::remove_dir_all(&dir).unwrap();
2991 }
2992
2993 #[test]
2994 fn load_vault_zero_secrets() {
2995 let _lock = ENV_LOCK
2996 .lock()
2997 .unwrap_or_else(std::sync::PoisonError::into_inner);
2998
2999 let (secret, pubkey) = generate_keypair();
3000
3001 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
3002 let _ = fs::remove_dir_all(&dir);
3003 fs::create_dir_all(&dir).unwrap();
3004 let path = dir.join("test.murk");
3005
3006 let mut vault = types::Vault {
3008 version: types::VAULT_VERSION.into(),
3009 created: "2026-02-28T00:00:00Z".into(),
3010 vault_name: ".murk".into(),
3011 repo: String::new(),
3012 recipients: vec![pubkey.clone()],
3013 schema: BTreeMap::new(),
3014 policy: None,
3015 secrets: BTreeMap::new(),
3016 meta: String::new(),
3017 };
3018
3019 let mut recipients_map = HashMap::new();
3020 recipients_map.insert(pubkey.clone(), "alice".into());
3021 let original = types::Murk {
3022 values: HashMap::new(),
3023 recipients: recipients_map,
3024 private: HashMap::new(),
3025 legacy_mac: false,
3026 github_pins: HashMap::new(),
3027 ..Default::default()
3028 };
3029
3030 unsafe { std::env::set_var("MURK_KEY", &secret) };
3031 unsafe { std::env::remove_var("MURK_KEY_FILE") };
3032 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
3033
3034 let result = load_vault(path.to_str().unwrap());
3035 unsafe { std::env::remove_var("MURK_KEY") };
3036
3037 assert!(result.is_ok());
3038 let (_, murk, _) = result.unwrap();
3039 assert!(murk.values.is_empty());
3040 assert!(murk.private.is_empty());
3041
3042 fs::remove_dir_all(&dir).unwrap();
3043 }
3044
3045 #[test]
3046 fn load_vault_stripped_meta_with_secrets_fails() {
3047 let _lock = ENV_LOCK
3048 .lock()
3049 .unwrap_or_else(std::sync::PoisonError::into_inner);
3050
3051 let (secret, pubkey) = generate_keypair();
3052 let recipient = make_recipient(&pubkey);
3053
3054 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
3055 let _ = fs::remove_dir_all(&dir);
3056 fs::create_dir_all(&dir).unwrap();
3057 let path = dir.join("test.murk");
3058
3059 let mut vault = types::Vault {
3061 version: types::VAULT_VERSION.into(),
3062 created: "2026-02-28T00:00:00Z".into(),
3063 vault_name: ".murk".into(),
3064 repo: String::new(),
3065 recipients: vec![pubkey.clone()],
3066 schema: BTreeMap::new(),
3067 policy: None,
3068 secrets: BTreeMap::new(),
3069 meta: String::new(),
3070 };
3071 vault.secrets.insert(
3072 "KEY1".into(),
3073 types::SecretEntry {
3074 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
3075 private: BTreeMap::new(),
3076 grouped: std::collections::BTreeMap::default(),
3077 },
3078 );
3079
3080 let mut recipients_map = HashMap::new();
3081 recipients_map.insert(pubkey.clone(), "alice".into());
3082 let original = types::Murk {
3083 values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
3084 recipients: recipients_map,
3085 private: HashMap::new(),
3086 legacy_mac: false,
3087 github_pins: HashMap::new(),
3088 ..Default::default()
3089 };
3090
3091 unsafe { std::env::set_var("MURK_KEY", &secret) };
3092 unsafe { std::env::remove_var("MURK_KEY_FILE") };
3093 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
3094
3095 let mut tampered: types::Vault =
3097 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3098 tampered.meta = String::new();
3099 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
3100
3101 let result = load_vault(path.to_str().unwrap());
3103
3104 let err = result.expect_err("expected MAC validation to fail");
3105 assert!(
3106 err.to_string().contains("integrity check failed"),
3107 "expected integrity check failure, got: {err}"
3108 );
3109
3110 let mut garbled: types::Vault =
3114 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3115 garbled.meta = "not-base64!!".into();
3116 fs::write(&path, serde_json::to_string_pretty(&garbled).unwrap()).unwrap();
3117
3118 let result = load_vault(path.to_str().unwrap());
3119
3120 let err = result.expect_err("expected corrupt meta to fail");
3121 assert!(
3122 err.to_string().contains("integrity check failed"),
3123 "expected integrity check failure, got: {err}"
3124 );
3125
3126 let mut flipped: types::Vault =
3130 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3131 flipped.meta = BASE64.encode(b"flipped ciphertext bytes");
3132 fs::write(&path, serde_json::to_string_pretty(&flipped).unwrap()).unwrap();
3133
3134 let result = load_vault(path.to_str().unwrap());
3135 unsafe { std::env::remove_var("MURK_KEY") };
3136
3137 let err = result.expect_err("expected flipped meta to fail");
3138 assert!(
3139 err.to_string().contains("integrity check failed"),
3140 "expected integrity check failure, got: {err}"
3141 );
3142
3143 fs::remove_dir_all(&dir).unwrap();
3144 }
3145
3146 #[test]
3147 fn load_vault_empty_mac_with_secrets_fails() {
3148 let _lock = ENV_LOCK
3149 .lock()
3150 .unwrap_or_else(std::sync::PoisonError::into_inner);
3151
3152 let (secret, pubkey) = generate_keypair();
3153 let recipient = make_recipient(&pubkey);
3154
3155 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
3156 let _ = fs::remove_dir_all(&dir);
3157 fs::create_dir_all(&dir).unwrap();
3158 let path = dir.join("test.murk");
3159
3160 let mut vault = types::Vault {
3162 version: types::VAULT_VERSION.into(),
3163 created: "2026-02-28T00:00:00Z".into(),
3164 vault_name: ".murk".into(),
3165 repo: String::new(),
3166 recipients: vec![pubkey.clone()],
3167 schema: BTreeMap::new(),
3168 policy: None,
3169 secrets: BTreeMap::new(),
3170 meta: String::new(),
3171 };
3172 vault.secrets.insert(
3173 "KEY1".into(),
3174 types::SecretEntry {
3175 shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
3176 private: BTreeMap::new(),
3177 grouped: std::collections::BTreeMap::default(),
3178 },
3179 );
3180
3181 let mut recipients_map = HashMap::new();
3183 recipients_map.insert(pubkey.clone(), "alice".into());
3184 let meta = types::Meta {
3185 recipients: recipients_map,
3186 mac: String::new(),
3187 mac_key: None,
3188 github_pins: HashMap::new(),
3189 ..Default::default()
3190 };
3191 let meta_json = serde_json::to_vec(&meta).unwrap();
3192 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
3193
3194 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
3196
3197 unsafe { std::env::set_var("MURK_KEY", &secret) };
3199 unsafe { std::env::remove_var("MURK_KEY_FILE") };
3200 let result = load_vault(path.to_str().unwrap());
3201 unsafe { std::env::remove_var("MURK_KEY") };
3202
3203 let err = result.expect_err("expected MAC validation to fail");
3204 assert!(
3205 err.to_string().contains("integrity check failed"),
3206 "expected integrity check failure, got: {err}"
3207 );
3208
3209 fs::remove_dir_all(&dir).unwrap();
3210 }
3211
3212 #[test]
3213 fn compute_mac_changes_with_scoped_entries() {
3214 let mut vault = types::Vault {
3215 version: types::VAULT_VERSION.into(),
3216 created: "2026-02-28T00:00:00Z".into(),
3217 vault_name: ".murk".into(),
3218 repo: String::new(),
3219 recipients: vec!["age1abc".into()],
3220 schema: BTreeMap::new(),
3221 policy: None,
3222 secrets: BTreeMap::new(),
3223 meta: String::new(),
3224 };
3225
3226 vault.secrets.insert(
3227 "KEY".into(),
3228 types::SecretEntry {
3229 shared: "ciphertext".into(),
3230 private: BTreeMap::new(),
3231 grouped: std::collections::BTreeMap::default(),
3232 },
3233 );
3234
3235 let key = [0u8; 32];
3236 let mac_no_scoped = compute_mac(
3237 &vault,
3238 &std::collections::BTreeMap::new(),
3239 &std::collections::BTreeMap::new(),
3240 Some(&key),
3241 );
3242
3243 vault
3244 .secrets
3245 .get_mut("KEY")
3246 .unwrap()
3247 .private
3248 .insert("age1bob".into(), "scoped-ct".into());
3249
3250 let mac_with_scoped = compute_mac(
3251 &vault,
3252 &std::collections::BTreeMap::new(),
3253 &std::collections::BTreeMap::new(),
3254 Some(&key),
3255 );
3256 assert_ne!(mac_no_scoped, mac_with_scoped);
3257 }
3258
3259 #[test]
3260 #[allow(clippy::too_many_lines)] fn verify_mac_accepts_v1_prefix() {
3262 let vault = types::Vault {
3263 version: types::VAULT_VERSION.into(),
3264 created: "2026-02-28T00:00:00Z".into(),
3265 vault_name: ".murk".into(),
3266 repo: String::new(),
3267 recipients: vec!["age1abc".into()],
3268 schema: BTreeMap::new(),
3269 policy: None,
3270 secrets: BTreeMap::new(),
3271 meta: String::new(),
3272 };
3273
3274 let key = [0u8; 32];
3275 let v1_mac = compute_mac_v1(&vault);
3276 let v2_mac = compute_mac_v2(&vault);
3277 let v3_mac = compute_mac_v3(&vault, &key);
3278 assert!(verify_mac(
3279 &vault,
3280 &std::collections::BTreeMap::new(),
3281 &std::collections::BTreeMap::new(),
3282 &v1_mac,
3283 None
3284 ));
3285 assert!(verify_mac(
3286 &vault,
3287 &std::collections::BTreeMap::new(),
3288 &std::collections::BTreeMap::new(),
3289 &v2_mac,
3290 None
3291 ));
3292 assert!(verify_mac(
3293 &vault,
3294 &std::collections::BTreeMap::new(),
3295 &std::collections::BTreeMap::new(),
3296 &v3_mac,
3297 Some(&key)
3298 ));
3299 assert!(!verify_mac(
3300 &vault,
3301 &std::collections::BTreeMap::new(),
3302 &std::collections::BTreeMap::new(),
3303 "sha256:bogus",
3304 None
3305 ));
3306 assert!(!verify_mac(
3307 &vault,
3308 &std::collections::BTreeMap::new(),
3309 &std::collections::BTreeMap::new(),
3310 "blake3:bogus",
3311 Some(&key)
3312 ));
3313 assert!(!verify_mac(
3314 &vault,
3315 &std::collections::BTreeMap::new(),
3316 &std::collections::BTreeMap::new(),
3317 "blake3v2:bogus",
3318 Some(&key)
3319 ));
3320 assert!(!verify_mac(
3321 &vault,
3322 &std::collections::BTreeMap::new(),
3323 &std::collections::BTreeMap::new(),
3324 "blake3v3:bogus",
3325 Some(&key)
3326 ));
3327 assert!(!verify_mac(
3328 &vault,
3329 &std::collections::BTreeMap::new(),
3330 &std::collections::BTreeMap::new(),
3331 "unknown:prefix",
3332 None
3333 ));
3334
3335 let v4_mac = compute_mac_v4(&vault, &key);
3337 assert!(v4_mac.starts_with("blake3v2:"));
3338 assert!(verify_mac(
3339 &vault,
3340 &std::collections::BTreeMap::new(),
3341 &std::collections::BTreeMap::new(),
3342 &v4_mac,
3343 Some(&key)
3344 ));
3345
3346 let v5_mac = compute_mac_v5(&vault, &key);
3348 assert!(v5_mac.starts_with("blake3v3:"));
3349 assert!(verify_mac(
3350 &vault,
3351 &std::collections::BTreeMap::new(),
3352 &std::collections::BTreeMap::new(),
3353 &v5_mac,
3354 Some(&key)
3355 ));
3356 assert!(
3358 compute_mac(
3359 &vault,
3360 &std::collections::BTreeMap::new(),
3361 &std::collections::BTreeMap::new(),
3362 Some(&key)
3363 )
3364 .starts_with("blake3v3:")
3365 );
3366
3367 let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
3369 let v6_mac = compute_mac(
3370 &vault,
3371 &groups,
3372 &std::collections::BTreeMap::new(),
3373 Some(&key),
3374 );
3375 assert!(v6_mac.starts_with("blake3v4:"));
3376 assert!(verify_mac(
3377 &vault,
3378 &groups,
3379 &std::collections::BTreeMap::new(),
3380 &v6_mac,
3381 Some(&key)
3382 ));
3383 let tampered = BTreeMap::from([(
3385 "prod".to_string(),
3386 vec!["age1abc".to_string(), "age1evil".to_string()],
3387 )]);
3388 assert!(!verify_mac(
3389 &vault,
3390 &tampered,
3391 &std::collections::BTreeMap::new(),
3392 &v6_mac,
3393 Some(&key)
3394 ));
3395 }
3396
3397 #[test]
3398 fn verify_mac_rejects_grouped_under_legacy_prefix() {
3399 let mut vault = types::Vault {
3403 version: types::VAULT_VERSION.into(),
3404 created: "2026-02-28T00:00:00Z".into(),
3405 vault_name: ".murk".into(),
3406 repo: String::new(),
3407 recipients: vec!["age1abc".into()],
3408 schema: BTreeMap::new(),
3409 policy: None,
3410 secrets: BTreeMap::new(),
3411 meta: String::new(),
3412 };
3413 let key = [7u8; 32];
3414 let no_groups = BTreeMap::new();
3415 let v5_mac = compute_mac(
3416 &vault,
3417 &no_groups,
3418 &std::collections::BTreeMap::new(),
3419 Some(&key),
3420 );
3421 assert!(v5_mac.starts_with("blake3v3:"));
3422 assert!(verify_mac(
3423 &vault,
3424 &no_groups,
3425 &std::collections::BTreeMap::new(),
3426 &v5_mac,
3427 Some(&key)
3428 ));
3429
3430 vault.secrets.insert(
3432 "STOLEN".into(),
3433 types::SecretEntry {
3434 grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
3435 ..Default::default()
3436 },
3437 );
3438 assert!(!verify_mac(
3439 &vault,
3440 &no_groups,
3441 &std::collections::BTreeMap::new(),
3442 &v5_mac,
3443 Some(&key)
3444 ));
3445 }
3446
3447 #[test]
3448 fn mac_v7_covers_grant_metadata() {
3449 let vault = types::Vault {
3450 version: types::VAULT_VERSION.into(),
3451 created: "2026-02-28T00:00:00Z".into(),
3452 vault_name: ".murk".into(),
3453 repo: String::new(),
3454 recipients: vec!["age1abc".into(), "age1agent".into()],
3455 schema: BTreeMap::new(),
3456 policy: None,
3457 secrets: BTreeMap::new(),
3458 meta: String::new(),
3459 };
3460 let key = [9u8; 32];
3461 let no_groups = BTreeMap::new();
3462
3463 let grants = BTreeMap::from([(
3465 "codex".to_string(),
3466 types::GrantEntry {
3467 pubkey: "age1agent".into(),
3468 scope: vec!["STRIPE_KEY".into()],
3469 issued_at: "2026-02-28T00:00:00Z".into(),
3470 expires_at: "2026-02-28T02:00:00Z".into(),
3471 issuer: "age1abc".into(),
3472 },
3473 )]);
3474 let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
3475 assert!(v7_mac.starts_with("blake3v5:"));
3476 assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
3477
3478 let tampered = BTreeMap::from([(
3480 "codex".to_string(),
3481 types::GrantEntry {
3482 pubkey: "age1agent".into(),
3483 scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
3484 issued_at: "2026-02-28T00:00:00Z".into(),
3485 expires_at: "2026-02-28T02:00:00Z".into(),
3486 issuer: "age1abc".into(),
3487 },
3488 )]);
3489 assert!(!verify_mac(
3490 &vault,
3491 &no_groups,
3492 &tampered,
3493 &v7_mac,
3494 Some(&key)
3495 ));
3496 }
3497
3498 #[test]
3499 fn verify_mac_rejects_grants_under_legacy_prefix() {
3500 let vault = types::Vault {
3504 version: types::VAULT_VERSION.into(),
3505 created: "2026-02-28T00:00:00Z".into(),
3506 vault_name: ".murk".into(),
3507 repo: String::new(),
3508 recipients: vec!["age1abc".into()],
3509 schema: BTreeMap::new(),
3510 policy: None,
3511 secrets: BTreeMap::new(),
3512 meta: String::new(),
3513 };
3514 let key = [3u8; 32];
3515 let no_groups = BTreeMap::new();
3516 let grants = BTreeMap::from([(
3517 "codex".to_string(),
3518 types::GrantEntry {
3519 pubkey: "age1agent".into(),
3520 scope: vec!["STRIPE_KEY".into()],
3521 issued_at: "2026-02-28T00:00:00Z".into(),
3522 expires_at: "2026-02-28T02:00:00Z".into(),
3523 issuer: "age1abc".into(),
3524 },
3525 )]);
3526 let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
3528 assert!(v6_mac.starts_with("blake3v4:"));
3529 assert!(!verify_mac(
3530 &vault,
3531 &no_groups,
3532 &grants,
3533 &v6_mac,
3534 Some(&key)
3535 ));
3536 }
3537
3538 #[test]
3539 fn mac_v8_covers_policy() {
3540 let mut vault = types::Vault {
3541 version: types::VAULT_VERSION.into(),
3542 created: "2026-02-28T00:00:00Z".into(),
3543 vault_name: ".murk".into(),
3544 repo: String::new(),
3545 recipients: vec!["age1abc".into()],
3546 schema: BTreeMap::new(),
3547 policy: Some(types::Policy {
3548 agent_allow_tags: vec!["agents".into()],
3549 }),
3550 secrets: BTreeMap::new(),
3551 meta: String::new(),
3552 };
3553 let key = [11u8; 32];
3554 let no_groups = BTreeMap::new();
3555 let no_grants = BTreeMap::new();
3556
3557 let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
3559 assert!(v8_mac.starts_with("blake3v6:"));
3560 assert!(verify_mac(
3561 &vault,
3562 &no_groups,
3563 &no_grants,
3564 &v8_mac,
3565 Some(&key)
3566 ));
3567
3568 vault.policy = Some(types::Policy {
3570 agent_allow_tags: vec!["agents".into(), "production".into()],
3571 });
3572 assert!(!verify_mac(
3573 &vault,
3574 &no_groups,
3575 &no_grants,
3576 &v8_mac,
3577 Some(&key)
3578 ));
3579 }
3580
3581 #[test]
3582 fn mac_v8_policy_tags_are_unambiguous() {
3583 let base = types::Vault {
3586 version: types::VAULT_VERSION.into(),
3587 created: "2026-02-28T00:00:00Z".into(),
3588 vault_name: ".murk".into(),
3589 repo: String::new(),
3590 recipients: vec!["age1abc".into()],
3591 schema: BTreeMap::new(),
3592 policy: None,
3593 secrets: BTreeMap::new(),
3594 meta: String::new(),
3595 };
3596 let key = [7u8; 32];
3597 let groups = BTreeMap::new();
3598 let grants = BTreeMap::new();
3599
3600 let mut a = base.clone();
3601 a.policy = Some(types::Policy {
3602 agent_allow_tags: vec!["a\tb".into()],
3603 });
3604 let mut b = base.clone();
3605 b.policy = Some(types::Policy {
3606 agent_allow_tags: vec!["a".into(), "b".into()],
3607 });
3608
3609 let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
3610 let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
3611 assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
3612 }
3613
3614 #[test]
3615 fn verify_mac_rejects_policy_under_legacy_prefix() {
3616 let vault = types::Vault {
3620 version: types::VAULT_VERSION.into(),
3621 created: "2026-02-28T00:00:00Z".into(),
3622 vault_name: ".murk".into(),
3623 repo: String::new(),
3624 recipients: vec!["age1abc".into()],
3625 schema: BTreeMap::new(),
3626 policy: Some(types::Policy {
3627 agent_allow_tags: vec!["agents".into()],
3628 }),
3629 secrets: BTreeMap::new(),
3630 meta: String::new(),
3631 };
3632 let key = [5u8; 32];
3633 let no_groups = BTreeMap::new();
3634 let no_grants = BTreeMap::new();
3635 let v5_mac = compute_mac_v5(&vault, &key);
3637 assert!(v5_mac.starts_with("blake3v3:"));
3638 assert!(!verify_mac(
3639 &vault,
3640 &no_groups,
3641 &no_grants,
3642 &v5_mac,
3643 Some(&key)
3644 ));
3645 }
3646
3647 #[test]
3648 fn compute_mac_v5_covers_rotation_metadata() {
3649 let mut vault = types::Vault {
3650 version: types::VAULT_VERSION.into(),
3651 created: "2026-02-28T00:00:00Z".into(),
3652 vault_name: ".murk".into(),
3653 repo: String::new(),
3654 recipients: vec!["age1abc".into()],
3655 schema: BTreeMap::new(),
3656 policy: None,
3657 secrets: BTreeMap::new(),
3658 meta: String::new(),
3659 };
3660 vault.schema.insert(
3661 "API_KEY".into(),
3662 types::SchemaEntry {
3663 description: "Main API key".into(),
3664 updated: Some("2026-02-28T00:00:00Z".into()),
3665 ..Default::default()
3666 },
3667 );
3668
3669 let key = [0u8; 32];
3670 let baseline = compute_mac(
3671 &vault,
3672 &std::collections::BTreeMap::new(),
3673 &std::collections::BTreeMap::new(),
3674 Some(&key),
3675 );
3676
3677 vault
3679 .schema
3680 .get_mut("API_KEY")
3681 .unwrap()
3682 .rotation_interval_days = Some(90);
3683 let with_interval = compute_mac(
3684 &vault,
3685 &std::collections::BTreeMap::new(),
3686 &std::collections::BTreeMap::new(),
3687 Some(&key),
3688 );
3689 assert_ne!(baseline, with_interval);
3690
3691 vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
3693 let with_expiry = compute_mac(
3694 &vault,
3695 &std::collections::BTreeMap::new(),
3696 &std::collections::BTreeMap::new(),
3697 Some(&key),
3698 );
3699 assert_ne!(with_interval, with_expiry);
3700
3701 let mut cleared = vault.clone();
3704 cleared
3705 .schema
3706 .get_mut("API_KEY")
3707 .unwrap()
3708 .rotation_interval_days = None;
3709 cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
3710 assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
3711 }
3712
3713 #[test]
3714 fn compute_mac_v9_covers_revoked_at() {
3715 let mut vault = types::Vault {
3716 version: types::VAULT_VERSION.into(),
3717 created: "2026-02-28T00:00:00Z".into(),
3718 vault_name: ".murk".into(),
3719 repo: String::new(),
3720 recipients: vec!["age1abc".into()],
3721 schema: BTreeMap::new(),
3722 policy: None,
3723 secrets: BTreeMap::new(),
3724 meta: String::new(),
3725 };
3726 vault.schema.insert(
3727 "API_KEY".into(),
3728 types::SchemaEntry {
3729 description: "Main API key".into(),
3730 updated: Some("2026-02-28T00:00:00Z".into()),
3731 ..Default::default()
3732 },
3733 );
3734 let key = [0u8; 32];
3735 let groups = BTreeMap::new();
3736 let grants = BTreeMap::new();
3737
3738 let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
3740 assert!(baseline.starts_with("blake3v3:"));
3741
3742 vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
3744 let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
3745 assert!(with_marker.starts_with("blake3v7:"));
3746 assert_ne!(baseline, with_marker);
3747
3748 assert!(verify_mac(
3751 &vault,
3752 &groups,
3753 &grants,
3754 &with_marker,
3755 Some(&key)
3756 ));
3757 let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
3758 assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
3759
3760 let mut cleared = vault.clone();
3763 cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
3764 assert_eq!(
3765 compute_mac_v8(&vault, &groups, &grants, &key),
3766 compute_mac_v8(&cleared, &groups, &grants, &key)
3767 );
3768 }
3769
3770 #[test]
3771 fn compute_mac_changes_with_schema() {
3772 let mut vault = types::Vault {
3773 version: types::VAULT_VERSION.into(),
3774 created: "2026-02-28T00:00:00Z".into(),
3775 vault_name: ".murk".into(),
3776 repo: String::new(),
3777 recipients: vec!["age1abc".into()],
3778 schema: BTreeMap::new(),
3779 policy: None,
3780 secrets: BTreeMap::new(),
3781 meta: String::new(),
3782 };
3783
3784 let key = [0u8; 32];
3785 let mac_no_schema = compute_mac(
3786 &vault,
3787 &std::collections::BTreeMap::new(),
3788 &std::collections::BTreeMap::new(),
3789 Some(&key),
3790 );
3791
3792 vault.schema.insert(
3793 "API_KEY".into(),
3794 types::SchemaEntry {
3795 description: "Main API key".into(),
3796 tags: vec!["deploy".into()],
3797 ..Default::default()
3798 },
3799 );
3800
3801 let mac_with_schema = compute_mac(
3802 &vault,
3803 &std::collections::BTreeMap::new(),
3804 &std::collections::BTreeMap::new(),
3805 Some(&key),
3806 );
3807 assert_ne!(mac_no_schema, mac_with_schema);
3808
3809 let mac_before_retag = mac_with_schema;
3811 vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
3812 let mac_after_retag = compute_mac(
3813 &vault,
3814 &std::collections::BTreeMap::new(),
3815 &std::collections::BTreeMap::new(),
3816 Some(&key),
3817 );
3818 assert_ne!(mac_before_retag, mac_after_retag);
3819 }
3820
3821 #[test]
3822 fn mac_key_roundtrip() {
3823 let hex = generate_mac_key();
3824 assert_eq!(hex.len(), 64);
3825 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
3826
3827 let key = decode_mac_key(&hex).expect("valid hex should decode");
3828 let rehex = key.iter().fold(String::new(), |mut s, b| {
3830 use std::fmt::Write;
3831 let _ = write!(s, "{b:02x}");
3832 s
3833 });
3834 assert_eq!(hex, rehex);
3835 }
3836
3837 #[test]
3838 fn decode_mac_key_rejects_bad_input() {
3839 assert!(decode_mac_key("").is_none());
3840 assert!(decode_mac_key("tooshort").is_none());
3841 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()); }
3845
3846 #[test]
3847 fn blake3_mac_different_key_different_mac() {
3848 let vault = types::Vault {
3849 version: types::VAULT_VERSION.into(),
3850 created: "2026-02-28T00:00:00Z".into(),
3851 vault_name: ".murk".into(),
3852 repo: String::new(),
3853 recipients: vec!["age1abc".into()],
3854 schema: BTreeMap::new(),
3855 policy: None,
3856 secrets: BTreeMap::new(),
3857 meta: String::new(),
3858 };
3859
3860 let key1 = [0u8; 32];
3861 let key2 = [1u8; 32];
3862 let mac1 = compute_mac(
3863 &vault,
3864 &std::collections::BTreeMap::new(),
3865 &std::collections::BTreeMap::new(),
3866 Some(&key1),
3867 );
3868 let mac2 = compute_mac(
3869 &vault,
3870 &std::collections::BTreeMap::new(),
3871 &std::collections::BTreeMap::new(),
3872 Some(&key2),
3873 );
3874 assert_ne!(mac1, mac2);
3875 }
3876
3877 #[test]
3878 fn valid_key_names() {
3879 assert!(is_valid_key_name("DATABASE_URL"));
3880 assert!(is_valid_key_name("_PRIVATE"));
3881 assert!(is_valid_key_name("A"));
3882 assert!(is_valid_key_name("key123"));
3883 }
3884
3885 #[test]
3886 fn invalid_key_names() {
3887 assert!(!is_valid_key_name(""));
3888 assert!(!is_valid_key_name("123_START"));
3889 assert!(!is_valid_key_name("KEY-NAME"));
3890 assert!(!is_valid_key_name("KEY NAME"));
3891 assert!(!is_valid_key_name("FOO$(bar)"));
3892 assert!(!is_valid_key_name("KEY=VAL"));
3893 }
3894
3895 #[test]
3896 fn now_utc_format() {
3897 let ts = now_utc();
3898 assert!(ts.ends_with('Z'));
3899 assert_eq!(ts.len(), 20);
3900 assert_eq!(&ts[4..5], "-");
3901 assert_eq!(&ts[7..8], "-");
3902 assert_eq!(&ts[10..11], "T");
3903 }
3904}