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
1 change: 1 addition & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ stored rules in `validation_error` so an owner can remove and repair them.

| Group | Subcommand | Description |
|-------|-----------|-------------|
| `auth` | `nip98-request` | Sign and send a bounded NIP-98 HTTPS POST request |
| `messages` | `send` | Send a message to a channel |
| | `send-diff` | Send a code diff with metadata |
| | `edit` | Edit a message you sent |
Expand Down
73 changes: 70 additions & 3 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,40 @@ fn sign_nip98(
method: &str,
url: &str,
body: Option<&[u8]>,
) -> Result<String, CliError> {
sign_nip98_request(
keys,
method,
url,
body,
None,
&uuid::Uuid::new_v4().to_string(),
)
}

/// Sign a NIP-98 request with an optional audience and caller-provided nonce.
///
/// This is exposed for commands that send a signed request outside the Buzz
/// relay while keeping the private key and Authorization header inside the CLI.
pub fn sign_nip98_request(
keys: &Keys,
method: &str,
url: &str,
body: Option<&[u8]>,
audience: Option<&str>,
nonce: &str,
) -> Result<String, CliError> {
let mut tags = vec![
Tag::parse(["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
// Nonce prevents replay rejection for rapid-fire requests with identical bodies.
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(["nonce", nonce]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];
if let Some(audience) = audience {
tags.push(
Tag::parse(["aud", audience])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
);
}
if let Some(b) = body {
let hash = hex::encode(Sha256::digest(b));
tags.push(
Expand Down Expand Up @@ -480,6 +506,47 @@ mod media_download_tests {
.any(|tag| tag.first().map(String::as_str) == Some("x")));
}

#[test]
fn nip98_request_binds_url_method_payload_audience_and_nonce() {
let keys = Keys::generate();
let body = br#"{"action":"health.inspect"}"#;
let header = sign_nip98_request(
&keys,
"POST",
"https://broker.example/health/inspect",
Some(body),
Some("example-broker"),
"test-nonce",
)
.unwrap();
let encoded = header.strip_prefix("Nostr ").unwrap();
let json = B64.decode(encoded).unwrap();
let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap();
event.verify().unwrap();
assert_eq!(event.kind, Kind::Custom(27235));

let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
assert!(tags
.iter()
.any(|tag| tag.as_slice() == ["u", "https://broker.example/health/inspect"]));
assert!(tags.iter().any(|tag| tag.as_slice() == ["method", "POST"]));
assert!(tags
.iter()
.any(|tag| tag.as_slice() == ["aud", "example-broker"]));
assert!(tags
.iter()
.any(|tag| tag.as_slice() == ["nonce", "test-nonce"]));
assert!(tags.iter().any(|tag| {
tag.first().map(String::as_str) == Some("payload")
&& tag.get(1).map(String::as_str)
== Some(hex::encode(Sha256::digest(body)).as_str())
}));
}

#[test]
fn legacy_upload_retry_statuses_are_narrow() {
assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND));
Expand Down
135 changes: 135 additions & 0 deletions crates/buzz-cli/src/commands/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use std::io::Read;

use rand::Rng;

use crate::client::{sign_nip98_request, BuzzClient};
use crate::{AuthCmd, CliError};

const MAX_REQUEST_BODY_BYTES: usize = 1024;
const MAX_RESPONSE_BODY_BYTES: usize = 1024 * 1024;

pub async fn dispatch(sub: AuthCmd, client: &BuzzClient) -> Result<(), CliError> {
match sub {
AuthCmd::Nip98Request {
method,
url,
audience,
body,
} => nip98_request(client, &method, &url, &audience, &body).await,
}
}

async fn nip98_request(
client: &BuzzClient,
method: &str,
url: &str,
audience: &str,
body_source: &str,
) -> Result<(), CliError> {
let parsed = validate_request(method, url, body_source)?;

let mut input = std::io::stdin().take((MAX_REQUEST_BODY_BYTES + 1) as u64);
let mut body = Vec::new();
input
.read_to_end(&mut body)
.map_err(|e| CliError::Usage(format!("failed to read request body: {e}")))?;
if body.len() > MAX_REQUEST_BODY_BYTES {
return Err(CliError::Usage(format!(
"request body exceeds {MAX_REQUEST_BODY_BYTES} bytes"
)));
}

let mut nonce = [0_u8; 32];
rand::rng().fill_bytes(&mut nonce);
let authorization = sign_nip98_request(
client.keys(),
method,
url,
Some(&body),
Some(audience),
&hex::encode(nonce),
)?;

let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let response = http
.post(parsed)
.header(reqwest::header::AUTHORIZATION, authorization)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if bytes.len() > MAX_RESPONSE_BODY_BYTES {
return Err(CliError::Other(format!(
"response body exceeds {MAX_RESPONSE_BODY_BYTES} bytes"
)));
}
let body = String::from_utf8_lossy(&bytes);
let body_value = serde_json::from_slice::<serde_json::Value>(&bytes)
.unwrap_or_else(|_| serde_json::Value::String(body.into_owned()));
println!(
"{}",
serde_json::json!({
"status": status.as_u16(),
"body": body_value,
})
);
Ok(())
}

fn validate_request(method: &str, url: &str, body_source: &str) -> Result<url::Url, CliError> {
if method != "POST" {
return Err(CliError::Usage(
"nip98-request currently permits only POST".into(),
));
}
if body_source != "-" {
return Err(CliError::Usage(
"nip98-request body must be read from stdin with --body -".into(),
));
}

let parsed =
url::Url::parse(url).map_err(|e| CliError::Usage(format!("invalid request URL: {e}")))?;
if parsed.scheme() != "https" {
return Err(CliError::Usage(
"nip98-request requires an https URL".into(),
));
}
if parsed.fragment().is_some() || parsed.username() != "" || parsed.password().is_some() {
return Err(CliError::Usage(
"nip98-request URL cannot contain credentials or a fragment".into(),
));
}
Ok(parsed)
}

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

#[test]
fn accepts_bounded_https_post_from_stdin() {
let parsed = validate_request("POST", "https://broker.example/health", "-").unwrap();
assert_eq!(parsed.as_str(), "https://broker.example/health");
}

#[test]
fn rejects_unsafe_request_shapes() {
for (method, url, body) in [
("GET", "https://broker.example/health", "-"),
("POST", "http://broker.example/health", "-"),
("POST", "https://user@broker.example/health", "-"),
("POST", "https://broker.example/health#fragment", "-"),
("POST", "https://broker.example/health", "request.json"),
] {
assert!(
validate_request(method, url, body).is_err(),
"request must be rejected: {method} {url} {body}"
);
}
}
}
1 change: 1 addition & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod agents;
pub mod auth;
pub mod channel_templates;
pub mod channels;
pub mod dms;
Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ enum Cmd {
/// Draft owner-reviewed agent creation and updates
#[command(subcommand)]
Agents(AgentsCmd),
/// Sign and send narrowly scoped NIP-98 HTTP requests
#[command(subcommand)]
Auth(AuthCmd),
/// Send, read, search, and manage messages
#[command(subcommand)]
Messages(MessagesCmd),
Expand Down Expand Up @@ -226,6 +229,25 @@ enum Cmd {
Moderation(ModerationCmd),
}

#[derive(Subcommand)]
pub enum AuthCmd {
/// Sign and send a NIP-98 request without exposing the Authorization header
Nip98Request {
/// HTTP method; the functional pilot permits only POST
#[arg(long, default_value = "POST")]
method: String,
/// Exact absolute HTTPS URL bound into the signed event
#[arg(long)]
url: String,
/// Audience bound into the signed event
#[arg(long)]
audience: String,
/// Request body source; use '-' to read at most 1 KiB from stdin
#[arg(long, default_value = "-")]
body: String,
},
}

#[derive(Clone, Copy, clap::ValueEnum)]
pub enum RespondToArg {
#[value(name = "owner-only")]
Expand Down Expand Up @@ -1754,6 +1776,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {

match cli.command {
Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await,
Cmd::Auth(sub) => commands::auth::dispatch(sub, &client).await,
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
Expand Down Expand Up @@ -1792,6 +1815,7 @@ mod tests {
fn command_inventory_is_stable() {
let expected_groups: Vec<&str> = vec![
"agents",
"auth",
"canvas",
"channels",
"dms",
Expand Down