diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..dd6417c40b 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -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 | diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..63460c7d3c 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -86,14 +86,40 @@ fn sign_nip98( method: &str, url: &str, body: Option<&[u8]>, +) -> Result { + 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 { 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( @@ -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> = 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)); diff --git a/crates/buzz-cli/src/commands/auth.rs b/crates/buzz-cli/src/commands/auth.rs new file mode 100644 index 0000000000..6f660891f2 --- /dev/null +++ b/crates/buzz-cli/src/commands/auth.rs @@ -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::(&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 { + 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}" + ); + } + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..06a3e6aa81 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod agents; +pub mod auth; pub mod channel_templates; pub mod channels; pub mod dms; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d5c6b6f9ab..f2d41ec40c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -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), @@ -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")] @@ -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, @@ -1792,6 +1815,7 @@ mod tests { fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ "agents", + "auth", "canvas", "channels", "dms",