Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
- run: cargo clippy --all-features -- -D warnings
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
- uses: taiki-e/install-action@f092c064826410a38929a5791d2c0225b94432fe # cargo-audit
with:
Expand All @@ -40,7 +40,7 @@ jobs:
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
- uses: taiki-e/install-action@f092c064826410a38929a5791d2c0225b94432fe # nextest
- run: cargo nextest run --profile ci
- run: cargo nextest run --all-features --profile ci
- uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1
if: always()
with:
Expand Down
15 changes: 13 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,25 @@ if command -v sha256sum >/dev/null 2>&1; then
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$tmpdir/$archive" | cut -d' ' -f1)
else
echo "warning: no sha256sum or shasum found, skipping verification" >&2
actual="$expected"
echo "error: no sha256sum or shasum found — cannot verify download" >&2
echo "hint: install coreutils or use 'cargo install murk-cli' instead" >&2
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "error: checksum mismatch — expected $expected, got $actual" >&2
exit 1
fi

# Verify build provenance attestation (optional, requires gh CLI).
if command -v gh >/dev/null 2>&1; then
if gh attestation verify "$tmpdir/$archive" --repo "$REPO" >/dev/null 2>&1; then
echo "verified build provenance attestation"
else
echo "warning: attestation verification failed — binary may not have been built by CI" >&2
echo "hint: install the latest gh CLI for attestation support, or verify manually" >&2
fi
fi

tar xzf "$tmpdir/$archive" -C "$tmpdir"

if [ -w "$INSTALL_DIR" ]; then
Expand Down
4 changes: 2 additions & 2 deletions python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ def vault_dir():
dot_env = Path(tmpdir) / ".env"
for line in dot_env.read_text().splitlines():
if line.startswith("export MURK_KEY_FILE="):
key_file = line.split("=", 1)[1].strip()
key_file = line.split("=", 1)[1].strip().strip("'\"")
murk_key = Path(key_file).read_text().strip()
break
elif line.startswith("export MURK_KEY="):
murk_key = line.split("=", 1)[1].strip()
murk_key = line.split("=", 1)[1].strip().strip("'\"")
break
else:
pytest.fail("Could not find MURK_KEY in .env")
Expand Down
59 changes: 55 additions & 4 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ use std::path::Path;

use age::secrecy::SecretString;

/// Shell-escape a string using single quotes, safe for embedding in shell scripts.
/// If the value is a simple identifier (alphanumeric, `-`, `_`, `.`, `/`), returns it bare.
fn shell_escape(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}

/// Reject symlinks at the given path to prevent symlink-clobber attacks.
/// Returns Ok(()) if the path does not exist or is not a symlink.
pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
Expand Down Expand Up @@ -176,7 +189,8 @@ pub fn read_key_from_dotenv() -> Option<String> {
.strip_prefix("export MURK_KEY_FILE=")
.or_else(|| trimmed.strip_prefix("MURK_KEY_FILE="))
{
let p = Path::new(path_str.trim());
let unquoted = path_str.trim().trim_matches('\'');
let p = Path::new(unquoted);
if let Ok(contents) = read_secret_file(p, "MURK_KEY_FILE from .env") {
return Some(contents.trim().to_string());
}
Expand Down Expand Up @@ -349,8 +363,8 @@ pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), St
};

let full_content = format!(
"{existing}export MURK_KEY_FILE={}\n",
key_file_path.display()
"{existing}export MURK_KEY_FILE='{}'\n",
key_file_path.display().to_string().replace('\'', "'\\''")
);

