diff --git a/crates/coop/src/dialogs/accounts.rs b/crates/coop/src/dialogs/accounts.rs index 49748f3..c780046 100644 --- a/crates/coop/src/dialogs/accounts.rs +++ b/crates/coop/src/dialogs/accounts.rs @@ -91,7 +91,7 @@ impl AccountSelector { fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); - let task = nostr.read(cx).get_signer(&public_key, cx); + let task = nostr.read(cx).get_secret(public_key, cx); // Mark the public key as being logged in self.set_logging_in(public_key, cx); @@ -117,7 +117,7 @@ impl AccountSelector { let nostr = NostrRegistry::global(cx); nostr.update(cx, |this, cx| { - this.remove_signer(&public_key, cx); + this.remove_secret(&public_key, cx); }); } diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 155e673..d966015 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -79,16 +80,19 @@ pub struct NostrRegistry { /// Nostr signer signer: Arc, - /// Local public keys + /// All local stored identities npubs: Entity>, - /// App keys + /// Keys directory + key_dir: PathBuf, + + /// Master app keys used for various operations. /// - /// Used for Nostr Connect and NIP-4e operations + /// Example: Nostr Connect and NIP-4e operations app_keys: Keys, /// Tasks for asynchronous operations - tasks: Vec>, + tasks: Vec>>, } impl EventEmitter for NostrRegistry {} @@ -106,12 +110,20 @@ impl NostrRegistry { /// Create a new nostr instance fn new(window: &mut Window, cx: &mut Context) -> Self { + let key_dir = config_dir().join("keys"); + let app_keys = get_or_init_app_keys(cx).unwrap_or(Keys::generate()); + // Construct the nostr signer - let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate()); let signer = Arc::new(CoopSigner::new(app_keys.clone())); - // Construct the nostr npubs entity - let npubs = cx.new(|_| vec![]); + // Get all local stored npubs + let npubs = cx.new(|_| match Self::discover(&key_dir) { + Ok(npubs) => npubs, + Err(e) => { + log::error!("Failed to discover npubs: {e}"); + vec![] + } + }); // Construct the nostr lmdb instance let lmdb = cx.foreground_executor().block_on(async move { @@ -148,6 +160,7 @@ impl NostrRegistry { client, signer, npubs, + key_dir, app_keys, tasks: vec![], } @@ -173,6 +186,33 @@ impl NostrRegistry { self.app_keys.clone() } + /// Discover all npubs in the keys directory + fn discover(dir: &PathBuf) -> Result, Error> { + // Ensure keys directory exists + std::fs::create_dir_all(dir)?; + + let files = std::fs::read_dir(dir)?; + let mut entries = Vec::new(); + let mut npubs: Vec = Vec::new(); + + for file in files.flatten() { + let metadata = file.metadata()?; + let modified_time = metadata.modified()?; + let name = file.file_name().into_string().unwrap().replace(".npub", ""); + entries.push((modified_time, name)); + } + + // Sort by modification time (most recent first) + entries.sort_by(|a, b| b.0.cmp(&a.0)); + + for (_, name) in entries { + let public_key = PublicKey::parse(&name)?; + npubs.push(public_key); + } + + Ok(npubs) + } + /// Connect to the bootstrapping relays fn connect(&mut self, cx: &mut Context) { let client = self.client(); @@ -211,105 +251,139 @@ impl NostrRegistry { // Emit connecting event cx.emit(StateEvent::Connecting); - self.tasks - .push(cx.spawn(async move |this, cx| match task.await { - Ok(_) => { - this.update(cx, |this, cx| { - cx.emit(StateEvent::Connected); - this.get_npubs(cx); - }) - .ok(); - } - Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); - } - })); - } - - /// Get all used npubs - fn get_npubs(&mut self, cx: &mut Context) { - let npubs = self.npubs.downgrade(); - - let task: Task, Error>> = cx.background_spawn(async move { - let dir = config_dir().join("keys"); - // Ensure keys directory exists - smol::fs::create_dir_all(&dir).await?; - - let mut files = smol::fs::read_dir(&dir).await?; - let mut entries = Vec::new(); - - while let Some(Ok(entry)) = files.next().await { - let metadata = entry.metadata().await?; - let modified_time = metadata.modified()?; - let name = entry - .file_name() - .into_string() - .unwrap() - .replace(".npub", ""); - - entries.push((modified_time, name)); - } - - // Sort by modification time (most recent first) - entries.sort_by(|a, b| b.0.cmp(&a.0)); - - let mut npubs = Vec::new(); - - for (_, name) in entries { - let public_key = PublicKey::parse(&name)?; - npubs.push(public_key); - } - - Ok(npubs) - }); - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(public_keys) => match public_keys.is_empty() { - true => { - this.update(cx, |this, cx| { - this.create_identity(cx); - }) - .ok(); - } - false => { - // TODO: auto login - npubs - .update(cx, |this, cx| { - this.extend(public_keys); - cx.notify(); - }) - .ok(); - } - }, - Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); - } + if let Err(e) = task.await { + this.update(cx, |_this, cx| { + cx.emit(StateEvent::error(e.to_string())); + })?; + } else { + this.update(cx, |_this, cx| { + cx.emit(StateEvent::Connected); + })?; } + + Ok(()) })); } + /// Get the secret for a given npub. + pub fn get_secret( + &self, + public_key: PublicKey, + cx: &App, + ) -> Task, Error>> { + let npub = public_key.to_bech32().unwrap(); + let key_path = self.key_dir.join(format!("{}.npub", npub)); + let app_keys = self.app_keys.clone(); + + if let Ok(payload) = std::fs::read_to_string(key_path) { + cx.background_spawn(async move { + let decrypted = app_keys.nip44_decrypt(&public_key, &payload).await?; + let secret = SecretKey::parse(&decrypted)?; + let keys = Keys::new(secret); + + Ok(keys.into_nostr_signer()) + }) + } else { + self.get_secret_keyring(&npub, cx) + } + } + + /// Get the secret for a given npub in the OS credentials store. + #[deprecated = "Use get_secret instead"] + fn get_secret_keyring( + &self, + user: &str, + cx: &App, + ) -> Task, Error>> { + let read = cx.read_credentials(user); + let app_keys = self.app_keys.clone(); + + cx.background_spawn(async move { + let (_, secret) = read + .await + .map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))? + .ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?; + + // Try to parse as a direct secret key first + if let Ok(secret_key) = SecretKey::from_slice(&secret) { + return Ok(Keys::new(secret_key).into_nostr_signer()); + } + + // Convert the secret into string + let sec = String::from_utf8(secret) + .map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?; + + // Try to parse as a NIP-46 URI + let uri = + NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; + + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; + + // Set the auth URL handler + nip46.auth_url_handler(CoopAuthUrlHandler); + + Ok(nip46.into_nostr_signer()) + }) + } + + /// Add a new npub to the keys directory + fn write_secret( + &self, + public_key: PublicKey, + secret: String, + cx: &App, + ) -> Task> { + let npub = public_key.to_bech32().unwrap(); + let key_path = self.key_dir.join(format!("{}.npub", npub)); + let app_keys = self.app_keys.clone(); + + cx.background_spawn(async move { + // If the secret starts with "bunker://" (nostr connect), use it directly; otherwise, encrypt it + let content = if secret.starts_with("bunker://") { + secret + } else { + app_keys.nip44_encrypt(&public_key, &secret).await? + }; + + // Write the encrypted secret to the keys directory + smol::fs::write(key_path, &content).await?; + + Ok(()) + }) + } + + /// Remove a secret + pub fn remove_secret(&mut self, public_key: &PublicKey, cx: &mut Context) { + let public_key = public_key.to_owned(); + let npub = public_key.to_bech32().unwrap(); + + let keys_dir = config_dir().join("keys"); + let key_path = keys_dir.join(format!("{}.npub", npub)); + + // Remove the secret file from the keys directory + std::fs::remove_file(key_path).ok(); + + self.npubs.update(cx, |this, cx| { + this.retain(|k| k != &public_key); + cx.notify(); + }); + } + /// Create a new identity - fn create_identity(&mut self, cx: &mut Context) { + pub fn create_identity(&mut self, cx: &mut Context) { let client = self.client(); let keys = Keys::generate(); let async_keys = keys.clone(); - let username = keys.public_key().to_bech32().unwrap(); - let secret = keys.secret_key().to_secret_bytes(); - - // Create a write credential task - let write_credential = cx.write_credentials(&username, &username, &secret); - // Emit creating event cx.emit(StateEvent::Creating); + // Create the write secret task + let write_secret = + self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx); + // Run async tasks in background let task: Task> = cx.background_spawn(async move { let signer = async_keys.into_nostr_signer(); @@ -361,7 +435,7 @@ impl NostrRegistry { .await?; // Write user's credentials to the system keyring - write_credential.await?; + write_secret.await?; Ok(()) }); @@ -371,58 +445,19 @@ impl NostrRegistry { Ok(_) => { this.update(cx, |this, cx| { this.set_signer(keys, cx); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; + + Ok(()) })); } - /// Get the signer in keyring by username - pub fn get_signer( - &self, - public_key: &PublicKey, - cx: &App, - ) -> Task, Error>> { - let username = public_key.to_bech32().unwrap(); - let app_keys = self.app_keys.clone(); - let read_credential = cx.read_credentials(&username); - - cx.spawn(async move |_cx| { - let (_, secret) = read_credential - .await - .map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))? - .ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?; - - // Try to parse as a direct secret key first - if let Ok(secret_key) = SecretKey::from_slice(&secret) { - return Ok(Keys::new(secret_key).into_nostr_signer()); - } - - // Convert the secret into string - let sec = String::from_utf8(secret) - .map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?; - - // Try to parse as a NIP-46 URI - let uri = - NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; - - let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); - let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; - - // Set the auth URL handler - nip46.auth_url_handler(CoopAuthUrlHandler); - - Ok(nip46.into_nostr_signer()) - }) - } - /// Set the signer for the nostr client and verify the public key pub fn set_signer(&mut self, new: T, cx: &mut Context) where @@ -430,6 +465,7 @@ impl NostrRegistry { { let client = self.client(); let signer = self.signer(); + let key_dir = self.key_dir.clone(); // Create a task to update the signer and verify the public key let task: Task> = cx.background_spawn(async move { @@ -441,15 +477,6 @@ impl NostrRegistry { let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; - let npub = public_key.to_bech32().unwrap(); - let keys_dir = config_dir().join("keys"); - - // Ensure keys directory exists - smol::fs::create_dir_all(&keys_dir).await?; - - let key_path = keys_dir.join(format!("{}.npub", npub)); - smol::fs::write(key_path, "").await?; - log::info!("Signer's public key: {}", public_key); Ok(public_key) }); @@ -457,9 +484,7 @@ impl NostrRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(public_key) => { - // Update states this.update(cx, |this, cx| { - this.ensure_relay_list(&public_key, cx); // Add public key to npubs if not already present this.npubs.update(cx, |this, cx| { if !this.contains(&public_key) { @@ -467,65 +492,43 @@ impl NostrRegistry { cx.notify(); } }); + // Emit signer changed event cx.emit(StateEvent::SignerSet); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; - })); - } - /// Remove a signer from the keyring - pub fn remove_signer(&mut self, public_key: &PublicKey, cx: &mut Context) { - let public_key = public_key.to_owned(); - let npub = public_key.to_bech32().unwrap(); - let keys_dir = config_dir().join("keys"); - - self.tasks.push(cx.spawn(async move |this, cx| { - let key_path = keys_dir.join(format!("{}.npub", npub)); - smol::fs::remove_file(key_path).await.ok(); - - this.update(cx, |this, cx| { - this.npubs().update(cx, |this, cx| { - this.retain(|k| k != &public_key); - cx.notify(); - }); - }) - .ok(); + Ok(()) })); } /// Add a key signer to keyring pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context) { let keys = keys.clone(); - let username = keys.public_key().to_bech32().unwrap(); - let secret = keys.secret_key().to_secret_bytes(); - - // Write the credential to the keyring - let write_credential = cx.write_credentials(&username, "keys", &secret); + let write_secret = + self.write_secret(keys.public_key(), keys.secret_key().to_secret_hex(), cx); self.tasks.push(cx.spawn(async move |this, cx| { - match write_credential.await { + match write_secret.await { Ok(_) => { this.update(cx, |this, cx| { this.set_signer(keys, cx); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; + + Ok(()) })); } @@ -546,39 +549,32 @@ impl NostrRegistry { self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok((public_key, uri)) => { - let username = public_key.to_bech32().unwrap(); - let write_credential = this - .read_with(cx, |_this, cx| { - cx.write_credentials( - &username, - "nostrconnect", - uri.to_string().as_bytes(), - ) - }) - .unwrap(); + // Create the write secret task + let write_secret = this.read_with(cx, |this, cx| { + this.write_secret(public_key, uri.to_string(), cx) + })?; - match write_credential.await { + match write_secret.await { Ok(_) => { this.update(cx, |this, cx| { this.set_signer(nip46, cx); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } } } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; + + Ok(()) })); } @@ -594,17 +590,17 @@ impl NostrRegistry { Ok(event) => { this.update(cx, |this, cx| { this.ensure_connection(&event, cx); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::RelayNotConfigured); cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; + + Ok(()) })); } @@ -653,17 +649,17 @@ impl NostrRegistry { Ok(_) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::RelayConnected); - }) - .ok(); + })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(StateEvent::RelayNotConfigured); cx.emit(StateEvent::error(e.to_string())); - }) - .ok(); + })?; } }; + + Ok(()) })); } @@ -849,29 +845,33 @@ impl NostrRegistry { } } -/// Get or create a new app keys -fn get_or_init_app_keys() -> Result { - let dir = config_dir().join(".app_keys"); - - let content = match std::fs::read(&dir) { - Ok(content) => content, - Err(_) => { - // Generate new keys if file doesn't exist - let keys = Keys::generate(); - let secret_key = keys.secret_key(); - - // Create directory and write secret key - std::fs::create_dir_all(dir.parent().unwrap())?; - std::fs::write(&dir, secret_key.to_secret_bytes())?; - - return Ok(keys); +/// Get or create new app keys +fn get_or_init_app_keys(cx: &App) -> Result { + let read = cx.read_credentials(CLIENT_NAME); + let stored_keys: Option = cx.foreground_executor().block_on(async move { + if let Ok(Some((_, secret))) = read.await { + SecretKey::from_slice(&secret).map(Keys::new).ok() + } else { + None } - }; + }); - let secret_key = SecretKey::from_slice(&content)?; - let keys = Keys::new(secret_key); + if let Some(keys) = stored_keys { + Ok(keys) + } else { + let keys = Keys::generate(); + let user = keys.public_key().to_hex(); + let secret = keys.secret_key().to_secret_bytes(); + let write = cx.write_credentials(CLIENT_NAME, &user, &secret); - Ok(keys) + cx.foreground_executor().block_on(async move { + if let Err(e) = write.await { + log::error!("Keyring not available or panic: {e}") + } + }); + + Ok(keys) + } } fn default_relay_list() -> Vec<(RelayUrl, Option)> { diff --git a/crates/state/src/npubs.rs b/crates/state/src/npubs.rs new file mode 100644 index 0000000..13dd899 --- /dev/null +++ b/crates/state/src/npubs.rs @@ -0,0 +1,141 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Error, anyhow}; +use common::config_dir; +use gpui::{App, Context}; +use nostr_connect::prelude::*; + +use crate::{CLIENT_NAME, NOSTR_CONNECT_TIMEOUT}; + +#[derive(Debug)] +pub struct NostrRing { + /// Keys directory + dir: PathBuf, + + /// Master app keys used for various operations. + /// + /// Example: Nostr Connect and NIP-4e operations + app_keys: Keys, + + /// All local stored identities + npubs: Vec, +} + +impl NostrRing { + pub fn new(cx: &mut Context) -> Self { + let dir = config_dir().join("keys"); + let app_keys = get_or_init_app_keys(cx).unwrap_or(Keys::generate()); + + // Get all local stored npubs + let npubs = match Self::discover(&dir) { + Ok(npubs) => npubs, + Err(e) => { + log::error!("Failed to discover npubs: {e}"); + vec![] + } + }; + + Self { + dir, + app_keys, + npubs, + } + } + + /// Get the secret for a given npub, if it exists + fn get_secret(&self, public_key: &PublicKey, cx: &App) -> Result, Error> { + let npub = public_key.to_bech32()?; + let key_path = self.dir.join(format!("{}.npub", npub)); + + if let Ok(secret) = std::fs::read_to_string(key_path) { + let secret = SecretKey::parse(&secret)?; + let keys = Keys::new(secret); + + Ok(keys.into_nostr_signer()) + } else { + self.get_secret_keyring(&npub, cx) + } + } + + /// Get the secret for a given npub in the os credentials store + #[deprecated = "Use get_secret instead"] + fn get_secret_keyring(&self, user: &str, cx: &App) -> Result, Error> { + let read = cx.read_credentials(user); + let app_keys = self.app_keys.clone(); + + cx.foreground_executor().block_on(async move { + let (_, secret) = read + .await + .map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))? + .ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?; + + // Try to parse as a direct secret key first + if let Ok(secret_key) = SecretKey::from_slice(&secret) { + return Ok(Keys::new(secret_key).into_nostr_signer()); + } + + // Convert the secret into string + let sec = String::from_utf8(secret) + .map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?; + + // Try to parse as a NIP-46 URI + let uri = + NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; + + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; + + // Set the auth URL handler + nip46.auth_url_handler(CoopAuthUrlHandler); + + Ok(nip46.into_nostr_signer()) + }) + } + + /// Add a new npub to the keys directory + fn add(&mut self, public_key: PublicKey, secret: &str) -> Result<(), Error> { + let npub = public_key.to_bech32()?; + let key_path = self.dir.join(format!("{}.npub", npub)); + std::fs::write(key_path, secret)?; + + Ok(()) + } + + /// Remove a npub from the keys directory + fn remove(&self, public_key: &PublicKey) -> Result<(), Error> { + let npub = public_key.to_bech32()?; + let key_path = self.dir.join(format!("{}.npub", npub)); + std::fs::remove_file(key_path)?; + + Ok(()) + } + + /// Discover all npubs in the keys directory + fn discover(dir: &PathBuf) -> Result, Error> { + // Ensure keys directory exists + std::fs::create_dir_all(dir)?; + + let files = std::fs::read_dir(dir)?; + let mut entries = Vec::new(); + let mut npubs: Vec = Vec::new(); + + for file in files.flatten() { + let metadata = file.metadata()?; + let modified_time = metadata.modified()?; + let name = file.file_name().into_string().unwrap().replace(".npub", ""); + entries.push((modified_time, name)); + } + + // Sort by modification time (most recent first) + entries.sort_by(|a, b| b.0.cmp(&a.0)); + + for (_, name) in entries { + let public_key = PublicKey::parse(&name)?; + npubs.push(public_key); + } + + Ok(npubs) + } +} diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index 262e611..c6e9b20 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -42,7 +42,7 @@ impl CoopSigner { /// Get public key /// /// Ensure to call this method after the signer has been initialized. - /// Otherwise, this method will panic. + /// Otherwise, it will panic. pub fn public_key(&self) -> Option { *self.signer_pkey.read_blocking() }