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, dotenv_has_murk_key, key_file_path, parse_env, read_key_from_dotenv, resolve_key,
48    resolve_key_for_vault, warn_env_permissions, write_envrc, write_key_ref_to_dotenv,
49    write_key_to_dotenv, write_key_to_file,
50};
51pub use error::MurkError;
52pub use export::{
53    DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
54    parse_and_decrypt_values, resolve_secrets,
55};
56pub use git::{MergeDriverSetupStep, setup_merge_driver};
57pub use github::{GitHubError, fetch_keys};
58pub use info::{InfoEntry, VaultInfo, format_info_lines, vault_info};
59pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
60pub use merge::{MergeDriverOutput, run_merge_driver};
61pub use recipients::{
62    RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
63    list_recipients, revoke_recipient, truncate_pubkey,
64};
65pub use secrets::{add_secret, describe_key, get_secret, import_secrets, list_keys, remove_secret};
66
67use std::collections::{BTreeMap, BTreeSet, HashMap};
68use std::path::Path;
69
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, and recipients.
319///
320/// If an HMAC key is provided, uses BLAKE3 keyed hash (written as `blake3:`).
321/// Otherwise falls back to unkeyed SHA-256 v2 for legacy compatibility.
322pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String {
323    match mac_key {
324        Some(key) => compute_mac_v3(vault, key),
325        None => compute_mac_v2(vault),
326    }
327}
328
329/// Legacy MAC: covers key names, shared ciphertext, and recipients (no scoped).
330fn compute_mac_v1(vault: &types::Vault) -> String {
331    use sha2::{Digest, Sha256};
332
333    let mut hasher = Sha256::new();
334
335    for key in vault.secrets.keys() {
336        hasher.update(key.as_bytes());
337        hasher.update(b"\x00");
338    }
339
340    for entry in vault.secrets.values() {
341        hasher.update(entry.shared.as_bytes());
342        hasher.update(b"\x00");
343    }
344
345    let mut pks = vault.recipients.clone();
346    pks.sort();
347    for pk in &pks {
348        hasher.update(pk.as_bytes());
349        hasher.update(b"\x00");
350    }
351
352    let digest = hasher.finalize();
353    format!(
354        "sha256:{}",
355        digest.iter().fold(String::new(), |mut s, b| {
356            use std::fmt::Write;
357            let _ = write!(s, "{b:02x}");
358            s
359        })
360    )
361}
362
363/// V2 MAC: covers key names, shared ciphertext, scoped entries, and recipients.
364fn compute_mac_v2(vault: &types::Vault) -> String {
365    use sha2::{Digest, Sha256};
366
367    let mut hasher = Sha256::new();
368
369    // Hash sorted key names.
370    for key in vault.secrets.keys() {
371        hasher.update(key.as_bytes());
372        hasher.update(b"\x00");
373    }
374
375    // Hash encrypted shared values (as stored).
376    for entry in vault.secrets.values() {
377        hasher.update(entry.shared.as_bytes());
378        hasher.update(b"\x00");
379
380        // Hash scoped entries (sorted by pubkey for determinism).
381        let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
382        scoped_pks.sort();
383        for pk in scoped_pks {
384            hasher.update(pk.as_bytes());
385            hasher.update(b"\x01");
386            hasher.update(entry.scoped[pk].as_bytes());
387            hasher.update(b"\x00");
388        }
389    }
390
391    // Hash sorted recipient pubkeys.
392    let mut pks = vault.recipients.clone();
393    pks.sort();
394    for pk in &pks {
395        hasher.update(pk.as_bytes());
396        hasher.update(b"\x00");
397    }
398
399    let digest = hasher.finalize();
400    format!(
401        "sha256v2:{}",
402        digest.iter().fold(String::new(), |mut s, b| {
403            use std::fmt::Write;
404            let _ = write!(s, "{b:02x}");
405            s
406        })
407    )
408}
409
410/// V3 MAC: BLAKE3 keyed hash over the same inputs as v2.
411fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
412    let mut data = Vec::new();
413
414    for key_name in vault.secrets.keys() {
415        data.extend_from_slice(key_name.as_bytes());
416        data.push(0x00);
417    }
418
419    for entry in vault.secrets.values() {
420        data.extend_from_slice(entry.shared.as_bytes());
421        data.push(0x00);
422
423        let mut scoped_pks: Vec<&String> = entry.scoped.keys().collect();
424        scoped_pks.sort();
425        for pk in scoped_pks {
426            data.extend_from_slice(pk.as_bytes());
427            data.push(0x01);
428            data.extend_from_slice(entry.scoped[pk].as_bytes());
429            data.push(0x00);
430        }
431    }
432
433    let mut pks = vault.recipients.clone();
434    pks.sort();
435    for pk in &pks {
436        data.extend_from_slice(pk.as_bytes());
437        data.push(0x00);
438    }
439
440    let hash = blake3::keyed_hash(key, &data);
441    format!("blake3:{hash}")
442}
443
444/// Verify a stored MAC against the vault, accepting v1, v2, and blake3 schemes.
445pub(crate) fn verify_mac(
446    vault: &types::Vault,
447    stored_mac: &str,
448    mac_key: Option<&[u8; 32]>,
449) -> bool {
450    use constant_time_eq::constant_time_eq;
451
452    let expected = if stored_mac.starts_with("blake3:") {
453        match mac_key {
454            Some(key) => compute_mac_v3(vault, key),
455            None => return false,
456        }
457    } else if stored_mac.starts_with("sha256v2:") {
458        compute_mac_v2(vault)
459    } else if stored_mac.starts_with("sha256:") {
460        compute_mac_v1(vault)
461    } else {
462        return false;
463    };
464    constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
465}
466
467/// Generate a random 32-byte BLAKE3 MAC key, returned as hex.
468pub(crate) fn generate_mac_key() -> String {
469    let key: [u8; 32] = rand::random();
470    key.iter().fold(String::new(), |mut s, b| {
471        use std::fmt::Write;
472        let _ = write!(s, "{b:02x}");
473        s
474    })
475}
476
477/// Decode a hex-encoded 32-byte key.
478pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
479    if hex.len() != 64 {
480        return None;
481    }
482    let mut key = [0u8; 32];
483    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
484        key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
485    }
486    Some(key)
487}
488
489/// Generate an ISO-8601 UTC timestamp.
490pub(crate) fn now_utc() -> String {
491    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::testutil::*;
498    use std::collections::BTreeMap;
499    use std::fs;
500
501    use crate::testutil::ENV_LOCK;
502
503    #[test]
504    fn encrypt_decrypt_value_roundtrip() {
505        let (secret, pubkey) = generate_keypair();
506        let recipient = make_recipient(&pubkey);
507        let identity = make_identity(&secret);
508
509        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
510        let decrypted = decrypt_value(&encoded, &identity).unwrap();
511        assert_eq!(decrypted, b"hello world");
512    }
513
514    #[test]
515    fn decrypt_value_invalid_base64() {
516        let (secret, _) = generate_keypair();
517        let identity = make_identity(&secret);
518
519        let result = decrypt_value("not!valid!base64!!!", &identity);
520        assert!(result.is_err());
521        assert!(result.unwrap_err().to_string().contains("invalid base64"));
522    }
523
524    #[test]
525    fn encrypt_value_multiple_recipients() {
526        let (secret_a, pubkey_a) = generate_keypair();
527        let (secret_b, pubkey_b) = generate_keypair();
528
529        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
530        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
531
532        // Both can decrypt.
533        let id_a = make_identity(&secret_a);
534        let id_b = make_identity(&secret_b);
535        assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
536        assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
537    }
538
539    #[test]
540    fn decrypt_value_wrong_key_fails() {
541        let (_, pubkey) = generate_keypair();
542        let (wrong_secret, _) = generate_keypair();
543
544        let recipient = make_recipient(&pubkey);
545        let wrong_identity = make_identity(&wrong_secret);
546
547        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
548        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
549    }
550
551    #[test]
552    fn compute_mac_deterministic() {
553        let vault = types::Vault {
554            version: types::VAULT_VERSION.into(),
555            created: "2026-02-28T00:00:00Z".into(),
556            vault_name: ".murk".into(),
557            repo: String::new(),
558            recipients: vec!["age1abc".into()],
559            schema: BTreeMap::new(),
560            secrets: BTreeMap::new(),
561            meta: String::new(),
562        };
563
564        let key = [0u8; 32];
565        let mac1 = compute_mac(&vault, Some(&key));
566        let mac2 = compute_mac(&vault, Some(&key));
567        assert_eq!(mac1, mac2);
568        assert!(mac1.starts_with("blake3:"));
569
570        // Without key, falls back to sha256v2
571        let mac_legacy = compute_mac(&vault, None);
572        assert!(mac_legacy.starts_with("sha256v2:"));
573    }
574
575    #[test]
576    fn compute_mac_changes_with_different_secrets() {
577        let mut vault = types::Vault {
578            version: types::VAULT_VERSION.into(),
579            created: "2026-02-28T00:00:00Z".into(),
580            vault_name: ".murk".into(),
581            repo: String::new(),
582            recipients: vec!["age1abc".into()],
583            schema: BTreeMap::new(),
584            secrets: BTreeMap::new(),
585            meta: String::new(),
586        };
587
588        let key = [0u8; 32];
589        let mac_empty = compute_mac(&vault, Some(&key));
590
591        vault.secrets.insert(
592            "KEY".into(),
593            types::SecretEntry {
594                shared: "ciphertext".into(),
595                scoped: BTreeMap::new(),
596            },
597        );
598
599        let mac_with_secret = compute_mac(&vault, Some(&key));
600        assert_ne!(mac_empty, mac_with_secret);
601    }
602
603    #[test]
604    fn compute_mac_changes_with_different_recipients() {
605        let mut vault = types::Vault {
606            version: types::VAULT_VERSION.into(),
607            created: "2026-02-28T00:00:00Z".into(),
608            vault_name: ".murk".into(),
609            repo: String::new(),
610            recipients: vec!["age1abc".into()],
611            schema: BTreeMap::new(),
612            secrets: BTreeMap::new(),
613            meta: String::new(),
614        };
615
616        let key = [0u8; 32];
617        let mac1 = compute_mac(&vault, Some(&key));
618        vault.recipients.push("age1xyz".into());
619        let mac2 = compute_mac(&vault, Some(&key));
620        assert_ne!(mac1, mac2);
621    }
622
623    #[test]
624    fn save_vault_preserves_unchanged_ciphertext() {
625        let (secret, pubkey) = generate_keypair();
626        let recipient = make_recipient(&pubkey);
627        let identity = make_identity(&secret);
628
629        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
630        fs::create_dir_all(&dir).unwrap();
631        let path = dir.join("test.murk");
632
633        let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
634        let mut vault = types::Vault {
635            version: types::VAULT_VERSION.into(),
636            created: "2026-02-28T00:00:00Z".into(),
637            vault_name: ".murk".into(),
638            repo: String::new(),
639            recipients: vec![pubkey.clone()],
640            schema: BTreeMap::new(),
641            secrets: BTreeMap::new(),
642            meta: String::new(),
643        };
644        vault.secrets.insert(
645            "KEY1".into(),
646            types::SecretEntry {
647                shared: shared.clone(),
648                scoped: BTreeMap::new(),
649            },
650        );
651
652        let mut recipients_map = HashMap::new();
653        recipients_map.insert(pubkey.clone(), "alice".into());
654        let original = types::Murk {
655            values: HashMap::from([("KEY1".into(), "original".into())]),
656            recipients: recipients_map.clone(),
657            scoped: HashMap::new(),
658            legacy_mac: false,
659            github_pins: HashMap::new(),
660        };
661
662        let current = original.clone();
663        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
664
665        assert_eq!(vault.secrets["KEY1"].shared, shared);
666
667        let mut changed = current.clone();
668        changed.values.insert("KEY1".into(), "modified".into());
669        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
670
671        assert_ne!(vault.secrets["KEY1"].shared, shared);
672
673        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
674        assert_eq!(decrypted, b"modified");
675
676        fs::remove_dir_all(&dir).unwrap();
677    }
678
679    #[test]
680    fn save_vault_adds_new_secret() {
681        let (_, pubkey) = generate_keypair();
682        let recipient = make_recipient(&pubkey);
683
684        let dir = std::env::temp_dir().join("murk_test_save_add");
685        fs::create_dir_all(&dir).unwrap();
686        let path = dir.join("test.murk");
687
688        let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
689        let mut vault = types::Vault {
690            version: types::VAULT_VERSION.into(),
691            created: "2026-02-28T00:00:00Z".into(),
692            vault_name: ".murk".into(),
693            repo: String::new(),
694            recipients: vec![pubkey.clone()],
695            schema: BTreeMap::new(),
696            secrets: BTreeMap::new(),
697            meta: String::new(),
698        };
699        vault.secrets.insert(
700            "KEY1".into(),
701            types::SecretEntry {
702                shared,
703                scoped: BTreeMap::new(),
704            },
705        );
706
707        let mut recipients_map = HashMap::new();
708        recipients_map.insert(pubkey.clone(), "alice".into());
709        let original = types::Murk {
710            values: HashMap::from([("KEY1".into(), "val1".into())]),
711            recipients: recipients_map.clone(),
712            scoped: HashMap::new(),
713            legacy_mac: false,
714            github_pins: HashMap::new(),
715        };
716
717        let mut current = original.clone();
718        current.values.insert("KEY2".into(), "val2".into());
719
720        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
721
722        assert!(vault.secrets.contains_key("KEY1"));
723        assert!(vault.secrets.contains_key("KEY2"));
724
725        fs::remove_dir_all(&dir).unwrap();
726    }
727
728    #[test]
729    fn save_vault_removes_deleted_secret() {
730        let (_, pubkey) = generate_keypair();
731        let recipient = make_recipient(&pubkey);
732
733        let dir = std::env::temp_dir().join("murk_test_save_remove");
734        fs::create_dir_all(&dir).unwrap();
735        let path = dir.join("test.murk");
736
737        let mut vault = types::Vault {
738            version: types::VAULT_VERSION.into(),
739            created: "2026-02-28T00:00:00Z".into(),
740            vault_name: ".murk".into(),
741            repo: String::new(),
742            recipients: vec![pubkey.clone()],
743            schema: BTreeMap::new(),
744            secrets: BTreeMap::new(),
745            meta: String::new(),
746        };
747        vault.secrets.insert(
748            "KEY1".into(),
749            types::SecretEntry {
750                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
751                scoped: BTreeMap::new(),
752            },
753        );
754        vault.secrets.insert(
755            "KEY2".into(),
756            types::SecretEntry {
757                shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
758                scoped: BTreeMap::new(),
759            },
760        );
761
762        let mut recipients_map = HashMap::new();
763        recipients_map.insert(pubkey.clone(), "alice".into());
764        let original = types::Murk {
765            values: HashMap::from([
766                ("KEY1".into(), "val1".into()),
767                ("KEY2".into(), "val2".into()),
768            ]),
769            recipients: recipients_map.clone(),
770            scoped: HashMap::new(),
771            legacy_mac: false,
772            github_pins: HashMap::new(),
773        };
774
775        let mut current = original.clone();
776        current.values.remove("KEY2");
777
778        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
779
780        assert!(vault.secrets.contains_key("KEY1"));
781        assert!(!vault.secrets.contains_key("KEY2"));
782
783        fs::remove_dir_all(&dir).unwrap();
784    }
785
786    #[test]
787    fn save_vault_reencrypts_all_on_recipient_change() {
788        let (secret1, pubkey1) = generate_keypair();
789        let (_, pubkey2) = generate_keypair();
790        let recipient1 = make_recipient(&pubkey1);
791
792        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
793        fs::create_dir_all(&dir).unwrap();
794        let path = dir.join("test.murk");
795
796        let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
797        let mut vault = types::Vault {
798            version: types::VAULT_VERSION.into(),
799            created: "2026-02-28T00:00:00Z".into(),
800            vault_name: ".murk".into(),
801            repo: String::new(),
802            recipients: vec![pubkey1.clone(), pubkey2.clone()],
803            schema: BTreeMap::new(),
804            secrets: BTreeMap::new(),
805            meta: String::new(),
806        };
807        vault.secrets.insert(
808            "KEY1".into(),
809            types::SecretEntry {
810                shared: shared.clone(),
811                scoped: BTreeMap::new(),
812            },
813        );
814
815        let mut recipients_map = HashMap::new();
816        recipients_map.insert(pubkey1.clone(), "alice".into());
817        let original = types::Murk {
818            values: HashMap::from([("KEY1".into(), "val1".into())]),
819            recipients: recipients_map,
820            scoped: HashMap::new(),
821            legacy_mac: false,
822            github_pins: HashMap::new(),
823        };
824
825        let mut current_recipients = HashMap::new();
826        current_recipients.insert(pubkey1.clone(), "alice".into());
827        current_recipients.insert(pubkey2.clone(), "bob".into());
828        let current = types::Murk {
829            values: HashMap::from([("KEY1".into(), "val1".into())]),
830            recipients: current_recipients,
831            scoped: HashMap::new(),
832            legacy_mac: false,
833            github_pins: HashMap::new(),
834        };
835
836        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
837
838        assert_ne!(vault.secrets["KEY1"].shared, shared);
839
840        let identity1 = make_identity(&secret1);
841        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
842        assert_eq!(decrypted, b"val1");
843
844        fs::remove_dir_all(&dir).unwrap();
845    }
846
847    #[test]
848    fn save_vault_scoped_entry_lifecycle() {
849        let (secret, pubkey) = generate_keypair();
850        let recipient = make_recipient(&pubkey);
851        let identity = make_identity(&secret);
852
853        let dir = std::env::temp_dir().join("murk_test_save_scoped");
854        fs::create_dir_all(&dir).unwrap();
855        let path = dir.join("test.murk");
856
857        let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
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,
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(), "shared_val".into())]),
880            recipients: recipients_map.clone(),
881            scoped: HashMap::new(),
882            legacy_mac: false,
883            github_pins: HashMap::new(),
884        };
885
886        // Add a scoped override.
887        let mut current = original.clone();
888        let mut key_scoped = HashMap::new();
889        key_scoped.insert(pubkey.clone(), "my_override".into());
890        current.scoped.insert("KEY1".into(), key_scoped);
891
892        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
893
894        assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
895        let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
896        assert_eq!(scoped_val, b"my_override");
897
898        // Now remove the scoped override.
899        let original_with_scoped = current.clone();
900        let mut current_no_scoped = original_with_scoped.clone();
901        current_no_scoped.scoped.remove("KEY1");
902
903        save_vault(
904            path.to_str().unwrap(),
905            &mut vault,
906            &original_with_scoped,
907            &current_no_scoped,
908        )
909        .unwrap();
910
911        assert!(vault.secrets["KEY1"].scoped.is_empty());
912
913        fs::remove_dir_all(&dir).unwrap();
914    }
915
916    #[test]
917    fn load_vault_validates_mac() {
918        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
919
920        let (secret, pubkey) = generate_keypair();
921        let recipient = make_recipient(&pubkey);
922        let _identity = make_identity(&secret);
923
924        let dir = std::env::temp_dir().join("murk_test_load_mac");
925        let _ = fs::remove_dir_all(&dir);
926        fs::create_dir_all(&dir).unwrap();
927        let path = dir.join("test.murk");
928
929        // Build a vault with one secret, save it (computes valid MAC).
930        let mut vault = types::Vault {
931            version: types::VAULT_VERSION.into(),
932            created: "2026-02-28T00:00:00Z".into(),
933            vault_name: ".murk".into(),
934            repo: String::new(),
935            recipients: vec![pubkey.clone()],
936            schema: BTreeMap::new(),
937            secrets: BTreeMap::new(),
938            meta: String::new(),
939        };
940        vault.secrets.insert(
941            "KEY1".into(),
942            types::SecretEntry {
943                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
944                scoped: BTreeMap::new(),
945            },
946        );
947
948        let mut recipients_map = HashMap::new();
949        recipients_map.insert(pubkey.clone(), "alice".into());
950        let original = types::Murk {
951            values: HashMap::from([("KEY1".into(), "val1".into())]),
952            recipients: recipients_map,
953            scoped: HashMap::new(),
954            legacy_mac: false,
955            github_pins: HashMap::new(),
956        };
957
958        // save_vault needs MURK_KEY set to encrypt meta.
959        unsafe { std::env::set_var("MURK_KEY", &secret) };
960        unsafe { std::env::remove_var("MURK_KEY_FILE") };
961        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
962
963        // Now tamper: change the ciphertext in the saved vault file.
964        let mut tampered: types::Vault =
965            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
966        tampered.secrets.get_mut("KEY1").unwrap().shared =
967            encrypt_value(b"tampered", &[recipient]).unwrap();
968        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
969
970        // Load should fail MAC validation.
971        let result = load_vault(path.to_str().unwrap());
972        unsafe { std::env::remove_var("MURK_KEY") };
973
974        let err = result.err().expect("expected MAC validation to fail");
975        assert!(
976            err.to_string().contains("integrity check failed"),
977            "expected integrity check failure, got: {err}"
978        );
979
980        fs::remove_dir_all(&dir).unwrap();
981    }
982
983    #[test]
984    fn load_vault_succeeds_with_valid_mac() {
985        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
986
987        let (secret, pubkey) = generate_keypair();
988        let recipient = make_recipient(&pubkey);
989
990        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
991        let _ = fs::remove_dir_all(&dir);
992        fs::create_dir_all(&dir).unwrap();
993        let path = dir.join("test.murk");
994
995        let mut vault = types::Vault {
996            version: types::VAULT_VERSION.into(),
997            created: "2026-02-28T00:00:00Z".into(),
998            vault_name: ".murk".into(),
999            repo: String::new(),
1000            recipients: vec![pubkey.clone()],
1001            schema: BTreeMap::new(),
1002            secrets: BTreeMap::new(),
1003            meta: String::new(),
1004        };
1005        vault.secrets.insert(
1006            "KEY1".into(),
1007            types::SecretEntry {
1008                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1009                scoped: BTreeMap::new(),
1010            },
1011        );
1012
1013        let mut recipients_map = HashMap::new();
1014        recipients_map.insert(pubkey.clone(), "alice".into());
1015        let original = types::Murk {
1016            values: HashMap::from([("KEY1".into(), "val1".into())]),
1017            recipients: recipients_map,
1018            scoped: HashMap::new(),
1019            legacy_mac: false,
1020            github_pins: HashMap::new(),
1021        };
1022
1023        unsafe { std::env::set_var("MURK_KEY", &secret) };
1024        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1025        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1026
1027        // Load should succeed.
1028        let result = load_vault(path.to_str().unwrap());
1029        unsafe { std::env::remove_var("MURK_KEY") };
1030
1031        assert!(result.is_ok());
1032        let (_, murk, _) = result.unwrap();
1033        assert_eq!(murk.values["KEY1"], "val1");
1034
1035        fs::remove_dir_all(&dir).unwrap();
1036    }
1037
1038    #[test]
1039    fn load_vault_not_a_recipient() {
1040        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1041
1042        let (secret, _pubkey) = generate_keypair();
1043        let (other_secret, other_pubkey) = generate_keypair();
1044        let other_recipient = make_recipient(&other_pubkey);
1045
1046        let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
1047        let _ = fs::remove_dir_all(&dir);
1048        fs::create_dir_all(&dir).unwrap();
1049        let path = dir.join("test.murk");
1050
1051        // Build a vault encrypted to `other`, not to `secret`.
1052        let mut vault = types::Vault {
1053            version: types::VAULT_VERSION.into(),
1054            created: "2026-02-28T00:00:00Z".into(),
1055            vault_name: ".murk".into(),
1056            repo: String::new(),
1057            recipients: vec![other_pubkey.clone()],
1058            schema: BTreeMap::new(),
1059            secrets: BTreeMap::new(),
1060            meta: String::new(),
1061        };
1062        vault.secrets.insert(
1063            "KEY1".into(),
1064            types::SecretEntry {
1065                shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
1066                scoped: BTreeMap::new(),
1067            },
1068        );
1069
1070        // Save via save_vault (needs the other key for re-encryption).
1071        let mut recipients_map = HashMap::new();
1072        recipients_map.insert(other_pubkey.clone(), "other".into());
1073        let original = types::Murk {
1074            values: HashMap::from([("KEY1".into(), "val1".into())]),
1075            recipients: recipients_map,
1076            scoped: HashMap::new(),
1077            legacy_mac: false,
1078            github_pins: HashMap::new(),
1079        };
1080
1081        unsafe { std::env::set_var("MURK_KEY", &other_secret) };
1082        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1083        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1084
1085        // Now try to load with a key that is NOT a recipient.
1086        unsafe { std::env::set_var("MURK_KEY", secret) };
1087        let result = load_vault(path.to_str().unwrap());
1088        unsafe { std::env::remove_var("MURK_KEY") };
1089
1090        let err = match result {
1091            Err(e) => e,
1092            Ok(_) => panic!("expected load_vault to fail for non-recipient"),
1093        };
1094        // Non-recipient can't decrypt meta, so integrity check fails first.
1095        let msg = err.to_string();
1096        assert!(
1097            msg.contains("decryption failed")
1098                || msg.contains("no meta")
1099                || msg.contains("tampered"),
1100            "expected decryption or integrity failure, got: {err}"
1101        );
1102
1103        fs::remove_dir_all(&dir).unwrap();
1104    }
1105
1106    #[test]
1107    fn load_vault_zero_secrets() {
1108        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1109
1110        let (secret, pubkey) = generate_keypair();
1111
1112        let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
1113        let _ = fs::remove_dir_all(&dir);
1114        fs::create_dir_all(&dir).unwrap();
1115        let path = dir.join("test.murk");
1116
1117        // Build a vault with no secrets at all.
1118        let mut vault = types::Vault {
1119            version: types::VAULT_VERSION.into(),
1120            created: "2026-02-28T00:00:00Z".into(),
1121            vault_name: ".murk".into(),
1122            repo: String::new(),
1123            recipients: vec![pubkey.clone()],
1124            schema: BTreeMap::new(),
1125            secrets: BTreeMap::new(),
1126            meta: String::new(),
1127        };
1128
1129        let mut recipients_map = HashMap::new();
1130        recipients_map.insert(pubkey.clone(), "alice".into());
1131        let original = types::Murk {
1132            values: HashMap::new(),
1133            recipients: recipients_map,
1134            scoped: HashMap::new(),
1135            legacy_mac: false,
1136            github_pins: HashMap::new(),
1137        };
1138
1139        unsafe { std::env::set_var("MURK_KEY", &secret) };
1140        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1141        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1142
1143        let result = load_vault(path.to_str().unwrap());
1144        unsafe { std::env::remove_var("MURK_KEY") };
1145
1146        assert!(result.is_ok());
1147        let (_, murk, _) = result.unwrap();
1148        assert!(murk.values.is_empty());
1149        assert!(murk.scoped.is_empty());
1150
1151        fs::remove_dir_all(&dir).unwrap();
1152    }
1153
1154    #[test]
1155    fn load_vault_stripped_meta_with_secrets_fails() {
1156        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1157
1158        let (secret, pubkey) = generate_keypair();
1159        let recipient = make_recipient(&pubkey);
1160
1161        let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
1162        let _ = fs::remove_dir_all(&dir);
1163        fs::create_dir_all(&dir).unwrap();
1164        let path = dir.join("test.murk");
1165
1166        // Build a vault with one secret and a valid MAC via save_vault.
1167        let mut vault = types::Vault {
1168            version: types::VAULT_VERSION.into(),
1169            created: "2026-02-28T00:00:00Z".into(),
1170            vault_name: ".murk".into(),
1171            repo: String::new(),
1172            recipients: vec![pubkey.clone()],
1173            schema: BTreeMap::new(),
1174            secrets: BTreeMap::new(),
1175            meta: String::new(),
1176        };
1177        vault.secrets.insert(
1178            "KEY1".into(),
1179            types::SecretEntry {
1180                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1181                scoped: BTreeMap::new(),
1182            },
1183        );
1184
1185        let mut recipients_map = HashMap::new();
1186        recipients_map.insert(pubkey.clone(), "alice".into());
1187        let original = types::Murk {
1188            values: HashMap::from([("KEY1".into(), "val1".into())]),
1189            recipients: recipients_map,
1190            scoped: HashMap::new(),
1191            legacy_mac: false,
1192            github_pins: HashMap::new(),
1193        };
1194
1195        unsafe { std::env::set_var("MURK_KEY", &secret) };
1196        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1197        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1198
1199        // Tamper: strip meta field entirely.
1200        let mut tampered: types::Vault =
1201            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1202        tampered.meta = String::new();
1203        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1204
1205        // Load should fail: secrets present but no meta.
1206        let result = load_vault(path.to_str().unwrap());
1207        unsafe { std::env::remove_var("MURK_KEY") };
1208
1209        let err = result.err().expect("expected MAC validation to fail");
1210        assert!(
1211            err.to_string().contains("integrity check failed"),
1212            "expected integrity check failure, got: {err}"
1213        );
1214
1215        fs::remove_dir_all(&dir).unwrap();
1216    }
1217
1218    #[test]
1219    fn load_vault_empty_mac_with_secrets_fails() {
1220        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1221
1222        let (secret, pubkey) = generate_keypair();
1223        let recipient = make_recipient(&pubkey);
1224
1225        let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
1226        let _ = fs::remove_dir_all(&dir);
1227        fs::create_dir_all(&dir).unwrap();
1228        let path = dir.join("test.murk");
1229
1230        // Build a vault with one secret.
1231        let mut vault = types::Vault {
1232            version: types::VAULT_VERSION.into(),
1233            created: "2026-02-28T00:00:00Z".into(),
1234            vault_name: ".murk".into(),
1235            repo: String::new(),
1236            recipients: vec![pubkey.clone()],
1237            schema: BTreeMap::new(),
1238            secrets: BTreeMap::new(),
1239            meta: String::new(),
1240        };
1241        vault.secrets.insert(
1242            "KEY1".into(),
1243            types::SecretEntry {
1244                shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
1245                scoped: BTreeMap::new(),
1246            },
1247        );
1248
1249        // Manually create meta with empty MAC and encrypt it.
1250        let mut recipients_map = HashMap::new();
1251        recipients_map.insert(pubkey.clone(), "alice".into());
1252        let meta = types::Meta {
1253            recipients: recipients_map,
1254            mac: String::new(),
1255            mac_key: None,
1256            github_pins: HashMap::new(),
1257        };
1258        let meta_json = serde_json::to_vec(&meta).unwrap();
1259        vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
1260
1261        // Write the vault to disk.
1262        crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
1263
1264        // Load should fail: secrets present but MAC is empty.
1265        unsafe { std::env::set_var("MURK_KEY", &secret) };
1266        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1267        let result = load_vault(path.to_str().unwrap());
1268        unsafe { std::env::remove_var("MURK_KEY") };
1269
1270        let err = result.err().expect("expected MAC validation to fail");
1271        assert!(
1272            err.to_string().contains("integrity check failed"),
1273            "expected integrity check failure, got: {err}"
1274        );
1275
1276        fs::remove_dir_all(&dir).unwrap();
1277    }
1278
1279    #[test]
1280    fn compute_mac_changes_with_scoped_entries() {
1281        let mut vault = types::Vault {
1282            version: types::VAULT_VERSION.into(),
1283            created: "2026-02-28T00:00:00Z".into(),
1284            vault_name: ".murk".into(),
1285            repo: String::new(),
1286            recipients: vec!["age1abc".into()],
1287            schema: BTreeMap::new(),
1288            secrets: BTreeMap::new(),
1289            meta: String::new(),
1290        };
1291
1292        vault.secrets.insert(
1293            "KEY".into(),
1294            types::SecretEntry {
1295                shared: "ciphertext".into(),
1296                scoped: BTreeMap::new(),
1297            },
1298        );
1299
1300        let key = [0u8; 32];
1301        let mac_no_scoped = compute_mac(&vault, Some(&key));
1302
1303        vault
1304            .secrets
1305            .get_mut("KEY")
1306            .unwrap()
1307            .scoped
1308            .insert("age1bob".into(), "scoped-ct".into());
1309
1310        let mac_with_scoped = compute_mac(&vault, Some(&key));
1311        assert_ne!(mac_no_scoped, mac_with_scoped);
1312    }
1313
1314    #[test]
1315    fn verify_mac_accepts_v1_prefix() {
1316        let vault = types::Vault {
1317            version: types::VAULT_VERSION.into(),
1318            created: "2026-02-28T00:00:00Z".into(),
1319            vault_name: ".murk".into(),
1320            repo: String::new(),
1321            recipients: vec!["age1abc".into()],
1322            schema: BTreeMap::new(),
1323            secrets: BTreeMap::new(),
1324            meta: String::new(),
1325        };
1326
1327        let key = [0u8; 32];
1328        let v1_mac = compute_mac_v1(&vault);
1329        let v2_mac = compute_mac_v2(&vault);
1330        let v3_mac = compute_mac_v3(&vault, &key);
1331        assert!(verify_mac(&vault, &v1_mac, None));
1332        assert!(verify_mac(&vault, &v2_mac, None));
1333        assert!(verify_mac(&vault, &v3_mac, Some(&key)));
1334        assert!(!verify_mac(&vault, "sha256:bogus", None));
1335        assert!(!verify_mac(&vault, "blake3:bogus", Some(&key)));
1336        assert!(!verify_mac(&vault, "unknown:prefix", None));
1337    }
1338
1339    #[test]
1340    fn mac_key_roundtrip() {
1341        let hex = generate_mac_key();
1342        assert_eq!(hex.len(), 64);
1343        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1344
1345        let key = decode_mac_key(&hex).expect("valid hex should decode");
1346        // Re-encode and compare.
1347        let rehex = key.iter().fold(String::new(), |mut s, b| {
1348            use std::fmt::Write;
1349            let _ = write!(s, "{b:02x}");
1350            s
1351        });
1352        assert_eq!(hex, rehex);
1353    }
1354
1355    #[test]
1356    fn decode_mac_key_rejects_bad_input() {
1357        assert!(decode_mac_key("").is_none());
1358        assert!(decode_mac_key("tooshort").is_none());
1359        assert!(decode_mac_key(&"zz".repeat(32)).is_none()); // invalid hex
1360        assert!(decode_mac_key(&"aa".repeat(31)).is_none()); // 31 bytes
1361        assert!(decode_mac_key(&"aa".repeat(33)).is_none()); // 33 bytes
1362    }
1363
1364    #[test]
1365    fn blake3_mac_different_key_different_mac() {
1366        let vault = types::Vault {
1367            version: types::VAULT_VERSION.into(),
1368            created: "2026-02-28T00:00:00Z".into(),
1369            vault_name: ".murk".into(),
1370            repo: String::new(),
1371            recipients: vec!["age1abc".into()],
1372            schema: BTreeMap::new(),
1373            secrets: BTreeMap::new(),
1374            meta: String::new(),
1375        };
1376
1377        let key1 = [0u8; 32];
1378        let key2 = [1u8; 32];
1379        let mac1 = compute_mac(&vault, Some(&key1));
1380        let mac2 = compute_mac(&vault, Some(&key2));
1381        assert_ne!(mac1, mac2);
1382    }
1383
1384    #[test]
1385    fn valid_key_names() {
1386        assert!(is_valid_key_name("DATABASE_URL"));
1387        assert!(is_valid_key_name("_PRIVATE"));
1388        assert!(is_valid_key_name("A"));
1389        assert!(is_valid_key_name("key123"));
1390    }
1391
1392    #[test]
1393    fn invalid_key_names() {
1394        assert!(!is_valid_key_name(""));
1395        assert!(!is_valid_key_name("123_START"));
1396        assert!(!is_valid_key_name("KEY-NAME"));
1397        assert!(!is_valid_key_name("KEY NAME"));
1398        assert!(!is_valid_key_name("FOO$(bar)"));
1399        assert!(!is_valid_key_name("KEY=VAL"));
1400    }
1401
1402    #[test]
1403    fn now_utc_format() {
1404        let ts = now_utc();
1405        assert!(ts.ends_with('Z'));
1406        assert_eq!(ts.len(), 20);
1407        assert_eq!(&ts[4..5], "-");
1408        assert_eq!(&ts[7..8], "-");
1409        assert_eq!(&ts[10..11], "T");
1410    }
1411}