From 4515df53b4c15dabe622ed533346c17bfabe2478 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:35:51 -0400 Subject: [PATCH 1/5] add OpenSSF Scorecard workflow and cargo-audit to lint job --- .github/workflows/ci.yaml | 1 + .github/workflows/scorecard.yaml | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/scorecard.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 619272f..6e79278 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 test: name: Test (${{ matrix.os }}) diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..c114952 --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -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 From 91c11a2f88247713cac374ab5b05127566b184e4 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:48:05 -0400 Subject: [PATCH 2/5] add fuzz targets, symlink checks on all write paths, sharpen security docs --- Cargo.lock | 8 ++-- Cargo.toml | 1 + SECURITY.md | 10 +++++ fuzz/.gitignore | 4 ++ fuzz/Cargo.toml | 49 +++++++++++++++++++++++ fuzz/fuzz_targets/fuzz_env_parse.rs | 7 ++++ fuzz/fuzz_targets/fuzz_merge.rs | 16 ++++++++ fuzz/fuzz_targets/fuzz_recipient_parse.rs | 7 ++++ fuzz/fuzz_targets/fuzz_recovery.rs | 7 ++++ fuzz/fuzz_targets/fuzz_vault_parse.rs | 7 ++++ src/env.rs | 15 +++++++ src/git.rs | 2 + 12 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/fuzz_env_parse.rs create mode 100644 fuzz/fuzz_targets/fuzz_merge.rs create mode 100644 fuzz/fuzz_targets/fuzz_recipient_parse.rs create mode 100644 fuzz/fuzz_targets/fuzz_recovery.rs create mode 100644 fuzz/fuzz_targets/fuzz_vault_parse.rs diff --git a/Cargo.lock b/Cargo.lock index e77bce5..f624427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2869,18 +2869,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3a70fcf..5984f75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [".", "node"] +exclude = ["fuzz"] [package] name = "murk-cli" diff --git a/SECURITY.md b/SECURITY.md index a6f4518..5168175 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..27dce40 --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/fuzz_env_parse.rs b/fuzz/fuzz_targets/fuzz_env_parse.rs new file mode 100644 index 0000000..66c8f00 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_env_parse.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/fuzz_merge.rs b/fuzz/fuzz_targets/fuzz_merge.rs new file mode 100644 index 0000000..70d912c --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_merge.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/fuzz_recipient_parse.rs b/fuzz/fuzz_targets/fuzz_recipient_parse.rs new file mode 100644 index 0000000..93ca4ce --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_recipient_parse.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/fuzz_recovery.rs b/fuzz/fuzz_targets/fuzz_recovery.rs new file mode 100644 index 0000000..ac1df75 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_recovery.rs @@ -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); +}); diff --git a/fuzz/fuzz_targets/fuzz_vault_parse.rs b/fuzz/fuzz_targets/fuzz_vault_parse.rs new file mode 100644 index 0000000..e831525 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_vault_parse.rs @@ -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); +}); diff --git a/src/env.rs b/src/env.rs index 8400710..a33fa2e 100644 --- a/src/env.rs +++ b/src/env.rs @@ -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. @@ -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() { @@ -264,6 +276,7 @@ fn dirs_path() -> Result { /// 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; @@ -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}"))?; @@ -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 { let envrc = Path::new(".envrc"); + reject_symlink(envrc, ".envrc")?; let murk_line = format!("eval \"$(murk export --vault {vault_name})\""); if envrc.exists() { diff --git a/src/git.rs b/src/git.rs index eeec457..9e6b32d 100644 --- a/src/git.rs +++ b/src/git.rs @@ -38,6 +38,8 @@ pub fn setup_merge_driver() -> Result, 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}"))?; From 5e0cf8a364552243a1bde9e838b7886f25f405b8 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:51:08 -0400 Subject: [PATCH 3/5] soften absolute claims in README and THREAT_MODEL, fix BIP39 key derivation docs --- README.md | 18 ++++++++++-------- THREAT_MODEL.md | 6 +++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0e03898..ae1aacb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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. @@ -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 @@ -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 @@ -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 { @@ -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. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index da0189c..c5002cb 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -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. @@ -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). From d06f5edfa1d7857daa41400beb0a34454c51bb70 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:56:33 -0400 Subject: [PATCH 4/5] rename hmac_key to mac_key (BLAKE3 keyed hash, not HMAC); accept old field via serde alias --- src/init.rs | 10 +++++----- src/lib.rs | 46 +++++++++++++++++++++++----------------------- src/merge.rs | 10 +++++----- src/recipients.rs | 4 ++-- src/types.rs | 4 ++-- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/init.rs b/src/init.rs index f8626a9..a9c2f56 100644 --- a/src/init.rs +++ b/src/init.rs @@ -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}")))?; @@ -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(); diff --git a/src/lib.rs b/src/lib.rs index f29e915..1aad46c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,9 +140,9 @@ pub fn decrypt_vault( // with an integrity error, not a misleading "you are not a recipient" message. let (recipients, legacy_mac) = match decrypt_meta(vault, identity) { Some(meta) if !meta.mac.is_empty() => { - let hmac_key = meta.hmac_key.as_deref().and_then(decode_hmac_key); - if !verify_mac(vault, &meta.mac, hmac_key.as_ref()) { - let expected = compute_mac(vault, hmac_key.as_ref()); + let mac_key = meta.mac_key.as_deref().and_then(decode_mac_key); + if !verify_mac(vault, &meta.mac, mac_key.as_ref()) { + let expected = compute_mac(vault, mac_key.as_ref()); return Err(MurkError::Integrity(format!( "vault may have been tampered with (expected {expected}, got {})", meta.mac @@ -297,13 +297,13 @@ pub fn save_vault( vault.secrets = new_secrets; // Update meta — always generate a fresh BLAKE3 key on save. - let hmac_key_hex = generate_hmac_key(); - let hmac_key = decode_hmac_key(&hmac_key_hex).unwrap(); - let mac = compute_mac(vault, Some(&hmac_key)); + let mac_key_hex = generate_mac_key(); + let mac_key = decode_mac_key(&mac_key_hex).unwrap(); + let mac = compute_mac(vault, Some(&mac_key)); let meta = types::Meta { recipients: current.recipients.clone(), 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}")))?; @@ -316,8 +316,8 @@ pub fn save_vault( /// /// If an HMAC key is provided, uses BLAKE3 keyed hash (written as `blake3:`). /// Otherwise falls back to unkeyed SHA-256 v2 for legacy compatibility. -pub(crate) fn compute_mac(vault: &types::Vault, hmac_key: Option<&[u8; 32]>) -> String { - match hmac_key { +pub(crate) fn compute_mac(vault: &types::Vault, mac_key: Option<&[u8; 32]>) -> String { + match mac_key { Some(key) => compute_mac_v3(vault, key), None => compute_mac_v2(vault), } @@ -442,12 +442,12 @@ fn compute_mac_v3(vault: &types::Vault, key: &[u8; 32]) -> String { pub(crate) fn verify_mac( vault: &types::Vault, stored_mac: &str, - hmac_key: Option<&[u8; 32]>, + mac_key: Option<&[u8; 32]>, ) -> bool { use constant_time_eq::constant_time_eq; let expected = if stored_mac.starts_with("blake3:") { - match hmac_key { + match mac_key { Some(key) => compute_mac_v3(vault, key), None => return false, } @@ -462,7 +462,7 @@ pub(crate) fn verify_mac( } /// Generate a random 32-byte BLAKE3 MAC key, returned as hex. -pub(crate) fn generate_hmac_key() -> String { +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; @@ -472,7 +472,7 @@ pub(crate) fn generate_hmac_key() -> String { } /// Decode a hex-encoded 32-byte key. -pub(crate) fn decode_hmac_key(hex: &str) -> Option<[u8; 32]> { +pub(crate) fn decode_mac_key(hex: &str) -> Option<[u8; 32]> { if hex.len() != 64 { return None; } @@ -1238,7 +1238,7 @@ mod tests { let meta = types::Meta { recipients: recipients_map, mac: String::new(), - hmac_key: None, + mac_key: None, }; let meta_json = serde_json::to_vec(&meta).unwrap(); vault.meta = encrypt_value(&meta_json, &[recipient]).unwrap(); @@ -1322,12 +1322,12 @@ mod tests { } #[test] - fn hmac_key_roundtrip() { - let hex = generate_hmac_key(); + 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_hmac_key(&hex).expect("valid hex should decode"); + let key = decode_mac_key(&hex).expect("valid hex should decode"); // Re-encode and compare. let rehex = key.iter().fold(String::new(), |mut s, b| { use std::fmt::Write; @@ -1338,12 +1338,12 @@ mod tests { } #[test] - fn decode_hmac_key_rejects_bad_input() { - assert!(decode_hmac_key("").is_none()); - assert!(decode_hmac_key("tooshort").is_none()); - assert!(decode_hmac_key(&"zz".repeat(32)).is_none()); // invalid hex - assert!(decode_hmac_key(&"aa".repeat(31)).is_none()); // 31 bytes - assert!(decode_hmac_key(&"aa".repeat(33)).is_none()); // 33 bytes + 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()); // invalid hex + assert!(decode_mac_key(&"aa".repeat(31)).is_none()); // 31 bytes + assert!(decode_mac_key(&"aa".repeat(33)).is_none()); // 33 bytes } #[test] diff --git a/src/merge.rs b/src/merge.rs index 7923adb..e18fc6b 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -605,7 +605,7 @@ pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Opti let default_meta = || crate::types::Meta { recipients: HashMap::new(), mac: String::new(), - hmac_key: None, + mac_key: None, }; let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta); @@ -620,13 +620,13 @@ pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Opti // Only keep names for recipients still in the merged vault. names.retain(|pk, _| merged.recipients.contains(pk)); - let hmac_key_hex = crate::generate_hmac_key(); - let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap(); - let mac = compute_mac(merged, Some(&hmac_key)); + let mac_key_hex = crate::generate_mac_key(); + let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap(); + let mac = compute_mac(merged, Some(&mac_key)); let meta = crate::types::Meta { recipients: names, mac, - hmac_key: Some(hmac_key_hex), + mac_key: Some(mac_key_hex), }; let recipients = parse_recipients(&merged.recipients).ok()?; diff --git a/src/recipients.rs b/src/recipients.rs index 8fc7fe2..ddb7b08 100644 --- a/src/recipients.rs +++ b/src/recipients.rs @@ -495,7 +495,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 r2 = make_recipient(&pk2); @@ -537,7 +537,7 @@ mod tests { let meta = types::Meta { recipients: std::collections::HashMap::from([(pubkey.clone(), "Alice".into())]), mac: String::new(), - hmac_key: None, + mac_key: None, }; let meta_json = serde_json::to_vec(&meta).unwrap(); let meta_enc = crate::encrypt_value(&meta_json, &[recipient]).unwrap(); diff --git a/src/types.rs b/src/types.rs index 6787bd1..aafec8f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -58,8 +58,8 @@ pub struct Meta { /// Integrity MAC over secrets + schema. pub mac: String, /// BLAKE3 keyed MAC key (hex-encoded, 32 bytes). Generated at init, stored encrypted. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hmac_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none", alias = "hmac_key")] + pub mac_key: Option, } // -- Murk (decrypted in-memory state) -- From aa90f0d9fcc064abfe1ec29bd6d8ef7fdc6e6a0c Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:03:33 -0400 Subject: [PATCH 5/5] ignore RUSTSEC-2023-0071 in cargo-audit (already suppressed in deny.toml) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e79278..fce44ce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,7 +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 + - run: cargo install cargo-audit --locked && cargo audit --ignore RUSTSEC-2023-0071 test: name: Test (${{ matrix.os }})