Skip to main content

murk_cli/
lib.rs

1//! Encrypted secrets manager for developers — one file, age encryption, git-friendly.
2//!
3//! This library provides the core functionality for murk: vault I/O, age encryption,
4//! BIP39 key recovery, and secret management. The CLI binary wraps this library.
5
6#![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
19// Domain modules
20pub mod codename;
21pub mod crypto;
22pub mod env;
23pub mod export;
24pub mod integrity;
25pub mod merge;
26pub mod recipients;
27pub mod recovery;
28pub mod secrets;
29pub mod types;
30pub mod vault;
31
32// Shared test utilities
33#[cfg(test)]
34pub mod testutil;
35
36// Re-exports: keep the flat murk_cli::foo() API for main.rs
37pub use env::{parse_env, resolve_key, warn_env_permissions};
38pub use export::{DiffEntry, DiffKind, diff_secrets, export_secrets, resolve_secrets};
39pub use recipients::{RevokeResult, authorize_recipient, revoke_recipient};
40pub use secrets::{add_secret, describe_key, get_secret, list_keys, remove_secret};
41
42use std::collections::{BTreeMap, HashMap};
43use std::path::Path;
44
45use age::secrecy::ExposeSecret;
46use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
47
48/// Encrypt a value and return base64-encoded ciphertext.
49pub fn encrypt_value(
50    plaintext: &[u8],
51    recipients: &[age::x25519::Recipient],
52) -> Result<String, String> {
53    let ciphertext = crypto::encrypt(plaintext, recipients).map_err(|e| e.to_string())?;
54    Ok(BASE64.encode(&ciphertext))
55}
56
57/// Decrypt a base64-encoded ciphertext and return plaintext bytes.
58pub fn decrypt_value(encoded: &str, identity: &age::x25519::Identity) -> Result<Vec<u8>, String> {
59    let ciphertext = BASE64
60        .decode(encoded)
61        .map_err(|e| format!("invalid base64: {e}"))?;
62    crypto::decrypt(&ciphertext, identity).map_err(|e| e.to_string())
63}
64
65/// Load the vault: read JSON, decrypt all values, return working state.
66/// Returns the raw vault (for preserving unchanged ciphertext on save),
67/// the decrypted murk, and the identity.
68pub fn load_vault(
69    vault_path: &str,
70) -> Result<(types::Vault, types::Murk, age::x25519::Identity), String> {
71    let path = Path::new(vault_path);
72    let secret_key = resolve_key()?;
73
74    let identity =
75        crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
76            format!("invalid MURK_KEY (expected AGE-SECRET-KEY-1...): {e}. Run `murk restore` to recover from your 24-word phrase")
77        })?;
78
79    let vault = vault::read(path).map_err(|e| e.to_string())?;
80    let pubkey = identity.to_public().to_string();
81
82    // Decrypt shared values.
83    let mut values = HashMap::new();
84    for (key, entry) in &vault.secrets {
85        let plaintext = decrypt_value(&entry.shared, &identity).map_err(|_| {
86            "decryption failed — your MURK_KEY may not be a recipient of this vault. Check with `murk recipients`".to_string()
87        })?;
88        let value = String::from_utf8(plaintext)
89            .map_err(|e| format!("invalid UTF-8 in secret {key}: {e}"))?;
90        values.insert(key.clone(), value);
91    }
92
93    // Decrypt our own scoped (mote) values.
94    let mut scoped = HashMap::new();
95    for (key, entry) in &vault.secrets {
96        if let Some(encoded) = entry.scoped.get(&pubkey) {
97            if let Ok(plaintext) = decrypt_value(encoded, &identity) {
98                if let Ok(value) = String::from_utf8(plaintext) {
99                    scoped
100                        .entry(key.clone())
101                        .or_insert_with(HashMap::new)
102                        .insert(pubkey.clone(), value);
103                }
104            }
105        }
106    }
107
108    // Decrypt meta for recipient names and validate integrity MAC.
109    let recipients = if vault.meta.is_empty() {
110        HashMap::new()
111    } else if let Ok(plaintext) = decrypt_value(&vault.meta, &identity) {
112        let meta: types::Meta =
113            serde_json::from_slice(&plaintext).unwrap_or_else(|_| types::Meta {
114                recipients: HashMap::new(),
115                mac: String::new(),
116            });
117
118        // Validate MAC if present.
119        if !meta.mac.is_empty() {
120            let expected = compute_mac(&vault);
121            if meta.mac != expected {
122                return Err(format!(
123                    "integrity check failed: vault may have been tampered with (expected {}, got {})",
124                    meta.mac, expected
125                ));
126            }
127        }
128
129        meta.recipients
130    } else {
131        HashMap::new()
132    };
133
134    let murk = types::Murk {
135        values,
136        recipients,
137        scoped,
138    };
139
140    Ok((vault, murk, identity))
141}
142
143/// Save the vault: compare against original state and only re-encrypt changed values.
144/// Unchanged values keep their original ciphertext for minimal git diffs.
145pub fn save_vault(
146    vault_path: &str,
147    vault: &mut types::Vault,
148    original: &types::Murk,
149    current: &types::Murk,
150) -> Result<(), String> {
151    let recipients: Vec<age::x25519::Recipient> = vault
152        .recipients
153        .iter()
154        .map(|pk| crypto::parse_recipient(pk).map_err(|e| e.to_string()))
155        .collect::<Result<Vec<_>, _>>()?;
156
157    // Check if recipient list changed — forces full re-encryption of shared values.
158    let recipients_changed = {
159        let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
160        let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
161        current_pks.sort_unstable();
162        original_pks.sort_unstable();
163        current_pks != original_pks
164    };
165
166    let mut new_secrets = BTreeMap::new();
167
168    for (key, value) in &current.values {
169        // Determine shared ciphertext.
170        let shared = if !recipients_changed && original.values.get(key) == Some(value) {
171            // Value unchanged and recipients unchanged — keep original ciphertext.
172            if let Some(existing) = vault.secrets.get(key) {
173                existing.shared.clone()
174            } else {
175                encrypt_value(value.as_bytes(), &recipients)?
176            }
177        } else {
178            encrypt_value(value.as_bytes(), &recipients)?
179        };
180
181        // Handle scoped (mote) entries.
182        let mut scoped = vault
183            .secrets
184            .get(key)
185            .map(|e| e.scoped.clone())
186            .unwrap_or_default();
187
188        // Update/add/remove entries for recipients in current.scoped.
189        if let Some(key_scoped) = current.scoped.get(key) {
190            for (pk, val) in key_scoped {
191                let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
192                if original_val == Some(val) {
193                    // Unchanged — keep original ciphertext.
194                } else {
195                    // Changed or new — re-encrypt to this recipient only.
196                    let recipient = crypto::parse_recipient(pk).map_err(|e| e.to_string())?;
197                    scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
198                }
199            }
200        }
201
202        // Remove scoped entries for pubkeys no longer in current.scoped for this key.
203        if let Some(orig_key_scoped) = original.scoped.get(key) {
204            for pk in orig_key_scoped.keys() {
205                let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
206                if !still_present {
207                    scoped.remove(pk);
208                }
209            }
210        }
211
212        new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
213    }
214
215    vault.secrets = new_secrets;
216
217    // Update meta.
218    let mac = compute_mac(vault);
219    let meta = types::Meta {
220        recipients: current.recipients.clone(),
221        mac,
222    };
223    let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
224    vault.meta = encrypt_value(&meta_json, &recipients)?;
225
226    vault::write(Path::new(vault_path), vault).map_err(|e| e.to_string())
227}
228
229/// Compute an integrity MAC over the vault's secrets and schema.
230/// Covers: sorted key names, encrypted shared values, recipient pubkeys.
231pub fn compute_mac(vault: &types::Vault) -> String {
232    use sha2::{Digest, Sha256};
233
234    let mut hasher = Sha256::new();
235
236    // Hash sorted key names.
237    for key in vault.secrets.keys() {
238        hasher.update(key.as_bytes());
239        hasher.update(b"\x00");
240    }
241
242    // Hash encrypted shared values (as stored).
243    for entry in vault.secrets.values() {
244        hasher.update(entry.shared.as_bytes());
245        hasher.update(b"\x00");
246    }
247
248    // Hash sorted recipient pubkeys.
249    let mut pks = vault.recipients.clone();
250    pks.sort();
251    for pk in &pks {
252        hasher.update(pk.as_bytes());
253        hasher.update(b"\x00");
254    }
255
256    let digest = hasher.finalize();
257    format!(
258        "sha256:{}",
259        digest.iter().fold(String::new(), |mut s, b| {
260            use std::fmt::Write;
261            write!(s, "{b:02x}").unwrap();
262            s
263        })
264    )
265}
266
267/// Generate an ISO-8601 UTC timestamp.
268pub fn now_utc() -> String {
269    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::testutil::*;
276    use std::collections::BTreeMap;
277    use std::fs;
278
279    #[test]
280    fn encrypt_decrypt_value_roundtrip() {
281        let (secret, pubkey) = generate_keypair();
282        let recipient = make_recipient(&pubkey);
283        let identity = make_identity(&secret);
284
285        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
286        let decrypted = decrypt_value(&encoded, &identity).unwrap();
287        assert_eq!(decrypted, b"hello world");
288    }
289
290    #[test]
291    fn decrypt_value_invalid_base64() {
292        let (secret, _) = generate_keypair();
293        let identity = make_identity(&secret);
294
295        let result = decrypt_value("not!valid!base64!!!", &identity);
296        assert!(result.is_err());
297        assert!(result.unwrap_err().contains("invalid base64"));
298    }
299
300    #[test]
301    fn encrypt_value_multiple_recipients() {
302        let (secret_a, pubkey_a) = generate_keypair();
303        let (secret_b, pubkey_b) = generate_keypair();
304
305        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
306        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
307
308        // Both can decrypt.
309        let id_a = make_identity(&secret_a);
310        let id_b = make_identity(&secret_b);
311        assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
312        assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
313    }
314
315    #[test]
316    fn decrypt_value_wrong_key_fails() {
317        let (_, pubkey) = generate_keypair();
318        let (wrong_secret, _) = generate_keypair();
319
320        let recipient = make_recipient(&pubkey);
321        let wrong_identity = make_identity(&wrong_secret);
322
323        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
324        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
325    }
326
327    #[test]
328    fn compute_mac_deterministic() {
329        let vault = types::Vault {
330            version: "2.0".into(),
331            created: "2026-02-28T00:00:00Z".into(),
332            vault_name: ".murk".into(),
333            repo: String::new(),
334            recipients: vec!["age1abc".into()],
335            schema: BTreeMap::new(),
336            secrets: BTreeMap::new(),
337            meta: String::new(),
338        };
339
340        let mac1 = compute_mac(&vault);
341        let mac2 = compute_mac(&vault);
342        assert_eq!(mac1, mac2);
343        assert!(mac1.starts_with("sha256:"));
344    }
345
346    #[test]
347    fn compute_mac_changes_with_different_secrets() {
348        let mut vault = types::Vault {
349            version: "2.0".into(),
350            created: "2026-02-28T00:00:00Z".into(),
351            vault_name: ".murk".into(),
352            repo: String::new(),
353            recipients: vec!["age1abc".into()],
354            schema: BTreeMap::new(),
355            secrets: BTreeMap::new(),
356            meta: String::new(),
357        };
358
359        let mac_empty = compute_mac(&vault);
360
361        vault.secrets.insert(
362            "KEY".into(),
363            types::SecretEntry {
364                shared: "ciphertext".into(),
365                scoped: BTreeMap::new(),
366            },
367        );
368
369        let mac_with_secret = compute_mac(&vault);
370        assert_ne!(mac_empty, mac_with_secret);
371    }
372
373    #[test]
374    fn compute_mac_changes_with_different_recipients() {
375        let mut vault = types::Vault {
376            version: "2.0".into(),
377            created: "2026-02-28T00:00:00Z".into(),
378            vault_name: ".murk".into(),
379            repo: String::new(),
380            recipients: vec!["age1abc".into()],
381            schema: BTreeMap::new(),
382            secrets: BTreeMap::new(),
383            meta: String::new(),
384        };
385
386        let mac1 = compute_mac(&vault);
387        vault.recipients.push("age1xyz".into());
388        let mac2 = compute_mac(&vault);
389        assert_ne!(mac1, mac2);
390    }
391
392    #[test]
393    fn save_vault_preserves_unchanged_ciphertext() {
394        let (secret, pubkey) = generate_keypair();
395        let recipient = make_recipient(&pubkey);
396        let identity = make_identity(&secret);
397
398        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
399        fs::create_dir_all(&dir).unwrap();
400        let path = dir.join("test.murk");
401
402        let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
403        let mut vault = types::Vault {
404            version: "2.0".into(),
405            created: "2026-02-28T00:00:00Z".into(),
406            vault_name: ".murk".into(),
407            repo: String::new(),
408            recipients: vec![pubkey.clone()],
409            schema: BTreeMap::new(),
410            secrets: BTreeMap::new(),
411            meta: String::new(),
412        };
413        vault.secrets.insert(
414            "KEY1".into(),
415            types::SecretEntry {
416                shared: shared.clone(),
417                scoped: BTreeMap::new(),
418            },
419        );
420
421        let mut recipients_map = HashMap::new();
422        recipients_map.insert(pubkey.clone(), "alice".into());
423        let original = types::Murk {
424            values: HashMap::from([("KEY1".into(), "original".into())]),
425            recipients: recipients_map.clone(),
426            scoped: HashMap::new(),
427        };
428
429        let current = original.clone();
430        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
431
432        assert_eq!(vault.secrets["KEY1"].shared, shared);
433
434        let mut changed = current.clone();
435        changed.values.insert("KEY1".into(), "modified".into());
436        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
437
438        assert_ne!(vault.secrets["KEY1"].shared, shared);
439
440        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
441        assert_eq!(decrypted, b"modified");
442
443        fs::remove_dir_all(&dir).unwrap();
444    }
445
446    #[test]
447    fn save_vault_adds_new_secret() {
448        let (_, pubkey) = generate_keypair();
449        let recipient = make_recipient(&pubkey);
450
451        let dir = std::env::temp_dir().join("murk_test_save_add");
452        fs::create_dir_all(&dir).unwrap();
453        let path = dir.join("test.murk");
454
455        let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
456        let mut vault = types::Vault {
457            version: "2.0".into(),
458            created: "2026-02-28T00:00:00Z".into(),
459            vault_name: ".murk".into(),
460            repo: String::new(),
461            recipients: vec![pubkey.clone()],
462            schema: BTreeMap::new(),
463            secrets: BTreeMap::new(),
464            meta: String::new(),
465        };
466        vault.secrets.insert(
467            "KEY1".into(),
468            types::SecretEntry {
469                shared,
470                scoped: BTreeMap::new(),
471            },
472        );
473
474        let mut recipients_map = HashMap::new();
475        recipients_map.insert(pubkey.clone(), "alice".into());
476        let original = types::Murk {
477            values: HashMap::from([("KEY1".into(), "val1".into())]),
478            recipients: recipients_map.clone(),
479            scoped: HashMap::new(),
480        };
481
482        let mut current = original.clone();
483        current.values.insert("KEY2".into(), "val2".into());
484
485        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
486
487        assert!(vault.secrets.contains_key("KEY1"));
488        assert!(vault.secrets.contains_key("KEY2"));
489
490        fs::remove_dir_all(&dir).unwrap();
491    }
492
493    #[test]
494    fn save_vault_removes_deleted_secret() {
495        let (_, pubkey) = generate_keypair();
496        let recipient = make_recipient(&pubkey);
497
498        let dir = std::env::temp_dir().join("murk_test_save_remove");
499        fs::create_dir_all(&dir).unwrap();
500        let path = dir.join("test.murk");
501
502        let mut vault = types::Vault {
503            version: "2.0".into(),
504            created: "2026-02-28T00:00:00Z".into(),
505            vault_name: ".murk".into(),
506            repo: String::new(),
507            recipients: vec![pubkey.clone()],
508            schema: BTreeMap::new(),
509            secrets: BTreeMap::new(),
510            meta: String::new(),
511        };
512        vault.secrets.insert(
513            "KEY1".into(),
514            types::SecretEntry {
515                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
516                scoped: BTreeMap::new(),
517            },
518        );
519        vault.secrets.insert(
520            "KEY2".into(),
521            types::SecretEntry {
522                shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
523                scoped: BTreeMap::new(),
524            },
525        );
526
527        let mut recipients_map = HashMap::new();
528        recipients_map.insert(pubkey.clone(), "alice".into());
529        let original = types::Murk {
530            values: HashMap::from([
531                ("KEY1".into(), "val1".into()),
532                ("KEY2".into(), "val2".into()),
533            ]),
534            recipients: recipients_map.clone(),
535            scoped: HashMap::new(),
536        };
537
538        let mut current = original.clone();
539        current.values.remove("KEY2");
540
541        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
542
543        assert!(vault.secrets.contains_key("KEY1"));
544        assert!(!vault.secrets.contains_key("KEY2"));
545
546        fs::remove_dir_all(&dir).unwrap();
547    }
548
549    #[test]
550    fn save_vault_reencrypts_all_on_recipient_change() {
551        let (secret1, pubkey1) = generate_keypair();
552        let (_, pubkey2) = generate_keypair();
553        let recipient1 = make_recipient(&pubkey1);
554
555        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
556        fs::create_dir_all(&dir).unwrap();
557        let path = dir.join("test.murk");
558
559        let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
560        let mut vault = types::Vault {
561            version: "2.0".into(),
562            created: "2026-02-28T00:00:00Z".into(),
563            vault_name: ".murk".into(),
564            repo: String::new(),
565            recipients: vec![pubkey1.clone(), pubkey2.clone()],
566            schema: BTreeMap::new(),
567            secrets: BTreeMap::new(),
568            meta: String::new(),
569        };
570        vault.secrets.insert(
571            "KEY1".into(),
572            types::SecretEntry {
573                shared: shared.clone(),
574                scoped: BTreeMap::new(),
575            },
576        );
577
578        let mut recipients_map = HashMap::new();
579        recipients_map.insert(pubkey1.clone(), "alice".into());
580        let original = types::Murk {
581            values: HashMap::from([("KEY1".into(), "val1".into())]),
582            recipients: recipients_map,
583            scoped: HashMap::new(),
584        };
585
586        let mut current_recipients = HashMap::new();
587        current_recipients.insert(pubkey1.clone(), "alice".into());
588        current_recipients.insert(pubkey2.clone(), "bob".into());
589        let current = types::Murk {
590            values: HashMap::from([("KEY1".into(), "val1".into())]),
591            recipients: current_recipients,
592            scoped: HashMap::new(),
593        };
594
595        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
596
597        assert_ne!(vault.secrets["KEY1"].shared, shared);
598
599        let identity1 = make_identity(&secret1);
600        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
601        assert_eq!(decrypted, b"val1");
602
603        fs::remove_dir_all(&dir).unwrap();
604    }
605
606    #[test]
607    fn save_vault_scoped_entry_lifecycle() {
608        let (secret, pubkey) = generate_keypair();
609        let recipient = make_recipient(&pubkey);
610        let identity = make_identity(&secret);
611
612        let dir = std::env::temp_dir().join("murk_test_save_scoped");
613        fs::create_dir_all(&dir).unwrap();
614        let path = dir.join("test.murk");
615
616        let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
617        let mut vault = types::Vault {
618            version: "2.0".into(),
619            created: "2026-02-28T00:00:00Z".into(),
620            vault_name: ".murk".into(),
621            repo: String::new(),
622            recipients: vec![pubkey.clone()],
623            schema: BTreeMap::new(),
624            secrets: BTreeMap::new(),
625            meta: String::new(),
626        };
627        vault.secrets.insert(
628            "KEY1".into(),
629            types::SecretEntry {
630                shared,
631                scoped: BTreeMap::new(),
632            },
633        );
634
635        let mut recipients_map = HashMap::new();
636        recipients_map.insert(pubkey.clone(), "alice".into());
637        let original = types::Murk {
638            values: HashMap::from([("KEY1".into(), "shared_val".into())]),
639            recipients: recipients_map.clone(),
640            scoped: HashMap::new(),
641        };
642
643        // Add a scoped override.
644        let mut current = original.clone();
645        let mut key_scoped = HashMap::new();
646        key_scoped.insert(pubkey.clone(), "my_override".into());
647        current.scoped.insert("KEY1".into(), key_scoped);
648
649        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
650
651        assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
652        let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
653        assert_eq!(scoped_val, b"my_override");
654
655        // Now remove the scoped override.
656        let original_with_scoped = current.clone();
657        let mut current_no_scoped = original_with_scoped.clone();
658        current_no_scoped.scoped.remove("KEY1");
659
660        save_vault(
661            path.to_str().unwrap(),
662            &mut vault,
663            &original_with_scoped,
664            &current_no_scoped,
665        )
666        .unwrap();
667
668        assert!(vault.secrets["KEY1"].scoped.is_empty());
669
670        fs::remove_dir_all(&dir).unwrap();
671    }
672
673    #[test]
674    fn load_vault_validates_mac() {
675        let (secret, pubkey) = generate_keypair();
676        let recipient = make_recipient(&pubkey);
677        let identity = make_identity(&secret);
678
679        let dir = std::env::temp_dir().join("murk_test_load_mac");
680        fs::create_dir_all(&dir).unwrap();
681        let path = dir.join("test.murk");
682
683        // Build a vault with one secret, save it (computes valid MAC).
684        let mut vault = types::Vault {
685            version: "2.0".into(),
686            created: "2026-02-28T00:00:00Z".into(),
687            vault_name: ".murk".into(),
688            repo: String::new(),
689            recipients: vec![pubkey.clone()],
690            schema: BTreeMap::new(),
691            secrets: BTreeMap::new(),
692            meta: String::new(),
693        };
694        vault.secrets.insert(
695            "KEY1".into(),
696            types::SecretEntry {
697                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
698                scoped: BTreeMap::new(),
699            },
700        );
701
702        let mut recipients_map = HashMap::new();
703        recipients_map.insert(pubkey.clone(), "alice".into());
704        let original = types::Murk {
705            values: HashMap::from([("KEY1".into(), "val1".into())]),
706            recipients: recipients_map,
707            scoped: HashMap::new(),
708        };
709
710        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
711
712        // Now tamper: change the ciphertext in the saved vault file.
713        let mut tampered: types::Vault =
714            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
715        tampered.secrets.get_mut("KEY1").unwrap().shared =
716            encrypt_value(b"tampered", &[recipient]).unwrap();
717        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
718
719        // Load should fail MAC validation.
720        unsafe { std::env::set_var("MURK_KEY", secret) };
721        unsafe { std::env::remove_var("MURK_KEY_FILE") };
722        let result = load_vault(path.to_str().unwrap());
723        unsafe { std::env::remove_var("MURK_KEY") };
724
725        let err = result.err().expect("expected MAC validation to fail");
726        assert!(
727            err.contains("integrity check failed"),
728            "expected integrity check failure, got: {err}"
729        );
730
731        fs::remove_dir_all(&dir).unwrap();
732    }
733
734    #[test]
735    fn load_vault_succeeds_with_valid_mac() {
736        let (secret, pubkey) = generate_keypair();
737        let recipient = make_recipient(&pubkey);
738
739        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
740        fs::create_dir_all(&dir).unwrap();
741        let path = dir.join("test.murk");
742
743        let mut vault = types::Vault {
744            version: "2.0".into(),
745            created: "2026-02-28T00:00:00Z".into(),
746            vault_name: ".murk".into(),
747            repo: String::new(),
748            recipients: vec![pubkey.clone()],
749            schema: BTreeMap::new(),
750            secrets: BTreeMap::new(),
751            meta: String::new(),
752        };
753        vault.secrets.insert(
754            "KEY1".into(),
755            types::SecretEntry {
756                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
757                scoped: BTreeMap::new(),
758            },
759        );
760
761        let mut recipients_map = HashMap::new();
762        recipients_map.insert(pubkey.clone(), "alice".into());
763        let original = types::Murk {
764            values: HashMap::from([("KEY1".into(), "val1".into())]),
765            recipients: recipients_map,
766            scoped: HashMap::new(),
767        };
768
769        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
770
771        // Load should succeed.
772        unsafe { std::env::set_var("MURK_KEY", secret) };
773        unsafe { std::env::remove_var("MURK_KEY_FILE") };
774        let result = load_vault(path.to_str().unwrap());
775        unsafe { std::env::remove_var("MURK_KEY") };
776
777        assert!(result.is_ok());
778        let (_, murk, _) = result.unwrap();
779        assert_eq!(murk.values["KEY1"], "val1");
780
781        fs::remove_dir_all(&dir).unwrap();
782    }
783
784    #[test]
785    fn now_utc_format() {
786        let ts = now_utc();
787        assert!(ts.ends_with('Z'));
788        assert_eq!(ts.len(), 20);
789        assert_eq!(&ts[4..5], "-");
790        assert_eq!(&ts[7..8], "-");
791        assert_eq!(&ts[10..11], "T");
792    }
793}