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