diff --git a/Cargo.lock b/Cargo.lock index 823e5a2..dee9af4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7785,6 +7785,7 @@ dependencies = [ "flume 0.11.1", "gpui", "nostr", + "nostr-connect", "nostr-sdk", "rustls", "signed_core", diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs index 5f3f6ec..19772cc 100644 --- a/crates/signed_core/src/clone_url.rs +++ b/crates/signed_core/src/clone_url.rs @@ -11,7 +11,7 @@ pub enum CloneTarget { UserRepo { /// `npub1...` or a NIP-05 identifier. user: String, - relay_hint: Option, + relay_hint: Option, /// `d` tag identifier of the repository. identifier: String, }, @@ -35,7 +35,10 @@ pub fn parse_clone_url(url: &str) -> Option { } let (relay_hint, identifier) = match third { - Some(id) => (Some(percent_decode(second)), percent_decode(id)), + Some(id) => ( + RelayUrl::parse(&percent_decode(second)).ok(), + percent_decode(id), + ), None => (None, percent_decode(second)), }; @@ -78,8 +81,7 @@ mod tests { assert_eq!( target, CloneTarget::UserRepo { - user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr" - .to_owned(), + user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(), relay_hint: None, identifier: "ngit".to_owned(), } @@ -88,15 +90,12 @@ mod tests { #[test] fn parses_user_repo_with_relay_hint() { - let target = parse_clone_url( - "nostr://danconwaydev.com/relay.ngit.dev/ngit", - ) - .unwrap(); + let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap(); assert_eq!( target, CloneTarget::UserRepo { user: "danconwaydev.com".to_owned(), - relay_hint: Some("relay.ngit.dev".to_owned()), + relay_hint: RelayUrl::parse("relay.ngit.dev").ok(), identifier: "ngit".to_owned(), } ); @@ -112,7 +111,7 @@ mod tests { target, CloneTarget::UserRepo { user: "danconwaydev.com".to_owned(), - relay_hint: Some("ws://localhost:7334".to_owned()), + relay_hint: RelayUrl::parse("ws://localhost:7334").ok(), identifier: "my-local-only-repo".to_owned(), } ); diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index 7a4c38b..1a11220 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -2,12 +2,11 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use nostr_gossip_memory::prelude::*; -use nostr_sdk::prelude::*; - #[cfg(not(target_arch = "wasm32"))] use nostr_lmdb::prelude::*; #[cfg(target_arch = "wasm32")] use nostr_memory::prelude::*; +use nostr_sdk::prelude::*; use crate::signer::UniversalSigner; @@ -51,6 +50,8 @@ impl NostrBackend { .gossip(NostrGossipMemory::unbounded()) .gossip_config(GossipConfig::default().no_background_refresh()) .connect_timeout(Duration::from_secs(10)) + .verify_subscriptions(true) + .ban_relay_on_mismatch(true) .sleep_when_idle(SleepWhenIdle::Enabled { timeout: Duration::from_secs(600), }) @@ -72,6 +73,16 @@ impl NostrBackend { Ok(()) } + /// Add a relay used only for discovery (e.g. NIP-65 indexer relays). + /// No subscriptions or writes are routed through it. + pub async fn add_discovery_relay(&self, url: &str) -> Result<()> { + self.client + .add_relay(url) + .capabilities(RelayCapabilities::DISCOVERY) + .await?; + Ok(()) + } + pub async fn connect(&self) { self.client.connect().await; } diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 31a03ee..aec9e9c 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -10,6 +10,7 @@ signed_nostr = { path = "../signed_nostr" } nostr.workspace = true nostr-sdk.workspace = true +nostr-connect.workspace = true gpui.workspace = true flume.workspace = true diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index e9f2a79..668063a 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -1,12 +1,38 @@ +use std::time::Duration; + use anyhow::{Error, anyhow}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; +use nostr_connect::prelude::*; use nostr_sdk::prelude::*; -use signed_nostr::{NostrBackend, UniversalSigner, Update}; +use signed_core::filters; +use signed_nostr::{NostrBackend, SignedAuthUrlHandler, UniversalSigner, Update}; + +/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`). +pub const USER_KEYRING: &str = "su.reya.signed#user"; +/// Keyring entry holding the locally generated key for NIP-46 sessions. +pub const MASTER_KEYRING: &str = "su.reya.signed#master"; +/// Timeout for NIP-46 signer responses. +pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; + +/// Relays connected at startup, before any user-specific relay config is known. +pub const BOOTSTRAP_RELAYS: [&str; 4] = [ + "wss://relay.primal.net", + "wss://relay.ditto.pub", + "wss://index.ngit.dev", + "wss://profiles.nostr1.com", +]; + +/// Relays used for indexing user's relay list (NIP-65). +pub const INDEXER_RELAYS: [&str; 3] = [ + "wss://indexer.coracle.social", + "wss://purplepag.es", + "wss://user.kindpag.es", +]; #[derive(Debug, Clone)] pub enum BackendEvent { /// User has no signer configured. - NoSigner, + SignerRequired, /// The signer has changed (login/logout/account switch). SignerChanged, /// Relay bootstrap finished. @@ -77,11 +103,257 @@ impl Backend { Ok(()) }); - Self { + let mut this = Self { inner, current_user: None, tasks: vec![pump], + }; + + this.bootstrap(cx); + this + } + + /// Bootstrap the client: connect to the default relays (indexers as + /// discovery-only) and restore the saved session, if any. + fn bootstrap(&mut self, cx: &mut Context) { + let backend = self.inner.clone(); + + let task = cx.background_spawn(async move { + for url in BOOTSTRAP_RELAYS { + backend.add_relay(url).await?; + } + for url in INDEXER_RELAYS { + backend.add_discovery_relay(url).await?; + } + backend.connect().await; + Ok::<(), Error>(()) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(()) => { + this.update(cx, |_this, cx| cx.emit(BackendEvent::Connected))?; + } + Err(e) => { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + } + Ok(()) + })); + + self.restore_session(cx); + } + + /// Restore the saved session from the keyring. Emits + /// [`BackendEvent::SignerRequired`] if no credential is stored. + pub fn restore_session(&mut self, cx: &mut Context) { + if cfg!(target_arch = "wasm32") { + cx.emit(BackendEvent::SignerRequired); + return; } + + let user = cx.read_credentials(USER_KEYRING); + let master = self.master_key(cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + let content = match user.await { + Ok(Some((_username, secret))) => String::from_utf8(secret)?, + _ => { + this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + return Ok(()); + } + }; + + let result = async { + if content.starts_with("nsec1") { + let keys = Keys::new(SecretKey::parse(&content)?); + this.update(cx, |this, cx| this.set_signer(keys, cx))?; + } else if content.starts_with("bunker://") { + let uri = NostrConnectUri::parse(&content)?; + let mut signer = NostrConnect::new( + uri, + master.await, + Duration::from_secs(NOSTR_CONNECT_TIMEOUT), + None, + )?; + signer.auth_url_handler(SignedAuthUrlHandler); + this.update(cx, |this, cx| this.set_signer(signer, cx))?; + } else { + this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; + } + + Ok::<_, Error>(()) + } + .await; + + if let Err(e) = result { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::error(e.to_string())); + cx.emit(BackendEvent::SignerRequired); + })?; + } + + Ok(()) + })); + } + + /// Login with an `nsec1...` secret key. The credential is verified by + /// the signer flow and persisted in the keyring. + pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { + let nsec = nsec.trim().to_owned(); + + let keys = match SecretKey::parse(&nsec) { + Ok(secret) => Keys::new(secret), + Err(e) => { + cx.emit(BackendEvent::error(e.to_string())); + return; + } + }; + + let write = + cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes()); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = write.await { + this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + return Ok(()); + } + + this.update(cx, |this, cx| this.set_signer(keys, cx))?; + Ok(()) + })); + } + + /// Login with a `bunker://...` URI (NIP-46). The auth URL, if any, is + /// opened in the default browser. The credential is persisted in the + /// keyring after the signer proves reachable. + pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { + let uri_string = uri.trim().to_owned(); + + let connect_uri = match NostrConnectUri::parse(&uri_string) { + Ok(uri) => uri, + Err(e) => { + cx.emit(BackendEvent::error(e.to_string())); + return; + } + }; + + let master = self.master_key(cx); + let write = cx.write_credentials(USER_KEYRING, "bunker", uri_string.as_bytes()); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = async { + let mut signer = NostrConnect::new( + connect_uri, + master.await, + Duration::from_secs(NOSTR_CONNECT_TIMEOUT), + None, + )?; + signer.auth_url_handler(SignedAuthUrlHandler); + + // Verify the signer before persisting the credential. + signer.get_public_key_async().await?; + write.await?; + + this.update(cx, |this, cx| this.set_signer(signer, cx))?; + + Ok::<_, Error>(()) + } + .await; + + if let Err(e) = result { + this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + + Ok(()) + })); + } + + /// Remove the saved credential and reset to an anonymous session. + pub fn logout(&mut self, cx: &mut Context) { + let delete = cx.delete_credentials(USER_KEYRING); + + self.tasks.push(cx.spawn(async move |this, cx| { + delete.await.ok(); + + this.update(cx, |this, cx| { + this.inner.signer().swap_inner(Keys::generate()); + this.current_user = None; + cx.emit(BackendEvent::SignerChanged); + cx.emit(BackendEvent::SignerRequired); + cx.notify(); + })?; + + Ok(()) + })); + } + + /// Get (or generate and persist) the key used for NIP-46 sessions. + fn master_key(&self, cx: &App) -> Task { + let task = cx.read_credentials(MASTER_KEYRING); + + cx.spawn(async move |cx| { + let (keys, new_key) = match task.await { + Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) { + Ok(secret_key) => (Keys::new(secret_key), false), + _ => (Keys::generate(), true), + }, + _ => (Keys::generate(), true), + }; + + if new_key { + let username = keys.public_key().to_hex(); + let password = keys.secret_key().to_secret_bytes(); + + cx.update(|cx| { + let task = cx.write_credentials(MASTER_KEYRING, &username, &password); + cx.background_spawn(async move { task.await.ok() }).detach(); + }); + } + + keys + }) + } + + /// Fetch the user's grasp list (kind `10317`) and add the listed grasp + /// servers as relays. + fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { + let backend = self.inner.clone(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = async { + let events = backend + .client() + .fetch_events(filters::grasp_list(public_key)) + .await?; + + let urls: Vec = events + .into_iter() + .max_by_key(|e| e.created_at) + .map(|e| { + e.tags + .iter() + .filter(|t| t.kind() == "g") + .filter_map(|t| t.content().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + + for url in urls { + backend.add_relay(&url).await.ok(); + } + backend.connect().await; + + Ok::<_, Error>(()) + } + .await; + + if let Err(e) = result { + this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + + Ok(()) + })); } /// Get the nostr client. @@ -114,6 +386,7 @@ impl Backend { this.update(cx, |this, cx| { this.inner.signer().swap_inner(new_signer); this.current_user = Some(public_key); + this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); })?; @@ -155,6 +428,27 @@ impl Backend { })); } + /// Add relays used only for discovery (e.g. NIP-65 indexers) and + /// connect to them. No subscriptions or writes are routed through them. + pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { + let backend = self.inner.clone(); + + let task = cx.background_spawn(async move { + for url in urls { + backend.add_discovery_relay(&url).await?; + } + backend.connect().await; + Ok::<(), Error>(()) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = task.await { + this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; + } + Ok(()) + })); + } + /// Start a persistent subscription. Matching events are stored in the /// database automatically and surface as [`BackendEvent::NostrUpdate`]. pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) {