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