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