diff --git a/Cargo.lock b/Cargo.lock index 0e7ad2c..13d00cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,9 +495,9 @@ checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic-destructor" -version = "0.3.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" +checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23" [[package]] name = "atomic-waker" @@ -845,6 +845,21 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "browser-signer-proxy" +version = "0.1.0" +dependencies = [ + "atomic-destructor", + "event-listener", + "nostr", + "opaquerr", + "serde", + "serde_json", + "smol", + "tracing", + "uuid", +] + [[package]] name = "bstr" version = "1.13.0" @@ -1812,7 +1827,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2012,7 +2027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3232,12 +3247,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hybrid-array" version = "0.4.14" @@ -3261,7 +3270,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -4254,25 +4262,6 @@ dependencies = [ "serde", ] -[[package]] -name = "nostr-browser-signer-proxy" -version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" -dependencies = [ - "atomic-destructor", - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "nostr", - "opaquerr", - "serde", - "serde_json", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "nostr-connect" version = "0.45.0-alpha.8" @@ -4395,7 +4384,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5469,7 +5458,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6016,7 +6005,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6714,6 +6703,7 @@ name = "state" version = "1.0.0-beta5" dependencies = [ "anyhow", + "browser-signer-proxy", "common", "flume 0.11.1", "futures", @@ -6724,7 +6714,6 @@ dependencies = [ "mime_guess", "nostr", "nostr-blossom", - "nostr-browser-signer-proxy", "nostr-connect", "nostr-gossip-memory", "nostr-lmdb", @@ -7048,7 +7037,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8380,7 +8369,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9036,6 +9025,7 @@ name = "workspace" version = "1.0.0-beta5" dependencies = [ "anyhow", + "browser-signer-proxy", "chat", "chat_ui", "common", @@ -9043,7 +9033,6 @@ dependencies = [ "gpui", "instant", "log", - "nostr-browser-signer-proxy", "nostr-connect", "nostr-sdk", "person", diff --git a/Cargo.toml b/Cargo.toml index c9448bf..6d6016a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,6 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } -nostr-browser-signer-proxy = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } diff --git a/crates/browser-signer-proxy/Cargo.toml b/crates/browser-signer-proxy/Cargo.toml new file mode 100644 index 0000000..fd42ca1 --- /dev/null +++ b/crates/browser-signer-proxy/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "browser-signer-proxy" +version = "0.1.0" +edition.workspace = true +description = "Nostr browser signer (NIP-07) proxy using smol async runtime" +license = "MIT" +repository = "https://github.com/nostrdevkit/nostr" +publish = false + +[dependencies] +atomic-destructor = "0.2" +event-listener = "5" +nostr.workspace = true +opaquerr = "0.1" +serde.workspace = true +serde_json.workspace = true +smol.workspace = true +tracing = { version = "0.1", features = ["std"] } +uuid = { version = "1.23", features = ["serde", "v4"] } diff --git a/crates/browser-signer-proxy/README.md b/crates/browser-signer-proxy/README.md new file mode 100644 index 0000000..78cc508 --- /dev/null +++ b/crates/browser-signer-proxy/README.md @@ -0,0 +1,55 @@ +# browser-signer-proxy + +Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications. + +This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy) +using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio. + +## Description + +This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension +(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to +request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through +the browser extension. + +The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener` +and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely. + +## Usage + +```rust +use browser_signer_proxy::prelude::*; + +async fn example() -> Result<(), Error> { + // Create the proxy with default options (localhost:7400) + let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default()); + + // Open the proxy URL in a browser + webbrowser::open(&proxy.url())?; + + // Start the proxy server + proxy.start().await?; + + // Use it as an async Nostr signer + let public_key = proxy.get_public_key_async().await?; + println!("Connected with public key: {public_key}"); + + Ok(()) +} +``` + +## Differences from the tokio-based version + +| Feature | tokio (original) | smol (this crate) | +|---|---|---| +| Async runtime | `tokio` | `smol` | +| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 | +| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` | +| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` | +| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` | +| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` | +| Task spawning | `tokio::spawn` | `smol::spawn` | + +## License + +This project is distributed under the MIT software license. diff --git a/crates/browser-signer-proxy/index.html b/crates/browser-signer-proxy/index.html new file mode 100644 index 0000000..2b81b30 --- /dev/null +++ b/crates/browser-signer-proxy/index.html @@ -0,0 +1,44 @@ + + + + NIP-07 Proxy + + + + +
+

NIP-07 Proxy

+

This page acts

as a proxy between your native application and the NIP-07 browser extension.

+
+ Status: Checking... +
+

Keep this tab open while using your application. The page will automatically poll for requests from your native app.

+ +

Debug

Info +

Check the browser console (F12) for detailed logs.

+
+ + + + diff --git a/crates/browser-signer-proxy/proxy.js b/crates/browser-signer-proxy/proxy.js new file mode 100644 index 0000000..ecb96a4 --- /dev/null +++ b/crates/browser-signer-proxy/proxy.js @@ -0,0 +1,157 @@ +let isPolling = false; + +async function pollForRequests() { + if (isPolling) return; + isPolling = true; + + try { + const response = await fetch('/api/pending'); + const data = await response.json(); + + console.log('Polled for requests, got:', data); + + // Process any new requests + if (data.requests && data.requests.length > 0) { + console.log(`Processing ${data.requests.length} requests`); + for (const request of data.requests) { + await handleNip07Request(request); + } + } + } catch (error) { + console.error('Polling error:', error); + updateStatus('Error: ' + error.message, 'error'); + } + + isPolling = false; +} + +async function handleNip07Request(request) { + console.log('Handling request:', request); + + try { + let result; + + if (!window.nostr) { + throw new Error('NIP-07 extension not available'); + } + + switch (request.method) { + case 'get_public_key': + console.log('Calling nostr.getPublicKey()'); + result = await window.nostr.getPublicKey(); + console.log('Got public key:', result); + break; + + case 'sign_event': + console.log('Calling nostr.signEvent() with:', request.params); + result = await window.nostr.signEvent(request.params); + console.log('Got signed event:', result); + break; + + case 'nip04_encrypt': + console.log('Calling nostr.nip04.encrypt()'); + result = await window.nostr.nip04.encrypt( + request.params.public_key, + request.params.content + ); + break; + + case 'nip04_decrypt': + console.log('Calling nostr.nip04.decrypt()'); + result = await window.nostr.nip04.decrypt( + request.params.public_key, + request.params.content + ); + break; + + case 'nip44_encrypt': + console.log('Calling nostr.nip44.encrypt()'); + result = await window.nostr.nip44.encrypt( + request.params.public_key, + request.params.content + ); + break; + + case 'nip44_decrypt': + console.log('Calling nostr.nip44.decrypt()'); + result = await window.nostr.nip44.decrypt( + request.params.public_key, + request.params.content + ); + break; + + + default: + throw new Error(`Unknown method: ${request.method}`); + } + + // Send response back to server + const responsePayload = { + id: request.id, + result: result, + error: null + }; + + console.log('Sending response:', responsePayload); + + await fetch('/api/response', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsePayload) + }); + + console.log('Response sent successfully'); + updateStatus('Request processed successfully', 'connected'); + + } catch (error) { + console.error('Error handling request:', error); + + // Send error response back to server + const errorPayload = { + id: request.id, + result: null, + error: error.message + }; + + console.log('Sending error response:', errorPayload); + + await fetch('/api/response', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(errorPayload) + }); + + updateStatus('Error: ' + error.message, 'error'); + } +} + +function updateStatus(message, status) { + const statusEl = document.getElementById('nip07-proxy-status'); + if (statusEl) { + statusEl.textContent = message; + statusEl.style = status === "connected" ? + "color: green; font-weight: bold;" : + "color: red; font-weight: bold;"; + } +} + +// Start polling when page loads +window.addEventListener('load', () => { + console.log('NIP-07 Proxy loaded'); + + // Check if NIP-07 extension is available + if (window.nostr) { + console.log('NIP-07 extension detected'); + updateStatus('Connected to NIP-07 extension - Ready', 'connected'); + } else { + console.log('NIP-07 extension not found'); + updateStatus('NIP-07 extension not found', 'error'); + } + + // Start polling every 500 ms + setInterval(pollForRequests, 500); +}); diff --git a/crates/browser-signer-proxy/src/error.rs b/crates/browser-signer-proxy/src/error.rs new file mode 100644 index 0000000..54d97dc --- /dev/null +++ b/crates/browser-signer-proxy/src/error.rs @@ -0,0 +1,51 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! Error types for the browser signer proxy. + +opaquerr::define_kind! { + /// Nostr browser signer proxy error kind. + pub ErrorKind { + /// Nostr protocol error. + Protocol => "nostr protocol error", + /// I/O error. + IO => "I/O error", + /// JSON error. + Json => "JSON error", + /// The operation timed out. + Timeout => "timeout", + /// The operation cannot be completed in the current state. + State => "invalid state", + /// Anything not covered by the stable categories above. + Other => "other error", + } +} + +opaquerr::define_error! { + /// Nostr browser signer proxy error. + pub Error(ErrorKind) + + from { + nostr::error::Error => ErrorKind::Protocol, + std::io::Error => ErrorKind::IO, + serde_json::Error => ErrorKind::Json, + } +} + +impl Error { + pub(crate) fn generic(message: S) -> Self + where + S: Into, + { + Self::new(ErrorKind::Other, message.into()) + } + + pub(crate) fn timeout() -> Self { + Self::simple(ErrorKind::Timeout) + } + + pub(crate) fn shutdown() -> Self { + Self::with_static_message(ErrorKind::State, "server is shutdown") + } +} diff --git a/crates/browser-signer-proxy/src/lib.rs b/crates/browser-signer-proxy/src/lib.rs new file mode 100644 index 0000000..0da268a --- /dev/null +++ b/crates/browser-signer-proxy/src/lib.rs @@ -0,0 +1,764 @@ +use std::collections::HashMap; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use atomic_destructor::{AtomicDestroyer, AtomicDestructor}; +use event_listener::Event as ShutdownEvent; +use nostr::prelude::*; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{Value, json}; +use smol::channel; +use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use smol::lock::Mutex; +use smol::net::{TcpListener, TcpStream}; +use uuid::Uuid; + +mod error; +pub mod prelude; + +pub use self::error::Error; + +const DEFAULT_HTML: &str = include_str!("../index.html"); +const JS: &str = include_str!("../proxy.js"); + +type PendingResponseMap = HashMap>>; + +#[derive(Debug, Deserialize)] +struct Message { + id: Uuid, + error: Option, + result: Option, +} + +impl Message { + fn into_result(self) -> Result { + if let Some(error) = self.error { + Err(error) + } else { + Ok(self.result.unwrap_or(Value::Null)) + } + } +} + +#[derive(Debug, Clone, Copy)] +enum RequestMethod { + GetPublicKey, + SignEvent, + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, +} + +impl RequestMethod { + fn as_str(&self) -> &str { + match self { + Self::GetPublicKey => "get_public_key", + Self::SignEvent => "sign_event", + Self::Nip04Encrypt => "nip04_encrypt", + Self::Nip04Decrypt => "nip04_decrypt", + Self::Nip44Encrypt => "nip44_encrypt", + Self::Nip44Decrypt => "nip44_decrypt", + } + } +} + +impl Serialize for RequestMethod { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize)] +struct RequestData { + id: Uuid, + method: RequestMethod, + params: Value, +} + +impl RequestData { + #[inline] + fn new(method: RequestMethod, params: Value) -> Self { + Self { + id: Uuid::new_v4(), + method, + params, + } + } +} + +#[derive(Serialize)] +struct Requests<'a> { + requests: &'a [RequestData], +} + +impl<'a> Requests<'a> { + #[inline] + fn new(requests: &'a [RequestData]) -> Self { + Self { requests } + } + + #[inline] + fn len(&self) -> usize { + self.requests.len() + } +} + +/// Params for NIP-04 and NIP-44 encryption/decryption +#[derive(Serialize)] +struct CryptoParams<'a> { + public_key: &'a PublicKey, + content: &'a str, +} + +impl<'a> CryptoParams<'a> { + #[inline] + fn new(public_key: &'a PublicKey, content: &'a str) -> Self { + Self { + public_key, + content, + } + } +} + +#[derive(Debug)] +struct ProxyState { + /// Requests waiting to be picked up by browser + pub outgoing_requests: Mutex>, + /// Map of request ID to response sender + pub pending_responses: Mutex, + /// Last time the client asked for the pending requests + pub last_pending_request: Arc, +} + +/// Configuration options for [`BrowserSignerProxy`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserSignerProxyOptions { + /// Request timeout for the signer extension. Default is 30 seconds. + pub timeout: Duration, + /// Proxy server IP address and port. Default is `127.0.0.1:7400`. + pub addr: SocketAddr, + /// Custom HTML page. + // NOTE: not `Option` to move it between threads without reference counter + pub custom_html: &'static str, +} + +#[derive(Debug, Clone)] +struct InnerBrowserSignerProxy { + /// Configuration options for the proxy + options: BrowserSignerProxyOptions, + /// Internal state of the proxy including request queues + state: Arc, + /// Notification trigger for graceful shutdown + shutdown: Arc, + /// Flag to indicate if the server is shutdown + is_shutdown: Arc, + /// Flag indicating if the server is started + is_started: Arc, +} + +impl AtomicDestroyer for InnerBrowserSignerProxy { + fn on_destroy(&self) { + self.shutdown(); + } +} + +impl InnerBrowserSignerProxy { + #[inline] + fn is_shutdown(&self) -> bool { + self.is_shutdown.load(Ordering::SeqCst) + } + + fn shutdown(&self) { + // Mark the server as shutdown + self.is_shutdown.store(true, Ordering::SeqCst); + + // Notify all waiters that the proxy is shutting down + self.shutdown.notify(usize::MAX); + } +} + +/// Nostr Browser Signer Proxy +/// +/// Proxy to use Nostr Browser signer (NIP-07) in native applications. +#[derive(Debug, Clone)] +pub struct BrowserSignerProxy { + inner: AtomicDestructor, +} + +impl Default for BrowserSignerProxyOptions { + fn default() -> Self { + Self { + timeout: Duration::from_secs(30), + // 7 for NIP-07 and 400 because the NIP title is 40 bytes :) + addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)), + custom_html: "", + } + } +} + +impl BrowserSignerProxyOptions { + /// Sets the timeout duration. + pub const fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Sets the IP address. + pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self { + self.addr = SocketAddr::new(new_ip, self.addr.port()); + self + } + + /// Sets the port number. + pub const fn port(mut self, new_port: u16) -> Self { + self.addr = SocketAddr::new(self.addr.ip(), new_port); + self + } + + /// Sets a custom html page. + /// + /// The page must include `/proxy.js` script (``) + /// which will handle communication with the server and update the element + /// with id `nip07-proxy-status` with the status. + pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self { + self.custom_html = custom_html; + self + } +} + +impl BrowserSignerProxy { + /// Construct a new browser signer proxy + pub fn new(options: BrowserSignerProxyOptions) -> Self { + let state = ProxyState { + outgoing_requests: Mutex::new(Vec::new()), + pending_responses: Mutex::new(HashMap::new()), + last_pending_request: Arc::new(AtomicU64::new(0)), + }; + + Self { + inner: AtomicDestructor::new(InnerBrowserSignerProxy { + options, + state: Arc::new(state), + shutdown: Arc::new(ShutdownEvent::new()), + is_shutdown: Arc::new(AtomicBool::new(false)), + is_started: Arc::new(AtomicBool::new(false)), + }), + } + } + + /// Indicates whether the server is currently running. + #[inline] + pub fn is_started(&self) -> bool { + self.inner.is_started.load(Ordering::SeqCst) + } + + /// Checks if there is an open browser tab ready to respond to requests by + /// verifying the time since the last pending request. + #[inline] + pub fn is_session_active(&self) -> bool { + current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2 + } + + /// Get the signer proxy webpage URL + #[inline] + pub fn url(&self) -> String { + format!("http://{}", self.inner.options.addr) + } + + /// Start the proxy server. + /// + /// If this is not called explicitly, the server will be automatically + /// started on the first interaction with the signer. + pub async fn start(&self) -> Result<(), Error> { + // Ensure is not shutdown + if self.inner.is_shutdown() { + return Err(Error::shutdown()); + } + + // Mark the proxy as started and check if was already started + let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst); + + // Immediately return if already started + if is_started { + return Ok(()); + } + + let listener: TcpListener = match TcpListener::bind(self.inner.options.addr).await { + Ok(listener) => listener, + Err(e) => { + // Undo the started flag if binding fails + self.inner.is_started.store(false, Ordering::SeqCst); + return Err(Error::from(e)); + } + }; + + let addr: SocketAddr = self.inner.options.addr; + let state: Arc = self.inner.state.clone(); + let custom_html: &'static str = self.inner.options.custom_html; + let shutdown: Arc = self.inner.shutdown.clone(); + + smol::spawn(async move { + tracing::info!("Starting proxy server on {addr}"); + + loop { + // Race between accepting a new connection and shutdown signal + let shutdown_listener = shutdown.listen(); + + enum AcceptEvent { + Connection(Result<(TcpStream, SocketAddr), std::io::Error>), + Shutdown, + } + + let event = smol::future::or( + async { AcceptEvent::Connection(listener.accept().await) }, + async { + shutdown_listener.await; + AcceptEvent::Shutdown + }, + ) + .await; + + match event { + AcceptEvent::Connection(Ok((stream, _))) => { + let state: Arc = state.clone(); + let shutdown: Arc = shutdown.clone(); + + smol::spawn(async move { + let shutdown_listener = shutdown.listen(); + + smol::future::or( + async { + handle_connection(stream, state, custom_html).await; + }, + async { + shutdown_listener.await; + tracing::debug!( + "Closing connection, proxy server is shutting down." + ); + }, + ) + .await; + }) + .detach(); + } + AcceptEvent::Connection(Err(e)) => { + tracing::error!("Failed to accept connection: {e}"); + } + AcceptEvent::Shutdown => break, + } + } + + tracing::info!("Proxy server shut down."); + }) + .detach(); + + Ok(()) + } + + #[inline] + async fn store_pending_response(&self, id: Uuid, tx: channel::Sender>) { + let mut pending_responses = self.inner.state.pending_responses.lock().await; + pending_responses.insert(id, tx); + } + + #[inline] + async fn store_outgoing_request(&self, request: RequestData) { + let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await; + outgoing_requests.push(request); + } + + async fn request(&self, method: RequestMethod, params: Value) -> Result + where + T: DeserializeOwned, + { + // Start the proxy if not already started + self.start().await?; + + // Construct the request + let request: RequestData = RequestData::new(method, params); + + // Create a bounded channel of size 1 as a oneshot replacement + let (tx, rx) = channel::bounded::>(1); + + // Store the response sender + self.store_pending_response(request.id, tx).await; + + // Add to outgoing requests queue + self.store_outgoing_request(request).await; + + // Wait for response with timeout + let response = race_timeout(self.inner.options.timeout, rx.recv()).await; + + match response { + Ok(Ok(res)) => Ok(serde_json::from_value(res)?), + Ok(Err(error)) => Err(Error::generic(error)), + Err(TimeoutError) => Err(Error::timeout()), + } + } + + #[inline] + async fn _get_public_key(&self) -> Result { + self.request(RequestMethod::GetPublicKey, json!({})).await + } + + #[inline] + async fn _sign_event(&self, event: UnsignedEvent) -> Result { + let event: Event = self + .request(RequestMethod::SignEvent, serde_json::to_value(event)?) + .await?; + event.verify()?; + Ok(event) + } + + #[inline] + async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result { + let params = CryptoParams::new(public_key, content); + self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?) + .await + } + + #[inline] + async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result { + let params = CryptoParams::new(public_key, content); + self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?) + .await + } + + #[inline] + async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result { + let params = CryptoParams::new(public_key, content); + self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?) + .await + } + + #[inline] + async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result { + let params = CryptoParams::new(public_key, content); + self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?) + .await + } +} + +impl AsyncGetPublicKey for BrowserSignerProxy { + type Error = Error; + + #[inline] + fn get_public_key_async( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { self._get_public_key().await }) + } +} + +impl AsyncSignEvent for BrowserSignerProxy { + type Error = Error; + + #[inline] + fn sign_event_async( + &self, + unsigned: UnsignedEvent, + ) -> Pin> + Send + '_>> { + Box::pin(async move { self._sign_event(unsigned).await }) + } +} + +impl AsyncNip04 for BrowserSignerProxy { + type Error = Error; + + fn nip04_encrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + content: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { self._nip04_encrypt(public_key, content).await }) + } + + fn nip04_decrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + encrypted_content: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await }) + } +} + +impl AsyncNip44 for BrowserSignerProxy { + type Error = Error; + + fn nip44_encrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + content: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { self._nip44_encrypt(public_key, content).await }) + } + + fn nip44_decrypt_async<'a>( + &'a self, + public_key: &'a PublicKey, + payload: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { self._nip44_decrypt(public_key, payload).await }) + } +} + +// ── Minimal HTTP server ────────────────────────────────────────────────── + +/// Handle a single HTTP connection. +async fn handle_connection(stream: TcpStream, state: Arc, custom_html: &'static str) { + let mut reader = BufReader::new(stream); + + // Read the request line + let mut request_line = String::new(); + if reader.read_line(&mut request_line).await.is_err() { + return; + } + let request_line = request_line.trim_end().to_string(); + + // Parse method, path, and HTTP version from request line + let parts: Vec<&str> = request_line.split_whitespace().collect(); + if parts.len() < 2 { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + let method = parts[0].to_uppercase(); + let path = parts[1].to_string(); + + // Read headers until empty line + let mut headers = Vec::new(); + let mut content_length: usize = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.is_err() { + return; + } + let line = line.trim_end().to_string(); + if line.is_empty() { + break; + } + if let Some(value) = line.strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap_or(0); + } else if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = value.trim().parse().unwrap_or(0); + } + headers.push(line); + } + + match (method.as_str(), path.as_str()) { + // Serve the HTML proxy page + ("GET", "/") => { + let html = if custom_html.is_empty() { + DEFAULT_HTML + } else { + custom_html + }; + send_response(&mut reader, 200, "OK", "text/html", html).await; + } + // Serve the JS proxy script + ("GET", "/proxy.js") => { + send_response(&mut reader, 200, "OK", "application/javascript", JS).await; + } + // Browser polls this endpoint to get pending requests + ("GET", "/api/pending") => { + state + .last_pending_request + .store(current_time(), Ordering::SeqCst); + + let mut outgoing = state.outgoing_requests.lock().await; + + let requests = Requests::new(&outgoing); + let json = match serde_json::to_string(&requests) { + Ok(j) => j, + Err(e) => { + tracing::error!("Failed to serialize pending requests: {e}"); + send_response(&mut reader, 500, "Internal Server Error", "", "").await; + return; + } + }; + + tracing::debug!("Sending {} pending requests to browser", requests.len()); + + // Clear the outgoing requests after sending them + outgoing.clear(); + + send_response_cors_json(&mut reader, 200, "OK", &json).await; + } + // Receive response from browser extension + ("POST", "/api/response") => { + let mut body_bytes = vec![0u8; content_length]; + if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + + let message: Message = match serde_json::from_slice(&body_bytes) { + Ok(json) => json, + Err(e) => { + tracing::error!("Failed to parse response body: {e}"); + send_response(&mut reader, 400, "Invalid JSON", "", "").await; + return; + } + }; + + tracing::debug!("Received response from browser: {message:?}"); + + let id: Uuid = message.id; + let mut pending = state.pending_responses.lock().await; + + match pending.remove(&id) { + Some(sender) => { + // Use try_send since we already hold the lock + let _ = sender.try_send(message.into_result()); + tracing::info!("Forwarded response for request {id}"); + } + None => tracing::warn!("No pending request found for {id}"), + } + + send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await; + } + // CORS preflight + ("OPTIONS", _) => { + let response = "HTTP/1.1 200 OK\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\ + Access-Control-Allow-Headers: Content-Type\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n"; + let _ = reader.get_mut().write_all(response.as_bytes()).await; + let _ = reader.get_mut().flush().await; + } + // 404 - not found + _ => { + send_response(&mut reader, 404, "Not Found", "", "").await; + } + } +} + +/// Write an HTTP response to the stream. +async fn send_response( + stream: &mut (impl AsyncWriteExt + Unpin), + status: u16, + status_text: &str, + content_type: &str, + body: &str, +) { + let mut response = format!("HTTP/1.1 {status} {status_text}\r\n"); + + if !content_type.is_empty() { + response.push_str(&format!("Content-Type: {content_type}\r\n")); + } + + response.push_str(&format!("Content-Length: {}\r\n", body.len())); + response.push_str("Access-Control-Allow-Origin: *\r\n"); + response.push_str("Connection: close\r\n"); + response.push_str("\r\n"); + response.push_str(body); + + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +/// Write a response with CORS headers and JSON content type. +async fn send_response_cors_json( + stream: &mut (impl AsyncWriteExt + Unpin), + status: u16, + status_text: &str, + body: &str, +) { + let mut response = format!("HTTP/1.1 {status} {status_text}\r\n"); + response.push_str("Content-Type: application/json\r\n"); + response.push_str(&format!("Content-Length: {}\r\n", body.len())); + response.push_str("Access-Control-Allow-Origin: *\r\n"); + response.push_str("Connection: close\r\n"); + response.push_str("\r\n"); + response.push_str(body); + + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +/// Write a response with CORS headers. +async fn send_response_cors( + stream: &mut (impl AsyncWriteExt + Unpin), + status: u16, + status_text: &str, + content_type: &str, + body: &str, +) { + let mut response = format!("HTTP/1.1 {status} {status_text}\r\n"); + + if !content_type.is_empty() { + response.push_str(&format!("Content-Type: {content_type}\r\n")); + } + + response.push_str(&format!("Content-Length: {}\r\n", body.len())); + response.push_str("Access-Control-Allow-Origin: *\r\n"); + response.push_str("Connection: close\r\n"); + response.push_str("\r\n"); + response.push_str(body); + + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +// ── Timeout helper ─────────────────────────────────────────────────────── + +/// An error indicating that an operation timed out. +#[derive(Debug)] +struct TimeoutError; + +/// Races a channel receive against a duration. +/// +/// Returns the channel value on success, or [`TimeoutError`] if the duration +/// elapses first or the channel is closed. +async fn race_timeout( + duration: Duration, + recv: impl Future>, +) -> Result { + enum Event { + Value(T), + ChannelClosed, + Timeout, + } + + let event = smol::future::or( + async { + match recv.await { + Ok(value) => Event::Value(value), + Err(_) => Event::ChannelClosed, + } + }, + async { + smol::Timer::after(duration).await; + Event::Timeout + }, + ) + .await; + + match event { + Event::Value(value) => Ok(value), + Event::ChannelClosed | Event::Timeout => Err(TimeoutError), + } +} + +// ── Utility ────────────────────────────────────────────────────────────── + +/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the +/// time is before the epoch, returns 0. +#[inline] +fn current_time() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} diff --git a/crates/browser-signer-proxy/src/prelude.rs b/crates/browser-signer-proxy/src/prelude.rs new file mode 100644 index 0000000..94f7423 --- /dev/null +++ b/crates/browser-signer-proxy/src/prelude.rs @@ -0,0 +1,14 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! Prelude + +#![allow(unknown_lints)] +#![allow(ambiguous_glob_reexports)] +#![doc(hidden)] + +pub use nostr::prelude::*; + +pub use crate::error::{Error, ErrorKind}; +pub use crate::*; diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 3721ce3..5f1d824 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -12,7 +12,7 @@ nostr-sdk.workspace = true nostr-gossip-memory.workspace = true nostr-blossom.workspace = true nostr-connect.workspace = true -nostr-browser-signer-proxy.workspace = true +browser-signer-proxy = { path = "../browser-signer-proxy" } gpui.workspace = true instant.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 4f7d5f6..62ff7aa 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use anyhow::{Error, anyhow}; +use browser_signer_proxy::prelude::*; use common::config_dir; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; use gpui_tokio::Tokio; use instant::Duration; -use nostr_browser_signer_proxy::prelude::*; use nostr_connect::prelude::*; use nostr_gossip_memory::prelude::*; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 55f00db..d9690fb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -19,7 +19,7 @@ gpui.workspace = true nostr-sdk.workspace = true instant.workspace = true nostr-connect.workspace = true -nostr-browser-signer-proxy.workspace = true +browser-signer-proxy = { path = "../browser-signer-proxy" } anyhow.workspace = true serde.workspace = true