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