diff --git a/Cargo.lock b/Cargo.lock index 182111c..13d00cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" +[[package]] +name = "atomic-destructor" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -839,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" @@ -4206,7 +4227,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "aes", "base64", @@ -4231,7 +4252,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "base64", "bitcoin_hashes", @@ -4244,7 +4265,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "async-utility", "futures-core", @@ -4258,7 +4279,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "btreecap", "flatbuffers", @@ -4269,7 +4290,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "nostr", "opaquerr", @@ -4278,7 +4299,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "indexmap", "lru", @@ -4290,7 +4311,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "async-utility", "flume 0.12.0", @@ -4304,7 +4325,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "btreecap", "nostr", @@ -4315,7 +4336,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.0-alpha.8" -source = "git+https://github.com/rust-nostr/nostr#83cb556fa59259eb30f3df63f153f42aab77519b" +source = "git+https://github.com/rust-nostr/nostr#d0a1d67d3c9e5cf9710807a6a414c155a5f47215" dependencies = [ "async-utility", "async-wsocket", @@ -6682,6 +6703,7 @@ name = "state" version = "1.0.0-beta5" dependencies = [ "anyhow", + "browser-signer-proxy", "common", "flume 0.11.1", "futures", @@ -9003,6 +9025,7 @@ name = "workspace" version = "1.0.0-beta5" dependencies = [ "anyhow", + "browser-signer-proxy", "chat", "chat_ui", "common", 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..1b5c3a2 --- /dev/null +++ b/crates/browser-signer-proxy/index.html @@ -0,0 +1,189 @@ + + + + + + Coop — Web Signer Proxy + + + + + + +
+ + +

Web Signer

+

+ This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely. +

