Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod agent_management;
mod client;
mod commands;
mod error;
mod private_key;
mod validate;

use clap::{Parser, Subcommand};
Expand Down Expand Up @@ -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<std::ffi::OsString> = 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() {
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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<String>,

/// Read the Nostr private key from a file (mode 0600 recommended).
#[arg(long, value_name = "PATH")]
private_key_file: Option<std::path::PathBuf>,

/// 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<String>,
Expand Down Expand Up @@ -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.
Expand All @@ -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}")))?;
Expand Down
179 changes: 179 additions & 0 deletions crates/buzz-cli/src/private_key.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// Path from `--private-key-file`.
pub private_key_file: Option<std::path::PathBuf>,
/// 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<String, CliError> {
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<String, CliError> {
#[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<String, CliError> {
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<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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");
}
}