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
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
- run: cargo install cargo-audit --locked && cargo audit --ignore RUSTSEC-2023-0071

test:
name: Test (${{ matrix.os }})
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/scorecard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Scorecard

on:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1" # weekly Monday 6am UTC

permissions: read-all

jobs:
analysis:
name: Scorecard
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ossf/scorecard-action@05b42c624433fc40578a4c7a9aefebc00138e32f # v2.4.1
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
8 changes: 4 additions & 4 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[workspace]
members = [".", "node"]
exclude = ["fuzz"]

[package]
name = "murk-cli"
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
[![docs.rs](https://img.shields.io/docsrs/murk-cli)](https://docs.rs/murk-cli)
[![License](https://img.shields.io/crates/l/murk-cli)](LICENSE-MIT)

Encrypted secrets manager for developers. One key unlocks everything.
Encrypted secrets manager for developers.

murk stores encrypted secrets in a single `.murk` file that's safe to commit to git. It uses [age](https://age-encryption.org/) encryption, works with [direnv](https://direnv.net/), and supports teams — all in one binary with no runtime dependencies.
murk stores encrypted secrets in a single `.murk` file designed to be committed to git. Values are encrypted with [age](https://age-encryption.org/), key names remain readable. It works with [direnv](https://direnv.net/) and supports teams — one binary, no runtime dependencies.

> murk is pre-1.0 and has not been independently audited. Use good judgment with production secrets.

Expand All @@ -20,7 +20,7 @@ murk stores encrypted secrets in a single `.murk` file that's safe to commit to

Most teams share `.env` files over Slack. That's bad. Tools like SOPS and Vault exist but they're complex, require cloud setup, or pull in runtimes you don't want.

murk is simple: one key on your machine, one encrypted file in your repo, done.
murk is simple: one key on your machine, one encrypted file in your repo. See [THREAT_MODEL.md](THREAT_MODEL.md) for what it protects and what it doesn't.

## How murk compares

Expand All @@ -37,7 +37,7 @@ murk is simple: one key on your machine, one encrypted file in your repo, done.

**SOPS** is the closest alternative. Both encrypt values in-place and support age. murk differs in having scoped (per-user) secrets, a single-file vault model, built-in team management (`murk circle`), and BIP39 key recovery. SOPS has broader KMS backend support and a larger ecosystem.

**Vault** solves a different problem — it's centralized infrastructure for secret storage, rotation, and dynamic credentials. If you need a secrets server, use Vault. If you want encrypted secrets in your repo, use murk.
**Vault** solves a different problem — it's centralized infrastructure for secret storage, rotation, and dynamic credentials. If you need a secrets server, use Vault. murk is scoped to encrypted secrets in a repo.

**dotenvx** encrypts `.env` files but uses a single shared key for the whole team. There's no per-recipient encryption — if someone leaves, everyone needs a new key.

Expand Down Expand Up @@ -150,6 +150,8 @@ murk rotate --all # prompts for each secret
git commit -am "revoke carol, rotate secrets" && git push
```

Revocation re-encrypts the vault going forward, but old versions remain in git history. The revoked user can still decrypt any version they previously had access to. Always rotate secrets after revocation.

If you already have new values in a file, import them directly:

```bash
Expand All @@ -174,7 +176,7 @@ steps:
- run: ./deploy.sh # all vault secrets are now in the environment
```

Store your `MURK_KEY` as a GitHub Actions secret. All decrypted values are masked in logs.
Store your `MURK_KEY` as a GitHub Actions secret. Decrypted values are registered with GitHub's log masking, but masking depends on GitHub's runner behavior and is not a hard security boundary.

## Recovery

Expand Down Expand Up @@ -213,12 +215,12 @@ murk restore

## Design

- **age does the crypto** — no custom cryptography
- **age for encryption, BLAKE3 for integrity** — no custom cryptographic primitives, documented integrity layer
- **Git is the audit trail** — murk doesn't replicate what git does
- **Header is public, values are private** — key names are visible, values are not
- **Explicit over magic** — never silently overwrites or destroys data

The `.murk` file is safe to commit — key names are readable, values are individually encrypted:
The `.murk` file is designed to be committed — key names are readable, values are individually encrypted:

```json
{
Expand All @@ -244,7 +246,7 @@ See [SPEC.md](SPEC.md) for the full specification.

**Key names are plaintext** — the `.murk` header exposes key names (e.g. `STRIPE_SECRET_KEY`, `DATABASE_URL`) so that `murk info` works without a key and git diffs stay readable. Only values are encrypted. If your threat model requires hiding what services you use, this is a trade-off to be aware of.

**Key storage** — your secret key lives in `~/.config/murk/keys/` with `chmod 600` permissions, outside your repository. The `.env` file in your project contains only a `MURK_KEY_FILE` reference to this path, not the key itself. This is the same trust model as SSH keys in `~/.ssh`. If a machine is compromised, rotate your key and re-authorize with a new one.
**Key storage** — your secret key lives in `~/.config/murk/keys/` with `chmod 600` permissions, outside your repository. The `.env` file in your project contains only a `MURK_KEY_FILE` reference to this path, not the key itself. Similar to SSH keys in `~/.ssh`, but murk also exposes secrets via `export` and `exec` into subprocess environments. If a machine is compromised, rotate your key and re-authorize with a new one.

**Access control is advisory** — any authorized recipient can decrypt all shared secrets. Per-key access metadata in the schema is cosmetic and not enforced cryptographically. If a recipient has `MURK_KEY` and is in the recipient list, they can read everything in the shared layer. Use scoped secrets (motes) for values that should stay private to one recipient.

Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ If you find a vulnerability in the underlying cryptographic primitives, please r

For a detailed analysis of what murk protects and what it doesn't, see [THREAT_MODEL.md](THREAT_MODEL.md).

## Known Limitations

- **Not audited.** murk has not had an independent security audit. It is pre-1.0 software. Use good judgment with production secrets.
- **Not a Vault replacement.** murk is a file-based secrets manager for dev teams. It is not designed for regulated environments, dynamic secrets, rotation policies, or provable access controls. If you need a secrets server, use HashiCorp Vault.
- **Revoked recipients can read old git history.** Revoking a recipient re-encrypts the vault going forward, but old `.murk` versions remain in git. The revoked user can still decrypt any version they had access to. Always rotate secrets after revocation.
- **Plaintext during edit.** `murk edit` writes decrypted values to a temp file for `$EDITOR`. The file is overwritten with zeros and deleted afterward, but the plaintext existed on disk briefly. Core dumps, swap, or filesystem journaling could retain fragments.
- **Compromised workstation = full access.** If an attacker has access to the machine where your key lives, they can decrypt all shared secrets. murk is not a defense against a compromised machine — it protects secrets at rest in git and in transit.
- **Key names are public.** The `.murk` header exposes what secrets exist (e.g. `STRIPE_SECRET_KEY`). Only values are encrypted. This is a deliberate trade-off for usability (`murk info` without a key, readable git diffs).
- **No custom cryptography.** murk delegates all crypto to [age](https://age-encryption.org/). Minimal custom code, explicit trade-offs, single-file format.

## Known Issues

**SSH-RSA timing sidechannel (RUSTSEC-2023-0071)** — The `rsa` crate used by age's SSH-RSA support is affected by the Marvin Attack, a timing sidechannel. murk accepts `ssh-rsa` recipients via `circle authorize`, `ssh:` paths, and `github:username`. The risk is low for a local CLI (the attack requires many decryption queries against a server), but if your threat model includes timing oracles, use ed25519 keys instead of RSA. No upstream fix is available yet.
Expand Down
6 changes: 3 additions & 3 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ murk is pre-1.0 and has not been independently audited. See [SECURITY.md](SECURI

## What murk protects

**Secrets at rest in git.** The `.murk` file is safe to commit. Secret values are individually encrypted with [age](https://age-encryption.org/) (X25519 + ChaCha20-Poly1305). An attacker with read access to the repository cannot decrypt values without a recipient's private key.
**Secrets at rest in git.** The `.murk` file is designed to be committed. Secret values are individually encrypted with [age](https://age-encryption.org/) (X25519 + ChaCha20-Poly1305). An attacker with read access to the repository cannot decrypt values without a recipient's private key.

**Secrets in transit via git.** Since values are encrypted before they enter git, pushing/pulling over any transport (HTTPS, SSH, unencrypted) does not expose secret values.

Expand Down Expand Up @@ -115,13 +115,13 @@ murk includes a git merge driver (`murk merge-driver`) that performs three-way m

## Cryptographic properties

murk delegates all cryptography to age. It does not implement any custom cryptographic primitives.
murk uses age for all encryption and decryption. It does not implement custom cryptographic primitives, but defines a vault format and BLAKE3 keyed integrity layer on top of age.

- **Encryption:** age v1 (X25519 key agreement, ChaCha20-Poly1305 payload encryption)
- **Per-value encryption:** each secret value is encrypted independently with a fresh age file key
- **Recipient types:** age x25519 keys (`age1...`) and SSH keys (`ssh-ed25519`, `ssh-rsa`) — age handles both natively
- **Integrity:** BLAKE3 keyed MAC over sorted key names + encrypted shared values + sorted recipient public keys, stored inside an age-encrypted meta blob (legacy SHA-256 accepted on load)
- **Key derivation:** BIP39 mnemonic (256 bits of entropy) → SHA-256 → age identity (age keys only; SSH keys use their native format)
- **Key derivation:** BIP39 mnemonic (256 bits of entropy) → direct Bech32 encoding → age identity (no intermediate hash; same bytes, same key). SSH keys use their native format.

The MAC binds independent age ciphertexts together. Without it, an attacker could swap ciphertexts between key names (age authenticates individual ciphertexts but has no cross-value binding).

Expand Down
4 changes: 4 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
49 changes: 49 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[package]
name = "murk-cli-fuzz"
version = "0.0.0"
publish = false
edition = "2024"

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"

[dependencies.murk-cli]
path = ".."

[[bin]]
name = "fuzz_vault_parse"
path = "fuzz_targets/fuzz_vault_parse.rs"
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_env_parse"
path = "fuzz_targets/fuzz_env_parse.rs"
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_merge"
path = "fuzz_targets/fuzz_merge.rs"
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_recipient_parse"
path = "fuzz_targets/fuzz_recipient_parse.rs"
test = false
doc = false
bench = false

[[bin]]
name = "fuzz_recovery"
path = "fuzz_targets/fuzz_recovery.rs"
test = false
doc = false
bench = false
7 changes: 7 additions & 0 deletions fuzz/fuzz_targets/fuzz_env_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &str| {
// .env parsing must never panic on arbitrary input.
let _ = murk_cli::parse_env(data);
});
16 changes: 16 additions & 0 deletions fuzz/fuzz_targets/fuzz_merge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
// Split fuzzer input into three chunks for base/ours/theirs.
if data.len() < 3 {
return;
}
let chunk = data.len() / 3;
let base = std::str::from_utf8(&data[..chunk]).unwrap_or("");
let ours = std::str::from_utf8(&data[chunk..chunk * 2]).unwrap_or("");
let theirs = std::str::from_utf8(&data[chunk * 2..]).unwrap_or("");

// Merge driver must never panic on arbitrary vault-like input.
let _ = murk_cli::run_merge_driver(base, ours, theirs);
});
7 changes: 7 additions & 0 deletions fuzz/fuzz_targets/fuzz_recipient_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &str| {
// Recipient parsing must never panic on arbitrary input.
let _ = murk_cli::crypto::parse_recipient(data);
});
7 changes: 7 additions & 0 deletions fuzz/fuzz_targets/fuzz_recovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &str| {
// Recovery phrase handling must never panic on arbitrary input.
let _ = murk_cli::recovery::recover(data);
});
7 changes: 7 additions & 0 deletions fuzz/fuzz_targets/fuzz_vault_parse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &str| {
// Vault parsing must never panic on arbitrary input.
let _ = murk_cli::vault::parse(data);
});
15 changes: 15 additions & 0 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ use std::path::Path;

use age::secrecy::SecretString;

/// 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> {
if path.is_symlink() {
return Err(format!(
"{label} is a symlink — refusing to follow for security"
));
}
Ok(())
}

/// 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 @@ -178,6 +189,7 @@ pub fn dotenv_has_murk_key() -> bool {
/// On non-Unix platforms, permissions are not hardened.
pub fn write_key_to_dotenv(secret_key: &str) -> Result<(), String> {
let env_path = Path::new(".env");
reject_symlink(env_path, ".env")?;

// Read existing content (minus any MURK_KEY lines).
let existing = if env_path.exists() {
Expand Down Expand Up @@ -264,6 +276,7 @@ fn dirs_path() -> Result<std::path::PathBuf, String> {

/// Write a secret key to a file with restricted permissions.
pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(), String> {
reject_symlink(path, &path.display().to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
Expand All @@ -287,6 +300,7 @@ pub fn write_key_to_file(path: &std::path::Path, secret_key: &str) -> Result<(),
/// Write a MURK_KEY_FILE reference to `.env`, removing any existing MURK_KEY/MURK_KEY_FILE lines.
pub fn write_key_ref_to_dotenv(key_file_path: &std::path::Path) -> Result<(), String> {
let env_path = Path::new(".env");
reject_symlink(env_path, ".env")?;

let existing = if env_path.exists() {
let contents = fs::read_to_string(env_path).map_err(|e| format!("reading .env: {e}"))?;
Expand Down Expand Up @@ -347,6 +361,7 @@ pub enum EnvrcStatus {
/// If it exists but doesn't, appends the line. Otherwise creates the file.
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})\"");

if envrc.exists() {
Expand Down
2 changes: 2 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub fn setup_merge_driver() -> Result<Vec<MergeDriverSetupStep>, String> {
let gitattributes = Path::new(".gitattributes");
let merge_line = GITATTRIBUTES_LINE;

crate::env::reject_symlink(gitattributes, ".gitattributes")?;

if gitattributes.exists() {
let contents = fs::read_to_string(gitattributes)
.map_err(|e| format!("reading .gitattributes: {e}"))?;
Expand Down
10 changes: 5 additions & 5 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ pub fn create_vault(
meta: String::new(),
};

let hmac_key_hex = crate::generate_hmac_key();
let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
let mac = crate::compute_mac(&vault, Some(&hmac_key));
let mac_key_hex = crate::generate_mac_key();
let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
let mac = crate::compute_mac(&vault, Some(&mac_key));
let meta = types::Meta {
recipients: recipient_names,
mac,
hmac_key: Some(hmac_key_hex),
mac_key: Some(mac_key_hex),
};
let meta_json =
serde_json::to_vec(&meta).map_err(|e| MurkError::Secret(format!("meta serialize: {e}")))?;
Expand Down Expand Up @@ -206,7 +206,7 @@ mod tests {
let meta = types::Meta {
recipients: names,
mac: String::new(),
hmac_key: None,
mac_key: None,
};
let meta_json = serde_json::to_vec(&meta).unwrap();
let meta_enc = encrypt_value(&meta_json, &[recipient]).unwrap();
Expand Down
Loading
Loading