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