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 git;
25pub mod info;
26pub mod init;
27pub mod merge;
28pub mod recipients;
29pub mod recovery;
30pub mod secrets;
31pub mod types;
32pub mod vault;
33
34// Shared test utilities
35#[cfg(test)]
36pub mod testutil;
37
38// Re-exports: keep the flat murk_cli::foo() API for main.rs
39pub use env::{
40    EnvrcStatus, dotenv_has_murk_key, parse_env, read_key_from_dotenv, resolve_key,
41    warn_env_permissions, write_envrc, write_key_to_dotenv,
42};
43pub use export::{
44    DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets,
45    parse_and_decrypt_values, resolve_secrets,
46};
47pub use git::{MergeDriverSetupStep, setup_merge_driver};
48pub use info::{InfoEntry, VaultInfo, vault_info};
49pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
50pub use merge::{MergeDriverOutput, run_merge_driver};
51pub use recipients::{
52    RecipientEntry, RevokeResult, authorize_recipient, list_recipients, revoke_recipient,
53};
54pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
55
56use std::collections::{BTreeMap, HashMap};
57use std::path::Path;
58
59use age::secrecy::ExposeSecret;
60use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
61
62/// Decrypt the meta blob from a vault, returning the deserialized Meta if possible.
63pub(crate) fn decrypt_meta(
64    vault: &types::Vault,
65    identity: &age::x25519::Identity,
66) -> Option<types::Meta> {
67    if vault.meta.is_empty() {
68        return None;
69    }
70    let plaintext = decrypt_value(&vault.meta, identity).ok()?;
71    serde_json::from_slice(&plaintext).ok()
72}
73
74/// Parse a list of pubkey strings into age recipients.
75pub(crate) fn parse_recipients(pubkeys: &[String]) -> Result<Vec<age::x25519::Recipient>, String> {
76    pubkeys
77        .iter()
78        .map(|pk| crypto::parse_recipient(pk).map_err(|e| e.to_string()))
79        .collect()
80}
81
82/// Encrypt a value and return base64-encoded ciphertext.
83pub fn encrypt_value(
84    plaintext: &[u8],
85    recipients: &[age::x25519::Recipient],
86) -> Result<String, String> {
87    let ciphertext = crypto::encrypt(plaintext, recipients).map_err(|e| e.to_string())?;
88    Ok(BASE64.encode(&ciphertext))
89}
90
91/// Decrypt a base64-encoded ciphertext and return plaintext bytes.
92pub fn decrypt_value(encoded: &str, identity: &age::x25519::Identity) -> Result<Vec<u8>, String> {
93    let ciphertext = BASE64
94        .decode(encoded)
95        .map_err(|e| format!("invalid base64: {e}"))?;
96    crypto::decrypt(&ciphertext, identity).map_err(|e| e.to_string())
97}
98
99/// Load the vault: read JSON, decrypt all values, return working state.
100/// Returns the raw vault (for preserving unchanged ciphertext on save),
101/// the decrypted murk, and the identity.
102pub fn load_vault(
103    vault_path: &str,
104) -> Result<(types::Vault, types::Murk, age::x25519::Identity), String> {
105    let path = Path::new(vault_path);
106    let secret_key = resolve_key()?;
107
108    let identity =
109        crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
110            format!("invalid MURK_KEY (expected AGE-SECRET-KEY-1...): {e}. Run `murk restore` to recover from your 24-word phrase")
111        })?;
112
113    let vault = vault::read(path).map_err(|e| e.to_string())?;
114    let pubkey = identity.to_public().to_string();
115
116    // Decrypt shared values.
117    let mut values = HashMap::new();
118    for (key, entry) in &vault.secrets {
119        let plaintext = decrypt_value(&entry.shared, &identity).map_err(|_| {
120            "decryption failed — your MURK_KEY may not be a recipient of this vault. Check with `murk recipients`".to_string()
121        })?;
122        let value = String::from_utf8(plaintext)
123            .map_err(|e| format!("invalid UTF-8 in secret {key}: {e}"))?;
124        values.insert(key.clone(), value);
125    }
126
127    // Decrypt our scoped (mote) overrides.
128    let mut scoped = HashMap::new();
129    for (key, entry) in &vault.secrets {
130        if let Some(encoded) = entry.scoped.get(&pubkey) {
131            if let Ok(plaintext) = decrypt_value(encoded, &identity) {
132                if let Ok(value) = String::from_utf8(plaintext) {
133                    scoped
134                        .entry(key.clone())
135                        .or_insert_with(HashMap::new)
136                        .insert(pubkey.clone(), value);
137                }
138            }
139        }
140    }
141
142    // Decrypt meta for recipient names and validate integrity MAC.
143    let recipients = if vault.secrets.is_empty() {
144        // Fresh vault with no secrets — allow missing/empty meta since there's nothing to protect.
145        decrypt_meta(&vault, &identity)
146            .map(|m| m.recipients)
147            .unwrap_or_default()
148    } else {
149        // Vault has secrets — MAC is mandatory.
150        let meta = decrypt_meta(&vault, &identity).ok_or(
151            "integrity check failed: vault has secrets but no meta — vault may have been tampered with"
152        )?;
153        if meta.mac.is_empty() {
154            return Err("integrity check failed: vault has secrets but MAC is empty — vault may have been tampered with".into());
155        }
156        let expected = compute_mac(&vault);
157        if meta.mac != expected {
158            return Err(format!(
159                "integrity check failed: vault may have been tampered with (expected {}, got {})",
160                meta.mac, expected
161            ));
162        }
163        meta.recipients
164    };
165
166    let murk = types::Murk {
167        values,
168        recipients,
169        scoped,
170    };
171
172    Ok((vault, murk, identity))
173}
174
175/// Save the vault: compare against original state and only re-encrypt changed values.
176/// Unchanged values keep their original ciphertext for minimal git diffs.
177pub fn save_vault(
178    vault_path: &str,
179    vault: &mut types::Vault,
180    original: &types::Murk,
181    current: &types::Murk,
182) -> Result<(), String> {
183    let recipients = parse_recipients(&vault.recipients)?;
184
185    // Check if recipient list changed — forces full re-encryption of shared values.
186    let recipients_changed = {
187        let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
188        let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
189        current_pks.sort_unstable();
190        original_pks.sort_unstable();
191        current_pks != original_pks
192    };
193
194    let mut new_secrets = BTreeMap::new();
195
196    for (key, value) in &current.values {
197        // Determine shared ciphertext.
198        let shared = if !recipients_changed && original.values.get(key) == Some(value) {
199            // Value unchanged and recipients unchanged — keep original ciphertext.
200            if let Some(existing) = vault.secrets.get(key) {
201                existing.shared.clone()
202            } else {
203                encrypt_value(value.as_bytes(), &recipients)?
204            }
205        } else {
206            encrypt_value(value.as_bytes(), &recipients)?
207        };
208
209        // Handle scoped (mote) entries.
210        let mut scoped = vault
211            .secrets
212            .get(key)
213            .map(|e| e.scoped.clone())
214            .unwrap_or_default();
215
216        // Update/add/remove entries for recipients in current.scoped.
217        if let Some(key_scoped) = current.scoped.get(key) {
218            for (pk, val) in key_scoped {
219                let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
220                if original_val == Some(val) {
221                    // Unchanged — keep original ciphertext.
222                } else {
223                    // Changed or new — re-encrypt to this recipient only.
224                    let recipient = crypto::parse_recipient(pk).map_err(|e| e.to_string())?;
225                    scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
226                }
227            }
228        }
229
230        // Remove scoped entries for pubkeys no longer in current.scoped for this key.
231        if let Some(orig_key_scoped) = original.scoped.get(key) {
232            for pk in orig_key_scoped.keys() {
233                let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
234                if !still_present {
235                    scoped.remove(pk);
236                }
237            }
238        }
239
240        new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
241    }
242
243    vault.secrets = new_secrets;
244
245    // Update meta.
246    let mac = compute_mac(vault);
247    let meta = types::Meta {
248        recipients: current.recipients.clone(),
249        mac,
250    };
251    let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
252    vault.meta = encrypt_value(&meta_json, &recipients)?;
253
254    vault::write(Path::new(vault_path), vault).map_err(|e| e.to_string())
255}
256
257/// Compute an integrity MAC over the vault's secrets and schema.
258/// Covers: sorted key names, encrypted shared values, recipient pubkeys.
259pub(crate) fn compute_mac(vault: &types::Vault) -> String {
260    use sha2::{Digest, Sha256};
261
262    let mut hasher = Sha256::new();
263
264    // Hash sorted key names.
265    for key in vault.secrets.keys() {
266        hasher.update(key.as_bytes());
267        hasher.update(b"\x00");
268    }
269
270    // Hash encrypted shared values (as stored).
271    for entry in vault.secrets.values() {
272        hasher.update(entry.shared.as_bytes());
273        hasher.update(b"\x00");
274    }
275
276    // Hash sorted recipient pubkeys.
277    let mut pks = vault.recipients.clone();
278    pks.sort();
279    for pk in &pks {
280        hasher.update(pk.as_bytes());
281        hasher.update(b"\x00");
282    }
283
284    let digest = hasher.finalize();
285    format!(
286        "sha256:{}",
287        digest.iter().fold(String::new(), |mut s, b| {
288            use std::fmt::Write;
289            let _ = write!(s, "{b:02x}");
290            s
291        })
292    )
293}
294
295/// Generate an ISO-8601 UTC timestamp.
296pub(crate) fn now_utc() -> String {
297    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::testutil::*;
304    use std::collections::BTreeMap;
305    use std::fs;
306
307    #[test]
308    fn encrypt_decrypt_value_roundtrip() {
309        let (secret, pubkey) = generate_keypair();
310        let recipient = make_recipient(&pubkey);
311        let identity = make_identity(&secret);
312
313        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
314        let decrypted = decrypt_value(&encoded, &identity).unwrap();
315        assert_eq!(decrypted, b"hello world");
316    }
317
318    #[test]
319    fn decrypt_value_invalid_base64() {
320        let (secret, _) = generate_keypair();
321        let identity = make_identity(&secret);
322
323        let result = decrypt_value("not!valid!base64!!!", &identity);
324        assert!(result.is_err());
325        assert!(result.unwrap_err().contains("invalid base64"));
326    }
327
328    #[test]
329    fn encrypt_value_multiple_recipients() {
330        let (secret_a, pubkey_a) = generate_keypair();
331        let (secret_b, pubkey_b) = generate_keypair();
332
333        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
334        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
335
336        // Both can decrypt.
337        let id_a = make_identity(&secret_a);
338        let id_b = make_identity(&secret_b);
339        assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
340        assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
341    }
342
343    #[test]
344    fn decrypt_value_wrong_key_fails() {
345        let (_, pubkey) = generate_keypair();
346        let (wrong_secret, _) = generate_keypair();
347
348        let recipient = make_recipient(&pubkey);
349        let wrong_identity = make_identity(&wrong_secret);
350
351        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
352        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
353    }
354
355    #[test]
356    fn compute_mac_deterministic() {
357        let vault = types::Vault {
358            version: types::VAULT_VERSION.into(),
359            created: "2026-02-28T00:00:00Z".into(),
360            vault_name: ".murk".into(),
361            repo: String::new(),
362            recipients: vec!["age1abc".into()],
363            schema: BTreeMap::new(),
364            secrets: BTreeMap::new(),
365            meta: String::new(),
366        };
367
368        let mac1 = compute_mac(&vault);
369        let mac2 = compute_mac(&vault);
370        assert_eq!(mac1, mac2);
371        assert!(mac1.starts_with("sha256:"));
372    }
373
374    #[test]
375    fn compute_mac_changes_with_different_secrets() {
376        let mut vault = types::Vault {
377            version: types::VAULT_VERSION.into(),
378            created: "2026-02-28T00:00:00Z".into(),
379            vault_name: ".murk".into(),
380            repo: String::new(),
381            recipients: vec!["age1abc".into()],
382            schema: BTreeMap::new(),
383            secrets: BTreeMap::new(),
384            meta: String::new(),
385        };
386
387        let mac_empty = compute_mac(&vault);
388
389        vault.secrets.insert(
390            "KEY".into(),
391            types::SecretEntry {
392                shared: "ciphertext".into(),
393                scoped: BTreeMap::new(),
394            },
395        );
396
397        let mac_with_secret = compute_mac(&vault);
398        assert_ne!(mac_empty, mac_with_secret);
399    }
400
401    #[test]
402    fn compute_mac_changes_with_different_recipients() {
403        let mut vault = types::Vault {
404            version: types::VAULT_VERSION.into(),
405            created: "2026-02-28T00:00:00Z".into(),
406            vault_name: ".murk".into(),
407            repo: String::new(),
408            recipients: vec!["age1abc".into()],
409            schema: BTreeMap::new(),
410            secrets: BTreeMap::new(),
411            meta: String::new(),
412        };
413
414        let mac1 = compute_mac(&vault);
415        vault.recipients.push("age1xyz".into());
416        let mac2 = compute_mac(&vault);
417        assert_ne!(mac1, mac2);
418    }
419
420    #[test]
421    fn save_vault_preserves_unchanged_ciphertext() {
422        let (secret, pubkey) = generate_keypair();
423        let recipient = make_recipient(&pubkey);
424        let identity = make_identity(&secret);
425
426        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
427        fs::create_dir_all(&dir).unwrap();
428        let path = dir.join("test.murk");
429
430        let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
431        let mut vault = types::Vault {
432            version: types::VAULT_VERSION.into(),
433            created: "2026-02-28T00:00:00Z".into(),
434            vault_name: ".murk".into(),
435            repo: String::new(),
436            recipients: vec![pubkey.clone()],
437            schema: BTreeMap::new(),
438            secrets: BTreeMap::new(),
439            meta: String::new(),
440        };
441        vault.secrets.insert(
442            "KEY1".into(),
443            types::SecretEntry {
444                shared: shared.clone(),
445                scoped: BTreeMap::new(),
446            },
447        );
448
449        let mut recipients_map = HashMap::new();
450        recipients_map.insert(pubkey.clone(), "alice".into());
451        let original = types::Murk {
452            values: HashMap::from([("KEY1".into(), "original".into())]),
453            recipients: recipients_map.clone(),
454            scoped: HashMap::new(),
455        };
456
457        let current = original.clone();
458        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
459
460        assert_eq!(vault.secrets["KEY1"].shared, shared);
461
462        let mut changed = current.clone();
463        changed.values.insert("KEY1".into(), "modified".into());
464        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
465
466        assert_ne!(vault.secrets["KEY1"].shared, shared);
467
468        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
469        assert_eq!(decrypted, b"modified");
470
471        fs::remove_dir_all(&dir).unwrap();
472    }
473
474    #[test]
475    fn save_vault_adds_new_secret() {
476        let (_, pubkey) = generate_keypair();
477        let recipient = make_recipient(&pubkey);
478
479        let dir = std::env::temp_dir().join("murk_test_save_add");
480        fs::create_dir_all(&dir).unwrap();
481        let path = dir.join("test.murk");
482
483        let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
484        let mut vault = types::Vault {
485            version: types::VAULT_VERSION.into(),
486            created: "2026-02-28T00:00:00Z".into(),
487            vault_name: ".murk".into(),
488            repo: String::new(),
489            recipients: vec![pubkey.clone()],
490            schema: BTreeMap::new(),
491            secrets: BTreeMap::new(),
492            meta: String::new(),
493        };
494        vault.secrets.insert(
495            "KEY1".into(),
496            types::SecretEntry {
497                shared,
498                scoped: BTreeMap::new(),
499            },
500        );
501
502        let mut recipients_map = HashMap::new();
503        recipients_map.insert(pubkey.clone(), "alice".into());
504        let original = types::Murk {
505            values: HashMap::from([("KEY1".into(), "val1".into())]),
506            recipients: recipients_map.clone(),
507            scoped: HashMap::new(),
508        };
509
510        let mut current = original.clone();
511        current.values.insert("KEY2".into(), "val2".into());
512
513        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
514
515        assert!(vault.secrets.contains_key("KEY1"));
516        assert!(vault.secrets.contains_key("KEY2"));
517
518        fs::remove_dir_all(&dir).unwrap();
519    }
520
521    #[test]
522    fn save_vault_removes_deleted_secret() {
523        let (_, pubkey) = generate_keypair();
524        let recipient = make_recipient(&pubkey);
525
526        let dir = std::env::temp_dir().join("murk_test_save_remove");
527        fs::create_dir_all(&dir).unwrap();
528        let path = dir.join("test.murk");
529
530        let mut vault = types::Vault {
531            version: types::VAULT_VERSION.into(),
532            created: "2026-02-28T00:00:00Z".into(),
533            vault_name: ".murk".into(),
534            repo: String::new(),
535            recipients: vec![pubkey.clone()],
536            schema: BTreeMap::new(),
537            secrets: BTreeMap::new(),
538            meta: String::new(),
539        };
540        vault.secrets.insert(
541            "KEY1".into(),
542            types::SecretEntry {
543                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
544                scoped: BTreeMap::new(),
545            },
546        );
547        vault.secrets.insert(
548            "KEY2".into(),
549            types::SecretEntry {
550                shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
551                scoped: BTreeMap::new(),
552            },
553        );
554
555        let mut recipients_map = HashMap::new();
556        recipients_map.insert(pubkey.clone(), "alice".into());
557        let original = types::Murk {
558            values: HashMap::from([
559                ("KEY1".into(), "val1".into()),
560                ("KEY2".into(), "val2".into()),
561            ]),
562            recipients: recipients_map.clone(),
563            scoped: HashMap::new(),
564        };
565
566        let mut current = original.clone();
567        current.values.remove("KEY2");
568
569        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
570
571        assert!(vault.secrets.contains_key("KEY1"));
572        assert!(!vault.secrets.contains_key("KEY2"));
573
574        fs::remove_dir_all(&dir).unwrap();
575    }
576
577    #[test]
578    fn save_vault_reencrypts_all_on_recipient_change() {
579        let (secret1, pubkey1) = generate_keypair();
580        let (_, pubkey2) = generate_keypair();
581        let recipient1 = make_recipient(&pubkey1);
582
583        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
584        fs::create_dir_all(&dir).unwrap();
585        let path = dir.join("test.murk");
586
587        let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
588        let mut vault = types::Vault {
589            version: types::VAULT_VERSION.into(),
590            created: "2026-02-28T00:00:00Z".into(),
591            vault_name: ".murk".into(),
592            repo: String::new(),
593            recipients: vec![pubkey1.clone(), pubkey2.clone()],
594            schema: BTreeMap::new(),
595            secrets: BTreeMap::new(),
596            meta: String::new(),
597        };
598        vault.secrets.insert(
599            "KEY1".into(),
600            types::SecretEntry {
601                shared: shared.clone(),
602                scoped: BTreeMap::new(),
603            },
604        );
605
606        let mut recipients_map = HashMap::new();
607        recipients_map.insert(pubkey1.clone(), "alice".into());
608        let original = types::Murk {
609            values: HashMap::from([("KEY1".into(), "val1".into())]),
610            recipients: recipients_map,
611            scoped: HashMap::new(),
612        };
613
614        let mut current_recipients = HashMap::new();
615        current_recipients.insert(pubkey1.clone(), "alice".into());
616        current_recipients.insert(pubkey2.clone(), "bob".into());
617        let current = types::Murk {
618            values: HashMap::from([("KEY1".into(), "val1".into())]),
619            recipients: current_recipients,
620            scoped: HashMap::new(),
621        };
622
623        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
624
625        assert_ne!(vault.secrets["KEY1"].shared, shared);
626
627        let identity1 = make_identity(&secret1);
628        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
629        assert_eq!(decrypted, b"val1");
630
631        fs::remove_dir_all(&dir).unwrap();
632    }
633
634    #[test]
635    fn save_vault_scoped_entry_lifecycle() {
636        let (secret, pubkey) = generate_keypair();
637        let recipient = make_recipient(&pubkey);
638        let identity = make_identity(&secret);
639
640        let dir = std::env::temp_dir().join("murk_test_save_scoped");
641        fs::create_dir_all(&dir).unwrap();
642        let path = dir.join("test.murk");
643
644        let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
645        let mut vault = types::Vault {
646            version: types::VAULT_VERSION.into(),
647            created: "2026-02-28T00:00:00Z".into(),
648            vault_name: ".murk".into(),
649            repo: String::new(),
650            recipients: vec![pubkey.clone()],
651            schema: BTreeMap::new(),
652            secrets: BTreeMap::new(),
653            meta: String::new(),
654        };
655        vault.secrets.insert(
656            "KEY1".into(),
657            types::SecretEntry {
658                shared,
659                scoped: BTreeMap::new(),
660            },
661        );
662
663        let mut recipients_map = HashMap::new();
664        recipients_map.insert(pubkey.clone(), "alice".into());
665        let original = types::Murk {
666            values: HashMap::from([("KEY1".into(), "shared_val".into())]),
667            recipients: recipients_map.clone(),
668            scoped: HashMap::new(),
669        };
670
671        // Add a scoped override.
672        let mut current = original.clone();
673        let mut key_scoped = HashMap::new();
674        key_scoped.insert(pubkey.clone(), "my_override".into());
675        current.scoped.insert("KEY1".into(), key_scoped);
676
677        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
678
679        assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
680        let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
681        assert_eq!(scoped_val, b"my_override");
682
683        // Now remove the scoped override.
684        let original_with_scoped = current.clone();
685        let mut current_no_scoped = original_with_scoped.clone();
686        current_no_scoped.scoped.remove("KEY1");
687
688        save_vault(
689            path.to_str().unwrap(),
690            &mut vault,
691            &original_with_scoped,
692            &current_no_scoped,
693        )
694        .unwrap();
695
696        assert!(vault.secrets["KEY1"].scoped.is_empty());
697
698        fs::remove_dir_all(&dir).unwrap();
699    }
700
701    #[test]
702    fn load_vault_validates_mac() {
703        let (secret, pubkey) = generate_keypair();
704        let recipient = make_recipient(&pubkey);
705        let identity = make_identity(&secret);
706
707        let dir = std::env::temp_dir().join("murk_test_load_mac");
708        fs::create_dir_all(&dir).unwrap();
709        let path = dir.join("test.murk");
710
711        // Build a vault with one secret, save it (computes valid MAC).
712        let mut vault = types::Vault {
713            version: types::VAULT_VERSION.into(),
714            created: "2026-02-28T00:00:00Z".into(),
715            vault_name: ".murk".into(),
716            repo: String::new(),
717            recipients: vec![pubkey.clone()],
718            schema: BTreeMap::new(),
719            secrets: BTreeMap::new(),
720            meta: String::new(),
721        };
722        vault.secrets.insert(
723            "KEY1".into(),
724            types::SecretEntry {
725                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
726                scoped: BTreeMap::new(),
727            },
728        );
729
730        let mut recipients_map = HashMap::new();
731        recipients_map.insert(pubkey.clone(), "alice".into());
732        let original = types::Murk {
733            values: HashMap::from([("KEY1".into(), "val1".into())]),
734            recipients: recipients_map,
735            scoped: HashMap::new(),
736        };
737
738        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
739
740        // Now tamper: change the ciphertext in the saved vault file.
741        let mut tampered: types::Vault =
742            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
743        tampered.secrets.get_mut("KEY1").unwrap().shared =
744            encrypt_value(b"tampered", &[recipient]).unwrap();
745        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
746
747        // Load should fail MAC validation.
748        unsafe { std::env::set_var("MURK_KEY", secret) };
749        unsafe { std::env::remove_var("MURK_KEY_FILE") };
750        let result = load_vault(path.to_str().unwrap());
751        unsafe { std::env::remove_var("MURK_KEY") };
752
753        let err = result.err().expect("expected MAC validation to fail");
754        assert!(
755            err.contains("integrity check failed"),
756            "expected integrity check failure, got: {err}"
757        );
758
759        fs::remove_dir_all(&dir).unwrap();
760    }
761
762    #[test]
763    fn load_vault_succeeds_with_valid_mac() {
764        let (secret, pubkey) = generate_keypair();
765        let recipient = make_recipient(&pubkey);
766
767        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
768        fs::create_dir_all(&dir).unwrap();
769        let path = dir.join("test.murk");
770
771        let mut vault = types::Vault {
772            version: types::VAULT_VERSION.into(),
773            created: "2026-02-28T00:00:00Z".into(),
774            vault_name: ".murk".into(),
775            repo: String::new(),
776            recipients: vec![pubkey.clone()],
777            schema: BTreeMap::new(),
778            secrets: BTreeMap::new(),
779            meta: String::new(),
780        };
781        vault.secrets.insert(
782            "KEY1".into(),
783            types::SecretEntry {
784                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
785                scoped: BTreeMap::new(),
786            },
787        );
788
789        let mut recipients_map = HashMap::new();
790        recipients_map.insert(pubkey.clone(), "alice".into());
791        let original = types::Murk {
792            values: HashMap::from([("KEY1".into(), "val1".into())]),
793            recipients: recipients_map,
794            scoped: HashMap::new(),
795        };
796
797        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
798
799        // Load should succeed.
800        unsafe { std::env::set_var("MURK_KEY", secret) };
801        unsafe { std::env::remove_var("MURK_KEY_FILE") };
802        let result = load_vault(path.to_str().unwrap());
803        unsafe { std::env::remove_var("MURK_KEY") };
804
805        assert!(result.is_ok());
806        let (_, murk, _) = result.unwrap();
807        assert_eq!(murk.values["KEY1"], "val1");
808
809        fs::remove_dir_all(&dir).unwrap();
810    }
811
812    #[test]
813    fn load_vault_not_a_recipient() {
814        let (secret, pubkey) = generate_keypair();
815        let (other_secret, other_pubkey) = generate_keypair();
816        let other_recipient = make_recipient(&other_pubkey);
817
818        let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
819        let _ = fs::remove_dir_all(&dir);
820        fs::create_dir_all(&dir).unwrap();
821        let path = dir.join("test.murk");
822
823        // Build a vault encrypted to `other`, not to `secret`.
824        let mut vault = types::Vault {
825            version: types::VAULT_VERSION.into(),
826            created: "2026-02-28T00:00:00Z".into(),
827            vault_name: ".murk".into(),
828            repo: String::new(),
829            recipients: vec![other_pubkey.clone()],
830            schema: BTreeMap::new(),
831            secrets: BTreeMap::new(),
832            meta: String::new(),
833        };
834        vault.secrets.insert(
835            "KEY1".into(),
836            types::SecretEntry {
837                shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
838                scoped: BTreeMap::new(),
839            },
840        );
841
842        // Save via save_vault (needs the other key for re-encryption).
843        let mut recipients_map = HashMap::new();
844        recipients_map.insert(other_pubkey.clone(), "other".into());
845        let original = types::Murk {
846            values: HashMap::from([("KEY1".into(), "val1".into())]),
847            recipients: recipients_map,
848            scoped: HashMap::new(),
849        };
850
851        unsafe { std::env::set_var("MURK_KEY", &other_secret) };
852        unsafe { std::env::remove_var("MURK_KEY_FILE") };
853        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
854
855        // Now try to load with a key that is NOT a recipient.
856        unsafe { std::env::set_var("MURK_KEY", secret) };
857        let result = load_vault(path.to_str().unwrap());
858        unsafe { std::env::remove_var("MURK_KEY") };
859
860        let err = match result {
861            Err(e) => e,
862            Ok(_) => panic!("expected load_vault to fail for non-recipient"),
863        };
864        assert!(
865            err.contains("decryption failed"),
866            "expected decryption failure, got: {err}"
867        );
868
869        fs::remove_dir_all(&dir).unwrap();
870    }
871
872    #[test]
873    fn load_vault_zero_secrets() {
874        let (secret, pubkey) = generate_keypair();
875
876        let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
877        let _ = fs::remove_dir_all(&dir);
878        fs::create_dir_all(&dir).unwrap();
879        let path = dir.join("test.murk");
880
881        // Build a vault with no secrets at all.
882        let mut vault = types::Vault {
883            version: types::VAULT_VERSION.into(),
884            created: "2026-02-28T00:00:00Z".into(),
885            vault_name: ".murk".into(),
886            repo: String::new(),
887            recipients: vec![pubkey.clone()],
888            schema: BTreeMap::new(),
889            secrets: BTreeMap::new(),
890            meta: String::new(),
891        };
892
893        let mut recipients_map = HashMap::new();
894        recipients_map.insert(pubkey.clone(), "alice".into());
895        let original = types::Murk {
896            values: HashMap::new(),
897            recipients: recipients_map,
898            scoped: HashMap::new(),
899        };
900
901        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
902
903        unsafe { std::env::set_var("MURK_KEY", secret) };
904        unsafe { std::env::remove_var("MURK_KEY_FILE") };
905        let result = load_vault(path.to_str().unwrap());
906        unsafe { std::env::remove_var("MURK_KEY") };
907
908        assert!(result.is_ok());
909        let (_, murk, _) = result.unwrap();
910        assert!(murk.values.is_empty());
911        assert!(murk.scoped.is_empty());
912
913        fs::remove_dir_all(&dir).unwrap();
914    }
915
916    #[test]
917    fn load_vault_stripped_meta_with_secrets_fails() {
918        let (secret, pubkey) = generate_keypair();
919        let recipient = make_recipient(&pubkey);
920
921        let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
922        let _ = fs::remove_dir_all(&dir);
923        fs::create_dir_all(&dir).unwrap();
924        let path = dir.join("test.murk");
925
926        // Build a vault with one secret and a valid MAC via save_vault.
927        let mut vault = types::Vault {
928            version: types::VAULT_VERSION.into(),
929            created: "2026-02-28T00:00:00Z".into(),
930            vault_name: ".murk".into(),
931            repo: String::new(),
932            recipients: vec![pubkey.clone()],
933            schema: BTreeMap::new(),
934            secrets: BTreeMap::new(),
935            meta: String::new(),
936        };
937        vault.secrets.insert(
938            "KEY1".into(),
939            types::SecretEntry {
940                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
941                scoped: BTreeMap::new(),
942            },
943        );
944
945        let mut recipients_map = HashMap::new();
946        recipients_map.insert(pubkey.clone(), "alice".into());
947        let original = types::Murk {
948            values: HashMap::from([("KEY1".into(), "val1".into())]),
949            recipients: recipients_map,
950            scoped: HashMap::new(),
951        };
952
953        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
954
955        // Tamper: strip meta field entirely.
956        let mut tampered: types::Vault =
957            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
958        tampered.meta = String::new();
959        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
960
961        // Load should fail: secrets present but no meta.
962        unsafe { std::env::set_var("MURK_KEY", &secret) };
963        unsafe { std::env::remove_var("MURK_KEY_FILE") };
964        let result = load_vault(path.to_str().unwrap());
965        unsafe { std::env::remove_var("MURK_KEY") };
966
967        let err = result.err().expect("expected MAC validation to fail");
968        assert!(
969            err.contains("integrity check failed"),
970            "expected integrity check failure, got: {err}"
971        );
972
973        fs::remove_dir_all(&dir).unwrap();
974    }
975
976    #[test]
977    fn load_vault_empty_mac_with_secrets_fails() {
978        let (secret, pubkey) = generate_keypair();
979        let recipient = make_recipient(&pubkey);
980
981        let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
982        let _ = fs::remove_dir_all(&dir);
983        fs::create_dir_all(&dir).unwrap();
984        let path = dir.join("test.murk");
985
986        // Build a vault with one secret.
987        let mut vault = types::Vault {
988            version: types::VAULT_VERSION.into(),
989            created: "2026-02-28T00:00:00Z".into(),
990            vault_name: ".murk".into(),
991            repo: String::new(),
992            recipients: vec![pubkey.clone()],
993            schema: BTreeMap::new(),
994            secrets: BTreeMap::new(),
995            meta: String::new(),
996        };
997        vault.secrets.insert(
998            "KEY1".into(),
999            types::SecretEntry {
1000                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1001                scoped: BTreeMap::new(),
1002            },
1003        );
1004
1005        // Manually create meta with empty MAC and encrypt it.
1006        let mut recipients_map = HashMap::new();
1007        recipients_map.insert(pubkey.clone(), "alice".into());
1008        let meta = types::Meta {
1009            recipients: recipients_map,
1010            mac: String::new(),
1011        };
1012        let meta_json = serde_json::to_vec(&meta).unwrap();
1013        vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1014
1015        // Write the vault to disk.
1016        crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1017
1018        // Load should fail: secrets present but MAC is empty.
1019        unsafe { std::env::set_var("MURK_KEY", &secret) };
1020        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1021        let result = load_vault(path.to_str().unwrap());
1022        unsafe { std::env::remove_var("MURK_KEY") };
1023
1024        let err = result.err().expect("expected MAC validation to fail");
1025        assert!(
1026            err.contains("integrity check failed"),
1027            "expected integrity check failure, got: {err}"
1028        );
1029
1030        fs::remove_dir_all(&dir).unwrap();
1031    }
1032
1033    #[test]
1034    fn now_utc_format() {
1035        let ts = now_utc();
1036        assert!(ts.ends_with('Z'));
1037        assert_eq!(ts.len(), 20);
1038        assert_eq!(&ts[4..5], "-");
1039        assert_eq!(&ts[7..8], "-");
1040        assert_eq!(&ts[10..11], "T");
1041    }
1042}