#[cfg(unix)]
Expand Down Expand Up @@ -392,7 +406,8 @@ pub enum EnvrcStatus {
pub fn write_envrc(vault_name: &str) -> Result<EnvrcStatus, String> {
let envrc = Path::new(".envrc");
reject_symlink(envrc, ".envrc")?;
let murk_line = format!("eval \"$(murk export --vault {vault_name})\"");
let safe_vault_name = shell_escape(vault_name);
let murk_line = format!("eval \"$(murk export --vault {safe_vault_name})\"");

if envrc.exists() {
let contents = fs::read_to_string(envrc).map_err(|e| format!("reading .envrc: {e}"))?;
Expand Down Expand Up @@ -969,4 +984,40 @@ mod tests {
assert!(result.is_ok());
assert_eq!(result.unwrap(), "secret");
}

#[test]
fn shell_escape_bare_identifiers() {
assert_eq!(shell_escape(".murk"), ".murk");
assert_eq!(shell_escape("my-vault.murk"), "my-vault.murk");
assert_eq!(
shell_escape("/home/user/.config/murk/key"),
"/home/user/.config/murk/key"
);
}

#[test]
fn shell_escape_quotes_special_chars() {
assert_eq!(shell_escape("my vault"), "'my vault'");
assert_eq!(shell_escape("it's"), "'it'\\''s'");
assert_eq!(shell_escape("val'ue"), "'val'\\''ue'");
}

#[test]
fn write_envrc_escapes_vault_name() {
let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("murk_test_envrc_escape");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();

let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let status = write_envrc("my vault.murk").unwrap();
assert_eq!(status, EnvrcStatus::Created);

let contents = std::fs::read_to_string(dir.join(".envrc")).unwrap();
assert!(contents.contains("'my vault.murk'"));

std::env::set_current_dir(original_dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
}
114 changes: 107 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,14 @@ pub fn save_vault(
Ok(vault::write(Path::new(vault_path), vault)?)
}

/// Compute an integrity MAC over the vault's secrets, scoped entries, and recipients.
/// Compute an integrity MAC over the vault's secrets, scoped entries, recipients, and schema.
///
/// If an HMAC key is provided, uses BLAKE3 keyed hash (written as `blake3:`).
/// Otherwise falls back to unkeyed SHA-256 v2 for legacy compatibility.
/// If an HMAC key is provided, uses BLAKE3 keyed hash v4 (written as `blake3v2:`),
/// which includes schema in the MAC input. Otherwise falls back to unkeyed SHA-256 v2
/// for legacy compatibility.
pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String {
match mac_key {
Some(key) => compute_mac_v3(vault, key),
Some(key) => compute_mac_v4(vault, key),
None => compute_mac_v2(vault),
}
}
Expand Down Expand Up @@ -441,15 +442,73 @@ fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String {
format!("blake3:{hash}")
}

/// Verify a stored MAC against the vault, accepting v1, v2, and blake3 schemes.
/// V4 MAC: BLAKE3 keyed hash over secrets, recipients, AND schema.
/// Prefix `blake3v2:` distinguishes from v3 which omitted schema.
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.scoped.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.scoped[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);
}

// Schema: include descriptions, examples, and tags for each key.
// Uses 0x02 separator to distinguish from secrets/recipients data.
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}")
}

/// Verify a stored MAC against the vault, accepting v1, v2, blake3, and blake3v2 schemes.
pub(crate) fn verify_mac(
vault: &types::Vault,
stored_mac: &str,
mac_key: Option<&[u8; 32]>,
) -> bool {
use constant_time_eq::constant_time_eq;

let expected = if stored_mac.starts_with("blake3:") {
let expected = 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,
Expand Down Expand Up @@ -565,7 +624,7 @@ mod tests {
let mac1 = compute_mac(&vault, Some(&key));
let mac2 = compute_mac(&vault, Some(&key));
assert_eq!(mac1, mac2);
assert!(mac1.starts_with("blake3:"));
assert!(mac1.starts_with("blake3v2:"));

// Without key, falls back to sha256v2
let mac_legacy = compute_mac(&vault, None);
Expand Down Expand Up @@ -1333,7 +1392,48 @@ mod tests {
assert!(verify_mac(&vault, &v3_mac, Some(&key)));
assert!(!verify_mac(&vault, "sha256:bogus", None));
assert!(!verify_mac(&vault, "blake3:bogus", Some(&key)));
assert!(!verify_mac(&vault, "blake3v2:bogus", Some(&key)));
assert!(!verify_mac(&vault, "unknown:prefix", None));

// v4 (blake3v2) — includes schema
let v4_mac = compute_mac_v4(&vault, &key);
assert!(v4_mac.starts_with("blake3v2:"));
assert!(verify_mac(&vault, &v4_mac, Some(&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(),
secrets: BTreeMap::new(),
meta: String::new(),
};

let key = [0u8; 32];
let mac_no_schema = compute_mac(&vault, 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, Some(&key));
assert_ne!(mac_no_schema, mac_with_schema);

// Changing a tag changes the MAC
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, Some(&key));
assert_ne!(mac_before_retag, mac_after_retag);
}

#[test]
Expand Down
Loading
Loading