Skip to content
This repository was archived by the owner on Feb 3, 2025. It is now read-only.

Commit eb32dd8

Browse files
Hermes client
1 parent e921910 commit eb32dd8

File tree

6 files changed

+377
-39
lines changed

6 files changed

+377
-39
lines changed

mutiny-core/src/blindauth.rs

+28-10
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,13 @@ impl<S: MutinyStorage> BlindAuthClient<S> {
259259

260260
Ok(())
261261
}
262+
263+
pub fn get_unblinded_info_from_token(
264+
&self,
265+
token: SignedToken,
266+
) -> (fedimint_mint_client::Nonce, BlindingKey) {
267+
generate_nonce(&self.secret, token.service_id, token.plan_id, token.counter)
268+
}
262269
}
263270

264271
async fn get_available_tokens(
@@ -301,6 +308,25 @@ async fn derive_blind_token(
301308
plan_id: u32,
302309
counter: u32,
303310
) -> Result<UnsignedToken, MutinyError> {
311+
let (nonce, blinding_key) = generate_nonce(secret, service_id, plan_id, counter);
312+
let blinded_message = blind_message(nonce.to_message(), blinding_key);
313+
314+
let signed_token = UnsignedToken {
315+
counter,
316+
service_id,
317+
plan_id,
318+
blinded_message,
319+
};
320+
321+
Ok(signed_token)
322+
}
323+
324+
fn generate_nonce(
325+
secret: &DerivableSecret,
326+
service_id: u32,
327+
plan_id: u32,
328+
counter: u32,
329+
) -> (fedimint_mint_client::Nonce, BlindingKey) {
304330
let child_secret = secret
305331
.child_key(SERVICE_REGISTRATION_CHILD_ID)
306332
.child_key(ChildId(service_id.into()))
@@ -312,21 +338,13 @@ async fn derive_blind_token(
312338
.to_secp_key(fedimint_ln_common::bitcoin::secp256k1::SECP256K1);
313339

314340
let nonce = fedimint_mint_client::Nonce(spend_key.public_key());
341+
315342
let blinding_key = BlindingKey(
316343
child_secret
317344
.child_key(BLINDING_KEY_CHILD_ID)
318345
.to_bls12_381_key(),
319346
);
320-
let blinded_message = blind_message(nonce.to_message(), blinding_key);
321-
322-
let signed_token = UnsignedToken {
323-
counter,
324-
service_id,
325-
plan_id,
326-
blinded_message,
327-
};
328-
329-
Ok(signed_token)
347+
(nonce, blinding_key)
330348
}
331349

332350
// Creates the root derivation secret for the blind auth client:

mutiny-core/src/error.rs

+3
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ pub enum MutinyError {
171171
/// Token already spent.
172172
#[error("Token has been already spent.")]
173173
TokenAlreadySpent,
174+
/// Federation required.
175+
#[error("A federation is required")]
176+
FederationRequired,
174177
#[error(transparent)]
175178
Other(#[from] anyhow::Error),
176179
}

mutiny-core/src/hermes.rs

+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use std::{
2+
collections::HashMap,
3+
sync::{
4+
atomic::{AtomicBool, Ordering},
5+
Arc,
6+
},
7+
};
8+
9+
use async_lock::RwLock;
10+
use bitcoin::{bip32::ExtendedPrivKey, secp256k1::Secp256k1};
11+
use fedimint_core::config::FederationId;
12+
use futures::{pin_mut, select, FutureExt};
13+
use lightning::util::logger::Logger;
14+
use lightning::{log_error, log_warn};
15+
use nostr::nips::nip04::decrypt;
16+
use nostr::{Filter, Kind, Timestamp};
17+
use nostr_sdk::{Client, NostrSigner, RelayPoolNotification};
18+
use reqwest::Method;
19+
use serde::{Deserialize, Serialize};
20+
use tbs::unblind_signature;
21+
use url::Url;
22+
23+
use crate::{
24+
blindauth::{BlindAuthClient, SignedToken},
25+
error::MutinyError,
26+
federation::{FederationClient, FederationIdentity},
27+
logging::MutinyLogger,
28+
nostr::{derive_nostr_key, HERMES_CHAIN_INDEX, SERVICE_ACCOUNT_INDEX},
29+
storage::MutinyStorage,
30+
utils,
31+
};
32+
33+
const HERMES_SERVICE_ID: u32 = 1;
34+
const HERMES_FREE_PLAN_ID: u32 = 1;
35+
const HERMES_PAID_PLAN_ID: u32 = 2;
36+
37+
#[derive(Deserialize, Serialize)]
38+
pub struct RegisterRequest {
39+
pub name: Option<String>,
40+
pub pubkey: String,
41+
pub federation_id: FederationId,
42+
pub federation_invite_code: String,
43+
pub msg: tbs::Message,
44+
pub sig: tbs::Signature,
45+
}
46+
47+
#[derive(Deserialize, Serialize)]
48+
pub struct RegisterResponse {
49+
pub name: String,
50+
}
51+
52+
pub struct HermesClient<S: MutinyStorage> {
53+
pub(crate) primary_key: NostrSigner,
54+
pub public_key: nostr::PublicKey,
55+
pub client: Client,
56+
http_client: reqwest::Client,
57+
pub(crate) federations: Arc<RwLock<HashMap<FederationId, Arc<FederationClient<S>>>>>,
58+
blind_auth: BlindAuthClient<S>,
59+
base_url: String,
60+
storage: S,
61+
pub logger: Arc<MutinyLogger>,
62+
pub stop: Arc<AtomicBool>,
63+
}
64+
65+
impl<S: MutinyStorage> HermesClient<S> {
66+
pub async fn new(
67+
xprivkey: ExtendedPrivKey,
68+
base_url: String,
69+
federations: Arc<RwLock<HashMap<FederationId, Arc<FederationClient<S>>>>>,
70+
blind_auth: BlindAuthClient<S>,
71+
storage: &S,
72+
logger: Arc<MutinyLogger>,
73+
stop: Arc<AtomicBool>,
74+
) -> Result<Self, MutinyError> {
75+
let keys = derive_nostr_key(
76+
&Secp256k1::new(),
77+
xprivkey,
78+
SERVICE_ACCOUNT_INDEX,
79+
Some(HERMES_CHAIN_INDEX),
80+
None,
81+
)?;
82+
let public_key = keys.public_key();
83+
let signer = NostrSigner::Keys(keys);
84+
let client = Client::new(signer.clone());
85+
86+
let relays: Vec<String> = vec!["".to_string()];
87+
client
88+
.add_relays(relays.clone())
89+
.await
90+
.expect("Failed to add relays");
91+
92+
// TODO need to store the fact that we have a LNURL or not...
93+
94+
Ok(Self {
95+
primary_key: signer,
96+
public_key,
97+
client,
98+
http_client: reqwest::Client::new(),
99+
base_url,
100+
federations,
101+
blind_auth,
102+
storage: storage.clone(),
103+
logger,
104+
stop,
105+
})
106+
}
107+
108+
pub fn start(&self) -> Result<(), MutinyError> {
109+
let logger = self.logger.clone();
110+
let stop = self.stop.clone();
111+
let client = self.client.clone();
112+
let public_key = self.public_key.clone();
113+
let storage = self.storage.clone();
114+
let primary_key = self.primary_key.clone();
115+
116+
// if we haven't synced before, use now and save to storage
117+
// TODO FIXME this won't be very correct
118+
// I guess make a new dm sync time?
119+
let last_sync_time = storage.get_dm_sync_time()?;
120+
let time_stamp = match last_sync_time {
121+
None => {
122+
let now = Timestamp::now();
123+
storage.set_dm_sync_time(now.as_u64())?;
124+
now
125+
}
126+
Some(time) => Timestamp::from(time + 1), // add one so we get only new events
127+
};
128+
129+
utils::spawn(async move {
130+
loop {
131+
if stop.load(Ordering::Relaxed) {
132+
break;
133+
};
134+
135+
let received_dm_filter = Filter::new()
136+
.kind(Kind::EncryptedDirectMessage)
137+
.pubkey(public_key)
138+
.since(time_stamp);
139+
140+
client.connect().await;
141+
142+
client.subscribe(vec![received_dm_filter]).await;
143+
144+
let mut notifications = client.notifications();
145+
146+
loop {
147+
let read_fut = notifications.recv().fuse();
148+
let delay_fut = Box::pin(utils::sleep(1_000)).fuse();
149+
150+
pin_mut!(read_fut, delay_fut);
151+
select! {
152+
notification = read_fut => {
153+
match notification {
154+
Ok(RelayPoolNotification::Event { event, .. }) => {
155+
if event.verify().is_ok() {
156+
match event.kind {
157+
Kind::EncryptedDirectMessage => {
158+
match decrypt_dm(primary_key.clone(), public_key, &event.content).await {
159+
Ok(_) => {
160+
// TODO we need to parse and redeem ecash
161+
},
162+
Err(e) => {
163+
log_error!(logger, "Error decrypting DM: {e}");
164+
}
165+
}
166+
}
167+
kind => log_warn!(logger, "Received unexpected note of kind {kind}")
168+
}
169+
}
170+
},
171+
Ok(RelayPoolNotification::Message { .. }) => {}, // ignore messages
172+
Ok(RelayPoolNotification::Shutdown) => break, // if we disconnect, we restart to reconnect
173+
Ok(RelayPoolNotification::Stop) => {}, // Currently unused
174+
Ok(RelayPoolNotification::RelayStatus { .. }) => {}, // Currently unused
175+
Err(_) => break, // if we are erroring we should reconnect
176+
}
177+
}
178+
_ = delay_fut => {
179+
if stop.load(Ordering::Relaxed) {
180+
break;
181+
}
182+
}
183+
}
184+
}
185+
186+
if let Err(e) = client.disconnect().await {
187+
log_warn!(logger, "Error disconnecting from relays: {e}");
188+
}
189+
}
190+
});
191+
192+
Ok(())
193+
}
194+
195+
pub async fn check_available_name(&self, name: String) -> Result<bool, MutinyError> {
196+
check_name_request(self.http_client.clone(), &self.base_url, name).await
197+
}
198+
199+
pub async fn reserve_name(&self, name: String) -> Result<(), MutinyError> {
200+
// check that we have a name token available
201+
let available_tokens = self.blind_auth.available_tokens().await;
202+
let available_paid_token =
203+
match find_hermes_token(&available_tokens, HERMES_SERVICE_ID, HERMES_PAID_PLAN_ID) {
204+
Some(t) => t,
205+
None => return Err(MutinyError::NotFound),
206+
};
207+
208+
// check that we have a federation added and get it's id/invite code
209+
let federation_identity = match self.get_first_federation().await {
210+
Some(f) => f,
211+
None => return Err(MutinyError::FederationRequired),
212+
};
213+
214+
// do the unblinding
215+
let (nonce, blinding_key) = self
216+
.blind_auth
217+
.get_unblinded_info_from_token(available_paid_token.clone());
218+
let unblinded_sig = unblind_signature(blinding_key, available_paid_token.blind_sig);
219+
220+
// send the register request
221+
let req = RegisterRequest {
222+
name: Some(name),
223+
pubkey: self.public_key.to_string(),
224+
federation_id: federation_identity.federation_id,
225+
federation_invite_code: federation_identity.invite_code.to_string(),
226+
msg: nonce.to_message(),
227+
sig: unblinded_sig,
228+
};
229+
register_name(self.http_client.clone(), &self.base_url, req).await?;
230+
231+
Ok(())
232+
}
233+
234+
pub async fn get_first_federation(&self) -> Option<FederationIdentity> {
235+
let federations = self.federations.read().await;
236+
match federations.iter().next() {
237+
Some((_, n)) => Some(n.get_mutiny_federation_identity().await),
238+
None => None,
239+
}
240+
}
241+
242+
// TODO need a way to change the federation if the user's federation changes
243+
}
244+
245+
fn find_hermes_token(
246+
tokens: &Vec<SignedToken>,
247+
service_id: u32,
248+
plan_id: u32,
249+
) -> Option<&SignedToken> {
250+
tokens
251+
.iter()
252+
.find(|token| token.service_id == service_id && token.plan_id == plan_id)
253+
}
254+
255+
async fn check_name_request(
256+
http_client: reqwest::Client,
257+
base_url: &str,
258+
name: String,
259+
) -> Result<bool, MutinyError> {
260+
let url = Url::parse(&format!("{}/v1/check-username/{name}", base_url))
261+
.map_err(|_| MutinyError::ConnectionFailed)?;
262+
let request = http_client.request(Method::GET, url);
263+
264+
let res = utils::fetch_with_timeout(&http_client, request.build().expect("should build req"))
265+
.await?
266+
.json::<bool>()
267+
.await
268+
.map_err(|_| MutinyError::ConnectionFailed)?;
269+
270+
Ok(res)
271+
}
272+
273+
async fn register_name(
274+
http_client: reqwest::Client,
275+
base_url: &str,
276+
req: RegisterRequest,
277+
) -> Result<RegisterResponse, MutinyError> {
278+
let url = Url::parse(&format!("{}/v1/register", base_url))
279+
.map_err(|_| MutinyError::ConnectionFailed)?;
280+
let request = http_client.request(Method::POST, url).json(&req);
281+
282+
let res = utils::fetch_with_timeout(&http_client, request.build().expect("should build req"))
283+
.await?
284+
.json::<RegisterResponse>()
285+
.await
286+
.map_err(|_| MutinyError::ConnectionFailed)?;
287+
288+
Ok(res)
289+
}
290+
291+
/// Decrypts a DM using the primary key
292+
pub async fn decrypt_dm(
293+
primary_key: NostrSigner,
294+
pubkey: nostr::PublicKey,
295+
message: &str,
296+
) -> Result<String, MutinyError> {
297+
// todo we should handle NIP-44 as well
298+
match primary_key {
299+
NostrSigner::Keys(key) => {
300+
let secret = key.secret_key().expect("must have");
301+
let decrypted = decrypt(secret, &pubkey, message)?;
302+
Ok(decrypted)
303+
}
304+
#[cfg(target_arch = "wasm32")]
305+
NostrSigner::NIP07(_) => {
306+
// we do not use the nip07 signer for hermes keys
307+
unreachable!()
308+
}
309+
}
310+
}

mutiny-core/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod event;
1919
pub mod federation;
2020
mod fees;
2121
mod gossip;
22+
mod hermes;
2223
mod key;
2324
mod keymanager;
2425
pub mod labels;

0 commit comments

Comments
 (0)