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