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 mod pins;
36pub(crate) mod policy;
37pub(crate) mod recipients;
38pub mod recovery;
39pub mod scan;
40pub(crate) mod secrets;
41pub mod signing;
42pub mod types;
43pub mod vault;
44
45#[cfg(feature = "python")]
46mod python;
47
48// Shared test utilities
49#[cfg(test)]
50pub mod testutil;
51
52// Re-exports: keep the flat murk_cli::foo() API for main.rs
53pub use agent::{AgentPlan, AgentPlanKey, agent_plan, format_agent_plan_text};
54pub use env::{
55    EnvrcStatus, KeySource, agent_key_file_path, agent_keys_dir, dotenv_has_murk_key,
56    key_file_path, parse_env, resolve_key, resolve_key_for_vault, resolve_key_with_source,
57    warn_env_permissions, write_envrc, write_key_ref_to_dotenv, write_key_to_dotenv,
58    write_key_to_file,
59};
60pub use error::MurkError;
61pub use export::{
62    DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
63    parse_and_decrypt_values, resolve_secrets,
64};
65pub use git::{CommitSignature, MergeDriverSetupStep, last_commit_signature, setup_merge_driver};
66pub use github::{GitHubError, fetch_keys};
67pub use grants::{create_grant, parse_ttl, remove_grant, validate_grant_name};
68pub use groups::{
69    add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name,
70};
71pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info};
72pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
73pub use merge::{MergeDriverOutput, run_merge_driver};
74pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity, is_agent_key_allowed};
75pub use recipients::{
76    RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
77    list_recipients, revoke_recipient, truncate_pubkey,
78};
79pub use secrets::{
80    EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
81    import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
82};
83
84use std::collections::{BTreeMap, BTreeSet, HashMap};
85use std::path::Path;
86
87/// Check whether a key name is a valid shell identifier (safe for `export KEY=...`).
88/// Must start with a letter or underscore, and contain only `[A-Za-z0-9_]`.
89pub fn is_valid_key_name(key: &str) -> bool {
90    !key.is_empty()
91        && key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
92        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
93}
94
95use age::secrecy::ExposeSecret;
96use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
97use zeroize::Zeroizing;
98
99// Re-export polymorphic types for consumers.
100pub use crypto::{MurkIdentity, MurkRecipient};
101
102/// Decrypt the meta blob from a vault, returning the deserialized Meta if possible.
103pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
104    if vault.meta.is_empty() {
105        return None;
106    }
107    let plaintext = decrypt_value(&vault.meta, identity).ok()?;
108    serde_json::from_slice(&plaintext).ok()
109}
110
111/// Parse a list of pubkey strings into recipients (age or SSH).
112pub(crate) fn parse_recipients(
113    pubkeys: &[String],
114) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
115    pubkeys
116        .iter()
117        .map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
118        .collect()
119}
120
121/// Encrypt a value and return base64-encoded ciphertext.
122pub fn encrypt_value(
123    plaintext: &[u8],
124    recipients: &[crypto::MurkRecipient],
125) -> Result<String, MurkError> {
126    let ciphertext = crypto::encrypt(plaintext, recipients)?;
127    Ok(BASE64.encode(&ciphertext))
128}
129
130/// Decrypt a base64-encoded ciphertext and return plaintext bytes.
131///
132/// The returned buffer is zeroized on drop.
133pub fn decrypt_value(
134    encoded: &str,
135    identity: &crypto::MurkIdentity,
136) -> Result<Zeroizing<Vec<u8>>, MurkError> {
137    let ciphertext = BASE64.decode(encoded).map_err(|e| {
138        MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
139    })?;
140    Ok(crypto::decrypt(&ciphertext, identity)?)
141}
142
143/// Validate decrypted bytes as UTF-8 and return a zeroizing `String`.
144///
145/// The returned `String` and the input `&[u8]` are both zeroized when dropped
146/// (assuming the caller holds the bytes inside a `Zeroizing`), so plaintext
147/// never escapes to a non-zeroed buffer.
148pub(crate) fn plaintext_bytes_to_zeroizing_string(
149    bytes: &[u8],
150) -> Result<Zeroizing<String>, std::str::Utf8Error> {
151    let s = std::str::from_utf8(bytes)?;
152    Ok(Zeroizing::new(s.to_owned()))
153}
154
155/// Read a vault file from disk.
156///
157/// This is a thin wrapper around `vault::read` for a convenient string-path API.
158pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
159    Ok(vault::read(Path::new(vault_path))?)
160}
161
162/// Resolve a vault path argument, walking up parent directories to discover the vault.
163///
164/// Mirrors how git finds `.git` and cargo finds `Cargo.toml`: if the user passed a bare
165/// filename (no path separator, not absolute) and it does not exist in the current
166/// directory, walk up from CWD looking for a file of that name. Stops at:
167///
168/// - a directory containing `.git` (the git root — don't escape the repo)
169/// - `$HOME` (don't traverse into parents of the user's home)
170/// - the filesystem root
171///
172/// If a match is found, returns the absolute path. Otherwise returns the input unchanged,
173/// so downstream error messages still reference what the user asked for.
174///
175/// Explicit paths (absolute, or containing `/` or `\`) are returned unchanged — the user
176/// told us exactly where to look, so don't second-guess them.
177pub fn resolve_vault_path(arg: &str) -> String {
178    use std::path::PathBuf;
179
180    // Explicit path: no traversal.
181    if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
182        return arg.to_string();
183    }
184
185    let Ok(cwd) = std::env::current_dir() else {
186        return arg.to_string();
187    };
188
189    // Found in CWD — nothing to discover.
190    if cwd.join(arg).exists() {
191        return arg.to_string();
192    }
193
194    let home = std::env::var_os("HOME").map(PathBuf::from);
195    let mut dir = cwd.as_path();
196    loop {
197        let candidate = dir.join(arg);
198        if candidate.exists() {
199            return candidate.to_string_lossy().into_owned();
200        }
201        // Stop at git root after checking this directory.
202        if dir.join(".git").exists() {
203            break;
204        }
205        // Stop at $HOME boundary (don't traverse above the user's home).
206        if let Some(ref h) = home
207            && dir == h.as_path()
208        {
209            break;
210        }
211        match dir.parent() {
212            Some(parent) => dir = parent,
213            None => break,
214        }
215    }
216
217    arg.to_string()
218}
219
220/// The non-secret state carried out of the encrypted meta blob after integrity
221/// verification: recipient names, group membership, agent grants, the
222/// legacy-MAC flag, and pinned GitHub fingerprints.
223struct MetaState {
224    recipients: HashMap<String, String>,
225    groups: BTreeMap<String, Vec<String>>,
226    grants: BTreeMap<String, types::GrantEntry>,
227    legacy_mac: bool,
228    github_pins: HashMap<String, Vec<String>>,
229    signers: BTreeMap<String, String>,
230    signature: types::SignatureState,
231}
232
233/// Determine the signature state of a decrypted meta, treating a present-but-
234/// invalid signature as tampering (hard error). An absent signature is
235/// `Unsigned` — integrity then rests on git, and the caller warns.
236fn check_signature(
237    vault: &types::Vault,
238    meta: &types::Meta,
239) -> Result<types::SignatureState, MurkError> {
240    match &meta.sig {
241        Some(sig) => {
242            if verify_vault_signature(
243                vault,
244                &meta.groups,
245                &meta.grants,
246                &meta.github_pins,
247                &meta.signers,
248                sig,
249            ) {
250                Ok(types::SignatureState::Signed {
251                    signer: sig.signer.clone(),
252                    // ssh-ed25519 keys are self-authenticating (vk in the recipient
253                    // string). age keys are anchored only by a matching local pin,
254                    // which `load_vault` confirms; default to not-yet-anchored here.
255                    anchored: sig.signer.starts_with("ssh-ed25519 "),
256                })
257            } else {
258                Err(MurkError::Integrity(
259                    "vault signature is invalid — it may have been tampered with, or a signer's \
260                     verifying key changed. Run `murk verify` for details"
261                        .into(),
262                ))
263            }
264        }
265        None => Ok(types::SignatureState::Unsigned),
266    }
267}
268
269/// Decrypt the meta blob and verify the vault's integrity MAC, returning the
270/// recipient/group/grant state. Errors if the vault has secrets but a missing or
271/// invalid MAC — a tampered or inconsistent vault should fail loudly here rather
272/// than surface a misleading decryption error later. An identity that cannot
273/// decrypt an intact meta blob is simply not a recipient (revoked or never
274/// authorized) and gets a "not a recipient" error, not a tamper warning.
275fn resolve_meta_state(
276    vault: &types::Vault,
277    identity: &crypto::MurkIdentity,
278) -> Result<MetaState, MurkError> {
279    if vault.meta.is_empty() {
280        if vault.secrets.is_empty() {
281            return Ok(MetaState {
282                recipients: HashMap::new(),
283                groups: BTreeMap::new(),
284                grants: BTreeMap::new(),
285                legacy_mac: false,
286                github_pins: HashMap::new(),
287                signers: BTreeMap::new(),
288                signature: types::SignatureState::Unsigned,
289            });
290        }
291        return Err(MurkError::Integrity(
292            "vault has secrets but no meta — vault may have been tampered with".into(),
293        ));
294    }
295
296    // The meta blob is present, so failing to decrypt it usually means this
297    // identity is not in the recipient set — revoked or never authorized. That
298    // is an access problem, not tampering. But the public header lists who
299    // SHOULD be able to decrypt: if our key is listed there and still can't
300    // open the meta, the header and ciphertext disagree — that reads as
301    // tampering, and saying "not a recipient" would hide it. Garbled base64 or
302    // JSON likewise means the blob itself was damaged.
303    //
304    // Accepted residual: an attacker who replaces the meta AND removes a key
305    // from the header produces a vault indistinguishable from a legitimate
306    // revocation — no client-side check can tell those apart, for any choice
307    // of message here. Git history is the audit trail for that case (see
308    // THREAT_MODEL.md).
309    let ciphertext = BASE64.decode(&vault.meta).map_err(|_| {
310        MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
311    })?;
312    let plaintext = match crypto::decrypt(&ciphertext, identity) {
313        Ok(plaintext) => plaintext,
314        // A plugin failure (missing age-plugin binary, declined touch) is an
315        // environment problem — report it as-is, not as an access verdict.
316        Err(e) if matches!(identity, crypto::MurkIdentity::Plugin { .. }) => {
317            return Err(e.into());
318        }
319        Err(_) => {
320            if identity
321                .pubkey_string()
322                .is_ok_and(|pk| is_listed_recipient(vault, &pk))
323            {
324                return Err(MurkError::Integrity(
325                    "your key is listed as a recipient but cannot decrypt the vault meta — vault may have been tampered with".into(),
326                ));
327            }
328            return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
329                "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
330            )));
331        }
332    };
333    let meta: types::Meta = serde_json::from_slice(&plaintext).map_err(|_| {
334        MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
335    })?;
336
337    if meta.mac.is_empty() {
338        if !vault.secrets.is_empty() {
339            return Err(MurkError::Integrity(
340                "vault has secrets but MAC is empty — vault may have been tampered with".into(),
341            ));
342        }
343        let signature = check_signature(vault, &meta)?;
344        return Ok(MetaState {
345            recipients: meta.recipients,
346            groups: meta.groups,
347            grants: meta.grants,
348            legacy_mac: false,
349            github_pins: meta.github_pins,
350            signers: meta.signers,
351            signature,
352        });
353    }
354
355    let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
356    if !verify_mac(
357        vault,
358        &meta.groups,
359        &meta.grants,
360        &meta.mac,
361        mac_key.as_ref(),
362    ) {
363        let expected = compute_mac(vault, &meta.groups, &meta.grants, mac_key.as_ref());
364        return Err(MurkError::Integrity(format!(
365            "vault may have been tampered with (expected {expected}, got {})",
366            meta.mac
367        )));
368    }
369    let legacy_mac = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
370    let signature = check_signature(vault, &meta)?;
371    Ok(MetaState {
372        recipients: meta.recipients,
373        groups: meta.groups,
374        grants: meta.grants,
375        legacy_mac,
376        github_pins: meta.github_pins,
377        signers: meta.signers,
378        signature,
379    })
380}
381
382/// Whether `pubkey` names one of the vault's public header recipients. SSH
383/// entries may be stored with a trailing comment while `pubkey_string()` drops
384/// it, so ssh keys compare by key type and blob only.
385fn is_listed_recipient(vault: &types::Vault, pubkey: &str) -> bool {
386    fn ssh_head(s: &str) -> Option<(&str, &str)> {
387        let mut it = s.split_whitespace();
388        match (it.next(), it.next()) {
389            (Some(kind), Some(blob)) if kind.starts_with("ssh-") => Some((kind, blob)),
390            _ => None,
391        }
392    }
393    vault.recipients.iter().any(|r| {
394        r == pubkey || matches!((ssh_head(r), ssh_head(pubkey)), (Some(a), Some(b)) if a == b)
395    })
396}
397
398/// Decrypt a vault using the given identity. Verifies integrity, decrypts all
399/// shared and scoped values, and returns the working state.
400///
401/// Use this when you already have a key (e.g. from a Python SDK or test harness).
402/// For the common CLI case where the key comes from the environment, use `load_vault`.
403pub fn decrypt_vault(
404    vault: &types::Vault,
405    identity: &crypto::MurkIdentity,
406) -> Result<types::Murk, MurkError> {
407    let pubkey = identity.pubkey_string()?;
408
409    // Verify integrity BEFORE decrypting secrets — a tampered vault should fail
410    // with an integrity error, not a misleading "you are not a recipient" message.
411    let MetaState {
412        recipients,
413        groups,
414        grants,
415        legacy_mac,
416        github_pins,
417        signers,
418        signature,
419    } = resolve_meta_state(vault, identity)?;
420
421    // An agent grant is a recipient of the meta blob (so it can verify integrity
422    // and read its grant) but is deliberately excluded from the shared "everyone"
423    // layer. Such an identity legitimately cannot decrypt shared ciphertexts, so
424    // it skips them rather than erroring. A normal recipient that fails to decrypt
425    // shared is a genuine problem (a true outsider already failed at meta
426    // decryption above), so it still gets the clear "not a recipient" error.
427    let is_agent = grants.values().any(|g| g.pubkey == pubkey);
428
429    // Decrypt shared values (skip scoped-only entries with empty shared ciphertext).
430    let mut values: HashMap<String, Zeroizing<String>> = HashMap::new();
431    for (key, entry) in &vault.secrets {
432        if entry.shared.is_empty() {
433            continue;
434        }
435        let plaintext = match decrypt_value(&entry.shared, identity) {
436            Ok(plaintext) => plaintext,
437            Err(_) if is_agent => continue,
438            Err(_) => {
439                return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
440                    "you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
441                )));
442            }
443        };
444        let value = plaintext_bytes_to_zeroizing_string(&plaintext)
445            .map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
446        values.insert(key.clone(), value);
447    }
448
449    // Decrypt our private (per-recipient) overrides — the `me` tier.
450    let mut private: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
451    for (key, entry) in &vault.secrets {
452        if let Some(encoded) = entry.private.get(&pubkey)
453            && let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
454                plaintext_bytes_to_zeroizing_string(&pt)
455                    .map_err(|e| MurkError::Secret(e.to_string()))
456            })
457        {
458            private
459                .entry(key.clone())
460                .or_default()
461                .insert(pubkey.clone(), value);
462        }
463    }
464
465    // Decrypt named-group values we're a member of. age tells us whether our
466    // identity is a recipient, so we just try each group ciphertext and keep the
467    // ones that decrypt — non-members silently fall through.
468    let mut grouped: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
469    for (key, entry) in &vault.secrets {
470        for (group, encoded) in &entry.grouped {
471            if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
472                plaintext_bytes_to_zeroizing_string(&pt)
473                    .map_err(|e| MurkError::Secret(e.to_string()))
474            }) {
475                grouped
476                    .entry(key.clone())
477                    .or_default()
478                    .insert(group.clone(), value);
479            }
480        }
481    }
482
483    Ok(types::Murk {
484        values,
485        recipients,
486        private,
487        grouped,
488        groups,
489        grants,
490        legacy_mac,
491        github_pins,
492        signers,
493        signature,
494    })
495}
496
497/// Resolve the key from the environment, read the vault, and decrypt it.
498///
499/// Convenience wrapper combining `resolve_key` + `read_vault` + `decrypt_vault`.
500pub fn load_vault(
501    vault_path: &str,
502) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
503    let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
504
505    let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
506        MurkError::Key(format!(
507            "{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
508        ))
509    })?;
510
511    let vault = read_vault(vault_path)?;
512    let mut murk = decrypt_vault(&vault, &identity)?;
513
514    // Enforce the signer-registry pin as part of the trusted load path, so
515    // bindings get it too — not just the CLI. The age `signers` registry lives in
516    // the re-encryptable meta, so a repo-writer could register their own verifying
517    // key under an existing recipient's pubkey and forge that recipient's
518    // signature (`verify_vault_signature` would accept it against the swapped
519    // key). A pubkey's verifying key is a fixed derivation, so a *changed* key for
520    // an already-pinned pubkey is never legitimate: fail hard. `MURK_NO_SIGNER_PIN`
521    // opts out.
522    match pins::reconcile(vault_path, &murk.signers) {
523        pins::PinVerdict::Conflict { signer } => {
524            return Err(MurkError::Integrity(format!(
525                "signer {signer}'s verifying key changed since first seen — the signer registry \
526                 may have been tampered with to forge a signature. Inspect \
527                 `git log -p -- {vault_path}`; if the change is legitimate, clear the pin under \
528                 ~/.config/murk/signer-pins/ or set MURK_NO_SIGNER_PIN=1"
529            )));
530        }
531        pins::PinVerdict::Ok { first_use } => {
532            // An age signature is authenticated authorship only once its key is
533            // anchored by a matching prior pin. On a fresh clone (first-use) the
534            // registry key is trust-on-first-use, so mark it not-yet-anchored —
535            // git commit signing is the real anchor there. (ssh signers were
536            // already anchored=true in `check_signature`.)
537            if let types::SignatureState::Signed { signer, anchored } = &mut murk.signature
538                && !*anchored
539                && !first_use.contains(signer.as_str())
540            {
541                *anchored = true;
542            }
543        }
544    }
545
546    Ok((vault, murk, identity))
547}
548
549/// Re-encrypt a key's shared (everyone) ciphertext, reusing the existing one
550/// when the value and recipient set are unchanged (for minimal git diffs).
551fn rebuild_shared(
552    key: &str,
553    vault: &types::Vault,
554    recipients: &[crypto::MurkRecipient],
555    recipients_changed: bool,
556    original: &types::Murk,
557    current: &types::Murk,
558) -> Result<String, MurkError> {
559    let Some(value) = current.values.get(key) else {
560        // Scoped/group-only key — no shared ciphertext.
561        return Ok(String::new());
562    };
563    // Reuse the stored ciphertext when the value and recipient set are unchanged.
564    if !recipients_changed
565        && original.values.get(key) == Some(value)
566        && let Some(existing) = vault.secrets.get(key)
567    {
568        return Ok(existing.shared.clone());
569    }
570    encrypt_value(value.as_bytes(), recipients)
571}
572
573/// Re-encrypt a key's scoped (per-recipient) ciphertexts, keeping unchanged
574/// entries and dropping ones removed since load.
575fn rebuild_private(
576    key: &str,
577    vault: &types::Vault,
578    original: &types::Murk,
579    current: &types::Murk,
580) -> Result<BTreeMap<String, String>, MurkError> {
581    let mut scoped = vault
582        .secrets
583        .get(key)
584        .map(|e| e.private.clone())
585        .unwrap_or_default();
586
587    if let Some(key_scoped) = current.private.get(key) {
588        for (pk, val) in key_scoped {
589            let original_val = original.private.get(key).and_then(|m| m.get(pk));
590            if original_val != Some(val) {
591                let recipient = crypto::parse_recipient(pk)?;
592                scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
593            }
594        }
595    }
596
597    if let Some(orig_key_scoped) = original.private.get(key) {
598        for pk in orig_key_scoped.keys() {
599            let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
600            if !still_present {
601                scoped.remove(pk);
602            }
603        }
604    }
605
606    Ok(scoped)
607}
608
609/// Re-encrypt a key's named-group ciphertexts to each group's current members.
610/// Re-encrypts when the value changed or the group's membership changed; drops
611/// groups removed since load.
612fn rebuild_grouped(
613    key: &str,
614    vault: &types::Vault,
615    changed_groups: &BTreeSet<&str>,
616    original: &types::Murk,
617    current: &types::Murk,
618) -> Result<BTreeMap<String, String>, MurkError> {
619    let mut grouped = vault
620        .secrets
621        .get(key)
622        .map(|e| e.grouped.clone())
623        .unwrap_or_default();
624
625    if let Some(key_grouped) = current.grouped.get(key) {
626        for (group, val) in key_grouped {
627            let members = current.groups.get(group).ok_or_else(|| {
628                MurkError::Secret(format!("secret {key} references unknown group {group}"))
629            })?;
630            let original_val = original.grouped.get(key).and_then(|m| m.get(group));
631            if original_val != Some(val) || changed_groups.contains(group.as_str()) {
632                let group_recipients = parse_recipients(members)?;
633                grouped.insert(
634                    group.clone(),
635                    encrypt_value(val.as_bytes(), &group_recipients)?,
636                );
637            }
638        }
639    }
640
641    if let Some(orig_key_grouped) = original.grouped.get(key) {
642        for group in orig_key_grouped.keys() {
643            let still_present = current
644                .grouped
645                .get(key)
646                .is_some_and(|m| m.contains_key(group));
647            if !still_present {
648                grouped.remove(group);
649            }
650        }
651    }
652
653    Ok(grouped)
654}
655
656/// Keep each active grant's private copy of `key` in sync with the key's current
657/// shared value. A grant stages a per-agent private copy at grant time; without
658/// this, rotating a granted key would leave the agent reading the stale value
659/// (the operator can't see the agent's ciphertext to re-encrypt it, and
660/// `rebuild_private` preserves it as-is). When the value changed since load and
661/// the operator can read it, re-encrypt the agent's copy; unchanged values keep
662/// their preserved ciphertext (no churn), and keys the operator can't read are
663/// left untouched.
664fn resync_grant_private(
665    key: &str,
666    private: &mut BTreeMap<String, String>,
667    original: &types::Murk,
668    current: &types::Murk,
669) -> Result<(), MurkError> {
670    let Some(value) = current.values.get(key) else {
671        return Ok(());
672    };
673    if original.values.get(key) == Some(value) {
674        return Ok(());
675    }
676    for grant in current.grants.values() {
677        if grant.scope.iter().any(|k| k == key) {
678            let recipient = crypto::parse_recipient(&grant.pubkey)?;
679            private.insert(
680                grant.pubkey.clone(),
681                encrypt_value(value.as_bytes(), &[recipient])?,
682            );
683        }
684    }
685    Ok(())
686}
687
688/// Save the vault: compare against original state and only re-encrypt changed values.
689/// Unchanged values keep their original ciphertext for minimal git diffs.
690pub fn save_vault(
691    vault_path: &str,
692    vault: &mut types::Vault,
693    original: &types::Murk,
694    current: &types::Murk,
695) -> Result<(), MurkError> {
696    // The full recipient set encrypts the meta blob, so every recipient —
697    // including agent grants — can verify integrity and read group/grant state.
698    let recipients = parse_recipients(&vault.recipients)?;
699
700    // Agent grant pubkeys are deliberately excluded from the shared "everyone"
701    // layer: a granted agent must read only the scoped values granted to it, not
702    // every shared secret. They remain meta recipients (above) but never receive
703    // the shared ciphertext.
704    let grant_pubkeys: BTreeSet<&str> =
705        current.grants.values().map(|g| g.pubkey.as_str()).collect();
706    let shared_recipients: Vec<crypto::MurkRecipient> = vault
707        .recipients
708        .iter()
709        .filter(|pk| !grant_pubkeys.contains(pk.as_str()))
710        .map(|pk| crypto::parse_recipient(pk))
711        .collect::<Result<_, _>>()?;
712
713    // Check if the *shared* recipient set (recipients minus agent grants) changed
714    // — that forces full re-encryption of shared values. Adding or removing an
715    // agent doesn't change this set, so it doesn't needlessly churn shared
716    // ciphertext (and never pulls an agent into the shared layer).
717    let shared_recipients_changed = {
718        let orig_grant_pubkeys: BTreeSet<&str> = original
719            .grants
720            .values()
721            .map(|g| g.pubkey.as_str())
722            .collect();
723        let mut current_pks: Vec<&str> = vault
724            .recipients
725            .iter()
726            .map(String::as_str)
727            .filter(|pk| !grant_pubkeys.contains(pk))
728            .collect();
729        let mut original_pks: Vec<&str> = original
730            .recipients
731            .keys()
732            .map(String::as_str)
733            .filter(|pk| !orig_grant_pubkeys.contains(pk))
734            .collect();
735        current_pks.sort_unstable();
736        original_pks.sort_unstable();
737        current_pks != original_pks
738    };
739
740    // Groups whose membership changed since load — their secrets must be
741    // re-encrypted even when the plaintext is unchanged, so a removed member
742    // loses access (and a new one gains it).
743    let changed_groups: BTreeSet<&str> = current
744        .groups
745        .keys()
746        .chain(original.groups.keys())
747        .filter(|g| current.groups.get(*g) != original.groups.get(*g))
748        .map(String::as_str)
749        .collect();
750
751    let mut new_secrets = BTreeMap::new();
752
753    // Collect all keys with a shared, scoped, or grouped value in the operator's
754    // working state.
755    let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
756    all_keys.extend(current.private.keys());
757    all_keys.extend(current.grouped.keys());
758
759    // Preserve on-disk secrets the operator can't see (other groups' values, or
760    // other recipients' scoped entries). These never enter the decrypted `Murk`,
761    // so without this they'd be silently dropped when a non-member saves. A key
762    // the operator *deleted* was visible at load (in `original`) and is excluded,
763    // so deletions still take effect.
764    let original_visible: BTreeSet<&String> = original
765        .values
766        .keys()
767        .chain(original.private.keys())
768        .chain(original.grouped.keys())
769        .collect();
770    for key in vault.secrets.keys() {
771        if !original_visible.contains(key) {
772            all_keys.insert(key);
773        }
774    }
775
776    for key in all_keys {
777        let shared = rebuild_shared(
778            key,
779            vault,
780            &shared_recipients,
781            shared_recipients_changed,
782            original,
783            current,
784        )?;
785        let mut private = rebuild_private(key, vault, original, current)?;
786        resync_grant_private(key, &mut private, original, current)?;
787        let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
788        new_secrets.insert(
789            key.clone(),
790            types::SecretEntry {
791                shared,
792                private,
793                grouped,
794            },
795        );
796    }
797
798    vault.secrets = new_secrets;
799
800    let meta = build_meta(vault_path, vault, current);
801    let meta_json =
802        serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
803    vault.meta = encrypt_value(&meta_json, &recipients)?;
804
805    Ok(vault::write(Path::new(vault_path), vault)?)
806}
807
808/// Build the meta blob for a save: a fresh MAC key + MAC, and a signature when
809/// the operator holds a signing-capable identity (see [`sign_vault`]). The
810/// signer registry is carried forward from `current` so every recipient's
811/// verifying key persists across saves.
812fn build_meta(vault_path: &str, vault: &types::Vault, current: &types::Murk) -> types::Meta {
813    // Always generate a fresh BLAKE3 key on save.
814    let mac_key_hex = generate_mac_key();
815    let mac_key = decode_mac_key(&mac_key_hex).unwrap();
816    let mac = compute_mac(vault, &current.groups, &current.grants, Some(&mac_key));
817
818    // SSH/hardware identities can't sign, so the vault is written unsigned (a
819    // warning surfaced on next load).
820    let mut signers = current.signers.clone();
821    // Drop registry entries for pubkeys no longer in the recipient set — a
822    // revoked recipient's verifying key is inert (verify requires the signer to
823    // be a current recipient) but shouldn't linger. Prune BEFORE signing so the
824    // signed message matches the stored `signers`. (ssh-ed25519 signers are never
825    // registered, so only age entries are affected.)
826    signers.retain(|pk, _| vault.recipients.iter().any(|r| r == pk));
827    let sig = signing_identity(vault_path).and_then(|identity| {
828        sign_vault(
829            vault,
830            &current.groups,
831            &current.grants,
832            &current.github_pins,
833            &mut signers,
834            &identity,
835        )
836    });
837
838    types::Meta {
839        recipients: current.recipients.clone(),
840        mac,
841        mac_key: Some(mac_key_hex),
842        github_pins: current.github_pins.clone(),
843        groups: current.groups.clone(),
844        grants: current.grants.clone(),
845        signers,
846        sig,
847    }
848}
849
850/// Compute an integrity MAC over the vault's secrets, scoped entries, grouped
851/// entries, recipients, schema, and group membership.
852///
853/// With a key and at least one group, uses BLAKE3 keyed hash v6 (`blake3v4:`),
854/// which additionally covers the grouped ciphertexts and group definitions. With
855/// a key and no groups, uses v5 (`blake3v3:`) so group-free vaults stay
856/// byte-identical to before groups existed. Without a key, falls back to unkeyed
857/// SHA-256 v2 for legacy compatibility.
858pub(crate) fn compute_mac(
859    vault: &types::Vault,
860    groups: &BTreeMap<String, Vec<String>>,
861    grants: &BTreeMap<String, types::GrantEntry>,
862    mac_key: Option<&[u8; 32]>,
863) -> String {
864    match mac_key {
865        Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
866            compute_mac_v9(vault, groups, grants, key)
867        }
868        Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
869        Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
870        Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
871        Some(key) => compute_mac_v5(vault, key),
872        None => compute_mac_v2(vault),
873    }
874}
875
876/// Legacy MAC: covers key names, shared ciphertext, and recipients (no scoped).
877fn compute_mac_v1(vault: &types::Vault) -> String {
878    use sha2::{Digest, Sha256};
879
880    let mut hasher = Sha256::new();
881
882    for key in vault.secrets.keys() {
883        hasher.update(key.as_bytes());
884        hasher.update(b"\x00");
885    }
886
887    for entry in vault.secrets.values() {
888        hasher.update(entry.shared.as_bytes());
889        hasher.update(b"\x00");
890    }
891
892    let mut pks = vault.recipients.clone();
893    pks.sort();
894    for pk in &pks {
895        hasher.update(pk.as_bytes());
896        hasher.update(b"\x00");
897    }
898
899    let digest = hasher.finalize();
900    format!(
901        "sha256:{}",
902        digest.iter().fold(String::new(), |mut s, b| {
903            use std::fmt::Write;
904            let _ = write!(s, "{b:02x}");
905            s
906        })
907    )
908}
909
910/// V2 MAC: covers key names, shared ciphertext, scoped entries, and recipients.
911fn compute_mac_v2(vault: &types::Vault) -> String {
912    use sha2::{Digest, Sha256};
913
914    let mut hasher = Sha256::new();
915
916    // Hash sorted key names.
917    for key in vault.secrets.keys() {
918        hasher.update(key.as_bytes());
919        hasher.update(b"\x00");
920    }
921
922    // Hash encrypted shared values (as stored).
923    for entry in vault.secrets.values() {
924        hasher.update(entry.shared.as_bytes());
925        hasher.update(b"\x00");
926
927        // Hash scoped entries (sorted by pubkey for determinism).
928        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
929        scoped_pks.sort();
930        for pk in scoped_pks {
931            hasher.update(pk.as_bytes());
932            hasher.update(b"\x01");
933            hasher.update(entry.private[pk].as_bytes());
934            hasher.update(b"\x00");
935        }
936    }
937
938    // Hash sorted recipient pubkeys.
939    let mut pks = vault.recipients.clone();
940    pks.sort();
941    for pk in &pks {
942        hasher.update(pk.as_bytes());
943        hasher.update(b"\x00");
944    }
945
946    let digest = hasher.finalize();
947    format!(
948        "sha256v2:{}",
949        digest.iter().fold(String::new(), |mut s, b| {
950            use std::fmt::Write;
951            let _ = write!(s, "{b:02x}");
952            s
953        })
954    )
955}
956
957/// V3 MAC: BLAKE3 keyed hash over the same inputs as v2.
958fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
959    let mut data = Vec::new();
960
961    for key_name in vault.secrets.keys() {
962        data.extend_from_slice(key_name.as_bytes());
963        data.push(0x00);
964    }
965
966    for entry in vault.secrets.values() {
967        data.extend_from_slice(entry.shared.as_bytes());
968        data.push(0x00);
969
970        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
971        scoped_pks.sort();
972        for pk in scoped_pks {
973            data.extend_from_slice(pk.as_bytes());
974            data.push(0x01);
975            data.extend_from_slice(entry.private[pk].as_bytes());
976            data.push(0x00);
977        }
978    }
979
980    let mut pks = vault.recipients.clone();
981    pks.sort();
982    for pk in &pks {
983        data.extend_from_slice(pk.as_bytes());
984        data.push(0x00);
985    }
986
987    let hash = blake3::keyed_hash(key, &data);
988    format!("blake3:{hash}")
989}
990
991/// V4 MAC: BLAKE3 keyed hash over secrets, recipients, AND schema.
992/// Prefix `blake3v2:` distinguishes from v3 which omitted schema.
993fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
994    let mut data = Vec::new();
995
996    for key_name in vault.secrets.keys() {
997        data.extend_from_slice(key_name.as_bytes());
998        data.push(0x00);
999    }
1000
1001    for entry in vault.secrets.values() {
1002        data.extend_from_slice(entry.shared.as_bytes());
1003        data.push(0x00);
1004
1005        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1006        scoped_pks.sort();
1007        for pk in scoped_pks {
1008            data.extend_from_slice(pk.as_bytes());
1009            data.push(0x01);
1010            data.extend_from_slice(entry.private[pk].as_bytes());
1011            data.push(0x00);
1012        }
1013    }
1014
1015    let mut pks = vault.recipients.clone();
1016    pks.sort();
1017    for pk in &pks {
1018        data.extend_from_slice(pk.as_bytes());
1019        data.push(0x00);
1020    }
1021
1022    // Schema: include descriptions, examples, and tags for each key.
1023    // Uses 0x02 separator to distinguish from secrets/recipients data.
1024    for (key_name, entry) in &vault.schema {
1025        data.push(0x02);
1026        data.extend_from_slice(key_name.as_bytes());
1027        data.push(0x00);
1028        data.extend_from_slice(entry.description.as_bytes());
1029        data.push(0x00);
1030        if let Some(example) = &entry.example {
1031            data.extend_from_slice(example.as_bytes());
1032        }
1033        data.push(0x00);
1034        for tag in &entry.tags {
1035            data.extend_from_slice(tag.as_bytes());
1036            data.push(0x00);
1037        }
1038    }
1039
1040    let hash = blake3::keyed_hash(key, &data);
1041    format!("blake3v2:{hash}")
1042}
1043
1044/// V5 MAC: extends v4 to also cover each schema entry's lifecycle metadata —
1045/// `created`, `updated`, `rotation_interval_days`, and `expires_at`. This makes
1046/// rotation policy tamper-evident, so strict mode can treat it as a trustworthy
1047/// machine-checkable signal rather than freely-editable plaintext. Prefix
1048/// `blake3v3:` distinguishes it from v4 which stopped at description/example/tags.
1049fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
1050    let mut data = Vec::new();
1051
1052    for key_name in vault.secrets.keys() {
1053        data.extend_from_slice(key_name.as_bytes());
1054        data.push(0x00);
1055    }
1056
1057    for entry in vault.secrets.values() {
1058        data.extend_from_slice(entry.shared.as_bytes());
1059        data.push(0x00);
1060
1061        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1062        scoped_pks.sort();
1063        for pk in scoped_pks {
1064            data.extend_from_slice(pk.as_bytes());
1065            data.push(0x01);
1066            data.extend_from_slice(entry.private[pk].as_bytes());
1067            data.push(0x00);
1068        }
1069    }
1070
1071    let mut pks = vault.recipients.clone();
1072    pks.sort();
1073    for pk in &pks {
1074        data.extend_from_slice(pk.as_bytes());
1075        data.push(0x00);
1076    }
1077
1078    // Schema: description, example, tags (as in v4) plus lifecycle metadata.
1079    // Optional fields are emitted as their bytes (empty when absent) followed by
1080    // a 0x00 terminator, so present/absent stays deterministic. `0x02` separates
1081    // each schema entry from the secrets/recipients stream above.
1082    for (key_name, entry) in &vault.schema {
1083        data.push(0x02);
1084        data.extend_from_slice(key_name.as_bytes());
1085        data.push(0x00);
1086        data.extend_from_slice(entry.description.as_bytes());
1087        data.push(0x00);
1088        if let Some(example) = &entry.example {
1089            data.extend_from_slice(example.as_bytes());
1090        }
1091        data.push(0x00);
1092        for tag in &entry.tags {
1093            data.extend_from_slice(tag.as_bytes());
1094            data.push(0x00);
1095        }
1096        // Lifecycle metadata (new in v5). Strings go in as UTF-8; the interval
1097        // goes in as its decimal text for consistency with the rest of the stream.
1098        if let Some(created) = &entry.created {
1099            data.extend_from_slice(created.as_bytes());
1100        }
1101        data.push(0x00);
1102        if let Some(updated) = &entry.updated {
1103            data.extend_from_slice(updated.as_bytes());
1104        }
1105        data.push(0x00);
1106        if let Some(days) = entry.rotation_interval_days {
1107            data.extend_from_slice(days.to_string().as_bytes());
1108        }
1109        data.push(0x00);
1110        if let Some(expires) = &entry.expires_at {
1111            data.extend_from_slice(expires.as_bytes());
1112        }
1113        data.push(0x00);
1114    }
1115
1116    let hash = blake3::keyed_hash(key, &data);
1117    format!("blake3v3:{hash}")
1118}
1119
1120/// Append the v5/v6 schema byte stream to `data`. Kept identical to the inline
1121/// loop in `compute_mac_v5` so v6 reuses the exact schema encoding without
1122/// risking a change to v5's bytes.
1123fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
1124    for (key_name, entry) in &vault.schema {
1125        data.push(0x02);
1126        data.extend_from_slice(key_name.as_bytes());
1127        data.push(0x00);
1128        data.extend_from_slice(entry.description.as_bytes());
1129        data.push(0x00);
1130        if let Some(example) = &entry.example {
1131            data.extend_from_slice(example.as_bytes());
1132        }
1133        data.push(0x00);
1134        for tag in &entry.tags {
1135            data.extend_from_slice(tag.as_bytes());
1136            data.push(0x00);
1137        }
1138        if let Some(created) = &entry.created {
1139            data.extend_from_slice(created.as_bytes());
1140        }
1141        data.push(0x00);
1142        if let Some(updated) = &entry.updated {
1143            data.extend_from_slice(updated.as_bytes());
1144        }
1145        data.push(0x00);
1146        if let Some(days) = entry.rotation_interval_days {
1147            data.extend_from_slice(days.to_string().as_bytes());
1148        }
1149        data.push(0x00);
1150        if let Some(expires) = &entry.expires_at {
1151            data.extend_from_slice(expires.as_bytes());
1152        }
1153        data.push(0x00);
1154    }
1155}
1156
1157/// Append the v6 byte stream (secrets, scoped, grouped ciphertexts, recipients,
1158/// schema, and group definitions) to `data`. Factored out so v7 can extend the
1159/// exact same bytes without risking a change to v6's encoding.
1160fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
1161    for key_name in vault.secrets.keys() {
1162        data.extend_from_slice(key_name.as_bytes());
1163        data.push(0x00);
1164    }
1165
1166    for entry in vault.secrets.values() {
1167        data.extend_from_slice(entry.shared.as_bytes());
1168        data.push(0x00);
1169
1170        let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
1171        scoped_pks.sort();
1172        for pk in scoped_pks {
1173            data.extend_from_slice(pk.as_bytes());
1174            data.push(0x01);
1175            data.extend_from_slice(entry.private[pk].as_bytes());
1176            data.push(0x00);
1177        }
1178
1179        // Grouped ciphertexts, sorted by group name. `0x03` marks each entry so
1180        // the group stream can't be confused with the scoped (`0x01`) stream.
1181        let mut group_names: Vec<&String> = entry.grouped.keys().collect();
1182        group_names.sort();
1183        for g in group_names {
1184            data.push(0x03);
1185            data.extend_from_slice(g.as_bytes());
1186            data.push(0x00);
1187            data.extend_from_slice(entry.grouped[g].as_bytes());
1188            data.push(0x00);
1189        }
1190    }
1191
1192    let mut pks = vault.recipients.clone();
1193    pks.sort();
1194    for pk in &pks {
1195        data.extend_from_slice(pk.as_bytes());
1196        data.push(0x00);
1197    }
1198
1199    schema_mac_bytes(vault, data);
1200
1201    // Group definitions (sorted by name; members sorted). `0x04` separates each
1202    // group, `0x05` each member, so membership can't be tampered with undetected.
1203    for (name, members) in groups {
1204        data.push(0x04);
1205        data.extend_from_slice(name.as_bytes());
1206        data.push(0x00);
1207        let mut sorted = members.clone();
1208        sorted.sort();
1209        for member in &sorted {
1210            data.push(0x05);
1211            data.extend_from_slice(member.as_bytes());
1212        }
1213    }
1214}
1215
1216/// v6 MAC (`blake3v4:`). Extends v5 with the per-secret grouped ciphertexts and
1217/// the group membership map, so a named group's members and the values encrypted
1218/// to them cannot be tampered with undetected. Only emitted once a vault has at
1219/// least one group; group-free vaults keep writing v5 and stay byte-identical.
1220fn compute_mac_v6(
1221    vault: &types::Vault,
1222    groups: &BTreeMap<String, Vec<String>>,
1223    key: &[u8; 32],
1224) -> String {
1225    let mut data = Vec::new();
1226    v6_mac_bytes(vault, groups, &mut data);
1227    let hash = blake3::keyed_hash(key, &data);
1228    format!("blake3v4:{hash}")
1229}
1230
1231/// v7 MAC (`blake3v5:`). Extends v6 with agent grant metadata — each grant's
1232/// name, ephemeral pubkey, sorted scope, issued_at, expires_at, and issuer — so
1233/// a grant's TTL and scope cannot be tampered with undetected. Only emitted once
1234/// a vault has at least one grant; grant-free vaults keep writing v5/v6 and stay
1235/// byte-identical.
1236/// Append the v7 byte stream (v6 bytes plus agent grant metadata) to `data`.
1237/// Factored out so v8 can extend the exact same bytes without risking a change
1238/// to v7's encoding.
1239fn v7_mac_bytes(
1240    vault: &types::Vault,
1241    groups: &BTreeMap<String, Vec<String>>,
1242    grants: &BTreeMap<String, types::GrantEntry>,
1243    data: &mut Vec<u8>,
1244) {
1245    v6_mac_bytes(vault, groups, data);
1246
1247    // Grants (BTreeMap → sorted by name). `0x06` separates each grant; fixed
1248    // fields are 0x00-terminated; each scope key is prefixed `0x07` (sorted), so
1249    // the grant stream can't be confused with the group (`0x04`/`0x05`) stream.
1250    for (name, grant) in grants {
1251        data.push(0x06);
1252        data.extend_from_slice(name.as_bytes());
1253        data.push(0x00);
1254        data.extend_from_slice(grant.pubkey.as_bytes());
1255        data.push(0x00);
1256        data.extend_from_slice(grant.issued_at.as_bytes());
1257        data.push(0x00);
1258        data.extend_from_slice(grant.expires_at.as_bytes());
1259        data.push(0x00);
1260        data.extend_from_slice(grant.issuer.as_bytes());
1261        data.push(0x00);
1262        let mut scope = grant.scope.clone();
1263        scope.sort();
1264        for k in &scope {
1265            data.push(0x07);
1266            data.extend_from_slice(k.as_bytes());
1267        }
1268    }
1269}
1270
1271fn compute_mac_v7(
1272    vault: &types::Vault,
1273    groups: &BTreeMap<String, Vec<String>>,
1274    grants: &BTreeMap<String, types::GrantEntry>,
1275    key: &[u8; 32],
1276) -> String {
1277    let mut data = Vec::new();
1278    v7_mac_bytes(vault, groups, grants, &mut data);
1279    let hash = blake3::keyed_hash(key, &data);
1280    format!("blake3v5:{hash}")
1281}
1282
1283/// Append the v8 byte stream (v7 bytes plus the header policy block) to `data`.
1284/// Factored out so v9 can extend the exact same bytes without risking a change
1285/// to v8's encoding.
1286fn v8_mac_bytes(
1287    vault: &types::Vault,
1288    groups: &BTreeMap<String, Vec<String>>,
1289    grants: &BTreeMap<String, types::GrantEntry>,
1290    data: &mut Vec<u8>,
1291) {
1292    v7_mac_bytes(vault, groups, grants, data);
1293
1294    // Policy (header). `0x08` opens the policy block (present only when a policy
1295    // exists, so Some-but-empty is distinct from None). Each agent allow-tag is
1296    // length-prefixed (4-byte big-endian) and sorted, so the byte stream is
1297    // unambiguous regardless of tag contents — a crafted tag can't forge a
1298    // boundary (e.g. `["a\tb"]` and `["a", "b"]` hash differently). New policy
1299    // fields extend this block.
1300    if let Some(policy) = &vault.policy {
1301        data.push(0x08);
1302        let mut tags = policy.agent_allow_tags.clone();
1303        tags.sort();
1304        for tag in &tags {
1305            let bytes = tag.as_bytes();
1306            // usize→u64 is lossless on supported targets; fixed-width length
1307            // prefix keeps the encoding unambiguous.
1308            data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
1309            data.extend_from_slice(bytes);
1310        }
1311    }
1312}
1313
1314/// v8 MAC (`blake3v6:`). Extends v7 with the plaintext header policy object, so a
1315/// vault's agent access policy cannot be weakened or stripped undetected. Only
1316/// emitted once a vault has a policy; policy-free vaults keep writing v5/v6/v7
1317/// and stay byte-identical.
1318fn compute_mac_v8(
1319    vault: &types::Vault,
1320    groups: &BTreeMap<String, Vec<String>>,
1321    grants: &BTreeMap<String, types::GrantEntry>,
1322    key: &[u8; 32],
1323) -> String {
1324    let mut data = Vec::new();
1325    v8_mac_bytes(vault, groups, grants, &mut data);
1326    let hash = blake3::keyed_hash(key, &data);
1327    format!("blake3v6:{hash}")
1328}
1329
1330/// v9 MAC (`blake3v7:`). Extends v8 with each schema entry's `revoked_at` marker,
1331/// so the "still owed a rotation since a revoke" flag is tamper-evident — an
1332/// attacker editing `.murk` can't silently clear it. Only emitted once a vault
1333/// has at least one `revoked_at` set; vaults without one keep writing v5–v8 and
1334/// stay byte-identical.
1335fn compute_mac_v9(
1336    vault: &types::Vault,
1337    groups: &BTreeMap<String, Vec<String>>,
1338    grants: &BTreeMap<String, types::GrantEntry>,
1339    key: &[u8; 32],
1340) -> String {
1341    let mut data = Vec::new();
1342    v8_mac_bytes(vault, groups, grants, &mut data);
1343
1344    // Revoked-at markers, in schema order (BTreeMap → sorted by key name). `0x09`
1345    // opens each marker so the stream can't be confused with the schema (`0x02`)
1346    // or policy (`0x08`) blocks; absent markers emit nothing, so a vault that
1347    // sets one then clears it hashes identically to one that never set it.
1348    for (key_name, entry) in &vault.schema {
1349        if let Some(revoked_at) = &entry.revoked_at {
1350            data.push(0x09);
1351            data.extend_from_slice(key_name.as_bytes());
1352            data.push(0x00);
1353            data.extend_from_slice(revoked_at.as_bytes());
1354            data.push(0x00);
1355        }
1356    }
1357
1358    let hash = blake3::keyed_hash(key, &data);
1359    format!("blake3v7:{hash}")
1360}
1361
1362/// Verify a stored MAC against the vault, accepting v1, v2, blake3, blake3v2,
1363/// blake3v3, blake3v4, blake3v5, blake3v6, and blake3v7 schemes.
1364pub(crate) fn verify_mac(
1365    vault: &types::Vault,
1366    groups: &BTreeMap<String, Vec<String>>,
1367    grants: &BTreeMap<String, types::GrantEntry>,
1368    stored_mac: &str,
1369    mac_key: Option<&[u8; 32]>,
1370) -> bool {
1371    use constant_time_eq::constant_time_eq;
1372
1373    // `revoked_at` is only covered by v9 (`blake3v7:`). A vault carrying one but
1374    // stamped with an older MAC is tampered or inconsistent — reject it so an
1375    // attacker can't clear a pending-rotation flag by downgrading the MAC.
1376    if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
1377    {
1378        return false;
1379    }
1380
1381    // Policy is covered by v8 (`blake3v6:`) and v9 (`blake3v7:`). A vault carrying
1382    // a policy but stamped with an older MAC is tampered or inconsistent — reject
1383    // it so an attacker can't strip or weaken the policy by downgrading the MAC.
1384    if vault.policy.is_some()
1385        && !stored_mac.starts_with("blake3v6:")
1386        && !stored_mac.starts_with("blake3v7:")
1387    {
1388        return false;
1389    }
1390
1391    // Grant metadata is covered by v7 (`blake3v5:`) and up. A vault carrying
1392    // grants but stamped with an older MAC is tampered or inconsistent.
1393    if !grants.is_empty()
1394        && !stored_mac.starts_with("blake3v5:")
1395        && !stored_mac.starts_with("blake3v6:")
1396        && !stored_mac.starts_with("blake3v7:")
1397    {
1398        return false;
1399    }
1400
1401    // Group data is covered by v6 and up. A vault carrying any grouped ciphertext
1402    // or group membership but stamped with an older MAC is either tampered (an
1403    // attacker injected a `grouped` entry that the old MAC ignores, then relies on
1404    // group-before-shared resolution) or inconsistent. Reject it rather than
1405    // verify against a scheme that doesn't cover groups.
1406    let touches_groups =
1407        !groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
1408    if touches_groups
1409        && !stored_mac.starts_with("blake3v4:")
1410        && !stored_mac.starts_with("blake3v5:")
1411        && !stored_mac.starts_with("blake3v6:")
1412        && !stored_mac.starts_with("blake3v7:")
1413    {
1414        return false;
1415    }
1416
1417    let expected = if stored_mac.starts_with("blake3v7:") {
1418        match mac_key {
1419            Some(key) => compute_mac_v9(vault, groups, grants, key),
1420            None => return false,
1421        }
1422    } else if stored_mac.starts_with("blake3v6:") {
1423        match mac_key {
1424            Some(key) => compute_mac_v8(vault, groups, grants, key),
1425            None => return false,
1426        }
1427    } else if stored_mac.starts_with("blake3v5:") {
1428        match mac_key {
1429            Some(key) => compute_mac_v7(vault, groups, grants, key),
1430            None => return false,
1431        }
1432    } else if stored_mac.starts_with("blake3v4:") {
1433        match mac_key {
1434            Some(key) => compute_mac_v6(vault, groups, key),
1435            None => return false,
1436        }
1437    } else if stored_mac.starts_with("blake3v3:") {
1438        match mac_key {
1439            Some(key) => compute_mac_v5(vault, key),
1440            None => return false,
1441        }
1442    } else if stored_mac.starts_with("blake3v2:") {
1443        match mac_key {
1444            Some(key) => compute_mac_v4(vault, key),
1445            None => return false,
1446        }
1447    } else if stored_mac.starts_with("blake3:") {
1448        match mac_key {
1449            Some(key) => compute_mac_v3(vault, key),
1450            None => return false,
1451        }
1452    } else if stored_mac.starts_with("sha256v2:") {
1453        compute_mac_v2(vault)
1454    } else if stored_mac.starts_with("sha256:") {
1455        compute_mac_v1(vault)
1456    } else {
1457        return false;
1458    };
1459    constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
1460}
1461
1462/// Generate a random 32-byte BLAKE3 MAC key, returned as hex.
1463pub(crate) fn generate_mac_key() -> String {
1464    let key: [u8; 32] = rand::random();
1465    key.iter().fold(String::new(), |mut s, b| {
1466        use std::fmt::Write;
1467        let _ = write!(s, "{b:02x}");
1468        s
1469    })
1470}
1471
1472/// Decode a hex-encoded 32-byte key.
1473pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
1474    if hex.len() != 64 {
1475        return None;
1476    }
1477    let mut key = [0u8; 32];
1478    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
1479        key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
1480    }
1481    Some(key)
1482}
1483
1484/// Generate an ISO-8601 UTC timestamp.
1485pub(crate) fn now_utc() -> String {
1486    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1487}
1488
1489/// Version of the canonical signed-view serialization. Bumped if the set of
1490/// covered fields or their encoding changes, so an older binary refuses a newer
1491/// signature rather than misverifying it (mirrors the MAC-prefix downgrade guard).
1492const SIGNED_VIEW_VERSION: u32 = 1;
1493
1494/// Build the canonical, domain-tagged byte message that vault signatures cover.
1495///
1496/// Covers every security-relevant field — recipients, schema, secrets (all
1497/// tiers), policy, groups, grants, github pins, and the signer registry itself
1498/// (so a rogue verifying key can't be registered without breaking the signature).
1499/// It excludes the `sig` field it produces and the MAC/`mac_key` (a shared secret
1500/// the signature supersedes for authenticity). Determinism comes from sorted
1501/// maps (`BTreeMap`) and an explicitly sorted recipient list.
1502pub(crate) fn signing_message(
1503    vault: &types::Vault,
1504    groups: &BTreeMap<String, Vec<String>>,
1505    grants: &BTreeMap<String, types::GrantEntry>,
1506    github_pins: &HashMap<String, Vec<String>>,
1507    signers: &BTreeMap<String, String>,
1508) -> Vec<u8> {
1509    #[derive(serde::Serialize)]
1510    struct SignedView<'a> {
1511        v: u32,
1512        version: &'a str,
1513        recipients: Vec<&'a str>,
1514        schema: &'a BTreeMap<String, types::SchemaEntry>,
1515        secrets: &'a BTreeMap<String, types::SecretEntry>,
1516        policy: &'a Option<types::Policy>,
1517        groups: &'a BTreeMap<String, Vec<String>>,
1518        grants: &'a BTreeMap<String, types::GrantEntry>,
1519        github_pins: BTreeMap<&'a str, &'a Vec<String>>,
1520        signers: &'a BTreeMap<String, String>,
1521    }
1522
1523    let mut recipients: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
1524    recipients.sort_unstable();
1525    let pins: BTreeMap<&str, &Vec<String>> =
1526        github_pins.iter().map(|(k, v)| (k.as_str(), v)).collect();
1527
1528    let view = SignedView {
1529        v: SIGNED_VIEW_VERSION,
1530        version: &vault.version,
1531        recipients,
1532        schema: &vault.schema,
1533        secrets: &vault.secrets,
1534        policy: &vault.policy,
1535        groups,
1536        grants,
1537        github_pins: pins,
1538        signers,
1539    };
1540
1541    let mut msg = Vec::with_capacity(256);
1542    msg.extend_from_slice(b"murk.vault.sig.v1\n");
1543    serde_json::to_writer(&mut msg, &view).expect("canonical vault view serializes");
1544    msg
1545}
1546
1547/// Sign the vault with `identity` if it is signing-capable, registering its
1548/// verifying key in `signers`. Returns `None` for SSH/hardware identities that
1549/// cannot sign — the caller leaves the vault unsigned (a warning, not an error).
1550pub(crate) fn sign_vault(
1551    vault: &types::Vault,
1552    groups: &BTreeMap<String, Vec<String>>,
1553    grants: &BTreeMap<String, types::GrantEntry>,
1554    github_pins: &HashMap<String, Vec<String>>,
1555    signers: &mut BTreeMap<String, String>,
1556    identity: &crypto::MurkIdentity,
1557) -> Option<types::VaultSignature> {
1558    let signer = identity.pubkey_string().ok()?;
1559    // Only a current recipient's signature is meaningful — and verifiable, since
1560    // `verify_vault_signature` requires the signer to be a recipient. Signing as
1561    // a non-recipient would produce a signature that self-invalidates on load.
1562    if !signer_is_recipient(vault, &signer) {
1563        return None;
1564    }
1565    let sk = identity.signing_key()?;
1566    // age keys publish their verifying key in the registry (it can't be derived
1567    // from the public recipient). ssh-ed25519 keys don't: their verifying key is
1568    // recoverable from the recipient string, so they stay out of the registry.
1569    if identity.registers_verifying_key() {
1570        signers.insert(signer.clone(), signing::verifying_key_b64(&sk));
1571    }
1572    let msg = signing_message(vault, groups, grants, github_pins, signers);
1573    Some(types::VaultSignature {
1574        signer,
1575        v: SIGNED_VIEW_VERSION,
1576        sig: signing::sign(&sk, &msg),
1577    })
1578}
1579
1580/// Whether `signer` names a current recipient. ssh-ed25519 signers are matched
1581/// ignoring any comment on the stored recipient (a recipient may be stored as
1582/// `ssh-ed25519 <b64> user@host` while `signer` is the comment-stripped form).
1583fn signer_is_recipient(vault: &types::Vault, signer: &str) -> bool {
1584    if signer.starts_with("ssh-ed25519 ") {
1585        vault
1586            .recipients
1587            .iter()
1588            .any(|r| signing::ssh_ed25519_key_eq(r, signer))
1589    } else {
1590        vault.recipients.iter().any(|r| r == signer)
1591    }
1592}
1593
1594/// Verify a vault signature. Returns `true` only when the signed-view version is
1595/// understood, the signer is a current recipient, and the signature matches the
1596/// recomputed canonical message. The verifying key comes from the recipient
1597/// string for ssh-ed25519 signers (self-authenticating), or the `signers`
1598/// registry for age signers.
1599pub(crate) fn verify_vault_signature(
1600    vault: &types::Vault,
1601    groups: &BTreeMap<String, Vec<String>>,
1602    grants: &BTreeMap<String, types::GrantEntry>,
1603    github_pins: &HashMap<String, Vec<String>>,
1604    signers: &BTreeMap<String, String>,
1605    sig: &types::VaultSignature,
1606) -> bool {
1607    if sig.v != SIGNED_VIEW_VERSION {
1608        return false;
1609    }
1610    if !signer_is_recipient(vault, &sig.signer) {
1611        return false;
1612    }
1613    let vk = if sig.signer.starts_with("ssh-ed25519 ") {
1614        // Self-authenticating: the verifying key is in the signer string itself.
1615        match signing::ed25519_verifying_key_b64_from_ssh_recipient(&sig.signer) {
1616            Some(vk) => vk,
1617            None => return false,
1618        }
1619    } else {
1620        match signers.get(&sig.signer) {
1621            Some(vk) => vk.clone(),
1622            None => return false,
1623        }
1624    };
1625    let msg = signing_message(vault, groups, grants, github_pins, signers);
1626    signing::verify(&vk, &sig.sig, &msg)
1627}
1628
1629/// Resolve the operator's identity from the environment for signing on save.
1630/// Returns `None` when no key is configured — the vault is then written unsigned
1631/// rather than failing the save.
1632fn signing_identity(vault_path: &str) -> Option<crypto::MurkIdentity> {
1633    use age::secrecy::ExposeSecret;
1634    let secret = env::resolve_key_for_vault(vault_path).ok()?;
1635    crypto::parse_identity(secret.expose_secret()).ok()
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640    use super::*;
1641    use crate::testutil::*;
1642    use std::collections::BTreeMap;
1643    use std::fs;
1644
1645    use crate::testutil::ENV_LOCK;
1646
1647    #[test]
1648    fn resolve_vault_path_finds_in_parent_dir() {
1649        let _lock = ENV_LOCK
1650            .lock()
1651            .unwrap_or_else(std::sync::PoisonError::into_inner);
1652        let dir = tempfile::tempdir().unwrap();
1653        // Create a fake git repo with a vault at the root and a nested subdir.
1654        fs::create_dir(dir.path().join(".git")).unwrap();
1655        fs::write(dir.path().join(".murk"), "{}").unwrap();
1656        let nested = dir.path().join("a").join("b");
1657        fs::create_dir_all(&nested).unwrap();
1658
1659        let prev = std::env::current_dir().unwrap();
1660        std::env::set_current_dir(&nested).unwrap();
1661        let got = resolve_vault_path(".murk");
1662        std::env::set_current_dir(prev).unwrap();
1663
1664        assert_eq!(
1665            std::fs::canonicalize(&got).unwrap(),
1666            std::fs::canonicalize(dir.path().join(".murk")).unwrap()
1667        );
1668    }
1669
1670    #[test]
1671    fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
1672        let _lock = ENV_LOCK
1673            .lock()
1674            .unwrap_or_else(std::sync::PoisonError::into_inner);
1675        let dir = tempfile::tempdir().unwrap();
1676        fs::write(dir.path().join(".murk"), "{}").unwrap();
1677        let prev = std::env::current_dir().unwrap();
1678        std::env::set_current_dir(dir.path()).unwrap();
1679        let got = resolve_vault_path(".murk");
1680        std::env::set_current_dir(prev).unwrap();
1681        assert_eq!(got, ".murk");
1682    }
1683
1684    #[test]
1685    fn resolve_vault_path_passes_through_explicit_paths() {
1686        assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
1687        assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
1688        assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
1689    }
1690
1691    #[test]
1692    fn resolve_vault_path_stops_at_git_root() {
1693        let _lock = ENV_LOCK
1694            .lock()
1695            .unwrap_or_else(std::sync::PoisonError::into_inner);
1696        let dir = tempfile::tempdir().unwrap();
1697        // Vault lives OUTSIDE the git repo; traversal should not find it.
1698        fs::write(dir.path().join(".murk"), "{}").unwrap();
1699        let repo = dir.path().join("repo");
1700        fs::create_dir(&repo).unwrap();
1701        fs::create_dir(repo.join(".git")).unwrap();
1702        let nested = repo.join("sub");
1703        fs::create_dir(&nested).unwrap();
1704
1705        let prev = std::env::current_dir().unwrap();
1706        std::env::set_current_dir(&nested).unwrap();
1707        let got = resolve_vault_path(".murk");
1708        std::env::set_current_dir(prev).unwrap();
1709
1710        // Unchanged — we stopped at the git root and never saw the outer vault.
1711        assert_eq!(got, ".murk");
1712    }
1713
1714    #[test]
1715    fn encrypt_decrypt_value_roundtrip() {
1716        let (secret, pubkey) = generate_keypair();
1717        let recipient = make_recipient(&pubkey);
1718        let identity = make_identity(&secret);
1719
1720        let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
1721        let decrypted = decrypt_value(&encoded, &identity).unwrap();
1722        assert_eq!(&decrypted[..], b"hello world");
1723    }
1724
1725    #[test]
1726    fn decrypt_value_invalid_base64() {
1727        let (secret, _) = generate_keypair();
1728        let identity = make_identity(&secret);
1729
1730        let result = decrypt_value("not!valid!base64!!!", &identity);
1731        assert!(result.is_err());
1732        assert!(result.unwrap_err().to_string().contains("invalid base64"));
1733    }
1734
1735    #[test]
1736    fn encrypt_value_multiple_recipients() {
1737        let (secret_a, pubkey_a) = generate_keypair();
1738        let (secret_b, pubkey_b) = generate_keypair();
1739
1740        let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
1741        let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
1742
1743        // Both can decrypt.
1744        let id_a = make_identity(&secret_a);
1745        let id_b = make_identity(&secret_b);
1746        assert_eq!(
1747            &decrypt_value(&encoded, &id_a).unwrap()[..],
1748            b"shared secret"
1749        );
1750        assert_eq!(
1751            &decrypt_value(&encoded, &id_b).unwrap()[..],
1752            b"shared secret"
1753        );
1754    }
1755
1756    #[test]
1757    fn decrypt_value_wrong_key_fails() {
1758        let (_, pubkey) = generate_keypair();
1759        let (wrong_secret, _) = generate_keypair();
1760
1761        let recipient = make_recipient(&pubkey);
1762        let wrong_identity = make_identity(&wrong_secret);
1763
1764        let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
1765        assert!(decrypt_value(&encoded, &wrong_identity).is_err());
1766    }
1767
1768    #[test]
1769    fn compute_mac_deterministic() {
1770        let vault = types::Vault {
1771            version: types::VAULT_VERSION.into(),
1772            created: "2026-02-28T00:00:00Z".into(),
1773            vault_name: ".murk".into(),
1774            repo: String::new(),
1775            recipients: vec!["age1abc".into()],
1776            schema: BTreeMap::new(),
1777            policy: None,
1778            secrets: BTreeMap::new(),
1779            meta: String::new(),
1780        };
1781
1782        let key = [0u8; 32];
1783        let mac1 = compute_mac(
1784            &vault,
1785            &std::collections::BTreeMap::new(),
1786            &std::collections::BTreeMap::new(),
1787            Some(&key),
1788        );
1789        let mac2 = compute_mac(
1790            &vault,
1791            &std::collections::BTreeMap::new(),
1792            &std::collections::BTreeMap::new(),
1793            Some(&key),
1794        );
1795        assert_eq!(mac1, mac2);
1796        assert!(mac1.starts_with("blake3v3:"));
1797
1798        // Without key, falls back to sha256v2
1799        let mac_legacy = compute_mac(
1800            &vault,
1801            &std::collections::BTreeMap::new(),
1802            &std::collections::BTreeMap::new(),
1803            None,
1804        );
1805        assert!(mac_legacy.starts_with("sha256v2:"));
1806    }
1807
1808    #[test]
1809    fn compute_mac_changes_with_different_secrets() {
1810        let mut vault = types::Vault {
1811            version: types::VAULT_VERSION.into(),
1812            created: "2026-02-28T00:00:00Z".into(),
1813            vault_name: ".murk".into(),
1814            repo: String::new(),
1815            recipients: vec!["age1abc".into()],
1816            schema: BTreeMap::new(),
1817            policy: None,
1818            secrets: BTreeMap::new(),
1819            meta: String::new(),
1820        };
1821
1822        let key = [0u8; 32];
1823        let mac_empty = compute_mac(
1824            &vault,
1825            &std::collections::BTreeMap::new(),
1826            &std::collections::BTreeMap::new(),
1827            Some(&key),
1828        );
1829
1830        vault.secrets.insert(
1831            "KEY".into(),
1832            types::SecretEntry {
1833                shared: "ciphertext".into(),
1834                private: BTreeMap::new(),
1835                grouped: std::collections::BTreeMap::default(),
1836            },
1837        );
1838
1839        let mac_with_secret = compute_mac(
1840            &vault,
1841            &std::collections::BTreeMap::new(),
1842            &std::collections::BTreeMap::new(),
1843            Some(&key),
1844        );
1845        assert_ne!(mac_empty, mac_with_secret);
1846    }
1847
1848    #[test]
1849    fn compute_mac_changes_with_different_recipients() {
1850        let mut vault = types::Vault {
1851            version: types::VAULT_VERSION.into(),
1852            created: "2026-02-28T00:00:00Z".into(),
1853            vault_name: ".murk".into(),
1854            repo: String::new(),
1855            recipients: vec!["age1abc".into()],
1856            schema: BTreeMap::new(),
1857            policy: None,
1858            secrets: BTreeMap::new(),
1859            meta: String::new(),
1860        };
1861
1862        let key = [0u8; 32];
1863        let mac1 = compute_mac(
1864            &vault,
1865            &std::collections::BTreeMap::new(),
1866            &std::collections::BTreeMap::new(),
1867            Some(&key),
1868        );
1869        vault.recipients.push("age1xyz".into());
1870        let mac2 = compute_mac(
1871            &vault,
1872            &std::collections::BTreeMap::new(),
1873            &std::collections::BTreeMap::new(),
1874            Some(&key),
1875        );
1876        assert_ne!(mac1, mac2);
1877    }
1878
1879    /// Build a single-secret vault (value "REAL") with `recipients=[pubkey]`.
1880    fn signed_test_vault(pubkey: &str, recipient: &crypto::MurkRecipient) -> types::Vault {
1881        let mut vault = types::Vault {
1882            version: types::VAULT_VERSION.into(),
1883            created: "2026-02-28T00:00:00Z".into(),
1884            vault_name: ".murk".into(),
1885            repo: String::new(),
1886            recipients: vec![pubkey.to_string()],
1887            schema: BTreeMap::new(),
1888            policy: None,
1889            secrets: BTreeMap::new(),
1890            meta: String::new(),
1891        };
1892        vault.secrets.insert(
1893            "API_KEY".into(),
1894            types::SecretEntry {
1895                shared: encrypt_value(b"REAL", std::slice::from_ref(recipient)).unwrap(),
1896                private: BTreeMap::new(),
1897                grouped: BTreeMap::new(),
1898            },
1899        );
1900        vault
1901    }
1902
1903    #[test]
1904    fn sign_and_verify_vault_roundtrips() {
1905        let (secret, pubkey) = generate_keypair();
1906        let identity = make_identity(&secret);
1907        let vault = signed_test_vault(&pubkey, &make_recipient(&pubkey));
1908        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1909
1910        let mut signers = BTreeMap::new();
1911        let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1912        assert_eq!(sig.signer, pubkey);
1913        assert!(verify_vault_signature(
1914            &vault, &g, &gr, &pins, &signers, &sig
1915        ));
1916    }
1917
1918    #[test]
1919    fn signature_detects_ciphertext_tampering() {
1920        let (secret, pubkey) = generate_keypair();
1921        let identity = make_identity(&secret);
1922        let recipient = make_recipient(&pubkey);
1923        let mut vault = signed_test_vault(&pubkey, &recipient);
1924        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1925
1926        let mut signers = BTreeMap::new();
1927        let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
1928
1929        // Attacker swaps in a different (still readable) ciphertext but cannot
1930        // re-sign without a recipient's signing key.
1931        vault.secrets.get_mut("API_KEY").unwrap().shared =
1932            encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
1933        assert!(
1934            !verify_vault_signature(&vault, &g, &gr, &pins, &signers, &sig),
1935            "tampered ciphertext must fail signature verification"
1936        );
1937    }
1938
1939    #[test]
1940    fn signature_rejects_non_recipient_signer() {
1941        // Outsider knows the victim's pubkey and tampers, then signs with THEIR
1942        // OWN key and registers their own verifying key. Verification rejects it
1943        // because the signer is not a current recipient of the vault.
1944        let (_victim_secret, victim_pub) = generate_keypair();
1945        let (attacker_secret, attacker_pub) = generate_keypair();
1946        let attacker = make_identity(&attacker_secret);
1947        let vault = signed_test_vault(&victim_pub, &make_recipient(&victim_pub));
1948        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
1949
1950        // sign_vault refuses because the attacker isn't a recipient.
1951        let mut signers = BTreeMap::new();
1952        assert!(sign_vault(&vault, &g, &gr, &pins, &mut signers, &attacker).is_none());
1953
1954        // Even a hand-forged registry + signature is rejected: signer ∉ recipients.
1955        let sk = attacker.signing_key().unwrap();
1956        signers.insert(attacker_pub.clone(), signing::verifying_key_b64(&sk));
1957        let msg = signing_message(&vault, &g, &gr, &pins, &signers);
1958        let forged = types::VaultSignature {
1959            signer: attacker_pub,
1960            v: SIGNED_VIEW_VERSION,
1961            sig: signing::sign(&sk, &msg),
1962        };
1963        assert!(!verify_vault_signature(
1964            &vault, &g, &gr, &pins, &signers, &forged
1965        ));
1966    }
1967
1968    #[test]
1969    fn end_to_end_forged_signature_fails_load() {
1970        // The full attack from the review, now defeated: outsider tampers a
1971        // ciphertext, re-MACs with a fresh key, re-encrypts meta to the victim's
1972        // public key — but keeps the now-stale signature (they can't produce a
1973        // valid one). load must fail with an integrity error.
1974        let _lock = ENV_LOCK
1975            .lock()
1976            .unwrap_or_else(std::sync::PoisonError::into_inner);
1977        let (secret, pubkey) = generate_keypair();
1978        let recipient = make_recipient(&pubkey);
1979        let identity = make_identity(&secret);
1980
1981        let dir = std::env::temp_dir().join("murk_test_forged_sig_load");
1982        let _ = fs::remove_dir_all(&dir);
1983        fs::create_dir_all(&dir).unwrap();
1984        let path = dir.join("test.murk");
1985
1986        // Create and sign the vault through the real save path.
1987        let mut vault = signed_test_vault(&pubkey, &recipient);
1988        let original = types::Murk {
1989            values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
1990            recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
1991            ..Default::default()
1992        };
1993        unsafe { std::env::set_var("MURK_KEY", &secret) };
1994        unsafe { std::env::remove_var("MURK_KEY_FILE") };
1995        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
1996
1997        // Sanity: it loads clean and reports a signer.
1998        let murk = load_vault(path.to_str().unwrap()).unwrap().1;
1999        assert!(matches!(
2000            &murk.signature,
2001            types::SignatureState::Signed { signer, .. } if *signer == pubkey
2002        ));
2003
2004        // Attacker tampers on disk: poison the value, keep the stale signature,
2005        // re-MAC + re-encrypt meta using only the (public) recipient key.
2006        let mut tampered: types::Vault =
2007            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2008        let stale_meta = decrypt_meta(&tampered, &identity).unwrap();
2009        tampered.secrets.get_mut("API_KEY").unwrap().shared =
2010            encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2011        let mac_key_hex = generate_mac_key();
2012        let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2013        let forged_mac = compute_mac(
2014            &tampered,
2015            &stale_meta.groups,
2016            &stale_meta.grants,
2017            Some(&mac_key),
2018        );
2019        let forged_meta = types::Meta {
2020            mac: forged_mac,
2021            mac_key: Some(mac_key_hex),
2022            sig: stale_meta.sig.clone(), // stale — over the pre-poison content
2023            signers: stale_meta.signers.clone(),
2024            ..stale_meta
2025        };
2026        tampered.meta =
2027            encrypt_value(&serde_json::to_vec(&forged_meta).unwrap(), &[recipient]).unwrap();
2028        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2029
2030        let result = load_vault(path.to_str().unwrap());
2031        unsafe { std::env::remove_var("MURK_KEY") };
2032        let err = result.expect_err("forged-MAC + stale-signature vault must fail to load");
2033        assert!(
2034            err.to_string().contains("signature is invalid"),
2035            "expected signature failure, got: {err}"
2036        );
2037
2038        fs::remove_dir_all(&dir).unwrap();
2039    }
2040
2041    // A real unencrypted ssh-ed25519 keypair (shared with crypto.rs/signing.rs tests).
2042    const SSH_ED25519_SK: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
2043    const SSH_ED25519_PK: &str =
2044        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
2045
2046    #[test]
2047    fn ssh_signed_vault_verifies_without_registry() {
2048        // The self-authenticating property: an ssh-ed25519 signer is NOT added to
2049        // the registry, and verification succeeds against an EMPTY registry
2050        // because the verifying key comes from the recipient string.
2051        let identity = make_identity(SSH_ED25519_SK);
2052        let recipient = make_recipient(SSH_ED25519_PK);
2053        let vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2054        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2055
2056        let mut signers = BTreeMap::new();
2057        let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2058        assert_eq!(sig.signer, SSH_ED25519_PK);
2059        assert!(signers.is_empty(), "ssh signer must not be registered");
2060        assert!(verify_vault_signature(
2061            &vault,
2062            &g,
2063            &gr,
2064            &pins,
2065            &BTreeMap::new(),
2066            &sig
2067        ));
2068    }
2069
2070    #[test]
2071    fn ssh_signed_vault_detects_tampering() {
2072        let identity = make_identity(SSH_ED25519_SK);
2073        let recipient = make_recipient(SSH_ED25519_PK);
2074        let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2075        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2076
2077        let mut signers = BTreeMap::new();
2078        let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
2079        vault.secrets.get_mut("API_KEY").unwrap().shared =
2080            encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2081        assert!(!verify_vault_signature(
2082            &vault, &g, &gr, &pins, &signers, &sig
2083        ));
2084    }
2085
2086    #[test]
2087    fn ssh_recipient_stored_with_comment_still_signs_and_verifies() {
2088        // Regression for the comment-mismatch bug: recipient stored WITH a comment
2089        // while the identity's pubkey_string() is comment-stripped. Normalized
2090        // matching must let it sign and verify.
2091        let identity = make_identity(SSH_ED25519_SK);
2092        let recipient = make_recipient(SSH_ED25519_PK);
2093        let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2094        vault.recipients = vec![format!("{SSH_ED25519_PK} someone@host")];
2095        let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
2096
2097        let mut signers = BTreeMap::new();
2098        let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity)
2099            .expect("comment-bearing recipient must still sign");
2100        assert!(verify_vault_signature(
2101            &vault, &g, &gr, &pins, &signers, &sig
2102        ));
2103    }
2104
2105    #[test]
2106    fn ssh_end_to_end_save_and_load_reports_signed() {
2107        let _lock = ENV_LOCK
2108            .lock()
2109            .unwrap_or_else(std::sync::PoisonError::into_inner);
2110        let recipient = make_recipient(SSH_ED25519_PK);
2111
2112        let dir = std::env::temp_dir().join("murk_test_ssh_e2e_sign");
2113        let _ = fs::remove_dir_all(&dir);
2114        fs::create_dir_all(&dir).unwrap();
2115        let path = dir.join("test.murk");
2116
2117        let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
2118        let original = types::Murk {
2119            values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2120            recipients: HashMap::from([(SSH_ED25519_PK.to_string(), "alice".to_string())]),
2121            ..Default::default()
2122        };
2123        unsafe { std::env::set_var("MURK_KEY", SSH_ED25519_SK) };
2124        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2125        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2126
2127        let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2128        unsafe { std::env::remove_var("MURK_KEY") };
2129        // ssh-ed25519 signers are self-authenticating, so anchored even on first load.
2130        assert_eq!(
2131            murk.signature,
2132            types::SignatureState::Signed {
2133                signer: SSH_ED25519_PK.to_string(),
2134                anchored: true,
2135            }
2136        );
2137
2138        fs::remove_dir_all(&dir).unwrap();
2139    }
2140
2141    #[test]
2142    fn save_prunes_stale_signer_registry_entries() {
2143        // A signer entry for a pubkey no longer in the recipient set is dropped on
2144        // the next write, and the vault still verifies (prune happens before sign).
2145        let _lock = ENV_LOCK
2146            .lock()
2147            .unwrap_or_else(std::sync::PoisonError::into_inner);
2148        let (secret, pubkey) = generate_keypair();
2149        let recipient = make_recipient(&pubkey);
2150
2151        let dir = std::env::temp_dir().join("murk_test_prune_signers");
2152        let _ = fs::remove_dir_all(&dir);
2153        fs::create_dir_all(&dir).unwrap();
2154        let path = dir.join("test.murk");
2155
2156        let mut vault = signed_test_vault(&pubkey, &recipient);
2157        let current = types::Murk {
2158            values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2159            recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2160            // A stale registry entry for a pubkey that is NOT a recipient.
2161            signers: BTreeMap::from([(
2162                "age1stalerevokedrecipient".to_string(),
2163                BASE64.encode([9u8; 32]),
2164            )]),
2165            ..Default::default()
2166        };
2167        unsafe { std::env::set_var("MURK_KEY", &secret) };
2168        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2169        save_vault(path.to_str().unwrap(), &mut vault, &current, &current).unwrap();
2170
2171        let murk = load_vault(path.to_str().unwrap()).unwrap().1;
2172        unsafe { std::env::remove_var("MURK_KEY") };
2173        assert!(
2174            !murk.signers.contains_key("age1stalerevokedrecipient"),
2175            "stale non-recipient signer entry must be pruned"
2176        );
2177        assert!(
2178            murk.signers.contains_key(&pubkey),
2179            "live signer must remain"
2180        );
2181        assert!(matches!(
2182            murk.signature,
2183            types::SignatureState::Signed { signer, .. } if signer == pubkey
2184        ));
2185
2186        fs::remove_dir_all(&dir).unwrap();
2187    }
2188
2189    #[test]
2190    fn registry_vk_swap_rejected_by_pin_on_load() {
2191        // The signer registry lives in the re-encryptable meta. An attacker can
2192        // register their OWN verifying key under an existing recipient's pubkey
2193        // and forge a signature the signature layer accepts. The TOFU pin, now
2194        // enforced hard inside load_vault, must catch the changed key.
2195        let _lock = ENV_LOCK
2196            .lock()
2197            .unwrap_or_else(std::sync::PoisonError::into_inner);
2198        // Isolate the pin store under a temp HOME.
2199        let home = tempfile::tempdir().unwrap();
2200        let prev_home = std::env::var_os("HOME");
2201        unsafe { std::env::set_var("HOME", home.path()) };
2202        unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2203
2204        let (secret, pubkey) = generate_keypair();
2205        let recipient = make_recipient(&pubkey);
2206        let identity = make_identity(&secret);
2207
2208        let dir = std::env::temp_dir().join("murk_test_vk_swap");
2209        let _ = fs::remove_dir_all(&dir);
2210        fs::create_dir_all(&dir).unwrap();
2211        let path = dir.join("test.murk");
2212        let ps = path.to_str().unwrap();
2213
2214        // Legit signed vault; first load pins pubkey -> the real verifying key.
2215        let mut vault = signed_test_vault(&pubkey, &recipient);
2216        let original = types::Murk {
2217            values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2218            recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2219            ..Default::default()
2220        };
2221        unsafe { std::env::set_var("MURK_KEY", &secret) };
2222        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2223        save_vault(ps, &mut vault, &original, &original).unwrap();
2224        load_vault(ps).unwrap(); // establishes the pin
2225
2226        // Attacker registers their own verifying key under `pubkey` and re-signs
2227        // the poisoned vault with their own key, then re-MACs + re-encrypts meta.
2228        let att_sk = signing::signing_key_from_age_bytes(&[42u8; 32]);
2229        let att_vk = signing::verifying_key_b64(&att_sk);
2230        let mut tampered: types::Vault =
2231            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2232        let stale = decrypt_meta(&tampered, &identity).unwrap();
2233        tampered.secrets.get_mut("API_KEY").unwrap().shared =
2234            encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
2235        let mut signers = stale.signers.clone();
2236        signers.insert(pubkey.clone(), att_vk);
2237        let msg = signing_message(
2238            &tampered,
2239            &stale.groups,
2240            &stale.grants,
2241            &stale.github_pins,
2242            &signers,
2243        );
2244        let forged_sig = types::VaultSignature {
2245            signer: pubkey.clone(),
2246            v: SIGNED_VIEW_VERSION,
2247            sig: signing::sign(&att_sk, &msg),
2248        };
2249        // The signature layer alone IS fooled — it verifies against the swapped key.
2250        assert!(verify_vault_signature(
2251            &tampered,
2252            &stale.groups,
2253            &stale.grants,
2254            &stale.github_pins,
2255            &signers,
2256            &forged_sig
2257        ));
2258        let mac_key_hex = generate_mac_key();
2259        let mac_key = decode_mac_key(&mac_key_hex).unwrap();
2260        let mac = compute_mac(&tampered, &stale.groups, &stale.grants, Some(&mac_key));
2261        let forged_meta = types::Meta {
2262            mac,
2263            mac_key: Some(mac_key_hex),
2264            sig: Some(forged_sig),
2265            signers,
2266            ..stale
2267        };
2268        tampered.meta = encrypt_value(
2269            &serde_json::to_vec(&forged_meta).unwrap(),
2270            std::slice::from_ref(&recipient),
2271        )
2272        .unwrap();
2273        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2274
2275        // The pin catches the changed verifying key even though the signature verifies.
2276        let err = load_vault(ps).unwrap_err();
2277        unsafe { std::env::remove_var("MURK_KEY") };
2278        match prev_home {
2279            Some(v) => unsafe { std::env::set_var("HOME", v) },
2280            None => unsafe { std::env::remove_var("HOME") },
2281        }
2282        assert!(
2283            err.to_string().contains("verifying key changed"),
2284            "expected pin failure, got: {err}"
2285        );
2286
2287        fs::remove_dir_all(&dir).unwrap();
2288    }
2289
2290    #[test]
2291    fn age_signature_first_use_then_anchored() {
2292        // An age signature is trust-on-first-use until its key is pinned: the
2293        // first load reports it unanchored, a later load (key matches the pin)
2294        // reports it anchored.
2295        let _lock = ENV_LOCK
2296            .lock()
2297            .unwrap_or_else(std::sync::PoisonError::into_inner);
2298        let home = tempfile::tempdir().unwrap();
2299        let prev_home = std::env::var_os("HOME");
2300        unsafe { std::env::set_var("HOME", home.path()) };
2301        unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
2302
2303        let (secret, pubkey) = generate_keypair();
2304        let recipient = make_recipient(&pubkey);
2305        let dir = std::env::temp_dir().join("murk_test_anchor_transition");
2306        let _ = fs::remove_dir_all(&dir);
2307        fs::create_dir_all(&dir).unwrap();
2308        let path = dir.join("test.murk");
2309        let ps = path.to_str().unwrap();
2310
2311        let mut vault = signed_test_vault(&pubkey, &recipient);
2312        let original = types::Murk {
2313            values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
2314            recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
2315            ..Default::default()
2316        };
2317        unsafe { std::env::set_var("MURK_KEY", &secret) };
2318        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2319        save_vault(ps, &mut vault, &original, &original).unwrap();
2320
2321        let first = load_vault(ps).unwrap().1;
2322        let second = load_vault(ps).unwrap().1;
2323        unsafe { std::env::remove_var("MURK_KEY") };
2324        match prev_home {
2325            Some(v) => unsafe { std::env::set_var("HOME", v) },
2326            None => unsafe { std::env::remove_var("HOME") },
2327        }
2328
2329        assert_eq!(
2330            first.signature,
2331            types::SignatureState::Signed {
2332                signer: pubkey.clone(),
2333                anchored: false,
2334            },
2335            "first load of an age key is trust-on-first-use"
2336        );
2337        assert_eq!(
2338            second.signature,
2339            types::SignatureState::Signed {
2340                signer: pubkey,
2341                anchored: true,
2342            },
2343            "second load is anchored by the pin"
2344        );
2345
2346        fs::remove_dir_all(&dir).unwrap();
2347    }
2348
2349    #[test]
2350    fn save_vault_preserves_unchanged_ciphertext() {
2351        let (secret, pubkey) = generate_keypair();
2352        let recipient = make_recipient(&pubkey);
2353        let identity = make_identity(&secret);
2354
2355        let dir = std::env::temp_dir().join("murk_test_save_unchanged");
2356        fs::create_dir_all(&dir).unwrap();
2357        let path = dir.join("test.murk");
2358
2359        let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
2360        let mut vault = types::Vault {
2361            version: types::VAULT_VERSION.into(),
2362            created: "2026-02-28T00:00:00Z".into(),
2363            vault_name: ".murk".into(),
2364            repo: String::new(),
2365            recipients: vec![pubkey.clone()],
2366            schema: BTreeMap::new(),
2367            policy: None,
2368            secrets: BTreeMap::new(),
2369            meta: String::new(),
2370        };
2371        vault.secrets.insert(
2372            "KEY1".into(),
2373            types::SecretEntry {
2374                shared: shared.clone(),
2375                private: BTreeMap::new(),
2376                grouped: std::collections::BTreeMap::default(),
2377            },
2378        );
2379
2380        let mut recipients_map = HashMap::new();
2381        recipients_map.insert(pubkey.clone(), "alice".into());
2382        let original = types::Murk {
2383            values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
2384            recipients: recipients_map.clone(),
2385            private: HashMap::new(),
2386            legacy_mac: false,
2387            github_pins: HashMap::new(),
2388            ..Default::default()
2389        };
2390
2391        let current = original.clone();
2392        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
2393
2394        assert_eq!(vault.secrets["KEY1"].shared, shared);
2395
2396        let mut changed = current.clone();
2397        changed
2398            .values
2399            .insert("KEY1".into(), crate::testutil::secret("modified"));
2400        save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
2401
2402        assert_ne!(vault.secrets["KEY1"].shared, shared);
2403
2404        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
2405        assert_eq!(&decrypted[..], b"modified");
2406
2407        fs::remove_dir_all(&dir).unwrap();
2408    }
2409
2410    #[test]
2411    fn save_vault_adds_new_secret() {
2412        let (_, pubkey) = generate_keypair();
2413        let recipient = make_recipient(&pubkey);
2414
2415        let dir = std::env::temp_dir().join("murk_test_save_add");
2416        fs::create_dir_all(&dir).unwrap();
2417        let path = dir.join("test.murk");
2418
2419        let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
2420        let mut vault = types::Vault {
2421            version: types::VAULT_VERSION.into(),
2422            created: "2026-02-28T00:00:00Z".into(),
2423            vault_name: ".murk".into(),
2424            repo: String::new(),
2425            recipients: vec![pubkey.clone()],
2426            schema: BTreeMap::new(),
2427            policy: None,
2428            secrets: BTreeMap::new(),
2429            meta: String::new(),
2430        };
2431        vault.secrets.insert(
2432            "KEY1".into(),
2433            types::SecretEntry {
2434                shared,
2435                private: BTreeMap::new(),
2436                grouped: std::collections::BTreeMap::default(),
2437            },
2438        );
2439
2440        let mut recipients_map = HashMap::new();
2441        recipients_map.insert(pubkey.clone(), "alice".into());
2442        let original = types::Murk {
2443            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2444            recipients: recipients_map.clone(),
2445            private: HashMap::new(),
2446            legacy_mac: false,
2447            github_pins: HashMap::new(),
2448            ..Default::default()
2449        };
2450
2451        let mut current = original.clone();
2452        current
2453            .values
2454            .insert("KEY2".into(), crate::testutil::secret("val2"));
2455
2456        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
2457
2458        assert!(vault.secrets.contains_key("KEY1"));
2459        assert!(vault.secrets.contains_key("KEY2"));
2460
2461        fs::remove_dir_all(&dir).unwrap();
2462    }
2463
2464    #[test]
2465    fn save_vault_removes_deleted_secret() {
2466        let (_, pubkey) = generate_keypair();
2467        let recipient = make_recipient(&pubkey);
2468
2469        let dir = std::env::temp_dir().join("murk_test_save_remove");
2470        fs::create_dir_all(&dir).unwrap();
2471        let path = dir.join("test.murk");
2472
2473        let mut vault = types::Vault {
2474            version: types::VAULT_VERSION.into(),
2475            created: "2026-02-28T00:00:00Z".into(),
2476            vault_name: ".murk".into(),
2477            repo: String::new(),
2478            recipients: vec![pubkey.clone()],
2479            schema: BTreeMap::new(),
2480            policy: None,
2481            secrets: BTreeMap::new(),
2482            meta: String::new(),
2483        };
2484        vault.secrets.insert(
2485            "KEY1".into(),
2486            types::SecretEntry {
2487                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2488                private: BTreeMap::new(),
2489                grouped: std::collections::BTreeMap::default(),
2490            },
2491        );
2492        vault.secrets.insert(
2493            "KEY2".into(),
2494            types::SecretEntry {
2495                shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
2496                private: BTreeMap::new(),
2497                grouped: std::collections::BTreeMap::default(),
2498            },
2499        );
2500
2501        let mut recipients_map = HashMap::new();
2502        recipients_map.insert(pubkey.clone(), "alice".into());
2503        let original = types::Murk {
2504            values: HashMap::from([
2505                ("KEY1".into(), crate::testutil::secret("val1")),
2506                ("KEY2".into(), crate::testutil::secret("val2")),
2507            ]),
2508            recipients: recipients_map.clone(),
2509            private: HashMap::new(),
2510            legacy_mac: false,
2511            github_pins: HashMap::new(),
2512            ..Default::default()
2513        };
2514
2515        let mut current = original.clone();
2516        current.values.remove("KEY2");
2517
2518        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
2519
2520        assert!(vault.secrets.contains_key("KEY1"));
2521        assert!(!vault.secrets.contains_key("KEY2"));
2522
2523        fs::remove_dir_all(&dir).unwrap();
2524    }
2525
2526    #[test]
2527    fn save_vault_reencrypts_all_on_recipient_change() {
2528        let (secret1, pubkey1) = generate_keypair();
2529        let (_, pubkey2) = generate_keypair();
2530        let recipient1 = make_recipient(&pubkey1);
2531
2532        let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
2533        fs::create_dir_all(&dir).unwrap();
2534        let path = dir.join("test.murk");
2535
2536        let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
2537        let mut vault = types::Vault {
2538            version: types::VAULT_VERSION.into(),
2539            created: "2026-02-28T00:00:00Z".into(),
2540            vault_name: ".murk".into(),
2541            repo: String::new(),
2542            recipients: vec![pubkey1.clone(), pubkey2.clone()],
2543            schema: BTreeMap::new(),
2544            policy: None,
2545            secrets: BTreeMap::new(),
2546            meta: String::new(),
2547        };
2548        vault.secrets.insert(
2549            "KEY1".into(),
2550            types::SecretEntry {
2551                shared: shared.clone(),
2552                private: BTreeMap::new(),
2553                grouped: std::collections::BTreeMap::default(),
2554            },
2555        );
2556
2557        let mut recipients_map = HashMap::new();
2558        recipients_map.insert(pubkey1.clone(), "alice".into());
2559        let original = types::Murk {
2560            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2561            recipients: recipients_map,
2562            private: HashMap::new(),
2563            legacy_mac: false,
2564            github_pins: HashMap::new(),
2565            ..Default::default()
2566        };
2567
2568        let mut current_recipients = HashMap::new();
2569        current_recipients.insert(pubkey1.clone(), "alice".into());
2570        current_recipients.insert(pubkey2.clone(), "bob".into());
2571        let current = types::Murk {
2572            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2573            recipients: current_recipients,
2574            private: HashMap::new(),
2575            legacy_mac: false,
2576            github_pins: HashMap::new(),
2577            ..Default::default()
2578        };
2579
2580        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
2581
2582        assert_ne!(vault.secrets["KEY1"].shared, shared);
2583
2584        let identity1 = make_identity(&secret1);
2585        let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
2586        assert_eq!(&decrypted[..], b"val1");
2587
2588        fs::remove_dir_all(&dir).unwrap();
2589    }
2590
2591    #[test]
2592    fn save_vault_scoped_entry_lifecycle() {
2593        let (secret, pubkey) = generate_keypair();
2594        let recipient = make_recipient(&pubkey);
2595        let identity = make_identity(&secret);
2596
2597        let dir = std::env::temp_dir().join("murk_test_save_scoped");
2598        fs::create_dir_all(&dir).unwrap();
2599        let path = dir.join("test.murk");
2600
2601        let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
2602        let mut vault = types::Vault {
2603            version: types::VAULT_VERSION.into(),
2604            created: "2026-02-28T00:00:00Z".into(),
2605            vault_name: ".murk".into(),
2606            repo: String::new(),
2607            recipients: vec![pubkey.clone()],
2608            schema: BTreeMap::new(),
2609            policy: None,
2610            secrets: BTreeMap::new(),
2611            meta: String::new(),
2612        };
2613        vault.secrets.insert(
2614            "KEY1".into(),
2615            types::SecretEntry {
2616                shared,
2617                private: BTreeMap::new(),
2618                grouped: std::collections::BTreeMap::default(),
2619            },
2620        );
2621
2622        let mut recipients_map = HashMap::new();
2623        recipients_map.insert(pubkey.clone(), "alice".into());
2624        let original = types::Murk {
2625            values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
2626            recipients: recipients_map.clone(),
2627            private: HashMap::new(),
2628            legacy_mac: false,
2629            github_pins: HashMap::new(),
2630            ..Default::default()
2631        };
2632
2633        // Add a scoped override.
2634        let mut current = original.clone();
2635        let mut key_scoped = HashMap::new();
2636        key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
2637        current.private.insert("KEY1".into(), key_scoped);
2638
2639        save_vault(path.to_str().unwrap(), &mut vault, &original, &current).unwrap();
2640
2641        assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
2642        let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
2643        assert_eq!(&scoped_val[..], b"my_override");
2644
2645        // Now remove the scoped override.
2646        let original_with_scoped = current.clone();
2647        let mut current_no_scoped = original_with_scoped.clone();
2648        current_no_scoped.private.remove("KEY1");
2649
2650        save_vault(
2651            path.to_str().unwrap(),
2652            &mut vault,
2653            &original_with_scoped,
2654            &current_no_scoped,
2655        )
2656        .unwrap();
2657
2658        assert!(vault.secrets["KEY1"].private.is_empty());
2659
2660        fs::remove_dir_all(&dir).unwrap();
2661    }
2662
2663    #[test]
2664    fn load_vault_validates_mac() {
2665        let _lock = ENV_LOCK
2666            .lock()
2667            .unwrap_or_else(std::sync::PoisonError::into_inner);
2668
2669        let (secret, pubkey) = generate_keypair();
2670        let recipient = make_recipient(&pubkey);
2671        let _identity = make_identity(&secret);
2672
2673        let dir = std::env::temp_dir().join("murk_test_load_mac");
2674        let _ = fs::remove_dir_all(&dir);
2675        fs::create_dir_all(&dir).unwrap();
2676        let path = dir.join("test.murk");
2677
2678        // Build a vault with one secret, save it (computes valid MAC).
2679        let mut vault = types::Vault {
2680            version: types::VAULT_VERSION.into(),
2681            created: "2026-02-28T00:00:00Z".into(),
2682            vault_name: ".murk".into(),
2683            repo: String::new(),
2684            recipients: vec![pubkey.clone()],
2685            schema: BTreeMap::new(),
2686            policy: None,
2687            secrets: BTreeMap::new(),
2688            meta: String::new(),
2689        };
2690        vault.secrets.insert(
2691            "KEY1".into(),
2692            types::SecretEntry {
2693                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
2694                private: BTreeMap::new(),
2695                grouped: std::collections::BTreeMap::default(),
2696            },
2697        );
2698
2699        let mut recipients_map = HashMap::new();
2700        recipients_map.insert(pubkey.clone(), "alice".into());
2701        let original = types::Murk {
2702            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2703            recipients: recipients_map,
2704            private: HashMap::new(),
2705            legacy_mac: false,
2706            github_pins: HashMap::new(),
2707            ..Default::default()
2708        };
2709
2710        // save_vault needs MURK_KEY set to encrypt meta.
2711        unsafe { std::env::set_var("MURK_KEY", &secret) };
2712        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2713        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2714
2715        // Now tamper: change the ciphertext in the saved vault file.
2716        let mut tampered: types::Vault =
2717            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2718        tampered.secrets.get_mut("KEY1").unwrap().shared =
2719            encrypt_value(b"tampered", &[recipient]).unwrap();
2720        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2721
2722        // Load should fail MAC validation.
2723        let result = load_vault(path.to_str().unwrap());
2724        unsafe { std::env::remove_var("MURK_KEY") };
2725
2726        let err = result.expect_err("expected MAC validation to fail");
2727        assert!(
2728            err.to_string().contains("integrity check failed"),
2729            "expected integrity check failure, got: {err}"
2730        );
2731
2732        fs::remove_dir_all(&dir).unwrap();
2733    }
2734
2735    #[test]
2736    fn load_vault_succeeds_with_valid_mac() {
2737        let _lock = ENV_LOCK
2738            .lock()
2739            .unwrap_or_else(std::sync::PoisonError::into_inner);
2740
2741        let (secret, pubkey) = generate_keypair();
2742        let recipient = make_recipient(&pubkey);
2743
2744        let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
2745        let _ = fs::remove_dir_all(&dir);
2746        fs::create_dir_all(&dir).unwrap();
2747        let path = dir.join("test.murk");
2748
2749        let mut vault = types::Vault {
2750            version: types::VAULT_VERSION.into(),
2751            created: "2026-02-28T00:00:00Z".into(),
2752            vault_name: ".murk".into(),
2753            repo: String::new(),
2754            recipients: vec![pubkey.clone()],
2755            schema: BTreeMap::new(),
2756            policy: None,
2757            secrets: BTreeMap::new(),
2758            meta: String::new(),
2759        };
2760        vault.secrets.insert(
2761            "KEY1".into(),
2762            types::SecretEntry {
2763                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2764                private: BTreeMap::new(),
2765                grouped: std::collections::BTreeMap::default(),
2766            },
2767        );
2768
2769        let mut recipients_map = HashMap::new();
2770        recipients_map.insert(pubkey.clone(), "alice".into());
2771        let original = types::Murk {
2772            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2773            recipients: recipients_map,
2774            private: HashMap::new(),
2775            legacy_mac: false,
2776            github_pins: HashMap::new(),
2777            ..Default::default()
2778        };
2779
2780        unsafe { std::env::set_var("MURK_KEY", &secret) };
2781        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2782        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2783
2784        // Load should succeed.
2785        let result = load_vault(path.to_str().unwrap());
2786        unsafe { std::env::remove_var("MURK_KEY") };
2787
2788        assert!(result.is_ok());
2789        let (_, murk, _) = result.unwrap();
2790        assert_eq!(murk.values["KEY1"].as_str(), "val1");
2791
2792        fs::remove_dir_all(&dir).unwrap();
2793    }
2794
2795    #[test]
2796    fn load_vault_not_a_recipient() {
2797        let _lock = ENV_LOCK
2798            .lock()
2799            .unwrap_or_else(std::sync::PoisonError::into_inner);
2800
2801        let (secret, _pubkey) = generate_keypair();
2802        let (other_secret, other_pubkey) = generate_keypair();
2803        let other_recipient = make_recipient(&other_pubkey);
2804
2805        let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
2806        let _ = fs::remove_dir_all(&dir);
2807        fs::create_dir_all(&dir).unwrap();
2808        let path = dir.join("test.murk");
2809
2810        // Build a vault encrypted to `other`, not to `secret`.
2811        let mut vault = types::Vault {
2812            version: types::VAULT_VERSION.into(),
2813            created: "2026-02-28T00:00:00Z".into(),
2814            vault_name: ".murk".into(),
2815            repo: String::new(),
2816            recipients: vec![other_pubkey.clone()],
2817            schema: BTreeMap::new(),
2818            policy: None,
2819            secrets: BTreeMap::new(),
2820            meta: String::new(),
2821        };
2822        vault.secrets.insert(
2823            "KEY1".into(),
2824            types::SecretEntry {
2825                shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
2826                private: BTreeMap::new(),
2827                grouped: std::collections::BTreeMap::default(),
2828            },
2829        );
2830
2831        // Save via save_vault (needs the other key for re-encryption).
2832        let mut recipients_map = HashMap::new();
2833        recipients_map.insert(other_pubkey.clone(), "other".into());
2834        let original = types::Murk {
2835            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2836            recipients: recipients_map,
2837            private: HashMap::new(),
2838            legacy_mac: false,
2839            github_pins: HashMap::new(),
2840            ..Default::default()
2841        };
2842
2843        unsafe { std::env::set_var("MURK_KEY", &other_secret) };
2844        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2845        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2846
2847        // Now try to load with a key that is NOT a recipient.
2848        unsafe { std::env::set_var("MURK_KEY", secret) };
2849        let result = load_vault(path.to_str().unwrap());
2850        unsafe { std::env::remove_var("MURK_KEY") };
2851
2852        let Err(err) = result else {
2853            panic!("expected load_vault to fail for non-recipient");
2854        };
2855        // A non-recipient key gets a clean "not a recipient" error, not a
2856        // tamper warning — the meta blob is intact, it just isn't ours to read.
2857        let msg = err.to_string();
2858        assert!(
2859            msg.contains("not a recipient"),
2860            "expected not-a-recipient error, got: {err}"
2861        );
2862        assert!(
2863            !msg.contains("tampered"),
2864            "unauthorized key must not look like tampering, got: {err}"
2865        );
2866
2867        fs::remove_dir_all(&dir).unwrap();
2868    }
2869
2870    #[test]
2871    fn load_vault_zero_secrets() {
2872        let _lock = ENV_LOCK
2873            .lock()
2874            .unwrap_or_else(std::sync::PoisonError::into_inner);
2875
2876        let (secret, pubkey) = generate_keypair();
2877
2878        let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
2879        let _ = fs::remove_dir_all(&dir);
2880        fs::create_dir_all(&dir).unwrap();
2881        let path = dir.join("test.murk");
2882
2883        // Build a vault with no secrets at all.
2884        let mut vault = types::Vault {
2885            version: types::VAULT_VERSION.into(),
2886            created: "2026-02-28T00:00:00Z".into(),
2887            vault_name: ".murk".into(),
2888            repo: String::new(),
2889            recipients: vec![pubkey.clone()],
2890            schema: BTreeMap::new(),
2891            policy: None,
2892            secrets: BTreeMap::new(),
2893            meta: String::new(),
2894        };
2895
2896        let mut recipients_map = HashMap::new();
2897        recipients_map.insert(pubkey.clone(), "alice".into());
2898        let original = types::Murk {
2899            values: HashMap::new(),
2900            recipients: recipients_map,
2901            private: HashMap::new(),
2902            legacy_mac: false,
2903            github_pins: HashMap::new(),
2904            ..Default::default()
2905        };
2906
2907        unsafe { std::env::set_var("MURK_KEY", &secret) };
2908        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2909        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2910
2911        let result = load_vault(path.to_str().unwrap());
2912        unsafe { std::env::remove_var("MURK_KEY") };
2913
2914        assert!(result.is_ok());
2915        let (_, murk, _) = result.unwrap();
2916        assert!(murk.values.is_empty());
2917        assert!(murk.private.is_empty());
2918
2919        fs::remove_dir_all(&dir).unwrap();
2920    }
2921
2922    #[test]
2923    fn load_vault_stripped_meta_with_secrets_fails() {
2924        let _lock = ENV_LOCK
2925            .lock()
2926            .unwrap_or_else(std::sync::PoisonError::into_inner);
2927
2928        let (secret, pubkey) = generate_keypair();
2929        let recipient = make_recipient(&pubkey);
2930
2931        let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
2932        let _ = fs::remove_dir_all(&dir);
2933        fs::create_dir_all(&dir).unwrap();
2934        let path = dir.join("test.murk");
2935
2936        // Build a vault with one secret and a valid MAC via save_vault.
2937        let mut vault = types::Vault {
2938            version: types::VAULT_VERSION.into(),
2939            created: "2026-02-28T00:00:00Z".into(),
2940            vault_name: ".murk".into(),
2941            repo: String::new(),
2942            recipients: vec![pubkey.clone()],
2943            schema: BTreeMap::new(),
2944            policy: None,
2945            secrets: BTreeMap::new(),
2946            meta: String::new(),
2947        };
2948        vault.secrets.insert(
2949            "KEY1".into(),
2950            types::SecretEntry {
2951                shared: encrypt_value(b"val1", &[recipient]).unwrap(),
2952                private: BTreeMap::new(),
2953                grouped: std::collections::BTreeMap::default(),
2954            },
2955        );
2956
2957        let mut recipients_map = HashMap::new();
2958        recipients_map.insert(pubkey.clone(), "alice".into());
2959        let original = types::Murk {
2960            values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
2961            recipients: recipients_map,
2962            private: HashMap::new(),
2963            legacy_mac: false,
2964            github_pins: HashMap::new(),
2965            ..Default::default()
2966        };
2967
2968        unsafe { std::env::set_var("MURK_KEY", &secret) };
2969        unsafe { std::env::remove_var("MURK_KEY_FILE") };
2970        save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
2971
2972        // Tamper: strip meta field entirely.
2973        let mut tampered: types::Vault =
2974            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2975        tampered.meta = String::new();
2976        fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
2977
2978        // Load should fail: secrets present but no meta.
2979        let result = load_vault(path.to_str().unwrap());
2980
2981        let err = result.expect_err("expected MAC validation to fail");
2982        assert!(
2983            err.to_string().contains("integrity check failed"),
2984            "expected integrity check failure, got: {err}"
2985        );
2986
2987        // Tamper differently: garble the meta blob so it no longer decodes.
2988        // A recipient hitting damaged meta should still see an integrity
2989        // error, not "not a recipient".
2990        let mut garbled: types::Vault =
2991            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
2992        garbled.meta = "not-base64!!".into();
2993        fs::write(&path, serde_json::to_string_pretty(&garbled).unwrap()).unwrap();
2994
2995        let result = load_vault(path.to_str().unwrap());
2996
2997        let err = result.expect_err("expected corrupt meta to fail");
2998        assert!(
2999            err.to_string().contains("integrity check failed"),
3000            "expected integrity check failure, got: {err}"
3001        );
3002
3003        // Tamper again: valid base64 that fails authenticated decryption (a
3004        // byte-flipped meta blob). Our key is listed in the public header, so
3005        // this must read as tampering, not "not a recipient".
3006        let mut flipped: types::Vault =
3007            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3008        flipped.meta = BASE64.encode(b"flipped ciphertext bytes");
3009        fs::write(&path, serde_json::to_string_pretty(&flipped).unwrap()).unwrap();
3010
3011        let result = load_vault(path.to_str().unwrap());
3012        unsafe { std::env::remove_var("MURK_KEY") };
3013
3014        let err = result.expect_err("expected flipped meta to fail");
3015        assert!(
3016            err.to_string().contains("integrity check failed"),
3017            "expected integrity check failure, got: {err}"
3018        );
3019
3020        fs::remove_dir_all(&dir).unwrap();
3021    }
3022
3023    #[test]
3024    fn load_vault_empty_mac_with_secrets_fails() {
3025        let _lock = ENV_LOCK
3026            .lock()
3027            .unwrap_or_else(std::sync::PoisonError::into_inner);
3028
3029        let (secret, pubkey) = generate_keypair();
3030        let recipient = make_recipient(&pubkey);
3031
3032        let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
3033        let _ = fs::remove_dir_all(&dir);
3034        fs::create_dir_all(&dir).unwrap();
3035        let path = dir.join("test.murk");
3036
3037        // Build a vault with one secret.
3038        let mut vault = types::Vault {
3039            version: types::VAULT_VERSION.into(),
3040            created: "2026-02-28T00:00:00Z".into(),
3041            vault_name: ".murk".into(),
3042            repo: String::new(),
3043            recipients: vec![pubkey.clone()],
3044            schema: BTreeMap::new(),
3045            policy: None,
3046            secrets: BTreeMap::new(),
3047            meta: String::new(),
3048        };
3049        vault.secrets.insert(
3050            "KEY1".into(),
3051            types::SecretEntry {
3052                shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
3053                private: BTreeMap::new(),
3054                grouped: std::collections::BTreeMap::default(),
3055            },
3056        );
3057
3058        // Manually create meta with empty MAC and encrypt it.
3059        let mut recipients_map = HashMap::new();
3060        recipients_map.insert(pubkey.clone(), "alice".into());
3061        let meta = types::Meta {
3062            recipients: recipients_map,
3063            mac: String::new(),
3064            mac_key: None,
3065            github_pins: HashMap::new(),
3066            ..Default::default()
3067        };
3068        let meta_json = serde_json::to_vec(&meta).unwrap();
3069        vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
3070
3071        // Write the vault to disk.
3072        crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
3073
3074        // Load should fail: secrets present but MAC is empty.
3075        unsafe { std::env::set_var("MURK_KEY", &secret) };
3076        unsafe { std::env::remove_var("MURK_KEY_FILE") };
3077        let result = load_vault(path.to_str().unwrap());
3078        unsafe { std::env::remove_var("MURK_KEY") };
3079
3080        let err = result.expect_err("expected MAC validation to fail");
3081        assert!(
3082            err.to_string().contains("integrity check failed"),
3083            "expected integrity check failure, got: {err}"
3084        );
3085
3086        fs::remove_dir_all(&dir).unwrap();
3087    }
3088
3089    #[test]
3090    fn compute_mac_changes_with_scoped_entries() {
3091        let mut vault = types::Vault {
3092            version: types::VAULT_VERSION.into(),
3093            created: "2026-02-28T00:00:00Z".into(),
3094            vault_name: ".murk".into(),
3095            repo: String::new(),
3096            recipients: vec!["age1abc".into()],
3097            schema: BTreeMap::new(),
3098            policy: None,
3099            secrets: BTreeMap::new(),
3100            meta: String::new(),
3101        };
3102
3103        vault.secrets.insert(
3104            "KEY".into(),
3105            types::SecretEntry {
3106                shared: "ciphertext".into(),
3107                private: BTreeMap::new(),
3108                grouped: std::collections::BTreeMap::default(),
3109            },
3110        );
3111
3112        let key = [0u8; 32];
3113        let mac_no_scoped = compute_mac(
3114            &vault,
3115            &std::collections::BTreeMap::new(),
3116            &std::collections::BTreeMap::new(),
3117            Some(&key),
3118        );
3119
3120        vault
3121            .secrets
3122            .get_mut("KEY")
3123            .unwrap()
3124            .private
3125            .insert("age1bob".into(), "scoped-ct".into());
3126
3127        let mac_with_scoped = compute_mac(
3128            &vault,
3129            &std::collections::BTreeMap::new(),
3130            &std::collections::BTreeMap::new(),
3131            Some(&key),
3132        );
3133        assert_ne!(mac_no_scoped, mac_with_scoped);
3134    }
3135
3136    #[test]
3137    #[allow(clippy::too_many_lines)] // exhaustively enumerates every MAC scheme
3138    fn verify_mac_accepts_v1_prefix() {
3139        let vault = types::Vault {
3140            version: types::VAULT_VERSION.into(),
3141            created: "2026-02-28T00:00:00Z".into(),
3142            vault_name: ".murk".into(),
3143            repo: String::new(),
3144            recipients: vec!["age1abc".into()],
3145            schema: BTreeMap::new(),
3146            policy: None,
3147            secrets: BTreeMap::new(),
3148            meta: String::new(),
3149        };
3150
3151        let key = [0u8; 32];
3152        let v1_mac = compute_mac_v1(&vault);
3153        let v2_mac = compute_mac_v2(&vault);
3154        let v3_mac = compute_mac_v3(&vault, &key);
3155        assert!(verify_mac(
3156            &vault,
3157            &std::collections::BTreeMap::new(),
3158            &std::collections::BTreeMap::new(),
3159            &v1_mac,
3160            None
3161        ));
3162        assert!(verify_mac(
3163            &vault,
3164            &std::collections::BTreeMap::new(),
3165            &std::collections::BTreeMap::new(),
3166            &v2_mac,
3167            None
3168        ));
3169        assert!(verify_mac(
3170            &vault,
3171            &std::collections::BTreeMap::new(),
3172            &std::collections::BTreeMap::new(),
3173            &v3_mac,
3174            Some(&key)
3175        ));
3176        assert!(!verify_mac(
3177            &vault,
3178            &std::collections::BTreeMap::new(),
3179            &std::collections::BTreeMap::new(),
3180            "sha256:bogus",
3181            None
3182        ));
3183        assert!(!verify_mac(
3184            &vault,
3185            &std::collections::BTreeMap::new(),
3186            &std::collections::BTreeMap::new(),
3187            "blake3:bogus",
3188            Some(&key)
3189        ));
3190        assert!(!verify_mac(
3191            &vault,
3192            &std::collections::BTreeMap::new(),
3193            &std::collections::BTreeMap::new(),
3194            "blake3v2:bogus",
3195            Some(&key)
3196        ));
3197        assert!(!verify_mac(
3198            &vault,
3199            &std::collections::BTreeMap::new(),
3200            &std::collections::BTreeMap::new(),
3201            "blake3v3:bogus",
3202            Some(&key)
3203        ));
3204        assert!(!verify_mac(
3205            &vault,
3206            &std::collections::BTreeMap::new(),
3207            &std::collections::BTreeMap::new(),
3208            "unknown:prefix",
3209            None
3210        ));
3211
3212        // v4 (blake3v2) — includes schema; still accepted as legacy
3213        let v4_mac = compute_mac_v4(&vault, &key);
3214        assert!(v4_mac.starts_with("blake3v2:"));
3215        assert!(verify_mac(
3216            &vault,
3217            &std::collections::BTreeMap::new(),
3218            &std::collections::BTreeMap::new(),
3219            &v4_mac,
3220            Some(&key)
3221        ));
3222
3223        // v5 (blake3v3) — current scheme, includes lifecycle metadata
3224        let v5_mac = compute_mac_v5(&vault, &key);
3225        assert!(v5_mac.starts_with("blake3v3:"));
3226        assert!(verify_mac(
3227            &vault,
3228            &std::collections::BTreeMap::new(),
3229            &std::collections::BTreeMap::new(),
3230            &v5_mac,
3231            Some(&key)
3232        ));
3233        // compute_mac emits v5 when there are no groups
3234        assert!(
3235            compute_mac(
3236                &vault,
3237                &std::collections::BTreeMap::new(),
3238                &std::collections::BTreeMap::new(),
3239                Some(&key)
3240            )
3241            .starts_with("blake3v3:")
3242        );
3243
3244        // v6 (blake3v4) — emitted once a group exists; verifies and round-trips
3245        let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
3246        let v6_mac = compute_mac(
3247            &vault,
3248            &groups,
3249            &std::collections::BTreeMap::new(),
3250            Some(&key),
3251        );
3252        assert!(v6_mac.starts_with("blake3v4:"));
3253        assert!(verify_mac(
3254            &vault,
3255            &groups,
3256            &std::collections::BTreeMap::new(),
3257            &v6_mac,
3258            Some(&key)
3259        ));
3260        // Tampering with membership changes the MAC.
3261        let tampered = BTreeMap::from([(
3262            "prod".to_string(),
3263            vec!["age1abc".to_string(), "age1evil".to_string()],
3264        )]);
3265        assert!(!verify_mac(
3266            &vault,
3267            &tampered,
3268            &std::collections::BTreeMap::new(),
3269            &v6_mac,
3270            Some(&key)
3271        ));
3272    }
3273
3274    #[test]
3275    fn verify_mac_rejects_grouped_under_legacy_prefix() {
3276        // A v5 (blake3v3) MAC doesn't cover grouped ciphertext. Injecting a
3277        // grouped entry must not verify against the old scheme — otherwise an
3278        // attacker without a key could add a group value that wins on read.
3279        let mut vault = types::Vault {
3280            version: types::VAULT_VERSION.into(),
3281            created: "2026-02-28T00:00:00Z".into(),
3282            vault_name: ".murk".into(),
3283            repo: String::new(),
3284            recipients: vec!["age1abc".into()],
3285            schema: BTreeMap::new(),
3286            policy: None,
3287            secrets: BTreeMap::new(),
3288            meta: String::new(),
3289        };
3290        let key = [7u8; 32];
3291        let no_groups = BTreeMap::new();
3292        let v5_mac = compute_mac(
3293            &vault,
3294            &no_groups,
3295            &std::collections::BTreeMap::new(),
3296            Some(&key),
3297        );
3298        assert!(v5_mac.starts_with("blake3v3:"));
3299        assert!(verify_mac(
3300            &vault,
3301            &no_groups,
3302            &std::collections::BTreeMap::new(),
3303            &v5_mac,
3304            Some(&key)
3305        ));
3306
3307        // Attacker injects a grouped entry; the v5 MAC is now invalid for it.
3308        vault.secrets.insert(
3309            "STOLEN".into(),
3310            types::SecretEntry {
3311                grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
3312                ..Default::default()
3313            },
3314        );
3315        assert!(!verify_mac(
3316            &vault,
3317            &no_groups,
3318            &std::collections::BTreeMap::new(),
3319            &v5_mac,
3320            Some(&key)
3321        ));
3322    }
3323
3324    #[test]
3325    fn mac_v7_covers_grant_metadata() {
3326        let vault = types::Vault {
3327            version: types::VAULT_VERSION.into(),
3328            created: "2026-02-28T00:00:00Z".into(),
3329            vault_name: ".murk".into(),
3330            repo: String::new(),
3331            recipients: vec!["age1abc".into(), "age1agent".into()],
3332            schema: BTreeMap::new(),
3333            policy: None,
3334            secrets: BTreeMap::new(),
3335            meta: String::new(),
3336        };
3337        let key = [9u8; 32];
3338        let no_groups = BTreeMap::new();
3339
3340        // compute_mac emits v7 (blake3v5) once a grant exists.
3341        let grants = BTreeMap::from([(
3342            "codex".to_string(),
3343            types::GrantEntry {
3344                pubkey: "age1agent".into(),
3345                scope: vec!["STRIPE_KEY".into()],
3346                issued_at: "2026-02-28T00:00:00Z".into(),
3347                expires_at: "2026-02-28T02:00:00Z".into(),
3348                issuer: "age1abc".into(),
3349            },
3350        )]);
3351        let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
3352        assert!(v7_mac.starts_with("blake3v5:"));
3353        assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
3354
3355        // Widening the scope (or extending the TTL) changes the MAC.
3356        let tampered = BTreeMap::from([(
3357            "codex".to_string(),
3358            types::GrantEntry {
3359                pubkey: "age1agent".into(),
3360                scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
3361                issued_at: "2026-02-28T00:00:00Z".into(),
3362                expires_at: "2026-02-28T02:00:00Z".into(),
3363                issuer: "age1abc".into(),
3364            },
3365        )]);
3366        assert!(!verify_mac(
3367            &vault,
3368            &no_groups,
3369            &tampered,
3370            &v7_mac,
3371            Some(&key)
3372        ));
3373    }
3374
3375    #[test]
3376    fn verify_mac_rejects_grants_under_legacy_prefix() {
3377        // Grant metadata is only covered by v7. A vault carrying grants but
3378        // stamped with an older (group-era) MAC must not verify — otherwise an
3379        // attacker could fabricate or extend a grant the MAC ignores.
3380        let vault = types::Vault {
3381            version: types::VAULT_VERSION.into(),
3382            created: "2026-02-28T00:00:00Z".into(),
3383            vault_name: ".murk".into(),
3384            repo: String::new(),
3385            recipients: vec!["age1abc".into()],
3386            schema: BTreeMap::new(),
3387            policy: None,
3388            secrets: BTreeMap::new(),
3389            meta: String::new(),
3390        };
3391        let key = [3u8; 32];
3392        let no_groups = BTreeMap::new();
3393        let grants = BTreeMap::from([(
3394            "codex".to_string(),
3395            types::GrantEntry {
3396                pubkey: "age1agent".into(),
3397                scope: vec!["STRIPE_KEY".into()],
3398                issued_at: "2026-02-28T00:00:00Z".into(),
3399                expires_at: "2026-02-28T02:00:00Z".into(),
3400                issuer: "age1abc".into(),
3401            },
3402        )]);
3403        // A v6 MAC (no grants in the digest) must be rejected once grants exist.
3404        let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
3405        assert!(v6_mac.starts_with("blake3v4:"));
3406        assert!(!verify_mac(
3407            &vault,
3408            &no_groups,
3409            &grants,
3410            &v6_mac,
3411            Some(&key)
3412        ));
3413    }
3414
3415    #[test]
3416    fn mac_v8_covers_policy() {
3417        let mut vault = types::Vault {
3418            version: types::VAULT_VERSION.into(),
3419            created: "2026-02-28T00:00:00Z".into(),
3420            vault_name: ".murk".into(),
3421            repo: String::new(),
3422            recipients: vec!["age1abc".into()],
3423            schema: BTreeMap::new(),
3424            policy: Some(types::Policy {
3425                agent_allow_tags: vec!["agents".into()],
3426            }),
3427            secrets: BTreeMap::new(),
3428            meta: String::new(),
3429        };
3430        let key = [11u8; 32];
3431        let no_groups = BTreeMap::new();
3432        let no_grants = BTreeMap::new();
3433
3434        // compute_mac emits v8 (blake3v6) once a policy exists.
3435        let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
3436        assert!(v8_mac.starts_with("blake3v6:"));
3437        assert!(verify_mac(
3438            &vault,
3439            &no_groups,
3440            &no_grants,
3441            &v8_mac,
3442            Some(&key)
3443        ));
3444
3445        // Weakening the policy (adding an allowed tag) changes the MAC.
3446        vault.policy = Some(types::Policy {
3447            agent_allow_tags: vec!["agents".into(), "production".into()],
3448        });
3449        assert!(!verify_mac(
3450            &vault,
3451            &no_groups,
3452            &no_grants,
3453            &v8_mac,
3454            Some(&key)
3455        ));
3456    }
3457
3458    #[test]
3459    fn mac_v8_policy_tags_are_unambiguous() {
3460        // A crafted tag must not collide with a different tag list: ["a\tb"] and
3461        // ["a", "b"] previously hashed identically under a separator-only scheme.
3462        let base = types::Vault {
3463            version: types::VAULT_VERSION.into(),
3464            created: "2026-02-28T00:00:00Z".into(),
3465            vault_name: ".murk".into(),
3466            repo: String::new(),
3467            recipients: vec!["age1abc".into()],
3468            schema: BTreeMap::new(),
3469            policy: None,
3470            secrets: BTreeMap::new(),
3471            meta: String::new(),
3472        };
3473        let key = [7u8; 32];
3474        let groups = BTreeMap::new();
3475        let grants = BTreeMap::new();
3476
3477        let mut a = base.clone();
3478        a.policy = Some(types::Policy {
3479            agent_allow_tags: vec!["a\tb".into()],
3480        });
3481        let mut b = base.clone();
3482        b.policy = Some(types::Policy {
3483            agent_allow_tags: vec!["a".into(), "b".into()],
3484        });
3485
3486        let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
3487        let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
3488        assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
3489    }
3490
3491    #[test]
3492    fn verify_mac_rejects_policy_under_legacy_prefix() {
3493        // Policy is only covered by v8. A vault carrying a policy but stamped
3494        // with an older MAC must not verify — otherwise an attacker could strip
3495        // or weaken the policy by downgrading the MAC.
3496        let vault = types::Vault {
3497            version: types::VAULT_VERSION.into(),
3498            created: "2026-02-28T00:00:00Z".into(),
3499            vault_name: ".murk".into(),
3500            repo: String::new(),
3501            recipients: vec!["age1abc".into()],
3502            schema: BTreeMap::new(),
3503            policy: Some(types::Policy {
3504                agent_allow_tags: vec!["agents".into()],
3505            }),
3506            secrets: BTreeMap::new(),
3507            meta: String::new(),
3508        };
3509        let key = [5u8; 32];
3510        let no_groups = BTreeMap::new();
3511        let no_grants = BTreeMap::new();
3512        // A v5 MAC (no policy in the digest) must be rejected once a policy exists.
3513        let v5_mac = compute_mac_v5(&vault, &key);
3514        assert!(v5_mac.starts_with("blake3v3:"));
3515        assert!(!verify_mac(
3516            &vault,
3517            &no_groups,
3518            &no_grants,
3519            &v5_mac,
3520            Some(&key)
3521        ));
3522    }
3523
3524    #[test]
3525    fn compute_mac_v5_covers_rotation_metadata() {
3526        let mut vault = types::Vault {
3527            version: types::VAULT_VERSION.into(),
3528            created: "2026-02-28T00:00:00Z".into(),
3529            vault_name: ".murk".into(),
3530            repo: String::new(),
3531            recipients: vec!["age1abc".into()],
3532            schema: BTreeMap::new(),
3533            policy: None,
3534            secrets: BTreeMap::new(),
3535            meta: String::new(),
3536        };
3537        vault.schema.insert(
3538            "API_KEY".into(),
3539            types::SchemaEntry {
3540                description: "Main API key".into(),
3541                updated: Some("2026-02-28T00:00:00Z".into()),
3542                ..Default::default()
3543            },
3544        );
3545
3546        let key = [0u8; 32];
3547        let baseline = compute_mac(
3548            &vault,
3549            &std::collections::BTreeMap::new(),
3550            &std::collections::BTreeMap::new(),
3551            Some(&key),
3552        );
3553
3554        // Setting a rotation interval changes the MAC — tamper-evident.
3555        vault
3556            .schema
3557            .get_mut("API_KEY")
3558            .unwrap()
3559            .rotation_interval_days = Some(90);
3560        let with_interval = compute_mac(
3561            &vault,
3562            &std::collections::BTreeMap::new(),
3563            &std::collections::BTreeMap::new(),
3564            Some(&key),
3565        );
3566        assert_ne!(baseline, with_interval);
3567
3568        // So does an expiry.
3569        vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
3570        let with_expiry = compute_mac(
3571            &vault,
3572            &std::collections::BTreeMap::new(),
3573            &std::collections::BTreeMap::new(),
3574            Some(&key),
3575        );
3576        assert_ne!(with_interval, with_expiry);
3577
3578        // v4 (which ignores these fields) is blind to the change — the reason
3579        // v5 exists. Confirms the new fields really are what moved the MAC.
3580        let mut cleared = vault.clone();
3581        cleared
3582            .schema
3583            .get_mut("API_KEY")
3584            .unwrap()
3585            .rotation_interval_days = None;
3586        cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
3587        assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
3588    }
3589
3590    #[test]
3591    fn compute_mac_v9_covers_revoked_at() {
3592        let mut vault = types::Vault {
3593            version: types::VAULT_VERSION.into(),
3594            created: "2026-02-28T00:00:00Z".into(),
3595            vault_name: ".murk".into(),
3596            repo: String::new(),
3597            recipients: vec!["age1abc".into()],
3598            schema: BTreeMap::new(),
3599            policy: None,
3600            secrets: BTreeMap::new(),
3601            meta: String::new(),
3602        };
3603        vault.schema.insert(
3604            "API_KEY".into(),
3605            types::SchemaEntry {
3606                description: "Main API key".into(),
3607                updated: Some("2026-02-28T00:00:00Z".into()),
3608                ..Default::default()
3609            },
3610        );
3611        let key = [0u8; 32];
3612        let groups = BTreeMap::new();
3613        let grants = BTreeMap::new();
3614
3615        // No marker → v8 falls through to v5 (no policy/grants/groups here).
3616        let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
3617        assert!(baseline.starts_with("blake3v3:"));
3618
3619        // Setting `revoked_at` switches the written scheme to v9 and changes the MAC.
3620        vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
3621        let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
3622        assert!(with_marker.starts_with("blake3v7:"));
3623        assert_ne!(baseline, with_marker);
3624
3625        // The v9 MAC round-trips, and a downgraded (v8) MAC is rejected while the
3626        // marker is present — an attacker can't clear it by stamping an older scheme.
3627        assert!(verify_mac(
3628            &vault,
3629            &groups,
3630            &grants,
3631            &with_marker,
3632            Some(&key)
3633        ));
3634        let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
3635        assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
3636
3637        // v8 (which ignores the marker) is blind to it — confirms `revoked_at` is
3638        // what moved the v9 digest, mirroring the v5 rotation-metadata test.
3639        let mut cleared = vault.clone();
3640        cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
3641        assert_eq!(
3642            compute_mac_v8(&vault, &groups, &grants, &key),
3643            compute_mac_v8(&cleared, &groups, &grants, &key)
3644        );
3645    }
3646
3647    #[test]
3648    fn compute_mac_changes_with_schema() {
3649        let mut vault = types::Vault {
3650            version: types::VAULT_VERSION.into(),
3651            created: "2026-02-28T00:00:00Z".into(),
3652            vault_name: ".murk".into(),
3653            repo: String::new(),
3654            recipients: vec!["age1abc".into()],
3655            schema: BTreeMap::new(),
3656            policy: None,
3657            secrets: BTreeMap::new(),
3658            meta: String::new(),
3659        };
3660
3661        let key = [0u8; 32];
3662        let mac_no_schema = compute_mac(
3663            &vault,
3664            &std::collections::BTreeMap::new(),
3665            &std::collections::BTreeMap::new(),
3666            Some(&key),
3667        );
3668
3669        vault.schema.insert(
3670            "API_KEY".into(),
3671            types::SchemaEntry {
3672                description: "Main API key".into(),
3673                tags: vec!["deploy".into()],
3674                ..Default::default()
3675            },
3676        );
3677
3678        let mac_with_schema = compute_mac(
3679            &vault,
3680            &std::collections::BTreeMap::new(),
3681            &std::collections::BTreeMap::new(),
3682            Some(&key),
3683        );
3684        assert_ne!(mac_no_schema, mac_with_schema);
3685
3686        // Changing a tag changes the MAC
3687        let mac_before_retag = mac_with_schema;
3688        vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
3689        let mac_after_retag = compute_mac(
3690            &vault,
3691            &std::collections::BTreeMap::new(),
3692            &std::collections::BTreeMap::new(),
3693            Some(&key),
3694        );
3695        assert_ne!(mac_before_retag, mac_after_retag);
3696    }
3697
3698    #[test]
3699    fn mac_key_roundtrip() {
3700        let hex = generate_mac_key();
3701        assert_eq!(hex.len(), 64);
3702        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
3703
3704        let key = decode_mac_key(&hex).expect("valid hex should decode");
3705        // Re-encode and compare.
3706        let rehex = key.iter().fold(String::new(), |mut s, b| {
3707            use std::fmt::Write;
3708            let _ = write!(s, "{b:02x}");
3709            s
3710        });
3711        assert_eq!(hex, rehex);
3712    }
3713
3714    #[test]
3715    fn decode_mac_key_rejects_bad_input() {
3716        assert!(decode_mac_key("").is_none());
3717        assert!(decode_mac_key("tooshort").is_none());
3718        assert!(decode_mac_key(&"zz".repeat(32)).is_none()); // invalid hex
3719        assert!(decode_mac_key(&"aa".repeat(31)).is_none()); // 31 bytes
3720        assert!(decode_mac_key(&"aa".repeat(33)).is_none()); // 33 bytes
3721    }
3722
3723    #[test]
3724    fn blake3_mac_different_key_different_mac() {
3725        let vault = types::Vault {
3726            version: types::VAULT_VERSION.into(),
3727            created: "2026-02-28T00:00:00Z".into(),
3728            vault_name: ".murk".into(),
3729            repo: String::new(),
3730            recipients: vec!["age1abc".into()],
3731            schema: BTreeMap::new(),
3732            policy: None,
3733            secrets: BTreeMap::new(),
3734            meta: String::new(),
3735        };
3736
3737        let key1 = [0u8; 32];
3738        let key2 = [1u8; 32];
3739        let mac1 = compute_mac(
3740            &vault,
3741            &std::collections::BTreeMap::new(),
3742            &std::collections::BTreeMap::new(),
3743            Some(&key1),
3744        );
3745        let mac2 = compute_mac(
3746            &vault,
3747            &std::collections::BTreeMap::new(),
3748            &std::collections::BTreeMap::new(),
3749            Some(&key2),
3750        );
3751        assert_ne!(mac1, mac2);
3752    }
3753
3754    #[test]
3755    fn valid_key_names() {
3756        assert!(is_valid_key_name("DATABASE_URL"));
3757        assert!(is_valid_key_name("_PRIVATE"));
3758        assert!(is_valid_key_name("A"));
3759        assert!(is_valid_key_name("key123"));
3760    }
3761
3762    #[test]
3763    fn invalid_key_names() {
3764        assert!(!is_valid_key_name(""));
3765        assert!(!is_valid_key_name("123_START"));
3766        assert!(!is_valid_key_name("KEY-NAME"));
3767        assert!(!is_valid_key_name("KEY NAME"));
3768        assert!(!is_valid_key_name("FOO$(bar)"));
3769        assert!(!is_valid_key_name("KEY=VAL"));
3770    }
3771
3772    #[test]
3773    fn now_utc_format() {
3774        let ts = now_utc();
3775        assert!(ts.ends_with('Z'));
3776        assert_eq!(ts.len(), 20);
3777        assert_eq!(&ts[4..5], "-");
3778        assert_eq!(&ts[7..8], "-");
3779        assert_eq!(&ts[10..11], "T");
3780    }
3781}