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