From ef44579cb89514686c06eab2ec39fc7b0e7e1a31 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:15:11 +0300 Subject: [PATCH 1/6] feat(cli): add private_key resolver with file and stdin sources Implements preference order and argv detection so --private-key can be deprecated without dropping BUZZ_PRIVATE_KEY support (#4032). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-cli/src/private_key.rs | 179 +++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 crates/buzz-cli/src/private_key.rs diff --git a/crates/buzz-cli/src/private_key.rs b/crates/buzz-cli/src/private_key.rs new file mode 100644 index 0000000000..130bb3b7a1 --- /dev/null +++ b/crates/buzz-cli/src/private_key.rs @@ -0,0 +1,179 @@ +//! Resolve the CLI identity secret without requiring it on argv. +//! +//! Prefer `BUZZ_PRIVATE_KEY`, `--private-key-file`, or `--private-key-stdin`. +//! Bare `--private-key` remains accepted but warns: argv values land in shell +//! history and process listings (see block/buzz#4032). + +use crate::error::CliError; +use std::fs; +use std::io::{self, Read}; +use std::path::Path; + +/// Sources that can supply the Nostr private key for relay commands. +#[derive(Debug, Clone, Default)] +pub struct PrivateKeyInputs { + /// Value from `--private-key` / `BUZZ_PRIVATE_KEY` (clap merges both). + pub private_key: Option, + /// Path from `--private-key-file`. + pub private_key_file: Option, + /// When true, read a single line/key from stdin (`--private-key-stdin`). + pub private_key_stdin: bool, + /// True when `--private-key` appeared on argv (not only via the env var). + pub private_key_from_argv: bool, +} + +/// Resolve the private key string, applying preference order and deprecation. +pub fn resolve_private_key(inputs: PrivateKeyInputs) -> Result { + let mut sources = 0u8; + if inputs.private_key_file.is_some() { + sources += 1; + } + if inputs.private_key_stdin { + sources += 1; + } + if inputs.private_key.is_some() { + sources += 1; + } + if sources > 1 { + return Err(CliError::Usage( + "specify only one of --private-key-file, --private-key-stdin, or BUZZ_PRIVATE_KEY/--private-key" + .into(), + )); + } + + if let Some(path) = inputs.private_key_file.as_deref() { + return read_private_key_file(path); + } + + if inputs.private_key_stdin { + return read_private_key_stdin(); + } + + if let Some(key) = inputs.private_key { + if inputs.private_key_from_argv { + eprintln!( + "warning: --private-key puts the secret in shell history and process listings; \ +prefer BUZZ_PRIVATE_KEY, --private-key-file, or --private-key-stdin (see https://github.com/block/buzz/issues/4032)" + ); + } + let trimmed = key.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth( + "BUZZ_PRIVATE_KEY is empty (use --private-key-file, --private-key-stdin, or set env var)" + .into(), + )); + } + return Ok(trimmed); + } + + Err(CliError::Auth( + "BUZZ_PRIVATE_KEY is required (prefer --private-key-file / --private-key-stdin / env; \ +--private-key is deprecated)".into(), + )) +} + +fn read_private_key_file(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(path) { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + eprintln!( + "warning: private key file {} is group/world-accessible (mode {:o}); prefer chmod 600", + path.display(), + mode + ); + } + } + } + + let contents = fs::read_to_string(path).map_err(|e| { + CliError::Auth(format!( + "failed to read --private-key-file {}: {e}", + path.display() + )) + })?; + let trimmed = contents.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth(format!( + "--private-key-file {} is empty", + path.display() + ))); + } + Ok(trimmed) +} + +fn read_private_key_stdin() -> Result { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Auth(format!("failed to read --private-key-stdin: {e}")))?; + let trimmed = buf.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliError::Auth( + "--private-key-stdin produced an empty key".into(), + )); + } + Ok(trimmed) +} + +/// Detect whether `--private-key` was present on argv (vs env-only). +pub fn private_key_flag_on_argv(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|a| { + let a = a.as_ref(); + a == "--private-key" || a.starts_with("--private-key=") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn argv_detector_matches_flag_forms() { + assert!(private_key_flag_on_argv(["buzz", "--private-key", "nsec1x"])); + assert!(private_key_flag_on_argv(["buzz", "--private-key=nsec1x"])); + assert!(!private_key_flag_on_argv(["buzz", "channels", "list"])); + } + + #[test] + fn prefers_file_contents() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, " nsec1filekey ").unwrap(); + let key = resolve_private_key(PrivateKeyInputs { + private_key_file: Some(file.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + assert_eq!(key, "nsec1filekey"); + } + + #[test] + fn rejects_multiple_sources() { + let err = resolve_private_key(PrivateKeyInputs { + private_key: Some("nsec1a".into()), + private_key_stdin: true, + ..Default::default() + }) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn env_value_without_argv_does_not_require_file() { + let key = resolve_private_key(PrivateKeyInputs { + private_key: Some("nsec1env".into()), + private_key_from_argv: false, + ..Default::default() + }) + .unwrap(); + assert_eq!(key, "nsec1env"); + } +} From 2a20c1cc6b32cd67825c2ce8a9cedc58538152a5 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:15:11 +0300 Subject: [PATCH 2/6] feat(cli): expose --private-key-file and --private-key-stdin Wires the new flags through clap and run(), warning when the secret is passed on argv into shell history / process listings. Signed-off-by: Taksh --- crates/buzz-cli/src/lib.rs | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0860f9dae6..c0ccdc11de 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod private_key; mod validate; use clap::{Parser, Subcommand}; @@ -37,7 +38,11 @@ where // double-install returns Err and is harmless. let _ = rustls::crypto::ring::default_provider().install_default(); - let cli = match Cli::try_parse_from(args) { + let argv: Vec = args.into_iter().map(Into::into).collect(); + let private_key_from_argv = + private_key::private_key_flag_on_argv(argv.iter().filter_map(|s| s.to_str())); + + let cli = match Cli::try_parse_from(argv) { Ok(cli) => cli, Err(e) => { if e.use_stderr() { @@ -50,7 +55,7 @@ where } } }; - match run(cli).await { + match run(cli, private_key_from_argv).await { Ok(()) => 0, Err(e) => { error::print_error(&e); @@ -68,9 +73,13 @@ Buzz CLI — interact with a Buzz relay Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] - BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] + BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [preferred over --private-key] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] +Identity secrets: prefer BUZZ_PRIVATE_KEY, --private-key-file, or +--private-key-stdin. Passing --private-key on argv is deprecated (shell +history / process listings). + The 'pack' subcommand runs locally and does not require a relay connection. Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict @@ -81,10 +90,20 @@ struct Cli { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")] relay: String, - /// Nostr private key (hex or nsec). This is the CLI's identity. + /// Nostr private key (hex or nsec). Prefer `BUZZ_PRIVATE_KEY`, + /// `--private-key-file`, or `--private-key-stdin` — passing the secret on + /// argv leaks into shell history and `ps` (deprecated). #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, + /// Read the Nostr private key from a file (mode 0600 recommended). + #[arg(long, value_name = "PATH")] + private_key_file: Option, + + /// Read the Nostr private key from stdin (trim surrounding whitespace). + #[arg(long, default_value_t = false)] + private_key_stdin: bool, + /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, @@ -1803,7 +1822,7 @@ fn normalize_auth_tag_input(input: &str) -> String { trimmed.to_owned() } -async fn run(cli: Cli) -> Result<(), CliError> { +async fn run(cli: Cli, private_key_from_argv: bool) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); // Pack commands are local-only — no relay connection needed. @@ -1816,8 +1835,11 @@ async fn run(cli: Cli) -> Result<(), CliError> { // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { - CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into()) + let private_key_str = private_key::resolve_private_key(private_key::PrivateKeyInputs { + private_key: cli.private_key, + private_key_file: cli.private_key_file, + private_key_stdin: cli.private_key_stdin, + private_key_from_argv, })?; let keys = Keys::parse(&private_key_str) .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; From 8609720d2f644cab5ef2ca37c5041afe34e3f235 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:15:11 +0300 Subject: [PATCH 3/6] docs(cli): prefer env/file/stdin over --private-key in README Documents the safer identity sources and links #4032 for the argv leak. Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-cli/README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d2..6fd214925c 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -10,16 +10,26 @@ cargo install --path crates/buzz-cli ## Authentication -| Env Var | Mode | Use Case | -|---------|------|----------| -| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair | +| Source | Mode | Use Case | +|--------|------|----------| +| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair (preferred) | +| `--private-key-file PATH` | same | Secrets on disk (mode `0600`) | +| `--private-key-stdin` | same | Piping from a password manager | +| `--private-key` | same | **Deprecated** — leaks into shell history and `ps` | ```bash # Private key identity (NIP-98 signed requests) export BUZZ_PRIVATE_KEY="nsec1..." buzz channels list + +# Or keep the secret out of argv / the environment of child processes: +buzz --private-key-file ~/.config/buzz/nsec channels list +printf '%s' "$NSEC" | buzz --private-key-stdin channels list ``` +Do not pass `--private-key nsec1...` on the command line — see +[block/buzz#4032](https://github.com/block/buzz/issues/4032). + ## Usage All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. From c969be286cf85d838335f1073a8583b64c384139 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:16:23 +0300 Subject: [PATCH 4/6] docs(security): note CLI private-key argv deprecation Cross-links #4032 next to the existing BUZZ_PRIVATE_KEY keyring guidance. Co-authored-by: Cursor Signed-off-by: Taksh --- SECURITY.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 09ea73022b..9a0211a750 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,7 +90,10 @@ a rotated key from a leftover file. When no keyring backend is available (headless Linux with no Secret Service, for example), keys fall back to a `0o600` owner-only file. The `BUZZ_PRIVATE_KEY` environment variable, when set, always takes precedence over both stores — this -is how harnessed agents and CI receive their identity. +is how harnessed agents and CI receive their identity. The `buzz` CLI also +accepts `--private-key-file` / `--private-key-stdin`; passing `--private-key` on +argv is deprecated because the secret enters shell history and process listings +(see #4032). ### Input Validation From d4b19ef9bfaa50d777457b26a54df031492055ed Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:16:23 +0300 Subject: [PATCH 5/6] docs(cli): mention --private-key-file in TESTING.md Keeps the manual test script aligned with the safer auth sources. Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-cli/TESTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faa..71503c61cc 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -76,6 +76,7 @@ Export: ```bash export BUZZ_RELAY_URL="http://localhost:3000" export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output +# Prefer file/stdin in shared shells: buzz --private-key-file /tmp/nsec … ``` ### Scope reference From e9ee13a2f79423f48158996ab80ba7a0119766f5 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:16:54 +0300 Subject: [PATCH 6/6] docs(agents): prefer file/stdin private key for local buzz CLI Keeps agent docs aligned with the #4032 argv deprecation. Co-authored-by: Cursor Signed-off-by: Taksh --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4f03b312bc..c2e4ddbf23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,8 @@ check existing reply handlers for the pattern. (`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected by the ACP harness into managed agent subprocesses. In development, set `BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually. +Prefer `--private-key-file` / `--private-key-stdin` over `--private-key` on +argv (shell history / `ps` leak — #4032). ### Building the CLI