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