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