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