#![warn(clippy::pedantic)]
#![allow(
clippy::doc_markdown,
clippy::cast_possible_wrap,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::must_use_candidate,
clippy::similar_names,
clippy::unreadable_literal,
clippy::too_many_arguments,
clippy::implicit_hasher
)]
pub(crate) mod agent;
pub(crate) mod codename;
pub mod crypto;
pub mod edit;
pub(crate) mod env;
pub mod error;
pub(crate) mod export;
pub(crate) mod git;
pub mod github;
pub(crate) mod grants;
pub(crate) mod groups;
pub mod hardening;
pub(crate) mod info;
pub(crate) mod init;
pub(crate) mod merge;
pub mod pins;
pub(crate) mod policy;
pub(crate) mod recipients;
pub mod recovery;
pub mod scan;
pub(crate) mod secrets;
pub mod signing;
pub mod types;
pub mod vault;
#[cfg(feature = "python")]
mod python;
#[cfg(test)]
pub mod testutil;
pub use agent::{AgentPlan, AgentPlanKey, agent_plan, format_agent_plan_text};
pub use env::{
EnvrcStatus, KeySource, agent_key_file_path, agent_keys_dir, dotenv_has_murk_key,
key_file_path, parse_env, resolve_key, resolve_key_for_vault, resolve_key_with_source,
warn_env_permissions, write_envrc, write_key_ref_to_dotenv, write_key_to_dotenv,
write_key_to_file,
};
pub use error::MurkError;
pub use export::{
DiffEntry, DiffKind, decrypt_vault_values, diff_secrets, export_secrets, format_diff_lines,
parse_and_decrypt_values, resolve_secrets,
};
pub use git::{CommitSignature, MergeDriverSetupStep, last_commit_signature, setup_merge_driver};
pub use github::{GitHubError, fetch_keys};
pub use grants::{create_grant, parse_ttl, remove_grant, validate_grant_name};
pub use groups::{
add_member, create_group, delete_group, remove_member, resolve_member, validate_group_name,
};
pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info};
pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key};
pub use merge::{MergeDriverOutput, run_merge_driver};
pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity, is_agent_key_allowed};
pub use recipients::{
RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label,
list_recipients, revoke_recipient, truncate_pubkey,
};
pub use secrets::{
EXPIRY_WARN_DAYS, RotationIssue, add_grouped_secret, add_secret, describe_key, get_secret,
import_secrets, list_keys, mark_revoked, remove_secret, rotation_health,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
pub fn is_valid_key_name(key: &str) -> bool {
!key.is_empty()
&& key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
use age::secrecy::ExposeSecret;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use zeroize::Zeroizing;
pub use crypto::{MurkIdentity, MurkRecipient};
pub fn decrypt_meta(vault: &types::Vault, identity: &crypto::MurkIdentity) -> Option<types::Meta> {
if vault.meta.is_empty() {
return None;
}
let plaintext = decrypt_value(&vault.meta, identity).ok()?;
serde_json::from_slice(&plaintext).ok()
}
pub(crate) fn parse_recipients(
pubkeys: &[String],
) -> Result<Vec<crypto::MurkRecipient>, MurkError> {
pubkeys
.iter()
.map(|pk| crypto::parse_recipient(pk).map_err(MurkError::from))
.collect()
}
pub fn encrypt_value(
plaintext: &[u8],
recipients: &[crypto::MurkRecipient],
) -> Result<String, MurkError> {
let ciphertext = crypto::encrypt(plaintext, recipients)?;
Ok(BASE64.encode(&ciphertext))
}
pub fn decrypt_value(
encoded: &str,
identity: &crypto::MurkIdentity,
) -> Result<Zeroizing<Vec<u8>>, MurkError> {
let ciphertext = BASE64.decode(encoded).map_err(|e| {
MurkError::Crypto(crypto::CryptoError::Decrypt(format!("invalid base64: {e}")))
})?;
Ok(crypto::decrypt(&ciphertext, identity)?)
}
pub(crate) fn plaintext_bytes_to_zeroizing_string(
bytes: &[u8],
) -> Result<Zeroizing<String>, std::str::Utf8Error> {
let s = std::str::from_utf8(bytes)?;
Ok(Zeroizing::new(s.to_owned()))
}
pub fn read_vault(vault_path: &str) -> Result<types::Vault, MurkError> {
Ok(vault::read(Path::new(vault_path))?)
}
pub fn resolve_vault_path(arg: &str) -> String {
use std::path::PathBuf;
if arg.is_empty() || arg.contains('/') || arg.contains('\\') || Path::new(arg).is_absolute() {
return arg.to_string();
}
let Ok(cwd) = std::env::current_dir() else {
return arg.to_string();
};
if cwd.join(arg).exists() {
return arg.to_string();
}
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut dir = cwd.as_path();
loop {
let candidate = dir.join(arg);
if candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
if dir.join(".git").exists() {
break;
}
if let Some(ref h) = home
&& dir == h.as_path()
{
break;
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
arg.to_string()
}
struct MetaState {
recipients: HashMap<String, String>,
groups: BTreeMap<String, Vec<String>>,
grants: BTreeMap<String, types::GrantEntry>,
legacy_mac: bool,
github_pins: HashMap<String, Vec<String>>,
signers: BTreeMap<String, String>,
signature: types::SignatureState,
}
fn check_signature(
vault: &types::Vault,
meta: &types::Meta,
) -> Result<types::SignatureState, MurkError> {
match &meta.sig {
Some(sig) => {
if verify_vault_signature(
vault,
&meta.groups,
&meta.grants,
&meta.github_pins,
&meta.signers,
sig,
) {
Ok(types::SignatureState::Signed {
signer: sig.signer.clone(),
anchored: sig.signer.starts_with("ssh-ed25519 "),
})
} else {
Err(MurkError::Integrity(
"vault signature is invalid — it may have been tampered with, or a signer's \
verifying key changed. Run `murk verify` for details"
.into(),
))
}
}
None => Ok(types::SignatureState::Unsigned),
}
}
fn resolve_meta_state(
vault: &types::Vault,
identity: &crypto::MurkIdentity,
) -> Result<MetaState, MurkError> {
if vault.meta.is_empty() {
if vault.secrets.is_empty() {
return Ok(MetaState {
recipients: HashMap::new(),
groups: BTreeMap::new(),
grants: BTreeMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
signers: BTreeMap::new(),
signature: types::SignatureState::Unsigned,
});
}
return Err(MurkError::Integrity(
"vault has secrets but no meta — vault may have been tampered with".into(),
));
}
let ciphertext = BASE64.decode(&vault.meta).map_err(|_| {
MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
})?;
let plaintext = match crypto::decrypt(&ciphertext, identity) {
Ok(plaintext) => plaintext,
Err(e) if matches!(identity, crypto::MurkIdentity::Plugin { .. }) => {
return Err(e.into());
}
Err(_) => {
if identity
.pubkey_string()
.is_ok_and(|pk| is_listed_recipient(vault, &pk))
{
return Err(MurkError::Integrity(
"your key is listed as a recipient but cannot decrypt the vault meta — vault may have been tampered with".into(),
));
}
return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
"you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
)));
}
};
let meta: types::Meta = serde_json::from_slice(&plaintext).map_err(|_| {
MurkError::Integrity("vault meta is corrupt — vault may have been tampered with".into())
})?;
if meta.mac.is_empty() {
if !vault.secrets.is_empty() {
return Err(MurkError::Integrity(
"vault has secrets but MAC is empty — vault may have been tampered with".into(),
));
}
let signature = check_signature(vault, &meta)?;
return Ok(MetaState {
recipients: meta.recipients,
groups: meta.groups,
grants: meta.grants,
legacy_mac: false,
github_pins: meta.github_pins,
signers: meta.signers,
signature,
});
}
let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key);
if !verify_mac(
vault,
&meta.groups,
&meta.grants,
&meta.mac,
mac_key.as_ref(),
) {
let expected = compute_mac(vault, &meta.groups, &meta.grants, mac_key.as_ref());
return Err(MurkError::Integrity(format!(
"vault may have been tampered with (expected {expected}, got {})",
meta.mac
)));
}
let legacy_mac = meta.mac.starts_with("sha256:") || meta.mac.starts_with("sha256v2:");
let signature = check_signature(vault, &meta)?;
Ok(MetaState {
recipients: meta.recipients,
groups: meta.groups,
grants: meta.grants,
legacy_mac,
github_pins: meta.github_pins,
signers: meta.signers,
signature,
})
}
fn is_listed_recipient(vault: &types::Vault, pubkey: &str) -> bool {
fn ssh_head(s: &str) -> Option<(&str, &str)> {
let mut it = s.split_whitespace();
match (it.next(), it.next()) {
(Some(kind), Some(blob)) if kind.starts_with("ssh-") => Some((kind, blob)),
_ => None,
}
}
vault.recipients.iter().any(|r| {
r == pubkey || matches!((ssh_head(r), ssh_head(pubkey)), (Some(a), Some(b)) if a == b)
})
}
pub fn decrypt_vault(
vault: &types::Vault,
identity: &crypto::MurkIdentity,
) -> Result<types::Murk, MurkError> {
let pubkey = identity.pubkey_string()?;
let MetaState {
recipients,
groups,
grants,
legacy_mac,
github_pins,
signers,
signature,
} = resolve_meta_state(vault, identity)?;
let is_agent = grants.values().any(|g| g.pubkey == pubkey);
let mut values: HashMap<String, Zeroizing<String>> = HashMap::new();
for (key, entry) in &vault.secrets {
if entry.shared.is_empty() {
continue;
}
let plaintext = match decrypt_value(&entry.shared, identity) {
Ok(plaintext) => plaintext,
Err(_) if is_agent => continue,
Err(_) => {
return Err(MurkError::Crypto(crypto::CryptoError::Decrypt(
"you are not a recipient of this vault. Run `murk circle` to check, or ask a recipient to authorize you".into(),
)));
}
};
let value = plaintext_bytes_to_zeroizing_string(&plaintext)
.map_err(|e| MurkError::Secret(format!("invalid UTF-8 in secret {key}: {e}")))?;
values.insert(key.clone(), value);
}
let mut private: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
for (key, entry) in &vault.secrets {
if let Some(encoded) = entry.private.get(&pubkey)
&& let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
plaintext_bytes_to_zeroizing_string(&pt)
.map_err(|e| MurkError::Secret(e.to_string()))
})
{
private
.entry(key.clone())
.or_default()
.insert(pubkey.clone(), value);
}
}
let mut grouped: HashMap<String, HashMap<String, Zeroizing<String>>> = HashMap::new();
for (key, entry) in &vault.secrets {
for (group, encoded) in &entry.grouped {
if let Ok(value) = decrypt_value(encoded, identity).and_then(|pt| {
plaintext_bytes_to_zeroizing_string(&pt)
.map_err(|e| MurkError::Secret(e.to_string()))
}) {
grouped
.entry(key.clone())
.or_default()
.insert(group.clone(), value);
}
}
}
Ok(types::Murk {
values,
recipients,
private,
grouped,
groups,
grants,
legacy_mac,
github_pins,
signers,
signature,
})
}
pub fn load_vault(
vault_path: &str,
) -> Result<(types::Vault, types::Murk, crypto::MurkIdentity), MurkError> {
let secret_key = env::resolve_key_for_vault(vault_path).map_err(MurkError::Key)?;
let identity = crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
MurkError::Key(format!(
"{e}. For age keys, set MURK_KEY. For SSH keys, set MURK_KEY_FILE=~/.ssh/id_ed25519"
))
})?;
let vault = read_vault(vault_path)?;
let mut murk = decrypt_vault(&vault, &identity)?;
match pins::reconcile(vault_path, &murk.signers) {
pins::PinVerdict::Conflict { signer } => {
return Err(MurkError::Integrity(format!(
"signer {signer}'s verifying key changed since first seen — the signer registry \
may have been tampered with to forge a signature. Inspect \
`git log -p -- {vault_path}`; if the change is legitimate, clear the pin under \
~/.config/murk/signer-pins/ or set MURK_NO_SIGNER_PIN=1"
)));
}
pins::PinVerdict::Ok { first_use } => {
if let types::SignatureState::Signed { signer, anchored } = &mut murk.signature
&& !*anchored
&& !first_use.contains(signer.as_str())
{
*anchored = true;
}
}
}
Ok((vault, murk, identity))
}
fn rebuild_shared(
key: &str,
vault: &types::Vault,
recipients: &[crypto::MurkRecipient],
recipients_changed: bool,
original: &types::Murk,
current: &types::Murk,
) -> Result<String, MurkError> {
let Some(value) = current.values.get(key) else {
return Ok(String::new());
};
if !recipients_changed
&& original.values.get(key) == Some(value)
&& let Some(existing) = vault.secrets.get(key)
{
return Ok(existing.shared.clone());
}
encrypt_value(value.as_bytes(), recipients)
}
fn rebuild_private(
key: &str,
vault: &types::Vault,
original: &types::Murk,
current: &types::Murk,
) -> Result<BTreeMap<String, String>, MurkError> {
let mut scoped = vault
.secrets
.get(key)
.map(|e| e.private.clone())
.unwrap_or_default();
if let Some(key_scoped) = current.private.get(key) {
for (pk, val) in key_scoped {
let original_val = original.private.get(key).and_then(|m| m.get(pk));
if original_val != Some(val) {
let recipient = crypto::parse_recipient(pk)?;
scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
}
}
}
if let Some(orig_key_scoped) = original.private.get(key) {
for pk in orig_key_scoped.keys() {
let still_present = current.private.get(key).is_some_and(|m| m.contains_key(pk));
if !still_present {
scoped.remove(pk);
}
}
}
Ok(scoped)
}
fn rebuild_grouped(
key: &str,
vault: &types::Vault,
changed_groups: &BTreeSet<&str>,
original: &types::Murk,
current: &types::Murk,
) -> Result<BTreeMap<String, String>, MurkError> {
let mut grouped = vault
.secrets
.get(key)
.map(|e| e.grouped.clone())
.unwrap_or_default();
if let Some(key_grouped) = current.grouped.get(key) {
for (group, val) in key_grouped {
let members = current.groups.get(group).ok_or_else(|| {
MurkError::Secret(format!("secret {key} references unknown group {group}"))
})?;
let original_val = original.grouped.get(key).and_then(|m| m.get(group));
if original_val != Some(val) || changed_groups.contains(group.as_str()) {
let group_recipients = parse_recipients(members)?;
grouped.insert(
group.clone(),
encrypt_value(val.as_bytes(), &group_recipients)?,
);
}
}
}
if let Some(orig_key_grouped) = original.grouped.get(key) {
for group in orig_key_grouped.keys() {
let still_present = current
.grouped
.get(key)
.is_some_and(|m| m.contains_key(group));
if !still_present {
grouped.remove(group);
}
}
}
Ok(grouped)
}
fn resync_grant_private(
key: &str,
private: &mut BTreeMap<String, String>,
original: &types::Murk,
current: &types::Murk,
) -> Result<(), MurkError> {
let Some(value) = current.values.get(key) else {
return Ok(());
};
if original.values.get(key) == Some(value) {
return Ok(());
}
for grant in current.grants.values() {
if grant.scope.iter().any(|k| k == key) {
let recipient = crypto::parse_recipient(&grant.pubkey)?;
private.insert(
grant.pubkey.clone(),
encrypt_value(value.as_bytes(), &[recipient])?,
);
}
}
Ok(())
}
pub fn save_vault(
vault_path: &str,
vault: &mut types::Vault,
original: &types::Murk,
current: &types::Murk,
) -> Result<(), MurkError> {
let recipients = parse_recipients(&vault.recipients)?;
let grant_pubkeys: BTreeSet<&str> =
current.grants.values().map(|g| g.pubkey.as_str()).collect();
let shared_recipients: Vec<crypto::MurkRecipient> = vault
.recipients
.iter()
.filter(|pk| !grant_pubkeys.contains(pk.as_str()))
.map(|pk| crypto::parse_recipient(pk))
.collect::<Result<_, _>>()?;
let shared_recipients_changed = {
let orig_grant_pubkeys: BTreeSet<&str> = original
.grants
.values()
.map(|g| g.pubkey.as_str())
.collect();
let mut current_pks: Vec<&str> = vault
.recipients
.iter()
.map(String::as_str)
.filter(|pk| !grant_pubkeys.contains(pk))
.collect();
let mut original_pks: Vec<&str> = original
.recipients
.keys()
.map(String::as_str)
.filter(|pk| !orig_grant_pubkeys.contains(pk))
.collect();
current_pks.sort_unstable();
original_pks.sort_unstable();
current_pks != original_pks
};
let changed_groups: BTreeSet<&str> = current
.groups
.keys()
.chain(original.groups.keys())
.filter(|g| current.groups.get(*g) != original.groups.get(*g))
.map(String::as_str)
.collect();
let mut new_secrets = BTreeMap::new();
let mut all_keys: BTreeSet<&String> = current.values.keys().collect();
all_keys.extend(current.private.keys());
all_keys.extend(current.grouped.keys());
let original_visible: BTreeSet<&String> = original
.values
.keys()
.chain(original.private.keys())
.chain(original.grouped.keys())
.collect();
for key in vault.secrets.keys() {
if !original_visible.contains(key) {
all_keys.insert(key);
}
}
for key in all_keys {
let shared = rebuild_shared(
key,
vault,
&shared_recipients,
shared_recipients_changed,
original,
current,
)?;
let mut private = rebuild_private(key, vault, original, current)?;
resync_grant_private(key, &mut private, original, current)?;
let grouped = rebuild_grouped(key, vault, &changed_groups, original, current)?;
new_secrets.insert(
key.clone(),
types::SecretEntry {
shared,
private,
grouped,
},
);
}
vault.secrets = new_secrets;
let meta = build_meta(vault_path, vault, current);
let meta_json =
serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
vault.meta = encrypt_value(&meta_json, &recipients)?;
Ok(vault::write(Path::new(vault_path), vault)?)
}
fn build_meta(vault_path: &str, vault: &types::Vault, current: &types::Murk) -> types::Meta {
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let mac = compute_mac(vault, ¤t.groups, ¤t.grants, Some(&mac_key));
let mut signers = current.signers.clone();
signers.retain(|pk, _| vault.recipients.iter().any(|r| r == pk));
let sig = signing_identity(vault_path).and_then(|identity| {
sign_vault(
vault,
¤t.groups,
¤t.grants,
¤t.github_pins,
&mut signers,
&identity,
)
});
types::Meta {
recipients: current.recipients.clone(),
mac,
mac_key: Some(mac_key_hex),
github_pins: current.github_pins.clone(),
groups: current.groups.clone(),
grants: current.grants.clone(),
signers,
sig,
}
}
pub(crate) fn compute_mac(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
mac_key: Option<&[u8; 32]>,
) -> String {
match mac_key {
Some(key) if vault.schema.values().any(|e| e.revoked_at.is_some()) => {
compute_mac_v9(vault, groups, grants, key)
}
Some(key) if vault.policy.is_some() => compute_mac_v8(vault, groups, grants, key),
Some(key) if !grants.is_empty() => compute_mac_v7(vault, groups, grants, key),
Some(key) if !groups.is_empty() => compute_mac_v6(vault, groups, key),
Some(key) => compute_mac_v5(vault, key),
None => compute_mac_v2(vault),
}
}
fn compute_mac_v1(vault: &types::Vault) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
for key in vault.secrets.keys() {
hasher.update(key.as_bytes());
hasher.update(b"\x00");
}
for entry in vault.secrets.values() {
hasher.update(entry.shared.as_bytes());
hasher.update(b"\x00");
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
hasher.update(pk.as_bytes());
hasher.update(b"\x00");
}
let digest = hasher.finalize();
format!(
"sha256:{}",
digest.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
)
}
fn compute_mac_v2(vault: &types::Vault) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
for key in vault.secrets.keys() {
hasher.update(key.as_bytes());
hasher.update(b"\x00");
}
for entry in vault.secrets.values() {
hasher.update(entry.shared.as_bytes());
hasher.update(b"\x00");
let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
scoped_pks.sort();
for pk in scoped_pks {
hasher.update(pk.as_bytes());
hasher.update(b"\x01");
hasher.update(entry.private[pk].as_bytes());
hasher.update(b"\x00");
}
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
hasher.update(pk.as_bytes());
hasher.update(b"\x00");
}
let digest = hasher.finalize();
format!(
"sha256v2:{}",
digest.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
)
}
fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
let mut data = Vec::new();
for key_name in vault.secrets.keys() {
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
}
for entry in vault.secrets.values() {
data.extend_from_slice(entry.shared.as_bytes());
data.push(0x00);
let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
scoped_pks.sort();
for pk in scoped_pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x01);
data.extend_from_slice(entry.private[pk].as_bytes());
data.push(0x00);
}
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x00);
}
let hash = blake3::keyed_hash(key, &data);
format!("blake3:{hash}")
}
fn compute_mac_v4(vault: &types::Vault, key: &[u8; 32]) -> String {
let mut data = Vec::new();
for key_name in vault.secrets.keys() {
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
}
for entry in vault.secrets.values() {
data.extend_from_slice(entry.shared.as_bytes());
data.push(0x00);
let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
scoped_pks.sort();
for pk in scoped_pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x01);
data.extend_from_slice(entry.private[pk].as_bytes());
data.push(0x00);
}
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x00);
}
for (key_name, entry) in &vault.schema {
data.push(0x02);
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
data.extend_from_slice(entry.description.as_bytes());
data.push(0x00);
if let Some(example) = &entry.example {
data.extend_from_slice(example.as_bytes());
}
data.push(0x00);
for tag in &entry.tags {
data.extend_from_slice(tag.as_bytes());
data.push(0x00);
}
}
let hash = blake3::keyed_hash(key, &data);
format!("blake3v2:{hash}")
}
fn compute_mac_v5(vault: &types::Vault, key: &[u8; 32]) -> String {
let mut data = Vec::new();
for key_name in vault.secrets.keys() {
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
}
for entry in vault.secrets.values() {
data.extend_from_slice(entry.shared.as_bytes());
data.push(0x00);
let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
scoped_pks.sort();
for pk in scoped_pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x01);
data.extend_from_slice(entry.private[pk].as_bytes());
data.push(0x00);
}
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x00);
}
for (key_name, entry) in &vault.schema {
data.push(0x02);
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
data.extend_from_slice(entry.description.as_bytes());
data.push(0x00);
if let Some(example) = &entry.example {
data.extend_from_slice(example.as_bytes());
}
data.push(0x00);
for tag in &entry.tags {
data.extend_from_slice(tag.as_bytes());
data.push(0x00);
}
if let Some(created) = &entry.created {
data.extend_from_slice(created.as_bytes());
}
data.push(0x00);
if let Some(updated) = &entry.updated {
data.extend_from_slice(updated.as_bytes());
}
data.push(0x00);
if let Some(days) = entry.rotation_interval_days {
data.extend_from_slice(days.to_string().as_bytes());
}
data.push(0x00);
if let Some(expires) = &entry.expires_at {
data.extend_from_slice(expires.as_bytes());
}
data.push(0x00);
}
let hash = blake3::keyed_hash(key, &data);
format!("blake3v3:{hash}")
}
fn schema_mac_bytes(vault: &types::Vault, data: &mut Vec<u8>) {
for (key_name, entry) in &vault.schema {
data.push(0x02);
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
data.extend_from_slice(entry.description.as_bytes());
data.push(0x00);
if let Some(example) = &entry.example {
data.extend_from_slice(example.as_bytes());
}
data.push(0x00);
for tag in &entry.tags {
data.extend_from_slice(tag.as_bytes());
data.push(0x00);
}
if let Some(created) = &entry.created {
data.extend_from_slice(created.as_bytes());
}
data.push(0x00);
if let Some(updated) = &entry.updated {
data.extend_from_slice(updated.as_bytes());
}
data.push(0x00);
if let Some(days) = entry.rotation_interval_days {
data.extend_from_slice(days.to_string().as_bytes());
}
data.push(0x00);
if let Some(expires) = &entry.expires_at {
data.extend_from_slice(expires.as_bytes());
}
data.push(0x00);
}
}
fn v6_mac_bytes(vault: &types::Vault, groups: &BTreeMap<String, Vec<String>>, data: &mut Vec<u8>) {
for key_name in vault.secrets.keys() {
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
}
for entry in vault.secrets.values() {
data.extend_from_slice(entry.shared.as_bytes());
data.push(0x00);
let mut scoped_pks: Vec<&String> = entry.private.keys().collect();
scoped_pks.sort();
for pk in scoped_pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x01);
data.extend_from_slice(entry.private[pk].as_bytes());
data.push(0x00);
}
let mut group_names: Vec<&String> = entry.grouped.keys().collect();
group_names.sort();
for g in group_names {
data.push(0x03);
data.extend_from_slice(g.as_bytes());
data.push(0x00);
data.extend_from_slice(entry.grouped[g].as_bytes());
data.push(0x00);
}
}
let mut pks = vault.recipients.clone();
pks.sort();
for pk in &pks {
data.extend_from_slice(pk.as_bytes());
data.push(0x00);
}
schema_mac_bytes(vault, data);
for (name, members) in groups {
data.push(0x04);
data.extend_from_slice(name.as_bytes());
data.push(0x00);
let mut sorted = members.clone();
sorted.sort();
for member in &sorted {
data.push(0x05);
data.extend_from_slice(member.as_bytes());
}
}
}
fn compute_mac_v6(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v6_mac_bytes(vault, groups, &mut data);
let hash = blake3::keyed_hash(key, &data);
format!("blake3v4:{hash}")
}
fn v7_mac_bytes(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
data: &mut Vec<u8>,
) {
v6_mac_bytes(vault, groups, data);
for (name, grant) in grants {
data.push(0x06);
data.extend_from_slice(name.as_bytes());
data.push(0x00);
data.extend_from_slice(grant.pubkey.as_bytes());
data.push(0x00);
data.extend_from_slice(grant.issued_at.as_bytes());
data.push(0x00);
data.extend_from_slice(grant.expires_at.as_bytes());
data.push(0x00);
data.extend_from_slice(grant.issuer.as_bytes());
data.push(0x00);
let mut scope = grant.scope.clone();
scope.sort();
for k in &scope {
data.push(0x07);
data.extend_from_slice(k.as_bytes());
}
}
}
fn compute_mac_v7(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v7_mac_bytes(vault, groups, grants, &mut data);
let hash = blake3::keyed_hash(key, &data);
format!("blake3v5:{hash}")
}
fn v8_mac_bytes(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
data: &mut Vec<u8>,
) {
v7_mac_bytes(vault, groups, grants, data);
if let Some(policy) = &vault.policy {
data.push(0x08);
let mut tags = policy.agent_allow_tags.clone();
tags.sort();
for tag in &tags {
let bytes = tag.as_bytes();
data.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
data.extend_from_slice(bytes);
}
}
}
fn compute_mac_v8(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v8_mac_bytes(vault, groups, grants, &mut data);
let hash = blake3::keyed_hash(key, &data);
format!("blake3v6:{hash}")
}
fn compute_mac_v9(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
key: &[u8; 32],
) -> String {
let mut data = Vec::new();
v8_mac_bytes(vault, groups, grants, &mut data);
for (key_name, entry) in &vault.schema {
if let Some(revoked_at) = &entry.revoked_at {
data.push(0x09);
data.extend_from_slice(key_name.as_bytes());
data.push(0x00);
data.extend_from_slice(revoked_at.as_bytes());
data.push(0x00);
}
}
let hash = blake3::keyed_hash(key, &data);
format!("blake3v7:{hash}")
}
pub(crate) fn verify_mac(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
stored_mac: &str,
mac_key: Option<&[u8; 32]>,
) -> bool {
use constant_time_eq::constant_time_eq;
if vault.schema.values().any(|e| e.revoked_at.is_some()) && !stored_mac.starts_with("blake3v7:")
{
return false;
}
if vault.policy.is_some()
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}
if !grants.is_empty()
&& !stored_mac.starts_with("blake3v5:")
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}
let touches_groups =
!groups.is_empty() || vault.secrets.values().any(|e| !e.grouped.is_empty());
if touches_groups
&& !stored_mac.starts_with("blake3v4:")
&& !stored_mac.starts_with("blake3v5:")
&& !stored_mac.starts_with("blake3v6:")
&& !stored_mac.starts_with("blake3v7:")
{
return false;
}
let expected = if stored_mac.starts_with("blake3v7:") {
match mac_key {
Some(key) => compute_mac_v9(vault, groups, grants, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v6:") {
match mac_key {
Some(key) => compute_mac_v8(vault, groups, grants, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v5:") {
match mac_key {
Some(key) => compute_mac_v7(vault, groups, grants, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v4:") {
match mac_key {
Some(key) => compute_mac_v6(vault, groups, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v3:") {
match mac_key {
Some(key) => compute_mac_v5(vault, key),
None => return false,
}
} else if stored_mac.starts_with("blake3v2:") {
match mac_key {
Some(key) => compute_mac_v4(vault, key),
None => return false,
}
} else if stored_mac.starts_with("blake3:") {
match mac_key {
Some(key) => compute_mac_v3(vault, key),
None => return false,
}
} else if stored_mac.starts_with("sha256v2:") {
compute_mac_v2(vault)
} else if stored_mac.starts_with("sha256:") {
compute_mac_v1(vault)
} else {
return false;
};
constant_time_eq(stored_mac.as_bytes(), expected.as_bytes())
}
pub(crate) fn generate_mac_key() -> String {
let key: [u8; 32] = rand::random();
key.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
}
pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut key = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
key[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
}
Some(key)
}
pub(crate) fn now_utc() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
const SIGNED_VIEW_VERSION: u32 = 1;
pub(crate) fn signing_message(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
github_pins: &HashMap<String, Vec<String>>,
signers: &BTreeMap<String, String>,
) -> Vec<u8> {
#[derive(serde::Serialize)]
struct SignedView<'a> {
v: u32,
version: &'a str,
recipients: Vec<&'a str>,
schema: &'a BTreeMap<String, types::SchemaEntry>,
secrets: &'a BTreeMap<String, types::SecretEntry>,
policy: &'a Option<types::Policy>,
groups: &'a BTreeMap<String, Vec<String>>,
grants: &'a BTreeMap<String, types::GrantEntry>,
github_pins: BTreeMap<&'a str, &'a Vec<String>>,
signers: &'a BTreeMap<String, String>,
}
let mut recipients: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
recipients.sort_unstable();
let pins: BTreeMap<&str, &Vec<String>> =
github_pins.iter().map(|(k, v)| (k.as_str(), v)).collect();
let view = SignedView {
v: SIGNED_VIEW_VERSION,
version: &vault.version,
recipients,
schema: &vault.schema,
secrets: &vault.secrets,
policy: &vault.policy,
groups,
grants,
github_pins: pins,
signers,
};
let mut msg = Vec::with_capacity(256);
msg.extend_from_slice(b"murk.vault.sig.v1\n");
serde_json::to_writer(&mut msg, &view).expect("canonical vault view serializes");
msg
}
pub(crate) fn sign_vault(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
github_pins: &HashMap<String, Vec<String>>,
signers: &mut BTreeMap<String, String>,
identity: &crypto::MurkIdentity,
) -> Option<types::VaultSignature> {
let signer = identity.pubkey_string().ok()?;
if !signer_is_recipient(vault, &signer) {
return None;
}
let sk = identity.signing_key()?;
if identity.registers_verifying_key() {
signers.insert(signer.clone(), signing::verifying_key_b64(&sk));
}
let msg = signing_message(vault, groups, grants, github_pins, signers);
Some(types::VaultSignature {
signer,
v: SIGNED_VIEW_VERSION,
sig: signing::sign(&sk, &msg),
})
}
fn signer_is_recipient(vault: &types::Vault, signer: &str) -> bool {
if signer.starts_with("ssh-ed25519 ") {
vault
.recipients
.iter()
.any(|r| signing::ssh_ed25519_key_eq(r, signer))
} else {
vault.recipients.iter().any(|r| r == signer)
}
}
pub(crate) fn verify_vault_signature(
vault: &types::Vault,
groups: &BTreeMap<String, Vec<String>>,
grants: &BTreeMap<String, types::GrantEntry>,
github_pins: &HashMap<String, Vec<String>>,
signers: &BTreeMap<String, String>,
sig: &types::VaultSignature,
) -> bool {
if sig.v != SIGNED_VIEW_VERSION {
return false;
}
if !signer_is_recipient(vault, &sig.signer) {
return false;
}
let vk = if sig.signer.starts_with("ssh-ed25519 ") {
match signing::ed25519_verifying_key_b64_from_ssh_recipient(&sig.signer) {
Some(vk) => vk,
None => return false,
}
} else {
match signers.get(&sig.signer) {
Some(vk) => vk.clone(),
None => return false,
}
};
let msg = signing_message(vault, groups, grants, github_pins, signers);
signing::verify(&vk, &sig.sig, &msg)
}
fn signing_identity(vault_path: &str) -> Option<crypto::MurkIdentity> {
use age::secrecy::ExposeSecret;
let secret = env::resolve_key_for_vault(vault_path).ok()?;
crypto::parse_identity(secret.expose_secret()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use std::collections::BTreeMap;
use std::fs;
use crate::testutil::ENV_LOCK;
#[test]
fn resolve_vault_path_finds_in_parent_dir() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".murk"), "{}").unwrap();
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(&nested).unwrap();
let got = resolve_vault_path(".murk");
std::env::set_current_dir(prev).unwrap();
assert_eq!(
std::fs::canonicalize(&got).unwrap(),
std::fs::canonicalize(dir.path().join(".murk")).unwrap()
);
}
#[test]
fn resolve_vault_path_returns_as_is_when_found_in_cwd() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".murk"), "{}").unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let got = resolve_vault_path(".murk");
std::env::set_current_dir(prev).unwrap();
assert_eq!(got, ".murk");
}
#[test]
fn resolve_vault_path_passes_through_explicit_paths() {
assert_eq!(resolve_vault_path("/abs/path.murk"), "/abs/path.murk");
assert_eq!(resolve_vault_path("./foo.murk"), "./foo.murk");
assert_eq!(resolve_vault_path("sub/dir.murk"), "sub/dir.murk");
}
#[test]
fn resolve_vault_path_stops_at_git_root() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".murk"), "{}").unwrap();
let repo = dir.path().join("repo");
fs::create_dir(&repo).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
let nested = repo.join("sub");
fs::create_dir(&nested).unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(&nested).unwrap();
let got = resolve_vault_path(".murk");
std::env::set_current_dir(prev).unwrap();
assert_eq!(got, ".murk");
}
#[test]
fn encrypt_decrypt_value_roundtrip() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
let decrypted = decrypt_value(&encoded, &identity).unwrap();
assert_eq!(&decrypted[..], b"hello world");
}
#[test]
fn decrypt_value_invalid_base64() {
let (secret, _) = generate_keypair();
let identity = make_identity(&secret);
let result = decrypt_value("not!valid!base64!!!", &identity);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid base64"));
}
#[test]
fn encrypt_value_multiple_recipients() {
let (secret_a, pubkey_a) = generate_keypair();
let (secret_b, pubkey_b) = generate_keypair();
let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
let id_a = make_identity(&secret_a);
let id_b = make_identity(&secret_b);
assert_eq!(
&decrypt_value(&encoded, &id_a).unwrap()[..],
b"shared secret"
);
assert_eq!(
&decrypt_value(&encoded, &id_b).unwrap()[..],
b"shared secret"
);
}
#[test]
fn decrypt_value_wrong_key_fails() {
let (_, pubkey) = generate_keypair();
let (wrong_secret, _) = generate_keypair();
let recipient = make_recipient(&pubkey);
let wrong_identity = make_identity(&wrong_secret);
let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
assert!(decrypt_value(&encoded, &wrong_identity).is_err());
}
#[test]
fn compute_mac_deterministic() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [0u8; 32];
let mac1 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
let mac2 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_eq!(mac1, mac2);
assert!(mac1.starts_with("blake3v3:"));
let mac_legacy = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
None,
);
assert!(mac_legacy.starts_with("sha256v2:"));
}
#[test]
fn compute_mac_changes_with_different_secrets() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [0u8; 32];
let mac_empty = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
vault.secrets.insert(
"KEY".into(),
types::SecretEntry {
shared: "ciphertext".into(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mac_with_secret = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(mac_empty, mac_with_secret);
}
#[test]
fn compute_mac_changes_with_different_recipients() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [0u8; 32];
let mac1 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
vault.recipients.push("age1xyz".into());
let mac2 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(mac1, mac2);
}
fn signed_test_vault(pubkey: &str, recipient: &crypto::MurkRecipient) -> types::Vault {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.to_string()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"API_KEY".into(),
types::SecretEntry {
shared: encrypt_value(b"REAL", std::slice::from_ref(recipient)).unwrap(),
private: BTreeMap::new(),
grouped: BTreeMap::new(),
},
);
vault
}
#[test]
fn sign_and_verify_vault_roundtrips() {
let (secret, pubkey) = generate_keypair();
let identity = make_identity(&secret);
let vault = signed_test_vault(&pubkey, &make_recipient(&pubkey));
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
assert_eq!(sig.signer, pubkey);
assert!(verify_vault_signature(
&vault, &g, &gr, &pins, &signers, &sig
));
}
#[test]
fn signature_detects_ciphertext_tampering() {
let (secret, pubkey) = generate_keypair();
let identity = make_identity(&secret);
let recipient = make_recipient(&pubkey);
let mut vault = signed_test_vault(&pubkey, &recipient);
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
vault.secrets.get_mut("API_KEY").unwrap().shared =
encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
assert!(
!verify_vault_signature(&vault, &g, &gr, &pins, &signers, &sig),
"tampered ciphertext must fail signature verification"
);
}
#[test]
fn signature_rejects_non_recipient_signer() {
let (_victim_secret, victim_pub) = generate_keypair();
let (attacker_secret, attacker_pub) = generate_keypair();
let attacker = make_identity(&attacker_secret);
let vault = signed_test_vault(&victim_pub, &make_recipient(&victim_pub));
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
assert!(sign_vault(&vault, &g, &gr, &pins, &mut signers, &attacker).is_none());
let sk = attacker.signing_key().unwrap();
signers.insert(attacker_pub.clone(), signing::verifying_key_b64(&sk));
let msg = signing_message(&vault, &g, &gr, &pins, &signers);
let forged = types::VaultSignature {
signer: attacker_pub,
v: SIGNED_VIEW_VERSION,
sig: signing::sign(&sk, &msg),
};
assert!(!verify_vault_signature(
&vault, &g, &gr, &pins, &signers, &forged
));
}
#[test]
fn end_to_end_forged_signature_fails_load() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let dir = std::env::temp_dir().join("murk_test_forged_sig_load");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = signed_test_vault(&pubkey, &recipient);
let original = types::Murk {
values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let murk = load_vault(path.to_str().unwrap()).unwrap().1;
assert!(matches!(
&murk.signature,
types::SignatureState::Signed { signer, .. } if *signer == pubkey
));
let mut tampered: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let stale_meta = decrypt_meta(&tampered, &identity).unwrap();
tampered.secrets.get_mut("API_KEY").unwrap().shared =
encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let forged_mac = compute_mac(
&tampered,
&stale_meta.groups,
&stale_meta.grants,
Some(&mac_key),
);
let forged_meta = types::Meta {
mac: forged_mac,
mac_key: Some(mac_key_hex),
sig: stale_meta.sig.clone(), signers: stale_meta.signers.clone(),
..stale_meta
};
tampered.meta =
encrypt_value(&serde_json::to_vec(&forged_meta).unwrap(), &[recipient]).unwrap();
fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
let err = result.expect_err("forged-MAC + stale-signature vault must fail to load");
assert!(
err.to_string().contains("signature is invalid"),
"expected signature failure, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
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-----";
const SSH_ED25519_PK: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
#[test]
fn ssh_signed_vault_verifies_without_registry() {
let identity = make_identity(SSH_ED25519_SK);
let recipient = make_recipient(SSH_ED25519_PK);
let vault = signed_test_vault(SSH_ED25519_PK, &recipient);
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
assert_eq!(sig.signer, SSH_ED25519_PK);
assert!(signers.is_empty(), "ssh signer must not be registered");
assert!(verify_vault_signature(
&vault,
&g,
&gr,
&pins,
&BTreeMap::new(),
&sig
));
}
#[test]
fn ssh_signed_vault_detects_tampering() {
let identity = make_identity(SSH_ED25519_SK);
let recipient = make_recipient(SSH_ED25519_PK);
let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity).unwrap();
vault.secrets.get_mut("API_KEY").unwrap().shared =
encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
assert!(!verify_vault_signature(
&vault, &g, &gr, &pins, &signers, &sig
));
}
#[test]
fn ssh_recipient_stored_with_comment_still_signs_and_verifies() {
let identity = make_identity(SSH_ED25519_SK);
let recipient = make_recipient(SSH_ED25519_PK);
let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
vault.recipients = vec![format!("{SSH_ED25519_PK} someone@host")];
let (g, gr, pins) = (BTreeMap::new(), BTreeMap::new(), HashMap::new());
let mut signers = BTreeMap::new();
let sig = sign_vault(&vault, &g, &gr, &pins, &mut signers, &identity)
.expect("comment-bearing recipient must still sign");
assert!(verify_vault_signature(
&vault, &g, &gr, &pins, &signers, &sig
));
}
#[test]
fn ssh_end_to_end_save_and_load_reports_signed() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let recipient = make_recipient(SSH_ED25519_PK);
let dir = std::env::temp_dir().join("murk_test_ssh_e2e_sign");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = signed_test_vault(SSH_ED25519_PK, &recipient);
let original = types::Murk {
values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
recipients: HashMap::from([(SSH_ED25519_PK.to_string(), "alice".to_string())]),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", SSH_ED25519_SK) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let murk = load_vault(path.to_str().unwrap()).unwrap().1;
unsafe { std::env::remove_var("MURK_KEY") };
assert_eq!(
murk.signature,
types::SignatureState::Signed {
signer: SSH_ED25519_PK.to_string(),
anchored: true,
}
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_prunes_stale_signer_registry_entries() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_prune_signers");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = signed_test_vault(&pubkey, &recipient);
let current = types::Murk {
values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
signers: BTreeMap::from([(
"age1stalerevokedrecipient".to_string(),
BASE64.encode([9u8; 32]),
)]),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, ¤t, ¤t).unwrap();
let murk = load_vault(path.to_str().unwrap()).unwrap().1;
unsafe { std::env::remove_var("MURK_KEY") };
assert!(
!murk.signers.contains_key("age1stalerevokedrecipient"),
"stale non-recipient signer entry must be pruned"
);
assert!(
murk.signers.contains_key(&pubkey),
"live signer must remain"
);
assert!(matches!(
murk.signature,
types::SignatureState::Signed { signer, .. } if signer == pubkey
));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn registry_vk_swap_rejected_by_pin_on_load() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let home = tempfile::tempdir().unwrap();
let prev_home = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", home.path()) };
unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let dir = std::env::temp_dir().join("murk_test_vk_swap");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let ps = path.to_str().unwrap();
let mut vault = signed_test_vault(&pubkey, &recipient);
let original = types::Murk {
values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(ps, &mut vault, &original, &original).unwrap();
load_vault(ps).unwrap();
let att_sk = signing::signing_key_from_age_bytes(&[42u8; 32]);
let att_vk = signing::verifying_key_b64(&att_sk);
let mut tampered: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let stale = decrypt_meta(&tampered, &identity).unwrap();
tampered.secrets.get_mut("API_KEY").unwrap().shared =
encrypt_value(b"POISON", std::slice::from_ref(&recipient)).unwrap();
let mut signers = stale.signers.clone();
signers.insert(pubkey.clone(), att_vk);
let msg = signing_message(
&tampered,
&stale.groups,
&stale.grants,
&stale.github_pins,
&signers,
);
let forged_sig = types::VaultSignature {
signer: pubkey.clone(),
v: SIGNED_VIEW_VERSION,
sig: signing::sign(&att_sk, &msg),
};
assert!(verify_vault_signature(
&tampered,
&stale.groups,
&stale.grants,
&stale.github_pins,
&signers,
&forged_sig
));
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let mac = compute_mac(&tampered, &stale.groups, &stale.grants, Some(&mac_key));
let forged_meta = types::Meta {
mac,
mac_key: Some(mac_key_hex),
sig: Some(forged_sig),
signers,
..stale
};
tampered.meta = encrypt_value(
&serde_json::to_vec(&forged_meta).unwrap(),
std::slice::from_ref(&recipient),
)
.unwrap();
fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
let err = load_vault(ps).unwrap_err();
unsafe { std::env::remove_var("MURK_KEY") };
match prev_home {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert!(
err.to_string().contains("verifying key changed"),
"expected pin failure, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn age_signature_first_use_then_anchored() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let home = tempfile::tempdir().unwrap();
let prev_home = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", home.path()) };
unsafe { std::env::remove_var("MURK_NO_SIGNER_PIN") };
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_anchor_transition");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let ps = path.to_str().unwrap();
let mut vault = signed_test_vault(&pubkey, &recipient);
let original = types::Murk {
values: HashMap::from([("API_KEY".into(), crate::testutil::secret("REAL"))]),
recipients: HashMap::from([(pubkey.clone(), "alice".to_string())]),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(ps, &mut vault, &original, &original).unwrap();
let first = load_vault(ps).unwrap().1;
let second = load_vault(ps).unwrap().1;
unsafe { std::env::remove_var("MURK_KEY") };
match prev_home {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert_eq!(
first.signature,
types::SignatureState::Signed {
signer: pubkey.clone(),
anchored: false,
},
"first load of an age key is trust-on-first-use"
);
assert_eq!(
second.signature,
types::SignatureState::Signed {
signer: pubkey,
anchored: true,
},
"second load is anchored by the pin"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_vault_preserves_unchanged_ciphertext() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let dir = std::env::temp_dir().join("murk_test_save_unchanged");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let shared = encrypt_value(b"original", std::slice::from_ref(&recipient)).unwrap();
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: shared.clone(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("original"))]),
recipients: recipients_map.clone(),
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
let current = original.clone();
save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
assert_eq!(vault.secrets["KEY1"].shared, shared);
let mut changed = current.clone();
changed
.values
.insert("KEY1".into(), crate::testutil::secret("modified"));
save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
assert_ne!(vault.secrets["KEY1"].shared, shared);
let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
assert_eq!(&decrypted[..], b"modified");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_vault_adds_new_secret() {
let (_, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_save_add");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap();
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared,
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map.clone(),
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
let mut current = original.clone();
current
.values
.insert("KEY2".into(), crate::testutil::secret("val2"));
save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
assert!(vault.secrets.contains_key("KEY1"));
assert!(vault.secrets.contains_key("KEY2"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_vault_removes_deleted_secret() {
let (_, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_save_remove");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
vault.secrets.insert(
"KEY2".into(),
types::SecretEntry {
shared: encrypt_value(b"val2", std::slice::from_ref(&recipient)).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([
("KEY1".into(), crate::testutil::secret("val1")),
("KEY2".into(), crate::testutil::secret("val2")),
]),
recipients: recipients_map.clone(),
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
let mut current = original.clone();
current.values.remove("KEY2");
save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
assert!(vault.secrets.contains_key("KEY1"));
assert!(!vault.secrets.contains_key("KEY2"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_vault_reencrypts_all_on_recipient_change() {
let (secret1, pubkey1) = generate_keypair();
let (_, pubkey2) = generate_keypair();
let recipient1 = make_recipient(&pubkey1);
let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let shared = encrypt_value(b"val1", std::slice::from_ref(&recipient1)).unwrap();
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey1.clone(), pubkey2.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: shared.clone(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey1.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
let mut current_recipients = HashMap::new();
current_recipients.insert(pubkey1.clone(), "alice".into());
current_recipients.insert(pubkey2.clone(), "bob".into());
let current = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: current_recipients,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
assert_ne!(vault.secrets["KEY1"].shared, shared);
let identity1 = make_identity(&secret1);
let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
assert_eq!(&decrypted[..], b"val1");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_vault_scoped_entry_lifecycle() {
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let identity = make_identity(&secret);
let dir = std::env::temp_dir().join("murk_test_save_scoped");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let shared = encrypt_value(b"shared_val", std::slice::from_ref(&recipient)).unwrap();
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared,
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("shared_val"))]),
recipients: recipients_map.clone(),
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
let mut current = original.clone();
let mut key_scoped = HashMap::new();
key_scoped.insert(pubkey.clone(), crate::testutil::secret("my_override"));
current.private.insert("KEY1".into(), key_scoped);
save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
assert!(vault.secrets["KEY1"].private.contains_key(&pubkey));
let scoped_val = decrypt_value(&vault.secrets["KEY1"].private[&pubkey], &identity).unwrap();
assert_eq!(&scoped_val[..], b"my_override");
let original_with_scoped = current.clone();
let mut current_no_scoped = original_with_scoped.clone();
current_no_scoped.private.remove("KEY1");
save_vault(
path.to_str().unwrap(),
&mut vault,
&original_with_scoped,
¤t_no_scoped,
)
.unwrap();
assert!(vault.secrets["KEY1"].private.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_validates_mac() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let _identity = make_identity(&secret);
let dir = std::env::temp_dir().join("murk_test_load_mac");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let mut tampered: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
tampered.secrets.get_mut("KEY1").unwrap().shared =
encrypt_value(b"tampered", &[recipient]).unwrap();
fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
let err = result.expect_err("expected MAC validation to fail");
assert!(
err.to_string().contains("integrity check failed"),
"expected integrity check failure, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_succeeds_with_valid_mac() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", &[recipient]).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
assert!(result.is_ok());
let (_, murk, _) = result.unwrap();
assert_eq!(murk.values["KEY1"].as_str(), "val1");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_not_a_recipient() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, _pubkey) = generate_keypair();
let (other_secret, other_pubkey) = generate_keypair();
let other_recipient = make_recipient(&other_pubkey);
let dir = std::env::temp_dir().join("murk_test_load_not_recipient");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![other_pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", &[other_recipient]).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(other_pubkey.clone(), "other".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &other_secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
unsafe { std::env::set_var("MURK_KEY", secret) };
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
let Err(err) = result else {
panic!("expected load_vault to fail for non-recipient");
};
let msg = err.to_string();
assert!(
msg.contains("not a recipient"),
"expected not-a-recipient error, got: {err}"
);
assert!(
!msg.contains("tampered"),
"unauthorized key must not look like tampering, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_zero_secrets() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let dir = std::env::temp_dir().join("murk_test_load_zero_secrets");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::new(),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
assert!(result.is_ok());
let (_, murk, _) = result.unwrap();
assert!(murk.values.is_empty());
assert!(murk.private.is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_stripped_meta_with_secrets_fails() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_load_stripped_meta");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", &[recipient]).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let original = types::Murk {
values: HashMap::from([("KEY1".into(), crate::testutil::secret("val1"))]),
recipients: recipients_map,
private: HashMap::new(),
legacy_mac: false,
github_pins: HashMap::new(),
..Default::default()
};
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
let mut tampered: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
tampered.meta = String::new();
fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
let result = load_vault(path.to_str().unwrap());
let err = result.expect_err("expected MAC validation to fail");
assert!(
err.to_string().contains("integrity check failed"),
"expected integrity check failure, got: {err}"
);
let mut garbled: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
garbled.meta = "not-base64!!".into();
fs::write(&path, serde_json::to_string_pretty(&garbled).unwrap()).unwrap();
let result = load_vault(path.to_str().unwrap());
let err = result.expect_err("expected corrupt meta to fail");
assert!(
err.to_string().contains("integrity check failed"),
"expected integrity check failure, got: {err}"
);
let mut flipped: types::Vault =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
flipped.meta = BASE64.encode(b"flipped ciphertext bytes");
fs::write(&path, serde_json::to_string_pretty(&flipped).unwrap()).unwrap();
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
let err = result.expect_err("expected flipped meta to fail");
assert!(
err.to_string().contains("integrity check failed"),
"expected integrity check failure, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn load_vault_empty_mac_with_secrets_fails() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (secret, pubkey) = generate_keypair();
let recipient = make_recipient(&pubkey);
let dir = std::env::temp_dir().join("murk_test_load_empty_mac");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.murk");
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec![pubkey.clone()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY1".into(),
types::SecretEntry {
shared: encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let mut recipients_map = HashMap::new();
recipients_map.insert(pubkey.clone(), "alice".into());
let meta = types::Meta {
recipients: recipients_map,
mac: String::new(),
mac_key: None,
github_pins: HashMap::new(),
..Default::default()
};
let meta_json = serde_json::to_vec(&meta).unwrap();
vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap();
crate::vault::write(Path::new(path.to_str().unwrap()), &vault).unwrap();
unsafe { std::env::set_var("MURK_KEY", &secret) };
unsafe { std::env::remove_var("MURK_KEY_FILE") };
let result = load_vault(path.to_str().unwrap());
unsafe { std::env::remove_var("MURK_KEY") };
let err = result.expect_err("expected MAC validation to fail");
assert!(
err.to_string().contains("integrity check failed"),
"expected integrity check failure, got: {err}"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn compute_mac_changes_with_scoped_entries() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.secrets.insert(
"KEY".into(),
types::SecretEntry {
shared: "ciphertext".into(),
private: BTreeMap::new(),
grouped: std::collections::BTreeMap::default(),
},
);
let key = [0u8; 32];
let mac_no_scoped = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
vault
.secrets
.get_mut("KEY")
.unwrap()
.private
.insert("age1bob".into(), "scoped-ct".into());
let mac_with_scoped = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(mac_no_scoped, mac_with_scoped);
}
#[test]
#[allow(clippy::too_many_lines)] fn verify_mac_accepts_v1_prefix() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [0u8; 32];
let v1_mac = compute_mac_v1(&vault);
let v2_mac = compute_mac_v2(&vault);
let v3_mac = compute_mac_v3(&vault, &key);
assert!(verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&v1_mac,
None
));
assert!(verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&v2_mac,
None
));
assert!(verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&v3_mac,
Some(&key)
));
assert!(!verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
"sha256:bogus",
None
));
assert!(!verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
"blake3:bogus",
Some(&key)
));
assert!(!verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
"blake3v2:bogus",
Some(&key)
));
assert!(!verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
"blake3v3:bogus",
Some(&key)
));
assert!(!verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
"unknown:prefix",
None
));
let v4_mac = compute_mac_v4(&vault, &key);
assert!(v4_mac.starts_with("blake3v2:"));
assert!(verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&v4_mac,
Some(&key)
));
let v5_mac = compute_mac_v5(&vault, &key);
assert!(v5_mac.starts_with("blake3v3:"));
assert!(verify_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&v5_mac,
Some(&key)
));
assert!(
compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key)
)
.starts_with("blake3v3:")
);
let groups = BTreeMap::from([("prod".to_string(), vec!["age1abc".to_string()])]);
let v6_mac = compute_mac(
&vault,
&groups,
&std::collections::BTreeMap::new(),
Some(&key),
);
assert!(v6_mac.starts_with("blake3v4:"));
assert!(verify_mac(
&vault,
&groups,
&std::collections::BTreeMap::new(),
&v6_mac,
Some(&key)
));
let tampered = BTreeMap::from([(
"prod".to_string(),
vec!["age1abc".to_string(), "age1evil".to_string()],
)]);
assert!(!verify_mac(
&vault,
&tampered,
&std::collections::BTreeMap::new(),
&v6_mac,
Some(&key)
));
}
#[test]
fn verify_mac_rejects_grouped_under_legacy_prefix() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [7u8; 32];
let no_groups = BTreeMap::new();
let v5_mac = compute_mac(
&vault,
&no_groups,
&std::collections::BTreeMap::new(),
Some(&key),
);
assert!(v5_mac.starts_with("blake3v3:"));
assert!(verify_mac(
&vault,
&no_groups,
&std::collections::BTreeMap::new(),
&v5_mac,
Some(&key)
));
vault.secrets.insert(
"STOLEN".into(),
types::SecretEntry {
grouped: BTreeMap::from([("prod".to_string(), "injected-ct".to_string())]),
..Default::default()
},
);
assert!(!verify_mac(
&vault,
&no_groups,
&std::collections::BTreeMap::new(),
&v5_mac,
Some(&key)
));
}
#[test]
fn mac_v7_covers_grant_metadata() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into(), "age1agent".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [9u8; 32];
let no_groups = BTreeMap::new();
let grants = BTreeMap::from([(
"codex".to_string(),
types::GrantEntry {
pubkey: "age1agent".into(),
scope: vec!["STRIPE_KEY".into()],
issued_at: "2026-02-28T00:00:00Z".into(),
expires_at: "2026-02-28T02:00:00Z".into(),
issuer: "age1abc".into(),
},
)]);
let v7_mac = compute_mac(&vault, &no_groups, &grants, Some(&key));
assert!(v7_mac.starts_with("blake3v5:"));
assert!(verify_mac(&vault, &no_groups, &grants, &v7_mac, Some(&key)));
let tampered = BTreeMap::from([(
"codex".to_string(),
types::GrantEntry {
pubkey: "age1agent".into(),
scope: vec!["STRIPE_KEY".into(), "PROD_DB".into()],
issued_at: "2026-02-28T00:00:00Z".into(),
expires_at: "2026-02-28T02:00:00Z".into(),
issuer: "age1abc".into(),
},
)]);
assert!(!verify_mac(
&vault,
&no_groups,
&tampered,
&v7_mac,
Some(&key)
));
}
#[test]
fn verify_mac_rejects_grants_under_legacy_prefix() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [3u8; 32];
let no_groups = BTreeMap::new();
let grants = BTreeMap::from([(
"codex".to_string(),
types::GrantEntry {
pubkey: "age1agent".into(),
scope: vec!["STRIPE_KEY".into()],
issued_at: "2026-02-28T00:00:00Z".into(),
expires_at: "2026-02-28T02:00:00Z".into(),
issuer: "age1abc".into(),
},
)]);
let v6_mac = compute_mac_v6(&vault, &no_groups, &key);
assert!(v6_mac.starts_with("blake3v4:"));
assert!(!verify_mac(
&vault,
&no_groups,
&grants,
&v6_mac,
Some(&key)
));
}
#[test]
fn mac_v8_covers_policy() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: Some(types::Policy {
agent_allow_tags: vec!["agents".into()],
}),
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [11u8; 32];
let no_groups = BTreeMap::new();
let no_grants = BTreeMap::new();
let v8_mac = compute_mac(&vault, &no_groups, &no_grants, Some(&key));
assert!(v8_mac.starts_with("blake3v6:"));
assert!(verify_mac(
&vault,
&no_groups,
&no_grants,
&v8_mac,
Some(&key)
));
vault.policy = Some(types::Policy {
agent_allow_tags: vec!["agents".into(), "production".into()],
});
assert!(!verify_mac(
&vault,
&no_groups,
&no_grants,
&v8_mac,
Some(&key)
));
}
#[test]
fn mac_v8_policy_tags_are_unambiguous() {
let base = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [7u8; 32];
let groups = BTreeMap::new();
let grants = BTreeMap::new();
let mut a = base.clone();
a.policy = Some(types::Policy {
agent_allow_tags: vec!["a\tb".into()],
});
let mut b = base.clone();
b.policy = Some(types::Policy {
agent_allow_tags: vec!["a".into(), "b".into()],
});
let mac_a = compute_mac(&a, &groups, &grants, Some(&key));
let mac_b = compute_mac(&b, &groups, &grants, Some(&key));
assert_ne!(mac_a, mac_b, "distinct tag lists must not share a MAC");
}
#[test]
fn verify_mac_rejects_policy_under_legacy_prefix() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: Some(types::Policy {
agent_allow_tags: vec!["agents".into()],
}),
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [5u8; 32];
let no_groups = BTreeMap::new();
let no_grants = BTreeMap::new();
let v5_mac = compute_mac_v5(&vault, &key);
assert!(v5_mac.starts_with("blake3v3:"));
assert!(!verify_mac(
&vault,
&no_groups,
&no_grants,
&v5_mac,
Some(&key)
));
}
#[test]
fn compute_mac_v5_covers_rotation_metadata() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.schema.insert(
"API_KEY".into(),
types::SchemaEntry {
description: "Main API key".into(),
updated: Some("2026-02-28T00:00:00Z".into()),
..Default::default()
},
);
let key = [0u8; 32];
let baseline = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
vault
.schema
.get_mut("API_KEY")
.unwrap()
.rotation_interval_days = Some(90);
let with_interval = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(baseline, with_interval);
vault.schema.get_mut("API_KEY").unwrap().expires_at = Some("2026-09-01T23:59:59Z".into());
let with_expiry = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(with_interval, with_expiry);
let mut cleared = vault.clone();
cleared
.schema
.get_mut("API_KEY")
.unwrap()
.rotation_interval_days = None;
cleared.schema.get_mut("API_KEY").unwrap().expires_at = None;
assert_eq!(compute_mac_v4(&vault, &key), compute_mac_v4(&cleared, &key));
}
#[test]
fn compute_mac_v9_covers_revoked_at() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
vault.schema.insert(
"API_KEY".into(),
types::SchemaEntry {
description: "Main API key".into(),
updated: Some("2026-02-28T00:00:00Z".into()),
..Default::default()
},
);
let key = [0u8; 32];
let groups = BTreeMap::new();
let grants = BTreeMap::new();
let baseline = compute_mac(&vault, &groups, &grants, Some(&key));
assert!(baseline.starts_with("blake3v3:"));
vault.schema.get_mut("API_KEY").unwrap().revoked_at = Some("2026-06-18T00:00:00Z".into());
let with_marker = compute_mac(&vault, &groups, &grants, Some(&key));
assert!(with_marker.starts_with("blake3v7:"));
assert_ne!(baseline, with_marker);
assert!(verify_mac(
&vault,
&groups,
&grants,
&with_marker,
Some(&key)
));
let v8_mac = compute_mac_v8(&vault, &groups, &grants, &key);
assert!(!verify_mac(&vault, &groups, &grants, &v8_mac, Some(&key)));
let mut cleared = vault.clone();
cleared.schema.get_mut("API_KEY").unwrap().revoked_at = None;
assert_eq!(
compute_mac_v8(&vault, &groups, &grants, &key),
compute_mac_v8(&cleared, &groups, &grants, &key)
);
}
#[test]
fn compute_mac_changes_with_schema() {
let mut vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key = [0u8; 32];
let mac_no_schema = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
vault.schema.insert(
"API_KEY".into(),
types::SchemaEntry {
description: "Main API key".into(),
tags: vec!["deploy".into()],
..Default::default()
},
);
let mac_with_schema = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(mac_no_schema, mac_with_schema);
let mac_before_retag = mac_with_schema;
vault.schema.get_mut("API_KEY").unwrap().tags = vec!["ops".into()];
let mac_after_retag = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key),
);
assert_ne!(mac_before_retag, mac_after_retag);
}
#[test]
fn mac_key_roundtrip() {
let hex = generate_mac_key();
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
let key = decode_mac_key(&hex).expect("valid hex should decode");
let rehex = key.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
assert_eq!(hex, rehex);
}
#[test]
fn decode_mac_key_rejects_bad_input() {
assert!(decode_mac_key("").is_none());
assert!(decode_mac_key("tooshort").is_none());
assert!(decode_mac_key(&"zz".repeat(32)).is_none()); assert!(decode_mac_key(&"aa".repeat(31)).is_none()); assert!(decode_mac_key(&"aa".repeat(33)).is_none()); }
#[test]
fn blake3_mac_different_key_different_mac() {
let vault = types::Vault {
version: types::VAULT_VERSION.into(),
created: "2026-02-28T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1abc".into()],
schema: BTreeMap::new(),
policy: None,
secrets: BTreeMap::new(),
meta: String::new(),
};
let key1 = [0u8; 32];
let key2 = [1u8; 32];
let mac1 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key1),
);
let mac2 = compute_mac(
&vault,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
Some(&key2),
);
assert_ne!(mac1, mac2);
}
#[test]
fn valid_key_names() {
assert!(is_valid_key_name("DATABASE_URL"));
assert!(is_valid_key_name("_PRIVATE"));
assert!(is_valid_key_name("A"));
assert!(is_valid_key_name("key123"));
}
#[test]
fn invalid_key_names() {
assert!(!is_valid_key_name(""));
assert!(!is_valid_key_name("123_START"));
assert!(!is_valid_key_name("KEY-NAME"));
assert!(!is_valid_key_name("KEY NAME"));
assert!(!is_valid_key_name("FOO$(bar)"));
assert!(!is_valid_key_name("KEY=VAL"));
}
#[test]
fn now_utc_format() {
let ts = now_utc();
assert!(ts.ends_with('Z'));
assert_eq!(ts.len(), 20);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
}
}