Skip to content
Draft
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
35 changes: 35 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

Agent-first command-line interface for Buzz relay. JSON in, JSON out.

Long-running CLI clients can use the
[long-running client contract](../../docs/cli-external-agents.md).

## Install

```bash
Expand All @@ -20,6 +23,25 @@ export BUZZ_PRIVATE_KEY="nsec1..."
buzz channels list
```

### Generate a local identity

`buzz keys generate` creates a keypair without a relay connection and without
an existing `BUZZ_PRIVATE_KEY`.

```bash
buzz keys generate --out ./identity.nsec
# → {"pubkey":"<64-hex>","npub":"npub1...","secret_key_path":"./identity.nsec"}

export BUZZ_PRIVATE_KEY="$(cat ./identity.nsec)"
```

On Unix, the secret is written with mode `0600` and is **not** printed unless
`--stdout` is passed; stdout carries only the public half, so the pubkey can be
registered without the secret ever passing through another process. Windows
file output fails closed until Buzz can guarantee owner-only ACLs there; use
`--stdout` with a platform secret store. An existing `--out` file is never
overwritten without `--force`.

## 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 All @@ -28,6 +50,9 @@ All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=use
# Set relay URL (defaults to http://localhost:3000)
export BUZZ_RELAY_URL="https://relay.example.com"

# Realtime signed-event stream
buzz listen --channel <uuid> --mentions-of-me --envelope v1 --no-reconnect

# Messages
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
Expand All @@ -53,6 +78,7 @@ buzz reactions add --event <event-id> --emoji "👍"
buzz reactions get --event <event-id>

