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
16 changes: 16 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: Bug report
about: Report a bug in murk
labels: bug
---

**What happened?**

**What did you expect?**

**Steps to reproduce**

**Environment**
- OS:
- murk version (`murk --version`):
- Install method (brew, cargo, binary):
9 changes: 9 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: Feature request
about: Suggest an improvement
labels: enhancement
---

**What problem does this solve?**

**What does the solution look like?**
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- Brief bullet list of changes -->
29 changes: 29 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Code of Conduct

## Our Pledge

We are committed to making participation in this project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to a positive environment:

- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community

Examples of unacceptable behavior:

- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening a private issue or contacting the maintainer directly. All complaints will be reviewed and investigated promptly and fairly.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Contributing

murk is pre-1.0. Contributions are welcome, especially in these areas:

- Bug reports and security issues (see [SECURITY.md](SECURITY.md) for private reporting)
- Test coverage, especially adversarial/edge-case tests
- Documentation improvements
- Platform compatibility fixes (Windows, Linux, macOS)

## Development

```bash
cargo build # Build
cargo test # Run all tests
cargo clippy # Lint
cargo fmt # Format
cargo run -- <command> # Run murk with arguments
```

## Pull Requests

- Keep PRs focused — one logical change per PR
- Run `cargo fmt`, `cargo clippy`, and `cargo test` before submitting
- PR descriptions should be a short bullet list of changes
- New features should include tests

## Security

If you find a security vulnerability, **do not open a public issue**. Use [GitHub's private vulnerability reporting](https://github.com/iicky/murk/security/advisories/new) instead.

## License

By contributing, you agree that your contributions will be licensed under the same terms as the project (MIT OR Apache-2.0).
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ clap_complete = "4.6.0"
fs2 = "0.4.3"
pyo3 = { version = "0.28", features = ["extension-module"], optional = true }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[features]
default = []
python = ["pyo3"]
Expand Down
93 changes: 69 additions & 24 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ pub(crate) fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
Ok(())
}

/// Read a file, rejecting symlinks and (on Unix) group/world-readable permissions.
/// Returns the file contents as a string.
fn read_secret_file(path: &Path, label: &str) -> Result<String, String> {
reject_symlink(path, label)?;

#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = fs::metadata(path) {
let mode = meta.mode();
if mode & WORLD_READABLE_MASK != 0 {
return Err(format!(
"{label} is readable by others (mode {:o}). Run: chmod 600 {}",
mode & 0o777,
path.display()
));
}
}
}

fs::read_to_string(path).map_err(|e| format!("cannot read {label}: {e}"))
}

/// Environment variable for the secret key.
pub const ENV_MURK_KEY: &str = "MURK_KEY";
/// Environment variable for the secret key file path.
Expand Down Expand Up @@ -60,21 +83,19 @@ pub fn resolve_key_for_vault(vault_path: &str) -> Result<SecretString, String> {
// 2. Key file env var.
if let Ok(path) = env::var(ENV_MURK_KEY_FILE) {
let p = std::path::Path::new(&path);
if p.is_symlink() {
return Err("MURK_KEY_FILE is a symlink — refusing to follow for security".into());
}
return fs::read_to_string(p)
.map(|contents| SecretString::from(contents.trim().to_string()))
.map_err(|e| format!("cannot read key file: {e}"));
let contents = read_secret_file(p, "MURK_KEY_FILE")?;
return Ok(SecretString::from(contents.trim().to_string()));
}
// 3. Key file for the specified vault.
if let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists()) {
return fs::read_to_string(&path)
.map(|contents| SecretString::from(contents.trim().to_string()))
.map_err(|e| format!("cannot read key file: {e}"));
let contents = read_secret_file(&path, "key file")?;
return Ok(SecretString::from(contents.trim().to_string()));
}
// 4. Backward compat: read from .env file.
// 4. Backward compat: read from .env file (deprecated).
if let Some(key) = read_key_from_dotenv() {
eprintln!(
"\x1b[1;33mwarn\x1b[0m reading key from .env is deprecated — use MURK_KEY_FILE or `murk init` instead"
);
return Ok(SecretString::from(key));
}
Err(
Expand Down Expand Up @@ -143,27 +164,36 @@ pub fn warn_env_permissions() {

/// Read MURK_KEY from `.env` file if present.
///
/// Checks for both `export MURK_KEY=...` and `MURK_KEY=...` forms.
/// Returns the key value or `None` if not found.
/// Supports `MURK_KEY_FILE=path` references (preferred) and legacy inline
/// `MURK_KEY=value` (deprecated — logs a warning).
pub fn read_key_from_dotenv() -> Option<String> {
let contents = fs::read_to_string(".env").ok()?;
let env_path = Path::new(".env");
let contents = fs::read_to_string(env_path).ok()?;
for line in contents.lines() {
let trimmed = line.trim();
// Direct key: MURK_KEY=AGE-SECRET-KEY-...
// Key file reference (preferred path).
if let Some(path_str) = trimmed
.strip_prefix("export MURK_KEY_FILE=")
.or_else(|| trimmed.strip_prefix("MURK_KEY_FILE="))
{
let p = Path::new(path_str.trim());
if let Ok(contents) = read_secret_file(p, "MURK_KEY_FILE from .env") {
return Some(contents.trim().to_string());
}
}
// Inline key (deprecated — still accepted for backward compat).
if let Some(key) = trimmed.strip_prefix("export MURK_KEY=") {
eprintln!(
"\x1b[1;33mwarn\x1b[0m inline MURK_KEY in .env is deprecated — use MURK_KEY_FILE instead"
);
return Some(key.to_string());
}
if let Some(key) = trimmed.strip_prefix("MURK_KEY=") {
eprintln!(
"\x1b[1;33mwarn\x1b[0m inline MURK_KEY in .env is deprecated — use MURK_KEY_FILE instead"
);
return Some(key.to_string());
}
// Key file reference: MURK_KEY_FILE=~/.config/murk/keys/...
if let Some(contents) = trimmed
.strip_prefix("export MURK_KEY_FILE=")
.or_else(|| trimmed.strip_prefix("MURK_KEY_FILE="))
.and_then(|p| fs::read_to_string(p.trim()).ok())
{
return Some(contents.trim().to_string());
}
}
None
}
Expand Down Expand Up @@ -535,7 +565,22 @@ mod tests {
unsafe { env::remove_var("MURK_KEY") };

let path = std::env::temp_dir().join("murk_test_key_file");
std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
{
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&path)
.unwrap();
std::io::Write::write_all(&mut f, b"AGE-SECRET-KEY-1FROMFILE\n").unwrap();
}
#[cfg(not(unix))]
std::fs::write(&path, "AGE-SECRET-KEY-1FROMFILE\n").unwrap();
}

unsafe { env::set_var("MURK_KEY_FILE", path.to_str().unwrap()) };
let result = resolve_key();
Expand All @@ -558,7 +603,7 @@ mod tests {
resolve_key_sandbox_teardown(&tmp, &prev);

assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot read key file"));
assert!(result.unwrap_err().contains("cannot read"));
}

#[test]
Expand Down
25 changes: 23 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,8 +992,12 @@ fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) {
buf
};

