Skip to main content

murk_cli/
lib.rs

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