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