// Write to a secure tempfile.
let dir = std::env::temp_dir();
// Prefer XDG_RUNTIME_DIR (typically tmpfs, not written to disk) over /tmp.
let dir = std::env::var("XDG_RUNTIME_DIR")
.ok()
.map(std::path::PathBuf::from)
.filter(|p| p.is_dir())
.unwrap_or_else(std::env::temp_dir);
let mut tmp = tempfile::Builder::new()
.prefix("murk-edit-")
.suffix(".env")
Expand Down Expand Up @@ -1455,6 +1459,18 @@ fn cmd_diff(git_ref: &str, show_values: bool, json: bool, vault_path: &str) {
}
}

fn warn_rsa_keys(keys: &[String]) {
let rsa_count = keys.iter().filter(|k| k.starts_with("ssh-rsa ")).count();
if rsa_count > 0 {
eprintln!(
"{} {} ssh-rsa key{} added — ed25519 is recommended (see RUSTSEC-2023-0071)",
"warn".yellow().bold(),
rsa_count,
if rsa_count == 1 { "" } else { "s" }
);
}
}

fn cmd_authorize(pubkey: &str, name: Option<&str>, vault_path: &str) {
let (mut vault, murk, _identity, _lock) = load_vault_locked(vault_path);
let original = murk.clone();
Expand Down Expand Up @@ -1514,6 +1530,9 @@ fn cmd_authorize(pubkey: &str, name: Option<&str>, vault_path: &str) {
summary,
if added == 1 { "" } else { "s" }
);

let added_keys: Vec<String> = keys.iter().map(|(_, k)| k.clone()).collect();
warn_rsa_keys(&added_keys);
} else if let Some(path_hint) = pubkey.strip_prefix("ssh:") {
// Read SSH public key from a file.
let path = if path_hint.is_empty() {
Expand Down Expand Up @@ -1560,6 +1579,7 @@ fn cmd_authorize(pubkey: &str, name: Option<&str>, vault_path: &str) {
.map(|n| n.to_string())
.unwrap_or_else(|| path.display().to_string());
eprintln!("{} authorized {}", "◆".magenta(), display.bold());
warn_rsa_keys(&[key_string]);
} else {
// Raw pubkey (age or SSH).
try_or_die(murk_cli::authorize_recipient(
Expand All @@ -1573,6 +1593,7 @@ fn cmd_authorize(pubkey: &str, name: Option<&str>, vault_path: &str) {

let display = name.unwrap_or(pubkey);
eprintln!("{} authorized {}", "◆".magenta(), display.bold());
warn_rsa_keys(&[pubkey.to_string()]);
}
}

Expand Down
36 changes: 26 additions & 10 deletions src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,32 @@ fn lock_path(vault_path: &Path) -> PathBuf {
/// read-modify-write cycles to prevent concurrent writes from losing changes.
pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
let lp = lock_path(vault_path);
if lp.is_symlink() {
return Err(VaultError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"lock file is a symlink — refusing to follow: {}",
lp.display()
),
)));
}
let file = File::create(&lp)?;

// Open lock file without following symlinks (race-safe on Unix).
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&lp)?
};
#[cfg(not(unix))]
let file = {
// Fallback: check-then-open (still has TOCTOU on non-Unix).
if lp.is_symlink() {
return Err(VaultError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"lock file is a symlink — refusing to follow: {}",
lp.display()
),
)));
}
File::create(&lp)?
};
file.lock_exclusive().map_err(|e| {
VaultError::Io(std::io::Error::new(
e.kind(),
Expand Down
Loading