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