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