upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/nip46.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/nip46.rs')
-rw-r--r--src/nip46.rs397
1 files changed, 397 insertions, 0 deletions
diff --git a/src/nip46.rs b/src/nip46.rs
new file mode 100644
index 0000000..bedc814
--- /dev/null
+++ b/src/nip46.rs
@@ -0,0 +1,397 @@
1use crate::db::MirrorDb;
2use anyhow::{Context, Result};
3use nostr::nips::nip04;
4use nostr::nips::nip46::{Message, NostrConnectURI, NostrConnectMetadata, Request, ResponseResult};
5use nostr_sdk::prelude::*;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::{oneshot, RwLock};
10
11struct Session {
12 npub: PublicKey,
13 client_keys: Keys,
14 signer_pubkey: Option<PublicKey>,
15 connected: bool,
16 pairing_uri: String,
17}
18
19struct PendingRequest {
20 tx: oneshot::Sender<Result<ResponseResult>>,
21 created_at: std::time::Instant,
22}
23
24pub struct Nip46Client {
25 sessions: Arc<RwLock<HashMap<String, Session>>>,
26 pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
27 client: nostr_sdk::Client,
28 relays: Vec<String>,
29 signing_timeout: Duration,
30 db: Arc<MirrorDb>,
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct Nip46Status {
35 pub npub: String,
36 pub connected: bool,
37 pub pairing_uri: Option<String>,
38}
39
40impl Nip46Client {
41 pub async fn new(
42 relays: Vec<String>,
43 signing_timeout_secs: u64,
44 db: Arc<MirrorDb>,
45 ) -> Result<Self> {
46 let client = nostr_sdk::Client::default();
47 for url in &relays {
48 let _ = client.add_relay(url).await;
49 }
50 client.connect().await;
51
52 let sessions = Arc::new(RwLock::new(HashMap::new()));
53 let pending = Arc::new(RwLock::new(HashMap::new()));
54
55 Ok(Self {
56 sessions,
57 pending,
58 client,
59 relays,
60 signing_timeout: Duration::from_secs(signing_timeout_secs),
61 db,
62 })
63 }
64
65 pub async fn init_sessions(&self, npubs: &[PublicKey]) -> Result<()> {
66 let records = self.db.get_all_nip46_sessions().await?;
67 let existing: HashMap<String, _> = records
68 .into_iter()
69 .map(|r| (r.npub, r))
70 .collect();
71
72 for pk in npubs {
73 let pk_hex = pk.to_hex();
74 let session = if let Some(rec) = existing.get(&pk_hex) {
75 let client_keys =
76 Keys::parse(&rec.client_secret).context("invalid stored client secret")?;
77 let signer_pk = rec
78 .signer_pubkey
79 .as_ref()
80 .map(|s| PublicKey::from_hex(s))
81 .transpose()
82 .context("invalid stored signer pubkey")?;
83
84 let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays);
85
86 Session {
87 npub: *pk,
88 client_keys,
89 signer_pubkey: signer_pk,
90 connected: rec.connected && signer_pk.is_some(),
91 pairing_uri,
92 }
93 } else {
94 let client_keys = Keys::generate();
95 let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays);
96
97 self.db
98 .upsert_nip46_session(&pk_hex, &client_keys.secret_key()?.to_hex(), None, false)
99 .await?;
100
101 Session {
102 npub: *pk,
103 client_keys,
104 signer_pubkey: None,
105 connected: false,
106 pairing_uri,
107 }
108 };
109
110 tracing::info!(
111 npub = %pk_hex,
112 connected = session.connected,
113 has_signer = session.signer_pubkey.is_some(),
114 "NIP-46 session initialized"
115 );
116
117 self.sessions.write().await.insert(pk_hex, session);
118 }
119
120 Ok(())
121 }
122
123 pub async fn start_listener(&self) {
124 let sessions = self.sessions.read().await;
125 let client_pubkeys: Vec<PublicKey> = sessions
126 .values()
127 .map(|s| s.client_keys.public_key())
128 .collect();
129 drop(sessions);
130
131 if client_pubkeys.is_empty() {
132 tracing::warn!("no NIP-46 sessions to listen for");
133 return;
134 }
135
136 let filter = Filter::new()
137 .kind(Kind::Custom(24133))
138 .pubkeys(client_pubkeys);
139
140 let _ = self
141 .client
142 .subscribe(filter, None)
143 .await
144 .map_err(|e| tracing::error!(error = %e, "failed to subscribe for NIP-46 responses"));
145
146 let sessions = self.sessions.clone();
147 let pending = self.pending.clone();
148 let db = self.db.clone();
149 let client = self.client.clone();
150
151 tokio::spawn(async move {
152 let _ = client
153 .handle_notifications(|notification| {
154 let sessions = sessions.clone();
155 let pending = pending.clone();
156 let db = db.clone();
157
158 async move {
159 if let RelayPoolNotification::Event { event, .. } = notification.as_ref() {
160 if event.kind == Kind::Custom(24133) {
161 let _ =
162 Self::handle_response(&sessions, &pending, &db, event).await;
163 }
164 }
165 Ok(false)
166 }
167 })
168 .await
169 .map_err(|e| tracing::error!(error = %e, "NIP-46 listener exited"));
170 });
171
172 tracing::info!("NIP-46 response listener started");
173 }
174
175 async fn handle_response(
176 sessions: &Arc<RwLock<HashMap<String, Session>>>,
177 pending: &Arc<RwLock<HashMap<String, PendingRequest>>>,
178 db: &Arc<MirrorDb>,
179 event: &Event,
180 ) -> Result<()> {
181 let sessions_guard = sessions.read().await;
182
183 let session = sessions_guard
184 .values()
185 .find(|s| s.client_keys.public_key() == event.pubkey);
186
187 let session = match session {
188 Some(s) => s,
189 None => return Ok(()),
190 };
191
192 let decrypted = nip04::decrypt(
193 session.client_keys.secret_key()?,
194 &event.pubkey,
195 &event.content,
196 )
197 .context("failed to decrypt NIP-46 response")?;
198
199 let message: Message = Message::from_json(&decrypted)
200 .context("failed to parse NIP-46 message")?;
201
202 match &message {
203 Message::Response { id, result, error } => {
204 if let Some(err) = error {
205 tracing::error!(id = %id, error = %err, "NIP-46 response error");
206 let mut pending_guard = pending.write().await;
207 if let Some(p) = pending_guard.remove(id) {
208 let _ = p.tx.send(Err(anyhow::anyhow!("signer error: {}", err)));
209 }
210 return Ok(());
211 }
212
213 if let Some(result) = result {
214 match result {
215 ResponseResult::GetPublicKey(pk) => {
216 let pk_hex = session.npub.to_hex();
217 let signer_hex = pk.to_hex();
218
219 tracing::info!(
220 npub = %pk_hex,
221 signer = %signer_hex,
222 "NIP-46 session connected — learned signer pubkey"
223 );
224
225 drop(sessions_guard);
226 let mut sessions_guard = sessions.write().await;
227 if let Some(s) = sessions_guard.get_mut(&pk_hex) {
228 s.signer_pubkey = Some(*pk);
229 s.connected = true;
230 }
231
232 let _ = db
233 .upsert_nip46_session(
234 &pk_hex,
235 &session.client_keys.secret_key()?.to_hex(),
236 Some(&signer_hex),
237 true,
238 )
239 .await;
240 }
241 _ => {
242 let mut pending_guard = pending.write().await;
243 if let Some(p) = pending_guard.remove(id) {
244 let _ = p.tx.send(Ok(result.clone()));
245 }
246 }
247 }
248 }
249 }
250 Message::Request { id, req } => {
251 tracing::debug!(id = %id, method = %req.method(), "received NIP-46 request from signer");
252 }
253 }
254
255 Ok(())
256 }
257
258 pub async fn connect_session(&self, npub: &PublicKey) -> Result<()> {
259 let sessions = self.sessions.read().await;
260 let pk_hex = npub.to_hex();
261 let session = sessions
262 .get(&pk_hex)
263 .context("no NIP-46 session for npub")?;
264
265 let connect_req = Request::Connect {
266 public_key: *npub,
267 secret: None,
268 };
269
270 self.send_request_to_signer(&session, connect_req).await?;
271
272 tracing::info!(npub = %pk_hex, "sent connect request to signer");
273 Ok(())
274 }
275
276 pub async fn sign_event(&self, npub: &PublicKey, unsigned: &UnsignedEvent) -> Result<Event> {
277 let sessions = self.sessions.read().await;
278 let pk_hex = npub.to_hex();
279 let session = sessions
280 .get(&pk_hex)
281 .context("no NIP-46 session for npub")?;
282
283 if !session.connected || session.signer_pubkey.is_none() {
284 anyhow::bail!("NIP-46 session not connected — pair with signer first");
285 }
286
287 let result = self
288 .send_request_to_signer(&session, Request::SignEvent(unsigned.clone()))
289 .await?;
290
291 match result {
292 ResponseResult::SignEvent(signed) => Ok(*signed),
293 other => anyhow::bail!("unexpected NIP-46 response: {:?}", other),
294 }
295 }
296
297 async fn send_request_to_signer(
298 &self,
299 session: &Session,
300 request: Request,
301 ) -> Result<ResponseResult> {
302 let signer_pubkey = session
303 .signer_pubkey
304 .context("no signer pubkey — not paired")?;
305
306 let message = Message::request(request);
307 let message_json = message.as_json();
308
309 let encrypted =
310 nip04::encrypt(session.client_keys.secret_key()?, &signer_pubkey, &message_json)?;
311
312 let event_builder = EventBuilder::new(
313 Kind::Custom(24133),
314 &encrypted,
315 [Tag::public_key(signer_pubkey)],
316 );
317 let event = event_builder.sign_with_keys(&session.client_keys)?;
318
319 let request_id = message.id().to_string();
320
321 let (tx, rx) = oneshot::channel();
322 {
323 let mut pending = self.pending.write().await;
324 pending.insert(
325 request_id.clone(),
326 PendingRequest {
327 tx,
328 created_at: std::time::Instant::now(),
329 },
330 );
331 }
332
333 self.client.send_event(event).await?;
334
335 let timeout = self.signing_timeout;
336 let result = tokio::time::timeout(timeout, async {
337 match rx.await {
338 Ok(Ok(response)) => Ok(response),
339 Ok(Err(e)) => Err(e),
340 Err(_) => Err(anyhow::anyhow!("signing request channel dropped")),
341 }
342 })
343 .await
344 .map_err(|_| {
345 {
346 let pending_id = request_id.clone();
347 async move {
348 let mut pending = self.pending.write().await;
349 pending.remove(&pending_id);
350 }
351 }
352 anyhow::anyhow!(
353 "NIP-46 signing request timed out after {}s",
354 timeout.as_secs()
355 )
356 })??;
357
358 Ok(result)
359 }
360
361 pub async fn get_status(&self) -> Vec<Nip46Status> {
362 let sessions = self.sessions.read().await;
363 sessions
364 .values()
365 .map(|s| Nip46Status {
366 npub: s.npub.to_hex(),
367 connected: s.connected,
368 pairing_uri: if !s.connected {
369 Some(s.pairing_uri.clone())
370 } else {
371 None
372 },
373 })
374 .collect()
375 }
376
377 pub async fn get_pairing_uri(&self, npub: &PublicKey) -> Option<String> {
378 let sessions = self.sessions.read().await;
379 sessions
380 .get(&npub.to_hex())
381 .map(|s| s.pairing_uri.clone())
382 }
383
384 fn build_pairing_uri(client_keys: &Keys, relays: &[String]) -> String {
385 let relay_urls: Vec<RelayUrl> = relays
386 .iter()
387 .filter_map(|r| RelayUrl::parse(r).ok())
388 .collect();
389
390 let uri = NostrConnectURI::client(
391 client_keys.public_key(),
392 relay_urls,
393 "grasp-mirror",
394 );
395 uri.to_string()
396 }
397}