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 agent;
21pub(crate) mod codename;
22pub mod crypto;
23pub mod edit;
24pub(crate) mod env;
25pub mod error;
26pub(crate) mod export;
27pub(crate) mod git;
28pub mod github;
29pub(crate) mod grants;
30pub(crate) mod groups;
31pub mod hardening;
32pub(crate) mod info;
33pub(crate) mod init;
34pub(crate) mod merge;
35pub(crate) mod policy;
36pub(crate) mod recipients;
37pub mod recovery;
38pub mod scan;
39pub(crate) mod secrets;
40pub mod types;
41pub mod vault;
42
43#[cfg(feature = "python")]
44mod python;
45
46// Shared test utilities
47#[cfg(test)]
48pub mod testutil;
49
50// Re-exports: keep the flat murk_cli::foo() API for main.rs
51pub use agent::{AgentPlan, AgentPlanKey, agent_plan, format_agent_plan_text};
52pub use env::{
53    EnvrcStatus, KeySource, agent_key_file_path, agent_keys_dir, dotenv_has_murk_key,
54    key_file_path, parse_env, resolve_key, resolve_key_for_vault, resolve_key_with_source,
55    warn_env_permissions, write_envrc, write_key_ref_to_dotenv, write_key_to_dotenv,
56    write_key_to_file,
57};
58pub use error::MurkError;
59pub use export::{
60    DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
61    parse_and_decrypt_values, resolve_secrets,
62};
63pub use git::{MergeDriverSetupStep, setup_merge_driver};
64pub use github::{GitHubError, fetch_keys};
65pub use grants::{create_grant, parse_ttl, remove_grant, validate_grant_name};
66pub use groups::{
67    add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name,
68};
69pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info};
70pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
71pub use merge::{MergeDriverOutput, run_merge_driver};
72pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity};
73pub use recipients::{
74    RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
75    list_recipients, revoke_recipient, truncate_pubkey,
76};
77pub use secrets::{
78    EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
79    import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
80};
81
82use std::collections::{BTreeMap, BTreeSet, HashMap};
83use std::path::Path;
84
85/// Check whether a key name is a valid shell identifier (safe for `export KEY=...`).
86/// Must start with a letter or underscore, and contain only `[A-Za-z0-9_]`.
87pub fn is_valid_key_name(key: &str) -> bool {
88    !key.is_empty()
89        && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
90        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
91}
92
93use age::secrecy::ExposeSecret;
94use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
95use zeroize::Zeroizing;
96
97// Re-export polymorphic types for consumers.
98pub use crypto::{MurkIdentity, MurkRecipient};
99
100/// Decrypt the meta blob from a vault, returning the deserialized Meta if possible.
101pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
102    if vault.meta.is_empty() {
103        return None;
104    }
105    let plaintext = decrypt_value(&vault.meta, identity).ok()?;
106    serde_json::from_slice(&plaintext).ok()
107}
108
109/// Parse a list of pubkey strings into recipients (age or SSH).
110pub(crate) fn parse_recipients(
111    pubkeys: &[String],
112) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
113    pubkeys
114        .iter()
115        .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
116        .collect()
117}
118
119/// Encrypt a value and return base64-encoded ciphertext.
120pub fn encrypt_value(
121    plaintext: &[u8],
122    recipients: &[crypto::MurkRecipient],
123) -> Result<String, MurkError> {
124    let ciphertext = crypto::encrypt(plaintext, recipients)?;
125    Ok(BASE64.encode(&ciphertext))
126}
127
128/// Decrypt a base64-encoded ciphertext and return plaintext bytes.
129///
130/// The returned buffer is zeroized on drop.
131pub fn decrypt_value(
132    encoded: &str,
133    identity: &crypto::MurkIdentity,
134) -> Result<Zeroizing<Vec<u8>>, MurkError> {
135    let ciphertext = BASE64.decode(encoded).map_err(|e| {
136        MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
137    })?;
138    Ok(crypto::decrypt(&ciphertext, identity)?)
139}
140
141/// Validate decrypted bytes as UTF-8 and return a zeroizing `String`.
142///
143/// The returned `String` and the input `&[u8]` are both zeroized when dropped
144/// (assuming the caller holds the bytes inside a `Zeroizing`), so plaintext
145/// never escapes to a non-zeroed buffer.
146pub(crate) fn plaintext_bytes_to_zeroizing_string(
147    bytes: &[u8],
148) -> Result<Zeroizing<String>, std::str::Utf8Error> {
149    let s = std::str::from_utf8(bytes)?;
150    Ok(Zeroizing::new(s.to_owned()))
151}
152
153/// Read a vault file from disk.
154///
155/// This is a thin wrapper around `vault::read` for a convenient string-path API.
156pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
157    Ok(vault::read(Path::new(vault_path))?)
158}
159
160/// Resolve a vault path argument, walking up parent directories to discover the vault.
161///
162/// Mirrors how git finds `.git` and cargo finds `Cargo.toml`: if the user passed a bare
163/// filename (no path separator, not absolute) and it does not exist in the current
164/// directory, walk up from CWD looking for a file of that name. Stops at:
165///
166/// - a directory containing `.git` (the git root — don't escape the repo)
167/// - `$HOME` (don't traverse into parents of the user's home)
168/// - the filesystem root
169///
170/// If a match is found, returns the absolute path. Otherwise returns the input unchanged,
171/// so downstream error messages still reference what the user asked for.
172///
173/// Explicit paths (absolute, or containing `/` or `\`) are returned unchanged — the user
174/// told us exactly where to look, so don't second-guess them.
175pub fn resolve_vault_path(arg: &str) -> String {
176    use std::path::PathBuf;
177
178    // Explicit path: no traversal.
179    if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
180        return arg.to_string();
181    }
182
183    let Ok(cwd) = std::env::current_dir() else {
184        return arg.to_string();
185    };
186
187    // Found in CWD — nothing to discover.
188    if cwd.join(arg).exists() {
189        return arg.to_string();
190    }
191
192    let home = std::env::var_os("HOME").map(PathBuf::from);
193    let mut dir = cwd.as_path();
194    loop {
195        let candidate = dir.join(arg);
196        if candidate.exists() {
197            return candidate.to_string_lossy().into_owned();
198        }
199        // Stop at git root after checking this directory.
200        if dir.join(".git").exists() {
201            break;
202        }
203        // Stop at $HOME boundary (don't traverse above the user's home).
204        if let Some(ref h) = home
205            && dir == h.as_path()
206        {
207            break;
208        }
209        match dir.parent() {
210            Some(parent) => dir = parent,
211            None => break,
212        }
213    }
214
215    arg.to_string()
216}
217
218/// The non-secret state carried out of the encrypted meta blob after integrity
219/// verification: recipient names, group membership, agent grants, the
220/// legacy-MAC flag, and pinned GitHub fingerprints.
221struct MetaState {
222    recipients: HashMap<String, String>,
223    groups: BTreeMap<String, Vec<String>>,
224    grants: BTreeMap<String, types::GrantEntry>,
225    legacy_mac: bool,
226    github_pins: HashMap<String, Vec<String>>,
227}
228
229/// Decrypt the meta blob and verify the vault's integrity MAC, returning the
230/// recipient/group/grant state. Errors if the vault has secrets but a missing or
231/// invalid MAC — a tampered or inconsistent vault should fail loudly here rather
232/// than surface a misleading decryption error later.
233fn resolve_meta_state(
234    vault: &types::Vault,
235    identity: &crypto::MurkIdentity,
236) -> Result<MetaState, MurkError> {
237    match decrypt_meta(vault, identity) {
238        Some(meta) if !meta.mac.is_empty() => {
239            let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
240            if !verify_mac(
241                vault,
242                &meta.groups,
243                &meta.grants,
244                &meta.mac,
245                mac_key.as_ref(),
246            ) {
247                let expected = compute_mac(vault, &meta.groups, &meta.grants, mac_key.as_ref());
248                return Err(MurkError::Integrity(format!(
249                    "vault may have been tampered with (expected {expected}, got {})",
250                    meta.mac
251                )));
252            }
253            let legacy_mac = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
254            Ok(MetaState {
255                recipients: meta.recipients,
256                groups: meta.groups,
257                grants: meta.grants,
258                legacy_mac,
259                github_pins: meta.github_pins,
260            })
261        }
262        Some(meta) if vault.secrets.is_empty() => Ok(MetaState {
263            recipients: meta.recipients,
264            groups: meta.groups,
265            grants: meta.grants,
266            legacy_mac: false,
267            github_pins: meta.github_pins,
268        }),
269        Some(_) => Err(MurkError::Integrity(
270            "vault has secrets but MAC is empty — vault may have been tampered with".into(),
271        )),
272        None if vault.secrets.is_empty() && vault.meta.is_empty() => Ok(MetaState {
273            recipients: HashMap::new(),
274            groups: BTreeMap::new(),
275            grants: BTreeMap::new(),
276            legacy_mac: false,
277            github_pins: HashMap::new(),
278        }),
279        None => Err(MurkError::Integrity(
280            "vault has secrets but no meta — vault may have been tampered with".into(),
281        )),
282    }
283}
284
285/// Decrypt a vault using the given identity. Verifies integrity, decrypts all
286/// shared and scoped values, and returns the working state.
287///
288/// Use this when you already have a key (e.g. from a Python SDK or test harness).
289/// For the common CLI case where the key comes from the environment, use `load_vault`.
290pub fn decrypt_vault(
291    vault: &types::Vault,
292    identity: &crypto::MurkIdentity,
293) -> Result<types::Murk, MurkError> {
294    let pubkey = identity.pubkey_string()?;
295
296    // Verify integrity BEFORE decrypting secrets — a tampered vault should fail
297    // with an integrity error, not a misleading "you are not a recipient" message.
298    let MetaState {
299        recipients,
300        groups,
301        grants,
302        legacy_mac,
303        github_pins,
304    } = resolve_meta_state(vault, identity)?;
305
306    // An agent grant is a recipient of the meta blob (so it can verify integrity
307    // and read its grant) but is deliberately excluded from the shared "everyone"
308    // layer. Such an identity legitimately cannot decrypt shared ciphertexts, so
309    // it skips them rather than erroring. A normal recipient that fails to decrypt
310    // shared is a genuine problem (a true outsider already failed at meta
311    // decryption above), so it still gets the clear "not a recipient" error.
312    let is_agent = grants.values().any(|g| g.pubkey == pubkey);
313
314    // Decrypt shared values (skip scoped-only entries with empty shared ciphertext).
315    let mut values: HashMap<String, Zeroizing<String>> = HashMap::new();
316    for (key, entry) in &vault.secrets {
317        if entry.shared.is_empty() {
318            continue;
319        }
320        let plaintext = match decrypt_value(&entry.shared, identity) {
321            Ok(plaintext) => plaintext,
322            Err(_) if is_agent => continue,
323            Err(_) => {
324                return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
325                    "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
326                )));
327            }
328        };
329        let value = plaintext_bytes_to_zeroizing_string(&plaintext)
330            .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
331        values.insert(key.clone(), value);
332    }
333
334    // Decrypt our private (per-recipient) overrides — the `me` tier.
335    let mut private: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
336    for (key, entry) in &vault.secrets {
337        if let Some(encoded) = entry.private.get(&pubkey)
338            && let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
339                plaintext_bytes_to_zeroizing_string(&pt)
340                    .map_err(|e| MurkError::Secret(e.to_string()))
341            })
342        {
343            private
344                .entry(key.clone())
345                .or_default()
346                .insert(pubkey.clone(), value);
347        }
348    }
349
350    // Decrypt named-group values we're a member of. age tells us whether our
351    // identity is a recipient, so we just try each group ciphertext and keep the
352    // ones that decrypt — non-members silently fall through.
353    let mut grouped: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
354    for (key, entry) in &vault.secrets {
355        for (group, encoded) in &entry.grouped {
356            if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
357                plaintext_bytes_to_zeroizing_string(&pt)
358                    .map_err(|e| MurkError::Secret(e.to_string()))
359            }) {
360                grouped
361                    .entry(key.clone())
362                    .or_default()
363                    .insert(group.clone(), value);
364            }
365        }
366    }
367
368    Ok(types::Murk {
369        values,
370        recipients,
371        private,
372        grouped,
373        groups,
374        grants,
375        legacy_mac,
376        github_pins,
377    })
378}
379
380/// Resolve the key from the environment, read the vault, and decrypt it.
381///
382/// Convenience wrapper combining `resolve_key` + `read_vault` + `decrypt_vault`.
383pub fn load_vault(
384    vault_path: &str,
385) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
386    let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
387
388    let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
389        MurkError::Key(format!(
390            "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
391        ))
392    })?;
393
394    let vault = read_vault(vault_path)?;
395    let murk = decrypt_vault(&vault, &identity)?;
396
397    Ok((vault, murk, identity))
398}
399
400/// Re-encrypt a key's shared (everyone) ciphertext, reusing the existing one
401/// when the value and recipient set are unchanged (for minimal git diffs).
402fn rebuild_shared(
403    key: &str,
404    vault: &types::Vault,
405    recipients: &[crypto::MurkRecipient],
406    recipients_changed: bool,
407    original: &types::Murk,
408    current: &types::Murk,
409) -> Result<String, MurkError> {
410    let Some(value) = current.values.get(key) else {
411        // Scoped/group-only key — no shared ciphertext.
412        return Ok(String::new());
413    };
414    // Reuse the stored ciphertext when the value and recipient set are unchanged.
415    if !recipients_changed
416        && original.values.get(key) == Some(value)
417        && let Some(existing) = vault.secrets.get(key)
418    {
419        return Ok(existing.shared.clone());
420    }
421    encrypt_value(value.as_bytes(), recipients)
422}
423
424/// Re-encrypt a key's scoped (per-recipient) ciphertexts, keeping unchanged
425/// entries and dropping ones removed since load.
426fn rebuild_private(
427    key: &str,
428    vault: &types::Vault,
429    original: &types::Murk,
430    current: &types::Murk,
431) -> Result<BTreeMap<String, String>, MurkError> {
432    let mut scoped = vault
433        .secrets
434        .get(key)
435        .map(|e| e.private.clone())
436        .unwrap_or_default();
437
438    if let Some(key_scoped) = current.private.get(key) {
439        for (pk, val) in key_scoped {
440            let original_val = original.private.get(key).and_then(|m| m.get(pk));
441            if original_val != Some(val) {
442                let recipient = crypto::parse_recipient(pk)?;
443                scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
444            }
445        }
446    }
447
448    if let Some(orig_key_scoped) = original.private.get(key) {
449        for pk in orig_key_scoped.keys() {
450            let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
451            if !still_present {
452                scoped.remove(pk);
453            }
454        }
455    }
456
457    Ok(scoped)
458}
459
460/// Re-encrypt a key's named-group ciphertexts to each group's current members.
461/// Re-encrypts when the value changed or the group's membership changed; drops
462/// groups removed since load.
463fn rebuild_grouped(
464    key: &str,
465    vault: &types::Vault,
466    changed_groups: &BTreeSet<&str>,
467    original: &types::Murk,
468    current: &types::Murk,
469) -> Result<BTreeMap<String, String>, MurkError> {
470    let mut grouped = vault
471        .secrets
472        .get(key)
473        .map(|e| e.grouped.clone())
474        .unwrap_or_default();
475
476    if let Some(key_grouped) = current.grouped.get(key) {
477        for (group, val) in key_grouped {
478            let members = current.groups.get(group).ok_or_else(|| {
479                MurkError::Secret(format!("secret {key} references unknown group {group}"))
480            })?;
481            let original_val = original.grouped.get(key).and_then(|m| m.get(group));
482            if original_val != Some(val) || changed_groups.contains(group.as_str()) {
483                let group_recipients = parse_recipients(members)?;
484                grouped.insert(
485                    group.clone(),
486                    encrypt_value(val.as_bytes(), &group_recipients)?,
487                );
488            }
489        }
490    }
491
492    if let Some(orig_key_grouped) = original.grouped.get(key) {
493        for group in orig_key_grouped.keys() {
494            let still_present = current
495                .grouped
496                .get(key)
497                .is_some_and(|m| m.contains_key(group));
498            if !still_present {
499                grouped.remove(group);
500            }
501        }
502    }
503
504    Ok(grouped)
505}
506
507/// Keep each active grant's private copy of `key` in sync with the key's current
508/// shared value. A grant stages a per-agent private copy at grant time; without
509/// this, rotating a granted key would leave the agent reading the stale value
510/// (the operator can't see the agent's ciphertext to re-encrypt it, and
511/// `rebuild_private` preserves it as-is). When the value changed since load and
512/// the operator can read it, re-encrypt the agent's copy; unchanged values keep
513/// their preserved ciphertext (no churn), and keys the operator can't read are
514/// left untouched.
515fn resync_grant_private(
516    key: &str,
517    private: &mut BTreeMap<String, String>,
518    original: &types::Murk,
519    current: &types::Murk,
520) -> Result<(), MurkError> {
521    let Some(value) = current.values.get(key) else {
522        return Ok(());
523    };
524    if original.values.get(key) == Some(value) {
525        return Ok(());
526    }
527    for grant in current.grants.values() {
528        if grant.scope.iter().any(|k| k == key) {
529            let recipient = crypto::parse_recipient(&grant.pubkey)?;
530            private.insert(
531                grant.pubkey.clone(),
532                encrypt_value(value.as_bytes(), &[recipient])?,
533            );
534        }
535    }
536    Ok(())
537}
538
539/// Save the vault: compare against original state and only re-encrypt changed values.
540/// Unchanged values keep their original ciphertext for minimal git diffs.
541pub fn save_vault(
542    vault_path: &str,
543    vault: &mut types::Vault,
544    original: &types::Murk,
545    current: &types::Murk,
546) -> Result<(), MurkError> {
547    // The full recipient set encrypts the meta blob, so every recipient —
548    // including agent grants — can verify integrity and read group/grant state.
549    let recipients = parse_recipients(&vault.recipients)?;
550
551    // Agent grant pubkeys are deliberately excluded from the shared "everyone"
552    // layer: a granted agent must read only the scoped values granted to it, not
553    // every shared secret. They remain meta recipients (above) but never receive
554    // the shared ciphertext.
555    let grant_pubkeys: BTreeSet<&str> =
556        current.grants.values().map(|g| g.pubkey.as_str()).collect();
557    let shared_recipients: Vec<crypto::MurkRecipient> = vault
558        .recipients
559        .iter()
560        .filter(|pk| !grant_pubkeys.contains(pk.as_str()))
561        .map(|pk| crypto::parse_recipient(pk))
562        .collect::<Result<_, _>>()?;
563
564    // Check if the *shared* recipient set (recipients minus agent grants) changed
565    // — that forces full re-encryption of shared values. Adding or removing an
566    // agent doesn't change this set, so it doesn't needlessly churn shared
567    // ciphertext (and never pulls an agent into the shared layer).
568    let shared_recipients_changed = {
569        let orig_grant_pubkeys: BTreeSet<&str> = original
570            .grants
571            .values()
572            .map(|g| g.pubkey.as_str())
573            .collect();
574        let mut current_pks: Vec<&str> = vault
575            .recipients
576            .iter()
577            .map(String::as_str)
578            .filter(|pk| !grant_pubkeys.contains(pk))
579            .collect();
580        let mut original_pks: Vec<&str> = original
581            .recipients
582            .keys()
583            .map(String::as_str)
584            .filter(|pk| !orig_grant_pubkeys.contains(pk))
585            .collect();
586        current_pks.sort_unstable();
587        original_pks.sort_unstable();
588        current_pks != original_pks
589    };
590
591    // Groups whose membership changed since load — their secrets must be
592    // re-encrypted even when the plaintext is unchanged, so a removed member
593    // loses access (and a new one gains it).
594    let changed_groups: BTreeSet<&str> = current
595        .groups
596        .keys()
597        .chain(original.groups.keys())
598        .filter(|g| current.groups.get(*g) != original.groups.get(*g))
599        .map(String::as_str)
600        .collect();
601
602    let mut new_secrets = BTreeMap::new();
603
604    // Collect all keys with a shared, scoped, or grouped value in the operator's
605    // working state.
606    let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
607    all_keys.extend(current.private.keys());
608    all_keys.extend(current.grouped.keys());
609
610    // Preserve on-disk secrets the operator can't see (other groups' values, or
611    // other recipients' scoped entries). These never enter the decrypted `Murk`,
612    // so without this they'd be silently dropped when a non-member saves. A key
613    // the operator *deleted* was visible at load (in `original`) and is excluded,
614    // so deletions still take effect.
615    let original_visible: BTreeSet<&String> = original
616        .values
617        .keys()
618        .chain(original.private.keys())
619        .chain(original.grouped.keys())
620        .collect();
621    for key in vault.secrets.keys() {
622        if !original_visible.contains(key) {
623            all_keys.insert(key);
624        }
625    }
626
627    for key in all_keys {
628        let shared = rebuild_shared(
629            key,
630            vault,
631            &shared_recipients,
632            shared_recipients_changed,
633            original,
634            current,
635        )?;
636        let mut private = rebuild_private(key, vault, original, current)?;
637        resync_grant_private(key, &mut private, original, current)?;
638        let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
639        new_secrets.insert(
640            key.clone(),
641            types::SecretEntry {
642                shared,
643                private,
644                grouped,
645            },
646        );
647    }
648
649    vault.secrets = new_secrets;
650
651    // Update meta — always generate a fresh BLAKE3 key on save.
652    let mac_key_hex = generate_mac_key();
653    let mac_key = decode_mac_key(&mac_key_hex).unwrap();
654    let mac = compute_mac(vault, &current.groups, &current.grants, Some(&mac_key));
655    let meta = types::Meta {
656        recipients: current.recipients.clone(),
657        mac,
658        mac_key: Some(mac_key_hex),
659        github_pins: current.github_pins.clone(),
660        groups: current.groups.clone(),
661        grants: current.grants.clone(),
662    };
663    let meta_json =
664        serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
665    vault.meta = encrypt_value(&meta_json, &recipients)?;
666
667    Ok(vault::write(Path::new(vault_path), vault)?)
668}
669
670/// Compute an integrity MAC over the vault's secrets, scoped entries, grouped
671/// entries, recipients, schema, and group membership.
672///
673/// With a key and at least one group, uses BLAKE3 keyed hash v6 (`blake3v4:`),
674/// which additionally covers the grouped ciphertexts and group definitions. With
675/// a key and no groups, uses v5 (`blake3v3:`) so group-free vaults stay
676/// byte-identical to before groups existed. Without a key, falls back to unkeyed
677/// SHA-256 v2 for legacy compatibility.
678pub(crate) fn compute_mac(
679    vault: &types::Vault,
680    groups: &BTreeMap<String, Vec<String>>,
681    grants: &BTreeMap<String, types::GrantEntry>,
682    mac_key: Option<&[u8; 32]>,
683) -> String {
684    match mac_key {
685        Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
686            compute_mac_v9(vault, groups, grants, key)
687        }
688        Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
689        Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
690        Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
691        Some(key) => compute_mac_v5(vault, key),
692        None => compute_mac_v2(vault),
693    }
694}
695
696/// Legacy MAC: covers key names, shared ciphertext, and recipients (no scoped).
697fn compute_mac_v1(vault: &types::Vault) -> String {
698    use sha2::{Digest, Sha256};
699
700    let mut hasher = Sha256::new();
701
702    for key in vault.secrets.keys() {
703        hasher.update(key.as_bytes());
704        hasher.update(b"\x00");
705    }
706
707    for entry in vault.secrets.values() {
708        hasher.update(entry.shared.as_bytes());
709        hasher.update(b"\x00");
710    }
711
712    let mut pks = vault.recipients.clone();
713    pks.sort();
714    for pk in &pks {
715        hasher.update(pk.as_bytes());
716        hasher.update(b"\x00");
717    }
718
719    let digest = hasher.finalize();
720    format!(
721        "sha256:{}",
722        digest.iter().fold(String::new(), |mut s, b| {
723            use std::fmt::Write;
724            let _ = write!(s, "{b:02x}");
725            s
726        })
727    )
728}
729
730/// V2 MAC: covers key names, shared ciphertext, scoped entries, and recipients.
731fn compute_mac_v2(vault: &types::Vault) -> String {
732    use sha2::{Digest, Sha256};
733
734    let mut hasher = Sha256::new();
735
736    // Hash sorted key names.
737    for key in vault.secrets.keys() {
738        hasher.update(key.as_bytes());
739        hasher.update(b"\x00");
740    }
741
742    // Hash encrypted shared values (as stored).
743    for entry in vault.secrets.values() {
744        hasher.update(entry.shared.as_bytes());
745        hasher.update(b"\x00");
746
747        // Hash scoped entries (sorted by pubkey for determinism).
748        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
749        scoped_pks.sort();
750        for pk in scoped_pks {
751            hasher.update(pk.as_bytes());
752            hasher.update(b"\x01");
753            hasher.update(entry.private[pk].as_bytes());
754            hasher.update(b"\x00");
755        }
756    }
757
758    // Hash sorted recipient pubkeys.
759    let mut pks = vault.recipients.clone();
760    pks.sort();
761    for pk in &pks {
762        hasher.update(pk.as_bytes());
763        hasher.update(b"\x00");
764    }
765
766    let digest = hasher.finalize();
767    format!(
768        "sha256v2:{}",
769        digest.iter().fold(String::new(), |mut s, b| {
770            use std::fmt::Write;
771            let _ = write!(s, "{b:02x}");
772            s
773        })
774    )
775}
776
777/// V3 MAC: BLAKE3 keyed hash over the same inputs as v2.
778fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
779    let mut data = Vec::new();
780
781    for key_name in vault.secrets.keys() {
782        data.extend_from_slice(key_name.as_bytes());
783        data.push(0x00);
784    }
785
786    for entry in vault.secrets.values() {
787        data.extend_from_slice(entry.shared.as_bytes());
788        data.push(0x00);
789
790        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
791        scoped_pks.sort();
792        for pk in scoped_pks {
793            data.extend_from_slice(pk.as_bytes());
794            data.push(0x01);
795            data.extend_from_slice(entry.private[pk].as_bytes());
796            data.push(0x00);
797        }
798    }
799
800    let mut pks = vault.recipients.clone();
801    pks.sort();
802    for pk in &pks {
803        data.extend_from_slice(pk.as_bytes());
804        data.push(0x00);
805    }
806
807    let hash = blake3::keyed_hash(key, &data);
808    format!("blake3:{hash}")
809}
810
811/// V4 MAC: BLAKE3 keyed hash over secrets, recipients, AND schema.
812/// Prefix `blake3v2:` distinguishes from v3 which omitted schema.
813fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
814    let mut data = Vec::new();
815
816    for key_name in vault.secrets.keys() {
817        data.extend_from_slice(key_name.as_bytes());
818        data.push(0x00);
819    }
820
821    for entry in vault.secrets.values() {
822        data.extend_from_slice(entry.shared.as_bytes());
823        data.push(0x00);
824
825        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
826        scoped_pks.sort();
827        for pk in scoped_pks {
828            data.extend_from_slice(pk.as_bytes());
829            data.push(0x01);
830            data.extend_from_slice(entry.private[pk].as_bytes());
831            data.push(0x00);
832        }
833    }
834
835    let mut pks = vault.recipients.clone();
836    pks.sort();
837    for pk in &pks {
838        data.extend_from_slice(pk.as_bytes());
839        data.push(0x00);
840    }
841
842    // Schema: include descriptions, examples, and tags for each key.
843    // Uses 0x02 separator to distinguish from secrets/recipients data.
844    for (key_name, entry) in &vault.schema {
845        data.push(0x02);
846        data.extend_from_slice(key_name.as_bytes());
847        data.push(0x00);
848        data.extend_from_slice(entry.description.as_bytes());
849        data.push(0x00);
850        if let Some(example) = &entry.example {
851            data.extend_from_slice(example.as_bytes());
852        }
853        data.push(0x00);
854        for tag in &entry.tags {
855            data.extend_from_slice(tag.as_bytes());
856            data.push(0x00);
857        }
858    }
859
860    let hash = blake3::keyed_hash(key, &data);
861    format!("blake3v2:{hash}")
862}
863
864/// V5 MAC: extends v4 to also cover each schema entry's lifecycle metadata —
865/// `created`, `updated`, `rotation_interval_days`, and `expires_at`. This makes
866/// rotation policy tamper-evident, so strict mode can treat it as a trustworthy
867/// machine-checkable signal rather than freely-editable plaintext. Prefix
868/// `blake3v3:` distinguishes it from v4 which stopped at description/example/tags.
869fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
870    let mut data = Vec::new();
871
872    for key_name in vault.secrets.keys() {
873        data.extend_from_slice(key_name.as_bytes());
874        data.push(0x00);
875    }
876
877    for entry in vault.secrets.values() {
878        data.extend_from_slice(entry.shared.as_bytes());
879        data.push(0x00);
880
881        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
882        scoped_pks.sort();
883        for pk in scoped_pks {
884            data.extend_from_slice(pk.as_bytes());
885            data.push(0x01);
886            data.extend_from_slice(entry.private[pk].as_bytes());
887            data.push(0x00);
888        }
889    }
890
891    let mut pks = vault.recipients.clone();
892    pks.sort();
893    for pk in &pks {
894        data.extend_from_slice(pk.as_bytes());
895        data.push(0x00);
896    }
897
898    // Schema: description, example, tags (as in v4) plus lifecycle metadata.
899    // Optional fields are emitted as their bytes (empty when absent) followed by
900    // a 0x00 terminator, so present/absent stays deterministic. `0x02` separates
901    // each schema entry from the secrets/recipients stream above.
902    for (key_name, entry) in &vault.schema {
903        data.push(0x02);
904        data.extend_from_slice(key_name.as_bytes());
905        data.push(0x00);
906        data.extend_from_slice(entry.description.as_bytes());
907        data.push(0x00);
908        if let Some(example) = &entry.example {
909            data.extend_from_slice(example.as_bytes());
910        }
911        data.push(0x00);
912        for tag in &entry.tags {
913            data.extend_from_slice(tag.as_bytes());
914            data.push(0x00);
915        }
916        // Lifecycle metadata (new in v5). Strings go in as UTF-8; the interval
917        // goes in as its decimal text for consistency with the rest of the stream.
918        if let Some(created) = &entry.created {
919            data.extend_from_slice(created.as_bytes());
920        }
921        data.push(0x00);
922        if let Some(updated) = &entry.updated {
923            data.extend_from_slice(updated.as_bytes());
924        }
925        data.push(0x00);
926        if let Some(days) = entry.rotation_interval_days {
927            data.extend_from_slice(days.to_string().as_bytes());
928        }
929        data.push(0x00);
930        if let Some(expires) = &entry.expires_at {
931            data.extend_from_slice(expires.as_bytes());
932        }
933        data.push(0x00);
934    }
935
936    let hash = blake3::keyed_hash(key, &data);
937    format!("blake3v3:{hash}")
938}
939
940/// Append the v5/v6 schema byte stream to `data`. Kept identical to the inline
941/// loop in `compute_mac_v5` so v6 reuses the exact schema encoding without
942/// risking a change to v5's bytes.
943fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
944    for (key_name, entry) in &vault.schema {
945        data.push(0x02);
946        data.extend_from_slice(key_name.as_bytes());
947        data.push(0x00);
948        data.extend_from_slice(entry.description.as_bytes());
949        data.push(0x00);
950        if let Some(example) = &entry.example {
951            data.extend_from_slice(example.as_bytes());
952        }
953        data.push(0x00);
954        for tag in &entry.tags {
955            data.extend_from_slice(tag.as_bytes());
956            data.push(0x00);
957        }
958        if let Some(created) = &entry.created {
959            data.extend_from_slice(created.as_bytes());
960        }
961        data.push(0x00);
962        if let Some(updated) = &entry.updated {
963            data.extend_from_slice(updated.as_bytes());
964        }
965        data.push(0x00);
966        if let Some(days) = entry.rotation_interval_days {
967            data.extend_from_slice(days.to_string().as_bytes());
968        }
969        data.push(0x00);
970        if let Some(expires) = &entry.expires_at {
971            data.extend_from_slice(expires.as_bytes());
972        }
973        data.push(0x00);
974    }
975}
976
977/// Append the v6 byte stream (secrets, scoped, grouped ciphertexts, recipients,
978/// schema, and group definitions) to `data`. Factored out so v7 can extend the
979/// exact same bytes without risking a change to v6's encoding.
980fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
981    for key_name in vault.secrets.keys() {
982        data.extend_from_slice(key_name.as_bytes());
983        data.push(0x00);
984    }
985
986    for entry in vault.secrets.values() {
987        data.extend_from_slice(entry.shared.as_bytes());
988        data.push(0x00);
989
990        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
991        scoped_pks.sort();
992        for pk in scoped_pks {
993            data.extend_from_slice(pk.as_bytes());
994            data.push(0x01);
995            data.extend_from_slice(entry.private[pk].as_bytes());
996            data.push(0x00);
997        }
998
999        // Grouped ciphertexts, sorted by group name. `0x03` marks each entry so
1000        // the group stream can't be confused with the scoped (`0x01`) stream.
1001        let mut group_names: Vec<&String> = entry.grouped.keys().collect();
1002        group_names.sort();
1003        for g in group_names {
1004            data.push(0x03);
1005            data.extend_from_slice(g.as_bytes());
1006            data.push(0x00);
1007            data.extend_from_slice(entry.grouped[g].as_bytes());
1008            data.push(0x00);
1009        }
1010    }
1011
1012    let mut pks = vault.recipients.clone();
1013    pks.sort();
1014    for pk in &pks {
1015        data.extend_from_slice(pk.as_bytes());
1016        data.push(0x00);
1017    }
1018
1019    schema_mac_bytes(vault, data);
1020
1021    // Group definitions (sorted by name; members sorted). `0x04` separates each
1022    // group, `0x05` each member, so membership can't be tampered with undetected.
1023    for (name, members) in groups {
1024        data.push(0x04);
1025        data.extend_from_slice(name.as_bytes());
1026        data.push(0x00);
1027        let mut sorted = members.clone();
1028        sorted.sort();
1029        for member in &sorted {
1030            data.push(0x05);
1031            data.extend_from_slice(member.as_bytes());
1032        }
1033    }
1034}
1035
1036/// v6 MAC (`blake3v4:`). Extends v5 with the per-secret grouped ciphertexts and
1037/// the group membership map, so a named group's members and the values encrypted
1038/// to them cannot be tampered with undetected. Only emitted once a vault has at
1039/// least one group; group-free vaults keep writing v5 and stay byte-identical.
1040fn compute_mac_v6(
1041    vault: &types::Vault,
1042    groups: &BTreeMap<String, Vec<String>>,
1043    key: &[u8; 32],
1044) -> String {
1045    let mut data = Vec::new();
1046    v6_mac_bytes(vault, groups, &mut data);
1047    let hash = blake3::keyed_hash(key, &data);
1048    format!("blake3v4:{hash}")
1049}
1050
1051/// v7 MAC (`blake3v5:`). Extends v6 with agent grant metadata — each grant's
1052/// name, ephemeral pubkey, sorted scope, issued_at, expires_at, and issuer — so
1053/// a grant's TTL and scope cannot be tampered with undetected. Only emitted once
1054/// a vault has at least one grant; grant-free vaults keep writing v5/v6 and stay
1055/// byte-identical.
1056/// Append the v7 byte stream (v6 bytes plus agent grant metadata) to `data`.
1057/// Factored out so v8 can extend the exact same bytes without risking a change
1058/// to v7's encoding.
1059fn v7_mac_bytes(
1060    vault: &types::Vault,
1061    groups: &BTreeMap<String, Vec<String>>,
1062    grants: &BTreeMap<String, types::GrantEntry>,
1063    data: &mut Vec<u8>,
1064) {
1065    v6_mac_bytes(vault, groups, data);
1066
1067    // Grants (BTreeMap → sorted by name). `0x06` separates each grant; fixed
1068    // fields are 0x00-terminated; each scope key is prefixed `0x07` (sorted), so
1069    // the grant stream can't be confused with the group (`0x04`/`0x05`) stream.
1070    for (name, grant) in grants {
1071        data.push(0x06);
1072        data.extend_from_slice(name.as_bytes());
1073        data.push(0x00);
1074        data.extend_from_slice(grant.pubkey.as_bytes());
1075        data.push(0x00);
1076        data.extend_from_slice(grant.issued_at.as_bytes());
1077        data.push(0x00);
1078        data.extend_from_slice(grant.expires_at.as_bytes());
1079        data.push(0x00);
1080        data.extend_from_slice(grant.issuer.as_bytes());
1081        data.push(0x00);
1082        let mut scope = grant.scope.clone();
1083        scope.sort();
1084        for k in &scope {
1085            data.push(0x07);
1086            data.extend_from_slice(k.as_bytes());
1087        }
1088    }
1089}
1090
1091fn compute_mac_v7(
1092    vault: &types::Vault,
1093    groups: &BTreeMap<String, Vec<String>>,
1094    grants: &BTreeMap<String, types::GrantEntry>,
1095    key: &[u8; 32],
1096) -> String {
1097    let mut data = Vec::new();
1098    v7_mac_bytes(vault, groups, grants, &mut data);
1099    let hash = blake3::keyed_hash(key, &data);
1100    format!("blake3v5:{hash}")
1101}
1102
1103/// Append the v8 byte stream (v7 bytes plus the header policy block) to `data`.
1104/// Factored out so v9 can extend the exact same bytes without risking a change
1105/// to v8's encoding.
1106fn v8_mac_bytes(
1107    vault: &types::Vault,
1108    groups: &BTreeMap<String, Vec<String>>,
1109    grants: &BTreeMap<String, types::GrantEntry>,
1110    data: &mut Vec<u8>,
1111) {
1112    v7_mac_bytes(vault, groups, grants, data);
1113
1114    // Policy (header). `0x08` opens the policy block (present only when a policy
1115    // exists, so Some-but-empty is distinct from None). Each agent allow-tag is
1116    // length-prefixed (4-byte big-endian) and sorted, so the byte stream is
1117    // unambiguous regardless of tag contents — a crafted tag can't forge a
1118    // boundary (e.g. `["a\tb"]` and `["a", "b"]` hash differently). New policy
1119    // fields extend this block.
1120    if let Some(policy) = &vault.policy {
1121        data.push(0x08);
1122        let mut tags = policy.agent_allow_tags.clone();
1123        tags.sort();
1124        for tag in &tags {
1125            let bytes = tag.as_bytes();
1126            // usize→u64 is lossless on supported targets; fixed-width length
1127            // prefix keeps the encoding unambiguous.
1128            data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
1129            data.extend_from_slice(bytes);
1130        }
1131    }
1132}
1133
1134/// v8 MAC (`blake3v6:`). Extends v7 with the plaintext header policy object, so a
1135/// vault's agent access policy cannot be weakened or stripped undetected. Only
1136/// emitted once a vault has a policy; policy-free vaults keep writing v5/v6/v7
1137/// and stay byte-identical.
1138fn compute_mac_v8(
1139    vault: &types::Vault,
1140    groups: &BTreeMap<String, Vec<String>>,
1141    grants: &BTreeMap<String, types::GrantEntry>,
1142    key: &[u8; 32],
1143) -> String {
1144    let mut data = Vec::new();
1145    v8_mac_bytes(vault, groups, grants, &mut data);
1146    let hash = blake3::keyed_hash(key, &data);
1147    format!("blake3v6:{hash}")
1148}
1149
1150/// v9 MAC (`blake3v7:`). Extends v8 with each schema entry's `revoked_at` marker,
1151/// so the "still owed a rotation since a revoke" flag is tamper-evident — an
1152/// attacker editing `.murk` can't silently clear it. Only emitted once a vault
1153/// has at least one `revoked_at` set; vaults without one keep writing v5–v8 and
1154/// stay byte-identical.
1155fn compute_mac_v9(
1156    vault: &types::Vault,
1157    groups: &BTreeMap<String, Vec<String>>,
1158    grants: &BTreeMap<String, types::GrantEntry>,
1159    key: &[u8; 32],
1160) -> String {
1161    let mut data = Vec::new();
1162    v8_mac_bytes(vault, groups, grants, &mut data);
1163
1164    // Revoked-at markers, in schema order (BTreeMap → sorted by key name). `0x09`
1165    // opens each marker so the stream can't be confused with the schema (`0x02`)
1166    // or policy (`0x08`) blocks; absent markers emit nothing, so a vault that
1167    // sets one then clears it hashes identically to one that never set it.
1168    for (key_name, entry) in &vault.schema {
1169        if let Some(revoked_at) = &entry.revoked_at {
1170            data.push(0x09);
1171            data.extend_from_slice(key_name.as_bytes());
1172            data.push(0x00);
1173            data.extend_from_slice(revoked_at.as_bytes());
1174            data.push(0x00);
1175        }
1176    }
1177
1178    let hash = blake3::keyed_hash(key, &data);
1179    format!("blake3v7:{hash}")
1180}
1181
1182/// Verify a stored MAC against the vault, accepting v1, v2, blake3, blake3v2,
1183/// blake3v3, blake3v4, blake3v5, blake3v6, and blake3v7 schemes.
1184pub(crate) fn verify_mac(
1185    vault: &types::Vault,
1186    groups: &BTreeMap<String, Vec<String>>,
1187    grants: &BTreeMap<String, types::GrantEntry>,
1188    stored_mac: &str,
1189    mac_key: Option<&[u8; 32]>,
1190) -> bool {
1191    use constant_time_eq::constant_time_eq;
1192
1193    // `revoked_at` is only covered by v9 (`blake3v7:`). A vault carrying one but
1194    // stamped with an older MAC is tampered or inconsistent — reject it so an
1195    // attacker can't clear a pending-rotation flag by downgrading the MAC.
1196    if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
1197    {
1198        return false;
1199    }
1200
1201    // Policy is covered by v8 (`blake3v6:`) and v9 (`blake3v7:`). A vault carrying
1202    // a policy but stamped with an older MAC is tampered or inconsistent — reject
1203    // it so an attacker can't strip or weaken the policy by downgrading the MAC.
1204    if vault.policy.is_some()
1205        && !stored_mac.starts_with("blake3v6:")
1206        && !stored_mac.starts_with("blake3v7:")
1207    {
1208        return false;
1209    }
1210
1211    // Grant metadata is covered by v7 (`blake3v5:`) and up. A vault carrying
1212    // grants but stamped with an older MAC is tampered or inconsistent.
1213    if !grants.is_empty()
1214        && !stored_mac.starts_with("blake3v5:")
1215        && !stored_mac.starts_with("blake3v6:")
1216        && !stored_mac.starts_with("blake3v7:")
1217    {
1218        return false;
1219    }
1220
1221    // Group data is covered by v6 and up. A vault carrying any grouped ciphertext
1222    // or group membership but stamped with an older MAC is either tampered (an
1223    // attacker injected a `grouped` entry that the old MAC ignores, then relies on
1224    // group-before-shared resolution) or inconsistent. Reject it rather than
1225    // verify against a scheme that doesn't cover groups.
1226    let touches_groups =
1227        !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
1228    if touches_groups
1229        && !stored_mac.starts_with("blake3v4:")
1230        && !stored_mac.starts_with("blake3v5:")
1231        && !stored_mac.starts_with("blake3v6:")
1232        && !stored_mac.starts_with("blake3v7:")
1233    {
1234        return false;
1235    }
1236
1237    let expected = if stored_mac.starts_with("blake3v7:") {
1238        match mac_key {
1239            Some(key) => compute_mac_v9(vault, groups, grants, key),
1240            None => return false,
1241        }
1242    } else if stored_mac.starts_with("blake3v6:") {
1243        match mac_key {
1244            Some(key) => compute_mac_v8(vault, groups, grants, key),
1245            None => return false,
1246        }
1247    } else if stored_mac.starts_with("blake3v5:") {
1248        match mac_key {
1249            Some(key) => compute_mac_v7(vault, groups, grants, key),
1250            None => return false,
1251        }
1252    } else if stored_mac.starts_with("blake3v4:") {
1253        match mac_key {
1254            Some(key) => compute_mac_v6(vault, groups, key),
1255            None => return false,
1256        }
1257    } else if stored_mac.starts_with("blake3v3:") {
1258        match mac_key {
1259            Some(key) => compute_mac_v5(vault, key),
1260            None => return false,
1261        }
1262    } else if stored_mac.starts_with("blake3v2:") {
1263        match mac_key {
1264            Some(key) => compute_mac_v4(vault, key),
1265            None => return false,
1266        }
1267    } else if stored_mac.starts_with("blake3:") {
1268        match mac_key {
1269            Some(key) => compute_mac_v3(vault, key),
1270            None => return false,
1271        }
1272    } else if stored_mac.starts_with("sha256v2:") {
1273        compute_mac_v2(vault)
1274    } else if stored_mac.starts_with("sha256:") {
1275        compute_mac_v1(vault)
1276    } else {
1277        return false;
1278    };
1279    constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
1280}
1281
1282/// Generate a random 32-byte BLAKE3 MAC key, returned as hex.
1283pub(crate) fn generate_mac_key() -> String {
1284    let key: [u8; 32] = rand::random();
1285    key.iter().fold(String::new(), |mut s, b| {
1286        use std::fmt::Write;
1287        let _ = write!(s, "{b:02x}");
1288        s
1289    })
1290}
1291
1292/// Decode a hex-encoded 32-byte key.
1293pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
1294    if hex.len() != 64 {
1295        return None;
1296    }
1297    let mut key = [0u8; 32];
1298    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
1299        key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
1300    }
1301    Some(key)
1302}
1303
1304/// Generate an ISO-8601 UTC timestamp.
1305pub(crate) fn now_utc() -> String {
1306    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311    use super::*;
1312    use crate::testutil::*;
1313    use std::collections::BTreeMap;
1314    use std::fs;
1315
1316    use crate::testutil::ENV_LOCK;
1317
1318    #[test]
1319    fn resolve_vault_path_finds_in_parent_dir() {
1320        let _lock = ENV_LOCK
1321            .lock()
1322            .unwrap_or_else(std::sync::PoisonError::into_inner);
1323        let dir = tempfile::tempdir().unwrap();
1324        // Create a fake git repo with a vault at the root and a nested subdir.
1325        fs::create_dir(dir.path().join(".git")).unwrap();
1326        fs::write(dir.path().join(".murk"), "{}").unwrap();
1327        let nested = dir.path().join("a").join("b");
1328        fs::create_dir_all(&nested).unwrap();
1329
1330        let prev = std::env::current_dir().unwrap();
1331        std::env::set_current_dir(&nested).unwrap();
1332        let got = resolve_vault_path(".murk");
1333        std::env::set_current_dir(prev).unwrap();
1334
1335        assert_eq!(
1336            std::fs::canonicalize(&got).unwrap(),
1337            std::fs::canonicalize(dir.path().join(".murk")).unwrap()
1338        );
1339    }
1340
1341    #[test]
1342    fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
1343        let _lock = ENV_LOCK
1344            .lock()
1345            .unwrap_or_else(std::sync::PoisonError::into_inner);
1346        let dir = tempfile::tempdir().unwrap();
1347        fs::write(dir.path().join(".murk"), "{}").unwrap();
1348        let prev = std::env::current_dir().unwrap();
1349        std::env::set_current_dir(dir.path()).unwrap();
1350        let got = resolve_vault_path(".murk");
1351        std::env::set_current_dir(prev).unwrap();
1352        assert_eq!(got, ".murk");
1353    }
1354
1355    #[test]
1356    fn resolve_vault_path_passes_through_explicit_paths() {
1357        assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
1358        assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
1359        assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
1360    }
1361
1362    #[test]
1363    fn resolve_vault_path_stops_at_git_root() {
1364        let _lock = ENV_LOCK
1365            .lock()
1366            .unwrap_or_else(std::sync::PoisonError::into_inner);
1367        let dir = tempfile::tempdir().unwrap();
1368        // Vault lives OUTSIDE the git repo; traversal should not find it.
1369        fs::write(dir.path().join(".murk"), "{}").unwrap();
1370        let repo = dir.path().join("repo");
1371        fs::create_dir(&repo).unwrap();
1372        fs::create_dir(repo.join(".git")).unwrap();
1373        let nested = repo.join("sub");
1374        fs::create_dir(&nested).unwrap();
1375
1376        let prev = std::env::current_dir().unwrap();
1377        std::env::set_current_dir(&nested).unwrap();
1378        let got = resolve_vault_path(".murk");
1379        std::env::set_current_dir(prev).unwrap();
1380
1381        // Unchanged — we stopped at the git root and never saw the outer vault.
1382        assert_eq!(got, ".murk");
1383    }
1384
1385    #[test]
1386    fn encrypt_decrypt_value_roundtrip() {
1387        let (secret, pubkey) = generate_keypair();
1388        let recipient = make_recipient(&pubkey);
1389        let identity = make_identity(&secret);
1390
1391        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
1392        let decrypted = decrypt_value(&encoded, &identity).unwrap();
1393        assert_eq!(&decrypted[..], b"hello world");
1394    }
1395
1396    #[test]
1397    fn decrypt_value_invalid_base64() {
1398        let (secret, _) = generate_keypair();
1399        let identity = make_identity(&secret);
1400
1401        let result = decrypt_value("not!valid!base64!!!", &identity);
1402        assert!(result.is_err());
1403        assert!(result.unwrap_err().to_string().contains("invalid base64"));
1404    }
1405
1406    #[test]
1407    fn encrypt_value_multiple_recipients() {
1408        let (secret_a, pubkey_a) = generate_keypair();
1409        let (secret_b, pubkey_b) = generate_keypair();
1410
1411        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
1412        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
1413
1414        // Both can decrypt.
1415        let id_a = make_identity(&secret_a);
1416        let id_b = make_identity(&secret_b);
1417        assert_eq!(
1418            &decrypt_value(&encoded, &id_a).unwrap()[..],
1419            b"shared secret"
1420        );
1421        assert_eq!(
1422            &decrypt_value(&encoded, &id_b).unwrap()[..],
1423            b"shared secret"
1424        );
1425    }
1426
1427    #[test]
1428    fn decrypt_value_wrong_key_fails() {
1429        let (_, pubkey) = generate_keypair();
1430        let (wrong_secret, _) = generate_keypair();
1431
1432        let recipient = make_recipient(&pubkey);
1433        let wrong_identity = make_identity(&wrong_secret);
1434
1435        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
1436        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
1437    }
1438
1439    #[test]
1440    fn compute_mac_deterministic() {
1441        let vault = types::Vault {
1442            version: types::VAULT_VERSION.into(),
1443            created: "2026-02-28T00:00:00Z".into(),
1444            vault_name: ".murk".into(),
1445            repo: String::new(),
1446            recipients: vec!["age1abc".into()],
1447            schema: BTreeMap::new(),
1448            policy: None,
1449            secrets: BTreeMap::new(),
1450            meta: String::new(),
1451        };
1452
1453        let key = [0u8; 32];
1454        let mac1 = compute_mac(
1455            &vault,
1456            &std::collections::BTreeMap::new(),
1457            &std::collections::BTreeMap::new(),
1458            Some(&key),
1459        );
1460        let mac2 = compute_mac(
1461            &vault,
1462            &std::collections::BTreeMap::new(),
1463            &std::collections::BTreeMap::new(),
1464            Some(&key),
1465        );
1466        assert_eq!(mac1, mac2);
1467        assert!(mac1.starts_with("blake3v3:"));
1468
1469        // Without key, falls back to sha256v2
1470        let mac_legacy = compute_mac(
1471            &vault,
1472            &std::collections::BTreeMap::new(),
1473            &std::collections::BTreeMap::new(),
1474            None,
1475        );
1476        assert!(mac_legacy.starts_with("sha256v2:"));
1477    }
1478
1479    #[test]
1480    fn compute_mac_changes_with_different_secrets() {
1481        let mut vault = types::Vault {
1482            version: types::VAULT_VERSION.into(),
1483            created: "2026-02-28T00:00:00Z".into(),
1484            vault_name: ".murk".into(),
1485            repo: String::new(),
1486            recipients: vec!["age1abc".into()],
1487            schema: BTreeMap::new(),
1488            policy: None,
1489            secrets: BTreeMap::new(),
1490            meta: String::new(),
1491        };
1492
1493        let key = [0u8; 32];
1494        let mac_empty = compute_mac(
1495            &vault,
1496            &std::collections::BTreeMap::new(),
1497            &std::collections::BTreeMap::new(),
1498            Some(&key),
1499        );
1500
1501        vault.secrets.insert(
1502            "KEY".into(),
1503            types::SecretEntry {
1504                shared: "ciphertext".into(),
1505                private: BTreeMap::new(),
1506                grouped: std::collections::BTreeMap::default(),
1507            },
1508        );
1509
1510        let mac_with_secret = compute_mac(
1511            &vault,
1512            &std::collections::BTreeMap::new(),
1513            &std::collections::BTreeMap::new(),
1514            Some(&key),
1515        );
1516        assert_ne!(mac_empty, mac_with_secret);
1517    }
1518
1519    #[test]
1520    fn compute_mac_changes_with_different_recipients() {
1521        let mut vault = types::Vault {
1522            version: types::VAULT_VERSION.into(),
1523            created: "2026-02-28T00:00:00Z".into(),
1524            vault_name: ".murk".into(),
1525            repo: String::new(),
1526            recipients: vec!["age1abc".into()],
1527            schema: BTreeMap::new(),
1528            policy: None,
1529            secrets: BTreeMap::new(),
1530            meta: String::new(),
1531        };
1532
1533        let key = [0u8; 32];
1534        let mac1 = compute_mac(
1535            &vault,
1536            &std::collections::BTreeMap::new(),
1537            &std::collections::BTreeMap::new(),
1538            Some(&key),
1539        );
1540        vault.recipients.push("age1xyz".into());
1541        let mac2 = compute_mac(
1542            &vault,
1543            &std::collections::BTreeMap::new(),
1544            &std::collections::BTreeMap::new(),
1545            Some(&key),
1546        );
1547        assert_ne!(mac1, mac2);
1548    }
1549
1550    #[test]
1551    fn save_vault_preserves_unchanged_ciphertext() {
1552        let (secret, pubkey) = generate_keypair();
1553        let recipient = make_recipient(&pubkey);
1554        let identity = make_identity(&secret);
1555
1556        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
1557        fs::create_dir_all(&dir).unwrap();
1558        let path = dir.join("test.murk");
1559
1560        let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
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![pubkey.clone()],
1567            schema: BTreeMap::new(),
1568            policy: None,
1569            secrets: BTreeMap::new(),
1570            meta: String::new(),
1571        };
1572        vault.secrets.insert(
1573            "KEY1".into(),
1574            types::SecretEntry {
1575                shared: shared.clone(),
1576                private: BTreeMap::new(),
1577                grouped: std::collections::BTreeMap::default(),
1578            },
1579        );
1580
1581        let mut recipients_map = HashMap::new();
1582        recipients_map.insert(pubkey.clone(), "alice".into());
1583        let original = types::Murk {
1584            values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
1585            recipients: recipients_map.clone(),
1586            private: HashMap::new(),
1587            legacy_mac: false,
1588            github_pins: HashMap::new(),
1589            ..Default::default()
1590        };
1591
1592        let current = original.clone();
1593        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1594
1595        assert_eq!(vault.secrets["KEY1"].shared, shared);
1596
1597        let mut changed = current.clone();
1598        changed
1599            .values
1600            .insert("KEY1".into(), crate::testutil::secret("modified"));
1601        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
1602
1603        assert_ne!(vault.secrets["KEY1"].shared, shared);
1604
1605        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
1606        assert_eq!(&decrypted[..], b"modified");
1607
1608        fs::remove_dir_all(&dir).unwrap();
1609    }
1610
1611    #[test]
1612    fn save_vault_adds_new_secret() {
1613        let (_, pubkey) = generate_keypair();
1614        let recipient = make_recipient(&pubkey);
1615
1616        let dir = std::env::temp_dir().join("murk_test_save_add");
1617        fs::create_dir_all(&dir).unwrap();
1618        let path = dir.join("test.murk");
1619
1620        let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
1621        let mut 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![pubkey.clone()],
1627            schema: BTreeMap::new(),
1628            policy: None,
1629            secrets: BTreeMap::new(),
1630            meta: String::new(),
1631        };
1632        vault.secrets.insert(
1633            "KEY1".into(),
1634            types::SecretEntry {
1635                shared,
1636                private: BTreeMap::new(),
1637                grouped: std::collections::BTreeMap::default(),
1638            },
1639        );
1640
1641        let mut recipients_map = HashMap::new();
1642        recipients_map.insert(pubkey.clone(), "alice".into());
1643        let original = types::Murk {
1644            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1645            recipients: recipients_map.clone(),
1646            private: HashMap::new(),
1647            legacy_mac: false,
1648            github_pins: HashMap::new(),
1649            ..Default::default()
1650        };
1651
1652        let mut current = original.clone();
1653        current
1654            .values
1655            .insert("KEY2".into(), crate::testutil::secret("val2"));
1656
1657        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1658
1659        assert!(vault.secrets.contains_key("KEY1"));
1660        assert!(vault.secrets.contains_key("KEY2"));
1661
1662        fs::remove_dir_all(&dir).unwrap();
1663    }
1664
1665    #[test]
1666    fn save_vault_removes_deleted_secret() {
1667        let (_, pubkey) = generate_keypair();
1668        let recipient = make_recipient(&pubkey);
1669
1670        let dir = std::env::temp_dir().join("murk_test_save_remove");
1671        fs::create_dir_all(&dir).unwrap();
1672        let path = dir.join("test.murk");
1673
1674        let mut vault = types::Vault {
1675            version: types::VAULT_VERSION.into(),
1676            created: "2026-02-28T00:00:00Z".into(),
1677            vault_name: ".murk".into(),
1678            repo: String::new(),
1679            recipients: vec![pubkey.clone()],
1680            schema: BTreeMap::new(),
1681            policy: None,
1682            secrets: BTreeMap::new(),
1683            meta: String::new(),
1684        };
1685        vault.secrets.insert(
1686            "KEY1".into(),
1687            types::SecretEntry {
1688                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
1689                private: BTreeMap::new(),
1690                grouped: std::collections::BTreeMap::default(),
1691            },
1692        );
1693        vault.secrets.insert(
1694            "KEY2".into(),
1695            types::SecretEntry {
1696                shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
1697                private: BTreeMap::new(),
1698                grouped: std::collections::BTreeMap::default(),
1699            },
1700        );
1701
1702        let mut recipients_map = HashMap::new();
1703        recipients_map.insert(pubkey.clone(), "alice".into());
1704        let original = types::Murk {
1705            values: HashMap::from([
1706                ("KEY1".into(), crate::testutil::secret("val1")),
1707                ("KEY2".into(), crate::testutil::secret("val2")),
1708            ]),
1709            recipients: recipients_map.clone(),
1710            private: HashMap::new(),
1711            legacy_mac: false,
1712            github_pins: HashMap::new(),
1713            ..Default::default()
1714        };
1715
1716        let mut current = original.clone();
1717        current.values.remove("KEY2");
1718
1719        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1720
1721        assert!(vault.secrets.contains_key("KEY1"));
1722        assert!(!vault.secrets.contains_key("KEY2"));
1723
1724        fs::remove_dir_all(&dir).unwrap();
1725    }
1726
1727    #[test]
1728    fn save_vault_reencrypts_all_on_recipient_change() {
1729        let (secret1, pubkey1) = generate_keypair();
1730        let (_, pubkey2) = generate_keypair();
1731        let recipient1 = make_recipient(&pubkey1);
1732
1733        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
1734        fs::create_dir_all(&dir).unwrap();
1735        let path = dir.join("test.murk");
1736
1737        let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
1738        let mut vault = types::Vault {
1739            version: types::VAULT_VERSION.into(),
1740            created: "2026-02-28T00:00:00Z".into(),
1741            vault_name: ".murk".into(),
1742            repo: String::new(),
1743            recipients: vec![pubkey1.clone(), pubkey2.clone()],
1744            schema: BTreeMap::new(),
1745            policy: None,
1746            secrets: BTreeMap::new(),
1747            meta: String::new(),
1748        };
1749        vault.secrets.insert(
1750            "KEY1".into(),
1751            types::SecretEntry {
1752                shared: shared.clone(),
1753                private: BTreeMap::new(),
1754                grouped: std::collections::BTreeMap::default(),
1755            },
1756        );
1757
1758        let mut recipients_map = HashMap::new();
1759        recipients_map.insert(pubkey1.clone(), "alice".into());
1760        let original = types::Murk {
1761            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1762            recipients: recipients_map,
1763            private: HashMap::new(),
1764            legacy_mac: false,
1765            github_pins: HashMap::new(),
1766            ..Default::default()
1767        };
1768
1769        let mut current_recipients = HashMap::new();
1770        current_recipients.insert(pubkey1.clone(), "alice".into());
1771        current_recipients.insert(pubkey2.clone(), "bob".into());
1772        let current = types::Murk {
1773            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1774            recipients: current_recipients,
1775            private: HashMap::new(),
1776            legacy_mac: false,
1777            github_pins: HashMap::new(),
1778            ..Default::default()
1779        };
1780
1781        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1782
1783        assert_ne!(vault.secrets["KEY1"].shared, shared);
1784
1785        let identity1 = make_identity(&secret1);
1786        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
1787        assert_eq!(&decrypted[..], b"val1");
1788
1789        fs::remove_dir_all(&dir).unwrap();
1790    }
1791
1792    #[test]
1793    fn save_vault_scoped_entry_lifecycle() {
1794        let (secret, pubkey) = generate_keypair();
1795        let recipient = make_recipient(&pubkey);
1796        let identity = make_identity(&secret);
1797
1798        let dir = std::env::temp_dir().join("murk_test_save_scoped");
1799        fs::create_dir_all(&dir).unwrap();
1800        let path = dir.join("test.murk");
1801
1802        let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
1803        let mut vault = types::Vault {
1804            version: types::VAULT_VERSION.into(),
1805            created: "2026-02-28T00:00:00Z".into(),
1806            vault_name: ".murk".into(),
1807            repo: String::new(),
1808            recipients: vec![pubkey.clone()],
1809            schema: BTreeMap::new(),
1810            policy: None,
1811            secrets: BTreeMap::new(),
1812            meta: String::new(),
1813        };
1814        vault.secrets.insert(
1815            "KEY1".into(),
1816            types::SecretEntry {
1817                shared,
1818                private: BTreeMap::new(),
1819                grouped: std::collections::BTreeMap::default(),
1820            },
1821        );
1822
1823        let mut recipients_map = HashMap::new();
1824        recipients_map.insert(pubkey.clone(), "alice".into());
1825        let original = types::Murk {
1826            values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
1827            recipients: recipients_map.clone(),
1828            private: HashMap::new(),
1829            legacy_mac: false,
1830            github_pins: HashMap::new(),
1831            ..Default::default()
1832        };
1833
1834        // Add a scoped override.
1835        let mut current = original.clone();
1836        let mut key_scoped = HashMap::new();
1837        key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
1838        current.private.insert("KEY1".into(), key_scoped);
1839
1840        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
1841
1842        assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
1843        let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
1844        assert_eq!(&scoped_val[..], b"my_override");
1845
1846        // Now remove the scoped override.
1847        let original_with_scoped = current.clone();
1848        let mut current_no_scoped = original_with_scoped.clone();
1849        current_no_scoped.private.remove("KEY1");
1850
1851        save_vault(
1852            path.to_str().unwrap(),
1853            &mut vault,
1854            &original_with_scoped,
1855            &current_no_scoped,
1856        )
1857        .unwrap();
1858
1859        assert!(vault.secrets["KEY1"].private.is_empty());
1860
1861        fs::remove_dir_all(&dir).unwrap();
1862    }
1863
1864    #[test]
1865    fn load_vault_validates_mac() {
1866        let _lock = ENV_LOCK
1867            .lock()
1868            .unwrap_or_else(std::sync::PoisonError::into_inner);
1869
1870        let (secret, pubkey) = generate_keypair();
1871        let recipient = make_recipient(&pubkey);
1872        let _identity = make_identity(&secret);
1873
1874        let dir = std::env::temp_dir().join("murk_test_load_mac");
1875        let _ = fs::remove_dir_all(&dir);
1876        fs::create_dir_all(&dir).unwrap();
1877        let path = dir.join("test.murk");
1878
1879        // Build a vault with one secret, save it (computes valid MAC).
1880        let mut vault = types::Vault {
1881            version: types::VAULT_VERSION.into(),
1882            created: "2026-02-28T00:00:00Z".into(),
1883            vault_name: ".murk".into(),
1884            repo: String::new(),
1885            recipients: vec![pubkey.clone()],
1886            schema: BTreeMap::new(),
1887            policy: None,
1888            secrets: BTreeMap::new(),
1889            meta: String::new(),
1890        };
1891        vault.secrets.insert(
1892            "KEY1".into(),
1893            types::SecretEntry {
1894                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
1895                private: BTreeMap::new(),
1896                grouped: std::collections::BTreeMap::default(),
1897            },
1898        );
1899
1900        let mut recipients_map = HashMap::new();
1901        recipients_map.insert(pubkey.clone(), "alice".into());
1902        let original = types::Murk {
1903            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1904            recipients: recipients_map,
1905            private: HashMap::new(),
1906            legacy_mac: false,
1907            github_pins: HashMap::new(),
1908            ..Default::default()
1909        };
1910
1911        // save_vault needs MURK_KEY set to encrypt meta.
1912        unsafe { std::env::set_var("MURK_KEY", &secret) };
1913        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1914        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1915
1916        // Now tamper: change the ciphertext in the saved vault file.
1917        let mut tampered: types::Vault =
1918            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1919        tampered.secrets.get_mut("KEY1").unwrap().shared =
1920            encrypt_value(b"tampered", &[recipient]).unwrap();
1921        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
1922
1923        // Load should fail MAC validation.
1924        let result = load_vault(path.to_str().unwrap());
1925        unsafe { std::env::remove_var("MURK_KEY") };
1926
1927        let err = result.expect_err("expected MAC validation to fail");
1928        assert!(
1929            err.to_string().contains("integrity check failed"),
1930            "expected integrity check failure, got: {err}"
1931        );
1932
1933        fs::remove_dir_all(&dir).unwrap();
1934    }
1935
1936    #[test]
1937    fn load_vault_succeeds_with_valid_mac() {
1938        let _lock = ENV_LOCK
1939            .lock()
1940            .unwrap_or_else(std::sync::PoisonError::into_inner);
1941
1942        let (secret, pubkey) = generate_keypair();
1943        let recipient = make_recipient(&pubkey);
1944
1945        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
1946        let _ = fs::remove_dir_all(&dir);
1947        fs::create_dir_all(&dir).unwrap();
1948        let path = dir.join("test.murk");
1949
1950        let mut vault = types::Vault {
1951            version: types::VAULT_VERSION.into(),
1952            created: "2026-02-28T00:00:00Z".into(),
1953            vault_name: ".murk".into(),
1954            repo: String::new(),
1955            recipients: vec![pubkey.clone()],
1956            schema: BTreeMap::new(),
1957            policy: None,
1958            secrets: BTreeMap::new(),
1959            meta: String::new(),
1960        };
1961        vault.secrets.insert(
1962            "KEY1".into(),
1963            types::SecretEntry {
1964                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
1965                private: BTreeMap::new(),
1966                grouped: std::collections::BTreeMap::default(),
1967            },
1968        );
1969
1970        let mut recipients_map = HashMap::new();
1971        recipients_map.insert(pubkey.clone(), "alice".into());
1972        let original = types::Murk {
1973            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
1974            recipients: recipients_map,
1975            private: HashMap::new(),
1976            legacy_mac: false,
1977            github_pins: HashMap::new(),
1978            ..Default::default()
1979        };
1980
1981        unsafe { std::env::set_var("MURK_KEY", &secret) };
1982        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1983        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1984
1985        // Load should succeed.
1986        let result = load_vault(path.to_str().unwrap());
1987        unsafe { std::env::remove_var("MURK_KEY") };
1988
1989        assert!(result.is_ok());
1990        let (_, murk, _) = result.unwrap();
1991        assert_eq!(murk.values["KEY1"].as_str(), "val1");
1992
1993        fs::remove_dir_all(&dir).unwrap();
1994    }
1995
1996    #[test]
1997    fn load_vault_not_a_recipient() {
1998        let _lock = ENV_LOCK
1999            .lock()
2000            .unwrap_or_else(std::sync::PoisonError::into_inner);
2001
2002        let (secret, _pubkey) = generate_keypair();
2003        let (other_secret, other_pubkey) = generate_keypair();
2004        let other_recipient = make_recipient(&other_pubkey);
2005
2006        let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
2007        let _ = fs::remove_dir_all(&dir);
2008        fs::create_dir_all(&dir).unwrap();
2009        let path = dir.join("test.murk");
2010
2011        // Build a vault encrypted to `other`, not to `secret`.
2012        let mut vault = types::Vault {
2013            version: types::VAULT_VERSION.into(),
2014            created: "2026-02-28T00:00:00Z".into(),
2015            vault_name: ".murk".into(),
2016            repo: String::new(),
2017            recipients: vec![other_pubkey.clone()],
2018            schema: BTreeMap::new(),
2019            policy: None,
2020            secrets: BTreeMap::new(),
2021            meta: String::new(),
2022        };
2023        vault.secrets.insert(
2024            "KEY1".into(),
2025            types::SecretEntry {
2026                shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
2027                private: BTreeMap::new(),
2028                grouped: std::collections::BTreeMap::default(),
2029            },
2030        );
2031
2032        // Save via save_vault (needs the other key for re-encryption).
2033        let mut recipients_map = HashMap::new();
2034        recipients_map.insert(other_pubkey.clone(), "other".into());
2035        let original = types::Murk {
2036            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2037            recipients: recipients_map,
2038            private: HashMap::new(),
2039            legacy_mac: false,
2040            github_pins: HashMap::new(),
2041            ..Default::default()
2042        };
2043
2044        unsafe { std::env::set_var("MURK_KEY", &other_secret) };
2045        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2046        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2047
2048        // Now try to load with a key that is NOT a recipient.
2049        unsafe { std::env::set_var("MURK_KEY", secret) };
2050        let result = load_vault(path.to_str().unwrap());
2051        unsafe { std::env::remove_var("MURK_KEY") };
2052
2053        let Err(err) = result else {
2054            panic!("expected load_vault to fail for non-recipient");
2055        };
2056        // Non-recipient can't decrypt meta, so integrity check fails first.
2057        let msg = err.to_string();
2058        assert!(
2059            msg.contains("decryption failed")
2060                || msg.contains("no meta")
2061                || msg.contains("tampered"),
2062            "expected decryption or integrity failure, got: {err}"
2063        );
2064
2065        fs::remove_dir_all(&dir).unwrap();
2066    }
2067
2068    #[test]
2069    fn load_vault_zero_secrets() {
2070        let _lock = ENV_LOCK
2071            .lock()
2072            .unwrap_or_else(std::sync::PoisonError::into_inner);
2073
2074        let (secret, pubkey) = generate_keypair();
2075
2076        let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
2077        let _ = fs::remove_dir_all(&dir);
2078        fs::create_dir_all(&dir).unwrap();
2079        let path = dir.join("test.murk");
2080
2081        // Build a vault with no secrets at all.
2082        let mut vault = types::Vault {
2083            version: types::VAULT_VERSION.into(),
2084            created: "2026-02-28T00:00:00Z".into(),
2085            vault_name: ".murk".into(),
2086            repo: String::new(),
2087            recipients: vec![pubkey.clone()],
2088            schema: BTreeMap::new(),
2089            policy: None,
2090            secrets: BTreeMap::new(),
2091            meta: String::new(),
2092        };
2093
2094        let mut recipients_map = HashMap::new();
2095        recipients_map.insert(pubkey.clone(), "alice".into());
2096        let original = types::Murk {
2097            values: HashMap::new(),
2098            recipients: recipients_map,
2099            private: HashMap::new(),
2100            legacy_mac: false,
2101            github_pins: HashMap::new(),
2102            ..Default::default()
2103        };
2104
2105        unsafe { std::env::set_var("MURK_KEY", &secret) };
2106        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2107        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2108
2109        let result = load_vault(path.to_str().unwrap());
2110        unsafe { std::env::remove_var("MURK_KEY") };
2111
2112        assert!(result.is_ok());
2113        let (_, murk, _) = result.unwrap();
2114        assert!(murk.values.is_empty());
2115        assert!(murk.private.is_empty());
2116
2117        fs::remove_dir_all(&dir).unwrap();
2118    }
2119
2120    #[test]
2121    fn load_vault_stripped_meta_with_secrets_fails() {
2122        let _lock = ENV_LOCK
2123            .lock()
2124            .unwrap_or_else(std::sync::PoisonError::into_inner);
2125
2126        let (secret, pubkey) = generate_keypair();
2127        let recipient = make_recipient(&pubkey);
2128
2129        let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
2130        let _ = fs::remove_dir_all(&dir);
2131        fs::create_dir_all(&dir).unwrap();
2132        let path = dir.join("test.murk");
2133
2134        // Build a vault with one secret and a valid MAC via save_vault.
2135        let mut vault = types::Vault {
2136            version: types::VAULT_VERSION.into(),
2137            created: "2026-02-28T00:00:00Z".into(),
2138            vault_name: ".murk".into(),
2139            repo: String::new(),
2140            recipients: vec![pubkey.clone()],
2141            schema: BTreeMap::new(),
2142            policy: None,
2143            secrets: BTreeMap::new(),
2144            meta: String::new(),
2145        };
2146        vault.secrets.insert(
2147            "KEY1".into(),
2148            types::SecretEntry {
2149                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2150                private: BTreeMap::new(),
2151                grouped: std::collections::BTreeMap::default(),
2152            },
2153        );
2154
2155        let mut recipients_map = HashMap::new();
2156        recipients_map.insert(pubkey.clone(), "alice".into());
2157        let original = types::Murk {
2158            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2159            recipients: recipients_map,
2160            private: HashMap::new(),
2161            legacy_mac: false,
2162            github_pins: HashMap::new(),
2163            ..Default::default()
2164        };
2165
2166        unsafe { std::env::set_var("MURK_KEY", &secret) };
2167        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2168        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2169
2170        // Tamper: strip meta field entirely.
2171        let mut tampered: types::Vault =
2172            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2173        tampered.meta = String::new();
2174        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2175
2176        // Load should fail: secrets present but no meta.
2177        let result = load_vault(path.to_str().unwrap());
2178        unsafe { std::env::remove_var("MURK_KEY") };
2179
2180        let err = result.expect_err("expected MAC validation to fail");
2181        assert!(
2182            err.to_string().contains("integrity check failed"),
2183            "expected integrity check failure, got: {err}"
2184        );
2185
2186        fs::remove_dir_all(&dir).unwrap();
2187    }
2188
2189    #[test]
2190    fn load_vault_empty_mac_with_secrets_fails() {
2191        let _lock = ENV_LOCK
2192            .lock()
2193            .unwrap_or_else(std::sync::PoisonError::into_inner);
2194
2195        let (secret, pubkey) = generate_keypair();
2196        let recipient = make_recipient(&pubkey);
2197
2198        let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
2199        let _ = fs::remove_dir_all(&dir);
2200        fs::create_dir_all(&dir).unwrap();
2201        let path = dir.join("test.murk");
2202
2203        // Build a vault with one secret.
2204        let mut vault = types::Vault {
2205            version: types::VAULT_VERSION.into(),
2206            created: "2026-02-28T00:00:00Z".into(),
2207            vault_name: ".murk".into(),
2208            repo: String::new(),
2209            recipients: vec![pubkey.clone()],
2210            schema: BTreeMap::new(),
2211            policy: None,
2212            secrets: BTreeMap::new(),
2213            meta: String::new(),
2214        };
2215        vault.secrets.insert(
2216            "KEY1".into(),
2217            types::SecretEntry {
2218                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2219                private: BTreeMap::new(),
2220                grouped: std::collections::BTreeMap::default(),
2221            },
2222        );
2223
2224        // Manually create meta with empty MAC and encrypt it.
2225        let mut recipients_map = HashMap::new();
2226        recipients_map.insert(pubkey.clone(), "alice".into());
2227        let meta = types::Meta {
2228            recipients: recipients_map,
2229            mac: String::new(),
2230            mac_key: None,
2231            github_pins: HashMap::new(),
2232            ..Default::default()
2233        };
2234        let meta_json = serde_json::to_vec(&meta).unwrap();
2235        vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
2236
2237        // Write the vault to disk.
2238        crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
2239
2240        // Load should fail: secrets present but MAC is empty.
2241        unsafe { std::env::set_var("MURK_KEY", &secret) };
2242        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2243        let result = load_vault(path.to_str().unwrap());
2244        unsafe { std::env::remove_var("MURK_KEY") };
2245
2246        let err = result.expect_err("expected MAC validation to fail");
2247        assert!(
2248            err.to_string().contains("integrity check failed"),
2249            "expected integrity check failure, got: {err}"
2250        );
2251
2252        fs::remove_dir_all(&dir).unwrap();
2253    }
2254
2255    #[test]
2256    fn compute_mac_changes_with_scoped_entries() {
2257        let mut vault = types::Vault {
2258            version: types::VAULT_VERSION.into(),
2259            created: "2026-02-28T00:00:00Z".into(),
2260            vault_name: ".murk".into(),
2261            repo: String::new(),
2262            recipients: vec!["age1abc".into()],
2263            schema: BTreeMap::new(),
2264            policy: None,
2265            secrets: BTreeMap::new(),
2266            meta: String::new(),
2267        };
2268
2269        vault.secrets.insert(
2270            "KEY".into(),
2271            types::SecretEntry {
2272                shared: "ciphertext".into(),
2273                private: BTreeMap::new(),
2274                grouped: std::collections::BTreeMap::default(),
2275            },
2276        );
2277
2278        let key = [0u8; 32];
2279        let mac_no_scoped = compute_mac(
2280            &vault,
2281            &std::collections::BTreeMap::new(),
2282            &std::collections::BTreeMap::new(),
2283            Some(&key),
2284        );
2285
2286        vault
2287            .secrets
2288            .get_mut("KEY")
2289            .unwrap()
2290            .private
2291            .insert("age1bob".into(), "scoped-ct".into());
2292
2293        let mac_with_scoped = compute_mac(
2294            &vault,
2295            &std::collections::BTreeMap::new(),
2296            &std::collections::BTreeMap::new(),
2297            Some(&key),
2298        );
2299        assert_ne!(mac_no_scoped, mac_with_scoped);
2300    }
2301
2302    #[test]
2303    #[allow(clippy::too_many_lines)] // exhaustively enumerates every MAC scheme
2304    fn verify_mac_accepts_v1_prefix() {
2305        let vault = types::Vault {
2306            version: types::VAULT_VERSION.into(),
2307            created: "2026-02-28T00:00:00Z".into(),
2308            vault_name: ".murk".into(),
2309            repo: String::new(),
2310            recipients: vec!["age1abc".into()],
2311            schema: BTreeMap::new(),
2312            policy: None,
2313            secrets: BTreeMap::new(),
2314            meta: String::new(),
2315        };
2316
2317        let key = [0u8; 32];
2318        let v1_mac = compute_mac_v1(&vault);
2319        let v2_mac = compute_mac_v2(&vault);
2320        let v3_mac = compute_mac_v3(&vault, &key);
2321        assert!(verify_mac(
2322            &vault,
2323            &std::collections::BTreeMap::new(),
2324            &std::collections::BTreeMap::new(),
2325            &v1_mac,
2326            None
2327        ));
2328        assert!(verify_mac(
2329            &vault,
2330            &std::collections::BTreeMap::new(),
2331            &std::collections::BTreeMap::new(),
2332            &v2_mac,
2333            None
2334        ));
2335        assert!(verify_mac(
2336            &vault,
2337            &std::collections::BTreeMap::new(),
2338            &std::collections::BTreeMap::new(),
2339            &v3_mac,
2340            Some(&key)
2341        ));
2342        assert!(!verify_mac(
2343            &vault,
2344            &std::collections::BTreeMap::new(),
2345            &std::collections::BTreeMap::new(),
2346            "sha256:bogus",
2347            None
2348        ));
2349        assert!(!verify_mac(
2350            &vault,
2351            &std::collections::BTreeMap::new(),
2352            &std::collections::BTreeMap::new(),
2353            "blake3:bogus",
2354            Some(&key)
2355        ));
2356        assert!(!verify_mac(
2357            &vault,
2358            &std::collections::BTreeMap::new(),
2359            &std::collections::BTreeMap::new(),
2360            "blake3v2:bogus",
2361            Some(&key)
2362        ));
2363        assert!(!verify_mac(
2364            &vault,
2365            &std::collections::BTreeMap::new(),
2366            &std::collections::BTreeMap::new(),
2367            "blake3v3:bogus",
2368            Some(&key)
2369        ));
2370        assert!(!verify_mac(
2371            &vault,
2372            &std::collections::BTreeMap::new(),
2373            &std::collections::BTreeMap::new(),
2374            "unknown:prefix",
2375            None
2376        ));
2377
2378        // v4 (blake3v2) — includes schema; still accepted as legacy
2379        let v4_mac = compute_mac_v4(&vault, &key);
2380        assert!(v4_mac.starts_with("blake3v2:"));
2381        assert!(verify_mac(
2382            &vault,
2383            &std::collections::BTreeMap::new(),
2384            &std::collections::BTreeMap::new(),
2385            &v4_mac,
2386            Some(&key)
2387        ));
2388
2389        // v5 (blake3v3) — current scheme, includes lifecycle metadata
2390        let v5_mac = compute_mac_v5(&vault, &key);
2391        assert!(v5_mac.starts_with("blake3v3:"));
2392        assert!(verify_mac(
2393            &vault,
2394            &std::collections::BTreeMap::new(),
2395            &std::collections::BTreeMap::new(),
2396            &v5_mac,
2397            Some(&key)
2398        ));
2399        // compute_mac emits v5 when there are no groups
2400        assert!(
2401            compute_mac(
2402                &vault,
2403                &std::collections::BTreeMap::new(),
2404                &std::collections::BTreeMap::new(),
2405                Some(&key)
2406            )
2407            .starts_with("blake3v3:")
2408        );
2409
2410        // v6 (blake3v4) — emitted once a group exists; verifies and round-trips
2411        let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
2412        let v6_mac = compute_mac(
2413            &vault,
2414            &groups,
2415            &std::collections::BTreeMap::new(),
2416            Some(&key),
2417        );
2418        assert!(v6_mac.starts_with("blake3v4:"));
2419        assert!(verify_mac(
2420            &vault,
2421            &groups,
2422            &std::collections::BTreeMap::new(),
2423            &v6_mac,
2424            Some(&key)
2425        ));
2426        // Tampering with membership changes the MAC.
2427        let tampered = BTreeMap::from([(
2428            "prod".to_string(),
2429            vec!["age1abc".to_string(), "age1evil".to_string()],
2430        )]);
2431        assert!(!verify_mac(
2432            &vault,
2433            &tampered,
2434            &std::collections::BTreeMap::new(),
2435            &v6_mac,
2436            Some(&key)
2437        ));
2438    }
2439
2440    #[test]
2441    fn verify_mac_rejects_grouped_under_legacy_prefix() {
2442        // A v5 (blake3v3) MAC doesn't cover grouped ciphertext. Injecting a
2443        // grouped entry must not verify against the old scheme — otherwise an
2444        // attacker without a key could add a group value that wins on read.
2445        let mut vault = types::Vault {
2446            version: types::VAULT_VERSION.into(),
2447            created: "2026-02-28T00:00:00Z".into(),
2448            vault_name: ".murk".into(),
2449            repo: String::new(),
2450            recipients: vec!["age1abc".into()],
2451            schema: BTreeMap::new(),
2452            policy: None,
2453            secrets: BTreeMap::new(),
2454            meta: String::new(),
2455        };
2456        let key = [7u8; 32];
2457        let no_groups = BTreeMap::new();
2458        let v5_mac = compute_mac(
2459            &vault,
2460            &no_groups,
2461            &std::collections::BTreeMap::new(),
2462            Some(&key),
2463        );
2464        assert!(v5_mac.starts_with("blake3v3:"));
2465        assert!(verify_mac(
2466            &vault,
2467            &no_groups,
2468            &std::collections::BTreeMap::new(),
2469            &v5_mac,
2470            Some(&key)
2471        ));
2472
2473        // Attacker injects a grouped entry; the v5 MAC is now invalid for it.
2474        vault.secrets.insert(
2475            "STOLEN".into(),
2476            types::SecretEntry {
2477                grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
2478                ..Default::default()
2479            },
2480        );
2481        assert!(!verify_mac(
2482            &vault,
2483            &no_groups,
2484            &std::collections::BTreeMap::new(),
2485            &v5_mac,
2486            Some(&key)
2487        ));
2488    }
2489
2490    #[test]
2491    fn mac_v7_covers_grant_metadata() {
2492        let vault = types::Vault {
2493            version: types::VAULT_VERSION.into(),
2494            created: "2026-02-28T00:00:00Z".into(),
2495            vault_name: ".murk".into(),
2496            repo: String::new(),
2497            recipients: vec!["age1abc".into(), "age1agent".into()],
2498            schema: BTreeMap::new(),
2499            policy: None,
2500            secrets: BTreeMap::new(),
2501            meta: String::new(),
2502        };
2503        let key = [9u8; 32];
2504        let no_groups = BTreeMap::new();
2505
2506        // compute_mac emits v7 (blake3v5) once a grant exists.
2507        let grants = BTreeMap::from([(
2508            "codex".to_string(),
2509            types::GrantEntry {
2510                pubkey: "age1agent".into(),
2511                scope: vec!["STRIPE_KEY".into()],
2512                issued_at: "2026-02-28T00:00:00Z".into(),
2513                expires_at: "2026-02-28T02:00:00Z".into(),
2514                issuer: "age1abc".into(),
2515            },
2516        )]);
2517        let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
2518        assert!(v7_mac.starts_with("blake3v5:"));
2519        assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
2520
2521        // Widening the scope (or extending the TTL) changes the MAC.
2522        let tampered = BTreeMap::from([(
2523            "codex".to_string(),
2524            types::GrantEntry {
2525                pubkey: "age1agent".into(),
2526                scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
2527                issued_at: "2026-02-28T00:00:00Z".into(),
2528                expires_at: "2026-02-28T02:00:00Z".into(),
2529                issuer: "age1abc".into(),
2530            },
2531        )]);
2532        assert!(!verify_mac(
2533            &vault,
2534            &no_groups,
2535            &tampered,
2536            &v7_mac,
2537            Some(&key)
2538        ));
2539    }
2540
2541    #[test]
2542    fn verify_mac_rejects_grants_under_legacy_prefix() {
2543        // Grant metadata is only covered by v7. A vault carrying grants but
2544        // stamped with an older (group-era) MAC must not verify — otherwise an
2545        // attacker could fabricate or extend a grant the MAC ignores.
2546        let vault = types::Vault {
2547            version: types::VAULT_VERSION.into(),
2548            created: "2026-02-28T00:00:00Z".into(),
2549            vault_name: ".murk".into(),
2550            repo: String::new(),
2551            recipients: vec!["age1abc".into()],
2552            schema: BTreeMap::new(),
2553            policy: None,
2554            secrets: BTreeMap::new(),
2555            meta: String::new(),
2556        };
2557        let key = [3u8; 32];
2558        let no_groups = BTreeMap::new();
2559        let grants = BTreeMap::from([(
2560            "codex".to_string(),
2561            types::GrantEntry {
2562                pubkey: "age1agent".into(),
2563                scope: vec!["STRIPE_KEY".into()],
2564                issued_at: "2026-02-28T00:00:00Z".into(),
2565                expires_at: "2026-02-28T02:00:00Z".into(),
2566                issuer: "age1abc".into(),
2567            },
2568        )]);
2569        // A v6 MAC (no grants in the digest) must be rejected once grants exist.
2570        let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
2571        assert!(v6_mac.starts_with("blake3v4:"));
2572        assert!(!verify_mac(
2573            &vault,
2574            &no_groups,
2575            &grants,
2576            &v6_mac,
2577            Some(&key)
2578        ));
2579    }
2580
2581    #[test]
2582    fn mac_v8_covers_policy() {
2583        let mut vault = types::Vault {
2584            version: types::VAULT_VERSION.into(),
2585            created: "2026-02-28T00:00:00Z".into(),
2586            vault_name: ".murk".into(),
2587            repo: String::new(),
2588            recipients: vec!["age1abc".into()],
2589            schema: BTreeMap::new(),
2590            policy: Some(types::Policy {
2591                agent_allow_tags: vec!["agents".into()],
2592            }),
2593            secrets: BTreeMap::new(),
2594            meta: String::new(),
2595        };
2596        let key = [11u8; 32];
2597        let no_groups = BTreeMap::new();
2598        let no_grants = BTreeMap::new();
2599
2600        // compute_mac emits v8 (blake3v6) once a policy exists.
2601        let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
2602        assert!(v8_mac.starts_with("blake3v6:"));
2603        assert!(verify_mac(
2604            &vault,
2605            &no_groups,
2606            &no_grants,
2607            &v8_mac,
2608            Some(&key)
2609        ));
2610
2611        // Weakening the policy (adding an allowed tag) changes the MAC.
2612        vault.policy = Some(types::Policy {
2613            agent_allow_tags: vec!["agents".into(), "production".into()],
2614        });
2615        assert!(!verify_mac(
2616            &vault,
2617            &no_groups,
2618            &no_grants,
2619            &v8_mac,
2620            Some(&key)
2621        ));
2622    }
2623
2624    #[test]
2625    fn mac_v8_policy_tags_are_unambiguous() {
2626        // A crafted tag must not collide with a different tag list: ["a\tb"] and
2627        // ["a", "b"] previously hashed identically under a separator-only scheme.
2628        let base = types::Vault {
2629            version: types::VAULT_VERSION.into(),
2630            created: "2026-02-28T00:00:00Z".into(),
2631            vault_name: ".murk".into(),
2632            repo: String::new(),
2633            recipients: vec!["age1abc".into()],
2634            schema: BTreeMap::new(),
2635            policy: None,
2636            secrets: BTreeMap::new(),
2637            meta: String::new(),
2638        };
2639        let key = [7u8; 32];
2640        let groups = BTreeMap::new();
2641        let grants = BTreeMap::new();
2642
2643        let mut a = base.clone();
2644        a.policy = Some(types::Policy {
2645            agent_allow_tags: vec!["a\tb".into()],
2646        });
2647        let mut b = base.clone();
2648        b.policy = Some(types::Policy {
2649            agent_allow_tags: vec!["a".into(), "b".into()],
2650        });
2651
2652        let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
2653        let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
2654        assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
2655    }
2656
2657    #[test]
2658    fn verify_mac_rejects_policy_under_legacy_prefix() {
2659        // Policy is only covered by v8. A vault carrying a policy but stamped
2660        // with an older MAC must not verify — otherwise an attacker could strip
2661        // or weaken the policy by downgrading the MAC.
2662        let vault = types::Vault {
2663            version: types::VAULT_VERSION.into(),
2664            created: "2026-02-28T00:00:00Z".into(),
2665            vault_name: ".murk".into(),
2666            repo: String::new(),
2667            recipients: vec!["age1abc".into()],
2668            schema: BTreeMap::new(),
2669            policy: Some(types::Policy {
2670                agent_allow_tags: vec!["agents".into()],
2671            }),
2672            secrets: BTreeMap::new(),
2673            meta: String::new(),
2674        };
2675        let key = [5u8; 32];
2676        let no_groups = BTreeMap::new();
2677        let no_grants = BTreeMap::new();
2678        // A v5 MAC (no policy in the digest) must be rejected once a policy exists.
2679        let v5_mac = compute_mac_v5(&vault, &key);
2680        assert!(v5_mac.starts_with("blake3v3:"));
2681        assert!(!verify_mac(
2682            &vault,
2683            &no_groups,
2684            &no_grants,
2685            &v5_mac,
2686            Some(&key)
2687        ));
2688    }
2689
2690    #[test]
2691    fn compute_mac_v5_covers_rotation_metadata() {
2692        let mut vault = types::Vault {
2693            version: types::VAULT_VERSION.into(),
2694            created: "2026-02-28T00:00:00Z".into(),
2695            vault_name: ".murk".into(),
2696            repo: String::new(),
2697            recipients: vec!["age1abc".into()],
2698            schema: BTreeMap::new(),
2699            policy: None,
2700            secrets: BTreeMap::new(),
2701            meta: String::new(),
2702        };
2703        vault.schema.insert(
2704            "API_KEY".into(),
2705            types::SchemaEntry {
2706                description: "Main API key".into(),
2707                updated: Some("2026-02-28T00:00:00Z".into()),
2708                ..Default::default()
2709            },
2710        );
2711
2712        let key = [0u8; 32];
2713        let baseline = compute_mac(
2714            &vault,
2715            &std::collections::BTreeMap::new(),
2716            &std::collections::BTreeMap::new(),
2717            Some(&key),
2718        );
2719
2720        // Setting a rotation interval changes the MAC — tamper-evident.
2721        vault
2722            .schema
2723            .get_mut("API_KEY")
2724            .unwrap()
2725            .rotation_interval_days = Some(90);
2726        let with_interval = compute_mac(
2727            &vault,
2728            &std::collections::BTreeMap::new(),
2729            &std::collections::BTreeMap::new(),
2730            Some(&key),
2731        );
2732        assert_ne!(baseline, with_interval);
2733
2734        // So does an expiry.
2735        vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
2736        let with_expiry = compute_mac(
2737            &vault,
2738            &std::collections::BTreeMap::new(),
2739            &std::collections::BTreeMap::new(),
2740            Some(&key),
2741        );
2742        assert_ne!(with_interval, with_expiry);
2743
2744        // v4 (which ignores these fields) is blind to the change — the reason
2745        // v5 exists. Confirms the new fields really are what moved the MAC.
2746        let mut cleared = vault.clone();
2747        cleared
2748            .schema
2749            .get_mut("API_KEY")
2750            .unwrap()
2751            .rotation_interval_days = None;
2752        cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
2753        assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
2754    }
2755
2756    #[test]
2757    fn compute_mac_v9_covers_revoked_at() {
2758        let mut vault = types::Vault {
2759            version: types::VAULT_VERSION.into(),
2760            created: "2026-02-28T00:00:00Z".into(),
2761            vault_name: ".murk".into(),
2762            repo: String::new(),
2763            recipients: vec!["age1abc".into()],
2764            schema: BTreeMap::new(),
2765            policy: None,
2766            secrets: BTreeMap::new(),
2767            meta: String::new(),
2768        };
2769        vault.schema.insert(
2770            "API_KEY".into(),
2771            types::SchemaEntry {
2772                description: "Main API key".into(),
2773                updated: Some("2026-02-28T00:00:00Z".into()),
2774                ..Default::default()
2775            },
2776        );
2777        let key = [0u8; 32];
2778        let groups = BTreeMap::new();
2779        let grants = BTreeMap::new();
2780
2781        // No marker → v8 falls through to v5 (no policy/grants/groups here).
2782        let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
2783        assert!(baseline.starts_with("blake3v3:"));
2784
2785        // Setting `revoked_at` switches the written scheme to v9 and changes the MAC.
2786        vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
2787        let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
2788        assert!(with_marker.starts_with("blake3v7:"));
2789        assert_ne!(baseline, with_marker);
2790
2791        // The v9 MAC round-trips, and a downgraded (v8) MAC is rejected while the
2792        // marker is present — an attacker can't clear it by stamping an older scheme.
2793        assert!(verify_mac(
2794            &vault,
2795            &groups,
2796            &grants,
2797            &with_marker,
2798            Some(&key)
2799        ));
2800        let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
2801        assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
2802
2803        // v8 (which ignores the marker) is blind to it — confirms `revoked_at` is
2804        // what moved the v9 digest, mirroring the v5 rotation-metadata test.
2805        let mut cleared = vault.clone();
2806        cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
2807        assert_eq!(
2808            compute_mac_v8(&vault, &groups, &grants, &key),
2809            compute_mac_v8(&cleared, &groups, &grants, &key)
2810        );
2811    }
2812
2813    #[test]
2814    fn compute_mac_changes_with_schema() {
2815        let mut vault = types::Vault {
2816            version: types::VAULT_VERSION.into(),
2817            created: "2026-02-28T00:00:00Z".into(),
2818            vault_name: ".murk".into(),
2819            repo: String::new(),
2820            recipients: vec!["age1abc".into()],
2821            schema: BTreeMap::new(),
2822            policy: None,
2823            secrets: BTreeMap::new(),
2824            meta: String::new(),
2825        };
2826
2827        let key = [0u8; 32];
2828        let mac_no_schema = compute_mac(
2829            &vault,
2830            &std::collections::BTreeMap::new(),
2831            &std::collections::BTreeMap::new(),
2832            Some(&key),
2833        );
2834
2835        vault.schema.insert(
2836            "API_KEY".into(),
2837            types::SchemaEntry {
2838                description: "Main API key".into(),
2839                tags: vec!["deploy".into()],
2840                ..Default::default()
2841            },
2842        );
2843
2844        let mac_with_schema = compute_mac(
2845            &vault,
2846            &std::collections::BTreeMap::new(),
2847            &std::collections::BTreeMap::new(),
2848            Some(&key),
2849        );
2850        assert_ne!(mac_no_schema, mac_with_schema);
2851
2852        // Changing a tag changes the MAC
2853        let mac_before_retag = mac_with_schema;
2854        vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
2855        let mac_after_retag = compute_mac(
2856            &vault,
2857            &std::collections::BTreeMap::new(),
2858            &std::collections::BTreeMap::new(),
2859            Some(&key),
2860        );
2861        assert_ne!(mac_before_retag, mac_after_retag);
2862    }
2863
2864    #[test]
2865    fn mac_key_roundtrip() {
2866        let hex = generate_mac_key();
2867        assert_eq!(hex.len(), 64);
2868        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
2869
2870        let key = decode_mac_key(&hex).expect("valid hex should decode");
2871        // Re-encode and compare.
2872        let rehex = key.iter().fold(String::new(), |mut s, b| {
2873            use std::fmt::Write;
2874            let _ = write!(s, "{b:02x}");
2875            s
2876        });
2877        assert_eq!(hex, rehex);
2878    }
2879
2880    #[test]
2881    fn decode_mac_key_rejects_bad_input() {
2882        assert!(decode_mac_key("").is_none());
2883        assert!(decode_mac_key("tooshort").is_none());
2884        assert!(decode_mac_key(&"zz".repeat(32)).is_none()); // invalid hex
2885        assert!(decode_mac_key(&"aa".repeat(31)).is_none()); // 31 bytes
2886        assert!(decode_mac_key(&"aa".repeat(33)).is_none()); // 33 bytes
2887    }
2888
2889    #[test]
2890    fn blake3_mac_different_key_different_mac() {
2891        let vault = types::Vault {
2892            version: types::VAULT_VERSION.into(),
2893            created: "2026-02-28T00:00:00Z".into(),
2894            vault_name: ".murk".into(),
2895            repo: String::new(),
2896            recipients: vec!["age1abc".into()],
2897            schema: BTreeMap::new(),
2898            policy: None,
2899            secrets: BTreeMap::new(),
2900            meta: String::new(),
2901        };
2902
2903        let key1 = [0u8; 32];
2904        let key2 = [1u8; 32];
2905        let mac1 = compute_mac(
2906            &vault,
2907            &std::collections::BTreeMap::new(),
2908            &std::collections::BTreeMap::new(),
2909            Some(&key1),
2910        );
2911        let mac2 = compute_mac(
2912            &vault,
2913            &std::collections::BTreeMap::new(),
2914            &std::collections::BTreeMap::new(),
2915            Some(&key2),
2916        );
2917        assert_ne!(mac1, mac2);
2918    }
2919
2920    #[test]
2921    fn valid_key_names() {
2922        assert!(is_valid_key_name("DATABASE_URL"));
2923        assert!(is_valid_key_name("_PRIVATE"));
2924        assert!(is_valid_key_name("A"));
2925        assert!(is_valid_key_name("key123"));
2926    }
2927
2928    #[test]
2929    fn invalid_key_names() {
2930        assert!(!is_valid_key_name(""));
2931        assert!(!is_valid_key_name("123_START"));
2932        assert!(!is_valid_key_name("KEY-NAME"));
2933        assert!(!is_valid_key_name("KEY NAME"));
2934        assert!(!is_valid_key_name("FOO$(bar)"));
2935        assert!(!is_valid_key_name("KEY=VAL"));
2936    }
2937
2938    #[test]
2939    fn now_utc_format() {
2940        let ts = now_utc();
2941        assert!(ts.ends_with('Z'));
2942        assert_eq!(ts.len(), 20);
2943        assert_eq!(&ts[4..5], "-");
2944        assert_eq!(&ts[7..8], "-");
2945        assert_eq!(&ts[10..11], "T");
2946    }
2947}