diff --git a/mutiny-core/src/blindauth.rs b/mutiny-core/src/blindauth.rs index d8bfcb87e..e69638dd6 100644 --- a/mutiny-core/src/blindauth.rs +++ b/mutiny-core/src/blindauth.rs @@ -259,6 +259,13 @@ impl BlindAuthClient { Ok(()) } + + pub fn get_unblinded_info_from_token( + &self, + token: &SignedToken, + ) -> (fedimint_mint_client::Nonce, BlindingKey) { + generate_nonce(&self.secret, token.service_id, token.plan_id, token.counter) + } } async fn get_available_tokens( @@ -301,6 +308,25 @@ async fn derive_blind_token( plan_id: u32, counter: u32, ) -> Result { + let (nonce, blinding_key) = generate_nonce(secret, service_id, plan_id, counter); + let blinded_message = blind_message(nonce.to_message(), blinding_key); + + let unsigned_token = UnsignedToken { + counter, + service_id, + plan_id, + blinded_message, + }; + + Ok(unsigned_token) +} + +fn generate_nonce( + secret: &DerivableSecret, + service_id: u32, + plan_id: u32, + counter: u32, +) -> (fedimint_mint_client::Nonce, BlindingKey) { let child_secret = secret .child_key(SERVICE_REGISTRATION_CHILD_ID) .child_key(ChildId(service_id.into())) @@ -312,21 +338,13 @@ async fn derive_blind_token( .to_secp_key(fedimint_ln_common::bitcoin::secp256k1::SECP256K1); let nonce = fedimint_mint_client::Nonce(spend_key.public_key()); + let blinding_key = BlindingKey( child_secret .child_key(BLINDING_KEY_CHILD_ID) .to_bls12_381_key(), ); - let blinded_message = blind_message(nonce.to_message(), blinding_key); - - let signed_token = UnsignedToken { - counter, - service_id, - plan_id, - blinded_message, - }; - - Ok(signed_token) + (nonce, blinding_key) } // Creates the root derivation secret for the blind auth client: diff --git a/mutiny-core/src/error.rs b/mutiny-core/src/error.rs index 50b1eee6e..02b94eb4e 100644 --- a/mutiny-core/src/error.rs +++ b/mutiny-core/src/error.rs @@ -171,6 +171,9 @@ pub enum MutinyError { /// Token already spent. #[error("Token has been already spent.")] TokenAlreadySpent, + /// Federation required. + #[error("A federation is required")] + FederationRequired, #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/mutiny-core/src/hermes.rs b/mutiny-core/src/hermes.rs new file mode 100644 index 000000000..e8bc64808 --- /dev/null +++ b/mutiny-core/src/hermes.rs @@ -0,0 +1,304 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +use async_lock::RwLock; +use bitcoin::{bip32::ExtendedPrivKey, secp256k1::Secp256k1}; +use fedimint_core::config::FederationId; +use futures::{pin_mut, select, FutureExt}; +use lightning::util::logger::Logger; +use lightning::{log_error, log_warn}; +use nostr::{nips::nip04::decrypt, Keys}; +use nostr::{Filter, Kind, Timestamp}; +use nostr_sdk::{Client, NostrSigner, RelayPoolNotification}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use tbs::unblind_signature; +use url::Url; + +use crate::{ + blindauth::{BlindAuthClient, SignedToken}, + error::MutinyError, + federation::{FederationClient, FederationIdentity}, + logging::MutinyLogger, + nostr::{derive_nostr_key, HERMES_CHAIN_INDEX, SERVICE_ACCOUNT_INDEX}, + storage::MutinyStorage, + utils, +}; + +const HERMES_SERVICE_ID: u32 = 1; +const HERMES_FREE_PLAN_ID: u32 = 1; +const HERMES_PAID_PLAN_ID: u32 = 2; + +#[derive(Deserialize, Serialize)] +pub struct RegisterRequest { + pub name: Option, + pub pubkey: String, + pub federation_id: FederationId, + pub federation_invite_code: String, + pub msg: tbs::Message, + pub sig: tbs::Signature, +} + +#[derive(Deserialize, Serialize)] +pub struct RegisterResponse { + pub name: String, +} + +pub struct HermesClient { + pub(crate) primary_key: Keys, + pub public_key: nostr::PublicKey, + pub client: Client, + http_client: reqwest::Client, + pub(crate) federations: Arc>>>>, + blind_auth: BlindAuthClient, + base_url: String, + storage: S, + pub logger: Arc, + pub stop: Arc, +} + +impl HermesClient { + pub async fn new( + xprivkey: ExtendedPrivKey, + base_url: String, + federations: Arc>>>>, + blind_auth: BlindAuthClient, + storage: &S, + logger: Arc, + stop: Arc, + ) -> Result { + let keys = derive_nostr_key( + &Secp256k1::new(), + xprivkey, + SERVICE_ACCOUNT_INDEX, + Some(HERMES_CHAIN_INDEX), + None, + )?; + let public_key = keys.public_key(); + let client = Client::new(&keys); + + let relays: Vec = vec![ + "wss://relay.primal.net".to_string(), + "wss://relay.damus.io".to_string(), + "wss://nostr.mutinywallet.com".to_string(), + "wss://relay.mutinywallet.com".to_string(), + ]; + client + .add_relays(relays) + .await + .expect("Failed to add relays"); + + // TODO need to store the fact that we have a LNURL or not... + + Ok(Self { + primary_key: keys, + public_key, + client, + http_client: reqwest::Client::new(), + base_url, + federations, + blind_auth, + storage: storage.clone(), + logger, + stop, + }) + } + + pub fn start(&self) -> Result<(), MutinyError> { + let logger = self.logger.clone(); + let stop = self.stop.clone(); + let client = self.client.clone(); + let public_key = self.public_key.clone(); + let storage = self.storage.clone(); + let primary_key = self.primary_key.clone(); + + // if we haven't synced before, use now and save to storage + // TODO FIXME this won't be very correct + // I guess make a new dm sync time? + let last_sync_time = storage.get_dm_sync_time()?; + let time_stamp = match last_sync_time { + None => { + let now = Timestamp::now(); + storage.set_dm_sync_time(now.as_u64())?; + now + } + Some(time) => Timestamp::from(time + 1), // add one so we get only new events + }; + + utils::spawn(async move { + loop { + if stop.load(Ordering::Relaxed) { + break; + }; + + let received_dm_filter = Filter::new() + .kind(Kind::EncryptedDirectMessage) + .pubkey(public_key) + .since(time_stamp); + + client.connect().await; + + client.subscribe(vec![received_dm_filter]).await; + + let mut notifications = client.notifications(); + + loop { + let read_fut = notifications.recv().fuse(); + let delay_fut = Box::pin(utils::sleep(1_000)).fuse(); + + pin_mut!(read_fut, delay_fut); + select! { + notification = read_fut => { + match notification { + Ok(RelayPoolNotification::Event { event, .. }) => { + if event.verify().is_ok() { + match event.kind { + Kind::EncryptedDirectMessage => { + match decrypt_dm(primary_key.clone(), public_key, &event.content).await { + Ok(_) => { + // TODO we need to parse and redeem ecash + }, + Err(e) => { + log_error!(logger, "Error decrypting DM: {e}"); + } + } + } + kind => log_warn!(logger, "Received unexpected note of kind {kind}") + } + } + }, + Ok(RelayPoolNotification::Message { .. }) => {}, // ignore messages + Ok(RelayPoolNotification::Shutdown) => break, // if we disconnect, we restart to reconnect + Ok(RelayPoolNotification::Stop) => {}, // Currently unused + Ok(RelayPoolNotification::RelayStatus { .. }) => {}, // Currently unused + Err(_) => break, // if we are erroring we should reconnect + } + } + _ = delay_fut => { + if stop.load(Ordering::Relaxed) { + break; + } + } + } + } + + if let Err(e) = client.disconnect().await { + log_warn!(logger, "Error disconnecting from relays: {e}"); + } + } + }); + + Ok(()) + } + + pub async fn check_available_name(&self, name: String) -> Result { + check_name_request(&self.http_client, &self.base_url, name).await + } + + pub async fn reserve_name(&self, name: String) -> Result<(), MutinyError> { + // check that we have a name token available + let available_tokens = self.blind_auth.available_tokens().await; + let available_paid_token = + match find_hermes_token(&available_tokens, HERMES_SERVICE_ID, HERMES_PAID_PLAN_ID) { + Some(t) => t, + None => return Err(MutinyError::NotFound), + }; + + // check that we have a federation added and get it's id/invite code + let federation_identity = match self.get_first_federation().await { + Some(f) => f, + None => return Err(MutinyError::FederationRequired), + }; + + // do the unblinding + let (nonce, blinding_key) = self + .blind_auth + .get_unblinded_info_from_token(available_paid_token); + let unblinded_sig = unblind_signature(blinding_key, available_paid_token.blind_sig); + + // send the register request + let req = RegisterRequest { + name: Some(name), + pubkey: self.public_key.to_string(), + federation_id: federation_identity.federation_id, + federation_invite_code: federation_identity.invite_code.to_string(), + msg: nonce.to_message(), + sig: unblinded_sig, + }; + register_name(&self.http_client.clone(), &self.base_url, req).await?; + + Ok(()) + } + + pub async fn get_first_federation(&self) -> Option { + let federations = self.federations.read().await; + match federations.iter().next() { + Some((_, n)) => Some(n.get_mutiny_federation_identity().await), + None => None, + } + } + + // TODO need a way to change the federation if the user's federation changes +} + +fn find_hermes_token( + tokens: &Vec, + service_id: u32, + plan_id: u32, +) -> Option<&SignedToken> { + tokens + .iter() + .find(|token| token.service_id == service_id && token.plan_id == plan_id) +} + +async fn check_name_request( + http_client: &reqwest::Client, + base_url: &str, + name: String, +) -> Result { + let url = Url::parse(&format!("{}/v1/check-username/{name}", base_url)) + .map_err(|_| MutinyError::ConnectionFailed)?; + let request = http_client.request(Method::GET, url); + + let res = utils::fetch_with_timeout(http_client, request.build().expect("should build req")) + .await? + .json::() + .await + .map_err(|_| MutinyError::ConnectionFailed)?; + + Ok(res) +} + +async fn register_name( + http_client: &reqwest::Client, + base_url: &str, + req: RegisterRequest, +) -> Result { + let url = Url::parse(&format!("{}/v1/register", base_url)) + .map_err(|_| MutinyError::ConnectionFailed)?; + let request = http_client.request(Method::POST, url).json(&req); + + let res = utils::fetch_with_timeout(http_client, request.build().expect("should build req")) + .await? + .json::() + .await + .map_err(|_| MutinyError::ConnectionFailed)?; + + Ok(res) +} + +/// Decrypts a DM using the primary key +pub async fn decrypt_dm( + primary_key: Keys, + pubkey: nostr::PublicKey, + message: &str, +) -> Result { + let secret = primary_key.secret_key().expect("must have"); + let decrypted = decrypt(secret, &pubkey, message)?; + Ok(decrypted) +} diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index efc498bbe..6dca48f89 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod event; pub mod federation; mod fees; mod gossip; +mod hermes; mod key; mod keymanager; pub mod labels; diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 7552bb3de..c0b6a9696 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -37,6 +37,9 @@ mod primal; const PROFILE_ACCOUNT_INDEX: u32 = 0; const NWC_ACCOUNT_INDEX: u32 = 1; +pub(crate) const SERVICE_ACCOUNT_INDEX: u32 = 2; + +pub(crate) const HERMES_CHAIN_INDEX: u32 = 0; const USER_NWC_PROFILE_START_INDEX: u32 = 1000; @@ -1179,14 +1182,14 @@ impl NostrManager { xprivkey: ExtendedPrivKey, profile_index: u32, ) -> Result<(Keys, Keys), MutinyError> { - let client_key = Self::derive_nostr_key( + let client_key = derive_nostr_key( context, xprivkey, NWC_ACCOUNT_INDEX, Some(profile_index), Some(0), )?; - let server_key = Self::derive_nostr_key( + let server_key = derive_nostr_key( context, xprivkey, NWC_ACCOUNT_INDEX, @@ -1197,31 +1200,6 @@ impl NostrManager { Ok((client_key, server_key)) } - fn derive_nostr_key( - context: &Secp256k1, - xprivkey: ExtendedPrivKey, - account: u32, - chain: Option, - index: Option, - ) -> Result { - let chain = match chain { - Some(chain) => ChildNumber::from_hardened_idx(chain)?, - None => ChildNumber::from_normal_idx(0)?, - }; - - let index = match index { - Some(index) => ChildNumber::from_hardened_idx(index)?, - None => ChildNumber::from_normal_idx(0)?, - }; - - let path = DerivationPath::from_str(&format!("m/44'/1237'/{account}'/{chain}/{index}"))?; - let key = xprivkey.derive_priv(context, &path)?; - - // just converting to nostr secret key, unwrap is safe - let secret_key = SecretKey::from_slice(&key.private_key.secret_bytes()).unwrap(); - Ok(Keys::new(secret_key)) - } - /// Creates a new NostrManager pub fn from_mnemonic( xprivkey: ExtendedPrivKey, @@ -1236,8 +1214,7 @@ impl NostrManager { // use provided nsec, otherwise generate it from seed let (primary_key, public_key) = match key_source { NostrKeySource::Derived => { - let keys = - Self::derive_nostr_key(&context, xprivkey, PROFILE_ACCOUNT_INDEX, None, None)?; + let keys = derive_nostr_key(&context, xprivkey, PROFILE_ACCOUNT_INDEX, None, None)?; let public_key = keys.public_key(); let signer = NostrSigner::Keys(keys); (signer, public_key) @@ -1285,6 +1262,31 @@ impl NostrManager { } } +pub fn derive_nostr_key( + context: &Secp256k1, + xprivkey: ExtendedPrivKey, + account: u32, + chain: Option, + index: Option, +) -> Result { + let chain = match chain { + Some(chain) => ChildNumber::from_hardened_idx(chain)?, + None => ChildNumber::from_normal_idx(0)?, + }; + + let index = match index { + Some(index) => ChildNumber::from_hardened_idx(index)?, + None => ChildNumber::from_normal_idx(0)?, + }; + + let path = DerivationPath::from_str(&format!("m/44'/1237'/{account}'/{chain}/{index}"))?; + let key = xprivkey.derive_priv(context, &path)?; + + // just converting to nostr secret key, unwrap is safe + let secret_key = SecretKey::from_slice(&key.private_key.secret_bytes()).unwrap(); + Ok(Keys::new(secret_key)) +} + fn get_next_nwc_index( profile_type: ProfileType, profiles: &[NostrWalletConnect], diff --git a/mutiny-wasm/src/error.rs b/mutiny-wasm/src/error.rs index 8f7955af6..e786f1a0d 100644 --- a/mutiny-wasm/src/error.rs +++ b/mutiny-wasm/src/error.rs @@ -168,6 +168,9 @@ pub enum MutinyJsError { /// Token already spent. #[error("Token has been already spent.")] TokenAlreadySpent, + /// Federation required. + #[error("A federation is required")] + FederationRequired, /// Unknown error. #[error("Unknown Error")] UnknownError, @@ -220,6 +223,7 @@ impl From for MutinyJsError { MutinyError::CashuMintError => MutinyJsError::CashuMintError, MutinyError::EmptyMintURLError => MutinyJsError::EmptyMintURLError, MutinyError::TokenAlreadySpent => MutinyJsError::TokenAlreadySpent, + MutinyError::FederationRequired => MutinyJsError::FederationRequired, MutinyError::Other(e) => { error!("Got unhandled error: {e}"); // FIXME: For some unknown reason, InsufficientBalance is being returned as `Other`