From fbcf3534cf842e0f1b915f7237f26c18975e7250 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:30:24 -0400 Subject: [PATCH] support age plugin identities for hardware-backed keys --- Cargo.lock | 113 ++++++++++++++++++- Cargo.toml | 2 +- README.md | 35 ++++++ SPEC.md | 17 ++- src/crypto.rs | 294 +++++++++++++++++++++++++++++++++++++++--------- src/env.rs | 20 ++-- src/github.rs | 1 + src/main.rs | 13 ++- src/recovery.rs | 17 ++- 9 files changed, 444 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c78a716..fcf893d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,22 +58,28 @@ dependencies = [ "cbc", "chacha20poly1305", "cipher", + "console", "cookie-factory", "ctr", "curve25519-dalek", "hmac", "i18n-embed", "i18n-embed-fl", + "is-terminal", "lazy_static", "nom", "num-traits", "pin-project", + "pinentry", "rand 0.8.5", + "rpassword", "rsa", "rust-embed", "scrypt", "sha2 0.10.9", "subtle", + "which", + "wsl", "x25519-dalek", "zeroize", ] @@ -93,6 +99,7 @@ dependencies = [ "rand 0.8.5", "secrecy", "sha2 0.10.9", + "tempfile", ] [[package]] @@ -528,6 +535,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -764,6 +783,18 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -1064,6 +1095,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex-conservative" version = "0.2.2" @@ -1091,6 +1128,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1275,6 +1321,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1334,6 +1391,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1626,6 +1689,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pinentry" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a108bb5e4e654a3d20c834e5676b4b20f7cd2cc73b22f849d93e028b259340ec" +dependencies = [ + "log", + "nom", + "percent-encoding", + "secrecy", + "wait-timeout", + "which", + "zeroize", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2024,6 +2102,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2033,7 +2124,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2296,7 +2387,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2616,6 +2707,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2885,6 +2988,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wsl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" + [[package]] name = "x25519-dalek" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4911d2a..d27114d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ name = "murk" path = "src/main.rs" [dependencies] -age = { version = "0.11.2", features = ["ssh"] } +age = { version = "0.11.2", features = ["ssh", "plugin", "cli-common"] } ureq = "3" base64 = "0.22.1" bech32 = "0.11.1" diff --git a/README.md b/README.md index 7325d5b..e7e67bb 100644 --- a/README.md +++ b/README.md @@ -251,12 +251,47 @@ See [SPEC.md](SPEC.md) for the full specification. **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. +**Hardware-backed keys** — for stronger protection, point `MURK_KEY_FILE` at an age plugin identity file (YubiKey, Apple Secure Enclave, FIDO2, etc.) so the private key lives in hardware and never exists as bytes on disk. See [hardware identities](#hardware-identities) below. + **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. See [THREAT_MODEL.md](THREAT_MODEL.md) for the full threat model. **AI agents** — if you're using murk with AI coding agents, see [docs/ai-agents.md](docs/ai-agents.md) for safe patterns. +## Hardware identities + +By default `murk init` generates a raw age key and stores it under `~/.config/murk/keys/`. That's fine for development, but the key lives on disk as plaintext — anyone with read access to the file can decrypt everything. + +For production use, point `MURK_KEY_FILE` at an [age plugin identity file](https://github.com/FiloSottile/age#plugins) so the private key lives in tamper-resistant hardware and only consents to decrypt with a physical action (touch, Touch ID, PIN): + +| Hardware | Plugin | +| --- | --- | +| YubiKey, Nitrokey, any PIV-capable smart card | [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey) | +| Apple Secure Enclave (Touch ID) | [`age-plugin-se`](https://github.com/remko/age-plugin-se) | +| Any FIDO2 security key | [`age-plugin-fido2-hmac`](https://github.com/olastor/age-plugin-fido2-hmac) | +| OpenPGP Card | [`age-plugin-openpgp-card`](https://crates.io/crates/age-plugin-openpgp-card) | + +Example setup with a YubiKey: + +```bash +# Install the plugin, then generate an identity bound to the YubiKey +brew install age-plugin-yubikey +age-plugin-yubikey --generate > ~/.config/murk/yubikey.txt + +# Point murk at the identity file +echo 'export MURK_KEY_FILE=~/.config/murk/yubikey.txt' >> .env + +# Authorize the YubiKey's public key on your vault +murk authorize $(grep 'public key' ~/.config/murk/yubikey.txt | awk '{print $NF}') +``` + +The identity file contains a `# public key: age1yubikey1...` header followed by an `AGE-PLUGIN-YUBIKEY-1...` pointer. murk reads the pubkey from the header (no plugin call needed for scoped secret lookup) and invokes the plugin only when actually decrypting — at which point the YubiKey prompts you to tap it. + +**No BIP39 recovery for hardware identities.** The whole point of hardware-backed keys is that the raw key bytes never leave the device, so there are no bytes to encode as a recovery phrase. Instead, enroll a second hardware device at setup and add both pubkeys as recipients (`murk authorize `) — if you lose one, the backup still decrypts. + +**`MURK_KEY` vs `MURK_KEY_FILE`.** Setting `MURK_KEY` to a raw `AGE-SECRET-KEY-1...` string in `.env` works, but the key is then plaintext on disk. Prefer `MURK_KEY_FILE` pointing at either a file under `~/.config/murk/keys/` (convenience) or a hardware-backed plugin identity file (strongly recommended for production). + ## License MIT OR Apache-2.0 diff --git a/SPEC.md b/SPEC.md index cab71fc..36e228f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -35,12 +35,25 @@ Existing secrets tools are either too complex (SOPS, Vault), tied to a runtime ( | Variable | Required | Description | | -------------- | -------- | ------------------------------------------------------- | -| `MURK_KEY` | No | Your age private key. Inline alternative to `MURK_KEY_FILE`. Takes priority if both are set. | -| `MURK_KEY_FILE`| Yes | Path to a file containing your age private key. Set by `murk init`. | +| `MURK_KEY` | No | Raw age private key (`AGE-SECRET-KEY-1...`). Dev-mode convenience — the key is plaintext on disk. | +| `MURK_KEY_FILE`| Yes | Path to a private key file. Set by `murk init`. May be a raw age key, an SSH PEM key, or an age plugin identity file with a `# public key: age1...` header. | | `MURK_VAULT` | No | Vault filename. Defaults to `.murk`. | Your identity is your key. murk derives your public key from `MURK_KEY` or `MURK_KEY_FILE` to determine which scoped secrets are yours and to identify you in the recipient list. +### Hardware-backed identities + +When `MURK_KEY_FILE` points at an age plugin identity file, murk uses the hardware-backed key without ever seeing the raw bytes. The file format is: + +``` +# public key: age1yubikey1qwt50d05nh5vutpdzmlg5wn80xq5negm8cn9ss4xswuaalgb5wh5ug3pcs3 +AGE-PLUGIN-YUBIKEY-1Q9WFTQQVZN3FASCJ3N9WEHUMFCYMCQSA2F8YVRMMGY6N76C6DMC6A8FTMP +``` + +The `# public key:` header is required — murk reads it to determine the recipient without spawning the plugin. The `AGE-PLUGIN--1...` line is the opaque pointer the plugin binary understands. On decrypt, murk invokes `age-plugin-` (which must be on `$PATH`) and the plugin may prompt the user for physical consent (touch, PIN). Plugin identities have no BIP39 recovery phrase; `murk recover` errors on them. Back up a second hardware device as a vault recipient instead. + +Setting `MURK_KEY` (the inline env var) to an `AGE-PLUGIN-...` string is rejected — bare plugin identities don't carry the recipient pubkey, so murk can't resolve scoped secrets without spawning the plugin. Use a file path via `MURK_KEY_FILE`. + ### Key storage `murk init` writes the secret key to `~/.config/murk/keys/` (chmod 600) and writes a `MURK_KEY_FILE` reference to `.env`: diff --git a/src/crypto.rs b/src/crypto.rs index 4e64470..fc8ac74 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,4 +1,10 @@ +use std::collections::HashMap; use std::io::{Read, Write}; + +use age::cli_common::UiCallbacks; +use age::plugin::{ + Identity as PluginIdentity, IdentityPluginV1, Recipient as PluginRecipient, RecipientPluginV1, +}; use zeroize::Zeroizing; /// Errors that can occur during crypto operations. @@ -21,11 +27,14 @@ impl std::fmt::Display for CryptoError { /// A recipient that can receive age-encrypted data. /// -/// Wraps either an age x25519 recipient or an SSH public key recipient. +/// Wraps an age x25519 recipient, an SSH public key recipient, or a plugin +/// recipient like `age1yubikey1...`. Plugin recipients dispatch to an external +/// `age-plugin-` binary during encryption. #[derive(Clone)] pub enum MurkRecipient { Age(age::x25519::Recipient), Ssh(age::ssh::Recipient), + Plugin(PluginRecipient), } impl std::fmt::Debug for MurkRecipient { @@ -33,34 +42,47 @@ impl std::fmt::Debug for MurkRecipient { match self { MurkRecipient::Age(r) => write!(f, "Age({r})"), MurkRecipient::Ssh(r) => write!(f, "Ssh({r})"), - } - } -} - -impl MurkRecipient { - /// Borrow as a trait object for passing to age's encryptor. - pub fn as_dyn(&self) -> &dyn age::Recipient { - match self { - MurkRecipient::Age(r) => r, - MurkRecipient::Ssh(r) => r, + MurkRecipient::Plugin(r) => write!(f, "Plugin({r})"), } } } /// An identity that can decrypt age-encrypted data. /// -/// Wraps either an age x25519 identity or an SSH private key identity. +/// Plugin identities (`AGE-PLUGIN--1...`) carry the recipient pubkey +/// alongside the pointer so `pubkey_string` does not require spawning the +/// plugin binary. Decryption spawns `age-plugin-` via +/// [`IdentityPluginV1`] to access the hardware-backed key. #[derive(Clone)] pub enum MurkIdentity { Age(age::x25519::Identity), Ssh(age::ssh::Identity), + Plugin { + identity: PluginIdentity, + pubkey: String, + }, +} + +/// Debug prints only the identity *kind*, never key material, to keep +/// accidental logs from leaking secrets. +impl std::fmt::Debug for MurkIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MurkIdentity::Age(_) => write!(f, "Age()"), + MurkIdentity::Ssh(_) => write!(f, "Ssh()"), + MurkIdentity::Plugin { pubkey, identity } => { + write!(f, "Plugin({} → {pubkey})", identity.plugin()) + } + } + } } impl MurkIdentity { /// Return the public key string for this identity. /// - /// For age keys: `age1...` - /// For SSH keys: `ssh-ed25519 AAAA...` or `ssh-rsa AAAA...` + /// For age keys: `age1...`. For SSH keys: `ssh-ed25519 AAAA...` or + /// `ssh-rsa AAAA...`. For plugin keys: the `age11...` recipient + /// that was parsed from the identity file's `# public key:` header. pub fn pubkey_string(&self) -> Result { match self { MurkIdentity::Age(id) => Ok(id.to_public().to_string()), @@ -70,72 +92,156 @@ impl MurkIdentity { })?; Ok(recipient.to_string()) } + MurkIdentity::Plugin { pubkey, .. } => Ok(pubkey.clone()), } } - /// Borrow as a trait object for passing to age's decryptor. - fn as_dyn(&self) -> &dyn age::Identity { + /// Plugin name (e.g. `"yubikey"`, `"se"`) if this is a plugin identity. + pub fn plugin_name(&self) -> Option<&str> { match self { - MurkIdentity::Age(id) => id, - MurkIdentity::Ssh(id) => id, + MurkIdentity::Plugin { identity, .. } => Some(identity.plugin()), + _ => None, } } } /// Parse a public key string into a `MurkRecipient`. /// -/// Tries x25519 (`age1...`) first, then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`). +/// Tries x25519 (`age1...`), then SSH (`ssh-ed25519 ...` / `ssh-rsa ...`), +/// then age plugin recipients (`age11...`). pub fn parse_recipient(pubkey: &str) -> Result { - // Try age x25519 first. if let Ok(r) = pubkey.parse::() { return Ok(MurkRecipient::Age(r)); } - - // Try SSH. if let Ok(r) = pubkey.parse::() { return Ok(MurkRecipient::Ssh(r)); } - + if let Ok(r) = pubkey.parse::() { + return Ok(MurkRecipient::Plugin(r)); + } Err(CryptoError::InvalidKey(format!( - "not a valid age or SSH public key: {pubkey}" + "not a valid age, SSH, or plugin public key: {pubkey}" ))) } -/// Parse a secret key string into a `MurkIdentity`. +/// Parse a secret key or identity-file contents into a `MurkIdentity`. /// -/// Tries age (`AGE-SECRET-KEY-1...`) first, then SSH PEM format. -/// Encrypted SSH keys are rejected with a clear error. -pub fn parse_identity(secret_key: &str) -> Result { - // Try age x25519 first. - if let Ok(id) = secret_key.parse::() { +/// Accepts three shapes: +/// - A bare age secret key (`AGE-SECRET-KEY-1...`) +/// - An SSH PEM-encoded private key (unencrypted only; encrypted keys are rejected) +/// - A plugin identity file — multi-line text with a `# public key: age1...` +/// header followed by an `AGE-PLUGIN--1...` pointer, as produced by +/// tools like `age-plugin-yubikey --identity` +/// +/// Comments and blank lines are permitted anywhere. +pub fn parse_identity(input: &str) -> Result { + let trimmed = input.trim(); + if let Ok(id) = trimmed.parse::() { return Ok(MurkIdentity::Age(id)); } - // Try SSH PEM. - let reader = std::io::BufReader::new(secret_key.as_bytes()); - match age::ssh::Identity::from_buffer(reader, None) { - Ok(id) => match &id { - age::ssh::Identity::Unencrypted(_) => Ok(MurkIdentity::Ssh(id)), - age::ssh::Identity::Encrypted(_) => Err(CryptoError::InvalidKey( - "encrypted SSH keys are not yet supported — use an unencrypted key or an age key" - .into(), - )), - age::ssh::Identity::Unsupported(k) => Err(CryptoError::InvalidKey(format!( - "unsupported SSH key type: {k:?}" - ))), - }, - Err(_) => Err(CryptoError::InvalidKey( - "not a valid age secret key or SSH private key".into(), - )), + // SSH PEM has its own framing; feed the full input. + let reader = std::io::BufReader::new(input.as_bytes()); + if let Ok(id) = age::ssh::Identity::from_buffer(reader, None) { + match id { + age::ssh::Identity::Unencrypted(_) => return Ok(MurkIdentity::Ssh(id)), + age::ssh::Identity::Encrypted(_) => { + return Err(CryptoError::InvalidKey( + "encrypted SSH keys are not yet supported — use an unencrypted key or an age key" + .into(), + )); + } + age::ssh::Identity::Unsupported(k) => { + return Err(CryptoError::InvalidKey(format!( + "unsupported SSH key type: {k:?}" + ))); + } + } + } + + // Identity-file form: walk lines, capture `# public key:` header, then + // accept a following plugin pointer. + let mut pubkey: Option = None; + for line in input.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line + .strip_prefix('#') + .map(str::trim) + .and_then(|s| s.strip_prefix("public key:")) + { + pubkey = Some(rest.trim().to_string()); + continue; + } + if line.starts_with('#') { + continue; + } + if let Ok(identity) = line.parse::() { + let pk = pubkey.ok_or_else(|| { + CryptoError::InvalidKey( + "plugin identity is missing its `# public key: age1...` header. Save the \ + plugin output (the header line PLUS the AGE-PLUGIN-... line) to a file and \ + set MURK_KEY_FILE to its path — setting MURK_KEY to just the identity \ + string is not enough, because murk needs the recipient pubkey" + .into(), + ) + })?; + parse_recipient(&pk).map_err(|e| { + CryptoError::InvalidKey(format!( + "`# public key:` header in identity file is not a valid recipient: {e}" + )) + })?; + return Ok(MurkIdentity::Plugin { + identity, + pubkey: pk, + }); + } + // Unrecognised non-comment line — retry as an age key for trailing-whitespace tolerance. + if let Ok(id) = line.parse::() { + return Ok(MurkIdentity::Age(id)); + } + break; } + + Err(CryptoError::InvalidKey( + "not a valid age secret key, SSH private key, or plugin identity file".into(), + )) } /// Encrypt plaintext bytes to one or more recipients. +/// +/// Plugin recipients are grouped by plugin name and dispatched via +/// [`RecipientPluginV1`]. Native (age/ssh) recipients pass through directly. pub fn encrypt(plaintext: &[u8], recipients: &[MurkRecipient]) -> Result, CryptoError> { - let recipient_refs: Vec<&dyn age::Recipient> = - recipients.iter().map(MurkRecipient::as_dyn).collect(); + let mut native: Vec<&dyn age::Recipient> = vec![]; + let mut grouped: HashMap> = HashMap::new(); + + for r in recipients { + match r { + MurkRecipient::Age(r) => native.push(r), + MurkRecipient::Ssh(r) => native.push(r), + MurkRecipient::Plugin(r) => grouped + .entry(r.plugin().to_string()) + .or_default() + .push(r.clone()), + } + } + + let mut plugins: Vec> = vec![]; + for (name, plugin_recipients) in grouped { + let plugin = RecipientPluginV1::new(&name, &plugin_recipients, &[], UiCallbacks) + .map_err(|e| CryptoError::Encrypt(format!("age-plugin-{name} unavailable: {e}")))?; + plugins.push(plugin); + } + + let mut all_refs: Vec<&dyn age::Recipient> = native; + for plugin in &plugins { + all_refs.push(plugin); + } - let encryptor = age::Encryptor::with_recipients(recipient_refs.into_iter()) + let encryptor = age::Encryptor::with_recipients(all_refs.into_iter()) .map_err(|e| CryptoError::Encrypt(e.to_string()))?; let mut ciphertext = vec![]; @@ -156,11 +262,14 @@ pub fn encrypt(plaintext: &[u8], recipients: &[MurkRecipient]) -> Result Ok(ciphertext) } -/// Decrypt ciphertext using an identity (age or SSH key). +/// Decrypt ciphertext using an identity (age, SSH, or plugin). /// /// Returns the plaintext wrapped in `Zeroizing>` so the buffer is /// cleared when dropped. Defense-in-depth against plaintext lingering in /// freed heap memory. +/// +/// For plugin identities this spawns `age-plugin-` and may prompt +/// the user (YubiKey touch, Touch ID, PIN entry) via [`UiCallbacks`]. pub fn decrypt( ciphertext: &[u8], identity: &MurkIdentity, @@ -169,8 +278,30 @@ pub fn decrypt( .map_err(|e| CryptoError::Decrypt(e.to_string()))?; let mut plaintext = Zeroizing::new(vec![]); + + // Hold the plugin object outside the match so the &dyn borrow stays valid. + let plugin_holder: Option> = match identity { + MurkIdentity::Plugin { identity, .. } => Some( + IdentityPluginV1::new( + identity.plugin(), + std::slice::from_ref(identity), + UiCallbacks, + ) + .map_err(|e| { + CryptoError::Decrypt(format!("age-plugin-{} unavailable: {e}", identity.plugin())) + })?, + ), + _ => None, + }; + + let id_ref: &dyn age::Identity = match identity { + MurkIdentity::Age(id) => id, + MurkIdentity::Ssh(id) => id, + MurkIdentity::Plugin { .. } => plugin_holder.as_ref().expect("constructed above"), + }; + let mut reader = decryptor - .decrypt(std::iter::once(identity.as_dyn())) + .decrypt(std::iter::once(id_ref)) .map_err(|e| CryptoError::Decrypt(e.to_string()))?; reader @@ -243,6 +374,67 @@ mod tests { assert!(parse_identity("nihil-et-nemo").is_err()); } + // ── Plugin identity tests ── + + /// Build a syntactically-valid plugin identity + recipient pair for a + /// given plugin name. Uses bech32 with dummy entropy — these tests verify + /// parsing and dispatch, not plugin interop. + fn make_plugin_pair(plugin: &str) -> (String, String) { + use bech32::{Bech32, Hrp}; + let entropy = [0u8; 20]; + let identity_hrp = Hrp::parse(&format!("age-plugin-{plugin}-")).unwrap(); + let identity = bech32::encode::(identity_hrp, &entropy) + .unwrap() + .to_uppercase(); + let recipient_hrp = Hrp::parse(&format!("age1{plugin}")).unwrap(); + let recipient = bech32::encode::(recipient_hrp, &entropy).unwrap(); + (identity, recipient) + } + + #[test] + fn parse_identity_plugin_file() { + let (identity_str, pubkey_str) = make_plugin_pair("yubikey"); + let file = format!( + "# created: 2024-01-01T00:00:00-00:00\n# public key: {pubkey_str}\n{identity_str}\n" + ); + let id = parse_identity(&file).expect("parses plugin identity file"); + match &id { + MurkIdentity::Plugin { identity, pubkey } => { + assert_eq!(identity.plugin(), "yubikey"); + assert_eq!(pubkey, &pubkey_str); + } + _ => panic!("expected Plugin variant, got {id:?}"), + } + assert_eq!(id.pubkey_string().unwrap(), pubkey_str); + } + + #[test] + fn parse_identity_plugin_file_missing_pubkey_header() { + let (identity_str, _) = make_plugin_pair("yubikey"); + let err = parse_identity(&format!("{identity_str}\n")) + .unwrap_err() + .to_string(); + assert!( + err.contains("public key") && err.contains("MURK_KEY_FILE"), + "expected pubkey + MURK_KEY_FILE guidance, got: {err}" + ); + } + + #[test] + fn parse_recipient_plugin_yubikey() { + let (_, pubkey_str) = make_plugin_pair("yubikey"); + let r = parse_recipient(&pubkey_str).unwrap(); + assert!(matches!(r, MurkRecipient::Plugin(_))); + } + + #[test] + fn plugin_identity_trailing_whitespace_tolerated() { + let (identity_str, pubkey_str) = make_plugin_pair("yubikey"); + let file = format!("\n\n# public key: {pubkey_str}\n{identity_str}\n\n"); + let id = parse_identity(&file).expect("parses with extra whitespace"); + assert_eq!(id.plugin_name(), Some("yubikey")); + } + // ── New edge-case tests ── #[test] diff --git a/src/env.rs b/src/env.rs index 84be9de..b97a95b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -121,20 +121,20 @@ pub fn resolve_key_with_source(vault_path: &str) -> Result<(SecretString, KeySou if let Some(k) = env::var(ENV_MURK_KEY).ok().filter(|k| !k.is_empty()) { return Ok((SecretString::from(k), KeySource::EnvVar)); } + // File paths return full contents (not trimmed) so that plugin identity + // files — which contain a `# public key: age1...` header above an + // `AGE-PLUGIN-...-1...` pointer — round-trip intact through parse_identity. if let Ok(path) = env::var(ENV_MURK_KEY_FILE) { let p = std::path::Path::new(&path); let contents = read_secret_file(p, "MURK_KEY_FILE")?; return Ok(( - SecretString::from(contents.trim().to_string()), + SecretString::from(contents), KeySource::EnvFile(p.to_path_buf()), )); } if let Some(path) = key_file_path(vault_path).ok().filter(|p| p.exists()) { let contents = read_secret_file(&path, "key file")?; - return Ok(( - SecretString::from(contents.trim().to_string()), - KeySource::Auto(path), - )); + return Ok((SecretString::from(contents), KeySource::Auto(path))); } Err( "MURK_KEY not set. Run `murk init` to generate a key, set MURK_KEY_FILE to point at one, or ask a recipient to authorize you. If your .env contains an inline MURK_KEY or MURK_KEY_FILE, run `direnv allow` (or `source .env`) so it is exported to the environment — murk no longer reads .env directly." @@ -626,7 +626,9 @@ mod tests { let secret = result.unwrap(); use age::secrecy::ExposeSecret; - assert_eq!(secret.expose_secret(), "AGE-SECRET-KEY-1FROMFILE"); + // File contents pass through unmodified so plugin identity files + // (multi-line with `# public key:` header) round-trip intact. + assert_eq!(secret.expose_secret().trim(), "AGE-SECRET-KEY-1FROMFILE"); } #[test] @@ -716,8 +718,12 @@ mod tests { // Confirms the murk-82q fix: even if .env sits in CWD with an inline // MURK_KEY, resolve_key_with_source must not pick it up. The runtime // only trusts the environment and the vault-keyed auto lookup. - let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // + // Lock order: ENV_LOCK before CWD_LOCK, matching every other test + // that grabs both. Reversing the order deadlocks against parallel + // tests that hold ENV_LOCK while waiting for CWD_LOCK. let _env_lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _cwd = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join("murk_test_resolve_ignores_dotenv"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); diff --git a/src/github.rs b/src/github.rs index cc55342..4037955 100644 --- a/src/github.rs +++ b/src/github.rs @@ -88,6 +88,7 @@ pub fn parse_github_keys( let normalized = match &recipient { MurkRecipient::Ssh(r) => r.to_string(), MurkRecipient::Age(_) => unreachable!("SSH key parsed as age key"), + MurkRecipient::Plugin(_) => unreachable!("SSH key parsed as plugin recipient"), }; keys.push((recipient, normalized)); } diff --git a/src/main.rs b/src/main.rs index cf07c0b..5698d92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1904,14 +1904,19 @@ fn cmd_restore() { fn cmd_recover() { let secret_key = resolve_key(); - // SSH keys don't have BIP39 recovery phrases — only age keys do. + // SSH keys and plugin identities don't have BIP39 recovery phrases. let identity = murk_cli::crypto::parse_identity(secret_key.expose_secret()).unwrap_or_else(|e| die(&e, 1)); - if matches!(identity, MurkIdentity::Ssh(_)) { - die( + match identity { + MurkIdentity::Ssh(_) => die( &"recovery phrases are for age keys only. SSH keys are managed by your SSH agent — back up ~/.ssh instead", 1, - ); + ), + MurkIdentity::Plugin { .. } => die( + &"plugin identities (YubiKey, Secure Enclave, FIDO2) do not have recovery phrases. BIP39 words encode the raw 32 key bytes, but hardware-backed keys never leave the device — there are no bytes to encode. Recovery means enrolling a backup hardware device at setup and adding its pubkey as a recipient with `murk authorize`", + 1, + ), + MurkIdentity::Age(_) => {} } println!( diff --git a/src/recovery.rs b/src/recovery.rs index 10209a2..c877828 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -48,9 +48,24 @@ pub fn generate() -> Result<(Zeroizing, Zeroizing, String), Reco /// Re-derive the BIP39 24-word mnemonic from an existing MURK_KEY. /// Decodes the Bech32 key back to raw bytes, then encodes as a mnemonic. +/// +/// Plugin identities (`AGE-PLUGIN-*`) have no recovery phrase because BIP39 +/// words encode the raw 32 key bytes and hardware-backed keys never leave +/// the device. Back up a second hardware device as a vault recipient instead. pub fn phrase_from_key(secret_key: &str) -> Result, RecoveryError> { + let trimmed = secret_key.trim(); + if trimmed.to_ascii_uppercase().contains("AGE-PLUGIN-") { + return Err(RecoveryError::InvalidKey( + "plugin identities (YubiKey, Secure Enclave, FIDO2) do not have recovery phrases. \ + BIP39 words encode the raw 32 key bytes, but hardware-backed keys never leave the \ + device — there are no bytes to encode. Recovery means enrolling a backup hardware \ + device at setup and adding its pubkey as a recipient with `murk authorize`" + .into(), + )); + } + // age keys are uppercase; bech32 decoding requires lowercase. - let lowercase = Zeroizing::new(secret_key.to_lowercase()); + let lowercase = Zeroizing::new(trimmed.to_lowercase()); let (_, key_bytes) = bech32::decode(&lowercase).map_err(|e| RecoveryError::InvalidKey(e.to_string()))?; let key_bytes = Zeroizing::new(key_bytes);