diff options
Diffstat (limited to 'src/nip46.rs')
| -rw-r--r-- | src/nip46.rs | 397 |
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 @@ | |||
| 1 | use crate::db::MirrorDb; | ||
| 2 | use anyhow::{Context, Result}; | ||
| 3 | use nostr::nips::nip04; | ||
| 4 | use nostr::nips::nip46::{Message, NostrConnectURI, NostrConnectMetadata, Request, ResponseResult}; | ||
| 5 | use nostr_sdk::prelude::*; | ||
| 6 | use std::collections::HashMap; | ||
| 7 | use std::sync::Arc; | ||
| 8 | use std::time::Duration; | ||
| 9 | use tokio::sync::{oneshot, RwLock}; | ||
| 10 | |||
| 11 | struct Session { | ||
| 12 | npub: PublicKey, | ||
| 13 | client_keys: Keys, | ||
| 14 | signer_pubkey: Option<PublicKey>, | ||
| 15 | connected: bool, | ||
| 16 | pairing_uri: String, | ||
| 17 | } | ||
| 18 | |||
| 19 | struct PendingRequest { | ||
| 20 | tx: oneshot::Sender<Result<ResponseResult>>, | ||
| 21 | created_at: std::time::Instant, | ||
| 22 | } | ||
| 23 | |||
| 24 | pub 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)] | ||
| 34 | pub struct Nip46Status { | ||
| 35 | pub npub: String, | ||
| 36 | pub connected: bool, | ||
| 37 | pub pairing_uri: Option<String>, | ||
| 38 | } | ||
| 39 | |||
| 40 | impl 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 | } | ||