+ +
+
+ Checking extension… +
+ +
+ Keep this tab open while using the app — it automatically handles sign-in requests in the background. +
+
+ + + + diff --git a/crates/browser-signer-proxy/proxy.js b/crates/browser-signer-proxy/proxy.js new file mode 100644 index 0000000..f04465b --- /dev/null +++ b/crates/browser-signer-proxy/proxy.js @@ -0,0 +1,156 @@ +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, state) { + const container = document.getElementById('nip07-status'); + const textEl = document.getElementById('nip07-status-text'); + if (container && textEl) { + container.className = 'status status--' + state; + textEl.textContent = message; + } +} + +// 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 — ready', 'connected'); + } else { + console.log('NIP-07 extension not found'); + updateStatus('No NIP-07 extension 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 273dbe2..fee518b 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -28,6 +28,7 @@ mime_guess = "2.0.4" nostr-memory.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +browser-signer-proxy = { path = "../browser-signer-proxy" } nostr-lmdb.workspace = true smol.workspace = true gpui_tokio.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 210356a..23fa04a 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,8 +1,11 @@ use std::collections::HashMap; use anyhow::{Error, anyhow}; +#[cfg(not(target_arch = "wasm32"))] +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_connect::prelude::*; use nostr_gossip_memory::prelude::*; @@ -262,6 +265,11 @@ impl NostrRegistry { this.set_signer(signer, cx); cx.notify(); })?; + } else if content == "proxy" { + #[cfg(not(target_arch = "wasm32"))] + this.update(cx, |this, cx| { + this.connect_proxy(cx); + })?; } } _ => { @@ -307,6 +315,81 @@ impl NostrRegistry { }) } + /// Start the browser proxy + #[cfg(not(target_arch = "wasm32"))] + pub fn connect_proxy(&mut self, cx: &mut Context) { + let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default()); + let (tx, rx) = flume::bounded::(1); + + self.tasks.push(Tokio::spawn_result(cx, { + let proxy = proxy.clone(); + async move { + // Start the proxy and get the web url + proxy.start().await?; + // Notify GPUI + let url = proxy.url(); + tx.send(url).ok(); + Ok(()) + } + })); + + self.tasks.push(Tokio::spawn_result(cx, { + let proxy = proxy.clone(); + async move { + loop { + if proxy.is_session_active() { + break; + } + smol::Timer::after(Duration::from_secs(1)).await; + } + Ok(()) + } + })); + + self.tasks.push(cx.spawn({ + let proxy = proxy.clone(); + async move |this, cx| { + while let Ok(url) = rx.recv_async().await { + this.update(cx, |this, cx| { + let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy"); + cx.background_spawn(async move { save.await.ok() }).detach(); + cx.open_url(&url); + this.set_signer(proxy.clone(), cx); + })?; + } + Ok(()) + } + })); + + // Monitor the session, if the browser disconnects, notify user to reconnect + self.tasks.push(cx.spawn({ + let proxy = proxy.clone(); + let executor = cx.background_executor().clone(); + async move |this, cx| { + // Wait for the signer to be confirmed (timeout is 30s) + executor.timer(Duration::from_secs(30)).await; + + loop { + executor.timer(Duration::from_secs(5)).await; + if !proxy.is_session_active() { + _ = this.update(cx, |this, cx| { + // Only notify if this proxy is still the active signer + if this.current_user.is_some() { + this.signer.swap_inner(Keys::generate()); + this.current_user = None; + cx.emit(StateEvent::NoSigner); + cx.notify(); + } + }); + break; + } + } + + Ok(()) + } + })); + } + /// Get the public key of a NIP-05 address pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task> { let client = self.client(); diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 45d04b6..92c4246 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -14,7 +14,7 @@ pub fn v_flex() -> Div { /// Returns a `Div` as divider. pub fn divider(cx: &App) -> Div { - div().my_2().w_full().h_px().bg(cx.theme().border_variant) + div().my_1().w_full().h_px().bg(cx.theme().border_variant) } macro_rules! font_weight { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 61bb5c6..d9690fb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -19,6 +19,7 @@ gpui.workspace = true nostr-sdk.workspace = true instant.workspace = true nostr-connect.workspace = true +browser-signer-proxy = { path = "../browser-signer-proxy" } anyhow.workspace = true serde.workspace = true diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index 06c64e2..cb41d76 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{Input, InputEvent, InputState}; -use ui::{Disableable, StyledExt, WindowExtension, v_flex}; +use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex}; #[derive(Debug)] pub struct ImportIdentity { @@ -164,6 +164,14 @@ impl ImportIdentity { })); } + #[cfg(not(target_arch = "wasm32"))] + fn proxy(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + nostr.update(cx, |this, cx| { + this.connect_proxy(cx); + }); + } + fn set_loading(&mut self, status: bool, cx: &mut Context) { self.loading = status; cx.notify(); @@ -199,10 +207,13 @@ impl ImportIdentity { impl Render for ImportIdentity { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login."; + const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages."; + const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login."; + let is_wasm = cfg!(target_arch = "wasm32"); let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1"); let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password; + let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://"); v_flex() .size_full() @@ -227,13 +238,20 @@ impl Render for ImportIdentity { .child(Input::new(&self.pass_input)), ) }) + .when(bunker_warning, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_warning) + .child(div().child(BUNKER_WARN)), + ) + }) .when(key_warning, |this| { this.child( div() .text_xs() .text_color(cx.theme().text_warning) - .child(div().font_semibold().child("Warning")) - .child(div().child(MSG)), + .child(div().child(KEY_WARN)), ) }), ) @@ -248,6 +266,19 @@ impl Render for ImportIdentity { this.login(window, cx); })), ) + .child(divider(cx)) + .when(!is_wasm, |this| { + this.child( + Button::new("proxy") + .label("Connect via Web Extension (Experimental)") + .ghost_alt() + .loading(self.loading) + .disabled(self.loading) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.proxy(cx); + })), + ) + }) .when_some(self.error.read(cx).as_ref(), |this, error| { this.child( div()