# Users & Presence
buzz users me # local identity; no relay request
buzz users get # your own profile
buzz users get --pubkey <hex> # single user
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
Expand Down Expand Up @@ -94,6 +120,12 @@ buzz repos protect remove --id my-repo --ref refs/heads/main
buzz channels list | jq '.[].name'
```

`buzz listen --envelope v1` emits full signed Nostr events, including `sig`,
after verifying the event ID, signature, and subscription-filter match. When
`--since` is supplied, the CLI preflights the replay window with `/count` and
refuses windows above the relay's 1,000-event historical cap instead of
silently truncating catch-up.

`protect set` replaces every existing rule for the exact ref pattern. Any
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.
Expand All @@ -102,6 +134,7 @@ stored rules in `validation_error` so an owner can remove and repair them.

| Group | Subcommand | Description |
|-------|-----------|-------------|
| `listen` | | Stream channel events as NDJSON |
| `messages` | `send` | Send a message to a channel |
| | `send-diff` | Send a code diff with metadata |
| | `edit` | Edit a message you sent |
Expand Down Expand Up @@ -133,6 +166,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| | `open` | Open a DM (1–8 pubkeys) |
| | `add-member` | Add member to DM group |
| `users` | `get` | Get user profile(s) |
| | `me` | Print the active local identity |
| | `set-profile` | Update your profile |
| | `presence` | Get presence status |
| | `set-presence` | Set presence status |
Expand Down Expand Up @@ -160,6 +194,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| `upload` | `file` | Upload a file to the Blossom store |
| `pack` | `validate` | Validate a persona pack (local, no relay) |
| | `inspect` | Inspect a persona pack (local, no relay) |
| `keys` | `generate` | Generate a Nostr identity (local, no relay, no key required) |
| `mem` | `ls` | List non-tombstoned memories |
| | `get` | Print memory value to stdout |
| | `hash` | Print SHA-256 hex of memory value |
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,11 @@ impl BuzzClient {
&self.keys
}

/// Get the parsed NIP-OA auth tag, if configured.
pub fn auth_tag(&self) -> Option<&Tag> {
self.auth_tag.as_ref()
}

/// Get the relay base URL.
#[allow(dead_code)]
pub fn relay_url(&self) -> &str {
Expand Down
259 changes: 259 additions & 0 deletions crates/buzz-cli/src/commands/keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//! `buzz keys` subcommands — local Nostr identity operations.
//!
//! These commands run entirely in the invoking process. They make no
//! relay request and, unlike every other subcommand, do not require
//! `BUZZ_PRIVATE_KEY` to already be set — `keys generate` is how that value
//! comes into existence in the first place.

use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};

use nostr::{Keys, ToBech32};

use crate::error::CliError;

/// Permission bits for a freshly written secret-key file: owner read/write only.
#[cfg(unix)]
const SECRET_FILE_MODE: u32 = 0o600;

/// Run `buzz keys generate`.
///
/// Mints a fresh secp256k1 keypair and reports the **public** half on stdout.
/// The secret half is written to `out` and is printed only when `stdout_secret`
/// is set — an explicit opt-in for callers that pipe into their own secret
/// store rather than a file.
///
/// `force` permits overwriting an existing `out` path. Without it an existing
/// file is an error: re-running a connect flow must not be able to silently
/// destroy an identity that is already in use, which would orphan every event
/// signed by that identity.
pub fn cmd_generate(out: Option<&str>, stdout_secret: bool, force: bool) -> Result<(), CliError> {
if out.is_none() && !stdout_secret {
return Err(CliError::Usage(
"no destination for the generated secret key: pass --out <path> to write \
it to a file, or --stdout to print it"
.into(),
));
}

let keys = Keys::generate();
let pubkey = keys.public_key();
let npub = pubkey
.to_bech32()
.map_err(|e| CliError::Other(format!("failed to encode npub: {e}")))?;
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| CliError::Other(format!("failed to encode nsec: {e}")))?;

let written = match out {
Some(path) => Some(write_secret_file(Path::new(path), &nsec, force)?),
None => None,
};

// Ordering is deliberate: the file is on disk before anything is printed,
// so a caller that reads stdout and then reads the path can never observe
// a pubkey whose secret was not persisted.
let mut report = serde_json::json!({
"pubkey": pubkey.to_hex(),
"npub": npub,
});
if let Some(path) = &written {
report["secret_key_path"] = serde_json::json!(path.display().to_string());
}
if stdout_secret {
report["nsec"] = serde_json::json!(nsec);
}
println!("{report}");
Ok(())
}

/// Create `path` and write `nsec` to it with owner-only permissions.
///
/// The file is created with its restrictive mode from the outset via
/// `OpenOptions::mode` rather than being chmod-ed afterwards — a
/// create-then-chmod sequence leaves a window in which the secret is on disk
/// world-readable.
///
/// Windows is fail-closed for file output until this command installs and
/// verifies an owner-only DACL. Inheriting the parent directory ACL would break
/// the command's storage promise. Use `--stdout` and pipe into a platform
/// secret store on Windows.
///
/// Returns the canonical path that was written.
fn write_secret_file(path: &Path, nsec: &str, force: bool) -> Result<PathBuf, CliError> {
#[cfg(windows)]
{
let _ = (path, nsec, force);
return Err(CliError::Usage(
"writing key files is not supported on Windows yet because Buzz cannot \
guarantee owner-only ACLs there; use --stdout and pipe into a Windows \
secret store"
.into(),
));
}

#[cfg(not(windows))]
{
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(CliError::Usage(format!(
"directory does not exist: {}",
parent.display()
)));
}
}

let mut options = OpenOptions::new();
options.write(true);
if force {
options.create(true).truncate(true);
} else {
// create_new fails if the path exists, which is the guard we want —
// and it is atomic, so two concurrent generates cannot both believe
// they created the file.
options.create_new(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(SECRET_FILE_MODE);
}

let mut file = options.open(path).map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => CliError::Usage(format!(
"refusing to overwrite existing key file: {} (pass --force to replace it, \
but note that any client already using this identity will lose it)",
path.display()
)),
_ => CliError::Other(format!("failed to create {}: {e}", path.display())),
})?;

// `--force` reuses an existing inode, whose mode is whatever it already
// was; `OpenOptions::mode` only applies on creation. Re-assert the mode so
// the overwrite path cannot leave a permissive file behind.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(SECRET_FILE_MODE))
.map_err(|e| {
CliError::Other(format!(
"failed to set permissions on {}: {e}",
path.display()
))
})?;
}

writeln!(file, "{nsec}")
.map_err(|e| CliError::Other(format!("failed to write {}: {e}", path.display())))?;
file.sync_all()
.map_err(|e| CliError::Other(format!("failed to flush {}: {e}", path.display())))?;

Ok(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
}
}

pub fn dispatch(cmd: crate::KeysCmd) -> Result<(), CliError> {
use crate::KeysCmd;
match cmd {
KeysCmd::Generate { out, stdout, force } => cmd_generate(out.as_deref(), stdout, force),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn requires_a_destination() {
// Neither --out nor --stdout: the secret would be generated and
// immediately discarded, which is never what the caller meant.
let err = cmd_generate(None, false, false).expect_err("expected usage error");
assert!(matches!(err, CliError::Usage(_)));
}

#[test]
fn writes_secret_file_with_owner_only_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.nsec");
let written = write_secret_file(&path, "nsec1test", false).unwrap();

let contents = std::fs::read_to_string(&written).unwrap();
assert_eq!(contents.trim(), "nsec1test");

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&written).unwrap().permissions().mode();
assert_eq!(mode & 0o777, SECRET_FILE_MODE);
}
}

#[test]
fn refuses_to_overwrite_without_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.nsec");
write_secret_file(&path, "nsec1original", false).unwrap();

let err = write_secret_file(&path, "nsec1replacement", false)
.expect_err("expected overwrite refusal");
assert!(matches!(err, CliError::Usage(_)));

// The original identity survives the refused write.
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.trim(), "nsec1original");
}

#[test]
fn force_overwrites_and_keeps_owner_only_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.nsec");
write_secret_file(&path, "nsec1original", false).unwrap();

// Loosen the mode so the re-assert has something to correct.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
}

write_secret_file(&path, "nsec1replacement", true).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.trim(), "nsec1replacement");

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, SECRET_FILE_MODE);
}
}

#[test]
fn rejects_missing_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no-such-dir").join("identity.nsec");
let err = write_secret_file(&path, "nsec1test", false).expect_err("expected usage error");
assert!(matches!(err, CliError::Usage(_)));
}

#[test]
fn generated_secret_round_trips_to_the_reported_pubkey() {
// The whole point of the command is that the caller can later load the
// written secret and arrive at the pubkey that was printed. Prove the
// encode/parse pair agrees rather than trusting it.
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let reloaded = Keys::parse(&nsec).unwrap();
assert_eq!(reloaded.public_key(), keys.public_key());
}

#[cfg(windows)]
#[test]
fn windows_file_output_fails_closed_until_owner_only_acl_support_exists() {
let err = write_secret_file(Path::new("identity.nsec"), "nsec1test", false)
.expect_err("expected Windows fail-closed behavior");
assert!(matches!(err, CliError::Usage(message) if message.contains("owner-only ACLs")));
}
}
Loading