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 codename;
21pub mod crypto;
22pub mod edit;
23pub(crate) mod env;
24pub mod error;
25pub(crate) mod export;
26pub(crate) mod git;
27pub mod github;
28pub(crate) mod info;
29pub(crate) mod init;
30pub(crate) mod merge;
31pub(crate) mod recipients;
32pub mod recovery;
33pub mod scan;
34pub(crate) mod secrets;
35pub mod types;
36pub mod vault;
37
38#[cfg(feature = "python")]
39mod python;
40
41#[cfg(test)]
43pub mod testutil;
44
45pub use env::{
47 EnvrcStatus, dotenv_has_murk_key, key_file_path, parse_env, read_key_from_dotenv, resolve_key,
48 resolve_key_for_vault, warn_env_permissions, write_envrc, write_key_ref_to_dotenv,
49 write_key_to_dotenv, write_key_to_file,
50};
51pub use error::MurkError;
52pub use export::{
53 DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
54 parse_and_decrypt_values, resolve_secrets,
55};
56pub use git::{MergeDriverSetupStep, setup_merge_driver};
57pub use github::{GitHubError, fetch_keys};
58pub use info::{InfoEntry, VaultInfo, format_info_lines, vault_info};
59pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
60pub use merge::{MergeDriverOutput, run_merge_driver};
61pub use recipients::{
62 RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
63 list_recipients, revoke_recipient, truncate_pubkey,
64};
65pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
66
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::Path;
69
70pub fn is_valid_key_name(key: &str) -> bool {
73 !key.is_empty()
74 && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
75 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
76}
77
78use age::secrecy::ExposeSecret;
79use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
80
81pub use crypto::{MurkIdentity, MurkRecipient};
83
84pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
86 if vault.meta.is_empty() {
87 return None;
88 }
89 let plaintext = decrypt_value(&vault.meta, identity).ok()?;
90 serde_json::from_slice(&plaintext).ok()
91}
92
93pub(crate) fn parse_recipients(
95 pubkeys: &[String],
96) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
97 pubkeys
98 .iter()
99 .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
100 .collect()
101}
102
103pub fn encrypt_value(
105 plaintext: &[u8],
106 recipients: &[crypto::MurkRecipient],
107) -> Result<String, MurkError> {
108 let ciphertext = crypto::encrypt(plaintext, recipients)?;
109 Ok(BASE64.encode(&ciphertext))
110}
111
112pub fn decrypt_value(encoded: &str, identity: &crypto::MurkIdentity) -> Result<Vec<u8>, MurkError> {
114 let ciphertext = BASE64.decode(encoded).map_err(|e| {
115 MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
116 })?;
117 Ok(crypto::decrypt(&ciphertext, identity)?)
118}
119
120pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
124 Ok(vault::read(Path::new(vault_path))?)
125}
126
127pub fn decrypt_vault(
133 vault: &types::Vault,
134 identity: &crypto::MurkIdentity,
135) -> Result<types::Murk, MurkError> {
136 let pubkey = identity.pubkey_string()?;
137
138 let (recipients, legacy_mac, github_pins) = match decrypt_meta(vault, identity) {
141 Some(meta) if !meta.mac.is_empty() => {
142 let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
143 if !verify_mac(vault, &meta.mac, mac_key.as_ref()) {
144 let expected = compute_mac(vault, mac_key.as_ref());
145 return Err(MurkError::Integrity(format!(
146 "vault may have been tampered with (expected {expected}, got {})",
147 meta.mac
148 )));
149 }
150 let legacy = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
151 (meta.recipients, legacy, meta.github_pins)
152 }
153 Some(meta) if vault.secrets.is_empty() => (meta.recipients, false, meta.github_pins),
154 Some(_) => {
155 return Err(MurkError::Integrity(
156 "vault has secrets but MAC is empty — vault may have been tampered with".into(),
157 ));
158 }
159 None if vault.secrets.is_empty() && vault.meta.is_empty() => {
160 (HashMap::new(), false, HashMap::new())
161 }
162 None => {
163 return Err(MurkError::Integrity(
164 "vault has secrets but no meta — vault may have been tampered with".into(),
165 ));
166 }
167 };
168
169 let mut values = HashMap::new();
171 for (key, entry) in &vault.secrets {
172 if entry.shared.is_empty() {
173 continue;
174 }
175 let plaintext = decrypt_value(&entry.shared, identity).map_err(|_| {
176 MurkError::Crypto(crypto::CryptoError::Decrypt(
177 "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into()
178 ))
179 })?;
180 let value = String::from_utf8(plaintext)
181 .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
182 values.insert(key.clone(), value);
183 }
184
185 let mut scoped = HashMap::new();
187 for (key, entry) in &vault.secrets {
188 if let Some(encoded) = entry.scoped.get(&pubkey)
189 && let Ok(value) = decrypt_value(encoded, identity)
190 .and_then(|pt| String::from_utf8(pt).map_err(|e| MurkError::Secret(e.to_string())))
191 {
192 scoped
193 .entry(key.clone())
194 .or_insert_with(HashMap::new)
195 .insert(pubkey.clone(), value);
196 }
197 }
198
199 Ok(types::Murk {
200 values,
201 recipients,
202 scoped,
203 legacy_mac,
204 github_pins,
205 })
206}
207
208pub fn load_vault(
212 vault_path: &str,
213) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
214 let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
215
216 let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
217 MurkError::Key(format!(
218 "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
219 ))
220 })?;
221
222 let vault = read_vault(vault_path)?;
223 let murk = decrypt_vault(&vault, &identity)?;
224
225 Ok((vault, murk, identity))
226}
227
228pub fn save_vault(
231 vault_path: &str,
232 vault: &mut types::Vault,
233 original: &types::Murk,
234 current: &types::Murk,
235) -> Result<(), MurkError> {
236 let recipients = parse_recipients(&vault.recipients)?;
237
238 let recipients_changed = {
240 let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
241 let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
242 current_pks.sort_unstable();
243 original_pks.sort_unstable();
244 current_pks != original_pks
245 };
246
247 let mut new_secrets = BTreeMap::new();
248
249 let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
251 all_keys.extend(current.scoped.keys());
252
253 for key in all_keys {
254 let shared = if let Some(value) = current.values.get(key) {
255 if !recipients_changed && original.values.get(key) == Some(value) {
256 if let Some(existing) = vault.secrets.get(key) {
257 existing.shared.clone()
258 } else {
259 encrypt_value(value.as_bytes(), &recipients)?
260 }
261 } else {
262 encrypt_value(value.as_bytes(), &recipients)?
263 }
264 } else {
265 String::new()
267 };
268
269 let mut scoped = vault
270 .secrets
271 .get(key)
272 .map(|e| e.scoped.clone())
273 .unwrap_or_default();
274
275 if let Some(key_scoped) = current.scoped.get(key) {
276 for (pk, val) in key_scoped {
277 let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
278 if original_val == Some(val) {
279 } else {
281 let recipient = crypto::parse_recipient(pk)?;
282 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
283 }
284 }
285 }
286
287 if let Some(orig_key_scoped) = original.scoped.get(key) {
288 for pk in orig_key_scoped.keys() {
289 let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
290 if !still_present {
291 scoped.remove(pk);
292 }
293 }
294 }
295
296 new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
297 }
298
299 vault.secrets = new_secrets;
300
301 let mac_key_hex = generate_mac_key();
303 let mac_key = decode_mac_key(&mac_key_hex).unwrap();
304 let mac = compute_mac(vault, Some(&mac_key));
305 let meta = types::Meta {
306 recipients: current.recipients.clone(),
307 mac,
308 mac_key: Some(mac_key_hex),
309 github_pins: current.github_pins.clone(),
310 };
311 let meta_json =
312 serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
313 vault.meta = encrypt_value(&meta_json, &recipients)?;
314
315 Ok(vault::write(Path::new(vault_path), vault)?)
316}
317
318pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String {
324 match mac_key {
325 Some(key) => compute_mac_v4(vault, key),
326 None => compute_mac_v2(vault),
327 }
328}
329
330fn compute_mac_v1(vault: &types::Vault) -> String {
332 use sha2::{Digest, Sha256};
333
334 let mut hasher = Sha256::new();
335
336 for key in vault.secrets.keys() {
337 hasher.update(key.as_bytes());
338 hasher.update(b"\x00");
339 }
340
341 for entry in vault.secrets.values() {
342 hasher.update(entry.shared.as_bytes());
343 hasher.update(b"\x00");
344 }
345
346 let mut pks = vault.recipients.clone();
347 pks.sort();
348 for pk in &pks {
349 hasher.update(pk.as_bytes());
350 hasher.update(b"\x00");
351 }
352
353 let digest = hasher.finalize();
354 format!(
355 "sha256:{}",
356 digest.iter().fold(String::new(), |mut s, b| {
357 use std::fmt::Write;
358 let _ = write!(s, "{b:02x}");
359 s
360 })
361 )
362}
363
364fn compute_mac_v2(vault: &types::Vault) -> String {
366 use sha2::{Digest, Sha256};
367
368 let mut hasher = Sha256::new();
369
370 for key in vault.secrets.keys() {
372 hasher.update(key.as_bytes());
373 hasher.update(b"\x00");
374 }
375
376 for entry in vault.secrets.values() {
378 hasher.update(entry.shared.as_bytes());
379 hasher.update(b"\x00");
380
381 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
383 scoped_pks.sort();
384 for pk in scoped_pks {
385 hasher.update(pk.as_bytes());
386 hasher.update(b"\x01");
387 hasher.update(entry.scoped[pk].as_bytes());
388 hasher.update(b"\x00");
389 }
390 }
391
392 let mut pks = vault.recipients.clone();
394 pks.sort();
395 for pk in &pks {
396 hasher.update(pk.as_bytes());
397 hasher.update(b"\x00");
398 }
399
400 let digest = hasher.finalize();
401 format!(
402 "sha256v2:{}",
403 digest.iter().fold(String::new(), |mut s, b| {
404 use std::fmt::Write;
405 let _ = write!(s, "{b:02x}");
406 s
407 })
408 )
409}
410
411fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
413 let mut data = Vec::new();
414
415 for key_name in vault.secrets.keys() {
416 data.extend_from_slice(key_name.as_bytes());
417 data.push(0x00);
418 }
419
420 for entry in vault.secrets.values() {
421 data.extend_from_slice(entry.shared.as_bytes());
422 data.push(0x00);
423
424 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
425 scoped_pks.sort();
426 for pk in scoped_pks {
427 data.extend_from_slice(pk.as_bytes());
428 data.push(0x01);
429 data.extend_from_slice(entry.scoped[pk].as_bytes());
430 data.push(0x00);
431 }
432 }
433
434 let mut pks = vault.recipients.clone();
435 pks.sort();
436 for pk in &pks {
437 data.extend_from_slice(pk.as_bytes());
438 data.push(0x00);
439 }
440
441 let hash = blake3::keyed_hash(key, &data);
442 format!("blake3:{hash}")
443}
444
445fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
448 let mut data = Vec::new();
449
450 for key_name in vault.secrets.keys() {
451 data.extend_from_slice(key_name.as_bytes());
452 data.push(0x00);
453 }
454
455 for entry in vault.secrets.values() {
456 data.extend_from_slice(entry.shared.as_bytes());
457 data.push(0x00);
458
459 let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
460 scoped_pks.sort();
461 for pk in scoped_pks {
462 data.extend_from_slice(pk.as_bytes());
463 data.push(0x01);
464 data.extend_from_slice(entry.scoped[pk].as_bytes());
465 data.push(0x00);
466 }
467 }
468
469 let mut pks = vault.recipients.clone();
470 pks.sort();
471 for pk in &pks {
472 data.extend_from_slice(pk.as_bytes());
473 data.push(0x00);
474 }
475
476 for (key_name, entry) in &vault.schema {
479 data.push(0x02);
480 data.extend_from_slice(key_name.as_bytes());
481 data.push(0x00);
482 data.extend_from_slice(entry.description.as_bytes());
483 data.push(0x00);
484 if let Some(example) = &entry.example {
485 data.extend_from_slice(example.as_bytes());
486 }
487 data.push(0x00);
488 for tag in &entry.tags {
489 data.extend_from_slice(tag.as_bytes());
490 data.push(0x00);
491 }
492 }
493
494 let hash = blake3::keyed_hash(key, &data);
495 format!("blake3v2:{hash}")
496}
497
498pub(crate) fn verify_mac(
500 vault: &types::Vault,
501 stored_mac: &str,
502 mac_key: Option<&[u8; 32]>,
503) -> bool {
504 use constant_time_eq::constant_time_eq;
505
506 let expected = if stored_mac.starts_with("blake3v2:") {
507 match mac_key {
508 Some(key) => compute_mac_v4(vault, key),
509 None => return false,
510 }
511 } else if stored_mac.starts_with("blake3:") {
512 match mac_key {
513 Some(key) => compute_mac_v3(vault, key),
514 None => return false,
515 }
516 } else if stored_mac.starts_with("sha256v2:") {
517 compute_mac_v2(vault)
518 } else if stored_mac.starts_with("sha256:") {
519 compute_mac_v1(vault)
520 } else {
521 return false;
522 };
523 constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
524}
525
526pub(crate) fn generate_mac_key() -> String {
528 let key: [u8; 32] = rand::random();
529 key.iter().fold(String::new(), |mut s, b| {
530 use std::fmt::Write;
531 let _ = write!(s, "{b:02x}");
532 s
533 })
534}
535
536pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
538 if hex.len() != 64 {
539 return None;
540 }
541 let mut key = [0u8; 32];
542 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
543 key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
544 }
545 Some(key)
546}
547
548pub(crate) fn now_utc() -> String {
550 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::testutil::*;
557 use std::collections::BTreeMap;
558 use std::fs;
559
560 use crate::testutil::ENV_LOCK;
561
562 #[test]
563 fn encrypt_decrypt_value_roundtrip() {
564 let (secret, pubkey) = generate_keypair();
565 let recipient = make_recipient(&pubkey);
566 let identity = make_identity(&secret);
567
568 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
569 let decrypted = decrypt_value(&encoded, &identity).unwrap();
570 assert_eq!(decrypted, b"hello world");
571 }
572
573 #[test]
574 fn decrypt_value_invalid_base64() {
575 let (secret, _) = generate_keypair();
576 let identity = make_identity(&secret);
577
578 let result = decrypt_value("not!valid!base64!!!", &identity);
579 assert!(result.is_err());
580 assert!(result.unwrap_err().to_string().contains("invalid base64"));
581 }
582
583 #[test]
584 fn encrypt_value_multiple_recipients() {
585 let (secret_a, pubkey_a) = generate_keypair();
586 let (secret_b, pubkey_b) = generate_keypair();
587
588 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
589 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
590
591 let id_a = make_identity(&secret_a);
593 let id_b = make_identity(&secret_b);
594 assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
595 assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
596 }
597
598 #[test]
599 fn decrypt_value_wrong_key_fails() {
600 let (_, pubkey) = generate_keypair();
601 let (wrong_secret, _) = generate_keypair();
602
603 let recipient = make_recipient(&pubkey);
604 let wrong_identity = make_identity(&wrong_secret);
605
606 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
607 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
608 }
609
610 #[test]
611 fn compute_mac_deterministic() {
612 let vault = types::Vault {
613 version: types::VAULT_VERSION.into(),
614 created: "2026-02-28T00:00:00Z".into(),
615 vault_name: ".murk".into(),
616 repo: String::new(),
617 recipients: vec!["age1abc".into()],
618 schema: BTreeMap::new(),
619 secrets: BTreeMap::new(),
620 meta: String::new(),
621 };
622
623 let key = [0u8; 32];
624 let mac1 = compute_mac(&vault, Some(&key));
625 let mac2 = compute_mac(&vault, Some(&key));
626 assert_eq!(mac1, mac2);
627 assert!(mac1.starts_with("blake3v2:"));
628
629 let mac_legacy = compute_mac(&vault, None);
631 assert!(mac_legacy.starts_with("sha256v2:"));
632 }
633
634 #[test]
635 fn compute_mac_changes_with_different_secrets() {
636 let mut vault = types::Vault {
637 version: types::VAULT_VERSION.into(),
638 created: "2026-02-28T00:00:00Z".into(),
639 vault_name: ".murk".into(),
640 repo: String::new(),
641 recipients: vec!["age1abc".into()],
642 schema: BTreeMap::new(),
643 secrets: BTreeMap::new(),
644 meta: String::new(),
645 };
646
647 let key = [0u8; 32];
648 let mac_empty = compute_mac(&vault, Some(&key));
649
650 vault.secrets.insert(
651 "KEY".into(),
652 types::SecretEntry {
653 shared: "ciphertext".into(),
654 scoped: BTreeMap::new(),
655 },
656 );
657
658 let mac_with_secret = compute_mac(&vault, Some(&key));
659 assert_ne!(mac_empty, mac_with_secret);
660 }
661
662 #[test]
663 fn compute_mac_changes_with_different_recipients() {
664 let mut vault = types::Vault {
665 version: types::VAULT_VERSION.into(),
666 created: "2026-02-28T00:00:00Z".into(),
667 vault_name: ".murk".into(),
668 repo: String::new(),
669 recipients: vec!["age1abc".into()],
670 schema: BTreeMap::new(),
671 secrets: BTreeMap::new(),
672 meta: String::new(),
673 };
674
675 let key = [0u8; 32];
676 let mac1 = compute_mac(&vault, Some(&key));
677 vault.recipients.push("age1xyz".into());
678 let mac2 = compute_mac(&vault, Some(&key));
679 assert_ne!(mac1, mac2);
680 }
681
682 #[test]
683 fn save_vault_preserves_unchanged_ciphertext() {
684 let (secret, pubkey) = generate_keypair();
685 let recipient = make_recipient(&pubkey);
686 let identity = make_identity(&secret);
687
688 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
689 fs::create_dir_all(&dir).unwrap();
690 let path = dir.join("test.murk");
691
692 let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
693 let mut vault = types::Vault {
694 version: types::VAULT_VERSION.into(),
695 created: "2026-02-28T00:00:00Z".into(),
696 vault_name: ".murk".into(),
697 repo: String::new(),
698 recipients: vec![pubkey.clone()],
699 schema: BTreeMap::new(),
700 secrets: BTreeMap::new(),
701 meta: String::new(),
702 };
703 vault.secrets.insert(
704 "KEY1".into(),
705 types::SecretEntry {
706 shared: shared.clone(),
707 scoped: BTreeMap::new(),
708 },
709 );
710
711 let mut recipients_map = HashMap::new();
712 recipients_map.insert(pubkey.clone(), "alice".into());
713 let original = types::Murk {
714 values: HashMap::from([("KEY1".into(), "original".into())]),
715 recipients: recipients_map.clone(),
716 scoped: HashMap::new(),
717 legacy_mac: false,
718 github_pins: HashMap::new(),
719 };
720
721 let current = original.clone();
722 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
723
724 assert_eq!(vault.secrets["KEY1"].shared, shared);
725
726 let mut changed = current.clone();
727 changed.values.insert("KEY1".into(), "modified".into());
728 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
729
730 assert_ne!(vault.secrets["KEY1"].shared, shared);
731
732 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
733 assert_eq!(decrypted, b"modified");
734
735 fs::remove_dir_all(&dir).unwrap();
736 }
737
738 #[test]
739 fn save_vault_adds_new_secret() {
740 let (_, pubkey) = generate_keypair();
741 let recipient = make_recipient(&pubkey);
742
743 let dir = std::env::temp_dir().join("murk_test_save_add");
744 fs::create_dir_all(&dir).unwrap();
745 let path = dir.join("test.murk");
746
747 let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
748 let mut vault = types::Vault {
749 version: types::VAULT_VERSION.into(),
750 created: "2026-02-28T00:00:00Z".into(),
751 vault_name: ".murk".into(),
752 repo: String::new(),
753 recipients: vec![pubkey.clone()],
754 schema: BTreeMap::new(),
755 secrets: BTreeMap::new(),
756 meta: String::new(),
757 };
758 vault.secrets.insert(
759 "KEY1".into(),
760 types::SecretEntry {
761 shared,
762 scoped: BTreeMap::new(),
763 },
764 );
765
766 let mut recipients_map = HashMap::new();
767 recipients_map.insert(pubkey.clone(), "alice".into());
768 let original = types::Murk {
769 values: HashMap::from([("KEY1".into(), "val1".into())]),
770 recipients: recipients_map.clone(),
771 scoped: HashMap::new(),
772 legacy_mac: false,
773 github_pins: HashMap::new(),
774 };
775
776 let mut current = original.clone();
777 current.values.insert("KEY2".into(), "val2".into());
778
779 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
780
781 assert!(vault.secrets.contains_key("KEY1"));
782 assert!(vault.secrets.contains_key("KEY2"));
783
784 fs::remove_dir_all(&dir).unwrap();
785 }
786
787 #[test]
788 fn save_vault_removes_deleted_secret() {
789 let (_, pubkey) = generate_keypair();
790 let recipient = make_recipient(&pubkey);
791
792 let dir = std::env::temp_dir().join("murk_test_save_remove");
793 fs::create_dir_all(&dir).unwrap();
794 let path = dir.join("test.murk");
795
796 let mut vault = types::Vault {
797 version: types::VAULT_VERSION.into(),
798 created: "2026-02-28T00:00:00Z".into(),
799 vault_name: ".murk".into(),
800 repo: String::new(),
801 recipients: vec![pubkey.clone()],
802 schema: BTreeMap::new(),
803 secrets: BTreeMap::new(),
804 meta: String::new(),
805 };
806 vault.secrets.insert(
807 "KEY1".into(),
808 types::SecretEntry {
809 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
810 scoped: BTreeMap::new(),
811 },
812 );
813 vault.secrets.insert(
814 "KEY2".into(),
815 types::SecretEntry {
816 shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
817 scoped: BTreeMap::new(),
818 },
819 );
820
821 let mut recipients_map = HashMap::new();
822 recipients_map.insert(pubkey.clone(), "alice".into());
823 let original = types::Murk {
824 values: HashMap::from([
825 ("KEY1".into(), "val1".into()),
826 ("KEY2".into(), "val2".into()),
827 ]),
828 recipients: recipients_map.clone(),
829 scoped: HashMap::new(),
830 legacy_mac: false,
831 github_pins: HashMap::new(),
832 };
833
834 let mut current = original.clone();
835 current.values.remove("KEY2");
836
837 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
838
839 assert!(vault.secrets.contains_key("KEY1"));
840 assert!(!vault.secrets.contains_key("KEY2"));
841
842 fs::remove_dir_all(&dir).unwrap();
843 }
844
845 #[test]
846 fn save_vault_reencrypts_all_on_recipient_change() {
847 let (secret1, pubkey1) = generate_keypair();
848 let (_, pubkey2) = generate_keypair();
849 let recipient1 = make_recipient(&pubkey1);
850
851 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
852 fs::create_dir_all(&dir).unwrap();
853 let path = dir.join("test.murk");
854
855 let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
856 let mut vault = types::Vault {
857 version: types::VAULT_VERSION.into(),
858 created: "2026-02-28T00:00:00Z".into(),
859 vault_name: ".murk".into(),
860 repo: String::new(),
861 recipients: vec![pubkey1.clone(), pubkey2.clone()],
862 schema: BTreeMap::new(),
863 secrets: BTreeMap::new(),
864 meta: String::new(),
865 };
866 vault.secrets.insert(
867 "KEY1".into(),
868 types::SecretEntry {
869 shared: shared.clone(),
870 scoped: BTreeMap::new(),
871 },
872 );
873
874 let mut recipients_map = HashMap::new();
875 recipients_map.insert(pubkey1.clone(), "alice".into());
876 let original = types::Murk {
877 values: HashMap::from([("KEY1".into(), "val1".into())]),
878 recipients: recipients_map,
879 scoped: HashMap::new(),
880 legacy_mac: false,
881 github_pins: HashMap::new(),
882 };
883
884 let mut current_recipients = HashMap::new();
885 current_recipients.insert(pubkey1.clone(), "alice".into());
886 current_recipients.insert(pubkey2.clone(), "bob".into());
887 let current = types::Murk {
888 values: HashMap::from([("KEY1".into(), "val1".into())]),
889 recipients: current_recipients,
890 scoped: HashMap::new(),
891 legacy_mac: false,
892 github_pins: HashMap::new(),
893 };
894
895 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
896
897 assert_ne!(vault.secrets["KEY1"].shared, shared);
898
899 let identity1 = make_identity(&secret1);
900 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
901 assert_eq!(decrypted, b"val1");
902
903 fs::remove_dir_all(&dir).unwrap();
904 }
905
906 #[test]
907 fn save_vault_scoped_entry_lifecycle() {
908 let (secret, pubkey) = generate_keypair();
909 let recipient = make_recipient(&pubkey);
910 let identity = make_identity(&secret);
911
912 let dir = std::env::temp_dir().join("murk_test_save_scoped");
913 fs::create_dir_all(&dir).unwrap();
914 let path = dir.join("test.murk");
915
916 let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
917 let mut vault = types::Vault {
918 version: types::VAULT_VERSION.into(),
919 created: "2026-02-28T00:00:00Z".into(),
920 vault_name: ".murk".into(),
921 repo: String::new(),
922 recipients: vec![pubkey.clone()],
923 schema: BTreeMap::new(),
924 secrets: BTreeMap::new(),
925 meta: String::new(),
926 };
927 vault.secrets.insert(
928 "KEY1".into(),
929 types::SecretEntry {
930 shared,
931 scoped: BTreeMap::new(),
932 },
933 );
934
935 let mut recipients_map = HashMap::new();
936 recipients_map.insert(pubkey.clone(), "alice".into());
937 let original = types::Murk {
938 values: HashMap::from([("KEY1".into(), "shared_val".into())]),
939 recipients: recipients_map.clone(),
940 scoped: HashMap::new(),
941 legacy_mac: false,
942 github_pins: HashMap::new(),
943 };
944
945 let mut current = original.clone();
947 let mut key_scoped = HashMap::new();
948 key_scoped.insert(pubkey.clone(), "my_override".into());
949 current.scoped.insert("KEY1".into(), key_scoped);
950
951 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
952
953 assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
954 let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
955 assert_eq!(scoped_val, b"my_override");
956
957 let original_with_scoped = current.clone();
959 let mut current_no_scoped = original_with_scoped.clone();
960 current_no_scoped.scoped.remove("KEY1");
961
962 save_vault(
963 path.to_str().unwrap(),
964 &mut vault,
965 &original_with_scoped,
966 ¤t_no_scoped,
967 )
968 .unwrap();
969
970 assert!(vault.secrets["KEY1"].scoped.is_empty());
971
972 fs::remove_dir_all(&dir).unwrap();
973 }
974
975 #[test]
976 fn load_vault_validates_mac() {
977 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
978
979 let (secret, pubkey) = generate_keypair();
980 let recipient = make_recipient(&pubkey);
981 let _identity = make_identity(&secret);
982
983 let dir = std::env::temp_dir().join("murk_test_load_mac");
984 let _ = fs::remove_dir_all(&dir);
985 fs::create_dir_all(&dir).unwrap();
986 let path = dir.join("test.murk");
987
988 let mut vault = types::Vault {
990 version: types::VAULT_VERSION.into(),
991 created: "2026-02-28T00:00:00Z".into(),
992 vault_name: ".murk".into(),
993 repo: String::new(),
994 recipients: vec![pubkey.clone()],
995 schema: BTreeMap::new(),
996 secrets: BTreeMap::new(),
997 meta: String::new(),
998 };
999 vault.secrets.insert(
1000 "KEY1".into(),
1001 types::SecretEntry {
1002 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1003 scoped: BTreeMap::new(),
1004 },
1005 );
1006
1007 let mut recipients_map = HashMap::new();
1008 recipients_map.insert(pubkey.clone(), "alice".into());
1009 let original = types::Murk {
1010 values: HashMap::from([("KEY1".into(), "val1".into())]),
1011 recipients: recipients_map,
1012 scoped: HashMap::new(),
1013 legacy_mac: false,
1014 github_pins: HashMap::new(),
1015 };
1016
1017 unsafe { std::env::set_var("MURK_KEY", &secret) };
1019 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1020 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1021
1022 let mut tampered: types::Vault =
1024 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1025 tampered.secrets.get_mut("KEY1").unwrap().shared =
1026 encrypt_value(b"tampered", &[recipient]).unwrap();
1027 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1028
1029 let result = load_vault(path.to_str().unwrap());
1031 unsafe { std::env::remove_var("MURK_KEY") };
1032
1033 let err = result.err().expect("expected MAC validation to fail");
1034 assert!(
1035 err.to_string().contains("integrity check failed"),
1036 "expected integrity check failure, got: {err}"
1037 );
1038
1039 fs::remove_dir_all(&dir).unwrap();
1040 }
1041
1042 #[test]
1043 fn load_vault_succeeds_with_valid_mac() {
1044 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1045
1046 let (secret, pubkey) = generate_keypair();
1047 let recipient = make_recipient(&pubkey);
1048
1049 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
1050 let _ = fs::remove_dir_all(&dir);
1051 fs::create_dir_all(&dir).unwrap();
1052 let path = dir.join("test.murk");
1053
1054 let mut vault = types::Vault {
1055 version: types::VAULT_VERSION.into(),
1056 created: "2026-02-28T00:00:00Z".into(),
1057 vault_name: ".murk".into(),
1058 repo: String::new(),
1059 recipients: vec![pubkey.clone()],
1060 schema: BTreeMap::new(),
1061 secrets: BTreeMap::new(),
1062 meta: String::new(),
1063 };
1064 vault.secrets.insert(
1065 "KEY1".into(),
1066 types::SecretEntry {
1067 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1068 scoped: BTreeMap::new(),
1069 },
1070 );
1071
1072 let mut recipients_map = HashMap::new();
1073 recipients_map.insert(pubkey.clone(), "alice".into());
1074 let original = types::Murk {
1075 values: HashMap::from([("KEY1".into(), "val1".into())]),
1076 recipients: recipients_map,
1077 scoped: HashMap::new(),
1078 legacy_mac: false,
1079 github_pins: HashMap::new(),
1080 };
1081
1082 unsafe { std::env::set_var("MURK_KEY", &secret) };
1083 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1084 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1085
1086 let result = load_vault(path.to_str().unwrap());
1088 unsafe { std::env::remove_var("MURK_KEY") };
1089
1090 assert!(result.is_ok());
1091 let (_, murk, _) = result.unwrap();
1092 assert_eq!(murk.values["KEY1"], "val1");
1093
1094 fs::remove_dir_all(&dir).unwrap();
1095 }
1096
1097 #[test]
1098 fn load_vault_not_a_recipient() {
1099 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1100
1101 let (secret, _pubkey) = generate_keypair();
1102 let (other_secret, other_pubkey) = generate_keypair();
1103 let other_recipient = make_recipient(&other_pubkey);
1104
1105 let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
1106 let _ = fs::remove_dir_all(&dir);
1107 fs::create_dir_all(&dir).unwrap();
1108 let path = dir.join("test.murk");
1109
1110 let mut vault = types::Vault {
1112 version: types::VAULT_VERSION.into(),
1113 created: "2026-02-28T00:00:00Z".into(),
1114 vault_name: ".murk".into(),
1115 repo: String::new(),
1116 recipients: vec![other_pubkey.clone()],
1117 schema: BTreeMap::new(),
1118 secrets: BTreeMap::new(),
1119 meta: String::new(),
1120 };
1121 vault.secrets.insert(
1122 "KEY1".into(),
1123 types::SecretEntry {
1124 shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
1125 scoped: BTreeMap::new(),
1126 },
1127 );
1128
1129 let mut recipients_map = HashMap::new();
1131 recipients_map.insert(other_pubkey.clone(), "other".into());
1132 let original = types::Murk {
1133 values: HashMap::from([("KEY1".into(), "val1".into())]),
1134 recipients: recipients_map,
1135 scoped: HashMap::new(),
1136 legacy_mac: false,
1137 github_pins: HashMap::new(),
1138 };
1139
1140 unsafe { std::env::set_var("MURK_KEY", &other_secret) };
1141 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1142 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1143
1144 unsafe { std::env::set_var("MURK_KEY", secret) };
1146 let result = load_vault(path.to_str().unwrap());
1147 unsafe { std::env::remove_var("MURK_KEY") };
1148
1149 let err = match result {
1150 Err(e) => e,
1151 Ok(_) => panic!("expected load_vault to fail for non-recipient"),
1152 };
1153 let msg = err.to_string();
1155 assert!(
1156 msg.contains("decryption failed")
1157 || msg.contains("no meta")
1158 || msg.contains("tampered"),
1159 "expected decryption or integrity failure, got: {err}"
1160 );
1161
1162 fs::remove_dir_all(&dir).unwrap();
1163 }
1164
1165 #[test]
1166 fn load_vault_zero_secrets() {
1167 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1168
1169 let (secret, pubkey) = generate_keypair();
1170
1171 let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
1172 let _ = fs::remove_dir_all(&dir);
1173 fs::create_dir_all(&dir).unwrap();
1174 let path = dir.join("test.murk");
1175
1176 let mut vault = types::Vault {
1178 version: types::VAULT_VERSION.into(),
1179 created: "2026-02-28T00:00:00Z".into(),
1180 vault_name: ".murk".into(),
1181 repo: String::new(),
1182 recipients: vec![pubkey.clone()],
1183 schema: BTreeMap::new(),
1184 secrets: BTreeMap::new(),
1185 meta: String::new(),
1186 };
1187
1188 let mut recipients_map = HashMap::new();
1189 recipients_map.insert(pubkey.clone(), "alice".into());
1190 let original = types::Murk {
1191 values: HashMap::new(),
1192 recipients: recipients_map,
1193 scoped: HashMap::new(),
1194 legacy_mac: false,
1195 github_pins: HashMap::new(),
1196 };
1197
1198 unsafe { std::env::set_var("MURK_KEY", &secret) };
1199 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1200 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1201
1202 let result = load_vault(path.to_str().unwrap());
1203 unsafe { std::env::remove_var("MURK_KEY") };
1204
1205 assert!(result.is_ok());
1206 let (_, murk, _) = result.unwrap();
1207 assert!(murk.values.is_empty());
1208 assert!(murk.scoped.is_empty());
1209
1210 fs::remove_dir_all(&dir).unwrap();
1211 }
1212
1213 #[test]
1214 fn load_vault_stripped_meta_with_secrets_fails() {
1215 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1216
1217 let (secret, pubkey) = generate_keypair();
1218 let recipient = make_recipient(&pubkey);
1219
1220 let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
1221 let _ = fs::remove_dir_all(&dir);
1222 fs::create_dir_all(&dir).unwrap();
1223 let path = dir.join("test.murk");
1224
1225 let mut vault = types::Vault {
1227 version: types::VAULT_VERSION.into(),
1228 created: "2026-02-28T00:00:00Z".into(),
1229 vault_name: ".murk".into(),
1230 repo: String::new(),
1231 recipients: vec![pubkey.clone()],
1232 schema: BTreeMap::new(),
1233 secrets: BTreeMap::new(),
1234 meta: String::new(),
1235 };
1236 vault.secrets.insert(
1237 "KEY1".into(),
1238 types::SecretEntry {
1239 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1240 scoped: BTreeMap::new(),
1241 },
1242 );
1243
1244 let mut recipients_map = HashMap::new();
1245 recipients_map.insert(pubkey.clone(), "alice".into());
1246 let original = types::Murk {
1247 values: HashMap::from([("KEY1".into(), "val1".into())]),
1248 recipients: recipients_map,
1249 scoped: HashMap::new(),
1250 legacy_mac: false,
1251 github_pins: HashMap::new(),
1252 };
1253
1254 unsafe { std::env::set_var("MURK_KEY", &secret) };
1255 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1256 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1257
1258 let mut tampered: types::Vault =
1260 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1261 tampered.meta = String::new();
1262 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1263
1264 let result = load_vault(path.to_str().unwrap());
1266 unsafe { std::env::remove_var("MURK_KEY") };
1267
1268 let err = result.err().expect("expected MAC validation to fail");
1269 assert!(
1270 err.to_string().contains("integrity check failed"),
1271 "expected integrity check failure, got: {err}"
1272 );
1273
1274 fs::remove_dir_all(&dir).unwrap();
1275 }
1276
1277 #[test]
1278 fn load_vault_empty_mac_with_secrets_fails() {
1279 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1280
1281 let (secret, pubkey) = generate_keypair();
1282 let recipient = make_recipient(&pubkey);
1283
1284 let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
1285 let _ = fs::remove_dir_all(&dir);
1286 fs::create_dir_all(&dir).unwrap();
1287 let path = dir.join("test.murk");
1288
1289 let mut vault = types::Vault {
1291 version: types::VAULT_VERSION.into(),
1292 created: "2026-02-28T00:00:00Z".into(),
1293 vault_name: ".murk".into(),
1294 repo: String::new(),
1295 recipients: vec![pubkey.clone()],
1296 schema: BTreeMap::new(),
1297 secrets: BTreeMap::new(),
1298 meta: String::new(),
1299 };
1300 vault.secrets.insert(
1301 "KEY1".into(),
1302 types::SecretEntry {
1303 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1304 scoped: BTreeMap::new(),
1305 },
1306 );
1307
1308 let mut recipients_map = HashMap::new();
1310 recipients_map.insert(pubkey.clone(), "alice".into());
1311 let meta = types::Meta {
1312 recipients: recipients_map,
1313 mac: String::new(),
1314 mac_key: None,
1315 github_pins: HashMap::new(),
1316 };
1317 let meta_json = serde_json::to_vec(&meta).unwrap();
1318 vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1319
1320 crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1322
1323 unsafe { std::env::set_var("MURK_KEY", &secret) };
1325 unsafe { std::env::remove_var("MURK_KEY_FILE") };
1326 let result = load_vault(path.to_str().unwrap());
1327 unsafe { std::env::remove_var("MURK_KEY") };
1328
1329 let err = result.err().expect("expected MAC validation to fail");
1330 assert!(
1331 err.to_string().contains("integrity check failed"),
1332 "expected integrity check failure, got: {err}"
1333 );
1334
1335 fs::remove_dir_all(&dir).unwrap();
1336 }
1337
1338 #[test]
1339 fn compute_mac_changes_with_scoped_entries() {
1340 let mut vault = types::Vault {
1341 version: types::VAULT_VERSION.into(),
1342 created: "2026-02-28T00:00:00Z".into(),
1343 vault_name: ".murk".into(),
1344 repo: String::new(),
1345 recipients: vec!["age1abc".into()],
1346 schema: BTreeMap::new(),
1347 secrets: BTreeMap::new(),
1348 meta: String::new(),
1349 };
1350
1351 vault.secrets.insert(
1352 "KEY".into(),
1353 types::SecretEntry {
1354 shared: "ciphertext".into(),
1355 scoped: BTreeMap::new(),
1356 },
1357 );
1358
1359 let key = [0u8; 32];
1360 let mac_no_scoped = compute_mac(&vault, Some(&key));
1361
1362 vault
1363 .secrets
1364 .get_mut("KEY")
1365 .unwrap()
1366 .scoped
1367 .insert("age1bob".into(), "scoped-ct".into());
1368
1369 let mac_with_scoped = compute_mac(&vault, Some(&key));
1370 assert_ne!(mac_no_scoped, mac_with_scoped);
1371 }
1372
1373 #[test]
1374 fn verify_mac_accepts_v1_prefix() {
1375 let vault = types::Vault {
1376 version: types::VAULT_VERSION.into(),
1377 created: "2026-02-28T00:00:00Z".into(),
1378 vault_name: ".murk".into(),
1379 repo: String::new(),
1380 recipients: vec!["age1abc".into()],
1381 schema: BTreeMap::new(),
1382 secrets: BTreeMap::new(),
1383 meta: String::new(),
1384 };
1385
1386 let key = [0u8; 32];
1387 let v1_mac = compute_mac_v1(&vault);
1388 let v2_mac = compute_mac_v2(&vault);
1389 let v3_mac = compute_mac_v3(&vault, &key);
1390 assert!(verify_mac(&vault, &v1_mac, None));
1391 assert!(verify_mac(&vault, &v2_mac, None));
1392 assert!(verify_mac(&vault, &v3_mac, Some(&key)));
1393 assert!(!verify_mac(&vault, "sha256:bogus", None));
1394 assert!(!verify_mac(&vault, "blake3:bogus", Some(&key)));
1395 assert!(!verify_mac(&vault, "blake3v2:bogus", Some(&key)));
1396 assert!(!verify_mac(&vault, "unknown:prefix", None));
1397
1398 let v4_mac = compute_mac_v4(&vault, &key);
1400 assert!(v4_mac.starts_with("blake3v2:"));
1401 assert!(verify_mac(&vault, &v4_mac, Some(&key)));
1402 }
1403
1404 #[test]
1405 fn compute_mac_changes_with_schema() {
1406 let mut vault = types::Vault {
1407 version: types::VAULT_VERSION.into(),
1408 created: "2026-02-28T00:00:00Z".into(),
1409 vault_name: ".murk".into(),
1410 repo: String::new(),
1411 recipients: vec!["age1abc".into()],
1412 schema: BTreeMap::new(),
1413 secrets: BTreeMap::new(),
1414 meta: String::new(),
1415 };
1416
1417 let key = [0u8; 32];
1418 let mac_no_schema = compute_mac(&vault, Some(&key));
1419
1420 vault.schema.insert(
1421 "API_KEY".into(),
1422 types::SchemaEntry {
1423 description: "Main API key".into(),
1424 tags: vec!["deploy".into()],
1425 ..Default::default()
1426 },
1427 );
1428
1429 let mac_with_schema = compute_mac(&vault, Some(&key));
1430 assert_ne!(mac_no_schema, mac_with_schema);
1431
1432 let mac_before_retag = mac_with_schema;
1434 vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
1435 let mac_after_retag = compute_mac(&vault, Some(&key));
1436 assert_ne!(mac_before_retag, mac_after_retag);
1437 }
1438
1439 #[test]
1440 fn mac_key_roundtrip() {
1441 let hex = generate_mac_key();
1442 assert_eq!(hex.len(), 64);
1443 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1444
1445 let key = decode_mac_key(&hex).expect("valid hex should decode");
1446 let rehex = key.iter().fold(String::new(), |mut s, b| {
1448 use std::fmt::Write;
1449 let _ = write!(s, "{b:02x}");
1450 s
1451 });
1452 assert_eq!(hex, rehex);
1453 }
1454
1455 #[test]
1456 fn decode_mac_key_rejects_bad_input() {
1457 assert!(decode_mac_key("").is_none());
1458 assert!(decode_mac_key("tooshort").is_none());
1459 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()); }
1463
1464 #[test]
1465 fn blake3_mac_different_key_different_mac() {
1466 let vault = types::Vault {
1467 version: types::VAULT_VERSION.into(),
1468 created: "2026-02-28T00:00:00Z".into(),
1469 vault_name: ".murk".into(),
1470 repo: String::new(),
1471 recipients: vec!["age1abc".into()],
1472 schema: BTreeMap::new(),
1473 secrets: BTreeMap::new(),
1474 meta: String::new(),
1475 };
1476
1477 let key1 = [0u8; 32];
1478 let key2 = [1u8; 32];
1479 let mac1 = compute_mac(&vault, Some(&key1));
1480 let mac2 = compute_mac(&vault, Some(&key2));
1481 assert_ne!(mac1, mac2);
1482 }
1483
1484 #[test]
1485 fn valid_key_names() {
1486 assert!(is_valid_key_name("DATABASE_URL"));
1487 assert!(is_valid_key_name("_PRIVATE"));
1488 assert!(is_valid_key_name("A"));
1489 assert!(is_valid_key_name("key123"));
1490 }
1491
1492 #[test]
1493 fn invalid_key_names() {
1494 assert!(!is_valid_key_name(""));
1495 assert!(!is_valid_key_name("123_START"));
1496 assert!(!is_valid_key_name("KEY-NAME"));
1497 assert!(!is_valid_key_name("KEY NAME"));
1498 assert!(!is_valid_key_name("FOO$(bar)"));
1499 assert!(!is_valid_key_name("KEY=VAL"));
1500 }
1501
1502 #[test]
1503 fn now_utc_format() {
1504 let ts = now_utc();
1505 assert!(ts.ends_with('Z'));
1506 assert_eq!(ts.len(), 20);
1507 assert_eq!(&ts[4..5], "-");
1508 assert_eq!(&ts[7..8], "-");
1509 assert_eq!(&ts[10..11], "T");
1510 }
1511}